plates-render 0.7.0

The document-to-HTML half of plates: what a published page looks like.
Documentation

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

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[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 &lt;div&gt;. 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 idprov::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 h2h3 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_documentcreatedupdated. 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 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.