Skip to main content

plates_render/
page.rs

1//! Page-shell helpers: navigation, breadcrumbs, SEO meta, feed/sitemap/robots
2//! generation, and small HTML/XML escaping utilities.
3//!
4//! These are pure functions over the value types in [`crate::types`]. The page
5//! *assembly* (full `<html>` document, theme/CSS/favicon) still lives in the
6//! publish plugin and will move here in a later slice.
7
8use crate::dates::{EPOCH_RFC3339, to_rfc822, to_rfc3339};
9use crate::links::{absolutize_html, root_prefix};
10use crate::types::{NavLink, PublishedPage, SiteNavNode, SiteNavigation};
11
12/// The newest-first order a feed lists entries in.
13///
14/// Sorts on [`PublishedPage::published_date`] — the same chain the site's own
15/// index groups and orders by — so a reader's list and the front page agree.
16/// Ties fall back to the title so a set of entries sharing a day is at least
17/// stable between builds.
18fn feed_items(pages: &[PublishedPage]) -> Vec<&PublishedPage> {
19    let mut items: Vec<&PublishedPage> = pages
20        .iter()
21        .filter(|p| !p.is_root && p.contents_links.is_empty() && !p.hide_from_feed)
22        .collect();
23
24    items.sort_by(|a, b| newest_first(a, b));
25    items.truncate(50);
26    items
27}
28
29/// Order two entries newest-first, with the undated last.
30///
31/// The one answer to "which of these comes first", shared with the grouped
32/// index in [`crate::site`] so a site cannot list its entries in one order and
33/// syndicate them in another.
34///
35/// Two things it has to get right, both of which a naive comparison gets wrong:
36///
37/// **The date is normalized before it is compared.** Sorting the raw string
38/// looks equivalent, since ISO dates sort lexicographically — right up until a
39/// date is not an ISO date, and a vault has ordinary ways to hand over one that
40/// is not. `date_of_document: unknown` is the conventional marker for a
41/// deliberately-undated record: a shoebox
42/// of scans imported on one afternoon must not inherit that afternoon. The
43/// chain is first-key-*present* wins, so the marker stops it here as it does in
44/// a view — but `'u' > '2'`, so a descending raw-string sort put every undated
45/// record *above* every dated one, at the head of the feed. Nothing validates a
46/// `type: date` field, so a typo (`19430512`) or a human spelling (`May 1943`)
47/// arrives the same way and sorts by its own first character.
48///
49/// **Undated is not a date.** The obvious repair — normalize, and let an
50/// unreadable one fall back to [`EPOCH_RFC3339`] like the emitted element does
51/// — is wrong for the archive this program is for. The epoch is not a floor,
52/// it is 1970, so an undated scan would sort *above* every letter written
53/// before it. A record with no readable date is therefore ordered as absent
54/// rather than as a moment, and lands after everything that has one.
55pub(crate) fn newest_first(a: &PublishedPage, b: &PublishedPage) -> std::cmp::Ordering {
56    use std::cmp::Ordering;
57
58    let key = |p: &PublishedPage| p.published_date().and_then(to_rfc3339);
59    match (key(a), key(b)) {
60        (Some(x), Some(y)) => y.cmp(&x),
61        (Some(_), None) => Ordering::Less,
62        (None, Some(_)) => Ordering::Greater,
63        (None, None) => Ordering::Equal,
64    }
65    // Ties fall back to the title so a set of entries sharing a day — or
66    // sharing no day at all — is at least stable between builds.
67    .then_with(|| a.title.cmp(&b.title))
68}
69
70/// Escape HTML special characters.
71pub fn html_escape(s: &str) -> String {
72    s.replace('&', "&amp;")
73        .replace('<', "&lt;")
74        .replace('>', "&gt;")
75        .replace('"', "&quot;")
76        .replace('\'', "&#39;")
77}
78
79/// Convert a title to an anchor ID.
80///
81/// [`prov::link::slug`] is the one slug rule in the project, so a heading anchor
82/// and the filename prov would mint for the same title agree. It drops
83/// punctuation rather than turning it into a separator (`v1.0 Release` becomes
84/// `v10-release`), and yields `"untitled"` for a title with nothing slug-able.
85pub fn title_to_anchor(title: &str) -> String {
86    prov::link::slug(title)
87}
88
89/// Render the site navigation: the mobile bar, then the sidebar.
90///
91/// `site_title` names the masthead — the link home at the top of the sidebar,
92/// which is where a site's name goes on every other site. The front page
93/// leaves the tree for it: when the tree's one root is the front page
94/// (`index.html`, the destination this crate gives every root), the list
95/// starts at its children, so an entry sits at the depth it has rather than
96/// one deeper. A rootless forest lists as it is, under a masthead that still
97/// links to `index.html`, which is where a supplied or synthesized front page
98/// lands.
99///
100/// A node with children is a disclosure — `<details>` around a `<summary>`
101/// holding the link — written `open` on the current page's ancestors and on
102/// the current page's own node, and closed everywhere else. The whole tree is
103/// still in the HTML: crawlable, printable, searchable with the browser's
104/// find, and correct with scripting off. No state is stored anywhere; which
105/// sections are open is a function of which page this is.
106///
107/// The link and the disclosure are separate targets: the `<a>` is the
108/// summary's content, so activating it navigates, and the rest of the summary
109/// — the chevron the stylesheet draws — toggles.
110pub fn render_site_nav(nav: &SiteNavigation, site_title: &str, root_prefix: &str) -> String {
111    if nav.tree.is_empty() {
112        return String::new();
113    }
114
115    fn render_nodes(nodes: &[SiteNavNode], prefix: &str) -> String {
116        let mut html = String::from("<ul class=\"nav-list\">");
117        for node in nodes {
118            let mut classes = Vec::new();
119            if !node.children.is_empty() {
120                classes.push("nav-section");
121            }
122            if node.is_current {
123                classes.push("nav-current");
124            }
125            if node.is_ancestor_of_current {
126                classes.push("nav-ancestor");
127            }
128
129            let class_attr = if classes.is_empty() {
130                String::new()
131            } else {
132                format!(r#" class="{}""#, classes.join(" "))
133            };
134
135            let aria = if node.is_current {
136                r#" aria-current="page""#
137            } else {
138                ""
139            };
140
141            let link = format!(
142                r#"<a href="{prefix}{href}"{aria}>{title}</a>"#,
143                prefix = prefix,
144                href = html_escape(&node.href),
145                aria = aria,
146                title = html_escape(&node.title),
147            );
148
149            if node.children.is_empty() {
150                html.push_str(&format!("<li{class_attr}>{link}</li>"));
151            } else {
152                let open = if node.is_current || node.is_ancestor_of_current {
153                    " open"
154                } else {
155                    ""
156                };
157                html.push_str(&format!(
158                    "<li{class_attr}><details{open}><summary>{link}</summary>{children}</details></li>",
159                    children = render_nodes(&node.children, prefix),
160                ));
161            }
162        }
163        html.push_str("</ul>");
164        html
165    }
166
167    // The front page is the masthead, not a row: its children are the top of
168    // the list. A forest — no root, or a supplied front page this crate never
169    // saw — lists as it stands.
170    let (on_front_page, top): (bool, &[SiteNavNode]) = match nav.tree.as_slice() {
171        [root] if root.href == FRONT_PAGE_DEST => (root.is_current, &root.children),
172        forest => (false, forest),
173    };
174    let masthead_aria = if on_front_page {
175        r#" aria-current="page""#
176    } else {
177        ""
178    };
179    let masthead = format!(
180        r#"<a class="site-masthead" href="{prefix}{FRONT_PAGE_DEST}"{aria}>{title}</a>"#,
181        prefix = root_prefix,
182        aria = masthead_aria,
183        title = html_escape(site_title),
184    );
185    let nav_list = if top.is_empty() {
186        String::new()
187    } else {
188        format!("\n{}", render_nodes(top, root_prefix))
189    };
190
191    // The drawer is a checkbox, not a button: `.nav-toggle-state:checked`
192    // slides the sidebar out with no script at all, so it opens for a reader
193    // with scripting off and inside a sandboxed frame that grants none — an
194    // encrypted site's reader shell renders every page in one. The script
195    // this crate emits only adds what CSS cannot: closing on a click outside
196    // or Escape, and scrolling the current row into view. The input precedes
197    // both the bar and the nav because `~` reaches a following sibling only.
198    format!(
199        r#"<input class="nav-toggle-state" type="checkbox" id="nav-toggle" aria-controls="site-nav" aria-label="Menu">
200<header class="site-bar">
201    <label class="nav-toggle" for="nav-toggle">Menu</label>
202    {masthead}
203</header>
204<nav class="site-nav" id="site-nav" aria-label="Site navigation">
205{masthead}{nav_list}
206</nav>"#,
207    )
208}
209
210/// Where the site's front page lands, which is the one destination this crate
211/// decides for itself (`site::dest_for`): the masthead links to it, and a
212/// tree rooted there is a tree whose root is the front page.
213const FRONT_PAGE_DEST: &str = "index.html";
214
215/// The pager: links to the page before and after `current` in the nav's
216/// reading order, or nothing for a page that has neither.
217///
218/// `rel="prev"`/`rel="next"` are what a reader mode and a search engine read a
219/// sequence off. The order is [`crate::nav::reading_order`]'s — the depth-first
220/// order the sidebar lists — so a page's "next" is the row below it.
221pub fn render_pager(order: &[NavLink], current: &str, root_prefix: &str) -> String {
222    let (prev, next) = crate::nav::neighbours(order, current);
223    if prev.is_none() && next.is_none() {
224        return String::new();
225    }
226    let link = |rel: &str, label: &str, target: &NavLink| {
227        format!(
228            r#"<a class="pager-{rel}" rel="{rel}" href="{prefix}{href}"><span>{label}</span> {title}</a>"#,
229            prefix = root_prefix,
230            href = html_escape(&target.href),
231            title = html_escape(&target.title),
232        )
233    };
234    let mut out = String::from(r#"<nav class="pager" aria-label="Pager">"#);
235    if let Some(prev) = prev {
236        out.push_str(&link("prev", "Previous", prev));
237    }
238    if let Some(next) = next {
239        out.push_str(&link("next", "Next", next));
240    }
241    out.push_str("</nav>");
242    out
243}
244
245/// Render full breadcrumb trail from root to current page.
246pub fn render_full_breadcrumbs(breadcrumbs: &[NavLink], prefix: &str) -> String {
247    if breadcrumbs.len() <= 1 {
248        return String::new();
249    }
250
251    let items: Vec<String> = breadcrumbs
252        .iter()
253        .enumerate()
254        .map(|(i, crumb)| {
255            if i == breadcrumbs.len() - 1 {
256                // Current page — no link
257                format!(
258                    r#"<span aria-current="page">{}</span>"#,
259                    html_escape(&crumb.title)
260                )
261            } else {
262                format!(
263                    r#"<a href="{}{}">{}</a>"#,
264                    prefix,
265                    html_escape(&crumb.href),
266                    html_escape(&crumb.title)
267                )
268            }
269        })
270        .collect();
271
272    format!(
273        r#"<nav class="breadcrumbs" aria-label="Breadcrumb">{}</nav>"#,
274        items.join(r#" <span class="breadcrumb-sep">/</span> "#)
275    )
276}
277
278/// Render breadcrumb navigation (parent link above the title).
279pub fn render_breadcrumb(page: &PublishedPage, single_file: bool) -> String {
280    let prefix = root_prefix(&page.dest_filename);
281    if let Some(ref parent) = page.parent_link {
282        let href = if single_file {
283            format!("#{}", title_to_anchor(&parent.title))
284        } else {
285            format!("{}{}", prefix, parent.href)
286        };
287        format!(
288            r#"<nav class="breadcrumb" aria-label="Breadcrumb"><a href="{}">{}</a></nav>"#,
289            html_escape(&href),
290            html_escape(&parent.title),
291        )
292    } else {
293        String::new()
294    }
295}
296
297/// Generate SEO meta tags for a page.
298pub fn generate_seo_meta(page: &PublishedPage, site_title: &str, base_url: &str) -> String {
299    let mut tags = Vec::new();
300
301    // og:title
302    tags.push(format!(
303        r#"<meta property="og:title" content="{}">"#,
304        html_escape(&page.title)
305    ));
306
307    // description + og:description
308    if let Some(ref desc) = page.description {
309        tags.push(format!(
310            r#"<meta name="description" content="{}">"#,
311            html_escape(desc)
312        ));
313        tags.push(format!(
314            r#"<meta property="og:description" content="{}">"#,
315            html_escape(desc)
316        ));
317    }
318
319    // author
320    if let Some(ref author) = page.author {
321        tags.push(format!(
322            r#"<meta name="author" content="{}">"#,
323            html_escape(author)
324        ));
325    }
326
327    // article:published_time — the date the entry is *of*, matching the order
328    // the site's own index lists it in, and in the RFC 3339 the Open Graph
329    // spec asks for rather than whatever the frontmatter happened to say.
330    if let Some(published) = page.published_date().and_then(to_rfc3339) {
331        tags.push(format!(
332            r#"<meta property="article:published_time" content="{}">"#,
333            html_escape(&published)
334        ));
335    }
336
337    // article:modified_time
338    if let Some(modified) = page.modified_date().and_then(to_rfc3339) {
339        tags.push(format!(
340            r#"<meta property="article:modified_time" content="{}">"#,
341            html_escape(&modified)
342        ));
343    }
344
345    // og:image — scan attachments for images, then fall back to first <img> in body
346    let og_image = find_og_image(page);
347    if let Some(img_url) = og_image {
348        let full_url = if img_url.starts_with("http://") || img_url.starts_with("https://") {
349            img_url
350        } else if !base_url.is_empty() {
351            format!(
352                "{}/{}",
353                base_url.trim_end_matches('/'),
354                img_url.trim_start_matches('/')
355            )
356        } else {
357            img_url
358        };
359        tags.push(format!(
360            r#"<meta property="og:image" content="{}">"#,
361            html_escape(&full_url)
362        ));
363    }
364
365    // og:type
366    let og_type = if page.is_root { "website" } else { "article" };
367    tags.push(format!(
368        r#"<meta property="og:type" content="{}">"#,
369        og_type
370    ));
371
372    // og:site_name
373    tags.push(format!(
374        r#"<meta property="og:site_name" content="{}">"#,
375        html_escape(site_title)
376    ));
377
378    // og:url + canonical
379    if !base_url.is_empty() {
380        let url = format!("{}/{}", base_url.trim_end_matches('/'), page.dest_filename);
381        tags.push(format!(
382            r#"<meta property="og:url" content="{}">"#,
383            html_escape(&url)
384        ));
385        tags.push(format!(
386            r#"<link rel="canonical" href="{}">"#,
387            html_escape(&url)
388        ));
389    }
390
391    tags.join("\n    ")
392}
393
394/// Find the best og:image for a page.
395fn find_og_image(page: &PublishedPage) -> Option<String> {
396    const IMAGE_EXTENSIONS: &[&str] = &[".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"];
397
398    // Check attachments for images
399    for s in &page.attachments {
400        let lower = s.to_lowercase();
401        if IMAGE_EXTENSIONS.iter().any(|ext| lower.ends_with(ext)) {
402            // An attachment is a link value like any other: unwrap `[alt](target)`
403            // and resolve it against the page that carries it.
404            let target = prov::Link::parse_path_only(s.trim()).target;
405            return Some(
406                prov::link::resolve(&page.source_path, &target)
407                    .to_string_lossy()
408                    .into_owned(),
409            );
410        }
411    }
412
413    // Fall back to first <img src="..."> in rendered body
414    if let Some(pos) = page.rendered_body.find("src=\"") {
415        let after = &page.rendered_body[pos + 5..];
416        if let Some(end) = after.find('"') {
417            return Some(after[..end].to_string());
418        }
419    }
420
421    None
422}
423
424/// Generate `<link>` tags for Atom and RSS feeds.
425pub fn generate_feed_link_tags(root_prefix: &str) -> String {
426    format!(
427        r#"<link rel="alternate" type="application/atom+xml" title="Atom Feed" href="{}feed.xml">
428    <link rel="alternate" type="application/rss+xml" title="RSS Feed" href="{}rss.xml">"#,
429        root_prefix, root_prefix,
430    )
431}
432
433/// Generate a sitemap.xml from published pages.
434pub fn generate_sitemap(pages: &[PublishedPage], base_url: &str) -> String {
435    let base = base_url.trim_end_matches('/');
436    let mut xml = String::from(
437        r#"<?xml version="1.0" encoding="UTF-8"?>
438<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
439"#,
440    );
441
442    for page in pages {
443        let loc = format!("{}/{}", base, page.dest_filename);
444        // W3C Datetime, which is what a sitemap's `lastmod` is specified as and
445        // which RFC 3339 satisfies. A date the vault wrote in some other
446        // spelling is left out rather than emitted for a crawler to reject.
447        let lastmod = page
448            .modified_date()
449            .and_then(to_rfc3339)
450            .unwrap_or_default();
451        let priority = if page.is_root {
452            "1.0"
453        } else if !page.contents_links.is_empty() {
454            "0.8"
455        } else {
456            "0.6"
457        };
458
459        xml.push_str("  <url>\n");
460        xml.push_str(&format!("    <loc>{}</loc>\n", xml_escape(&loc)));
461        if !lastmod.is_empty() {
462            xml.push_str(&format!(
463                "    <lastmod>{}</lastmod>\n",
464                xml_escape(&lastmod)
465            ));
466        }
467        xml.push_str(&format!("    <priority>{}</priority>\n", priority));
468        xml.push_str("  </url>\n");
469    }
470
471    xml.push_str("</urlset>\n");
472    xml
473}
474
475/// Generate robots.txt content.
476pub fn generate_robots_txt(base_url: &str, is_public: bool) -> String {
477    if is_public {
478        format!(
479            "User-agent: *\nAllow: /\nSitemap: {}/sitemap.xml\n",
480            base_url.trim_end_matches('/')
481        )
482    } else {
483        "User-agent: *\nDisallow: /\n".to_string()
484    }
485}
486
487/// Generate an Atom 1.0 feed.
488pub fn generate_atom_feed(
489    pages: &[PublishedPage],
490    site_title: &str,
491    base_url: &str,
492    site_description: &str,
493    site_author: &str,
494) -> String {
495    let base = base_url.trim_end_matches('/');
496
497    let items = feed_items(pages);
498
499    // Atom makes the feed's own `<updated>` mandatory, so this one falls back
500    // rather than being omitted.
501    let feed_updated = items
502        .first()
503        .and_then(|p| p.modified_date())
504        .and_then(to_rfc3339)
505        .unwrap_or_else(|| EPOCH_RFC3339.to_string());
506
507    let mut xml = format!(
508        r#"<?xml version="1.0" encoding="UTF-8"?>
509<feed xmlns="http://www.w3.org/2005/Atom">
510  <title>{title}</title>
511  <link href="{base}/" rel="alternate"/>
512  <link href="{base}/feed.xml" rel="self"/>
513  <id>{base}/</id>
514  <updated>{updated}</updated>
515"#,
516        title = xml_escape(site_title),
517        base = xml_escape(base),
518        updated = xml_escape(&feed_updated),
519    );
520
521    if !site_author.is_empty() {
522        xml.push_str(&format!(
523            "  <author><name>{}</name></author>\n",
524            xml_escape(site_author)
525        ));
526    }
527    if !site_description.is_empty() {
528        xml.push_str(&format!(
529            "  <subtitle>{}</subtitle>\n",
530            xml_escape(site_description)
531        ));
532    }
533
534    for page in &items {
535        let link = format!("{}/{}", base, page.dest_filename);
536        let published = page.published_date().and_then(to_rfc3339);
537        // Mandatory on every entry, like the feed's own above.
538        let updated = page
539            .modified_date()
540            .and_then(to_rfc3339)
541            .unwrap_or_else(|| EPOCH_RFC3339.to_string());
542        let summary = strip_html_truncate(&page.rendered_body, 280);
543
544        xml.push_str("  <entry>\n");
545        xml.push_str(&format!("    <title>{}</title>\n", xml_escape(&page.title)));
546        xml.push_str(&format!(
547            "    <link href=\"{}\" rel=\"alternate\"/>\n",
548            xml_escape(&link)
549        ));
550        xml.push_str(&format!("    <id>{}</id>\n", xml_escape(&link)));
551        if let Some(published) = published {
552            xml.push_str(&format!(
553                "    <published>{}</published>\n",
554                xml_escape(&published)
555            ));
556        }
557        xml.push_str(&format!(
558            "    <updated>{}</updated>\n",
559            xml_escape(&updated)
560        ));
561        if !summary.is_empty() {
562            xml.push_str(&format!(
563                "    <summary>{}</summary>\n",
564                xml_escape(&summary)
565            ));
566        }
567        // The body leaves the site here, so its page-relative links have to be
568        // resolved now — a reader has no way to reconstruct the base later.
569        xml.push_str(&format!(
570            "    <content type=\"html\"><![CDATA[{}]]></content>\n",
571            absolutize_html(&page.rendered_body, &page.dest_filename, base)
572        ));
573        xml.push_str("  </entry>\n");
574    }
575
576    xml.push_str("</feed>\n");
577    xml
578}
579
580/// Generate an RSS 2.0 feed.
581pub fn generate_rss_feed(
582    pages: &[PublishedPage],
583    site_title: &str,
584    base_url: &str,
585    site_description: &str,
586    _site_author: &str,
587) -> String {
588    let base = base_url.trim_end_matches('/');
589
590    let items = feed_items(pages);
591
592    // RSS 2.0 dates are RFC 822, which is a different grammar from Atom's —
593    // hence the second spelling of the same instants.
594    let last_build = items
595        .first()
596        .and_then(|p| p.modified_date())
597        .and_then(to_rfc822)
598        .unwrap_or_default();
599
600    let desc = if site_description.is_empty() {
601        site_title
602    } else {
603        site_description
604    };
605
606    let mut xml = format!(
607        r#"<?xml version="1.0" encoding="UTF-8"?>
608<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
609<channel>
610  <title>{title}</title>
611  <link>{base}/</link>
612  <description>{description}</description>
613  <atom:link href="{base}/rss.xml" rel="self" type="application/rss+xml"/>
614"#,
615        title = xml_escape(site_title),
616        base = xml_escape(base),
617        description = xml_escape(desc),
618    );
619
620    if !last_build.is_empty() {
621        xml.push_str(&format!(
622            "  <lastBuildDate>{}</lastBuildDate>\n",
623            xml_escape(&last_build)
624        ));
625    }
626
627    for page in &items {
628        let link = format!("{}/{}", base, page.dest_filename);
629        let pub_date = page.published_date().and_then(to_rfc822);
630
631        xml.push_str("  <item>\n");
632        xml.push_str(&format!("    <title>{}</title>\n", xml_escape(&page.title)));
633        xml.push_str(&format!("    <link>{}</link>\n", xml_escape(&link)));
634        xml.push_str(&format!(
635            "    <guid isPermaLink=\"true\">{}</guid>\n",
636            xml_escape(&link)
637        ));
638        if let Some(pub_date) = pub_date {
639            xml.push_str(&format!(
640                "    <pubDate>{}</pubDate>\n",
641                xml_escape(&pub_date)
642            ));
643        }
644        xml.push_str(&format!(
645            "    <description><![CDATA[{}]]></description>\n",
646            absolutize_html(&page.rendered_body, &page.dest_filename, base)
647        ));
648        xml.push_str("  </item>\n");
649    }
650
651    xml.push_str("</channel>\n</rss>\n");
652    xml
653}
654
655/// Strip HTML tags and truncate to `max_len` characters.
656fn strip_html_truncate(html: &str, max_len: usize) -> String {
657    let mut text = String::new();
658    let mut in_tag = false;
659
660    for ch in html.chars() {
661        if ch == '<' {
662            in_tag = true;
663            continue;
664        }
665        if ch == '>' {
666            in_tag = false;
667            continue;
668        }
669        if !in_tag {
670            text.push(ch);
671            if text.len() >= max_len {
672                break;
673            }
674        }
675    }
676
677    text.trim().to_string()
678}
679
680/// Escape characters for XML content.
681fn xml_escape(s: &str) -> String {
682    s.replace('&', "&amp;")
683        .replace('<', "&lt;")
684        .replace('>', "&gt;")
685        .replace('"', "&quot;")
686        .replace('\'', "&apos;")
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692    use crate::types::{NavLink, PageLayout, SiteNavNode};
693    use std::path::PathBuf;
694
695    fn make_page(dest: &str, title: &str, is_root: bool) -> PublishedPage {
696        PublishedPage {
697            source_path: PathBuf::from(format!("/workspace/{}", dest.replace(".html", ".md"))),
698            dest_filename: dest.to_string(),
699            title: title.to_string(),
700            rendered_body: "<p>Hello world</p>".to_string(),
701            markdown_body: "Hello world".to_string(),
702            contents_links: vec![],
703            parent_link: None,
704            is_root,
705            description: None,
706            author: None,
707            created: None,
708            updated: None,
709            date_of_document: None,
710            group_keys: vec![],
711            attachments: vec![],
712            styles: vec![],
713            scripts: vec![],
714            layout: PageLayout::default(),
715            shell: None,
716            lang: None,
717            nav_title: None,
718            nav_order: None,
719            hide_from_nav: false,
720            hide_from_feed: false,
721            id: None,
722            source_markdown: String::new(),
723            headings: vec![],
724            toc: true,
725        }
726    }
727
728    #[test]
729    fn test_seo_meta_basic() {
730        let mut page = make_page("about.html", "About", false);
731        page.description = Some("A test page".into());
732        page.author = Some("Alice".into());
733        let meta = generate_seo_meta(&page, "My Site", "https://example.com");
734
735        assert!(meta.contains(r#"og:title" content="About""#));
736        assert!(meta.contains(r#"name="description" content="A test page""#));
737        assert!(meta.contains(r#"og:description" content="A test page""#));
738        assert!(meta.contains(r#"name="author" content="Alice""#));
739        assert!(meta.contains(r#"og:type" content="article""#));
740        assert!(meta.contains(r#"og:site_name" content="My Site""#));
741        assert!(meta.contains(r#"og:url" content="https://example.com/about.html""#));
742        assert!(meta.contains(r#"canonical" href="https://example.com/about.html""#));
743    }
744
745    #[test]
746    fn test_seo_meta_root_is_website_type() {
747        let page = make_page("index.html", "Home", true);
748        let meta = generate_seo_meta(&page, "My Site", "https://example.com");
749        assert!(meta.contains(r#"og:type" content="website""#));
750    }
751
752    #[test]
753    fn test_seo_meta_no_base_url() {
754        let page = make_page("page.html", "Page", false);
755        let meta = generate_seo_meta(&page, "Site", "");
756        assert!(!meta.contains("canonical"));
757        assert!(!meta.contains("og:url"));
758    }
759
760    #[test]
761    fn test_sitemap_structure() {
762        let root = make_page("index.html", "Home", true);
763        let mut child = make_page("child.html", "Child", false);
764        child.contents_links = vec![NavLink {
765            href: "leaf.html".into(),
766            title: "Leaf".into(),
767        }];
768        let leaf = make_page("leaf.html", "Leaf", false);
769
770        let sitemap = generate_sitemap(&[root, child, leaf], "https://example.com");
771
772        assert!(sitemap.contains("<loc>https://example.com/index.html</loc>"));
773        assert!(sitemap.contains("<priority>1.0</priority>")); // root
774        assert!(sitemap.contains("<priority>0.8</priority>")); // child with contents
775        assert!(sitemap.contains("<priority>0.6</priority>")); // leaf
776    }
777
778    #[test]
779    fn test_robots_txt_public() {
780        let robots = generate_robots_txt("https://example.com", true);
781        assert!(robots.contains("Allow: /"));
782        assert!(robots.contains("Sitemap: https://example.com/sitemap.xml"));
783    }
784
785    #[test]
786    fn test_robots_txt_private() {
787        let robots = generate_robots_txt("https://example.com", false);
788        assert!(robots.contains("Disallow: /"));
789        assert!(!robots.contains("Sitemap"));
790    }
791
792    #[test]
793    fn test_atom_feed_excludes_root_and_index_pages() {
794        let root = make_page("index.html", "Home", true);
795        let mut index_child = make_page("section.html", "Section", false);
796        index_child.contents_links = vec![NavLink {
797            href: "leaf.html".into(),
798            title: "Leaf".into(),
799        }];
800        let leaf = make_page("leaf.html", "Leaf", false);
801
802        let atom = generate_atom_feed(
803            &[root, index_child, leaf],
804            "Site",
805            "https://example.com",
806            "",
807            "",
808        );
809
810        // Only the leaf should appear as an entry
811        assert_eq!(atom.matches("<entry>").count(), 1);
812        assert!(atom.contains("<title>Leaf</title>"));
813        assert!(!atom.contains("<title>Home</title>"));
814        assert!(!atom.contains("<title>Section</title>"));
815    }
816
817    #[test]
818    fn test_atom_feed_hide_from_feed() {
819        let root = make_page("index.html", "Home", true);
820        let mut hidden = make_page("hidden.html", "Hidden", false);
821        hidden.hide_from_feed = true;
822        let visible = make_page("visible.html", "Visible", false);
823
824        let atom = generate_atom_feed(
825            &[root, hidden, visible],
826            "Site",
827            "https://example.com",
828            "",
829            "",
830        );
831
832        assert_eq!(atom.matches("<entry>").count(), 1);
833        assert!(atom.contains("<title>Visible</title>"));
834        assert!(!atom.contains("<title>Hidden</title>"));
835    }
836
837    #[test]
838    fn test_rss_feed_structure() {
839        let root = make_page("index.html", "Home", true);
840        let mut leaf = make_page("post.html", "Post", false);
841        leaf.created = Some("2024-01-15".into());
842
843        let rss = generate_rss_feed(
844            &[root, leaf],
845            "My Blog",
846            "https://example.com",
847            "A blog",
848            "Author",
849        );
850
851        assert!(rss.contains("<title>My Blog</title>"));
852        assert!(rss.contains("<description>A blog</description>"));
853        assert!(rss.contains("<title>Post</title>"));
854        assert!(rss.contains("<guid isPermaLink=\"true\">https://example.com/post.html</guid>"));
855        // RFC 822, not the `2024-01-15` the vault wrote: RSS specifies the
856        // grammar, and readers hold it to that.
857        assert!(
858            rss.contains("<pubDate>Mon, 15 Jan 2024 00:00:00 +0000</pubDate>"),
859            "got {rss}"
860        );
861    }
862
863    /// The date chain the site's index groups by, answered the same way by the
864    /// feeds. A scanned letter's `date_of_document` is the year it was written
865    /// and its `created` is the day it was scanned; the feed used to order by
866    /// the latter while the front page listed by the former.
867    #[test]
868    fn feeds_order_by_the_same_date_chain_the_index_does() {
869        let mut letter = make_page("letter.html", "Letter", false);
870        letter.date_of_document = Some("1944-06-06".into());
871        letter.created = Some("2026-08-16".into());
872
873        let mut note = make_page("note.html", "Note", false);
874        note.created = Some("2026-01-02".into());
875
876        let pages = [letter, note];
877        let atom = generate_atom_feed(&pages, "Site", "https://ex.com", "", "");
878
879        // The letter is *of* 1944, so it sorts below the 2026 note and is
880        // published as its own date rather than its scanning date.
881        assert!(
882            atom.find("<title>Note</title>") < atom.find("<title>Letter</title>"),
883            "got {atom}"
884        );
885        assert!(atom.contains("<published>1944-06-06T00:00:00Z</published>"));
886        assert!(!atom.contains("1944-06-06</published>\n    <published>"));
887
888        let rss = generate_rss_feed(&pages, "Site", "https://ex.com", "", "");
889        assert!(rss.contains("<pubDate>Tue, 06 Jun 1944 00:00:00 +0000</pubDate>"));
890    }
891
892    /// `date_of_document: unknown` is the marker for a record that is
893    /// undated on purpose — a shoebox of scans must not inherit the afternoon
894    /// it was imported. The chain stops at the marker correctly, but a
895    /// descending sort of the *raw* string put `"unknown"` above every ISO
896    /// date, so the undated scans headed the feed wearing a 1970 timestamp.
897    #[test]
898    fn a_deliberately_undated_record_sorts_to_the_bottom() {
899        let mut undated = make_page("undated.html", "Undated", false);
900        undated.date_of_document = Some("unknown".into());
901        // Pre-epoch on purpose. Normalizing an unreadable date to
902        // `EPOCH_RFC3339` and sorting on that would place the undated scan
903        // *above* this letter, because the epoch is not a floor — it is 1970,
904        // and an archive of a family's papers is mostly older than that.
905        let mut old = make_page("old.html", "Old", false);
906        old.date_of_document = Some("1943-05-12".into());
907        let mut new = make_page("new.html", "New", false);
908        new.date_of_document = Some("2026-08-16".into());
909
910        let pages = [undated, old, new];
911        let atom = generate_atom_feed(&pages, "Site", "https://ex.com", "", "");
912
913        let at = |t: &str| atom.find(&format!("<title>{t}</title>")).unwrap();
914        assert!(
915            at("New") < at("Old") && at("Old") < at("Undated"),
916            "newest first, and the undated record last: {atom}"
917        );
918        // Where it sorts and what it says agree: 1970 sorts like 1970.
919        assert!(!atom.contains("<published>unknown</published>"));
920    }
921
922    /// Nothing validates a `type: date` field, so a typo and a human spelling
923    /// reach the feed the same way the marker does — and used to sort by their
924    /// own first character, above or below the ISO dates by accident.
925    #[test]
926    fn an_unparseable_date_does_not_sort_by_its_spelling() {
927        let mut wordy = make_page("wordy.html", "Wordy", false);
928        wordy.date_of_document = Some("May 1943".into());
929        let mut typo = make_page("typo.html", "Typo", false);
930        typo.date_of_document = Some("19430512".into());
931        let mut dated = make_page("dated.html", "Dated", false);
932        dated.date_of_document = Some("2026-08-16".into());
933
934        let pages = [wordy, typo, dated];
935        let atom = generate_atom_feed(&pages, "Site", "https://ex.com", "", "");
936
937        let at = |t: &str| atom.find(&format!("<title>{t}</title>")).unwrap();
938        assert!(
939            at("Dated") < at("Typo") && at("Dated") < at("Wordy"),
940            "the only readable date leads: {atom}"
941        );
942    }
943
944    /// Atom makes `<updated>` mandatory on the feed and on every entry, so an
945    /// entry the vault gave no date at all still has to carry one.
946    #[test]
947    fn an_undated_entry_still_carries_a_valid_atom_updated() {
948        let page = make_page("post.html", "Post", false);
949        let atom = generate_atom_feed(&[page], "Site", "https://ex.com", "", "");
950
951        assert!(atom.contains(&format!("<updated>{EPOCH_RFC3339}</updated>")));
952        // …but not a `<published>` it would have had to invent.
953        assert!(!atom.contains("<published>"));
954    }
955
956    /// A date the vault wrote in a spelling no feed grammar recognizes is left
957    /// out rather than passed through for a validator to choke on.
958    #[test]
959    fn an_unreadable_date_is_omitted_not_forwarded() {
960        let mut page = make_page("post.html", "Post", false);
961        page.created = Some("sometime last summer".into());
962
963        let atom = generate_atom_feed(&[page.clone()], "Site", "https://ex.com", "", "");
964        assert!(!atom.contains("sometime last summer"));
965        assert!(atom.contains(&format!("<updated>{EPOCH_RFC3339}</updated>")));
966
967        let rss = generate_rss_feed(&[page.clone()], "Site", "https://ex.com", "", "");
968        assert!(!rss.contains("sometime last summer"));
969        assert!(!rss.contains("<pubDate>"));
970
971        let sitemap = generate_sitemap(&[page.clone()], "https://ex.com");
972        assert!(!sitemap.contains("<lastmod>"));
973
974        let meta = generate_seo_meta(&page, "Site", "https://ex.com");
975        assert!(!meta.contains("article:published_time"));
976    }
977
978    /// A sitemap's `lastmod` is a W3C Datetime, which RFC 3339 satisfies and a
979    /// bare vault date does not reliably.
980    #[test]
981    fn sitemap_lastmod_is_a_w3c_datetime() {
982        let mut page = make_page("post.html", "Post", false);
983        page.updated = Some("2026-08-16".into());
984        let sitemap = generate_sitemap(&[page], "https://ex.com");
985        assert!(sitemap.contains("<lastmod>2026-08-16T00:00:00Z</lastmod>"));
986    }
987
988    /// Open Graph asks for RFC 3339 here too, and the published time follows
989    /// the same chain the feeds do.
990    #[test]
991    fn seo_article_times_are_rfc3339_from_the_shared_chain() {
992        let mut page = make_page("post.html", "Post", false);
993        page.date_of_document = Some("2026-01-15".into());
994        page.created = Some("2026-08-16".into());
995        page.updated = Some("2026-08-20".into());
996
997        let meta = generate_seo_meta(&page, "Site", "https://ex.com");
998        assert!(meta.contains(r#"article:published_time" content="2026-01-15T00:00:00Z""#));
999        assert!(meta.contains(r#"article:modified_time" content="2026-08-20T00:00:00Z""#));
1000    }
1001
1002    #[test]
1003    fn feed_content_carries_absolute_links_and_images() {
1004        // A feed entry is read away from the site — in a reader, or in an email
1005        // built from the feed — where a page-relative href resolves to nothing.
1006        let root = make_page("index.html", "Home", true);
1007        let mut leaf = make_page("notes/entry.html", "Entry", false);
1008        leaf.rendered_body =
1009            r#"<p><a href="../other.html">o</a><img src="../_attachments/a.jpg"></p>"#.to_string();
1010
1011        let pages = [root, leaf];
1012        let atom = generate_atom_feed(&pages, "Site", "https://ex.com", "", "");
1013        assert!(atom.contains(r#"href="https://ex.com/other.html""#));
1014        assert!(atom.contains(r#"src="https://ex.com/_attachments/a.jpg""#));
1015        assert!(!atom.contains(r#"href="../other.html""#));
1016
1017        let rss = generate_rss_feed(&pages, "Site", "https://ex.com", "", "");
1018        assert!(rss.contains(r#"href="https://ex.com/other.html""#));
1019        assert!(rss.contains(r#"src="https://ex.com/_attachments/a.jpg""#));
1020    }
1021
1022    // ── The sidebar ─────────────────────────────────────────────────────────
1023
1024    fn nav_node(href: &str, children: Vec<SiteNavNode>) -> SiteNavNode {
1025        SiteNavNode {
1026            title: href.trim_end_matches(".html").to_string(),
1027            href: href.to_string(),
1028            is_current: false,
1029            is_ancestor_of_current: false,
1030            children,
1031        }
1032    }
1033
1034    /// A tree rooted at the front page, with the reader on `b/leaf.html`.
1035    fn rooted_nav() -> SiteNavigation {
1036        let mut leaf = nav_node("b/leaf.html", vec![]);
1037        leaf.is_current = true;
1038        let mut b = nav_node("b.html", vec![leaf]);
1039        b.is_ancestor_of_current = true;
1040        let c = nav_node("c.html", vec![nav_node("c/kid.html", vec![])]);
1041        let mut root = nav_node("index.html", vec![nav_node("a.html", vec![]), b, c]);
1042        root.is_ancestor_of_current = true;
1043        SiteNavigation {
1044            tree: vec![root],
1045            breadcrumbs: vec![],
1046        }
1047    }
1048
1049    /// The front page is the masthead, not the first row: the list starts at
1050    /// its children, so an entry sits at the depth it has.
1051    #[test]
1052    fn the_front_page_leaves_the_tree_for_the_masthead() {
1053        let html = render_site_nav(&rooted_nav(), "My Site", "../");
1054        assert!(
1055            html.contains(r#"<a class="site-masthead" href="../index.html">My Site</a>"#),
1056            "got {html}"
1057        );
1058        assert!(
1059            html.starts_with(r#"<input class="nav-toggle-state""#),
1060            "the drawer's state comes first, then the bar: {html}"
1061        );
1062        assert!(
1063            html.contains(r#"<nav class="site-nav" id="site-nav" aria-label="Site navigation">"#),
1064            "got {html}"
1065        );
1066        // The root's children are the top of the list, and `index.html` is
1067        // not a row anywhere in it.
1068        assert!(html.contains(r#"<ul class="nav-list"><li><a href="../a.html">a</a></li>"#));
1069        assert_eq!(
1070            html.matches("index.html").count(),
1071            2,
1072            "bar and sidebar mastheads only"
1073        );
1074        // The toggle is a checkbox ahead of both the bar and the drawer, so
1075        // `:checked ~` reaches them without a script.
1076        let state = html
1077            .find(r#"<input class="nav-toggle-state" type="checkbox" id="nav-toggle""#)
1078            .expect("the drawer state precedes the bar");
1079        let bar = html.find(r#"<header class="site-bar">"#).unwrap();
1080        let nav = html.find(r#"<nav class="site-nav""#).unwrap();
1081        assert!(state < bar && bar < nav, "got {html}");
1082        assert!(
1083            html.contains(r#"<label class="nav-toggle" for="nav-toggle">Menu</label>"#),
1084            "got {html}"
1085        );
1086    }
1087
1088    /// The masthead is the current page on the front page, and nothing else
1089    /// is.
1090    #[test]
1091    fn the_masthead_is_current_on_the_front_page() {
1092        let mut nav = rooted_nav();
1093        nav.tree[0].is_current = true;
1094        nav.tree[0].is_ancestor_of_current = false;
1095        let html = render_site_nav(&nav, "My Site", "");
1096        assert!(
1097            html.contains(
1098                r#"<a class="site-masthead" href="index.html" aria-current="page">My Site</a>"#
1099            ),
1100            "got {html}"
1101        );
1102        let elsewhere = render_site_nav(&rooted_nav(), "My Site", "../");
1103        assert!(!elsewhere.contains(r#"site-masthead" href="../index.html" aria-current"#));
1104    }
1105
1106    /// A node with children is a disclosure, open on the current page's
1107    /// branch and closed elsewhere — and the link is the summary's content, so
1108    /// the title navigates and the chevron opens.
1109    #[test]
1110    fn sections_are_disclosures_open_along_the_current_branch() {
1111        let html = render_site_nav(&rooted_nav(), "My Site", "../");
1112        assert!(
1113            html.contains(
1114                r#"<li class="nav-section nav-ancestor"><details open><summary><a href="../b.html">b</a></summary><ul class="nav-list"><li class="nav-current"><a href="../b/leaf.html" aria-current="page">b/leaf</a></li></ul></details></li>"#
1115            ),
1116            "the ancestor is open: {html}"
1117        );
1118        assert!(
1119            html.contains(
1120                r#"<li class="nav-section"><details><summary><a href="../c.html">c</a></summary>"#
1121            ),
1122            "the other section is closed: {html}"
1123        );
1124    }
1125
1126    /// The current page's own section is open too, so a reader landing on a
1127    /// section's page sees what is under it.
1128    #[test]
1129    fn the_current_sections_own_disclosure_is_open() {
1130        let mut nav = rooted_nav();
1131        let root = &mut nav.tree[0];
1132        root.children[1].children[0].is_current = false;
1133        root.children[1].is_ancestor_of_current = false;
1134        root.children[1].is_current = true;
1135        let html = render_site_nav(&nav, "My Site", "");
1136        assert!(
1137            html.contains(
1138                r#"<li class="nav-section nav-current"><details open><summary><a href="b.html" aria-current="page">b</a></summary>"#
1139            ),
1140            "got {html}"
1141        );
1142    }
1143
1144    /// A rootless forest lists as it stands, under a masthead that still
1145    /// links home — and a lone forest root is not mistaken for a front page.
1146    #[test]
1147    fn a_forest_lists_under_the_masthead() {
1148        let nav = SiteNavigation {
1149            tree: vec![nav_node(
1150                "daily.html",
1151                vec![nav_node("daily/mon.html", vec![])],
1152            )],
1153            breadcrumbs: vec![],
1154        };
1155        let html = render_site_nav(&nav, "Notes", "");
1156        assert!(html.contains(r#"<a class="site-masthead" href="index.html">Notes</a>"#));
1157        assert!(
1158            html.contains(r#"<summary><a href="daily.html">daily</a></summary>"#),
1159            "the forest root is a row: {html}"
1160        );
1161    }
1162
1163    #[test]
1164    fn an_empty_tree_renders_no_nav_at_all() {
1165        let nav = SiteNavigation {
1166            tree: vec![],
1167            breadcrumbs: vec![],
1168        };
1169        assert_eq!(render_site_nav(&nav, "My Site", ""), "");
1170    }
1171
1172    // ── The pager ───────────────────────────────────────────────────────────
1173
1174    #[test]
1175    fn the_pager_links_the_neighbours_in_reading_order() {
1176        let order = crate::nav::reading_order(&rooted_nav().tree);
1177        let hrefs: Vec<&str> = order.iter().map(|l| l.href.as_str()).collect();
1178        assert_eq!(
1179            hrefs,
1180            [
1181                "index.html",
1182                "a.html",
1183                "b.html",
1184                "b/leaf.html",
1185                "c.html",
1186                "c/kid.html"
1187            ]
1188        );
1189
1190        let middle = render_pager(&order, "b/leaf.html", "../");
1191        assert_eq!(
1192            middle,
1193            r#"<nav class="pager" aria-label="Pager"><a class="pager-prev" rel="prev" href="../b.html"><span>Previous</span> b</a><a class="pager-next" rel="next" href="../c.html"><span>Next</span> c</a></nav>"#
1194        );
1195        let first = render_pager(&order, "index.html", "");
1196        assert!(!first.contains("pager-prev"), "{first}");
1197        assert!(first.contains(r#"rel="next" href="a.html""#), "{first}");
1198        let last = render_pager(&order, "c/kid.html", "../");
1199        assert!(last.contains(r#"rel="prev" href="../c.html""#), "{last}");
1200        assert!(!last.contains("pager-next"), "{last}");
1201    }
1202
1203    /// A page the nav does not hold is in no sequence, and a site of one page
1204    /// has nowhere to go.
1205    #[test]
1206    fn a_page_outside_the_order_gets_no_pager() {
1207        let order = crate::nav::reading_order(&rooted_nav().tree);
1208        assert_eq!(render_pager(&order, "hidden.html", ""), "");
1209        let alone = crate::nav::reading_order(&[nav_node("index.html", vec![])]);
1210        assert_eq!(render_pager(&alone, "index.html", ""), "");
1211    }
1212
1213    #[test]
1214    fn test_feed_links() {
1215        let links = generate_feed_link_tags("");
1216        assert!(links.contains("application/atom+xml"));
1217        assert!(links.contains("feed.xml"));
1218        assert!(links.contains("application/rss+xml"));
1219        assert!(links.contains("rss.xml"));
1220    }
1221
1222    #[test]
1223    fn test_strip_html_truncate() {
1224        let html = "<p>Hello <strong>world</strong>, this is a test.</p>";
1225        let result = strip_html_truncate(html, 11);
1226        assert_eq!(result, "Hello world");
1227    }
1228}