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}