Skip to content

Templates

create()’s path and template are both template strings, rendered against the same single options object. This page covers the language you write inside them.

Rendering happens in the engine at builder execute time, never in the SDK. Your factory ships the template and options verbatim — so template errors (a typo’d option name, a type mismatch) surface at run time, and the test harness stores the raw {= .name =} text, not rendered output.

schematics/hello/factory.ts
import { create } from "@pbuilder/sdk/commons";
create("src/{= .name | dasherize =}.ts", {
template: "export const {= .name | classify =} = 1;\n",
options: { name: "myThing" },
});

renders to path src/my-thing.ts with content:

export const MyThing = 1;

Three pieces are in play:

You pass What it does
path (first argument) A template string, rendered to produce the output file path.
template A template string, rendered to produce the file’s byte content.
options The data context — the single set of values shared by both renders.

The templateFile overload — create(path, { templateFile, options }) — reads a package-local file instead of an inline template string; everything on this page applies to it unchanged.

Templates use the delimiters {= and =}not the more familiar {{ }}. Inside the delimiters you reference your options through a leading dot:

You write It means
{= .name =} the name option
{= .user.address.city =} walk nested objects: useraddresscity
{= . =} the current context itself (useful inside range/with)

Array- and object-valued options pass as plain native values — you never hand-encode them.

Two small details:

  • Emit a literal {=. There is no backslash escape. To print the delimiter itself, wrap it in a quoted string action: {= "{=" =} renders the literal text {=.
  • Comments. {= /* a note to yourself */ =} never produces output.

Pipes transform a string value. You apply one with |:

Pipe "userProfile" becomes Rule
upper USERPROFILE uppercase every letter
lower userprofile lowercase every letter
capitalize UserProfile uppercase the first letter only
dasherize user-profile split into words, lowercase, join with -
underscore user_profile split into words, lowercase, join with _
camelize userProfile PascalCase, first word lowercased
classify UserProfile PascalCase including the first word

Chain pipes left to right — order is observable:

{= .name | underscore | upper =}

"MyComponent"my_componentMY_COMPONENT.

Three rules to remember:

  • Pipes are a closed set — these 7 and no others; an unknown pipe name is an error that lists the valid names.
  • Pipes take strings only. Applying a pipe to a number (or any non-string) is an error naming the pipe and the offending kind — the engine never silently stringifies a value.
  • classify does not singularize. Unlike the classify in Rails or Angular, a plural stays plural — users becomes Users, not User. If you need the singular, pass the singular in your options. There is no pluralize pipe either.

Blocks are opened by a keyword and closed by {= end =} — there are no braces {}. There is no for, no .map(), no .forEach(), no arrow function:

// JavaScript
methods.map(m => ` ${m.name}() {}\n`).join("")
{= range .methods =} {= .name =}() {}
{= end =}

with methods: [{ "name": "load" }, { "name": "save" }] this emits one line per element. Array order is preserved exactly — the engine never reorders your list.

Inside the block, the dot . becomes the current element. There is no parameter name unless you ask for one — . is the item, and .name reads the name field of the current item:

{= range .items =}[{= . =}]{= end =}

over ["x", "y"][x][y].

Want the index and the value? Declare them with $ variables and :=:

{= range $i, $v := .items =}{= $i =}:{= $v =} {= end =}

over ["a", "b"]0:a 1:b .

JavaScript Template
for (const m of methods) { … } {= range .methods =} … {= end =} (item is .)
items.map(x => …) {= range .items =} … {= end =}
items.forEach((v, i) => …) {= range $i, $v := .items =} … {= end =}
Object.entries(obj).map(([k, v]) => …) {= range $k, $v := .obj =} … {= end =}

Ranging over an object iterates its keys in sorted order (deterministic); ranging over an array keeps the array’s own order.

Putting it together — one options object driving both the path and a loop in the content:

create("src/{= .name | dasherize =}/{= .name | dasherize =}.component.ts", {
template:
"export class {= .name | classify =}Component {\n" +
"{= range .methods =} {= .name =}() {}\n{= end =}}\n",
options: {
name: "userProfile",
methods: [{ name: "load" }, { name: "save" }],
},
});

Path — src/user-profile/user-profile.component.ts

export class UserProfileComponent {
load() {}
save() {}
}

Conditionals: if, but the operators come first

Section titled “Conditionals: if, but the operators come first”

The single biggest surprise. A comparison is a function call with the operator name first, then its operands — the opposite of JavaScript’s infix style:

JavaScript Template
a === b eq a b
a !== b ne a b
a < b lt a b
a > b gt a b
a && b and a b
a || b or a b
!a not a

So an equality check is:

if (kind === "primary") { … } // JavaScript
{= if eq .kind "primary" =}…{= end =} // template — "eq" first, then the two operands

Combine conditions by nesting the calls in parentheses — there is no && / ||:

if (a > 1 && b < 5) { … } // JavaScript
{= if and (gt .a 1.0) (lt .b 5.0) =}…{= end =} // template

else and else if:

{= if eq .kind "primary" =}main
{= else if eq .kind "secondary" =}alt
{= else =}other
{= end =}

Two data helpers round out the operator set — all written operator-first as function calls, never piped:

Operator Meaning JS equivalent
len length of a string, array, or object .length / Object.keys().length
index element or key lookup arr[0] / obj["k"]
{= if gt (len .items) 0 =}has items{= end =}
{= index .items 0 =}

The guaranteed core is eq, ne, lt, gt, and, or, not, len, index. Build every condition from these and it will keep working across engine versions. (le and ge work today but are outside the guaranteed core — for a stable guarantee, invert with not (gt …) / not (lt …).)

Truthiness — almost JS-falsy, with one trap

Section titled “Truthiness — almost JS-falsy, with one trap”

{= if .x =} with no operator tests whether .x is “empty”. false, 0, "", and null are falsy, as in JS — but an empty array or object is falsy here too, where JS treats both as truthy. That’s actually convenient — to check “does this list have items” you write {= if .items =} directly, with no .length.

with rebinds the dot . to a nested object for the block, so inside you write .name and .email instead of .user.name / .user.email:

{= with .user =}{= .name =} ({= .email =}){= end =}

$name declares a variable with := (same as a range index/value). Assign a piped value once and reuse it instead of repeating the pipe:

{= $base := .name | dasherize =}
{= $base =}.component.ts
{= $base =}.component.spec.ts

The path argument uses the same language as template — the same fields, pipes, and sandbox. That’s how you get a cased directory and filename from one option:

create("src/{= .name | dasherize =}/{= .name | classify =}.ts", { template, options });

with name: "userProfile"src/user-profile/UserProfile.ts.

The rendered path is checked for containment: a path that tries to escape the workspace (for example via ../) is rejected with a typed error and zero files are written.

A line that contains only a control directive — range, if, else, end, with, an assignment, or a comment — is removed whole (its indentation and trailing newline) before rendering. So this template:

{= range .methods =}
{= .name =}
{= end =}

produces one clean line per method, with no blank lines from the range/end lines. A genuinely empty line — no directive on it — is always preserved, and a line holding a field or pipe expression ({= .name =}) is never trimmed.

Every failure is a typed, positioned error (it names a File:Line:Column) and, on failure, no files are written — the engine fails the whole render closed rather than producing a partial file. A mistyped option name gives you the exact line and column of the reference. Because the SDK never renders, all template errors surface at run time, never at the moment create() is called. See error handling for the full taxonomy.

For anything bigger than a couple of files, don’t write one create() per file — keep a folder of template files and mirror it with scaffold(). The rest of the mutation surface lives in mutation verbs.