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, 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">☰</button>
141<nav class="site-nav" aria-label="Site navigation">
142{nav_list}
143</nav>"#,
144 nav_list = nav_list,
145 )
146}
147
148pub 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 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
181pub 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
200pub fn generate_seo_meta(page: &PublishedPage, site_title: &str, base_url: &str) -> String {
202 let mut tags = Vec::new();
203
204 tags.push(format!(
206 r#"<meta property="og:title" content="{}">"#,
207 html_escape(&page.title)
208 ));
209
210 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 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 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 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 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 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 tags.push(format!(
277 r#"<meta property="og:site_name" content="{}">"#,
278 html_escape(site_title)
279 ));
280
281 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
297fn find_og_image(page: &PublishedPage) -> Option<String> {
299 const IMAGE_EXTENSIONS: &[&str] = &[".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"];
300
301 for s in &page.attachments {
303 let lower = s.to_lowercase();
304 if IMAGE_EXTENSIONS.iter().any(|ext| lower.ends_with(ext)) {
305 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 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
327pub 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
336pub 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 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
378pub 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
390pub 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 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 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 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
483pub 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 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
558fn 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
583fn xml_escape(s: &str) -> String {
585 s.replace('&', "&")
586 .replace('<', "<")
587 .replace('>', ">")
588 .replace('"', """)
589 .replace('\'', "'")
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>")); assert!(sitemap.contains("<priority>0.8</priority>")); assert!(sitemap.contains("<priority>0.6</priority>")); }
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 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 assert!(
759 rss.contains("<pubDate>Mon, 15 Jan 2024 00:00:00 +0000</pubDate>"),
760 "got {rss}"
761 );
762 }
763
764 #[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 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 #[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 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 assert!(!atom.contains("<published>unknown</published>"));
821 }
822
823 #[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 #[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 assert!(!atom.contains("<published>"));
855 }
856
857 #[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 #[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 #[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 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}