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 dos dialectos que se distribuyen hoy,
@pbuilder/sdk/typescript y @pbuilder/sdk/react, están ambos construidos sobre el AST propio
de TypeScript (expuesto a través de ts-morph), porque TS y TSX comparten gramática.
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 — encadenar tres ops no
produce tres escrituras.
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 — 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 — nunca 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.
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) |
Agrega import { name } from "from", fusionándose con una cláusula existente del mismo módulo. Idempotente — llamarla dos veces nunca duplica el import. |
removeImport(name, from) |
Elimina el binding nombrado; borra la sentencia completa cuando era el último. Idempotente sobre un binding ausente. |
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 — la op las agrega. |
.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":
await ts.find("src/index.ts").addVariable("counter", "0", { export: true, kind: "let" });// -> export let counter = 0;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 — dos declaraciones de valor compartiendo
un nombre es TypeScript inválido. 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 — nunca se validan ni se
sanitizan — 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.
El dialecto de React
Sección titulada «El dialecto de React»@pbuilder/sdk/react muta archivos .tsx — find() requiere la extensión .tsx explícita
(las rutas sin extensión y las .jsx se rechazan, nunca se normalizan). El paquete de ops de la
v1 es deliberadamente mínimo: dos ops estructuradas, 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 |
|---|---|
addImport(name, from) |
El mismo contrato que la del dialecto de TypeScript — idempotente, solo bindings nombrados (sin imports default ni de namespace en la v1). |
setJsxProp(element, prop, value?) |
Asigna una prop en el único elemento con ese nombre de tag — cero o múltiples coincidencias rechazan ruidosamente. value toma tres formas: '"hi"' (string), '{count}' (expresión), u omitido (shorthand booleano). |
Como addImport es solo de bindings nombrados, addImport("React", "react") siempre imprime
import { React } from "react", nunca import React from "react" — los imports default y de
namespace son alcance futuro, no un descuido.
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.
addImportrechaza cuandonameya está ligado en otra parte del archivo bajo un binding distinto — otro módulo, un alias del mismo módulo o un specifier type-only, o una declaración de valor de nivel superior (function/const/class/…) que comparte el nombre. No hay argumento de alias para esquivarlo; renombrar el binding existente o elegir otronamecorre por tu cuenta.
El value de setJsxProp se emite tal cual dentro de código ejecutable — el mismo límite de
confianza que los strings source de TypeScript. El SDK no realiza validación, escapado ni
sanitización sobre él. En contraste, element y prop son argumentos de nombre validados y no
son un canal de código confiable.
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). Obtienes gestión de imports re-ejecutable sin escribir 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) — todo handle de dialecto lo tiene. Elige la herramienta según la
edición, no por costumbre:
| Necesitas… | Usa |
|---|---|
| Agregar o quitar un import, agregar una declaración de nivel superior, definir una prop JSX | Las ops nombradas del dialecto |
Hacer cualquier otro cambio estructural en un archivo .ts o .tsx |
.modify(fn) sobre el handle del dialecto |
| Editar un tipo de archivo que ningún dialecto cubre — JSON, YAML, Markdown, CSS… | find de @pbuilder/sdk/commons |
El callback recibe solo el AST — un SourceFile de ts-morph en los dos dialectos actuales,
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. 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)»El dialecto de TypeScript no tiene una op para agregar elementos a un array literal. Esta factory
agrega una ruta a un array routes exportado, y no hace nada si 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)»setJsxProp edita props, no hijos. Esta factory combina una op nombrada con .modify() para
importar una página y agregar un <Route> dentro de <Routes> — una sola escritura para ambas
cosas:
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 — 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 — 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 — 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 y .tsx. Cualquier otro archivo — package.json, una
configuración YAML, un índice en Markdown, una hoja de estilos — 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.
{ "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»- 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.
La familia de dialectos está pensada para crecer: el soporte para más tipos de archivo como
HTML y CSS (cada uno respaldado por su propia librería de parser/AST), y dialectos para
frameworks como Angular, Vue y Svelte, están planificados. Mientras tanto, edita esos
archivos con find de @pbuilder/sdk/commons.