plates-render 0.7.3

The document-to-HTML half of plates: what a published page looks like.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
---
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:

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

```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 `&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 `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

| `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](#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.