Skip to content

Modify

When string-level mutation isn’t enough — “add an import to this module”, “set a prop on this JSX element” — dialects provide structured, AST-aware operations for one file type. A dialect never edits text by search-and-replace: it parses the file into an AST, mutates the tree, and prints the result back. Each file type gets the AST library that fits it best — the two dialects that ship today, @pbuilder/sdk/typescript and @pbuilder/sdk/react, are both built on TypeScript’s own AST (surfaced through ts-morph), because TS and TSX share a grammar.

A dialect’s entry point is its own find(path): it opens a coalescing, awaitable handle. Every op you chain mutates the same live AST, and the handle serializes to exactly one replaceContent-style directive when it flushes — chaining three ops does not produce three writes.

import * as ts from "@pbuilder/sdk/typescript";
await ts.find("src/index.ts")
.addImport("readFileSync", "node:fs")
.addFunction("hi", "(): void {}", { export: true });

A read (.read() on the handle, or any read on any path) drains the open directive first; a chain with one mid-chain read produces exactly two directives, cumulative — no edit is lost.

The handle is a thenable — the one async departure from the SDK’s otherwise synchronous verbs. await is not required for correctness: the run boundary drains every outstanding handle before the run flushes, so a forgotten await still commits its edit, and a chain that throws still surfaces its error contained at the run boundary — never as an unhandled rejection. await the chain yourself when you need to sequence a read after your own write, or to observe a failure locally in your own 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 What it does
addImport(name, from) Adds import { name } from "from", merging into an existing clause from the same module. Idempotent — calling it twice never duplicates the import.
removeImport(name, from) Removes the named binding; deletes the whole statement when it was the last one. Idempotent on an absent binding.
addFunction(name, source, opts?) Appends a top-level function. source includes the braces ("(): void {}").
addVariable(name, initializer, opts?) Appends a top-level variable (kind defaults to const).
addClass(name, source, opts?) Appends a top-level class. source excludes the braces — the op adds them.
.modify(fn) The universal escape hatch: direct ts-morph access to the file’s AST for anything the named ops don’t cover.

Note the deliberate contrast between the two source conventions:

// addFunction: source INCLUDES braces
await ts.find("src/index.ts").addFunction("hi", "(): void {}", { export: true });
// -> export function hi(): void {}
// addClass: source EXCLUDES braces (the op adds them)
await ts.find("src/index.ts").addClass("Point", " x = 0;");
// -> class Point {\n x = 0;\n}

addVariable emits {export }{kind} {name} = {initializer};kind accepts "const" (default), "let", or "var":

await ts.find("src/index.ts").addVariable("counter", "0", { export: true, kind: "let" });
// -> export let counter = 0;

Collision rules. The add* ops fail loud on a name collision with an existing value declaration or import binding — two value declarations sharing a name is invalid TypeScript. A type/interface sharing the name does not collide (TypeScript legally permits a value and a type to share an identifier).

@pbuilder/sdk/react mutates .tsx files — find() requires the explicit .tsx extension (extensionless and .jsx paths are rejected, never normalized). The v1 op-pack is deliberately minimal: two structured ops, with .modify(fn) as the escape hatch for everything else:

import * as react from "@pbuilder/sdk/react";
// src/Button.tsx before: 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 What it does
addImport(name, from) Same contract as the TypeScript dialect’s — idempotent, named-binding-only (no default/namespace imports in v1).
setJsxProp(element, prop, value?) Sets a prop on the one element with that tag name — zero or multiple matches reject loudly. value takes three forms: '"hi"' (string), '{count}' (expression), or omitted (boolean shorthand).

Because addImport is named-binding-only, addImport("React", "react") always prints import { React } from "react", never import React from "react" — default and namespace imports are follow-up scope, not an oversight.

Two React-specific subtleties:

  • Spread precedence. An inserted prop lands after a trailing {...spread}, so it wins at runtime under React’s later-position precedence: <Button {...rest} /> plus setJsxProp("Button", "onClick", "{safe}") prints <Button {...rest} onClick={safe} />, and safe wins even if rest also supplies an onClick.
  • Collision rejects. addImport rejects when name is already bound elsewhere in the file under a different binding — a different module, a same-module alias or type-only specifier, or a top-level value declaration (function/const/class/…) sharing the name. There is no alias argument to route around it; renaming the existing binding or choosing a different name is on you.

Factories re-run against already-generated projects, and the import ops are built for that: addImport called twice with the same name and module never duplicates the import line, and removeImport on an absent binding is a no-op (zero directives emitted). You get re-runnable import management without writing your own “is it already there?” check.

Named ops cover the common edits. When the change you need is not in that vocabulary, use .modify(fn) — every dialect handle has it. Pick the tool by the edit, not by habit:

You need to… Use
Add or remove an import, add a top-level declaration, set one JSX prop The dialect’s named ops
Make any other structural change to a .ts or .tsx file .modify(fn) on the dialect handle
Edit a file type no dialect covers — JSON, YAML, Markdown, CSS… find from @pbuilder/sdk/commons

The callback receives only the AST — a ts-morph SourceFile for both current dialects, the same live instance the named ops mutate. There is no second argument. Named ops and .modify() calls chained on one handle still flush as a single write, in chain order.

Custom edits usually need more than the methods on the AST: enum constants such as SyntaxKind, type guards such as Node.isStringLiteral, and type names for your own annotations. Each dialect exports its complete AST library as astLibrary, from the same entry point as 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

Use the astLibrary of the dialect whose handle you are editing — @pbuilder/sdk/typescript for .ts, @pbuilder/sdk/react for .tsx. You do not need to add ts-morph to your own dependencies to work with the AST. Each dialect owns its library and version; which library backs each dialect is listed in AST libraries.

The TypeScript dialect has no op to append to an array literal. This factory adds a path to an exported routes array, and does nothing when it is already there:

src/routes.ts (before)
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 (after, with --path=/pricing)
export const routes = [
"/",
"/about",
"/pricing"
];

Running it again with the same input leaves the file unchanged.

setJsxProp edits props, not children. This factory combines a named op with .modify() to import a page and add a <Route> inside <Routes> — one write for both:

src/App.tsx (before)
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 (after, with --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>
);
}

Both examples check before they edit, so a re-run writes nothing — the same idempotency the named ops give you.

  • Operate only on the ast the callback hands you, with the astLibrary of the same dialect. If your project also installs ts-morph directly, that copy is a separate realm: a Node or SourceFile created from it is not interchangeable with the callback’s AST, even at the same version. Do not pass objects across that boundary, and do not assume AST objects from different dialects are interchangeable.
  • .modify() runs with full process privilege — it is not a sandbox. Your own callbacks are your own trust; treat third-party dialects or op-packs built on it accordingly.
  • Strings you insert are code. Text passed to addElement, setBodyText and similar methods is inserted verbatim — sanitize anything derived from user input.

Dialects exist for .ts and .tsx today. Any other file — package.json, a YAML config, a Markdown index, a stylesheet — is still editable with find from @pbuilder/sdk/commons: it returns a handle for any path, with read() plus the text-level verbs (replaceContent, rename, move, copy, remove). There is no AST: read the content, transform it with whatever parser fits the format, and write the result back.

config/features.json (before)
{
"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 (after, with --flag=billing)
{
"enabled": [
"search",
"billing"
]
}

Two differences from a dialect edit:

  • You own the formatting. Re-serializing rewrites the whole file — here, JSON.stringify reflows the array. Use a format-preserving parser when layout matters.
  • Handle the three read states. read() resolves undefined for a missing file and "" for an empty one; branch with strict comparisons, as described in Reading files.
  • AST libraries — which library and version back each dialect, and how astLibrary exposes them.
  • Building a dialect of your own is a contributor-level surface. Every dialect must export its complete library as astLibrary, and its conformance fixture must pass the actual module and a library exercise — see the SDK’s dialect authoring guide.

The dialect family is designed to grow: support for more file types such as HTML and CSS (each backed by its own parser/AST library), and framework-aware dialects for Angular, Vue, and Svelte, are planned. Until then, edit those files with find from @pbuilder/sdk/commons.