title: plates-render part_of: 'plates' audience: public
plates-render
The document-to-HTML half of 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 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.
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, 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.rustbecomesplates-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 gainplates-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:
| Grammar | Spelling | Container name | Classes |
|---|---|---|---|
| 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:
:::each{of=entries as=entry}
-
: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:
© :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
layout: |
|
|---|---|
| (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
| Feature | |
|---|---|
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
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.
use ;
let render = render_site;
for page in &render.pages
for in &render.assets
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: verbatimskips 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 asrole="doc-endnotes"with#fn1anchors 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.