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 full site navigation sidebar.
90pub fn render_site_nav(nav: &SiteNavigation, root_prefix: &str) -> String {
91    if nav.tree.is_empty() {
92        return String::new();
93    }
94
95    fn render_nodes(nodes: &[SiteNavNode], prefix: &str) -> String {
96        let mut html = String::from("<ul class=\"nav-list\">");
97        for node in nodes {
98            let mut classes = Vec::new();
99            if node.is_current {
100                classes.push("nav-current");
101            }
102            if node.is_ancestor_of_current {
103                classes.push("nav-ancestor");
104            }
105
106            let class_attr = if classes.is_empty() {
107                String::new()
108            } else {
109                format!(r#" class="{}""#, classes.join(" "))
110            };
111
112            let aria = if node.is_current {
113                r#" aria-current="page""#
114            } else {
115                ""
116            };
117
118            html.push_str(&format!(
119                r#"<li{class}><a href="{prefix}{href}"{aria}>{title}</a>"#,
120                class = class_attr,
121                prefix = prefix,
122                href = html_escape(&node.href),
123                aria = aria,
124                title = html_escape(&node.title),
125            ));
126
127            if !node.children.is_empty() {
128                html.push_str(&render_nodes(&node.children, prefix));
129            }
130
131            html.push_str("</li>");
132        }
133        html.push_str("</ul>");
134        html
135    }
136
137    let nav_list = render_nodes(&nav.tree, root_prefix);
138
139    format!(
140        r#"<button class="nav-toggle" aria-label="Toggle navigation" aria-expanded="false">&#9776;</button>
141<nav class="site-nav" aria-label="Site navigation">
142{nav_list}
143</nav>"#,
144        nav_list = nav_list,
145    )
146}
147
148/// Render full breadcrumb trail from root to current page.
149pub fn render_full_breadcrumbs(breadcrumbs: &[NavLink], prefix: &str) -> String {
150    if breadcrumbs.len() <= 1 {
151        return String::new();
152    }
153
154    let items: Vec<String> = breadcrumbs
155        .iter()
156        .enumerate()
157        .map(|(i, crumb)| {
158            if i == breadcrumbs.len() - 1 {
159                // Current page — no link
160                format!(
161                    r#"<span aria-current="page">{}</span>"#,
162                    html_escape(&crumb.title)
163                )
164            } else {
165                format!(
166                    r#"<a href="{}{}">{}</a>"#,
167                    prefix,
168                    html_escape(&crumb.href),
169                    html_escape(&crumb.title)
170                )
171            }
172        })
173        .collect();
174
175    format!(
176        r#"<nav class="breadcrumbs" aria-label="Breadcrumb">{}</nav>"#,
177        items.join(r#" <span class="breadcrumb-sep">/</span> "#)
178    )
179}
180
181/// Render breadcrumb navigation (parent link above the title).
182pub fn render_breadcrumb(page: &PublishedPage, single_file: bool) -> String {
183    let prefix = root_prefix(&page.dest_filename);
184    if let Some(ref parent) = page.parent_link {
185        let href = if single_file {
186            format!("#{}", title_to_anchor(&parent.title))
187        } else {
188            format!("{}{}", prefix, parent.href)
189        };
190        format!(
191            r#"<nav class="breadcrumb" aria-label="Breadcrumb"><a href="{}">{}</a></nav>"#,
192            html_escape(&href),
193            html_escape(&parent.title),
194        )
195    } else {
196        String::new()
197    }
198}
199
200/// Generate SEO meta tags for a page.
201pub fn generate_seo_meta(page: &PublishedPage, site_title: &str, base_url: &str) -> String {
202    let mut tags = Vec::new();
203
204    // og:title
205    tags.push(format!(
206        r#"<meta property="og:title" content="{}">"#,
207        html_escape(&page.title)
208    ));
209
210    // description + og:description
211    if let Some(ref desc) = page.description {
212        tags.push(format!(
213            r#"<meta name="description" content="{}">"#,
214            html_escape(desc)
215        ));
216        tags.push(format!(
217            r#"<meta property="og:description" content="{}">"#,
218            html_escape(desc)
219        ));
220    }
221
222    // author
223    if let Some(ref author) = page.author {
224        tags.push(format!(
225            r#"<meta name="author" content="{}">"#,
226            html_escape(author)
227        ));
228    }
229
230    // article:published_time — the date the entry is *of*, matching the order
231    // the site's own index lists it in, and in the RFC 3339 the Open Graph
232    // spec asks for rather than whatever the frontmatter happened to say.
233    if let Some(published) = page.published_date().and_then(to_rfc3339) {
234        tags.push(format!(
235            r#"<meta property="article:published_time" content="{}">"#,
236            html_escape(&published)
237        ));
238    }
239
240    // article:modified_time
241    if let Some(modified) = page.modified_date().and_then(to_rfc3339) {
242        tags.push(format!(
243            r#"<meta property="article:modified_time" content="{}">"#,
244            html_escape(&modified)
245        ));
246    }
247
248    // og:image — scan attachments for images, then fall back to first <img> in body
249    let og_image = find_og_image(page);
250    if let Some(img_url) = og_image {
251        let full_url = if img_url.starts_with("http://") || img_url.starts_with("https://") {
252            img_url
253        } else if !base_url.is_empty() {
254            format!(
255                "{}/{}",
256                base_url.trim_end_matches('/'),
257                img_url.trim_start_matches('/')
258            )
259        } else {
260            img_url
261        };
262        tags.push(format!(
263            r#"<meta property="og:image" content="{}">"#,
264            html_escape(&full_url)
265        ));
266    }
267
268    // og:type
269    let og_type = if page.is_root { "website" } else { "article" };
270    tags.push(format!(
271        r#"<meta property="og:type" content="{}">"#,
272        og_type
273    ));
274
275    // og:site_name
276    tags.push(format!(
277        r#"<meta property="og:site_name" content="{}">"#,
278        html_escape(site_title)
279    ));
280
281    // og:url + canonical
282    if !base_url.is_empty() {
283        let url = format!("{}/{}", base_url.trim_end_matches('/'), page.dest_filename);
284        tags.push(format!(
285            r#"<meta property="og:url" content="{}">"#,
286            html_escape(&url)
287        ));
288        tags.push(format!(
289            r#"<link rel="canonical" href="{}">"#,
290            html_escape(&url)
291        ));
292    }
293
294    tags.join("\n    ")
295}
296
297/// Find the best og:image for a page.
298fn find_og_image(page: &PublishedPage) -> Option<String> {
299    const IMAGE_EXTENSIONS: &[&str] = &[".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"];
300
301    // Check attachments for images
302    for s in &page.attachments {
303        let lower = s.to_lowercase();
304        if IMAGE_EXTENSIONS.iter().any(|ext| lower.ends_with(ext)) {
305            // An attachment is a link value like any other: unwrap `[alt](target)`
306            // and resolve it against the page that carries it.
307            let target = prov::Link::parse_path_only(s.trim()).target;
308            return Some(
309                prov::link::resolve(&page.source_path, &target)
310                    .to_string_lossy()
311                    .into_owned(),
312            );
313        }
314    }
315
316    // Fall back to first <img src="..."> in rendered body
317    if let Some(pos) = page.rendered_body.find("src=\"") {
318        let after = &page.rendered_body[pos + 5..];
319        if let Some(end) = after.find('"') {
320            return Some(after[..end].to_string());
321        }
322    }
323
324    None
325}
326
327/// Generate `<link>` tags for Atom and RSS feeds.
328pub fn generate_feed_link_tags(root_prefix: &str) -> String {
329    format!(
330        r#"<link rel="alternate" type="application/atom+xml" title="Atom Feed" href="{}feed.xml">
331    <link rel="alternate" type="application/rss+xml" title="RSS Feed" href="{}rss.xml">"#,
332        root_prefix, root_prefix,
333    )
334}
335
336/// Generate a sitemap.xml from published pages.
337pub fn generate_sitemap(pages: &[PublishedPage], base_url: &str) -> String {
338    let base = base_url.trim_end_matches('/');
339    let mut xml = String::from(
340        r#"<?xml version="1.0" encoding="UTF-8"?>
341<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
342"#,
343    );
344
345    for page in pages {
346        let loc = format!("{}/{}", base, page.dest_filename);
347        // W3C Datetime, which is what a sitemap's `lastmod` is specified as and
348        // which RFC 3339 satisfies. A date the vault wrote in some other
349        // spelling is left out rather than emitted for a crawler to reject.
350        let lastmod = page
351            .modified_date()
352            .and_then(to_rfc3339)
353            .unwrap_or_default();
354        let priority = if page.is_root {
355            "1.0"
356        } else if !page.contents_links.is_empty() {
357            "0.8"
358        } else {
359            "0.6"
360        };
361
362        xml.push_str("  <url>\n");
363        xml.push_str(&format!("    <loc>{}</loc>\n", xml_escape(&loc)));
364        if !lastmod.is_empty() {
365            xml.push_str(&format!(
366                "    <lastmod>{}</lastmod>\n",
367                xml_escape(&lastmod)
368            ));
369        }
370        xml.push_str(&format!("    <priority>{}</priority>\n", priority));
371        xml.push_str("  </url>\n");
372    }
373
374    xml.push_str("</urlset>\n");
375    xml
376}
377
378/// Generate robots.txt content.
379pub fn generate_robots_txt(base_url: &str, is_public: bool) -> String {
380    if is_public {
381        format!(
382            "User-agent: *\nAllow: /\nSitemap: {}/sitemap.xml\n",
383            base_url.trim_end_matches('/')
384        )
385    } else {
386        "User-agent: *\nDisallow: /\n".to_string()
387    }
388}
389
390/// Generate an Atom 1.0 feed.
391pub fn generate_atom_feed(
392    pages: &[PublishedPage],
393    site_title: &str,
394    base_url: &str,
395    site_description: &str,
396    site_author: &str,
397) -> String {
398    let base = base_url.trim_end_matches('/');
399
400    let items = feed_items(pages);
401
402    // Atom makes the feed's own `<updated>` mandatory, so this one falls back
403    // rather than being omitted.
404    let feed_updated = items
405        .first()
406        .and_then(|p| p.modified_date())
407        .and_then(to_rfc3339)
408        .unwrap_or_else(|| EPOCH_RFC3339.to_string());
409
410    let mut xml = format!(
411        r#"<?xml version="1.0" encoding="UTF-8"?>
412<feed xmlns="http://www.w3.org/2005/Atom">
413  <title>{title}</title>
414  <link href="{base}/" rel="alternate"/>
415  <link href="{base}/feed.xml" rel="self"/>
416  <id>{base}/</id>
417  <updated>{updated}</updated>
418"#,
419        title = xml_escape(site_title),
420        base = xml_escape(base),
421        updated = xml_escape(&feed_updated),
422    );
423
424    if !site_author.is_empty() {
425        xml.push_str(&format!(
426            "  <author><name>{}</name></author>\n",
427            xml_escape(site_author)
428        ));
429    }
430    if !site_description.is_empty() {
431        xml.push_str(&format!(
432            "  <subtitle>{}</subtitle>\n",
433            xml_escape(site_description)
434        ));
435    }
436
437    for page in &items {
438        let link = format!("{}/{}", base, page.dest_filename);
439        let published = page.published_date().and_then(to_rfc3339);
440        // Mandatory on every entry, like the feed's own above.
441        let updated = page
442            .modified_date()
443            .and_then(to_rfc3339)
444            .unwrap_or_else(|| EPOCH_RFC3339.to_string());
445        let summary = strip_html_truncate(&page.rendered_body, 280);
446
447        xml.push_str("  <entry>\n");
448        xml.push_str(&format!("    <title>{}</title>\n", xml_escape(&page.title)));
449        xml.push_str(&format!(
450            "    <link href=\"{}\" rel=\"alternate\"/>\n",
451            xml_escape(&link)
452        ));
453        xml.push_str(&format!("    <id>{}</id>\n", xml_escape(&link)));
454        if let Some(published) = published {
455            xml.push_str(&format!(
456                "    <published>{}</published>\n",
457                xml_escape(&published)
458            ));
459        }
460        xml.push_str(&format!(
461            "    <updated>{}</updated>\n",
462            xml_escape(&updated)
463        ));
464        if !summary.is_empty() {
465            xml.push_str(&format!(
466                "    <summary>{}</summary>\n",
467                xml_escape(&summary)
468            ));
469        }
470        // The body leaves the site here, so its page-relative links have to be
471        // resolved now — a reader has no way to reconstruct the base later.
472        xml.push_str(&format!(
473            "    <content type=\"html\"><![CDATA[{}]]></content>\n",
474            absolutize_html(&page.rendered_body, &page.dest_filename, base)
475        ));
476        xml.push_str("  </entry>\n");
477    }
478
479    xml.push_str("</feed>\n");
480    xml
481}
482
483/// Generate an RSS 2.0 feed.
484pub fn generate_rss_feed(
485    pages: &[PublishedPage],
486    site_title: &str,
487    base_url: &str,
488    site_description: &str,
489    _site_author: &str,
490) -> String {
491    let base = base_url.trim_end_matches('/');
492
493    let items = feed_items(pages);
494
495    // RSS 2.0 dates are RFC 822, which is a different grammar from Atom's —
496    // hence the second spelling of the same instants.
497    let last_build = items
498        .first()
499        .and_then(|p| p.modified_date())
500        .and_then(to_rfc822)
501        .unwrap_or_default();
502
503    let desc = if site_description.is_empty() {
504        site_title
505    } else {
506        site_description
507    };
508
509    let mut xml = format!(
510        r#"<?xml version="1.0" encoding="UTF-8"?>
511<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
512<channel>
513  <title>{title}</title>
514  <link>{base}/</link>
515  <description>{description}</description>
516  <atom:link href="{base}/rss.xml" rel="self" type="application/rss+xml"/>
517"#,
518        title = xml_escape(site_title),
519        base = xml_escape(base),
520        description = xml_escape(desc),
521    );
522
523    if !last_build.is_empty() {
524        xml.push_str(&format!(
525            "  <lastBuildDate>{}</lastBuildDate>\n",
526            xml_escape(&last_build)
527        ));
528    }
529
530    for page in &items {
531        let link = format!("{}/{}", base, page.dest_filename);
532        let pub_date = page.published_date().and_then(to_rfc822);
533
534        xml.push_str("  <item>\n");
535        xml.push_str(&format!("    <title>{}</title>\n", xml_escape(&page.title)));
536        xml.push_str(&format!("    <link>{}</link>\n", xml_escape(&link)));
537        xml.push_str(&format!(
538            "    <guid isPermaLink=\"true\">{}</guid>\n",
539            xml_escape(&link)
540        ));
541        if let Some(pub_date) = pub_date {
542            xml.push_str(&format!(
543                "    <pubDate>{}</pubDate>\n",
544                xml_escape(&pub_date)
545            ));
546        }
547        xml.push_str(&format!(
548            "    <description><![CDATA[{}]]></description>\n",
549            absolutize_html(&page.rendered_body, &page.dest_filename, base)
550        ));
551        xml.push_str("  </item>\n");
552    }
553
554    xml.push_str("</channel>\n</rss>\n");
555    xml
556}
557
558/// Strip HTML tags and truncate to `max_len` characters.
559fn strip_html_truncate(html: &str, max_len: usize) -> String {
560    let mut text = String::new();
561    let mut in_tag = false;
562
563    for ch in html.chars() {
564        if ch == '<' {
565            in_tag = true;
566            continue;
567        }
568        if ch == '>' {
569            in_tag = false;
570            continue;
571        }
572        if !in_tag {
573            text.push(ch);
574            if text.len() >= max_len {
575                break;
576            }
577        }
578    }
579
580    text.trim().to_string()
581}
582
583/// Escape characters for XML content.
584fn xml_escape(s: &str) -> String {
585    s.replace('&', "&amp;")
586        .replace('<', "&lt;")
587        .replace('>', "&gt;")
588        .replace('"', "&quot;")
589        .replace('\'', "&apos;")
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595    use crate::types::{NavLink, PageLayout};
596    use std::path::PathBuf;
597
598    fn make_page(dest: &str, title: &str, is_root: bool) -> PublishedPage {
599        PublishedPage {
600            source_path: PathBuf::from(format!("/workspace/{}", dest.replace(".html", ".md"))),
601            dest_filename: dest.to_string(),
602            title: title.to_string(),
603            rendered_body: "<p>Hello world</p>".to_string(),
604            markdown_body: "Hello world".to_string(),
605            contents_links: vec![],
606            parent_link: None,
607            is_root,
608            description: None,
609            author: None,
610            created: None,
611            updated: None,
612            date_of_document: None,
613            group_keys: vec![],
614            attachments: vec![],
615            styles: vec![],
616            scripts: vec![],
617            layout: PageLayout::default(),
618            shell: None,
619            lang: None,
620            nav_title: None,
621            nav_order: None,
622            hide_from_nav: false,
623            hide_from_feed: false,
624            id: None,
625            source_markdown: String::new(),
626        }
627    }
628
629    #[test]
630    fn test_seo_meta_basic() {
631        let mut page = make_page("about.html", "About", false);
632        page.description = Some("A test page".into());
633        page.author = Some("Alice".into());
634        let meta = generate_seo_meta(&page, "My Site", "https://example.com");
635
636        assert!(meta.contains(r#"og:title" content="About""#));
637        assert!(meta.contains(r#"name="description" content="A test page""#));
638        assert!(meta.contains(r#"og:description" content="A test page""#));
639        assert!(meta.contains(r#"name="author" content="Alice""#));
640        assert!(meta.contains(r#"og:type" content="article""#));
641        assert!(meta.contains(r#"og:site_name" content="My Site""#));
642        assert!(meta.contains(r#"og:url" content="https://example.com/about.html""#));
643        assert!(meta.contains(r#"canonical" href="https://example.com/about.html""#));
644    }
645
646    #[test]
647    fn test_seo_meta_root_is_website_type() {
648        let page = make_page("index.html", "Home", true);
649        let meta = generate_seo_meta(&page, "My Site", "https://example.com");
650        assert!(meta.contains(r#"og:type" content="website""#));
651    }
652
653    #[test]
654    fn test_seo_meta_no_base_url() {
655        let page = make_page("page.html", "Page", false);
656        let meta = generate_seo_meta(&page, "Site", "");
657        assert!(!meta.contains("canonical"));
658        assert!(!meta.contains("og:url"));
659    }
660
661    #[test]
662    fn test_sitemap_structure() {
663        let root = make_page("index.html", "Home", true);
664        let mut child = make_page("child.html", "Child", false);
665        child.contents_links = vec![NavLink {
666            href: "leaf.html".into(),
667            title: "Leaf".into(),
668        }];
669        let leaf = make_page("leaf.html", "Leaf", false);
670
671        let sitemap = generate_sitemap(&[root, child, leaf], "https://example.com");
672
673        assert!(sitemap.contains("<loc>https://example.com/index.html</loc>"));
674        assert!(sitemap.contains("<priority>1.0</priority>")); // root
675        assert!(sitemap.contains("<priority>0.8</priority>")); // child with contents
676        assert!(sitemap.contains("<priority>0.6</priority>")); // leaf
677    }
678
679    #[test]
680    fn test_robots_txt_public() {
681        let robots = generate_robots_txt("https://example.com", true);
682        assert!(robots.contains("Allow: /"));
683        assert!(robots.contains("Sitemap: https://example.com/sitemap.xml"));
684    }
685
686    #[test]
687    fn test_robots_txt_private() {
688        let robots = generate_robots_txt("https://example.com", false);
689        assert!(robots.contains("Disallow: /"));
690        assert!(!robots.contains("Sitemap"));
691    }
692
693    #[test]
694    fn test_atom_feed_excludes_root_and_index_pages() {
695        let root = make_page("index.html", "Home", true);
696        let mut index_child = make_page("section.html", "Section", false);
697        index_child.contents_links = vec![NavLink {
698            href: "leaf.html".into(),
699            title: "Leaf".into(),
700        }];
701        let leaf = make_page("leaf.html", "Leaf", false);
702
703        let atom = generate_atom_feed(
704            &[root, index_child, leaf],
705            "Site",
706            "https://example.com",
707            "",
708            "",
709        );
710
711        // Only the leaf should appear as an entry
712        assert_eq!(atom.matches("<entry>").count(), 1);
713        assert!(atom.contains("<title>Leaf</title>"));
714        assert!(!atom.contains("<title>Home</title>"));
715        assert!(!atom.contains("<title>Section</title>"));
716    }
717
718    #[test]
719    fn test_atom_feed_hide_from_feed() {
720        let root = make_page("index.html", "Home", true);
721        let mut hidden = make_page("hidden.html", "Hidden", false);
722        hidden.hide_from_feed = true;
723        let visible = make_page("visible.html", "Visible", false);
724
725        let atom = generate_atom_feed(
726            &[root, hidden, visible],
727            "Site",
728            "https://example.com",
729            "",
730            "",
731        );
732
733        assert_eq!(atom.matches("<entry>").count(), 1);
734        assert!(atom.contains("<title>Visible</title>"));
735        assert!(!atom.contains("<title>Hidden</title>"));
736    }
737
738    #[test]
739    fn test_rss_feed_structure() {
740        let root = make_page("index.html", "Home", true);
741        let mut leaf = make_page("post.html", "Post", false);
742        leaf.created = Some("2024-01-15".into());
743
744        let rss = generate_rss_feed(
745            &[root, leaf],
746            "My Blog",
747            "https://example.com",
748            "A blog",
749            "Author",
750        );
751
752        assert!(rss.contains("<title>My Blog</title>"));
753        assert!(rss.contains("<description>A blog</description>"));
754        assert!(rss.contains("<title>Post</title>"));
755        assert!(rss.contains("<guid isPermaLink=\"true\">https://example.com/post.html</guid>"));
756        // RFC 822, not the `2024-01-15` the vault wrote: RSS specifies the
757        // grammar, and readers hold it to that.
758        assert!(
759            rss.contains("<pubDate>Mon, 15 Jan 2024 00:00:00 +0000</pubDate>"),
760            "got {rss}"
761        );
762    }
763
764    /// The date chain the site's index groups by, answered the same way by the
765    /// feeds. A scanned letter's `date_of_document` is the year it was written
766    /// and its `created` is the day it was scanned; the feed used to order by
767    /// the latter while the front page listed by the former.
768    #[test]
769    fn feeds_order_by_the_same_date_chain_the_index_does() {
770        let mut letter = make_page("letter.html", "Letter", false);
771        letter.date_of_document = Some("1944-06-06".into());
772        letter.created = Some("2026-08-16".into());
773
774        let mut note = make_page("note.html", "Note", false);
775        note.created = Some("2026-01-02".into());
776
777        let pages = [letter, note];
778        let atom = generate_atom_feed(&pages, "Site", "https://ex.com", "", "");
779
780        // The letter is *of* 1944, so it sorts below the 2026 note and is
781        // published as its own date rather than its scanning date.
782        assert!(
783            atom.find("<title>Note</title>") < atom.find("<title>Letter</title>"),
784            "got {atom}"
785        );
786        assert!(atom.contains("<published>1944-06-06T00:00:00Z</published>"));
787        assert!(!atom.contains("1944-06-06</published>\n    <published>"));
788
789        let rss = generate_rss_feed(&pages, "Site", "https://ex.com", "", "");
790        assert!(rss.contains("<pubDate>Tue, 06 Jun 1944 00:00:00 +0000</pubDate>"));
791    }
792
793    /// `date_of_document: unknown` is the marker for a record that is
794    /// undated on purpose — a shoebox of scans must not inherit the afternoon
795    /// it was imported. The chain stops at the marker correctly, but a
796    /// descending sort of the *raw* string put `"unknown"` above every ISO
797    /// date, so the undated scans headed the feed wearing a 1970 timestamp.
798    #[test]
799    fn a_deliberately_undated_record_sorts_to_the_bottom() {
800        let mut undated = make_page("undated.html", "Undated", false);
801        undated.date_of_document = Some("unknown".into());
802        // Pre-epoch on purpose. Normalizing an unreadable date to
803        // `EPOCH_RFC3339` and sorting on that would place the undated scan
804        // *above* this letter, because the epoch is not a floor — it is 1970,
805        // and an archive of a family's papers is mostly older than that.
806        let mut old = make_page("old.html", "Old", false);
807        old.date_of_document = Some("1943-05-12".into());
808        let mut new = make_page("new.html", "New", false);
809        new.date_of_document = Some("2026-08-16".into());
810
811        let pages = [undated, old, new];
812        let atom = generate_atom_feed(&pages, "Site", "https://ex.com", "", "");
813
814        let at = |t: &str| atom.find(&format!("<title>{t}</title>")).unwrap();
815        assert!(
816            at("New") < at("Old") && at("Old") < at("Undated"),
817            "newest first, and the undated record last: {atom}"
818        );
819        // Where it sorts and what it says agree: 1970 sorts like 1970.
820        assert!(!atom.contains("<published>unknown</published>"));
821    }
822
823    /// Nothing validates a `type: date` field, so a typo and a human spelling
824    /// reach the feed the same way the marker does — and used to sort by their
825    /// own first character, above or below the ISO dates by accident.
826    #[test]
827    fn an_unparseable_date_does_not_sort_by_its_spelling() {
828        let mut wordy = make_page("wordy.html", "Wordy", false);
829        wordy.date_of_document = Some("May 1943".into());
830        let mut typo = make_page("typo.html", "Typo", false);
831        typo.date_of_document = Some("19430512".into());
832        let mut dated = make_page("dated.html", "Dated", false);
833        dated.date_of_document = Some("2026-08-16".into());
834
835        let pages = [wordy, typo, dated];
836        let atom = generate_atom_feed(&pages, "Site", "https://ex.com", "", "");
837
838        let at = |t: &str| atom.find(&format!("<title>{t}</title>")).unwrap();
839        assert!(
840            at("Dated") < at("Typo") && at("Dated") < at("Wordy"),
841            "the only readable date leads: {atom}"
842        );
843    }
844
845    /// Atom makes `<updated>` mandatory on the feed and on every entry, so an
846    /// entry the vault gave no date at all still has to carry one.
847    #[test]
848    fn an_undated_entry_still_carries_a_valid_atom_updated() {
849        let page = make_page("post.html", "Post", false);
850        let atom = generate_atom_feed(&[page], "Site", "https://ex.com", "", "");
851
852        assert!(atom.contains(&format!("<updated>{EPOCH_RFC3339}</updated>")));
853        // …but not a `<published>` it would have had to invent.
854        assert!(!atom.contains("<published>"));
855    }
856
857    /// A date the vault wrote in a spelling no feed grammar recognizes is left
858    /// out rather than passed through for a validator to choke on.
859    #[test]
860    fn an_unreadable_date_is_omitted_not_forwarded() {
861        let mut page = make_page("post.html", "Post", false);
862        page.created = Some("sometime last summer".into());
863
864        let atom = generate_atom_feed(&[page.clone()], "Site", "https://ex.com", "", "");
865        assert!(!atom.contains("sometime last summer"));
866        assert!(atom.contains(&format!("<updated>{EPOCH_RFC3339}</updated>")));
867
868        let rss = generate_rss_feed(&[page.clone()], "Site", "https://ex.com", "", "");
869        assert!(!rss.contains("sometime last summer"));
870        assert!(!rss.contains("<pubDate>"));
871
872        let sitemap = generate_sitemap(&[page.clone()], "https://ex.com");
873        assert!(!sitemap.contains("<lastmod>"));
874
875        let meta = generate_seo_meta(&page, "Site", "https://ex.com");
876        assert!(!meta.contains("article:published_time"));
877    }
878
879    /// A sitemap's `lastmod` is a W3C Datetime, which RFC 3339 satisfies and a
880    /// bare vault date does not reliably.
881    #[test]
882    fn sitemap_lastmod_is_a_w3c_datetime() {
883        let mut page = make_page("post.html", "Post", false);
884        page.updated = Some("2026-08-16".into());
885        let sitemap = generate_sitemap(&[page], "https://ex.com");
886        assert!(sitemap.contains("<lastmod>2026-08-16T00:00:00Z</lastmod>"));
887    }
888
889    /// Open Graph asks for RFC 3339 here too, and the published time follows
890    /// the same chain the feeds do.
891    #[test]
892    fn seo_article_times_are_rfc3339_from_the_shared_chain() {
893        let mut page = make_page("post.html", "Post", false);
894        page.date_of_document = Some("2026-01-15".into());
895        page.created = Some("2026-08-16".into());
896        page.updated = Some("2026-08-20".into());
897
898        let meta = generate_seo_meta(&page, "Site", "https://ex.com");
899        assert!(meta.contains(r#"article:published_time" content="2026-01-15T00:00:00Z""#));
900        assert!(meta.contains(r#"article:modified_time" content="2026-08-20T00:00:00Z""#));
901    }
902
903    #[test]
904    fn feed_content_carries_absolute_links_and_images() {
905        // A feed entry is read away from the site — in a reader, or in an email
906        // built from the feed — where a page-relative href resolves to nothing.
907        let root = make_page("index.html", "Home", true);
908        let mut leaf = make_page("notes/entry.html", "Entry", false);
909        leaf.rendered_body =
910            r#"<p><a href="../other.html">o</a><img src="../_attachments/a.jpg"></p>"#.to_string();
911
912        let pages = [root, leaf];
913        let atom = generate_atom_feed(&pages, "Site", "https://ex.com", "", "");
914        assert!(atom.contains(r#"href="https://ex.com/other.html""#));
915        assert!(atom.contains(r#"src="https://ex.com/_attachments/a.jpg""#));
916        assert!(!atom.contains(r#"href="../other.html""#));
917
918        let rss = generate_rss_feed(&pages, "Site", "https://ex.com", "", "");
919        assert!(rss.contains(r#"href="https://ex.com/other.html""#));
920        assert!(rss.contains(r#"src="https://ex.com/_attachments/a.jpg""#));
921    }
922
923    #[test]
924    fn test_feed_links() {
925        let links = generate_feed_link_tags("");
926        assert!(links.contains("application/atom+xml"));
927        assert!(links.contains("feed.xml"));
928        assert!(links.contains("application/rss+xml"));
929        assert!(links.contains("rss.xml"));
930    }
931
932    #[test]
933    fn test_strip_html_truncate() {
934        let html = "<p>Hello <strong>world</strong>, this is a test.</p>";
935        let result = strip_html_truncate(html, 11);
936        assert_eq!(result, "Hello world");
937    }
938}