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