Skip to the document

Template syntax and data

Templates covers the everyday case: blanks in a document, filled in from a form in the app. This page is the rest: the full placeholder syntax, how placeholders behave inside frontmatter and block YAML, the values a vault or a folder can set once, and filling templates from JSON on the command line, which is how a spreadsheet, a CRM or a script feeds one.

Placeholder syntax

Placeholders use Mustache syntax. A document with at least one of them outside code gets a Use template button in the top bar and a Template badge in the sidebar, so it stands apart from the documents made from it, which often share its name.

---
title: "{{client.name}} proposal"
---

# Proposal for {{client.name}}

Dear {{client.contact}},

| Item | Price |
| --- | --- |
{{#items}}
| {{name}} | {{price}} {{currency}} |
{{/items}}

{{#discount}}
A discount of {{discount}} applies.
{{/discount}}
{{^signed}}
This proposal isn't signed yet.
{{/signed}}

```quote-table
currency: "{{currency}}"
rows: "{{items}}"
```
Tag What it does
{{name}} The value of name. Dots reach inside objects: {{client.name}}, and a number picks from a list: {{items.0.name}}
{{#items}}{{/items}} Repeats what's between for each item of a list, with the item's fields in reach by their own names. For a value that isn't a list, shows what's between once when it's set, not empty and not false
{{^items}}{{/items}} Shows what's between when the value is missing, false, empty text or an empty list
{{.}} The item itself, inside a section over a list of plain values
{{! note }} A comment, left out of the new document

A section tag or comment on a line of its own takes the whole line with it, so wrapping table rows, list items or whole blocks leaves no blank lines behind. Inside a section, a name the item doesn't have is looked up outside it, so {{currency}} above reads the document's currency on every row.

Values go in as they are. Nothing is escaped, so a value can carry markdown. {{{name}}} and {{&name}} work too, and mean the same as {{name}}.

Placeholders in frontmatter and blocks

In frontmatter and in a block's YAML, a placeholder inside some text fills in as text: note: Prepared for {{client.name}}. A placeholder that is the whole value takes the value with its type, so rows: "{{items}}" becomes a real YAML list of rows and qty: "{{count}}" a number. Quote it: rows: {{items}} fills in the same way, but until it's filled YAML reads the braces as a mapping and the block shows an error.

Values that contain YAML syntax, such as a colon or a #, are quoted for you.

What stays as it is

Code never counts: fences in a code language (```ts, ```markdown, ```text), fences with no language, ts-block and inline code. That's how this page shows placeholders without becoming a template itself. Double braces that don't hold a name, like JSX's style={{ color: "red" }}, are text too.

Values the vault and the template already have

A placeholder doesn't need data when something else sets it. Values come in layers, each over the one before: the vault's values in .vault/vault.json, then the frontmatter of the INDEX.md of each folder the template is in, outermost first, then the template's own frontmatter, then the data you pass. So a vault can set organisation once, a Lorem Ipsum Ltd folder can set client.name for everything in it, a template can set currency: GBP, and a form or a JSON record only has to carry what's different. A folder's values are its page's frontmatter: open the folder's page in Source view and add them there. Frontmatter that still holds a placeholder, like title: "{{client.name}} proposal", is something to fill rather than a value, so it's left out. The theme's cover, header and footer read the same layers, the document's frontmatter over its folders' and the vault's; see vault.json and frontmatter.

A field with a value from those layers is optional: plicine template fields shows its default, the JSON Schema carries it as default, --example and the app's form start from it, and apply uses it for anything the data leaves out.

Filling one from the command line

plicine template fields says what a template needs:

plicine template fields proposals/proposal-template.md
client          object  required  line 2
client.name     text    required  line 2
client.contact  text    required  line 7
items           list    required  line 11
items[].name    text    required  line 12
items[].price   text    required  line 12
currency        text    required  line 12
discount        text    optional  line 15
signed          flag    optional  line 18

--example prints data in the right shape with empty values, to fill in, and --schema prints a JSON Schema for it. plicine template apply does the filling:

plicine template fields proposals/proposal-template.md --example > lorem.json
plicine template apply proposals/proposal-template.md --data lorem.json --out proposals/lorem-ipsum.md

--set path=text and --set-json path=json put in single values, on top of --data or instead of it. --out - prints the document instead of writing it.

The JSON input

This is the contract for anything that feeds a template, such as an adapter that reads a spreadsheet:

  • One JSON object is the data for one document. Its keys are the placeholder names, nested the way the dots say: {{client.name}} reads { "client": { "name": "…" } }. A section over a list reads a JSON array of objects.
  • A JSON array of objects, or JSON Lines (one object per line), makes one document each. --out must then be a pattern that names each file from its data, such as "proposals/{{client.name}}.md". Slashes and other characters a file name can't hold become - in the name, and .md is added when it's left off.
  • --data - reads from stdin, so an adapter can stream records straight in.
  • plicine template fields --schema is the JSON Schema that data should match, and --json gives the fields, schema and example together for a tool to read.
{
  "client": { "name": "Lorem Ipsum Ltd", "contact": "Dolor Sit" },
  "currency": "GBP",
  "items": [
    { "name": "Lorem", "price": 1000 },
    { "name": "Ipsum", "price": 200 }
  ],
  "discount": "10%",
  "signed": false
}

Everything is checked before anything is written. A value the data doesn't have, two records naming one file, or a file that's already there stops the whole run with exit code 1 and a list of what's wrong. --allow-missing writes the documents anyway, keeping each missing placeholder as it was written so the document can be filled in later, and --force overwrites existing files. With --json the command prints { "ok": true, "documents": [{ "file", "missing", "warnings" }] }, or { "ok": false, "problems": [...] }.

A spreadsheet adapter only has to turn rows into those objects. A sketch in TypeScript, for a CSV whose headers are field paths like client.name (it splits on commas, so it's not for quoted cells):

// csv-to-jsonl.ts: bun csv-to-jsonl.ts clients.csv | plicine template apply proposal-template.md --data - --out "out/{{client.name}}.md"
const [header, ...rows] = (await Bun.file(process.argv[2]!).text()).trim().split("\n").map((line) => line.split(","));
for (const row of rows) {
  const record: Record<string, unknown> = {};
  header!.forEach((path, i) => {
    const keys = path.trim().split(".");
    let target = record;
    for (const key of keys.slice(0, -1)) target = (target[key] ??= {}) as Record<string, unknown>;
    target[keys.at(-1)!] = row[i]?.trim() ?? "";
  });
  console.log(JSON.stringify(record));
}

The details of every flag are in the CLI reference.