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.
How a dialect handle works
Section titled “How a dialect handle works”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.
The TypeScript dialect
Section titled “The TypeScript dialect”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 bracesawait 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).
source/initializer strings are inserted verbatim — never validated or sanitized — so the
author owns their syntax, and anything derived from untrusted input (schema options, CLI
answers, network data) is the author’s responsibility to sanitize.
The React dialect
Section titled “The React dialect”@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} />plussetJsxProp("Button", "onClick", "{safe}")prints<Button {...rest} onClick={safe} />, andsafewins even ifrestalso supplies anonClick. - Collision rejects.
addImportrejects whennameis 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 differentnameis on you.
setJsxProp’s value is emitted verbatim into executable code — same trust boundary as the
TypeScript source strings. The SDK performs no validation, escaping, or sanitisation on it.
By contrast, element and prop are validated name arguments and are not a trusted-code
channel.
Idempotency: safe to re-run
Section titled “Idempotency: safe to re-run”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.
Custom edits with .modify()
Section titled “Custom edits with .modify()”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.
The dialect’s AST library: astLibrary
Section titled “The dialect’s AST library: astLibrary”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 constantastLibrary.Node.isStringLiteral(node); // runtime helpertype Routes = astLibrary.ArrayLiteralExpression; // type-only accessUse 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.
Example: register a route (TypeScript)
Section titled “Example: register a route (TypeScript)”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:
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"];Running it again with the same input leaves the file unchanged.
Example: add a <Route> (React)
Section titled “Example: add a <Route> (React)”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:
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> );}Both examples check before they edit, so a re-run writes nothing — the same idempotency the named ops give you.
Rules for custom edits
Section titled “Rules for custom edits”- Operate only on the
astthe callback hands you, with theastLibraryof the same dialect. If your project also installs ts-morph directly, that copy is a separate realm: aNodeorSourceFilecreated 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,setBodyTextand similar methods is inserted verbatim — sanitize anything derived from user input.
A dialect handle’s .replaceContent(content) rejects while an AST op (named or .modify()) is
still pending on the same handle — the wholesale overwrite would silently lose the buffered
edit. The documented escape is .read(), which drains the pending edit first:
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 editFiles without a dialect
Section titled “Files without a dialect”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.
{ "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" ]}Two differences from a dialect edit:
- You own the formatting. Re-serializing rewrites the whole file — here,
JSON.stringifyreflows the array. Use a format-preserving parser when layout matters. - Handle the three read states.
read()resolvesundefinedfor a missing file and""for an empty one; branch with strict comparisons, as described in Reading files.
Going further
Section titled “Going further”- AST libraries — which library and version back each dialect,
and how
astLibraryexposes 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.