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

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.

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

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

@pbuilder/sdk/react muta archivos .tsxfind() 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á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 rechaza cuando name ya 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 otro name corre por tu cuenta.

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

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.

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

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

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.

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:

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 — 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 — sanitiza todo lo que venga de la entrada del usuario.

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.

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