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

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.

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.

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 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", and rejects any other value:

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

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

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 addImport never 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, addImport behaves as before. Any other position value rejects and leaves the file unchanged.
  • Inserted imports match the file’s quotes and semicolons, like every insertion.
  • sortImports only 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.

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");
// before
export const routes: Routes = [
{ path: "", component: HomeComponent },
{
path: "admin",
component: AdminComponent,
},
];
// after
export 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: 2 on bootstrapApplication(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.

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 });
// before
export class UserService {
private readonly cache = new Map<string, User>();
load(id: string) {
return this.cache.get(id);
}
}
// after
export 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.

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

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.

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.

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} /> plus setJsxProp("Button", "onClick", "{safe}") prints <Button {...rest} onClick={safe} />, and safe wins even if rest also supplies an onClick.
  • Collision rejects. addImport follows the TypeScript collision rules: a name already bound some other way in the file rejects with a value or import binding named "X" already exists. There is no alias argument to route around it; renaming the existing binding or choosing a different name is on you.

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.

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.

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.

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

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:

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.

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:

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 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, setBodyText and similar methods is inserted verbatim, so sanitize anything derived from user input.

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.

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.
  • 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 .css file, such as design tokens in a Tailwind @theme block.
  • 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 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.

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.