use std::collections::{HashMap, HashSet};
use crate::links::sanitize_rel_path;
use crate::types::{NavLink, OutlineNode, PublishedPage, SiteNavNode, SiteNavigation};
const MAX_NAV_DEPTH: usize = 6;
pub fn build_site_nav_tree(pages: &[PublishedPage], outline: &[OutlineNode]) -> Vec<SiteNavNode> {
let containment = Containment::of(pages, outline);
let mut placed: HashSet<&str> = HashSet::new();
let root = pages.iter().find(|p| p.is_root);
if let Some(r) = root {
placed.insert(r.dest_filename.as_str());
}
let mut top: Vec<SiteNavNode> = match root {
Some(r) => build_children(r, &containment, &mut placed, 0),
None => Vec::new(),
};
let mut orphans: Vec<(i32, SiteNavNode)> = Vec::new();
for (idx, page) in pages.iter().enumerate() {
if page.hide_from_nav || placed.contains(page.dest_filename.as_str()) {
continue;
}
if !containment.is_forest_root(page) {
continue;
}
placed.insert(page.dest_filename.as_str());
let children = build_children(page, &containment, &mut placed, 1);
orphans.push((
page.nav_order.unwrap_or(idx as i32),
node_for(page, None, children),
));
}
orphans.sort_by_key(|(key, _)| *key);
top.extend(orphans.into_iter().map(|(_, node)| node));
for page in pages {
if page.hide_from_nav || placed.contains(page.dest_filename.as_str()) {
continue;
}
placed.insert(page.dest_filename.as_str());
top.push(node_for(page, None, Vec::new()));
}
match root {
Some(r) => vec![node_for(r, None, top)],
None => top,
}
}
pub fn forest_roots<'p>(
pages: &'p [PublishedPage],
outline: &[OutlineNode],
) -> Vec<&'p PublishedPage> {
let containment = Containment::of(pages, outline);
pages
.iter()
.filter(|p| !p.hide_from_nav && containment.is_forest_root(p))
.collect()
}
pub struct OutlineEdge<'o> {
pub container: String,
pub contained: String,
pub label: Option<&'o str>,
}
pub fn pruned_edges<'o>(
outline: &'o [OutlineNode],
admits: &dyn Fn(&str) -> bool,
) -> Vec<OutlineEdge<'o>> {
fn walk<'o>(
node: &'o OutlineNode,
under: Option<&str>,
admits: &dyn Fn(&str) -> bool,
out: &mut Vec<OutlineEdge<'o>>,
) {
let path = sanitize_rel_path(&node.path);
let mine = admits(&path).then_some(path);
if let Some(path) = &mine
&& let Some(container) = under
{
out.push(OutlineEdge {
container: container.to_string(),
contained: path.clone(),
label: node.label.as_deref(),
});
}
let under = mine.as_deref().or(under);
for child in &node.children {
walk(child, under, admits, out);
}
}
let mut out = Vec::new();
for node in outline {
walk(node, None, admits, &mut out);
}
out
}
#[derive(Default)]
struct Containment<'p> {
children: HashMap<&'p str, Vec<Child<'p>>>,
parent: HashMap<&'p str, &'p str>,
}
struct Child<'p> {
page: &'p PublishedPage,
label: Option<&'p str>,
}
impl<'p> Containment<'p> {
fn of(pages: &'p [PublishedPage], outline: &'p [OutlineNode]) -> Self {
match outline.is_empty() {
true => Self::from_links(pages),
false => Self::from_outline(pages, outline),
}
}
fn from_links(pages: &'p [PublishedPage]) -> Self {
let by_dest: HashMap<&str, &PublishedPage> = pages
.iter()
.map(|p| (p.dest_filename.as_str(), p))
.collect();
let mut out = Self::default();
for page in pages {
let children: Vec<Child> = page
.contents_links
.iter()
.filter_map(|link| {
by_dest.get(link.href.as_str()).map(|child| Child {
page: child,
label: Some(link.title.as_str()),
})
})
.collect();
if !children.is_empty() {
out.children.insert(page.dest_filename.as_str(), children);
}
if let Some(parent) = &page.parent_link
&& let Some(container) = by_dest.get(parent.href.as_str())
{
out.parent.insert(
page.dest_filename.as_str(),
container.dest_filename.as_str(),
);
}
}
out
}
fn from_outline(pages: &'p [PublishedPage], outline: &'p [OutlineNode]) -> Self {
let mut by_source: HashMap<String, &PublishedPage> = HashMap::new();
for page in pages {
by_source
.entry(sanitize_rel_path(&page.source_path.to_string_lossy()))
.or_insert(page);
}
let edges = pruned_edges(outline, &|path| by_source.contains_key(path));
let mut out = Self::default();
for edge in edges {
let (Some(container), Some(page)) = (
by_source.get(&edge.container).copied(),
by_source.get(&edge.contained).copied(),
) else {
continue;
};
out.children
.entry(container.dest_filename.as_str())
.or_default()
.push(Child {
page,
label: edge.label,
});
out.parent
.entry(page.dest_filename.as_str())
.or_insert(container.dest_filename.as_str());
}
out
}
fn is_forest_root(&self, page: &PublishedPage) -> bool {
!self.parent.contains_key(page.dest_filename.as_str())
}
fn children_of(&self, page: &PublishedPage) -> &[Child<'p>] {
self.children
.get(page.dest_filename.as_str())
.map(Vec::as_slice)
.unwrap_or_default()
}
}
fn node_for(page: &PublishedPage, label: Option<&str>, children: Vec<SiteNavNode>) -> SiteNavNode {
let title = page
.nav_title
.clone()
.or_else(|| label.map(str::to_string))
.unwrap_or_else(|| page.title.clone());
SiteNavNode {
title,
href: page.dest_filename.clone(),
is_current: false,
is_ancestor_of_current: false,
children,
}
}
fn build_children<'p>(
page: &'p PublishedPage,
containment: &Containment<'p>,
placed: &mut HashSet<&'p str>,
depth: usize,
) -> Vec<SiteNavNode> {
if depth >= MAX_NAV_DEPTH {
return Vec::new();
}
let mut children: Vec<(i32, SiteNavNode)> = Vec::new();
for (idx, Child { page: child, label }) in containment.children_of(page).iter().enumerate() {
if child.hide_from_nav || placed.contains(child.dest_filename.as_str()) {
continue;
}
placed.insert(child.dest_filename.as_str());
let sub_children = build_children(child, containment, placed, depth + 1);
let sort_key = child.nav_order.unwrap_or(idx as i32);
children.push((sort_key, node_for(child, *label, sub_children)));
}
children.sort_by_key(|(key, _)| *key);
children.into_iter().map(|(_, node)| node).collect()
}
pub fn nav_for_page(
tree: &[SiteNavNode],
current_dest: &str,
pages: &[PublishedPage],
) -> SiteNavigation {
fn mark_current(nodes: &[SiteNavNode], target: &str) -> (Vec<SiteNavNode>, bool) {
let mut result = Vec::with_capacity(nodes.len());
let mut found = false;
for node in nodes {
let (children, child_found) = mark_current(&node.children, target);
let is_current = node.href == target;
let is_ancestor = child_found;
if is_current || is_ancestor {
found = true;
}
result.push(SiteNavNode {
title: node.title.clone(),
href: node.href.clone(),
is_current,
is_ancestor_of_current: is_ancestor,
children,
});
}
(result, found)
}
let (marked_tree, _) = mark_current(tree, current_dest);
let mut breadcrumbs = Vec::new();
if trail_to(tree, current_dest, &mut breadcrumbs) {
return SiteNavigation {
tree: marked_tree,
breadcrumbs,
};
}
let page_map: HashMap<&str, &PublishedPage> = pages
.iter()
.map(|p| (p.dest_filename.as_str(), p))
.collect();
if let Some(current_page) = page_map.get(current_dest) {
let mut chain = vec![NavLink {
href: current_page.dest_filename.clone(),
title: current_page
.nav_title
.clone()
.unwrap_or_else(|| current_page.title.clone()),
}];
let mut visited = HashSet::new();
visited.insert(current_dest.to_string());
let mut cursor = current_page.parent_link.as_ref();
while let Some(parent) = cursor {
if !visited.insert(parent.href.clone()) {
break; }
chain.push(NavLink {
href: parent.href.clone(),
title: page_map
.get(parent.href.as_str())
.and_then(|p| p.nav_title.clone())
.unwrap_or_else(|| parent.title.clone()),
});
cursor = page_map
.get(parent.href.as_str())
.and_then(|p| p.parent_link.as_ref());
}
chain.reverse();
breadcrumbs = chain;
}
SiteNavigation {
tree: marked_tree,
breadcrumbs,
}
}
fn trail_to(nodes: &[SiteNavNode], target: &str, into: &mut Vec<NavLink>) -> bool {
for node in nodes {
into.push(NavLink {
href: node.href.clone(),
title: node.title.clone(),
});
if node.href == target || trail_to(&node.children, target, into) {
return true;
}
into.pop();
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::PageLayout;
use std::path::PathBuf;
fn make_page(
dest: &str,
title: &str,
is_root: bool,
contents: Vec<NavLink>,
parent: Option<NavLink>,
) -> PublishedPage {
PublishedPage {
source_path: PathBuf::from(format!("/workspace/{}", dest.replace(".html", ".md"))),
dest_filename: dest.to_string(),
title: title.to_string(),
rendered_body: String::new(),
markdown_body: String::new(),
contents_links: contents,
parent_link: parent,
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,
nav_title: None,
nav_order: None,
hide_from_nav: false,
hide_from_feed: false,
id: None,
source_markdown: String::new(),
}
}
#[test]
fn test_nav_tree_flat_workspace() {
let pages = vec![
make_page(
"index.html",
"Home",
true,
vec![
NavLink {
href: "a.html".into(),
title: "A".into(),
},
NavLink {
href: "b.html".into(),
title: "B".into(),
},
],
None,
),
make_page(
"a.html",
"A",
false,
vec![],
Some(NavLink {
href: "index.html".into(),
title: "Home".into(),
}),
),
make_page(
"b.html",
"B",
false,
vec![],
Some(NavLink {
href: "index.html".into(),
title: "Home".into(),
}),
),
];
let tree = build_site_nav_tree(&pages, &[]);
assert_eq!(tree.len(), 1);
assert_eq!(tree[0].title, "Home");
assert_eq!(tree[0].children.len(), 2);
assert_eq!(tree[0].children[0].title, "A");
assert_eq!(tree[0].children[1].title, "B");
}
#[test]
fn test_nav_tree_deep_hierarchy() {
let pages = vec![
make_page(
"index.html",
"Root",
true,
vec![NavLink {
href: "parent.html".into(),
title: "Parent".into(),
}],
None,
),
make_page(
"parent.html",
"Parent",
false,
vec![NavLink {
href: "child.html".into(),
title: "Child".into(),
}],
Some(NavLink {
href: "index.html".into(),
title: "Root".into(),
}),
),
make_page(
"child.html",
"Child",
false,
vec![NavLink {
href: "grandchild.html".into(),
title: "Grandchild".into(),
}],
Some(NavLink {
href: "parent.html".into(),
title: "Parent".into(),
}),
),
make_page(
"grandchild.html",
"Grandchild",
false,
vec![],
Some(NavLink {
href: "child.html".into(),
title: "Child".into(),
}),
),
];
let tree = build_site_nav_tree(&pages, &[]);
assert_eq!(tree[0].children.len(), 1); assert_eq!(tree[0].children[0].children.len(), 1); assert_eq!(tree[0].children[0].children[0].children.len(), 1); assert_eq!(
tree[0].children[0].children[0].children[0].children.len(),
0
);
}
#[test]
fn test_nav_tree_hide_from_nav() {
let mut hidden_page = make_page(
"hidden.html",
"Hidden",
false,
vec![],
Some(NavLink {
href: "index.html".into(),
title: "Home".into(),
}),
);
hidden_page.hide_from_nav = true;
let pages = vec![
make_page(
"index.html",
"Home",
true,
vec![
NavLink {
href: "visible.html".into(),
title: "Visible".into(),
},
NavLink {
href: "hidden.html".into(),
title: "Hidden".into(),
},
],
None,
),
make_page(
"visible.html",
"Visible",
false,
vec![],
Some(NavLink {
href: "index.html".into(),
title: "Home".into(),
}),
),
hidden_page,
];
let tree = build_site_nav_tree(&pages, &[]);
assert_eq!(tree[0].children.len(), 1);
assert_eq!(tree[0].children[0].title, "Visible");
}
#[test]
fn test_nav_tree_nav_order() {
let mut page_b = make_page(
"b.html",
"B",
false,
vec![],
Some(NavLink {
href: "index.html".into(),
title: "Home".into(),
}),
);
page_b.nav_order = Some(1);
let mut page_a = make_page(
"a.html",
"A",
false,
vec![],
Some(NavLink {
href: "index.html".into(),
title: "Home".into(),
}),
);
page_a.nav_order = Some(2);
let pages = vec![
make_page(
"index.html",
"Home",
true,
vec![
NavLink {
href: "a.html".into(),
title: "A".into(),
},
NavLink {
href: "b.html".into(),
title: "B".into(),
},
],
None,
),
page_a,
page_b,
];
let tree = build_site_nav_tree(&pages, &[]);
assert_eq!(tree[0].children[0].title, "B");
assert_eq!(tree[0].children[1].title, "A");
}
#[test]
fn test_nav_tree_nav_title() {
let mut page_a = make_page(
"a.html",
"Full Title of A",
false,
vec![],
Some(NavLink {
href: "index.html".into(),
title: "Home".into(),
}),
);
page_a.nav_title = Some("Short A".to_string());
let pages = vec![
make_page(
"index.html",
"Home",
true,
vec![NavLink {
href: "a.html".into(),
title: "Full Title of A".into(),
}],
None,
),
page_a,
];
let tree = build_site_nav_tree(&pages, &[]);
assert_eq!(tree[0].children[0].title, "Short A");
}
fn hrefs(nodes: &[SiteNavNode]) -> Vec<String> {
let mut out = Vec::new();
for n in nodes {
out.push(n.href.clone());
out.extend(hrefs(&n.children));
}
out
}
fn parent(href: &str) -> Option<NavLink> {
Some(NavLink {
href: href.into(),
title: href.replace(".html", ""),
})
}
fn link(href: &str) -> NavLink {
NavLink {
href: href.into(),
title: href.replace(".html", ""),
}
}
#[test]
fn an_orphaned_page_becomes_its_own_root() {
let pages = vec![
make_page("index.html", "Home", true, vec![link("a.html")], None),
make_page("a.html", "A", false, vec![], parent("index.html")),
make_page("orphan.html", "Orphan", false, vec![], None),
];
let tree = build_site_nav_tree(&pages, &[]);
assert_eq!(tree.len(), 1, "one site root");
let top: Vec<&str> = tree[0].children.iter().map(|n| n.href.as_str()).collect();
assert_eq!(top, ["a.html", "orphan.html"]);
}
#[test]
fn an_orphan_keeps_the_children_that_survived() {
let pages = vec![
make_page("index.html", "Home", true, vec![], None),
make_page("daily.html", "Daily", false, vec![link("mon.html")], None),
make_page("mon.html", "Monday", false, vec![], parent("daily.html")),
];
let tree = build_site_nav_tree(&pages, &[]);
let daily = &tree[0].children[0];
assert_eq!(daily.href, "daily.html");
assert_eq!(daily.children.len(), 1);
assert_eq!(daily.children[0].href, "mon.html");
}
#[test]
fn every_page_appears_exactly_once() {
let pages = vec![
make_page("index.html", "Home", true, vec![link("a.html")], None),
make_page(
"a.html",
"A",
false,
vec![link("a-kid.html")],
parent("index.html"),
),
make_page("a-kid.html", "A Kid", false, vec![], parent("a.html")),
make_page("orphan.html", "Orphan", false, vec![], None),
make_page("unlisted.html", "Unlisted", false, vec![], parent("a.html")),
];
let found = hrefs(&build_site_nav_tree(&pages, &[]));
let unique: HashSet<&String> = found.iter().collect();
assert_eq!(found.len(), unique.len(), "no page listed twice: {found:?}");
for page in &pages {
assert!(
unique.contains(&page.dest_filename),
"{} is missing from the nav",
page.dest_filename
);
}
}
#[test]
fn a_containment_cycle_terminates() {
let pages = vec![
make_page("a.html", "A", false, vec![link("b.html")], parent("b.html")),
make_page("b.html", "B", false, vec![link("a.html")], parent("a.html")),
];
let found = hrefs(&build_site_nav_tree(&pages, &[]));
assert_eq!(found.len(), 2, "each page once: {found:?}");
assert!(found.contains(&"a.html".to_string()));
assert!(found.contains(&"b.html".to_string()));
}
#[test]
fn a_link_to_a_page_outside_the_set_is_dropped() {
let pages = vec![
make_page(
"index.html",
"Home",
true,
vec![link("here.html"), link("excluded.html")],
None,
),
make_page("here.html", "Here", false, vec![], parent("index.html")),
];
let found = hrefs(&build_site_nav_tree(&pages, &[]));
assert!(!found.iter().any(|h| h == "excluded.html"), "{found:?}");
assert_eq!(found.len(), 2);
}
#[test]
fn a_rootless_set_returns_a_bare_forest() {
let pages = vec![
make_page("a.html", "A", false, vec![], None),
make_page("b.html", "B", false, vec![], None),
];
let tree = build_site_nav_tree(&pages, &[]);
assert_eq!(tree.len(), 2);
assert_eq!(tree[0].href, "a.html");
assert_eq!(tree[1].href, "b.html");
}
#[test]
fn test_nav_for_page_marks_current_and_ancestors() {
let pages = vec![
make_page(
"index.html",
"Root",
true,
vec![NavLink {
href: "parent.html".into(),
title: "Parent".into(),
}],
None,
),
make_page(
"parent.html",
"Parent",
false,
vec![NavLink {
href: "child.html".into(),
title: "Child".into(),
}],
Some(NavLink {
href: "index.html".into(),
title: "Root".into(),
}),
),
make_page(
"child.html",
"Child",
false,
vec![],
Some(NavLink {
href: "parent.html".into(),
title: "Parent".into(),
}),
),
];
let tree = build_site_nav_tree(&pages, &[]);
let nav = nav_for_page(&tree, "child.html", &pages);
assert!(nav.tree[0].is_ancestor_of_current);
assert!(!nav.tree[0].is_current);
assert!(nav.tree[0].children[0].is_ancestor_of_current);
assert!(!nav.tree[0].children[0].is_current);
assert!(nav.tree[0].children[0].children[0].is_current);
assert!(!nav.tree[0].children[0].children[0].is_ancestor_of_current);
assert_eq!(nav.breadcrumbs.len(), 3);
assert_eq!(nav.breadcrumbs[0].title, "Root");
assert_eq!(nav.breadcrumbs[1].title, "Parent");
assert_eq!(nav.breadcrumbs[2].title, "Child");
}
fn node(path: &str, children: Vec<OutlineNode>) -> OutlineNode {
OutlineNode {
path: path.to_string(),
label: None,
children,
}
}
fn sourced(mut page: PublishedPage, source: &str) -> PublishedPage {
page.source_path = PathBuf::from(source);
page
}
#[test]
fn the_nav_follows_the_configured_spanning_relation() {
let pages = vec![
sourced(
make_page("index.html", "Home", true, vec![], None),
"index.md",
),
sourced(
make_page("field.html", "Field Notes", false, vec![], None),
"field.md",
),
sourced(
make_page("monday.html", "Monday", false, vec![], None),
"field/monday.md",
),
];
assert_eq!(
hrefs(&build_site_nav_tree(&pages, &[])),
["index.html", "field.html", "monday.html"]
);
let outline = vec![node(
"index.md",
vec![node("field.md", vec![node("field/monday.md", vec![])])],
)];
let tree = build_site_nav_tree(&pages, &outline);
let field = &tree[0].children[0];
assert_eq!(field.href, "field.html");
assert_eq!(field.children.len(), 1, "the hierarchy the archive has");
assert_eq!(field.children[0].href, "monday.html");
let nav = nav_for_page(&tree, "monday.html", &pages);
let trail: Vec<&str> = nav.breadcrumbs.iter().map(|b| b.title.as_str()).collect();
assert_eq!(trail, ["Home", "Field Notes", "Monday"]);
}
#[test]
fn a_pruned_container_hoists_the_pages_below_it() {
let pages = vec![
sourced(
make_page("index.html", "Home", true, vec![], None),
"index.md",
),
sourced(
make_page("letters.html", "Letters", false, vec![], None),
"letters.md",
),
sourced(
make_page("1943.html", "1943", false, vec![], None),
"letters/private/1943.md",
),
];
let outline = vec![node(
"index.md",
vec![node(
"letters.md",
vec![node(
"letters/private.md",
vec![node("letters/private/1943.md", vec![])],
)],
)],
)];
let letters = &build_site_nav_tree(&pages, &outline)[0].children[0];
assert_eq!(letters.href, "letters.html");
assert_eq!(
letters.children.len(),
1,
"the withheld tier is not a nav entry"
);
assert_eq!(letters.children[0].href, "1943.html", "what was under it");
}
#[test]
fn a_page_with_no_published_ancestor_becomes_a_forest_root() {
let pages = vec![sourced(
make_page("mon.html", "Monday", false, vec![], None),
"daily/mon.md",
)];
let outline = vec![node(
"root.md",
vec![node("daily.md", vec![node("daily/mon.md", vec![])])],
)];
assert_eq!(hrefs(&build_site_nav_tree(&pages, &outline)), ["mon.html"]);
}
#[test]
fn a_page_the_outline_does_not_reach_is_still_placed() {
let pages = vec![
sourced(
make_page("index.html", "Home", true, vec![], None),
"index.md",
),
sourced(
make_page("loose.html", "Loose", false, vec![], None),
"loose.md",
),
];
let outline = vec![node("index.md", vec![])];
let found = hrefs(&build_site_nav_tree(&pages, &outline));
assert_eq!(found, ["index.html", "loose.html"]);
}
}