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
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
//! Pure value types describing a site to be rendered.
//!
//! Nothing here opens, resolves or allocates anything on disk. These are the
//! *description* a caller hands the renderer, which is why the same description
//! can be assembled by a CLI, by a server, or by an edge worker.
//!
//! Appearance types (colors, typography, favicon, theme) live in
//! [`crate::appearance`].

use std::path::{Path, PathBuf};

/// Options for publishing.
#[derive(Debug, Clone)]
pub struct PublishOptions {
    /// Output as a single HTML file instead of multiple files
    pub single_file: bool,
    /// Site title (defaults to workspace title)
    pub title: Option<String>,
    /// Include audience filtering
    pub audience: Option<String>,
    /// Overwrite existing destination
    pub force: bool,
    /// Copy referenced attachment files to the output directory
    pub copy_attachments: bool,
    /// Base URL for sitemap, canonical URLs, og tags, and feeds.
    pub base_url: Option<String>,
    /// Generate sitemap.xml, robots.txt, and SEO meta tags (default true).
    pub generate_seo: bool,
    /// Generate feed.xml (Atom) and rss.xml (RSS) feeds (default true).
    pub generate_feeds: bool,
}

impl Default for PublishOptions {
    fn default() -> Self {
        Self {
            single_file: false,
            title: None,
            audience: None,
            force: false,
            copy_attachments: true,
            base_url: None,
            generate_seo: true,
            generate_feeds: true,
        }
    }
}

/// Which shell a page is wrapped in, from its frontmatter `layout:`.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum PageLayout {
    /// The site shell: nav, breadcrumbs, footer, site stylesheet, built-in
    /// interactivity script — or the caller's template in place of all of it.
    /// What a page with no `layout:` gets.
    #[default]
    Site,
    /// A complete document with none of the site's frame: no nav, no
    /// breadcrumbs, no footer, no site stylesheet and no built-in script — only
    /// the page's own `styles:`/`scripts:` around its rendered body.
    ///
    /// For a page that *is* a design of its own (a landing page, a poster, a
    /// visualization) rather than an entry in a site's furniture. It still
    /// appears in the nav, the sitemap and the feeds like any other page: bare
    /// is about what the page looks like, not about whether the site knows it.
    ///
    /// A supplied shell template does not apply to it — `bare` is a statement
    /// that this page carries its own frame, and wrapping it in someone else's
    /// would be the thing it asked not to happen.
    Bare,
    /// The body *is* the file. Everything after the metadata block is written
    /// out byte for byte: no wrapper, no head, no head links, no chrome — and,
    /// unlike every other layout, no parse either.
    ///
    /// A `bare` page is still rendered: its body goes through templating, twig,
    /// and link rewriting, and comes back as twig's serialization of it. That is
    /// right for prose and wrong for a hand-authored page, where 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.
    ///
    /// So `verbatim` is for a self-contained HTML file that carries frontmatter
    /// only so the vault can see it: the metadata makes it a document the site
    /// knows — it appears in the nav, the sitemap and the feeds like any other
    /// page — while the bytes below the metadata are published unread.
    ///
    /// The cost is that nothing is done *for* it. Its links are not rewritten,
    /// so a `.md` href in it stays a `.md` href and a vault-root-absolute path
    /// stays absolute; its `styles:`/`scripts:` are not emitted, since there is
    /// no head to emit them into. A verbatim page is responsible for itself,
    /// which is the point of asking for one.
    Verbatim,
}

impl PageLayout {
    /// Read a frontmatter `layout:` value. Anything unrecognized — including
    /// the absent case — is [`PageLayout::Site`], because a site whose theme
    /// spells a layout this version does not know should still publish.
    pub fn parse(value: Option<&str>) -> Self {
        match value.map(str::trim) {
            Some("bare") => Self::Bare,
            Some("verbatim") => Self::Verbatim,
            _ => Self::Site,
        }
    }

    /// Whether the body is published unread — no templating, no parse, no link
    /// rewriting. True only for [`PageLayout::Verbatim`].
    pub fn is_verbatim(self) -> bool {
        matches!(self, Self::Verbatim)
    }
}

/// A document that frames every page — a site's header or footer — as the
/// text of the file and the path it was read from.
///
/// The path is load-bearing twice over: its extension decides the grammar the
/// text is parsed in, and relative links in the text resolve against it.
///
/// Here rather than in [`crate::site`], which consumes it, because the caller
/// that *assembles* one has only read a file: `plates` builds a `FrameDoc` from
/// a vault without enabling `templating`, and the type it hands over cannot
/// live behind a feature it does not turn on.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FrameDoc {
    /// Vault-relative path, spelled the way `SourceDoc::path` is: no leading
    /// slash, extension included.
    pub path: String,
    /// The file's text, metadata block and all.
    pub source: String,
}

/// A navigation link.
#[derive(Debug, Clone)]
pub struct NavLink {
    /// Link href (relative path or anchor)
    pub href: String,
    /// Display title
    pub title: String,
}

/// A processed file ready for publishing.
#[derive(Debug, Clone)]
pub struct PublishedPage {
    /// Original source path
    pub source_path: PathBuf,
    /// Destination filename (e.g., "index.html" or "my-entry.html")
    pub dest_filename: String,
    /// Page title
    pub title: String,
    /// Rendered content in the output format (body only, no wrapper)
    pub rendered_body: String,
    /// Original markdown body
    pub markdown_body: String,
    /// Navigation links to children (from contents property)
    pub contents_links: Vec<NavLink>,
    /// Navigation link to parent (from part_of property)
    pub parent_link: Option<NavLink>,
    /// Whether this is the root index
    pub is_root: bool,
    /// Page description (from frontmatter `description`)
    pub description: Option<String>,
    /// Page author (from frontmatter `author`)
    pub author: Option<String>,
    /// Creation date (from frontmatter `created`)
    pub created: Option<String>,
    /// Last update date (from frontmatter `updated`)
    pub updated: Option<String>,
    /// The date the document is *about*, as opposed to when its file was made
    /// (from frontmatter `date_of_document`). First link in the date chain a
    /// grouped arrangement sorts by: `date_of_document` → `created` → `updated`,
    /// the same chain a grouped view is cut by.
    pub date_of_document: Option<String>,
    /// The values this page groups under in a grouped arrangement — the date
    /// cut to the view's grain, or the grouping field's values. Empty for a
    /// containment arrangement, or for a page carrying nothing to group by
    /// (which lands it in the "ungrouped" bucket rather than dropping it).
    pub group_keys: Vec<String>,
    /// Attachment paths (from frontmatter `attachments`)
    pub attachments: Vec<String>,
    /// Stylesheets this page pulls in (from frontmatter `styles`), as paths
    /// below the site root — already resolved against the document that named
    /// them, so `../theme.css` and `/theme.css` both arrive as `theme.css`.
    ///
    /// Emitted as `<link rel="stylesheet">` after the site stylesheet, rebased
    /// to the page's own depth. The file itself is the caller's to copy, the
    /// same way an `attachments` entry is.
    pub styles: Vec<String>,
    /// Scripts this page pulls in (from frontmatter `scripts`), resolved and
    /// copied exactly like [`styles`](Self::styles) and emitted as
    /// `<script defer src="…">` after the built-in interactivity script.
    pub scripts: Vec<String>,
    /// Which shell wraps this page (from frontmatter `layout`).
    pub layout: PageLayout,
    /// The shell template this page asked for by name (from frontmatter
    /// `shell`), as the vault-relative path it was written as — the key into
    /// [`SiteOptions::templates`](crate::site::SiteOptions::templates), since
    /// the render crate reads no files.
    ///
    /// `None` for a page that takes the site's own shell, which is every page
    /// that does not name one — and every `bare`/`verbatim` page, which take no
    /// shell at all and so are never recorded as wanting one.
    pub shell: Option<String>,
    /// The language *this page* is written in (from frontmatter `lang`), as a
    /// BCP 47 tag. `None` takes the site's
    /// ([`SiteOptions::lang`](crate::site::SiteOptions::lang)), which is the
    /// answer for every page in an archive that is written in one language.
    ///
    /// An archive is not obliged to be. A letter quoted in full, a page of
    /// translations, an entry someone wrote in their first language: each is a
    /// document whose `<html lang="…">` is a fact about the document, and a
    /// site-wide tag makes it a lie that screen readers and search engines both
    /// act on.
    pub lang: Option<String>,
    /// Override title shown in navigation (from frontmatter `nav_title`)
    pub nav_title: Option<String>,
    /// Sort order among siblings in navigation (from frontmatter `nav_order`)
    pub nav_order: Option<i32>,
    /// Whether to hide this page from the navigation tree
    pub hide_from_nav: bool,
    /// Whether to hide this page from RSS/Atom feeds
    pub hide_from_feed: bool,
    /// The source document's own identifier, read from frontmatter `id` — which
    /// is prov's registry id for the file.
    ///
    /// Carried through the render untouched: nothing here reads it. It is here
    /// because a caller that mints permalinks, builds an index, or addresses the
    /// published object by identity needs to know which page each id belongs to,
    /// and the render is the only place that pairing exists.
    pub id: Option<String>,
    /// The audience-scoped markdown source (frontmatter + visibility-filtered
    /// body) uploaded as a sibling so the server can serve `?content`/`?json`.
    pub source_markdown: String,
    /// The headings of the rendered body, in document order, each with the
    /// `id` the render gave it — what the `toc` shell slot and a template's
    /// `headings` list are made of. See [`crate::headings`].
    ///
    /// Empty for a `verbatim` page, whose body nothing reads.
    pub headings: Vec<Heading>,
    /// Whether the built-in shell writes this page's outline (frontmatter
    /// `toc`, `true` unless the page says `toc: false`). Turns off the
    /// `toc` slot only: the headings keep their anchors and a template still
    /// sees them.
    pub toc: bool,
}

/// One heading of a rendered body, as the outline lists it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Heading {
    /// 1–6, from the tag.
    pub level: u8,
    /// The anchor: the heading's `id`, as written on the tag.
    pub id: String,
    /// The heading's text, markup stripped and entities decoded.
    pub text: String,
}

impl PublishedPage {
    /// When the entry is *of*, as its vault wrote it:
    /// `date_of_document` → `created` → `updated`.
    ///
    /// The one chain, so a site cannot disagree with itself. A grouped
    /// arrangement files and orders entries by this (it is the chain prov's
    /// `views` cuts by), and
    /// the feeds, the sitemap and `article:published_time` used to answer a
    /// different question — `updated` → `created` — so a journal of scanned
    /// letters, whose `date_of_document` is the year it was written and whose
    /// `created` is the day it was scanned, syndicated in scanning order while
    /// its own front page listed it by letter date.
    pub fn published_date(&self) -> Option<&str> {
        self.date_of_document
            .as_deref()
            .or(self.created.as_deref())
            .or(self.updated.as_deref())
            .filter(|d| !d.is_empty())
    }

    /// When the entry last changed: `updated`, else whatever
    /// [`published_date`](Self::published_date) found.
    ///
    /// What a sitemap's `lastmod` and a feed entry's `<updated>` mean, as
    /// against the `<published>` above them.
    pub fn modified_date(&self) -> Option<&str> {
        self.updated
            .as_deref()
            .filter(|d| !d.is_empty())
            .or_else(|| self.published_date())
    }
}

/// One node of a site's **spanning outline**: the archive's own containment
/// hierarchy, materialized by whoever holds the workspace.
///
/// A vault's spine is configured, not spelled: prov's `spanning:` names the
/// relation whose links contain, and `contents:`/`part_of:` is one vault
/// dialect's spelling of it. This crate cannot read a workspace's configuration
/// — it reads nothing — so the layer that can walks the tree and hands the
/// result down as plain data. See [`SiteOptions::outline`](crate::site::SiteOptions::outline).
///
/// [`path`](Self::path) is the source path in the coordinates
/// [`SourceDoc::path`](crate::site::SourceDoc::path) is written in: rebased onto
/// the site's anchor, sanitized, carrying the body's own extension. That is what
/// lets a node be matched to the page it became without either side re-deriving
/// the other's naming rule.
///
/// A node naming a document this site does not publish is not an error and not a
/// nav entry — it is pruned, and its published descendants hoist to the nearest
/// ancestor that *is* published. Under explicit-only visibility that is the
/// ordinary shape, not the edge case.
#[derive(Debug, Clone, Default)]
pub struct OutlineNode {
    /// The source path this node names, spelled as
    /// [`SourceDoc::path`](crate::site::SourceDoc::path) spells it.
    pub path: String,
    /// The label the containing document's link carried (`[Label](path)`), when
    /// it carried one. A fallback only: a page's own `nav_title`/`title` wins.
    pub label: Option<String>,
    /// Contained nodes, in the order the containing document declared them.
    pub children: Vec<OutlineNode>,
}

/// One link between two documents, named by the relation that carries it.
///
/// A vault **declares its own relations** — `sequel`, `translation_of`,
/// `author`, whatever its configuration says — so the name is data, never
/// something this crate knows. Nothing here may hardcode a vocabulary: whatever
/// names arrive are the names a template can address.
///
/// [`relation`](Self::relation) is `None` for a link written in prose, which has
/// no name to be filed under. Those reach a template through `backlinks`, the
/// flat union, and nowhere else — a reserved key for them would collide with a
/// relation a vault is entitled to declare.
///
/// [`path`](Self::path) is the document at the far end, spelled as
/// [`SourceDoc::path`](crate::site::SourceDoc::path) spells it — the same
/// coordinates, so an edge can be matched to the page it names without either
/// side re-deriving the other's naming rule.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct LinkEdge {
    /// The relation this edge is written in, or `None` for a body link.
    pub relation: Option<String>,
    /// The document at the far end.
    pub path: String,
}

/// A node in the full site navigation tree.
#[derive(Debug, Clone)]
pub struct SiteNavNode {
    /// Node title
    pub title: String,
    /// Node href
    pub href: String,
    /// Whether this is the current page
    pub is_current: bool,
    /// Whether this node is an ancestor of the current page
    pub is_ancestor_of_current: bool,
    /// Child nodes
    pub children: Vec<SiteNavNode>,
}

/// Full site navigation context for a specific page.
#[derive(Debug, Clone)]
pub struct SiteNavigation {
    /// Full nav tree with current-page marking
    pub tree: Vec<SiteNavNode>,
    /// Breadcrumb trail from root to current page
    pub breadcrumbs: Vec<NavLink>,
}

/// Result of a publishing operation.
#[derive(Debug)]
pub struct PublishResult {
    /// Pages that were published
    pub pages: Vec<PublishedPage>,
    /// Total files processed
    pub files_processed: usize,
    /// Number of attachment files copied to the output directory
    pub attachments_copied: usize,
}

/// What a grouped arrangement sorts entries into groups by.
///
/// prov's own, not a mirror of it. This used to be a redeclaration — the crate
/// sits below the workspace layer and must stay portable to
/// `wasm32-unknown-unknown`, so it kept its own `DateGrain` and `Grouping` with
/// the spellings and prefix lengths copied across, on the reasoning that a site
/// grouped "by year" must cut dates the same way the app's lens does or the
/// published archive reads differently from the vault it came from.
///
/// Since prov 0.5 the grouping engine is `prov-views`, which reaches nothing
/// that can write and is already in this crate's dependency graph. So the way to
/// keep the two identical is to stop having two: the published site now groups
/// through the same [`Grouping::keys_of`] the vault does, and "identical" is a
/// fact rather than a promise two copies make to each other.
pub use prov::views::{Grain, Grouping};

/// How a site is arranged — the render-side half of a site's `view:`.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Arrangement {
    /// Nav follows containment where audience filtering left it intact, and
    /// pages the walk cannot reach become roots of their own. What a
    /// hierarchical vault wants, and the behaviour when a site declares no view.
    #[default]
    Containment,
    /// Entries are gathered into groups. The generated index shows the groups;
    /// the nav lists entries in group order rather than by containment, because
    /// a site that declared an arrangement asked for one.
    Grouped(Grouping),
}

/// Normalize a frontmatter `serve_at:` value into a path below the site root,
/// or `None` when it claims nothing this crate can serve.
///
/// The value is **site-root-absolute** and must start with `/`. That is what
/// makes it a claim on the site's own layout rather than on the directory the
/// document happens to sit in — and why, unlike a derived destination, it is
/// never rebased onto a site's anchor: it is already written in the
/// coordinates a rebasing would produce.
///
/// `/privacy` and `/privacy.html` are the same claim: a value that does not
/// already end in `.html` gains it, because what is being named is a page and a
/// page is an HTML file. Components are sanitized the way every other published
/// path is, and `.`/`..` are dropped rather than resolved — a destination is a
/// name *inside* the site, and there is nothing above the site root to reach.
pub fn serve_at_dest(value: &str) -> Option<String> {
    let rest = value.trim().strip_prefix('/')?;
    let mut parts: Vec<String> = Vec::new();
    for part in rest.split('/') {
        if part.is_empty() || part == "." || part == ".." {
            continue;
        }
        let cleaned = crate::links::sanitize_path_component(part);
        if !cleaned.is_empty() {
            parts.push(cleaned);
        }
    }
    if parts.is_empty() {
        return None;
    }
    let mut dest = parts.join("/");
    if !dest.ends_with(".html") {
        dest.push_str(".html");
    }
    Some(dest)
}

/// Convert a canonical source path to its sanitized `.html` output filename.
///
/// Public because a caller that must know where a source's HTML lands *before*
/// rendering it has no other way to ask: `build_pages` applies this same rule
/// internally, and re-deriving it elsewhere is how the two drift apart. It is
/// also what `plates`'s collection calls, so a site's uploaded keys and its
/// rendered links come from one function rather than from two that agree.
///
/// Ordinarily the extension is swapped and nothing else moves:
/// `notes/post.md` publishes at `notes/post.html`, in any content format.
///
/// # A folder note publishes as its directory's index
///
/// A source whose file stem is the name of the directory holding it —
/// `page/page.md`, `2026/2026.dj`, `about/about.html` — is that directory's
/// own note, the same document an `about/index.md` would be. The two spellings
/// are interchangeable across note-taking tools, and only one of them used to
/// land on `about/index.html`; the other published at `about/about.html` and
/// left the directory with no index at all, so a reader who asked for
/// `about/` got nothing. Both now publish at `<dir>/index.html`.
///
/// `index.md` needs no case of its own here and never did: swapping its
/// extension already yields `index.html`. This is the same destination reached
/// by the other spelling, which is exactly why the two cannot both be used in
/// one directory — `page/page.md` and `page/index.md` side by side claim
/// `page/index.html` twice. Collection refuses that pair by name
/// (`plates`'s `DestinationClaimedTwice`); nothing is reported here, because
/// this function sees one path at a time and has no second one to name.
///
/// A file with no directory above it is nobody's folder note: `page.md` at the
/// site root publishes at `page.html`. The comparison is against the immediate
/// directory only, so `notes/page.md` is untouched.
pub fn output_filename(canonical_md: &str) -> String {
    let path = Path::new(canonical_md);
    let folder_note = path
        .file_stem()
        .and_then(|s| s.to_str())
        .zip(
            path.parent()
                .and_then(Path::file_name)
                .and_then(|d| d.to_str()),
        )
        .is_some_and(|(stem, dir)| stem == dir);
    let with_ext = if folder_note {
        path.with_file_name("index.html")
    } else {
        path.with_extension("html")
    };
    let sanitized: PathBuf = with_ext
        .components()
        .map(|c| match c {
            std::path::Component::Normal(s) => std::ffi::OsString::from(
                crate::links::sanitize_path_component(&s.to_string_lossy()),
            ),
            other => other.as_os_str().to_owned(),
        })
        .collect();
    sanitized.to_string_lossy().into_owned()
}