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.
js
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) or img({ src, alt?, width?, height? })An image. The positional form needs a URL and explicit width and height in points; the object form carries alt text and allows the dimensions to be omitted when the intrinsic size is unknown.

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.

js
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:

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

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

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