Skip to main content

plates_render/
types.rs

1//! Pure value types describing a site to be rendered.
2//!
3//! Nothing here opens, resolves or allocates anything on disk. These are the
4//! *description* a caller hands the renderer, which is why the same description
5//! can be assembled by a CLI, by a server, or by an edge worker.
6//!
7//! Appearance types (colors, typography, favicon, theme) live in
8//! [`crate::appearance`].
9
10use std::path::{Path, PathBuf};
11
12/// Options for publishing.
13#[derive(Debug, Clone)]
14pub struct PublishOptions {
15    /// Output as a single HTML file instead of multiple files
16    pub single_file: bool,
17    /// Site title (defaults to workspace title)
18    pub title: Option<String>,
19    /// Include audience filtering
20    pub audience: Option<String>,
21    /// Overwrite existing destination
22    pub force: bool,
23    /// Copy referenced attachment files to the output directory
24    pub copy_attachments: bool,
25    /// Base URL for sitemap, canonical URLs, og tags, and feeds.
26    pub base_url: Option<String>,
27    /// Generate sitemap.xml, robots.txt, and SEO meta tags (default true).
28    pub generate_seo: bool,
29    /// Generate feed.xml (Atom) and rss.xml (RSS) feeds (default true).
30    pub generate_feeds: bool,
31}
32
33impl Default for PublishOptions {
34    fn default() -> Self {
35        Self {
36            single_file: false,
37            title: None,
38            audience: None,
39            force: false,
40            copy_attachments: true,
41            base_url: None,
42            generate_seo: true,
43            generate_feeds: true,
44        }
45    }
46}
47
48/// Which shell a page is wrapped in, from its frontmatter `layout:`.
49#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
50pub enum PageLayout {
51    /// The site shell: nav, breadcrumbs, footer, site stylesheet, built-in
52    /// interactivity script — or the caller's template in place of all of it.
53    /// What a page with no `layout:` gets.
54    #[default]
55    Site,
56    /// A complete document with none of the site's frame: no nav, no
57    /// breadcrumbs, no footer, no site stylesheet and no built-in script — only
58    /// the page's own `styles:`/`scripts:` around its rendered body.
59    ///
60    /// For a page that *is* a design of its own (a landing page, a poster, a
61    /// visualization) rather than an entry in a site's furniture. It still
62    /// appears in the nav, the sitemap and the feeds like any other page: bare
63    /// is about what the page looks like, not about whether the site knows it.
64    ///
65    /// A supplied shell template does not apply to it — `bare` is a statement
66    /// that this page carries its own frame, and wrapping it in someone else's
67    /// would be the thing it asked not to happen.
68    Bare,
69    /// The body *is* the file. Everything after the metadata block is written
70    /// out byte for byte: no wrapper, no head, no head links, no chrome — and,
71    /// unlike every other layout, no parse either.
72    ///
73    /// A `bare` page is still rendered: its body goes through templating, twig,
74    /// and link rewriting, and comes back as twig's serialization of it. That is
75    /// right for prose and wrong for a hand-authored page, where a reserialized
76    /// document is a *different* document — attribute order moves, void tags are
77    /// respelled, an inline `<script>` survives or does not depending on how the
78    /// parser felt about it. A designed landing page is a file someone wrote,
79    /// not a document someone described, and the only faithful thing to do with
80    /// it is copy it.
81    ///
82    /// So `verbatim` is for a self-contained HTML file that carries frontmatter
83    /// only so the vault can see it: the metadata makes it a document the site
84    /// knows — it appears in the nav, the sitemap and the feeds like any other
85    /// page — while the bytes below the metadata are published unread.
86    ///
87    /// The cost is that nothing is done *for* it. Its links are not rewritten,
88    /// so a `.md` href in it stays a `.md` href and a vault-root-absolute path
89    /// stays absolute; its `styles:`/`scripts:` are not emitted, since there is
90    /// no head to emit them into. A verbatim page is responsible for itself,
91    /// which is the point of asking for one.
92    Verbatim,
93}
94
95impl PageLayout {
96    /// Read a frontmatter `layout:` value. Anything unrecognized — including
97    /// the absent case — is [`PageLayout::Site`], because a site whose theme
98    /// spells a layout this version does not know should still publish.
99    pub fn parse(value: Option<&str>) -> Self {
100        match value.map(str::trim) {
101            Some("bare") => Self::Bare,
102            Some("verbatim") => Self::Verbatim,
103            _ => Self::Site,
104        }
105    }
106
107    /// Whether the body is published unread — no templating, no parse, no link
108    /// rewriting. True only for [`PageLayout::Verbatim`].
109    pub fn is_verbatim(self) -> bool {
110        matches!(self, Self::Verbatim)
111    }
112}
113
114/// A document that frames every page — a site's header or footer — as the
115/// text of the file and the path it was read from.
116///
117/// The path is load-bearing twice over: its extension decides the grammar the
118/// text is parsed in, and relative links in the text resolve against it.
119///
120/// Here rather than in [`crate::site`], which consumes it, because the caller
121/// that *assembles* one has only read a file: `plates` builds a `FrameDoc` from
122/// a vault without enabling `templating`, and the type it hands over cannot
123/// live behind a feature it does not turn on.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct FrameDoc {
126    /// Vault-relative path, spelled the way `SourceDoc::path` is: no leading
127    /// slash, extension included.
128    pub path: String,
129    /// The file's text, metadata block and all.
130    pub source: String,
131}
132
133/// A navigation link.
134#[derive(Debug, Clone)]
135pub struct NavLink {
136    /// Link href (relative path or anchor)
137    pub href: String,
138    /// Display title
139    pub title: String,
140}
141
142/// A processed file ready for publishing.
143#[derive(Debug, Clone)]
144pub struct PublishedPage {
145    /// Original source path
146    pub source_path: PathBuf,
147    /// Destination filename (e.g., "index.html" or "my-entry.html")
148    pub dest_filename: String,
149    /// Page title
150    pub title: String,
151    /// Rendered content in the output format (body only, no wrapper)
152    pub rendered_body: String,
153    /// Original markdown body
154    pub markdown_body: String,
155    /// Navigation links to children (from contents property)
156    pub contents_links: Vec<NavLink>,
157    /// Navigation link to parent (from part_of property)
158    pub parent_link: Option<NavLink>,
159    /// Whether this is the root index
160    pub is_root: bool,
161    /// Page description (from frontmatter `description`)
162    pub description: Option<String>,
163    /// Page author (from frontmatter `author`)
164    pub author: Option<String>,
165    /// Creation date (from frontmatter `created`)
166    pub created: Option<String>,
167    /// Last update date (from frontmatter `updated`)
168    pub updated: Option<String>,
169    /// The date the document is *about*, as opposed to when its file was made
170    /// (from frontmatter `date_of_document`). First link in the date chain a
171    /// grouped arrangement sorts by: `date_of_document` → `created` → `updated`,
172    /// the same chain a grouped view is cut by.
173    pub date_of_document: Option<String>,
174    /// The values this page groups under in a grouped arrangement — the date
175    /// cut to the view's grain, or the grouping field's values. Empty for a
176    /// containment arrangement, or for a page carrying nothing to group by
177    /// (which lands it in the "ungrouped" bucket rather than dropping it).
178    pub group_keys: Vec<String>,
179    /// Attachment paths (from frontmatter `attachments`)
180    pub attachments: Vec<String>,
181    /// Stylesheets this page pulls in (from frontmatter `styles`), as paths
182    /// below the site root — already resolved against the document that named
183    /// them, so `../theme.css` and `/theme.css` both arrive as `theme.css`.
184    ///
185    /// Emitted as `<link rel="stylesheet">` after the site stylesheet, rebased
186    /// to the page's own depth. The file itself is the caller's to copy, the
187    /// same way an `attachments` entry is.
188    pub styles: Vec<String>,
189    /// Scripts this page pulls in (from frontmatter `scripts`), resolved and
190    /// copied exactly like [`styles`](Self::styles) and emitted as
191    /// `<script defer src="…">` after the built-in interactivity script.
192    pub scripts: Vec<String>,
193    /// Which shell wraps this page (from frontmatter `layout`).
194    pub layout: PageLayout,
195    /// The shell template this page asked for by name (from frontmatter
196    /// `shell`), as the vault-relative path it was written as — the key into
197    /// [`SiteOptions::templates`](crate::site::SiteOptions::templates), since
198    /// the render crate reads no files.
199    ///
200    /// `None` for a page that takes the site's own shell, which is every page
201    /// that does not name one — and every `bare`/`verbatim` page, which take no
202    /// shell at all and so are never recorded as wanting one.
203    pub shell: Option<String>,
204    /// The language *this page* is written in (from frontmatter `lang`), as a
205    /// BCP 47 tag. `None` takes the site's
206    /// ([`SiteOptions::lang`](crate::site::SiteOptions::lang)), which is the
207    /// answer for every page in an archive that is written in one language.
208    ///
209    /// An archive is not obliged to be. A letter quoted in full, a page of
210    /// translations, an entry someone wrote in their first language: each is a
211    /// document whose `<html lang="…">` is a fact about the document, and a
212    /// site-wide tag makes it a lie that screen readers and search engines both
213    /// act on.
214    pub lang: Option<String>,
215    /// Override title shown in navigation (from frontmatter `nav_title`)
216    pub nav_title: Option<String>,
217    /// Sort order among siblings in navigation (from frontmatter `nav_order`)
218    pub nav_order: Option<i32>,
219    /// Whether to hide this page from the navigation tree
220    pub hide_from_nav: bool,
221    /// Whether to hide this page from RSS/Atom feeds
222    pub hide_from_feed: bool,
223    /// The source document's own identifier, read from frontmatter `id` — which
224    /// is prov's registry id for the file.
225    ///
226    /// Carried through the render untouched: nothing here reads it. It is here
227    /// because a caller that mints permalinks, builds an index, or addresses the
228    /// published object by identity needs to know which page each id belongs to,
229    /// and the render is the only place that pairing exists.
230    pub id: Option<String>,
231    /// The audience-scoped markdown source (frontmatter + visibility-filtered
232    /// body) uploaded as a sibling so the server can serve `?content`/`?json`.
233    pub source_markdown: String,
234    /// The headings of the rendered body, in document order, each with the
235    /// `id` the render gave it — what the `toc` shell slot and a template's
236    /// `headings` list are made of. See [`crate::headings`].
237    ///
238    /// Empty for a `verbatim` page, whose body nothing reads.
239    pub headings: Vec<Heading>,
240    /// Whether the built-in shell writes this page's outline (frontmatter
241    /// `toc`, `true` unless the page says `toc: false`). Turns off the
242    /// `toc` slot only: the headings keep their anchors and a template still
243    /// sees them.
244    pub toc: bool,
245}
246
247/// One heading of a rendered body, as the outline lists it.
248#[derive(Debug, Clone, PartialEq, Eq)]
249pub struct Heading {
250    /// 1–6, from the tag.
251    pub level: u8,
252    /// The anchor: the heading's `id`, as written on the tag.
253    pub id: String,
254    /// The heading's text, markup stripped and entities decoded.
255    pub text: String,
256}
257
258impl PublishedPage {
259    /// When the entry is *of*, as its vault wrote it:
260    /// `date_of_document` → `created` → `updated`.
261    ///
262    /// The one chain, so a site cannot disagree with itself. A grouped
263    /// arrangement files and orders entries by this (it is the chain prov's
264    /// `views` cuts by), and
265    /// the feeds, the sitemap and `article:published_time` used to answer a
266    /// different question — `updated` → `created` — so a journal of scanned
267    /// letters, whose `date_of_document` is the year it was written and whose
268    /// `created` is the day it was scanned, syndicated in scanning order while
269    /// its own front page listed it by letter date.
270    pub fn published_date(&self) -> Option<&str> {
271        self.date_of_document
272            .as_deref()
273            .or(self.created.as_deref())
274            .or(self.updated.as_deref())
275            .filter(|d| !d.is_empty())
276    }
277
278    /// When the entry last changed: `updated`, else whatever
279    /// [`published_date`](Self::published_date) found.
280    ///
281    /// What a sitemap's `lastmod` and a feed entry's `<updated>` mean, as
282    /// against the `<published>` above them.
283    pub fn modified_date(&self) -> Option<&str> {
284        self.updated
285            .as_deref()
286            .filter(|d| !d.is_empty())
287            .or_else(|| self.published_date())
288    }
289}
290
291/// One node of a site's **spanning outline**: the archive's own containment
292/// hierarchy, materialized by whoever holds the workspace.
293///
294/// A vault's spine is configured, not spelled: prov's `spanning:` names the
295/// relation whose links contain, and `contents:`/`part_of:` is one vault
296/// dialect's spelling of it. This crate cannot read a workspace's configuration
297/// — it reads nothing — so the layer that can walks the tree and hands the
298/// result down as plain data. See [`SiteOptions::outline`](crate::site::SiteOptions::outline).
299///
300/// [`path`](Self::path) is the source path in the coordinates
301/// [`SourceDoc::path`](crate::site::SourceDoc::path) is written in: rebased onto
302/// the site's anchor, sanitized, carrying the body's own extension. That is what
303/// lets a node be matched to the page it became without either side re-deriving
304/// the other's naming rule.
305///
306/// A node naming a document this site does not publish is not an error and not a
307/// nav entry — it is pruned, and its published descendants hoist to the nearest
308/// ancestor that *is* published. Under explicit-only visibility that is the
309/// ordinary shape, not the edge case.
310#[derive(Debug, Clone, Default)]
311pub struct OutlineNode {
312    /// The source path this node names, spelled as
313    /// [`SourceDoc::path`](crate::site::SourceDoc::path) spells it.
314    pub path: String,
315    /// The label the containing document's link carried (`[Label](path)`), when
316    /// it carried one. A fallback only: a page's own `nav_title`/`title` wins.
317    pub label: Option<String>,
318    /// Contained nodes, in the order the containing document declared them.
319    pub children: Vec<OutlineNode>,
320}
321
322/// One link between two documents, named by the relation that carries it.
323///
324/// A vault **declares its own relations** — `sequel`, `translation_of`,
325/// `author`, whatever its configuration says — so the name is data, never
326/// something this crate knows. Nothing here may hardcode a vocabulary: whatever
327/// names arrive are the names a template can address.
328///
329/// [`relation`](Self::relation) is `None` for a link written in prose, which has
330/// no name to be filed under. Those reach a template through `backlinks`, the
331/// flat union, and nowhere else — a reserved key for them would collide with a
332/// relation a vault is entitled to declare.
333///
334/// [`path`](Self::path) is the document at the far end, spelled as
335/// [`SourceDoc::path`](crate::site::SourceDoc::path) spells it — the same
336/// coordinates, so an edge can be matched to the page it names without either
337/// side re-deriving the other's naming rule.
338#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
339pub struct LinkEdge {
340    /// The relation this edge is written in, or `None` for a body link.
341    pub relation: Option<String>,
342    /// The document at the far end.
343    pub path: String,
344}
345
346/// A node in the full site navigation tree.
347#[derive(Debug, Clone)]
348pub struct SiteNavNode {
349    /// Node title
350    pub title: String,
351    /// Node href
352    pub href: String,
353    /// Whether this is the current page
354    pub is_current: bool,
355    /// Whether this node is an ancestor of the current page
356    pub is_ancestor_of_current: bool,
357    /// Child nodes
358    pub children: Vec<SiteNavNode>,
359}
360
361/// Full site navigation context for a specific page.
362#[derive(Debug, Clone)]
363pub struct SiteNavigation {
364    /// Full nav tree with current-page marking
365    pub tree: Vec<SiteNavNode>,
366    /// Breadcrumb trail from root to current page
367    pub breadcrumbs: Vec<NavLink>,
368}
369
370/// Result of a publishing operation.
371#[derive(Debug)]
372pub struct PublishResult {
373    /// Pages that were published
374    pub pages: Vec<PublishedPage>,
375    /// Total files processed
376    pub files_processed: usize,
377    /// Number of attachment files copied to the output directory
378    pub attachments_copied: usize,
379}
380
381/// What a grouped arrangement sorts entries into groups by.
382///
383/// prov's own, not a mirror of it. This used to be a redeclaration — the crate
384/// sits below the workspace layer and must stay portable to
385/// `wasm32-unknown-unknown`, so it kept its own `DateGrain` and `Grouping` with
386/// the spellings and prefix lengths copied across, on the reasoning that a site
387/// grouped "by year" must cut dates the same way the app's lens does or the
388/// published archive reads differently from the vault it came from.
389///
390/// Since prov 0.5 the grouping engine is `prov-views`, which reaches nothing
391/// that can write and is already in this crate's dependency graph. So the way to
392/// keep the two identical is to stop having two: the published site now groups
393/// through the same [`Grouping::keys_of`] the vault does, and "identical" is a
394/// fact rather than a promise two copies make to each other.
395pub use prov::views::{Grain, Grouping};
396
397/// How a site is arranged — the render-side half of a site's `view:`.
398#[derive(Debug, Clone, PartialEq, Eq, Default)]
399pub enum Arrangement {
400    /// Nav follows containment where audience filtering left it intact, and
401    /// pages the walk cannot reach become roots of their own. What a
402    /// hierarchical vault wants, and the behaviour when a site declares no view.
403    #[default]
404    Containment,
405    /// Entries are gathered into groups. The generated index shows the groups;
406    /// the nav lists entries in group order rather than by containment, because
407    /// a site that declared an arrangement asked for one.
408    Grouped(Grouping),
409}
410
411/// Normalize a frontmatter `serve_at:` value into a path below the site root,
412/// or `None` when it claims nothing this crate can serve.
413///
414/// The value is **site-root-absolute** and must start with `/`. That is what
415/// makes it a claim on the site's own layout rather than on the directory the
416/// document happens to sit in — and why, unlike a derived destination, it is
417/// never rebased onto a site's anchor: it is already written in the
418/// coordinates a rebasing would produce.
419///
420/// `/privacy` and `/privacy.html` are the same claim: a value that does not
421/// already end in `.html` gains it, because what is being named is a page and a
422/// page is an HTML file. Components are sanitized the way every other published
423/// path is, and `.`/`..` are dropped rather than resolved — a destination is a
424/// name *inside* the site, and there is nothing above the site root to reach.
425pub fn serve_at_dest(value: &str) -> Option<String> {
426    let rest = value.trim().strip_prefix('/')?;
427    let mut parts: Vec<String> = Vec::new();
428    for part in rest.split('/') {
429        if part.is_empty() || part == "." || part == ".." {
430            continue;
431        }
432        let cleaned = crate::links::sanitize_path_component(part);
433        if !cleaned.is_empty() {
434            parts.push(cleaned);
435        }
436    }
437    if parts.is_empty() {
438        return None;
439    }
440    let mut dest = parts.join("/");
441    if !dest.ends_with(".html") {
442        dest.push_str(".html");
443    }
444    Some(dest)
445}
446
447/// Convert a canonical source path to its sanitized `.html` output filename.
448///
449/// Public because a caller that must know where a source's HTML lands *before*
450/// rendering it has no other way to ask: `build_pages` applies this same rule
451/// internally, and re-deriving it elsewhere is how the two drift apart. It is
452/// also what `plates`'s collection calls, so a site's uploaded keys and its
453/// rendered links come from one function rather than from two that agree.
454///
455/// Ordinarily the extension is swapped and nothing else moves:
456/// `notes/post.md` publishes at `notes/post.html`, in any content format.
457///
458/// # A folder note publishes as its directory's index
459///
460/// A source whose file stem is the name of the directory holding it —
461/// `page/page.md`, `2026/2026.dj`, `about/about.html` — is that directory's
462/// own note, the same document an `about/index.md` would be. The two spellings
463/// are interchangeable across note-taking tools, and only one of them used to
464/// land on `about/index.html`; the other published at `about/about.html` and
465/// left the directory with no index at all, so a reader who asked for
466/// `about/` got nothing. Both now publish at `<dir>/index.html`.
467///
468/// `index.md` needs no case of its own here and never did: swapping its
469/// extension already yields `index.html`. This is the same destination reached
470/// by the other spelling, which is exactly why the two cannot both be used in
471/// one directory — `page/page.md` and `page/index.md` side by side claim
472/// `page/index.html` twice. Collection refuses that pair by name
473/// (`plates`'s `DestinationClaimedTwice`); nothing is reported here, because
474/// this function sees one path at a time and has no second one to name.
475///
476/// A file with no directory above it is nobody's folder note: `page.md` at the
477/// site root publishes at `page.html`. The comparison is against the immediate
478/// directory only, so `notes/page.md` is untouched.
479pub fn output_filename(canonical_md: &str) -> String {
480    let path = Path::new(canonical_md);
481    let folder_note = path
482        .file_stem()
483        .and_then(|s| s.to_str())
484        .zip(
485            path.parent()
486                .and_then(Path::file_name)
487                .and_then(|d| d.to_str()),
488        )
489        .is_some_and(|(stem, dir)| stem == dir);
490    let with_ext = if folder_note {
491        path.with_file_name("index.html")
492    } else {
493        path.with_extension("html")
494    };
495    let sanitized: PathBuf = with_ext
496        .components()
497        .map(|c| match c {
498            std::path::Component::Normal(s) => std::ffi::OsString::from(
499                crate::links::sanitize_path_component(&s.to_string_lossy()),
500            ),
501            other => other.as_os_str().to_owned(),
502        })
503        .collect();
504    sanitized.to_string_lossy().into_owned()
505}