Ir al contenido

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.

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.

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.

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 llaves
await 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;

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).

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 addImport ordenado 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, addImport se comporta como antes. Cualquier otro valor de position rechaza y deja el archivo sin cambios.
  • Los imports insertados respetan las comillas y los punto y coma del archivo, como toda inserción.
  • sortImports solo 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.

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");
// antes
export const routes: Routes = [
{ path: "", component: HomeComponent },
{
path: "admin",
component: AdminComponent,
},
];
// después
export 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: 2 sobre bootstrapApplication(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.

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 });
// antes
export class UserService {
private readonly cache = new Map<string, User>();
load(id: string) {
return this.cache.get(id);
}
}
// después
export 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.

@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").

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.

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.

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ás setJsxProp("Button", "onClick", "{safe}") imprime <Button {...rest} onClick={safe} />, y safe gana incluso si rest también provee un onClick.
  • Rechazo por colisión. addImport sigue las reglas de colisión de TypeScript: un name que ya está ligado de otra forma en el archivo rechaza con a value or import binding named "X" already exists. No hay argumento de alias para esquivarlo; renombrar el binding existente o elegir otro name corre por tu cuenta.

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.

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í?”.

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.

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 constant
astLibrary.Node.isStringLiteral(node); // runtime helper
type Routes = astLibrary.ArrayLiteralExpression; // type-only access

Usa 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.

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á:

src/routes.ts (antes)
export const routes = [
"/",
"/about",
];
factory.ts
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));
}
});
};
src/routes.ts (después, con --path=/pricing)
export const routes = [
"/",
"/about",
"/pricing"
];

Ejecutarla otra vez con la misma entrada deja el archivo sin cambios.

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:

src/App.tsx (antes)
import { Routes, Route } from "react-router-dom";
import { Home } from "./pages/Home";
export function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
</Routes>
);
}
factory.ts
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} />} />`);
});
};
src/App.tsx (después, con --path=/pricing --component=Pricing)
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.

  • Opera solo sobre el ast que te entrega el callback, con el astLibrary del mismo dialecto. Si tu proyecto también instala ts-morph directamente, esa copia es un realm separado: un Node o SourceFile creado 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, setBodyText y métodos similares se inserta tal cual, así que sanitiza todo lo que venga de la entrada del usuario.

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.

config/features.json (antes)
{
"enabled": ["search"]
}
factory.ts
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`);
};
config/features.json (después, con --flag=billing)
{
"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.stringify reacomoda el array. Usa un parser que preserve el formato cuando la disposición importe.
  • Maneja los tres estados de lectura. read() resuelve undefined para un archivo que no existe y "" para uno vacío; ramifica con comparaciones estrictas, como se describe en Leer archivos.
  • 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 @theme de Tailwind.
  • Dialecto NestJS: registra clases en un @Module de 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.