use crate::dates::{EPOCH_RFC3339, to_rfc822, to_rfc3339};
use crate::links::{absolutize_html, root_prefix};
use crate::types::{NavLink, PublishedPage, SiteNavNode, SiteNavigation};
fn feed_items(pages: &[PublishedPage]) -> Vec<&PublishedPage> {
let mut items: Vec<&PublishedPage> = pages
.iter()
.filter(|p| !p.is_root && p.contents_links.is_empty() && !p.hide_from_feed)
.collect();
items.sort_by(|a, b| newest_first(a, b));
items.truncate(50);
items
}
pub(crate) fn newest_first(a: &PublishedPage, b: &PublishedPage) -> std::cmp::Ordering {
use std::cmp::Ordering;
let key = |p: &PublishedPage| p.published_date().and_then(to_rfc3339);
match (key(a), key(b)) {
(Some(x), Some(y)) => y.cmp(&x),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => Ordering::Equal,
}
.then_with(|| a.title.cmp(&b.title))
}
pub fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
pub fn title_to_anchor(title: &str) -> String {
prov::link::slug(title)
}
pub fn render_site_nav(nav: &SiteNavigation, site_title: &str, root_prefix: &str) -> String {
if nav.tree.is_empty() {
return String::new();
}
fn render_nodes(nodes: &[SiteNavNode], prefix: &str) -> String {
let mut html = String::from("<ul class=\"nav-list\">");
for node in nodes {
let mut classes = Vec::new();
if !node.children.is_empty() {
classes.push("nav-section");
}
if node.is_current {
classes.push("nav-current");
}
if node.is_ancestor_of_current {
classes.push("nav-ancestor");
}
let class_attr = if classes.is_empty() {
String::new()
} else {
format!(r#" class="{}""#, classes.join(" "))
};
let aria = if node.is_current {
r#" aria-current="page""#
} else {
""
};
let link = format!(
r#"<a href="{prefix}{href}"{aria}>{title}</a>"#,
prefix = prefix,
href = html_escape(&node.href),
aria = aria,
title = html_escape(&node.title),
);
if node.children.is_empty() {
html.push_str(&format!("<li{class_attr}>{link}</li>"));
} else {
let open = if node.is_current || node.is_ancestor_of_current {
" open"
} else {
""
};
html.push_str(&format!(
"<li{class_attr}><details{open}><summary>{link}</summary>{children}</details></li>",
children = render_nodes(&node.children, prefix),
));
}
}
html.push_str("</ul>");
html
}
let (on_front_page, top): (bool, &[SiteNavNode]) = match nav.tree.as_slice() {
[root] if root.href == FRONT_PAGE_DEST => (root.is_current, &root.children),
forest => (false, forest),
};
let masthead_aria = if on_front_page {
r#" aria-current="page""#
} else {
""
};
let masthead = format!(
r#"<a class="site-masthead" href="{prefix}{FRONT_PAGE_DEST}"{aria}>{title}</a>"#,
prefix = root_prefix,
aria = masthead_aria,
title = html_escape(site_title),
);
let nav_list = if top.is_empty() {
String::new()
} else {
format!("\n{}", render_nodes(top, root_prefix))
};
format!(
r#"<input class="nav-toggle-state" type="checkbox" id="nav-toggle" aria-controls="site-nav" aria-label="Menu">
<header class="site-bar">
<label class="nav-toggle" for="nav-toggle">Menu</label>
{masthead}
</header>
<nav class="site-nav" id="site-nav" aria-label="Site navigation">
{masthead}{nav_list}
</nav>"#,
)
}
const FRONT_PAGE_DEST: &str = "index.html";
pub fn render_pager(order: &[NavLink], current: &str, root_prefix: &str) -> String {
let (prev, next) = crate::nav::neighbours(order, current);
if prev.is_none() && next.is_none() {
return String::new();
}
let link = |rel: &str, label: &str, target: &NavLink| {
format!(
r#"<a class="pager-{rel}" rel="{rel}" href="{prefix}{href}"><span>{label}</span> {title}</a>"#,
prefix = root_prefix,
href = html_escape(&target.href),
title = html_escape(&target.title),
)
};
let mut out = String::from(r#"<nav class="pager" aria-label="Pager">"#);
if let Some(prev) = prev {
out.push_str(&link("prev", "Previous", prev));
}
if let Some(next) = next {
out.push_str(&link("next", "Next", next));
}
out.push_str("</nav>");
out
}
pub fn render_full_breadcrumbs(breadcrumbs: &[NavLink], prefix: &str) -> String {
if breadcrumbs.len() <= 1 {
return String::new();
}
let items: Vec<String> = breadcrumbs
.iter()
.enumerate()
.map(|(i, crumb)| {
if i == breadcrumbs.len() - 1 {
format!(
r#"<span aria-current="page">{}</span>"#,
html_escape(&crumb.title)
)
} else {
format!(
r#"<a href="{}{}">{}</a>"#,
prefix,
html_escape(&crumb.href),
html_escape(&crumb.title)
)
}
})
.collect();
format!(
r#"<nav class="breadcrumbs" aria-label="Breadcrumb">{}</nav>"#,
items.join(r#" <span class="breadcrumb-sep">/</span> "#)
)
}
pub fn render_breadcrumb(page: &PublishedPage, single_file: bool) -> String {
let prefix = root_prefix(&page.dest_filename);
if let Some(ref parent) = page.parent_link {
let href = if single_file {
format!("#{}", title_to_anchor(&parent.title))
} else {
format!("{}{}", prefix, parent.href)
};
format!(
r#"<nav class="breadcrumb" aria-label="Breadcrumb"><a href="{}">{}</a></nav>"#,
html_escape(&href),
html_escape(&parent.title),
)
} else {
String::new()
}
}
pub fn generate_seo_meta(page: &PublishedPage, site_title: &str, base_url: &str) -> String {
let mut tags = Vec::new();
tags.push(format!(
r#"<meta property="og:title" content="{}">"#,
html_escape(&page.title)
));
if let Some(ref desc) = page.description {
tags.push(format!(
r#"<meta name="description" content="{}">"#,
html_escape(desc)
));
tags.push(format!(
r#"<meta property="og:description" content="{}">"#,
html_escape(desc)
));
}
if let Some(ref author) = page.author {
tags.push(format!(
r#"<meta name="author" content="{}">"#,
html_escape(author)
));
}
if let Some(published) = page.published_date().and_then(to_rfc3339) {
tags.push(format!(
r#"<meta property="article:published_time" content="{}">"#,
html_escape(&published)
));
}
if let Some(modified) = page.modified_date().and_then(to_rfc3339) {
tags.push(format!(
r#"<meta property="article:modified_time" content="{}">"#,
html_escape(&modified)
));
}
let og_image = find_og_image(page);
if let Some(img_url) = og_image {
let full_url = if img_url.starts_with("http://") || img_url.starts_with("https://") {
img_url
} else if !base_url.is_empty() {
format!(
"{}/{}",
base_url.trim_end_matches('/'),
img_url.trim_start_matches('/')
)
} else {
img_url
};
tags.push(format!(
r#"<meta property="og:image" content="{}">"#,
html_escape(&full_url)
));
}
let og_type = if page.is_root { "website" } else { "article" };
tags.push(format!(
r#"<meta property="og:type" content="{}">"#,
og_type
));
tags.push(format!(
r#"<meta property="og:site_name" content="{}">"#,
html_escape(site_title)
));
if !base_url.is_empty() {
let url = format!("{}/{}", base_url.trim_end_matches('/'), page.dest_filename);
tags.push(format!(
r#"<meta property="og:url" content="{}">"#,
html_escape(&url)
));
tags.push(format!(
r#"<link rel="canonical" href="{}">"#,
html_escape(&url)
));
}
tags.join("\n ")
}
fn find_og_image(page: &PublishedPage) -> Option<String> {
const IMAGE_EXTENSIONS: &[&str] = &[".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"];
for s in &page.attachments {
let lower = s.to_lowercase();
if IMAGE_EXTENSIONS.iter().any(|ext| lower.ends_with(ext)) {
let target = prov::Link::parse_path_only(s.trim()).target;
return Some(
prov::link::resolve(&page.source_path, &target)
.to_string_lossy()
.into_owned(),
);
}
}
if let Some(pos) = page.rendered_body.find("src=\"") {
let after = &page.rendered_body[pos + 5..];
if let Some(end) = after.find('"') {
return Some(after[..end].to_string());
}
}
None
}
pub fn generate_feed_link_tags(root_prefix: &str) -> String {
format!(
r#"<link rel="alternate" type="application/atom+xml" title="Atom Feed" href="{}feed.xml">
<link rel="alternate" type="application/rss+xml" title="RSS Feed" href="{}rss.xml">"#,
root_prefix, root_prefix,
)
}
pub fn generate_sitemap(pages: &[PublishedPage], base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let mut xml = String::from(
r#"<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
"#,
);
for page in pages {
let loc = format!("{}/{}", base, page.dest_filename);
let lastmod = page
.modified_date()
.and_then(to_rfc3339)
.unwrap_or_default();
let priority = if page.is_root {
"1.0"
} else if !page.contents_links.is_empty() {
"0.8"
} else {
"0.6"
};
xml.push_str(" <url>\n");
xml.push_str(&format!(" <loc>{}</loc>\n", xml_escape(&loc)));
if !lastmod.is_empty() {
xml.push_str(&format!(
" <lastmod>{}</lastmod>\n",
xml_escape(&lastmod)
));
}
xml.push_str(&format!(" <priority>{}</priority>\n", priority));
xml.push_str(" </url>\n");
}
xml.push_str("</urlset>\n");
xml
}
pub fn generate_robots_txt(base_url: &str, is_public: bool) -> String {
if is_public {
format!(
"User-agent: *\nAllow: /\nSitemap: {}/sitemap.xml\n",
base_url.trim_end_matches('/')
)
} else {
"User-agent: *\nDisallow: /\n".to_string()
}
}
pub fn generate_atom_feed(
pages: &[PublishedPage],
site_title: &str,
base_url: &str,
site_description: &str,
site_author: &str,
) -> String {
let base = base_url.trim_end_matches('/');
let items = feed_items(pages);
let feed_updated = items
.first()
.and_then(|p| p.modified_date())
.and_then(to_rfc3339)
.unwrap_or_else(|| EPOCH_RFC3339.to_string());
let mut xml = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>{title}</title>
<link href="{base}/" rel="alternate"/>
<link href="{base}/feed.xml" rel="self"/>
<id>{base}/</id>
<updated>{updated}</updated>
"#,
title = xml_escape(site_title),
base = xml_escape(base),
updated = xml_escape(&feed_updated),
);
if !site_author.is_empty() {
xml.push_str(&format!(
" <author><name>{}</name></author>\n",
xml_escape(site_author)
));
}
if !site_description.is_empty() {
xml.push_str(&format!(
" <subtitle>{}</subtitle>\n",
xml_escape(site_description)
));
}
for page in &items {
let link = format!("{}/{}", base, page.dest_filename);
let published = page.published_date().and_then(to_rfc3339);
let updated = page
.modified_date()
.and_then(to_rfc3339)
.unwrap_or_else(|| EPOCH_RFC3339.to_string());
let summary = strip_html_truncate(&page.rendered_body, 280);
xml.push_str(" <entry>\n");
xml.push_str(&format!(" <title>{}</title>\n", xml_escape(&page.title)));
xml.push_str(&format!(
" <link href=\"{}\" rel=\"alternate\"/>\n",
xml_escape(&link)
));
xml.push_str(&format!(" <id>{}</id>\n", xml_escape(&link)));
if let Some(published) = published {
xml.push_str(&format!(
" <published>{}</published>\n",
xml_escape(&published)
));
}
xml.push_str(&format!(
" <updated>{}</updated>\n",
xml_escape(&updated)
));
if !summary.is_empty() {
xml.push_str(&format!(
" <summary>{}</summary>\n",
xml_escape(&summary)
));
}
xml.push_str(&format!(
" <content type=\"html\"><![CDATA[{}]]></content>\n",
absolutize_html(&page.rendered_body, &page.dest_filename, base)
));
xml.push_str(" </entry>\n");
}
xml.push_str("</feed>\n");
xml
}
pub fn generate_rss_feed(
pages: &[PublishedPage],
site_title: &str,
base_url: &str,
site_description: &str,
_site_author: &str,
) -> String {
let base = base_url.trim_end_matches('/');
let items = feed_items(pages);
let last_build = items
.first()
.and_then(|p| p.modified_date())
.and_then(to_rfc822)
.unwrap_or_default();
let desc = if site_description.is_empty() {
site_title
} else {
site_description
};
let mut xml = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>{title}</title>
<link>{base}/</link>
<description>{description}</description>
<atom:link href="{base}/rss.xml" rel="self" type="application/rss+xml"/>
"#,
title = xml_escape(site_title),
base = xml_escape(base),
description = xml_escape(desc),
);
if !last_build.is_empty() {
xml.push_str(&format!(
" <lastBuildDate>{}</lastBuildDate>\n",
xml_escape(&last_build)
));
}
for page in &items {
let link = format!("{}/{}", base, page.dest_filename);
let pub_date = page.published_date().and_then(to_rfc822);
xml.push_str(" <item>\n");
xml.push_str(&format!(" <title>{}</title>\n", xml_escape(&page.title)));
xml.push_str(&format!(" <link>{}</link>\n", xml_escape(&link)));
xml.push_str(&format!(
" <guid isPermaLink=\"true\">{}</guid>\n",
xml_escape(&link)
));
if let Some(pub_date) = pub_date {
xml.push_str(&format!(
" <pubDate>{}</pubDate>\n",
xml_escape(&pub_date)
));
}
xml.push_str(&format!(
" <description><![CDATA[{}]]></description>\n",
absolutize_html(&page.rendered_body, &page.dest_filename, base)
));
xml.push_str(" </item>\n");
}
xml.push_str("</channel>\n</rss>\n");
xml
}
fn strip_html_truncate(html: &str, max_len: usize) -> String {
let mut text = String::new();
let mut in_tag = false;
for ch in html.chars() {
if ch == '<' {
in_tag = true;
continue;
}
if ch == '>' {
in_tag = false;
continue;
}
if !in_tag {
text.push(ch);
if text.len() >= max_len {
break;
}
}
}
text.trim().to_string()
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{NavLink, PageLayout, SiteNavNode};
use std::path::PathBuf;
fn make_page(dest: &str, title: &str, is_root: bool) -> PublishedPage {
PublishedPage {
source_path: PathBuf::from(format!("/workspace/{}", dest.replace(".html", ".md"))),
dest_filename: dest.to_string(),
title: title.to_string(),
rendered_body: "<p>Hello world</p>".to_string(),
markdown_body: "Hello world".to_string(),
contents_links: vec![],
parent_link: None,
is_root,
description: None,
author: None,
created: None,
updated: None,
date_of_document: None,
group_keys: vec![],
attachments: vec![],
styles: vec![],
scripts: vec![],
layout: PageLayout::default(),
shell: None,
lang: None,
nav_title: None,
nav_order: None,
hide_from_nav: false,
hide_from_feed: false,
id: None,
source_markdown: String::new(),
headings: vec![],
toc: true,
}
}
#[test]
fn test_seo_meta_basic() {
let mut page = make_page("about.html", "About", false);
page.description = Some("A test page".into());
page.author = Some("Alice".into());
let meta = generate_seo_meta(&page, "My Site", "https://example.com");
assert!(meta.contains(r#"og:title" content="About""#));
assert!(meta.contains(r#"name="description" content="A test page""#));
assert!(meta.contains(r#"og:description" content="A test page""#));
assert!(meta.contains(r#"name="author" content="Alice""#));
assert!(meta.contains(r#"og:type" content="article""#));
assert!(meta.contains(r#"og:site_name" content="My Site""#));
assert!(meta.contains(r#"og:url" content="https://example.com/about.html""#));
assert!(meta.contains(r#"canonical" href="https://example.com/about.html""#));
}
#[test]
fn test_seo_meta_root_is_website_type() {
let page = make_page("index.html", "Home", true);
let meta = generate_seo_meta(&page, "My Site", "https://example.com");
assert!(meta.contains(r#"og:type" content="website""#));
}
#[test]
fn test_seo_meta_no_base_url() {
let page = make_page("page.html", "Page", false);
let meta = generate_seo_meta(&page, "Site", "");
assert!(!meta.contains("canonical"));
assert!(!meta.contains("og:url"));
}
#[test]
fn test_sitemap_structure() {
let root = make_page("index.html", "Home", true);
let mut child = make_page("child.html", "Child", false);
child.contents_links = vec![NavLink {
href: "leaf.html".into(),
title: "Leaf".into(),
}];
let leaf = make_page("leaf.html", "Leaf", false);
let sitemap = generate_sitemap(&[root, child, leaf], "https://example.com");
assert!(sitemap.contains("<loc>https://example.com/index.html</loc>"));
assert!(sitemap.contains("<priority>1.0</priority>")); assert!(sitemap.contains("<priority>0.8</priority>")); assert!(sitemap.contains("<priority>0.6</priority>")); }
#[test]
fn test_robots_txt_public() {
let robots = generate_robots_txt("https://example.com", true);
assert!(robots.contains("Allow: /"));
assert!(robots.contains("Sitemap: https://example.com/sitemap.xml"));
}
#[test]
fn test_robots_txt_private() {
let robots = generate_robots_txt("https://example.com", false);
assert!(robots.contains("Disallow: /"));
assert!(!robots.contains("Sitemap"));
}
#[test]
fn test_atom_feed_excludes_root_and_index_pages() {
let root = make_page("index.html", "Home", true);
let mut index_child = make_page("section.html", "Section", false);
index_child.contents_links = vec![NavLink {
href: "leaf.html".into(),
title: "Leaf".into(),
}];
let leaf = make_page("leaf.html", "Leaf", false);
let atom = generate_atom_feed(
&[root, index_child, leaf],
"Site",
"https://example.com",
"",
"",
);
assert_eq!(atom.matches("<entry>").count(), 1);
assert!(atom.contains("<title>Leaf</title>"));
assert!(!atom.contains("<title>Home</title>"));
assert!(!atom.contains("<title>Section</title>"));
}
#[test]
fn test_atom_feed_hide_from_feed() {
let root = make_page("index.html", "Home", true);
let mut hidden = make_page("hidden.html", "Hidden", false);
hidden.hide_from_feed = true;
let visible = make_page("visible.html", "Visible", false);
let atom = generate_atom_feed(
&[root, hidden, visible],
"Site",
"https://example.com",
"",
"",
);
assert_eq!(atom.matches("<entry>").count(), 1);
assert!(atom.contains("<title>Visible</title>"));
assert!(!atom.contains("<title>Hidden</title>"));
}
#[test]
fn test_rss_feed_structure() {
let root = make_page("index.html", "Home", true);
let mut leaf = make_page("post.html", "Post", false);
leaf.created = Some("2024-01-15".into());
let rss = generate_rss_feed(
&[root, leaf],
"My Blog",
"https://example.com",
"A blog",
"Author",
);
assert!(rss.contains("<title>My Blog</title>"));
assert!(rss.contains("<description>A blog</description>"));
assert!(rss.contains("<title>Post</title>"));
assert!(rss.contains("<guid isPermaLink=\"true\">https://example.com/post.html</guid>"));
assert!(
rss.contains("<pubDate>Mon, 15 Jan 2024 00:00:00 +0000</pubDate>"),
"got {rss}"
);
}
#[test]
fn feeds_order_by_the_same_date_chain_the_index_does() {
let mut letter = make_page("letter.html", "Letter", false);
letter.date_of_document = Some("1944-06-06".into());
letter.created = Some("2026-08-16".into());
let mut note = make_page("note.html", "Note", false);
note.created = Some("2026-01-02".into());
let pages = [letter, note];
let atom = generate_atom_feed(&pages, "Site", "https://ex.com", "", "");
assert!(
atom.find("<title>Note</title>") < atom.find("<title>Letter</title>"),
"got {atom}"
);
assert!(atom.contains("<published>1944-06-06T00:00:00Z</published>"));
assert!(!atom.contains("1944-06-06</published>\n <published>"));
let rss = generate_rss_feed(&pages, "Site", "https://ex.com", "", "");
assert!(rss.contains("<pubDate>Tue, 06 Jun 1944 00:00:00 +0000</pubDate>"));
}
#[test]
fn a_deliberately_undated_record_sorts_to_the_bottom() {
let mut undated = make_page("undated.html", "Undated", false);
undated.date_of_document = Some("unknown".into());
let mut old = make_page("old.html", "Old", false);
old.date_of_document = Some("1943-05-12".into());
let mut new = make_page("new.html", "New", false);
new.date_of_document = Some("2026-08-16".into());
let pages = [undated, old, new];
let atom = generate_atom_feed(&pages, "Site", "https://ex.com", "", "");
let at = |t: &str| atom.find(&format!("<title>{t}</title>")).unwrap();
assert!(
at("New") < at("Old") && at("Old") < at("Undated"),
"newest first, and the undated record last: {atom}"
);
assert!(!atom.contains("<published>unknown</published>"));
}
#[test]
fn an_unparseable_date_does_not_sort_by_its_spelling() {
let mut wordy = make_page("wordy.html", "Wordy", false);
wordy.date_of_document = Some("May 1943".into());
let mut typo = make_page("typo.html", "Typo", false);
typo.date_of_document = Some("19430512".into());
let mut dated = make_page("dated.html", "Dated", false);
dated.date_of_document = Some("2026-08-16".into());
let pages = [wordy, typo, dated];
let atom = generate_atom_feed(&pages, "Site", "https://ex.com", "", "");
let at = |t: &str| atom.find(&format!("<title>{t}</title>")).unwrap();
assert!(
at("Dated") < at("Typo") && at("Dated") < at("Wordy"),
"the only readable date leads: {atom}"
);
}
#[test]
fn an_undated_entry_still_carries_a_valid_atom_updated() {
let page = make_page("post.html", "Post", false);
let atom = generate_atom_feed(&[page], "Site", "https://ex.com", "", "");
assert!(atom.contains(&format!("<updated>{EPOCH_RFC3339}</updated>")));
assert!(!atom.contains("<published>"));
}
#[test]
fn an_unreadable_date_is_omitted_not_forwarded() {
let mut page = make_page("post.html", "Post", false);
page.created = Some("sometime last summer".into());
let atom = generate_atom_feed(&[page.clone()], "Site", "https://ex.com", "", "");
assert!(!atom.contains("sometime last summer"));
assert!(atom.contains(&format!("<updated>{EPOCH_RFC3339}</updated>")));
let rss = generate_rss_feed(&[page.clone()], "Site", "https://ex.com", "", "");
assert!(!rss.contains("sometime last summer"));
assert!(!rss.contains("<pubDate>"));
let sitemap = generate_sitemap(&[page.clone()], "https://ex.com");
assert!(!sitemap.contains("<lastmod>"));
let meta = generate_seo_meta(&page, "Site", "https://ex.com");
assert!(!meta.contains("article:published_time"));
}
#[test]
fn sitemap_lastmod_is_a_w3c_datetime() {
let mut page = make_page("post.html", "Post", false);
page.updated = Some("2026-08-16".into());
let sitemap = generate_sitemap(&[page], "https://ex.com");
assert!(sitemap.contains("<lastmod>2026-08-16T00:00:00Z</lastmod>"));
}
#[test]
fn seo_article_times_are_rfc3339_from_the_shared_chain() {
let mut page = make_page("post.html", "Post", false);
page.date_of_document = Some("2026-01-15".into());
page.created = Some("2026-08-16".into());
page.updated = Some("2026-08-20".into());
let meta = generate_seo_meta(&page, "Site", "https://ex.com");
assert!(meta.contains(r#"article:published_time" content="2026-01-15T00:00:00Z""#));
assert!(meta.contains(r#"article:modified_time" content="2026-08-20T00:00:00Z""#));
}
#[test]
fn feed_content_carries_absolute_links_and_images() {
let root = make_page("index.html", "Home", true);
let mut leaf = make_page("notes/entry.html", "Entry", false);
leaf.rendered_body =
r#"<p><a href="../other.html">o</a><img src="../_attachments/a.jpg"></p>"#.to_string();
let pages = [root, leaf];
let atom = generate_atom_feed(&pages, "Site", "https://ex.com", "", "");
assert!(atom.contains(r#"href="https://ex.com/other.html""#));
assert!(atom.contains(r#"src="https://ex.com/_attachments/a.jpg""#));
assert!(!atom.contains(r#"href="../other.html""#));
let rss = generate_rss_feed(&pages, "Site", "https://ex.com", "", "");
assert!(rss.contains(r#"href="https://ex.com/other.html""#));
assert!(rss.contains(r#"src="https://ex.com/_attachments/a.jpg""#));
}
fn nav_node(href: &str, children: Vec<SiteNavNode>) -> SiteNavNode {
SiteNavNode {
title: href.trim_end_matches(".html").to_string(),
href: href.to_string(),
is_current: false,
is_ancestor_of_current: false,
children,
}
}
fn rooted_nav() -> SiteNavigation {
let mut leaf = nav_node("b/leaf.html", vec![]);
leaf.is_current = true;
let mut b = nav_node("b.html", vec![leaf]);
b.is_ancestor_of_current = true;
let c = nav_node("c.html", vec![nav_node("c/kid.html", vec![])]);
let mut root = nav_node("index.html", vec![nav_node("a.html", vec![]), b, c]);
root.is_ancestor_of_current = true;
SiteNavigation {
tree: vec![root],
breadcrumbs: vec![],
}
}
#[test]
fn the_front_page_leaves_the_tree_for_the_masthead() {
let html = render_site_nav(&rooted_nav(), "My Site", "../");
assert!(
html.contains(r#"<a class="site-masthead" href="../index.html">My Site</a>"#),
"got {html}"
);
assert!(
html.starts_with(r#"<input class="nav-toggle-state""#),
"the drawer's state comes first, then the bar: {html}"
);
assert!(
html.contains(r#"<nav class="site-nav" id="site-nav" aria-label="Site navigation">"#),
"got {html}"
);
assert!(html.contains(r#"<ul class="nav-list"><li><a href="../a.html">a</a></li>"#));
assert_eq!(
html.matches("index.html").count(),
2,
"bar and sidebar mastheads only"
);
let state = html
.find(r#"<input class="nav-toggle-state" type="checkbox" id="nav-toggle""#)
.expect("the drawer state precedes the bar");
let bar = html.find(r#"<header class="site-bar">"#).unwrap();
let nav = html.find(r#"<nav class="site-nav""#).unwrap();
assert!(state < bar && bar < nav, "got {html}");
assert!(
html.contains(r#"<label class="nav-toggle" for="nav-toggle">Menu</label>"#),
"got {html}"
);
}
#[test]
fn the_masthead_is_current_on_the_front_page() {
let mut nav = rooted_nav();
nav.tree[0].is_current = true;
nav.tree[0].is_ancestor_of_current = false;
let html = render_site_nav(&nav, "My Site", "");
assert!(
html.contains(
r#"<a class="site-masthead" href="index.html" aria-current="page">My Site</a>"#
),
"got {html}"
);
let elsewhere = render_site_nav(&rooted_nav(), "My Site", "../");
assert!(!elsewhere.contains(r#"site-masthead" href="../index.html" aria-current"#));
}
#[test]
fn sections_are_disclosures_open_along_the_current_branch() {
let html = render_site_nav(&rooted_nav(), "My Site", "../");
assert!(
html.contains(
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>"#
),
"the ancestor is open: {html}"
);
assert!(
html.contains(
r#"<li class="nav-section"><details><summary><a href="../c.html">c</a></summary>"#
),
"the other section is closed: {html}"
);
}
#[test]
fn the_current_sections_own_disclosure_is_open() {
let mut nav = rooted_nav();
let root = &mut nav.tree[0];
root.children[1].children[0].is_current = false;
root.children[1].is_ancestor_of_current = false;
root.children[1].is_current = true;
let html = render_site_nav(&nav, "My Site", "");
assert!(
html.contains(
r#"<li class="nav-section nav-current"><details open><summary><a href="b.html" aria-current="page">b</a></summary>"#
),
"got {html}"
);
}
#[test]
fn a_forest_lists_under_the_masthead() {
let nav = SiteNavigation {
tree: vec![nav_node(
"daily.html",
vec![nav_node("daily/mon.html", vec![])],
)],
breadcrumbs: vec![],
};
let html = render_site_nav(&nav, "Notes", "");
assert!(html.contains(r#"<a class="site-masthead" href="index.html">Notes</a>"#));
assert!(
html.contains(r#"<summary><a href="daily.html">daily</a></summary>"#),
"the forest root is a row: {html}"
);
}
#[test]
fn an_empty_tree_renders_no_nav_at_all() {
let nav = SiteNavigation {
tree: vec![],
breadcrumbs: vec![],
};
assert_eq!(render_site_nav(&nav, "My Site", ""), "");
}
#[test]
fn the_pager_links_the_neighbours_in_reading_order() {
let order = crate::nav::reading_order(&rooted_nav().tree);
let hrefs: Vec<&str> = order.iter().map(|l| l.href.as_str()).collect();
assert_eq!(
hrefs,
[
"index.html",
"a.html",
"b.html",
"b/leaf.html",
"c.html",
"c/kid.html"
]
);
let middle = render_pager(&order, "b/leaf.html", "../");
assert_eq!(
middle,
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>"#
);
let first = render_pager(&order, "index.html", "");
assert!(!first.contains("pager-prev"), "{first}");
assert!(first.contains(r#"rel="next" href="a.html""#), "{first}");
let last = render_pager(&order, "c/kid.html", "../");
assert!(last.contains(r#"rel="prev" href="../c.html""#), "{last}");
assert!(!last.contains("pager-next"), "{last}");
}
#[test]
fn a_page_outside_the_order_gets_no_pager() {
let order = crate::nav::reading_order(&rooted_nav().tree);
assert_eq!(render_pager(&order, "hidden.html", ""), "");
let alone = crate::nav::reading_order(&[nav_node("index.html", vec![])]);
assert_eq!(render_pager(&alone, "index.html", ""), "");
}
#[test]
fn test_feed_links() {
let links = generate_feed_link_tags("");
assert!(links.contains("application/atom+xml"));
assert!(links.contains("feed.xml"));
assert!(links.contains("application/rss+xml"));
assert!(links.contains("rss.xml"));
}
#[test]
fn test_strip_html_truncate() {
let html = "<p>Hello <strong>world</strong>, this is a test.</p>";
let result = strip_html_truncate(html, 11);
assert_eq!(result, "Hello world");
}
}