Modify
Cuando la mutación a nivel de string no alcanza (“agrega un import a este módulo”, “asigna una
prop a este elemento JSX”), los dialectos proveen operaciones estructuradas y conscientes
del AST para un tipo de archivo. Un dialecto nunca edita texto con buscar-y-reemplazar: parsea
el archivo a un AST, muta el árbol e imprime el resultado de vuelta. Cada tipo de archivo
recibe la librería de AST que mejor le calza. Los dialectos @pbuilder/sdk/typescript y
@pbuilder/sdk/react que cubre esta página están ambos construidos sobre el AST propio de
TypeScript (expuesto a través de ts-morph), porque TS y TSX comparten gramática. Los archivos
JSON y YAML tienen sus propios dialectos: ver Dialecto JSON y
Dialecto YAML, y los archivos CSS usan el
Dialecto CSS. El dialecto de React, el
Dialecto NestJS y el
Dialecto Angular suman ops de framework a las de TypeScript.
Cómo funciona un handle de dialecto
Sección titulada «Cómo funciona un handle de dialecto»El punto de entrada de un dialecto es su propio find(path): abre un handle coalescente y
awaitable. Cada op que encadenas muta el mismo AST vivo, y el handle se serializa a
exactamente una directiva estilo replaceContent cuando hace flush.
import * as ts from "@pbuilder/sdk/typescript";
await ts.find("src/index.ts") .addImport("readFileSync", "node:fs") .addFunction("hi", "(): void {}", { export: true });Una lectura (.read() sobre el handle, o cualquier lectura sobre cualquier ruta) drena primero
la directiva abierta; una cadena con una lectura a mitad de cadena produce exactamente dos
directivas acumulativas, y ninguna edición se pierde.
El handle es un thenable, la única desviación asíncrona respecto de los verbos, por lo
demás síncronos, del SDK. await no es requisito para la corrección: el límite del run drena
todos los handles pendientes antes de que el run haga flush, así que un await olvidado
igualmente confirma su edición, y una cadena que lanza igualmente hace aflorar su error
contenido en el límite del run en lugar de como un unhandled rejection. Haz await de la cadena tú
mismo cuando necesites secuenciar una lectura después de tu propia escritura, o para observar un
fallo localmente en tu propio try/catch.
Queries
Sección titulada «Queries»La mayoría de las ops escriben y devuelven el handle, así que se encadenan. Un dialecto también
puede declarar queries, que leen un valor. Una query termina la cadena: haz await y resuelve
al valor. Ve todas las ops encadenadas antes que ella en el mismo handle, no escribe nada, y las
ops anteriores se confirman igual. Una query que cambiaría el archivo rechaza y hace fallar el
run. getDeclaration del dialecto CSS es una query.
El dialecto de TypeScript
Sección titulada «El dialecto de TypeScript»import * as ts from "@pbuilder/sdk/typescript";
await ts.find("src/index.ts") .addImport("readFileSync", "node:fs") .addFunction("hi", "(): void {}", { export: true });| Op | Qué hace |
|---|---|
addImport(name, from, opts?) |
Agrega import { name } from "from", fusionándose con una cláusula existente del mismo módulo. Idempotente: llamarla dos veces nunca duplica el import. Con { position: "sorted" } ubica el import en orden en lugar de agregarlo al final. Ver Imports ordenados. |
addDefaultImport(name, from) |
Agrega import name from "from", fusionándose con una declaración de ese módulo que todavía no tiene default. Rechaza un segundo default con otro nombre. |
addNamespaceImport(name, from) |
Agrega import * as name from "from", fusionándose con una declaración que solo tiene default. Junto a imports nombrados abre su propia declaración. |
addTypeImport(name, from) |
Agrega import type { name } from "from" como sentencia propia, nunca fusionada con un import de valor. No hace nada si el módulo ya importa name, como valor o como tipo. |
addSideEffectImport(from) |
Agrega import "from". No hace nada si ese import simple ya existe. |
addReExport(name, from) |
Agrega export { name } from "from", fusionándose con un re-export existente de ese módulo. Rechaza un nombre que el archivo ya exporta. |
addExportAll(from, { as? }) |
Agrega export * from "from", o export * as ns from "from" con { as: "ns" }. |
removeImport(name, from) |
Elimina el binding nombrado; borra la sentencia completa cuando era el último. Idempotente sobre un binding ausente. |
removeExport(name, from) |
Elimina un re-export nombrado; borra la sentencia cuando era el último. Idempotente si no existe. |
removeExportAll(from, { as? }) |
Elimina export * from "from" (o la forma con as). Idempotente si no existe. |
sortImports() |
Ordena las declaraciones de import del archivo por módulo, y los nombres dentro de las llaves de cada una. Solo reordena. Es un no-op en un archivo ya ordenado. Ver Imports ordenados. |
addFunction(name, source, opts?) |
Agrega una función de nivel superior al final. source incluye las llaves ("(): void {}"). |
addVariable(name, initializer, opts?) |
Agrega una variable de nivel superior al final (kind por defecto es const). |
addClass(name, source, opts?) |
Agrega una clase de nivel superior al final. source excluye las llaves, y la op las agrega. |
addArrayElement(locator, element) |
Agrega element al array literal al que apunta locator, manteniendo el formato de la lista. No hace nada si el elemento ya está. Ver Arrays anidados. |
addClassProperty(className, name, initializer, opts?) |
Agrega name = initializer; después de la última propiedad de la única clase llamada className. No hace nada si la misma propiedad ya existe. |
.modify(fn) |
El escape universal: acceso directo a ts-morph sobre el AST del archivo para todo lo que las ops nombradas no cubren. |
Nota el contraste deliberado entre las dos convenciones de source:
// addFunction: source INCLUYE las llavesawait ts.find("src/index.ts").addFunction("hi", "(): void {}", { export: true });// -> export function hi(): void {}
// addClass: source EXCLUYE las llaves (la op las agrega)await ts.find("src/index.ts").addClass("Point", " x = 0;");// -> class Point {\n x = 0;\n}addVariable emite {export }{kind} {name} = {initializer};. kind acepta "const" (por
defecto), "let" o "var", y rechaza cualquier otro valor:
await ts.find("src/index.ts").addVariable("counter", "0", { export: true, kind: "let" });// -> export let counter = 0;Formas de import y archivos barrel
Sección titulada «Formas de import y archivos barrel»Cada forma de import tiene su propia op. Ejecutar una dos veces cambia el archivo una sola vez:
await ts .find("src/main.ts") .addDefaultImport("express", "express") .addTypeImport("Request", "express") .addSideEffectImport("reflect-metadata");// -> import express from "express";// -> import type { Request } from "express";// -> import "reflect-metadata";Las ops de re-export arman archivos barrel. Genera un archivo y después expórtalo desde
index.ts:
import { create } from "@pbuilder/sdk/commons";import * as ts from "@pbuilder/sdk/typescript";
create("src/user.service.ts", { template: "export class UserService {}", options: {} });await ts .find("src/index.ts") .addReExport("UserService", "./user.service") .addExportAll("./dto");// -> export { UserService } from "./user.service";// -> export * from "./dto";addReExport y addExportAll rechazan un nombre que el archivo ya exporta por cualquier vía
(otro re-export, un export * as o una declaración exportada), porque dos exports con el mismo
nombre son TypeScript inválido. addReExport y removeExport comparan por el nombre de
origen, así que export { name as Y } ya cuenta. removeExport nunca toca un export { x }
local ni un export *, y cada forma de export * solo se elimina a sí misma.
Validación de nombres. Cada op revisa name antes de tocar el archivo. Tiene que ser un
identificador JavaScript ASCII simple (^[A-Za-z_$][A-Za-z0-9_$]*$), no una palabra reservada
ni restringida en modo estricto (class, await, eval, arguments, …), y no puede ser
__proto__, constructor ni prototype. Un nombre rechazado deja el archivo sin cambios, y
removeImport lo rechaza aunque no hubiera nada que eliminar.
Reglas de colisión. Las ops add* fallan ruidosamente ante una colisión de nombre con una
declaración de valor o un binding de import existente, porque dos declaraciones de valor compartiendo
un nombre es TypeScript inválido. Un binding de import es cualquier forma de import: nombrado,
default (import X from "m") y de namespace (import * as X from "m"), incluidos los de solo
tipo. Un type/interface que comparte el nombre no colisiona
(TypeScript permite legalmente que un valor y un tipo compartan identificador).
Los strings de source/initializer se insertan tal cual, sin validarse ni
sanitizarse, así que el autor es dueño de su sintaxis, y cualquier cosa derivada de entrada no
confiable (opciones de schema, respuestas del CLI, datos de red) es responsabilidad del autor
sanitizarla.
Imports ordenados
Sección titulada «Imports ordenados»Por defecto, addImport agrega al final. Pasa { position: "sorted" } para mantener los
imports de un archivo en orden: el nombre va a su lugar alfabético dentro de las llaves, y una
declaración nueva va junto a sus hermanas de carpeta, antes de la primera que ordena después de
ella.
// import { BookOpen, Users } from "lucide-react";// import { authorHandlers } from "./domains/authors.mock";// import { config } from "./config";await ts.find("src/mocks/handlers.ts") .addImport("LayoutDashboard", "lucide-react", { position: "sorted" }) .addImport("bookHandlers", "./domains/books.mock", { position: "sorted" });// import { BookOpen, LayoutDashboard, Users } from "lucide-react";// import { authorHandlers } from "./domains/authors.mock";// import { bookHandlers } from "./domains/books.mock";// import { config } from "./config";sortImports() ordena el archivo completo: sus declaraciones de import por módulo, y los nombres
dentro de las llaves de cada declaración, con el mismo orden. Un formato de llaves en varias
líneas se conserva.
Ambas siguen las mismas reglas:
- El orden no distingue mayúsculas, y los specifiers de paquete (
react,node:fs,@/lib) van antes que los relativos. - Las hermanas de carpeta de un módulo son los imports existentes que comparten más segmentos
iniciales de ruta con
from. En un archivo sin imports, la declaración va donde la pondría la forma sin ordenar. - El
addImportordenado nunca mueve imports existentes, así que un archivo que estaba desordenado sigue desordenado alrededor de la línea nueva. Una declaración nueva va encima de un comentario pegado al import que la sigue. - Sin la opción,
addImportse comporta como antes. Cualquier otro valor depositionrechaza y deja el archivo sin cambios. - Los imports insertados respetan las comillas y los punto y coma del archivo, como toda inserción.
sortImportssolo reordena. Nunca combina dos declaraciones del mismo módulo, ni quita un import sin uso, ni reformatea uno, y en un archivo ya ordenado no escribe nada.- Las declaraciones se mueven solo dentro de una racha de imports consecutivos. Una línea en
blanco, un comentario, un import de efecto secundario (
import "./polyfills";, cuya posición cambia el comportamiento) o una declaración con un comentario pegado cortan la racha, y nada la cruza. Las llaves que contienen un comentario conservan su orden.
Arrays anidados: addArrayElement
Sección titulada «Arrays anidados: addArrayElement»La mayoría de los generadores crean un archivo y después lo registran en algún lado: un provider
en appConfig, un guard en una ruta, un módulo en @NgModule. addArrayElement(locator, element)
hace esa edición. El locator nombra una raíz y después un path desde esa raíz hasta el array:
| Raíz | Empieza en |
|---|---|
{ variable: "appConfig", path } |
El inicializador de esa variable de nivel superior |
{ call: "bootstrapApplication", argument: 1, path } |
Ese argumento de la única llamada a bootstrapApplication |
{ decorator: "NgModule", path } |
El argumento del único @NgModule(...) |
Cada segmento del path es una clave de objeto ("providers") o una coincidencia como
{ path: "admin" }, que selecciona el único elemento del array cuya propiedad path vale
"admin". No hay índices numéricos, porque un índice apunta al elemento equivocado en cuanto
alguien reordena el array. En cada paso la op mira a través de as, satisfies, aserciones
<T>, ! y paréntesis.
await ts .find("src/app/app.config.ts") .addImport("provideHttpClient", "@angular/common/http") .addArrayElement({ variable: "appConfig", path: ["providers"] }, { expression: "provideHttpClient()" });// antes: providers: [provideRouter(routes)],// después: providers: [provideRouter(routes), provideHttpClient()],Una coincidencia entra en una entrada de una lista. Acá la ruta admin todavía no tiene la clave
providers, así que la op la crea:
await ts .find("src/app/app.routes.ts") .addImport("AdminGuard", "./admin/admin.guard") .addArrayElement({ variable: "routes", path: [{ path: "admin" }, "providers"] }, "AdminGuard");// antesexport const routes: Routes = [ { path: "", component: HomeComponent }, { path: "admin", component: AdminComponent, },];
// despuésexport const routes: Routes = [ { path: "", component: HomeComponent }, { path: "admin", component: AdminComponent, providers: [AdminGuard], },];La op crea una clave que falta ({} pasa a ser { providers: [X] }) y el siguiente argumento
que falta de una llamada o un decorador (bootstrapApplication(App) pasa a ser
bootstrapApplication(App, { providers: [X] })). Rechaza, y deja el archivo sin cambios, cuando:
- la raíz no existe, o una coincidencia no selecciona nada;
- la raíz o una coincidencia seleccionan más de un nodo;
- el argumento está más allá del siguiente, como
argument: 2sobrebootstrapApplication(App); - la clave que falta podría venir ya de un spread (
{ ...base }), de una clave computada o de un getter o setter, porque una clave nueva podría ocultarla.
element es un identificador, o { expression: "..." } para código. La expresión tiene que
parsear como exactamente una expresión, así que no puede cerrar el array ni agregar una
sentencia. Fuera de eso la op la inserta tal cual, así que nunca la armes con entrada del
consumidor. Un elemento que ya está en el array no cambia nada: los elementos se comparan token
por token, y los espacios, los comentarios y las comas finales no cuentan. El elemento nuevo
sigue el formato de la lista. Una lista de una línea queda en una línea, una lista multilínea
recibe una línea nueva con la indentación de la última entrada, y una coma final se mantiene.
La op no agrega imports, así que combínala con addImport.
Propiedades de clase: addClassProperty
Sección titulada «Propiedades de clase: addClassProperty»addClassProperty(className, name, initializer, options?) escribe
{scope }{readonly }name = initializer; en una clase existente. options es
{ scope?: "public" | "protected" | "private"; readonly?: boolean }, y una opción omitida no
escribe nada.
await ts .find("src/app/user.service.ts") .addImport("inject", "@angular/core") .addClassProperty("UserService", "http", "inject(HttpClient)", { scope: "private", readonly: true });// antesexport class UserService { private readonly cache = new Map<string, User>();
load(id: string) { return this.cache.get(id); }}
// despuésexport class UserService { private readonly cache = new Map<string, User>(); private readonly http = inject(HttpClient);
load(id: string) { return this.cache.get(id); }}La propiedad va después de la última propiedad de la clase, lo que deja las propiedades por
encima del constructor y los métodos. Una clase sin propiedades la recibe como primer miembro.
El archivo tiene que tener exactamente una clase llamada className. Una propiedad con la misma
declaración no cambia nada, y cualquier otro miembro llamado name, incluido un método, rechaza.
initializer tiene que parsear como una expresión y se inserta tal cual, igual que
{ expression }. La op no agrega imports.
El dialecto de React
Sección titulada «El dialecto de React»@pbuilder/sdk/react muta archivos .tsx y .jsx, y find() requiere una de esas dos
extensiones en la ruta. Una ruta sin extensión se rechaza, nunca se normaliza, y cualquier otra
extensión se rechaza con .tsx and .jsx are the only extensions the React dialect supports; una
ruta .ts te remite a @pbuilder/sdk/typescript. El handle tiene todas
las ops del dialecto de TypeScript (addImport,
addDefaultImport, sortImports y el resto) más ops de JSX y de componente, con .modify(fn)
como escape para todo lo demás:
import * as react from "@pbuilder/sdk/react";
// src/Button.tsx antes: const el = <Button />;await react .find("src/Button.tsx") .addImport("handleClick", "./handlers") .setJsxProp("Button", "onClick", "{handleClick}");// -> import { handleClick } from "./handlers";// -> const el = <Button onClick={handleClick} />;| Op | Qué hace |
|---|---|
setJsxProp(target, prop, value?) |
Asigna una prop en el único elemento que nombra target. value toma tres formas: '"hi"' (string), '{count}' (expresión), u omitido (shorthand booleano). |
removeJsxProp(target, prop) |
Quita una prop del único elemento que nombra target. Una prop ausente es un no-op, y un {...spread} nunca se toca, aunque pueda traer la prop. |
addJsxChild(target, jsx) |
Agrega jsx, un único elemento o fragmento JSX, como último hijo del único elemento que nombra target. Un hijo con el mismo texto ya presente es un no-op. |
wrapJsxElement(target, wrapper) |
Envuelve el único elemento que nombra target en wrapper, un elemento JSX autocerrado cuyas props se conservan. Es un no-op cuando el target ya está directamente dentro de ese tag. |
renameJsxElement(target, tag) |
Renombra el único elemento que nombra target, el tag de apertura y el de cierre juntos. No toca los imports. |
addHookCall(component, statement) |
Inserta una sentencia en el cuerpo del componente llamado component, después de sus llamadas a hooks iniciales. Una sentencia idéntica ya presente es un no-op. |
addComponentProp(component, name, type, opts?) |
Agrega name: type (name?: con { optional: true }) al tipo de props del componente y a su parámetro desestructurado. El mismo nombre y tipo es un no-op; un tipo distinto rechaza. |
Las ops de import son las del dialecto de TypeScript, con las mismas reglas, así que
addImport("React", "react") imprime import { React } from "react". Para
import React from "react", usa addDefaultImport("React", "react").
Elegir el elemento
Sección titulada «Elegir el elemento»Cada op de JSX edita exactamente un elemento, y rechaza cuando coinciden cero o varios.
target es un nombre de tag ("Button"), o un objeto que suma props para acotar la
coincidencia. El valor string de una prop debe ser igual al texto del inicializador del atributo
tal como está escrito en el código, comillas incluidas; true coincide con la prop con cualquier
valor, shorthand incluido:
// src/App.tsx antes:// <Routes>// <Route path="/" element={<Home />} />// <Route path="/users" element={<Users />} />// </Routes>await react .find("src/App.tsx") .setJsxProp({ tag: "Route", props: { path: '"/users"' } }, "caseSensitive");// -> <Route path="/users" element={<Users />} caseSensitive />Un mensaje de rechazo nombra el tag y los nombres de las props, nunca sus valores.
Hijos, envoltorios y renombres
Sección titulada «Hijos, envoltorios y renombres»addJsxChild pone el hijo en su propia línea con la indentación de sus hermanos, o en línea
cuando el contenido del elemento está en línea. Un target autocerrado pasa a tener apertura y
cierre. jsx debe ser exactamente un elemento o fragmento JSX; cualquier otra cosa se rechaza
sin repetirla en el mensaje.
// src/App.tsx antes:// <Routes>// <Route path="/" element={<Home />} />// </Routes>await react .find("src/App.tsx") .addImport("Users", "./pages/Users") .addJsxChild("Routes", '<Route path="/users" element={<Users />} />');// -> <Routes>// -> <Route path="/" element={<Home />} />// -> <Route path="/users" element={<Users />} />// -> </Routes>wrapJsxElement recibe un wrapper autocerrado y pone el target dentro:
// src/main.tsx antes: root.render(<App />);await react.find("src/main.tsx").wrapJsxElement("App", "<QueryProvider client={queryClient} />");// -> root.render(<QueryProvider client={queryClient}><App /></QueryProvider>);renameJsxElement cambia el tag de apertura y el de cierre juntos. Los elementos anidados con el
mismo tag no se tocan, y renombrar al mismo tag es un no-op:
// src/Toolbar.tsx antes: <Button onClick={save}>Save</Button>await react .find("src/Toolbar.tsx") .addImport("PrimaryButton", "./ui") .renameJsxElement("Button", "PrimaryButton");// -> <PrimaryButton onClick={save}>Save</PrimaryButton>Ninguna de estas ops agrega imports, así que combínalas con addImport.
Ops de componente
Sección titulada «Ops de componente»addHookCall y addComponentProp reciben el nombre de un component. La op lo busca entre las
declaraciones function Name de nivel superior del archivo, exportadas o no, y las arrow
functions o function expressions de nivel superior asignadas con const Name =. Un componente
envuelto (memo(...), forwardRef(...)) no se encuentra, y cero o varias coincidencias
rechazan.
addHookCall(component, statement) inserta statement después de las llamadas a hooks
iniciales del componente (sentencias que llaman a useX(...) o React.useX(...)), o al
principio cuando no hay ninguna, con la indentación del cuerpo. statement debe ser exactamente
una sentencia. Una sentencia idéntica ya presente en el cuerpo es un no-op, y una arrow con
cuerpo de expresión (() => <div />) rechaza porque no tiene sentencias a las que agregar:
// src/Form.tsx antes:// export function Form() {// const [name, setName] = useState("");// return <form />;// }await react.find("src/Form.tsx").addHookCall("Form", "const t = useTranslation();");// -> export function Form() {// -> const [name, setName] = useState("");// -> const t = useTranslation();// -> return <form />;// -> }addComponentProp(component, name, type, options?) agrega name: type, o name?: type con
{ optional: true }, al tipo de props del componente. Cuando el parámetro de props está
desestructurado, también agrega name a la desestructuración, antes de un elemento ...rest.
El tipo de props es el type literal en línea del primer parámetro, o un interface o un type
literal declarado en el mismo archivo y referenciado por nombre. Un tipo de props importado o
sin tipar rechaza. Un componente sin parámetros recibe ({ name }: { name: type }). El mismo
nombre con el mismo texto de tipo es un no-op, y un tipo distinto rechaza. type debe ser
exactamente un tipo:
// src/Card.tsx antes:// interface CardProps {// title: string;// }// export function Card({ title }: CardProps) { ... }await react.find("src/Card.tsx").addComponentProp("Card", "subtitle", "string", { optional: true });// -> interface CardProps {// -> title: string;// -> subtitle?: string;// -> }// -> export function Card({ title, subtitle }: CardProps) { ... }Dos sutilezas específicas de React:
- Precedencia sobre spreads. Una prop insertada aterriza después de un
{...spread}final, así que gana en runtime bajo la precedencia por posición posterior de React:<Button {...rest} />mássetJsxProp("Button", "onClick", "{safe}")imprime<Button {...rest} onClick={safe} />, ysafegana incluso siresttambién provee unonClick. - Rechazo por colisión.
addImportsigue las reglas de colisión de TypeScript: unnameque ya está ligado de otra forma en el archivo rechaza cona value or import binding named "X" already exists. No hay argumento de alias para esquivarlo; renombrar el binding existente o elegir otronamecorre por tu cuenta.
El value de setJsxProp, el jsx de addJsxChild, el wrapper de wrapJsxElement, el
statement de addHookCall y el type de addComponentProp se emiten tal cual dentro de
código ejecutable, con el mismo límite de confianza que los strings source de TypeScript. El
SDK verifica que cada uno sea un único nodo JSX, una sentencia o un tipo, y cuando no lo es lo
rechaza sin repetirlo en el mensaje. No hace escapado ni sanitización. En contraste, los nombres de
componente, de tag y de prop son argumentos de nombre validados y no son un canal de código
confiable.
Archivos .jsx
Sección titulada «Archivos .jsx»Un archivo .jsx recibe el mismo handle que uno .tsx. El SDK lo parsea como JavaScript con
JSX, así que las ops de JSX, de hooks y de imports se comportan igual que en .tsx:
// src/App.jsx antes:// export default function App() {// return (// <Routes>// <Route path="/" element={<Home />} />// </Routes>// );// }await react .find("src/App.jsx") .addImport("Users", "./Users") .addHookCall("App", "const t = useTranslation();") .addJsxChild("Routes", '<Route path="/users" element={<Users />} />');Un archivo .jsx no puede contener sintaxis de TypeScript, así que tres ops lo rechazan al
llamarlas y dejan el archivo sin cambios: addTypeImport, addComponentProp, y
addClassProperty cuando le pasas scope o readonly. Sin esas dos opciones,
addClassProperty también funciona en .jsx. El error nombra la op y dice que esa edición
necesita .modify(), por ejemplo addComponentProp is not available for .jsx files.
Idempotencia: seguro de re-ejecutar
Sección titulada «Idempotencia: seguro de re-ejecutar»Los factories se re-ejecutan contra proyectos ya generados, y las ops de import están
construidas para eso: addImport llamada dos veces con el mismo nombre y módulo nunca duplica
la línea de import, y removeImport sobre un binding ausente es un no-op (cero directivas
emitidas), así que no necesitas tu propio chequeo de “¿ya está ahí?”.
Ediciones personalizadas con .modify()
Sección titulada «Ediciones personalizadas con .modify()»Las ops nombradas cubren las ediciones comunes. Cuando el cambio que necesitas no está en ese
vocabulario, usa .modify(fn), que todo handle de dialecto tiene. Elige la herramienta según la
edición:
| Necesitas… | Usa |
|---|---|
| Agregar, quitar u ordenar imports, agregar una declaración de nivel superior, agregar a un array, agregar una propiedad de clase, editar las props, los hijos, el envoltorio o el tag de un elemento JSX, agregar una llamada a hook o una prop a un componente de React | Las ops nombradas del dialecto |
Hacer cualquier otro cambio estructural en un archivo .ts, .tsx o .jsx |
.modify(fn) sobre el handle del dialecto |
Registrar un provider, controller, import de módulo o export en un @Module de NestJS |
El dialecto NestJS |
Registrar un componente, provider o ruta, o agregar un miembro signal o inject() en Angular |
El dialecto Angular |
| Editar un archivo JSON, YAML o CSS | El dialecto JSON, YAML o CSS |
| Editar un tipo de archivo que ningún dialecto cubre, como Markdown | find de @pbuilder/sdk/commons |
El callback recibe solo el AST: un SourceFile de ts-morph en los dos dialectos de esta página,
la misma instancia viva que mutan las ops nombradas. No hay un segundo argumento. Las ops
nombradas y las llamadas a .modify() encadenadas en un mismo handle siguen generando una
sola escritura, en el orden de la cadena.
La librería AST del dialecto: astLibrary
Sección titulada «La librería AST del dialecto: astLibrary»Las ediciones personalizadas suelen necesitar más que los métodos del AST: constantes de enums
como SyntaxKind, type guards como Node.isStringLiteral y nombres de tipos para tus propias
anotaciones. Cada dialecto exporta su librería AST completa como astLibrary, desde el mismo
punto de entrada que find:
import { find, astLibrary } from "@pbuilder/sdk/typescript";
astLibrary.SyntaxKind.ArrayLiteralExpression; // runtime constantastLibrary.Node.isStringLiteral(node); // runtime helpertype Routes = astLibrary.ArrayLiteralExpression; // type-only accessUsa el astLibrary del dialecto cuyo handle estás editando: @pbuilder/sdk/typescript para
.ts, @pbuilder/sdk/react para .tsx y .jsx. No necesitas agregar ts-morph a tus propias
dependencias para trabajar con el AST. Cada dialecto es dueño de su librería y su versión; qué
librería respalda a cada dialecto está en Librerías AST.
Ejemplo: registrar una ruta (TypeScript)
Sección titulada «Ejemplo: registrar una ruta (TypeScript)»addArrayElement solo acepta código que escribiste tú. Cuando
el elemento viene de la entrada del consumidor, ármalo en .modify() para controlar las comillas.
Esta factory agrega una ruta que recibe como entrada a un array routes exportado, y no hace nada
si la ruta ya está:
export const routes = [ "/", "/about",];import { find, astLibrary } from "@pbuilder/sdk/typescript";
type ArrayLiteral = astLibrary.ArrayLiteralExpression;
export default async (input: { path: string }) => { await find("src/routes.ts").modify((ast) => { const routes: ArrayLiteral = ast .getVariableDeclarationOrThrow("routes") .getInitializerIfKindOrThrow(astLibrary.SyntaxKind.ArrayLiteralExpression);
const alreadyRegistered = routes .getElements() .some((el) => astLibrary.Node.isStringLiteral(el) && el.getLiteralValue() === input.path);
if (!alreadyRegistered) { routes.addElement(JSON.stringify(input.path)); } });};export const routes = [ "/", "/about", "/pricing"];Ejecutarla otra vez con la misma entrada deja el archivo sin cambios.
Ejemplo: agregar un <Route> (React)
Sección titulada «Ejemplo: agregar un <Route> (React)»addJsxChild omite un hijo solo cuando ya está el mismo texto. Esta factory usa .modify()
para omitir el <Route> cuando alguna ruta ya tiene ese path, sea cual sea su element, e
importa la página en la misma escritura:
import { Routes, Route } from "react-router-dom";import { Home } from "./pages/Home";
export function App() { return ( <Routes> <Route path="/" element={<Home />} /> </Routes> );}import { find, astLibrary } from "@pbuilder/sdk/react";
export default async (input: { path: string; component: string }) => { await find("src/App.tsx") .addImport(input.component, `./pages/${input.component}`) .modify((ast) => { const routes = ast .getDescendantsOfKind(astLibrary.SyntaxKind.JsxElement) .find((el) => el.getOpeningElement().getTagNameNode().getText() === "Routes"); if (routes === undefined) { throw new Error("src/App.tsx has no <Routes> element"); }
const exists = routes .getDescendantsOfKind(astLibrary.SyntaxKind.JsxSelfClosingElement) .some((el) => { const attr = el.getAttribute("path"); return ( astLibrary.Node.isJsxAttribute(attr) && attr.getInitializer()?.getText() === JSON.stringify(input.path) ); }); if (exists) return;
const children = routes.getJsxChildren().map((child) => child.getText()).join("").trim(); routes.setBodyText(`${children}\n<Route path="${input.path}" element={<${input.component} />} />`); });};import { Routes, Route } from "react-router-dom";import { Home } from "./pages/Home";import { Pricing } from "./pages/Pricing";
export function App() { return ( <Routes> <Route path="/" element={<Home />} /> <Route path="/pricing" element={<Pricing />} /> </Routes> );}Ambos ejemplos verifican antes de editar, así que una re-ejecución no escribe nada: la misma idempotencia que te dan las ops nombradas.
Reglas para las ediciones personalizadas
Sección titulada «Reglas para las ediciones personalizadas»- Opera solo sobre el
astque te entrega el callback, con elastLibrarydel mismo dialecto. Si tu proyecto también instala ts-morph directamente, esa copia es un realm separado: unNodeoSourceFilecreado con ella no es intercambiable con el AST del callback, aunque sea la misma versión. No pases objetos a través de esa frontera, y no asumas que los objetos AST de dialectos distintos son intercambiables. .modify()se ejecuta con todos los privilegios del proceso y no es un sandbox. Tus propios callbacks son de tu confianza; trata con el mismo criterio a los dialectos u op-packs de terceros construidos sobre él.- Los strings que insertas son código. El texto que pasas a
addElement,setBodyTexty métodos similares se inserta tal cual, así que sanitiza todo lo que venga de la entrada del usuario.
El .replaceContent(content) de un handle de dialecto se rechaza mientras una op de AST
(nombrada o .modify()) sigue pendiente en el mismo handle, porque la sobrescritura completa perdería
en silencio la edición en buffer. La salida documentada es .read(), que drena primero la
edición pendiente:
const handle = ts.find("src/index.ts").addImport("readFileSync", "node:fs");await handle.read(); // drains the pending addImport edithandle.replaceContent("new content"); // succeeds — a legitimate sequential editArchivos sin dialecto
Sección titulada «Archivos sin dialecto»Hoy existen dialectos para .ts, .tsx, .jsx, .json, .jsonc, .yaml, .yml y .css. Cualquier
otro archivo (un índice en Markdown, una hoja de estilos .scss, un archivo .env) se puede editar igual con find
de @pbuilder/sdk/commons: devuelve un handle para cualquier ruta, con read() y los verbos
de texto (replaceContent, rename, move, copy, remove). No hay AST: lee el contenido,
transfórmalo con el parser que corresponda al formato y escribe el resultado.
El ejemplo usa un archivo JSON porque la ida y vuelta es corta. En una factory real, edita JSON con el dialecto JSON, que conserva el formato y los comentarios del archivo.
{ "enabled": ["search"]}import { find, replaceContent } from "@pbuilder/sdk/commons";
export default async (input: { flag: string }) => { const content = await find("config/features.json").read(); if (content === undefined) { throw new Error("config/features.json not found"); }
const config = JSON.parse(content) as { enabled: string[] }; if (config.enabled.includes(input.flag)) return;
config.enabled.push(input.flag); replaceContent("config/features.json", `${JSON.stringify(config, null, 2)}\n`);};{ "enabled": [ "search", "billing" ]}Dos diferencias con una edición de dialecto:
- El formato es responsabilidad tuya. Volver a serializar reescribe todo el archivo. Aquí,
JSON.stringifyreacomoda el array. Usa un parser que preserve el formato cuando la disposición importe. - Maneja los tres estados de lectura.
read()resuelveundefinedpara un archivo que no existe y""para uno vacío; ramifica con comparaciones estrictas, como se describe en Leer archivos.
Para ir más lejos
Sección titulada «Para ir más lejos»- Dialecto JSON y Dialecto YAML: ops nombradas para archivos JSON, JSONC y YAML que conservan comentarios y formato.
- Dialecto CSS: define y elimina declaraciones en un archivo
.css, como los design tokens de un bloque@themede Tailwind. - Dialecto NestJS: registra clases en un
@Modulede NestJS. - Dialecto Angular: registra componentes, providers y rutas, y
agrega miembros signal e
inject(). - Librerías AST: qué librería y versión respaldan a cada
dialecto, y cómo las expone
astLibrary. - Construir un dialecto propio es una superficie para contribuidores. Todo dialecto debe
exportar su librería completa como
astLibrary, y su fixture de conformance debe pasar el módulo real y un ejercicio de la librería. Ver la guía de autoría de dialectos del SDK.
El soporte para más tipos de archivo como HTML (cada uno respaldado por su propia librería de
parser/AST), y dialectos para frameworks como Vue y Svelte, están planificados. Mientras tanto, edita esos
archivos con find de @pbuilder/sdk/commons.