1use crate::dates::{EPOCH_RFC3339, to_rfc822, to_rfc3339};
9use crate::links::{absolutize_html, root_prefix};
10use crate::types::{NavLink, PublishedPage, SiteNavNode, SiteNavigation};
11
12fn 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
29pub(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 .then_with(|| a.title.cmp(&b.title))
68}
69
70pub fn html_escape(s: &str) -> String {
72 s.replace('&', "&")
73 .replace('<', "<")
74 .replace('>', ">")
75 .replace('"', """)
76 .replace('\'', "'")
77}
78
79pub fn title_to_anchor(title: &str) -> String {
86 prov::link::slug(title)
87}
88
89pub 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 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 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
210const FRONT_PAGE_DEST: &str = "index.html";
214
215pub 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
245pub 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 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
278pub 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
297pub fn generate_seo_meta(page: &PublishedPage, site_title: &str, base_url: &str) -> String {
299 let mut tags = Vec::new();
300
301 tags.push(format!(
303 r#"<meta property="og:title" content="{}">"#,
304 html_escape(&page.title)
305 ));
306
307 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 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 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 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 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 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 tags.push(format!(
374 r#"<meta property="og:site_name" content="{}">"#,
375 html_escape(site_title)
376 ));
377
378 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
394fn find_og_image(page: &PublishedPage) -> Option<String> {
396 const IMAGE_EXTENSIONS: &[&str] = &[".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"];
397
398 for s in &page.attachments {
400 let lower = s.to_lowercase();
401 if IMAGE_EXTENSIONS.iter().any(|ext| lower.ends_with(ext)) {
402 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 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
424pub 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
433pub 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 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
475pub 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
487pub 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 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 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 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
580pub 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 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
655fn 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
680fn xml_escape(s: &str) -> String {
682 s.replace('&', "&")
683 .replace('<', "<")
684 .replace('>', ">")
685 .replace('"', """)
686 .replace('\'', "'")
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>")); assert!(sitemap.contains("<priority>0.8</priority>")); assert!(sitemap.contains("<priority>0.6</priority>")); }
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 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 assert!(
858 rss.contains("<pubDate>Mon, 15 Jan 2024 00:00:00 +0000</pubDate>"),
859 "got {rss}"
860 );
861 }
862
863 #[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 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 #[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 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 assert!(!atom.contains("<published>unknown</published>"));
920 }
921
922 #[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 #[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 assert!(!atom.contains("<published>"));
954 }
955
956 #[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 #[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 #[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 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 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 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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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}