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.
The syntax comes from Go’s text/template, not JavaScript template literals. {= .name =}
is not ${name}; loops are range, not .map(); comparisons are written operator-first
(eq a b), not infix (a === b). JavaScript’s ===, &&, ||, !, <, > do not exist
in a template. Guessing from JS habits will mislead you — the sections below map each JS pattern
to its template form.
Where rendering happens
Section titled “Where rendering happens”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.
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.
Variables and nested access
Section titled “Variables and nested access”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: user → address → city |
{= . =} |
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.
A key that is present but null does not render as blank — it fails with a typed error.
This is deliberate: a null in your output is almost always a mistake, so the engine stops rather
than writing <no value>.
The seven pipes
Section titled “The seven pipes”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_component → MY_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.
classifydoes not singularize. Unlike theclassifyin Rails or Angular, a plural stays plural —usersbecomesUsers, notUser. If you need the singular, pass the singular in your options. There is nopluralizepipe either.
Loops: range, not for or .map()
Section titled “Loops: range, not for or .map()”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:
// JavaScriptmethods.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 operandsCombine 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 =} // templateelse 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 …).)
Pipes and operators are different tools: dasherize is a pipe (.name | dasherize); eq
is an operator (eq .kind "primary"). Operators are never piped.
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.
The number-type trap
Section titled “The number-type trap”Number literals must match the numeric type of what you compare against, or the render errors:
- Option values that are JSON numbers arrive as decimals. Compare them with a decimal
literal:
eq .count 2.0works;eq .count 2errors. lenreturns an integer. Compare it with a whole-number literal:gt (len .items) 0works;gt (len .items) 0.0errors.
In JavaScript 2 === 2.0 is true and you never think about it. Here the two literal forms are
distinct types. Rule of thumb: decimal literal for an option value, whole number for len.
with and variables
Section titled “with and variables”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.tsNot available: sub-templates. define, template, and block are blocked at parse time —
a template using them fails with a typed error and writes nothing. This is a deliberate safety
limit, not an oversight.
Templating the output path
Section titled “Templating the output path”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.
Whitespace: clean by default
Section titled “Whitespace: clean by default”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.
When something goes wrong
Section titled “When something goes wrong”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.
Next steps
Section titled “Next steps”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.