---
title: plates-render
part_of: '[plates](/README.md)'
audience: public
---
# plates-render
The document-to-HTML half of [`plates`](https://github.com/diaryx-org/plates):
**what a published page looks like**.
This crate reads nothing and resolves nothing. It is handed source text and a
description of the site that text belongs to, and it gives back HTML — which is
what lets one rendering run in a command-line build, in a sync server, and in an
edge worker without three implementations quietly disagreeing about what a site
looks like.
```
prov plates plates-render
───── ────── ─────────────
which where it lands, what it looks
documents ──▶ what ships with it ──▶ like
```
[`plates`](https://github.com/diaryx-org/plates/tree/main/plates) is the layer
above: it walks a `prov::Workspace`, decides which documents a site holds and
where each one lands, and calls this.
API documentation: [docs.rs/plates-render](https://docs.rs/plates-render).
## Portable by contract
It must keep compiling for `wasm32-unknown-unknown`, which is a constraint
rather than a preference: no host functions, no filesystem, no entropy and no
clock. A caller that has those reads the files and passes the bytes in — a shell
arrives as *text*, never as a path, and the date formatter re-spells a time it
was given rather than asking what time it is. The `wasm` job in `cargo xtask ci`
is what enforces it.
## Three grammars, one parser
A body's grammar is its own — Markdown, Djot or HTML, read off the source
document's extension via `prov::ContentFormat` — and all three go through
[`twig`](https://github.com/diaryx-org/twig), which is the same parser an editor
over the same archive uses. One engine for three grammars is the whole reason
`content_format` can exist, and it is why a document parsed by the publisher and
the same document parsed by the editor cannot disagree.
Before parsing, `preprocess_custom_syntax` rewrites Diaryx's own spellings —
highlights (`==like this==`), spoilers (`||like this||`) and HTML embeds — into
raw HTML, skipping fenced and inline code so a document *explaining* the syntax
is not rewritten by it. The same syntax works in Markdown and Djot, deliberately:
someone who switches a vault's `content_format` should not find that
`==highlight==` stopped working. HTML bodies are returned untouched.
## Highlighting
With the `syntax-highlighting` feature, a third stage runs after twig: fenced
blocks tagged with a language are coloured, in Markdown, Djot and hand-written
HTML bodies alike. It is a pass over the *rendered HTML* rather than over an
AST, which is what lets one implementation cover all three.
The grammars are `two-face`'s, which are bat's: 213 of them, against the 75
`syntect` bundles on its own. The difference is not academic — the smaller set
has no Zig, Swift, TOML or TypeScript.
Two properties are worth knowing before you style anything:
- **The output is classed, not styled.** syntect can write `style="color:#…"`
on every span; this does not, because the colour would then be decided at
render time for a stylesheet that has both a light and a dark palette. What
it writes is a class per scope atom — `keyword.control.rust` becomes
`plates-keyword plates-control plates-rust` — and the colours live in the
stylesheet beside the site's own. Recolouring a language never means
recompiling anything.
- **An unknown language is not an error.** A fence tagged with something no
grammar answers to is returned byte for byte, still carrying the
`language-…` class twig wrote. Only blocks that were actually coloured gain
`plates-highlighted`, which is what the built-in sheet scopes its palette to.
A site with a language none of the 213 cover supplies its own grammar, as the
*text* of a `.sublime-syntax` file — this crate opens no files, so a caller with
one on disk reads it and passes the bytes in, exactly as it already does for a
shell template. `SiteOptions::syntaxes` is where they go; one that will not
parse is reported on `SiteRender::syntax_errors` and skipped, never fatal.
## `:vis[…]` regions
The gate decides which documents leave. This decides which *parts* of one does,
filtered against the same audience name, so a body and the site holding it can
never disagree about who a paragraph is for.
Every grammar spells a marked region as one node, and twig parses all three into
the same AST kind — a container with a name, a class list and children:
| Markdown | `:::vis{.family}` … `:::` | `vis` | `family` |
| Markdown (inline) | `:vis[text]{.family}` | `vis` | `family` |
| Djot | `{.vis .family}` on the line above `:::` | `""` | `vis family` |
| HTML | `<div class="vis family">` | `div` | `vis family` |
So the predicate is uniform and needs no per-grammar branch: **a region is a
container named `vis`, or one whose classes contain `vis`**, and its declared
audiences are its classes, less `vis` itself.
This is done through the parser rather than by scanning text, and the difference
was a disclosure bug: a scanner treats a marker inside a code span — in a
document explaining the syntax — as a real directive, and misses a real directive
whose fence a list has indented. twig parses the body it is going to render
anyway, so the spans are free and they are the spans the renderer will agree
with.
Filtering **fails closed**. A body whose grammar cannot be parsed, a region that
cannot be accounted for, a marker left standing after the walk: all are errors,
never a body returned unfiltered.
## Body templates
A template is an ordinary Markdown document. Its block structure is spelled with
the same generic directives `:::vis` is — the family twig parses and an editor
edits — and its values with a text directive:
```markdown
:::each{of=entries as=entry}
- [:val[entry.title]]({{entry.href}}) — :val[entry.date]
:::
```
| | |
|---|---|
| `:val[path]` | Insert a value. An absent path is empty; a path naming a list is an error. |
| `:::each{of=… as=…}` | Repeat the body once per item, binding each to a name. |
| `:::if{has=… not=…}` | Include the body when every condition holds. `has`/`not` are `prov`'s, read the same way; several attributes are an implicit *and*. |
| `:::group{as=…}` | `:::each` over the site's own groups. It takes no `by=` — the arrangement the site's view declares is what decides its groups. |
Directive fences nest by length, like code fences: an outer fence must be
**longer** than the one it contains. That is twig's rule, not one of ours, and it
is the first thing an author gets wrong.
What a template can name:
| | |
|---|---|
| `site` | `title`, `lang`, `base_url` |
| `page` | this page as an entry |
| `entries` | the site's pages, in its own order — source order, `nav_order` overriding |
| `groups` | `{key, entries}` per group, when the arrangement is grouped |
| `children` | the pages this one contains |
| `parent` | the page containing this one, or null |
| `breadcrumbs` | the trail from the root down to this page, itself last |
| `backlinks` | the entries that link *to* this page, by path, each named once |
| `relations` | the relation edges this page writes, keyed by relation name |
| `inbound` | the pages that name this one, keyed by the relation they name it in |
| `prev`, `next` | the page before and after this one in the nav's reading order, or null |
| `headings` | this page's own headings, `{level, id, text}`, with the ids the anchors got |
An entry is `path`, `title`, `href`, `date`, `date_year`, `date_month`, `id`,
`description`, `group_keys`, `is_root`. The pre-computed date parts are there
instead of a filter syntax: a filter language is what turns a template format
into a template *engine*, and a field that turns out to be wanted is one line.
`headings` is the one key a body cannot know before its own template has run —
a template that generates its headings should still get them listed — so a body
that names it is expanded twice: once to find out what its headings are, and
once more with them in scope. Every other page pays nothing.
Every collection is assembled from the sources the render was handed, which are
already the gate-admitted set — so **a template cannot name a withheld
document**, because the data holding it was never built.
The three link keys are the exception that proves it, and the one place a caller
has to be careful. This crate reads nothing, so it cannot find what links to a
page or what a page links to; the edges arrive on `SourceDoc::inbound` and
`SourceDoc::outbound` from a caller that walked the archive, and **that caller
must narrow both ends of every edge to the same site** — a private target is
disclosed by being named just as a private source is. A name no source in this
render answers to is dropped rather than published as a dead link, which is a
second line of defence and not the first one.
`relations` and `inbound` are keyed by **the names the vault gave its
relations** (`inbound.sequel`, `relations.translation-of`). Nothing here holds a
list of them: a name arrives on a `LinkEdge` and becomes a key. A link written in
prose has no name, so it is in neither — a reserved key for it would take a name
a vault may legitimately declare — and it reaches a template through `backlinks`,
the flat union of typed and untyped. A relation whose every target this render
cannot answer for produces no key at all rather than an empty list.
### Why `{{ }}` survives in link destinations
One position in Markdown cannot hold a node. A link's destination is not
inline-parsed: twig stores it as a byte run and carries a positional escape
alphabet for it, which is a settled decision rather than a gap. So
`[:val[t]](:val[href])` cannot work, and a list of links is the most common thing
a template produces.
`{{path}}` therefore survives **in a link or image destination and nowhere
else** — and it is resolved by reading the destination off the AST node, never by
scanning text. The `href` an entry carries is a *destination*
(`notes/entry.html`), and the link rewrite knows it as one: it is rebased to the
page's depth like any other, rather than resolved as a source path and stripped. A `{{` in a code block is the contents of a `code_block`, not a
`link`, so the substitution cannot reach it. A `{{ }}` anywhere else is not a
template: it publishes as itself and is reported on
`SiteRender::body_template_errors`, which is also the migration path off the
Handlebars bodies this replaced.
A body template that will not expand publishes its own source and says so on the
same channel. It no longer fails silently, which is the discipline the shell
templates already held to.
## The shell
A shell template is the outer HTML document a page is wrapped in — everything
from `<!DOCTYPE html>` down to `</html>` — with the parts this crate computes
left as named slots. The built-in shell fills exactly the same slots, so a
template replaces that document rather than introducing a second, parallel notion
of what a page is made of.
`{{name}}` inserts a **text** slot, HTML-escaped. `{{{name}}}` inserts a **raw
HTML** slot verbatim. Each slot is one kind or the other, and writing it the
other way is an error rather than a page full of `<div>`. Anything that is
not a well-formed slot reference passes through literally, so a `{{` in an inline
script or a CSS block is left alone.
| Slot | Kind | What it holds |
|---|---|---|
| `lang` | text | for `<html lang="…">` |
| `document_title` | text | `"Entry - Site"`, or the site's name on the front page |
| `site_title` | text | the site's name on its own |
| `body_class` | text | `has-site-nav`, or empty — write it inside `class="…"` |
| `root_prefix` | text | `../` per level of depth, for a template's own `href="{{root_prefix}}index.html"` |
| `head` | raw | stylesheet, favicon, SEO meta, feed links, the page's `styles:` |
| `site_nav` | raw | the mobile bar and the sidebar — masthead and tree — empty when the site has no tree |
| `breadcrumbs` | raw | the breadcrumb trail |
| `toc` | raw | the page's outline, "On this page", or empty |
| `site_header` | raw | the site's header document, rendered for this page |
| `content` | raw | the rendered body, links already rewritten |
| `pager` | raw | links to the previous and next page in reading order |
| `site_footer` | raw | the site's footer document, rendered for this page |
| `footer` | raw | the built-in attribution line |
| `scripts` | raw | the built-in interactivity script, then the page's `scripts:` |
`<title>` is not part of `head`, so a template decides where its own title tag
goes. A page may name its own shell with `shell:` in frontmatter; a key the site
does not carry falls back to the site shell and says why.
### The frame
Every heading in a body leaves the render with an `id` — `prov::link::slug` of
its text, so a site render and a single-file render agree on what `## Status`
is called, numbered `-2`, `-3` when a page repeats one — and a link to itself,
inside the heading so the text is what a screen reader reads first. An `id` the
body already carries is kept. The headings are data before they are markup:
`toc` is the `h2`–`h3` ones as a nested list (empty when there are fewer than
two, or the page says `toc: false`), and `headings` is the whole list for a
template that wants its outline spelled its own way. The pass runs on the
rendered HTML, so it covers all three grammars with one implementation.
The sidebar starts with a masthead — the site's name, linking home — and the
tree starts at the front page's children, so an entry sits at the depth it has.
A node with children is a `<details>` disclosure, written `open` on the current
page's ancestors and on its own node and closed elsewhere: the whole tree is in
the HTML, crawlable and correct with scripting off, and which sections are open
is a function of which page this is rather than state kept anywhere. The link
is the summary's content, so the title navigates and the chevron opens. Below
the content, `pager` links the previous and next page in the tree's reading
order — the depth-first order the sidebar lists, continuous across the seam
between the front page's subtree and the orphans — with `rel="prev"`/`"next"`
for a reader mode to read the sequence off. A `hide_from_nav` page is in no
sequence.
`site_header` and `site_footer` are **documents**, not shell partials:
`SiteOptions::header`/`footer` take a Markdown, Djot or HTML file's text and
path, and each is rendered *per page* through the same pipeline a body is —
template expansion against that page's context, `:vis` filtering for the
site's audience, twig, link rewriting to the page's depth. A footer is then
one line in the archive's own vocabulary:
```markdown
© :val[site.title] · [Source](https://github.com/diaryx-org/plates) · MIT or Apache-2.0
```
A partial was refused for the reason Handlebars was: the shell substitutor is
named slots and nothing else, and a partial would be HTML — a thing an editor
over the archive cannot edit and the gate cannot filter. A frame document is
not an entry; the caller that plans the site keeps it out of the render set,
and its own metadata block is stripped and otherwise unread.
The built-in shell writes a skip link first in `<body>`, `<main id="content">`,
a mobile bar whose button says `Menu` and keeps `aria-expanded` true, closes
the drawer on Escape, and opens the sidebar scrolled to the reader's place.
The substitutor is deliberately small — named slots, no expressions, no control
flow. Handlebars was turned down, and the reason that decides it is that it has
no configurable delimiters: a shell is an HTML document, which is exactly where
inline `<style>` and `<script>` braces live, and `{{y()}}` inside a script would
become an expression. Bodies pay no such cost, which is why they spell their
values with a directive and the shell keeps its own substitutor.
### Layouts
| *(absent)* | The site shell: nav, breadcrumbs, footer, site stylesheet, built-in script — or the caller's template in place of all of it. |
| `bare` | A complete document with none of the site's frame, only the page's own `styles:`/`scripts:` around its rendered body. Still in the nav, the sitemap and the feeds: bare is about what a page looks like, not about whether the site knows it. |
| `verbatim` | The body *is* the file, written out byte for byte — no wrapper, no head, no chrome, and no parse. |
`verbatim` exists because a reserialized document is a *different* document:
attribute order moves, void tags are respelled, an inline `<script>` survives or
does not depending on how the parser felt about it. A designed landing page is a
file someone wrote, not a document someone described, and the only faithful thing
to do with it is copy it.
## Navigation is a forest, not a tree
Visibility is explicit-only, so a render set is an arbitrary subset of the
containment tree: a published entry whose parent is private is the normal case.
Descending from a single root left every such entry with a URL, a sitemap row and
a feed item, but no place in the sidebar. So containment survives where it
survives — a visible parent still nests its visible children — and every page the
walk cannot reach becomes a root of its own. The invariant, pinned by a test, is
that **every page in the render set appears exactly once in the nav**.
The containment itself is the archive's, not a reading of frontmatter. A vault
*names* the relation that contains (prov's `spanning:`), and this crate opens no
workspace to find out which — so the caller walks it and passes the materialized
outline in as `SiteOptions::outline`, in the same coordinates the sources are
named by. A node naming a document the site does not publish is pruned and what
hung below it hoists to the nearest published ancestor. The nav tree, the
breadcrumb trail and a template's `parent`/`children`/`breadcrumbs` are all read
off those same pruned edges, so they cannot contradict each other. With no
outline the crate falls back to each page's own `contents:`/`part_of:` links,
which is right for a vault that spells its spine that way and all a caller
holding nothing but sources could offer.
An `Arrangement` is either `Containment` or `Grouped`, and grouping is
`prov::views`' own `Grouping`/`Grain` — by date at a chosen grain, or by any
field — so a site groups its entries the way the vault's view cuts them, not a
second way that agrees until one of them is fixed.
## Dates, feeds and metadata
A vault's frontmatter carries dates as whatever the author typed. That is right
for a document — the grain a person wrote in is information, and prov keeps it —
and wrong for syndication, where Atom requires RFC 3339 and RSS 2.0 requires
RFC 822, and a feed carrying a bare `2026-08-16` is rejected by validators and
misparsed by readers. So the loose spelling is read once here and the strict one
written, for each grammar that needs it.
Which date is dated off one chain, first key *present* wins:
`date_of_document` → `created` → `updated`. A journal of scanned letters
syndicates by the date each letter was *written*, not the afternoon it was
scanned, and `date_of_document: unknown` is the conventional marker for a
deliberately undated record — it stops the chain rather than falling through to
the day the shoebox was imported. One sort order is shared between the generated
index and the feed, so a site cannot list its entries in one order and syndicate
them in another.
Sitemaps, `robots.txt`, canonical links, Open Graph metadata and both feeds are
generated together, and only with a `base_url`: a feed needs absolute URLs, so
without one there is no feed to advertise either.
## HTML attachments as islands
An authored HTML file embedded in a page ships verbatim inside a sandboxed
`<iframe>`. The frame is cross-origin by construction, so the two sides agree by
`postMessage` — the child reports its own height and the parent sizes the frame —
and the child half is written to the site root as one well-known script rather
than copied into every island.
## Features
| `yaml` *(default)* | `---` frontmatter, `registry.yaml` |
| `json`, `toml`, `fig-lang` | the other metadata dialects |
| `templating` | Body templates — the directive vocabulary and the context it resolves against — plus the whole-site entry point (`site::render_site`) |
| `syntax-highlighting` | Colour for fenced code blocks, via `syntect` and 213 Sublime grammars |
Metadata-format features forward to `prov`, which forwards them to `fig`. With a
format off, its parser is left out of the build and `prov` stops recognizing it,
so at least one must be on.
`templating` is off by default so a consumer that only needs Markdown, HTML and
nav does not carry a context assembler it will not call. It pulls in no template
*engine*: the structure is twig's directives, which this crate already parses for
visibility, and the values are a path lookup. A template is left in the file and
resolved on every view and publish — and when a target audience is supplied,
`:vis[…]` filtering runs *before* expansion, so a region this audience may not
see is never expanded at all.
`syntax-highlighting` is off for the same reason and more of it: the grammars
travel as an embedded dump of about a megabyte. See [Highlighting](#highlighting)
for what it does with them.
## Using it
Everything below the whole-site entry point is a pure function over the value
types in `types`, so a caller can use as much or as little of it as it needs:
`render_body` for one body, `build_site_nav_tree`/`nav_for_page` for navigation,
`transform_links` to rewrite `.md` targets to their published `.html` ones.
```rust
use plates_render::site::{SiteOptions, render_site};
let render = render_site(
&sources, // path + text + which one is the root
&SiteOptions {
site_title: Some("Field notes".into()),
base_url: Some("https://example.org".into()),
generate_seo: true,
generate_feeds: true,
template: Some(shell_html), // the shell as text; None uses the built-in
outline, // the archive's spanning tree, from `plates`
..SiteOptions::default()
},
);
for page in &render.pages {
// page.dest_filename, page.html
}
for (name, bytes) in &render.assets {
// the stylesheet, the favicon, the island child script
}
```
A render has **no error channel** — every page in `pages` is real HTML. A
template that will not compile falls back to the built-in shell and says why on
`render.template_error`; a page naming a shell the site does not carry falls back
the same way and is reported once, on `render.page_shell_errors`. A caller that
can show those to a person should: silently serving the wrong design is how a
broken theme survives a release.
## Status
`0.1`, and the API is expected to move before `1.0`. Known limitations, rather
than surprises:
- `layout: verbatim` skips all rewriting, link rewriting included. A verbatim
page's hrefs are final URLs by contract.
- Theme compilation warnings are returned, never logged. A caller that drops them
shows a broken design to its readers.
- Body HTML is twig's, not comrak's, which is what this crate used to run:
tasklists come out as `<ul class="task-list">` and footnotes as
`role="doc-endnotes"` with `#fn1` anchors rather than `#fn-1`. The bundled
stylesheet styles both spellings, so a site published before the change and one
published after render the same.
## License
MIT or Apache-2.0, at your option.