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::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 navigation link.
115#[derive(Debug, Clone)]
116pub struct NavLink {
117    /// Link href (relative path or anchor)
118    pub href: String,
119    /// Display title
120    pub title: String,
121}
122
123/// A processed file ready for publishing.
124#[derive(Debug, Clone)]
125pub struct PublishedPage {
126    /// Original source path
127    pub source_path: PathBuf,
128    /// Destination filename (e.g., "index.html" or "my-entry.html")
129    pub dest_filename: String,
130    /// Page title
131    pub title: String,
132    /// Rendered content in the output format (body only, no wrapper)
133    pub rendered_body: String,
134    /// Original markdown body
135    pub markdown_body: String,
136    /// Navigation links to children (from contents property)
137    pub contents_links: Vec<NavLink>,
138    /// Navigation link to parent (from part_of property)
139    pub parent_link: Option<NavLink>,
140    /// Whether this is the root index
141    pub is_root: bool,
142    /// Page description (from frontmatter `description`)
143    pub description: Option<String>,
144    /// Page author (from frontmatter `author`)
145    pub author: Option<String>,
146    /// Creation date (from frontmatter `created`)
147    pub created: Option<String>,
148    /// Last update date (from frontmatter `updated`)
149    pub updated: Option<String>,
150    /// The date the document is *about*, as opposed to when its file was made
151    /// (from frontmatter `date_of_document`). First link in the date chain a
152    /// grouped arrangement sorts by: `date_of_document` → `created` → `updated`,
153    /// the same chain a grouped view is cut by.
154    pub date_of_document: Option<String>,
155    /// The values this page groups under in a grouped arrangement — the date
156    /// cut to the view's grain, or the grouping field's values. Empty for a
157    /// containment arrangement, or for a page carrying nothing to group by
158    /// (which lands it in the "ungrouped" bucket rather than dropping it).
159    pub group_keys: Vec<String>,
160    /// Attachment paths (from frontmatter `attachments`)
161    pub attachments: Vec<String>,
162    /// Stylesheets this page pulls in (from frontmatter `styles`), as paths
163    /// below the site root — already resolved against the document that named
164    /// them, so `../theme.css` and `/theme.css` both arrive as `theme.css`.
165    ///
166    /// Emitted as `<link rel="stylesheet">` after the site stylesheet, rebased
167    /// to the page's own depth. The file itself is the caller's to copy, the
168    /// same way an `attachments` entry is.
169    pub styles: Vec<String>,
170    /// Scripts this page pulls in (from frontmatter `scripts`), resolved and
171    /// copied exactly like [`styles`](Self::styles) and emitted as
172    /// `<script defer src="…">` after the built-in interactivity script.
173    pub scripts: Vec<String>,
174    /// Which shell wraps this page (from frontmatter `layout`).
175    pub layout: PageLayout,
176    /// The shell template this page asked for by name (from frontmatter
177    /// `shell`), as the vault-relative path it was written as — the key into
178    /// [`SiteOptions::templates`](crate::site::SiteOptions::templates), since
179    /// the render crate reads no files.
180    ///
181    /// `None` for a page that takes the site's own shell, which is every page
182    /// that does not name one — and every `bare`/`verbatim` page, which take no
183    /// shell at all and so are never recorded as wanting one.
184    pub shell: Option<String>,
185    /// Override title shown in navigation (from frontmatter `nav_title`)
186    pub nav_title: Option<String>,
187    /// Sort order among siblings in navigation (from frontmatter `nav_order`)
188    pub nav_order: Option<i32>,
189    /// Whether to hide this page from the navigation tree
190    pub hide_from_nav: bool,
191    /// Whether to hide this page from RSS/Atom feeds
192    pub hide_from_feed: bool,
193    /// The source document's own identifier, read from frontmatter `id` — which
194    /// is prov's registry id for the file.
195    ///
196    /// Carried through the render untouched: nothing here reads it. It is here
197    /// because a caller that mints permalinks, builds an index, or addresses the
198    /// published object by identity needs to know which page each id belongs to,
199    /// and the render is the only place that pairing exists.
200    pub id: Option<String>,
201    /// The audience-scoped markdown source (frontmatter + visibility-filtered
202    /// body) uploaded as a sibling so the server can serve `?content`/`?json`.
203    pub source_markdown: String,
204}
205
206impl PublishedPage {
207    /// When the entry is *of*, as its vault wrote it:
208    /// `date_of_document` → `created` → `updated`.
209    ///
210    /// The one chain, so a site cannot disagree with itself. A grouped
211    /// arrangement files and orders entries by this (it is the chain prov's
212    /// `views` cuts by), and
213    /// the feeds, the sitemap and `article:published_time` used to answer a
214    /// different question — `updated` → `created` — so a journal of scanned
215    /// letters, whose `date_of_document` is the year it was written and whose
216    /// `created` is the day it was scanned, syndicated in scanning order while
217    /// its own front page listed it by letter date.
218    pub fn published_date(&self) -> Option<&str> {
219        self.date_of_document
220            .as_deref()
221            .or(self.created.as_deref())
222            .or(self.updated.as_deref())
223            .filter(|d| !d.is_empty())
224    }
225
226    /// When the entry last changed: `updated`, else whatever
227    /// [`published_date`](Self::published_date) found.
228    ///
229    /// What a sitemap's `lastmod` and a feed entry's `<updated>` mean, as
230    /// against the `<published>` above them.
231    pub fn modified_date(&self) -> Option<&str> {
232        self.updated
233            .as_deref()
234            .filter(|d| !d.is_empty())
235            .or_else(|| self.published_date())
236    }
237}
238
239/// One node of a site's **spanning outline**: the archive's own containment
240/// hierarchy, materialized by whoever holds the workspace.
241///
242/// A vault's spine is configured, not spelled: prov's `spanning:` names the
243/// relation whose links contain, and `contents:`/`part_of:` is one vault
244/// dialect's spelling of it. This crate cannot read a workspace's configuration
245/// — it reads nothing — so the layer that can walks the tree and hands the
246/// result down as plain data. See [`SiteOptions::outline`](crate::site::SiteOptions::outline).
247///
248/// [`path`](Self::path) is the source path in the coordinates
249/// [`SourceDoc::path`](crate::site::SourceDoc::path) is written in: rebased onto
250/// the site's anchor, sanitized, carrying the body's own extension. That is what
251/// lets a node be matched to the page it became without either side re-deriving
252/// the other's naming rule.
253///
254/// A node naming a document this site does not publish is not an error and not a
255/// nav entry — it is pruned, and its published descendants hoist to the nearest
256/// ancestor that *is* published. Under explicit-only visibility that is the
257/// ordinary shape, not the edge case.
258#[derive(Debug, Clone, Default)]
259pub struct OutlineNode {
260    /// The source path this node names, spelled as
261    /// [`SourceDoc::path`](crate::site::SourceDoc::path) spells it.
262    pub path: String,
263    /// The label the containing document's link carried (`[Label](path)`), when
264    /// it carried one. A fallback only: a page's own `nav_title`/`title` wins.
265    pub label: Option<String>,
266    /// Contained nodes, in the order the containing document declared them.
267    pub children: Vec<OutlineNode>,
268}
269
270/// One link between two documents, named by the relation that carries it.
271///
272/// A vault **declares its own relations** — `sequel`, `translation_of`,
273/// `author`, whatever its configuration says — so the name is data, never
274/// something this crate knows. Nothing here may hardcode a vocabulary: whatever
275/// names arrive are the names a template can address.
276///
277/// [`relation`](Self::relation) is `None` for a link written in prose, which has
278/// no name to be filed under. Those reach a template through `backlinks`, the
279/// flat union, and nowhere else — a reserved key for them would collide with a
280/// relation a vault is entitled to declare.
281///
282/// [`path`](Self::path) is the document at the far end, spelled as
283/// [`SourceDoc::path`](crate::site::SourceDoc::path) spells it — the same
284/// coordinates, so an edge can be matched to the page it names without either
285/// side re-deriving the other's naming rule.
286#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
287pub struct LinkEdge {
288    /// The relation this edge is written in, or `None` for a body link.
289    pub relation: Option<String>,
290    /// The document at the far end.
291    pub path: String,
292}
293
294/// A node in the full site navigation tree.
295#[derive(Debug, Clone)]
296pub struct SiteNavNode {
297    /// Node title
298    pub title: String,
299    /// Node href
300    pub href: String,
301    /// Whether this is the current page
302    pub is_current: bool,
303    /// Whether this node is an ancestor of the current page
304    pub is_ancestor_of_current: bool,
305    /// Child nodes
306    pub children: Vec<SiteNavNode>,
307}
308
309/// Full site navigation context for a specific page.
310#[derive(Debug, Clone)]
311pub struct SiteNavigation {
312    /// Full nav tree with current-page marking
313    pub tree: Vec<SiteNavNode>,
314    /// Breadcrumb trail from root to current page
315    pub breadcrumbs: Vec<NavLink>,
316}
317
318/// Result of a publishing operation.
319#[derive(Debug)]
320pub struct PublishResult {
321    /// Pages that were published
322    pub pages: Vec<PublishedPage>,
323    /// Total files processed
324    pub files_processed: usize,
325    /// Number of attachment files copied to the output directory
326    pub attachments_copied: usize,
327}
328
329/// What a grouped arrangement sorts entries into groups by.
330///
331/// prov's own, not a mirror of it. This used to be a redeclaration — the crate
332/// sits below the workspace layer and must stay portable to
333/// `wasm32-unknown-unknown`, so it kept its own `DateGrain` and `Grouping` with
334/// the spellings and prefix lengths copied across, on the reasoning that a site
335/// grouped "by year" must cut dates the same way the app's lens does or the
336/// published archive reads differently from the vault it came from.
337///
338/// Since prov 0.5 the grouping engine is `prov-views`, which reaches nothing
339/// that can write and is already in this crate's dependency graph. So the way to
340/// keep the two identical is to stop having two: the published site now groups
341/// through the same [`Grouping::keys_of`] the vault does, and "identical" is a
342/// fact rather than a promise two copies make to each other.
343pub use prov::views::{Grain, Grouping};
344
345/// How a site is arranged — the render-side half of a site's `view:`.
346#[derive(Debug, Clone, PartialEq, Eq, Default)]
347pub enum Arrangement {
348    /// Nav follows containment where audience filtering left it intact, and
349    /// pages the walk cannot reach become roots of their own. What a
350    /// hierarchical vault wants, and the behaviour when a site declares no view.
351    #[default]
352    Containment,
353    /// Entries are gathered into groups. The generated index shows the groups;
354    /// the nav lists entries in group order rather than by containment, because
355    /// a site that declared an arrangement asked for one.
356    Grouped(Grouping),
357}
358
359/// Normalize a frontmatter `serve_at:` value into a path below the site root,
360/// or `None` when it claims nothing this crate can serve.
361///
362/// The value is **site-root-absolute** and must start with `/`. That is what
363/// makes it a claim on the site's own layout rather than on the directory the
364/// document happens to sit in — and why, unlike a derived destination, it is
365/// never rebased onto a site's anchor: it is already written in the
366/// coordinates a rebasing would produce.
367///
368/// `/privacy` and `/privacy.html` are the same claim: a value that does not
369/// already end in `.html` gains it, because what is being named is a page and a
370/// page is an HTML file. Components are sanitized the way every other published
371/// path is, and `.`/`..` are dropped rather than resolved — a destination is a
372/// name *inside* the site, and there is nothing above the site root to reach.
373pub fn serve_at_dest(value: &str) -> Option<String> {
374    let rest = value.trim().strip_prefix('/')?;
375    let mut parts: Vec<String> = Vec::new();
376    for part in rest.split('/') {
377        if part.is_empty() || part == "." || part == ".." {
378            continue;
379        }
380        let cleaned = crate::links::sanitize_path_component(part);
381        if !cleaned.is_empty() {
382            parts.push(cleaned);
383        }
384    }
385    if parts.is_empty() {
386        return None;
387    }
388    let mut dest = parts.join("/");
389    if !dest.ends_with(".html") {
390        dest.push_str(".html");
391    }
392    Some(dest)
393}