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
@pbuilder/sdk/typescript and @pbuilder/sdk/react dialects covered on this page are both built
on TypeScript’s own AST (surfaced through ts-morph), because TS and TSX share a grammar. JSON and
YAML files have dialects of their own: see JSON dialect and
YAML dialect, and CSS files use the CSS dialect.
The React dialect, the NestJS dialect and the
Angular dialect add framework ops on top of the TypeScript ones.
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.
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 cumulative directives, and 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 instead of 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.
Queries
Section titled “Queries”Most ops write and return the handle, so they chain. A dialect can also declare queries, which
read a value. A query ends the chain: await it and it resolves to the value. It sees every op
chained before it on the same handle, writes nothing, and the ops before it still commit. A
query that would change the file rejects and fails the run. The
CSS dialect’s getDeclaration is a query.
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, opts?) |
Adds import { name } from "from", merging into an existing clause from the same module. Idempotent: calling it twice never duplicates the import. With { position: "sorted" } it places the import in order instead of appending it. See Sorted imports. |
addDefaultImport(name, from) |
Adds import name from "from", merging into a declaration of that module with no default yet. Rejects a second default under another name. |
addNamespaceImport(name, from) |
Adds import * as name from "from", merging into a default-only declaration. Beside named imports it opens its own declaration. |
addTypeImport(name, from) |
Adds import type { name } from "from" as its own statement, never merged into a value import. A no-op when the module already imports name, by value or as a type. |
addSideEffectImport(from) |
Adds import "from". A no-op when that bare import already exists. |
addReExport(name, from) |
Adds export { name } from "from", merging into an existing re-export of that module. Rejects a name the file already exports. |
addExportAll(from, { as? }) |
Adds export * from "from", or export * as ns from "from" with { as: "ns" }. |
removeImport(name, from) |
Removes the named binding; deletes the whole statement when it was the last one. Idempotent on an absent binding. |
removeExport(name, from) |
Removes a named re-export; deletes the statement when it was the last one. Idempotent on an absent one. |
removeExportAll(from, { as? }) |
Removes export * from "from" (or the as form). Idempotent on an absent one. |
sortImports() |
Sorts the file’s import declarations by module, and the names inside each one’s braces. Only reorders. A no-op on a sorted file. See Sorted imports. |
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, and the op adds them. |
addArrayElement(locator, element) |
Appends element to the array literal that locator points at, keeping the list’s layout. A no-op when the element is already there. See Nested arrays. |
addClassProperty(className, name, initializer, opts?) |
Adds name = initializer; after the last property of the only class named className. A no-op when the same property exists. |
.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", and rejects any other value:
await ts.find("src/index.ts").addVariable("counter", "0", { export: true, kind: "let" });// -> export let counter = 0;Import forms and barrel files
Section titled “Import forms and barrel files”Each import form has its own op. Running one twice changes the file once:
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";The re-export ops build barrel files. Generate a file, then export it from 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 and addExportAll reject a name the file already exports by any route (another
re-export, an export * as, or an exported declaration), because two exports sharing a name
are invalid TypeScript. addReExport and removeExport match by source name, so
export { name as Y } already counts. removeExport never touches a local export { x } or
an export *, and each export * form only removes itself.
Name validation. Every op checks name before it touches the file. It must be a plain
ASCII JavaScript identifier (^[A-Za-z_$][A-Za-z0-9_$]*$), not a reserved or
strict-mode-restricted word (class, await, eval, arguments, …), and not __proto__,
constructor, or prototype. A rejected name leaves the file unchanged, and removeImport
rejects it even when there was nothing to remove.
Collision rules. The add* ops fail loud on a name collision with an existing value
declaration or import binding, because two value declarations sharing a name is invalid
TypeScript. An import binding is any import form: named, default (import X from "m") and
namespace (import * as X from "m"), type-only ones included. 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.
Sorted imports
Section titled “Sorted imports”addImport appends by default. Pass { position: "sorted" } to keep a file’s imports in order:
the name goes into its alphabetical place inside the braces, and a new declaration goes next to
its folder siblings, before the first of them that sorts after it.
// 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() sorts the whole file instead: its import declarations by module, and the names
inside each declaration’s braces, in the same order. A multi-line brace layout is kept.
Both follow the same rules:
- Sorting is case-insensitive, and package specifiers (
react,node:fs,@/lib) sort ahead of relative ones. - A module’s folder siblings are the existing imports that share the most leading path segments
with
from. In a file with no imports, the declaration goes where the unsorted form puts it. - Sorted
addImportnever moves existing imports, so a file that was out of order stays out of order around the new line. A new declaration goes above a comment attached to the import it precedes. - Without the option,
addImportbehaves as before. Any otherpositionvalue rejects and leaves the file unchanged. - Inserted imports match the file’s quotes and semicolons, like every insertion.
sortImportsonly reorders. It never combines two declarations of one module, removes an unused import, or reformats one, and on a sorted file it writes nothing.- Declarations move only within a run of consecutive imports. A blank line, a comment, a
side-effect import (
import "./polyfills";, whose position changes behaviour), or a declaration with a comment attached ends the run, and nothing crosses it. Braces that contain a comment keep their order.
Nested arrays: addArrayElement
Section titled “Nested arrays: addArrayElement”Most generators create a file and then register it somewhere: a provider in appConfig, a
guard on a route, a module in @NgModule. addArrayElement(locator, element) makes that edit.
The locator names a root and then a path from that root to the array:
| Root | Starts at |
|---|---|
{ variable: "appConfig", path } |
The initializer of that top-level variable |
{ call: "bootstrapApplication", argument: 1, path } |
That argument of the only call to bootstrapApplication |
{ decorator: "NgModule", path } |
The argument of the only @NgModule(...) |
Each path segment is an object key ("providers") or a match such as { path: "admin" },
which selects the only array element whose path property is "admin". There are no numeric
indices, because an index points at the wrong element once someone reorders the array. At each
step the op looks through as, satisfies, <T> assertions, ! and parentheses.
await ts .find("src/app/app.config.ts") .addImport("provideHttpClient", "@angular/common/http") .addArrayElement({ variable: "appConfig", path: ["providers"] }, { expression: "provideHttpClient()" });// before: providers: [provideRouter(routes)],// after: providers: [provideRouter(routes), provideHttpClient()],A match walks into one entry of a list. Here the admin route has no providers key yet, so
the op creates it:
await ts .find("src/app/app.routes.ts") .addImport("AdminGuard", "./admin/admin.guard") .addArrayElement({ variable: "routes", path: [{ path: "admin" }, "providers"] }, "AdminGuard");// beforeexport const routes: Routes = [ { path: "", component: HomeComponent }, { path: "admin", component: AdminComponent, },];
// afterexport const routes: Routes = [ { path: "", component: HomeComponent }, { path: "admin", component: AdminComponent, providers: [AdminGuard], },];The op creates a missing key ({} becomes { providers: [X] }) and the next missing argument
of a call or decorator (bootstrapApplication(App) becomes
bootstrapApplication(App, { providers: [X] })). It rejects, and leaves the file unchanged,
when:
- the root does not exist, or a match selects nothing;
- the root or a match selects more than one node;
- the argument is past the next one, such as
argument: 2onbootstrapApplication(App); - the missing key could already come from a spread (
{ ...base }), a computed key, or a getter or setter, since a new key might shadow it.
element is an identifier, or { expression: "..." } for code. The expression must parse as
exactly one expression, so it cannot close the array or add a statement. The op inserts it
verbatim otherwise, so never build it from consumer input. An element that is already in the
array is a no-op: elements are compared token by token, and spacing, comments and trailing
commas don’t count. The new element follows the list’s layout. A one-line list stays on one
line, a multi-line list gets a new line at the last entry’s indentation, and a trailing comma
stays. The op adds no import, so pair it with addImport.
Class properties: addClassProperty
Section titled “Class properties: addClassProperty”addClassProperty(className, name, initializer, options?) writes
{scope }{readonly }name = initializer; into an existing class. options is
{ scope?: "public" | "protected" | "private"; readonly?: boolean }, and an omitted option
writes nothing.
await ts .find("src/app/user.service.ts") .addImport("inject", "@angular/core") .addClassProperty("UserService", "http", "inject(HttpClient)", { scope: "private", readonly: true });// beforeexport class UserService { private readonly cache = new Map<string, User>();
load(id: string) { return this.cache.get(id); }}
// afterexport class UserService { private readonly cache = new Map<string, User>(); private readonly http = inject(HttpClient);
load(id: string) { return this.cache.get(id); }}The property goes after the class’s last property, which keeps properties above the
constructor and methods. A class without properties gets it as its first member. The file must
have exactly one class named className. A property with the same declaration is a no-op, and
any other member named name, a method included, rejects. initializer must parse as one
expression and is inserted verbatim, like { expression }. The op adds no import.
The React dialect
Section titled “The React dialect”@pbuilder/sdk/react mutates .tsx and .jsx files, and find() requires one of those two
extensions on the path. An extensionless path is rejected, never normalized, and any other
extension rejects with .tsx and .jsx are the only extensions the React dialect supports; a
.ts path points you to @pbuilder/sdk/typescript. The handle carries every
TypeScript dialect op (addImport, addDefaultImport,
sortImports and the rest) plus JSX and component 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 |
|---|---|
setJsxProp(target, prop, value?) |
Sets a prop on the one element target names. value takes three forms: '"hi"' (string), '{count}' (expression), or omitted (boolean shorthand). |
removeJsxProp(target, prop) |
Removes a prop from the one element target names. An absent prop is a no-op, and a {...spread} is never touched, even when it may carry the prop. |
addJsxChild(target, jsx) |
Appends jsx, one JSX element or fragment, as the last child of the one element target names. A child with the same text already there is a no-op. |
wrapJsxElement(target, wrapper) |
Wraps the one element target names in wrapper, a self-closing JSX element whose props are kept. A no-op when the target is already directly inside that tag. |
renameJsxElement(target, tag) |
Renames the one element target names, opening and closing tag together. Imports are not touched. |
addHookCall(component, statement) |
Inserts one statement into the body of the component named component, after its leading hook calls. An identical statement already there is a no-op. |
addComponentProp(component, name, type, opts?) |
Adds name: type (name?: with { optional: true }) to the component’s props type and to its destructured parameter. The same name and type is a no-op; a different type rejects. |
The import ops are the TypeScript dialect’s own, with the same rules, so
addImport("React", "react") prints import { React } from "react". For
import React from "react", use addDefaultImport("React", "react").
Picking the element
Section titled “Picking the element”Every JSX op edits exactly one element, and rejects when zero or several elements match.
target is a tag name ("Button"), or an object that adds props
to narrow the match. A string prop value must equal the attribute’s initializer text as written
in the source, quotes included; true matches the prop with any value, shorthand included:
// src/App.tsx before:// <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 />A reject message names the tag and the prop names, never the prop values.
Children, wrappers and renames
Section titled “Children, wrappers and renames”addJsxChild puts the child on its own line at the siblings’ indentation, or inline when the
element’s content is inline. A self-closing target becomes an open/close pair. jsx must be
exactly one JSX element or fragment; anything else rejects without echoing it.
// src/App.tsx before:// <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 takes a self-closing wrapper and puts the target inside it:
// src/main.tsx before: root.render(<App />);await react.find("src/main.tsx").wrapJsxElement("App", "<QueryProvider client={queryClient} />");// -> root.render(<QueryProvider client={queryClient}><App /></QueryProvider>);renameJsxElement changes the opening and closing tag together. Nested elements with the same
tag are left alone, and renaming to the same tag is a no-op:
// src/Toolbar.tsx before: <Button onClick={save}>Save</Button>await react .find("src/Toolbar.tsx") .addImport("PrimaryButton", "./ui") .renameJsxElement("Button", "PrimaryButton");// -> <PrimaryButton onClick={save}>Save</PrimaryButton>None of these ops adds an import, so pair them with addImport.
Component ops
Section titled “Component ops”addHookCall and addComponentProp take a component name. The op looks for it among the
file’s top-level function Name declarations, exported or not, and top-level const Name =
arrow functions or function expressions. A wrapped component (memo(...), forwardRef(...))
is not found, and zero or several matches reject.
addHookCall(component, statement) inserts statement after the component’s leading hook
calls (statements that call useX(...) or React.useX(...)), or first when there are none,
at the body’s indentation. statement must be exactly one statement. An identical statement
already in the body is a no-op, and an expression-bodied arrow (() => <div />) rejects
because it has no statements to add to:
// src/Form.tsx before:// 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?) adds name: type, or name?: type with
{ optional: true }, to the component’s props type. When the props parameter is destructured,
it also adds name to the destructuring, before a ...rest element. The props type is the
first parameter’s inline type literal, or an interface or type literal declared in the same
file and referenced by name. An imported or untyped props type rejects. A component without
parameters gets ({ name }: { name: type }). The same name with the same type text is a no-op,
and a different type rejects. type must be exactly one type:
// src/Card.tsx before:// 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) { ... }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.
addImportfollows the TypeScript collision rules: anamealready bound some other way in the file rejects witha value or import binding named "X" already exists. There is no alias argument to route around it; renaming the existing binding or choosing a differentnameis on you.
setJsxProp’s value, addJsxChild’s jsx, wrapJsxElement’s wrapper, addHookCall’s
statement and addComponentProp’s type are emitted verbatim into executable code, with the
same trust boundary as the TypeScript source strings. The SDK checks that each is one JSX node,
one statement or one type, and rejects it without echoing it when it is not. It does no escaping
or sanitisation. By contrast, component, tag and prop names are validated name arguments and are
not a trusted-code channel.
.jsx files
Section titled “.jsx files”A .jsx file gets the same handle as a .tsx one. The SDK parses it as JavaScript with JSX, so
the JSX, hook and import ops behave as they do on .tsx:
// src/App.jsx before:// 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 />} />');A .jsx file cannot hold TypeScript syntax, so three ops reject on it at call time and leave the
file unchanged: addTypeImport, addComponentProp, and addClassProperty when you pass scope
or readonly. Without those two options, addClassProperty works on .jsx too. The error names
the op and says the edit needs .modify(), for example
addComponentProp is not available for .jsx files.
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), so you don’t need
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), which every dialect handle has. Pick the tool by the edit:
| You need to… | Use |
|---|---|
| Add, remove or sort imports, add a top-level declaration, append to an array, add a class property, edit one JSX element’s props, children, wrapper or tag, add a hook call or a prop to a React component | The dialect’s named ops |
Make any other structural change to a .ts, .tsx or .jsx file |
.modify(fn) on the dialect handle |
Register a provider, controller, module import or export in a NestJS @Module |
The NestJS dialect |
Register a component, provider or route, or add a signal or inject() member in Angular |
The Angular dialect |
| Edit a JSON, YAML or CSS file | The JSON, YAML or CSS dialect |
| Edit a file type no dialect covers, such as Markdown | find from @pbuilder/sdk/commons |
The callback receives only the AST: a ts-morph SourceFile for both dialects on this page,
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 and .jsx. 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)”addArrayElement takes only code you wrote yourself. When the
element comes from consumer input, build it in .modify() so you control the quoting. This
factory adds a path from its input to an exported routes array, and does nothing when the path
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)”addJsxChild skips a child only when the same text is already there. This factory uses
.modify() to skip the <Route> when any route already has that path, whatever its
element, and imports the page in the same write:
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 and 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, so 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, because 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, .tsx, .jsx, .json, .jsonc, .yaml, .yml and .css today. Any other
file (a Markdown index, an .scss stylesheet, an .env file) 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.
The example below uses a JSON file because the round trip is short. In a real factory, edit JSON with the JSON dialect, which keeps the file’s formatting and comments.
{ "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”- JSON dialect and YAML dialect: named ops for JSON, JSONC and YAML files that keep comments and formatting.
- CSS dialect: set and remove declarations in a
.cssfile, such as design tokens in a Tailwind@themeblock. - NestJS dialect: register classes in a NestJS
@Module. - Angular dialect: register components, providers and routes, and
add signal and
inject()members. - 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.
Support for more file types such as HTML (each backed by its own parser/AST library), and
framework-aware dialects for Vue and Svelte, are planned. Until then, edit those files with find from
@pbuilder/sdk/commons.