Template DSL

The builder DSL is a compact, JavaScript-like language for describing PDF templates. This guide walks through the concepts so you can read and hand-write DSL yourself. For the exhaustive function-by-function reference, see the template author skill file.

What the DSL is

A template is a layout with {{variable}} placeholders. You write it once, then render it any number of times with different data. Instead of hand-writing the verbose JSON document tree, you call small builder functions — doc(), page(), col(), table() — that compose into the same tree. The DSL output is roughly 75–95% smaller than the equivalent JSON, which is why it is also the format AI agents generate.

A DSL "script" is plain JavaScript evaluated in a sandbox. It must define two top-level constants:

ConstantPurpose
templateThe document, built with doc(...). Required.
sampleDataExample data used to fill the placeholders when no data is supplied to the render call. Recommended.
const template = doc( { size: "A4" }, page(col(s("Hello {{name}}"))) ); const sampleData = { name: "World" };

Core building blocks

Everything is built from a handful of primitives. Layout flows top-to-bottom by default; rows lay their children out left-to-right.

FunctionWhat it does
doc(opts, ...sections)Creates the document. Options: size, title, padding, currency, styles.
page(...kids)A page. doc() auto-wraps loose content in one, so it is often optional.
col(width?, ...kids)A vertical stack. First arg can be a width ("50%"), a class (".body"), or a style object.
r(...kids)A row — children flow horizontally.
s(text)A span — a run of text. Accepts a class or style object: s(".label", "Total").
text(...spans)An inline text block — children flow on one wrapped line. Use for mixed styles.
hdr(...) / ftr(...)Header / footer, repeated at the top / bottom of every page.
img(src, w, h)An image. Needs a URL and explicit width and height in points.

Units are points (1pt = 1/72 inch). An A4 page is 595 × 842pt.

Variables, loops, and conditionals

Placeholders are written {{ ... }} and resolved against your render data.

SyntaxMeaning
{{fieldName}}Simple value from your data.
{{customer.name}}Nested value via dot notation.
{{qty * price}}An expression — arithmetic, comparisons, and ternaries are supported.
{{total | currency}}A filter. currency and number format values; currency:AUD overrides the code.
each("item in items", ...)Loop — repeats its children once per array element.
when("discount > 0", ...)Conditional. Pair with elseWhen(...) / otherwise(...). Negate with !.

Inside a loop you also get {{@index}} (0-based), {{@first}}, and {{@last}}. ISO date strings in date-like fields are auto-formatted — send "2026-04-10" and it renders as 10 April 2026.

text(each("a in authors", s("{{a.name}}"), when("!@last", s(", ")) )) // → "Marie Curie, Alan Turing, Ada Lovelace"

Helpers: atoms, molecules, organisms

On top of the primitives, the DSL ships a library of higher-level helpers so you rarely assemble common patterns by hand. They come in three tiers:

Atoms — inline text shortcuts: bold(), italic(), underline(), mono(), colored(), link(), muted(), plus hr() and gap() for spacing.

Molecules — small composite pieces: lv() (label-value row), slv() (stacked label-value), addr() (address block), th() / td() (table cells), bullet().

Organisms — full sections: minHdr() (header), lvGrid() (label-value grid), addrs() (two-column from/to), table() (looped data table), totals() (totals section), terms() (notes block), ftrPages() (footer with page numbers).

Every text-bearing parameter of a molecule or organism accepts a plain string, a single inline element (bold("Paid")), or an array mixing both — so you never need to drop to raw s() just to add emphasis.

Styling elements

Most builders accept an inline style object as their first argument, e.g. col({ "font-size": 12, width: "50%" }, ...). You can also define reusable classes in doc({ styles: { ... } }) and reference them by name (".label").

Style property names are kebab-case, not camelCase. Even though the DSL is JavaScript-like, properties follow CSS conventions and must be quoted: { "font-size": 12 }, not { fontSize: 12 }. camelCase keys are silently ignored and the element renders with the default.

Common properties: font-family, font-size, font-weight, color, background-color, align, width, margin, padding, border, border-color, border-radius. Spacing properties take either a single number or a [top, right, bottom, left] array. The full property table — plus absolute positioning, watermarks, and the standard style kit — lives in the skill file.

To change something document-wide (e.g. the default font), edit the "." selector — the cascade root every element inherits from — rather than enumerating each class:

doc( { size: "A4", styles: { ".": { "font-family": "NotoSans", "font-size": 10 } } }, page(col(s("Inherits NotoSans 10pt"))) );

A complete example

This is a full invoice template. cols defines the table column widths once and is shared by table() and totals() so the totals rows line up with the table columns.

const cols = [ ["Description", "1fr"], ["Qty", "auto", "center"], ["Price", "auto", "right"], ["Amount", "auto", "right"], ]; const template = doc( { size: "A4", title: "Invoice {{invoiceNumber}}" }, minHdr("Invoice", "{{company.name}}"), lvGrid([ ["Invoice #:", "{{invoiceNumber}}"], ["Date:", "{{date}}"], ["Due:", "{{dueDate}}"], ]), gap(8), addrs( { label: "From", lines: ["{{company.name}}", "{{company.address}}"] }, { label: "Bill to", lines: ["{{customer.name}}", "{{customer.address}}"] } ), gap(8), table(cols, "item in items", [ ["{{item.description}}", "1fr"], ["{{item.qty}}", "auto", "center"], ["{{item.price | currency}}", "auto", "right"], ["{{item.amount | currency}}", "auto", "right"], ]), totals( [ ["Subtotal", "{{subtotal | currency}}"], ["Tax (10%)", "{{tax | currency}}"], ["Total Due", "{{total | currency}}", true], ], cols ), ftrPages("{{company.name}}") ); const sampleData = { invoiceNumber: "INV-2026-001", date: "2026-03-30", dueDate: "2026-04-29", company: { name: "Acme Corp", address: "123 Main St" }, customer: { name: "Jane Smith", address: "456 Oak Ave" }, items: [ { description: "Consulting", qty: 40, price: 150, amount: 6000 }, { description: "Travel expenses", qty: 1, price: 450, amount: 450 }, ], subtotal: 6450, tax: 645, total: 7095, };

Rendering your DSL

Send the script as the dsl field to POST /api/v1/preview while you author — it is free, deterministic, and about a second. When the value of data is omitted, the script's sampleData is used.

curl -X POST https://makespdf.com/api/v1/preview \ -H "Authorization: Bearer mpdf_your_api_key" \ -H "Content-Type: application/json" \ -d '{"dsl": "const template = doc(page(col(s(\"Hi {{name}}\"))));\nconst sampleData = { name: \"World\" };", "data": {"name": "Ada"}}' \ -o output.pdf

Once a template is finalized, save it with POST /api/v1/templates and render it in production with POST /api/v1/render using { templateId, data }. /preview is the free authoring endpoint; /render is the billed production endpoint. See the API Reference for both.

Going deeper

This page covers the concepts. The template author skill file is the canonical, exhaustive reference — every function, the full style-property table, fillable form fields, barcodes and QR codes, vector drawing, watermarks, and per-document recipes. It is the same material formatted for AI coding assistants; you can also hand it to Claude, Cursor, or Codex as context (see AI & Agent Setup).