use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use comrak::nodes::{AstNode, NodeValue};
use comrak::{Arena, Options, parse_document};
use crate::document::Document;
#[derive(Debug, PartialEq)]
pub struct Entry {
pub label: String,
pub file: PathBuf,
pub home: bool,
pub children: Vec<Group>,
}
#[derive(Debug, PartialEq)]
pub struct Group {
pub title: Option<String>,
pub entries: Vec<Entry>,
}
#[derive(Debug, PartialEq)]
pub struct Topic {
pub group: Option<String>,
pub label: String,
pub href: String,
}
pub fn tree(document: &Document) -> Vec<Group> {
let crate::document::Kind::Site { root } = &document.kind else {
return Vec::new();
};
let root = document.base.join(root);
let homes = homes(document, &root);
children(document, &root, &homes)
}
fn homes(document: &Document, root: &Path) -> HashMap<PathBuf, (PathBuf, usize)> {
let mut homes = HashMap::new();
let mut seen = HashSet::from([root.to_path_buf()]);
let mut level = vec![root.to_path_buf()];
while !level.is_empty() {
let mut next = Vec::new();
for page in &level {
let Ok(source) = std::fs::read_to_string(page) else {
continue;
};
for (index, topic) in topics(&source).into_iter().enumerate() {
let file = match resolve(document, page, &topic.href) {
Target::Page(file) | Target::Document(file) => file,
Target::None | Target::Problem(_) => continue,
};
if seen.insert(file.clone()) {
homes.insert(file.clone(), (page.clone(), index));
next.push(file);
}
}
}
level = next;
}
homes
}
fn children(
document: &Document,
page: &Path,
homes: &HashMap<PathBuf, (PathBuf, usize)>,
) -> Vec<Group> {
let Ok(source) = std::fs::read_to_string(page) else {
return Vec::new();
};
let mut groups: Vec<Group> = Vec::new();
for (index, topic) in topics(&source).into_iter().enumerate() {
let (file, page_target) = match resolve(document, page, &topic.href) {
Target::Page(file) => (file, true),
Target::Document(file) => (file, false),
Target::None | Target::Problem(_) => continue,
};
let home = homes
.get(&file)
.is_some_and(|(parent, at)| parent == page && *at == index);
let entry = Entry {
label: topic.label,
children: if home && page_target {
children(document, &file, homes)
} else {
Vec::new()
},
home,
file,
};
match groups.last_mut() {
Some(group) if group.title == topic.group => group.entries.push(entry),
_ => groups.push(Group {
title: topic.group,
entries: vec![entry],
}),
}
}
groups
}
#[derive(Debug, PartialEq)]
pub enum Target {
Page(PathBuf),
Document(PathBuf),
None,
Problem(String),
}
pub fn resolve(document: &Document, page: &Path, href: &str) -> Target {
if href.contains(':') || href.starts_with('#') {
return Target::None;
}
let path = href.split('#').next().unwrap_or(href);
let Some(target) = page
.parent()
.and_then(|folder| folder.join(path).canonicalize().ok())
.filter(|target| target.starts_with(&document.served) && target.is_file())
else {
return Target::Problem(format!("Topics entry {href} — no such file"));
};
if target.extension().is_some_and(|ext| ext == "md") {
if target.starts_with(&document.base) {
Target::Page(target)
} else {
Target::Problem(format!("Topics entry {href} — outside the site's folder"))
}
} else if crate::document::kind_of(&target).is_some() {
Target::Document(target)
} else {
Target::None
}
}
pub fn topics(source: &str) -> Vec<Topic> {
let arena = Arena::new();
let root = parse_document(&arena, source, &Options::default());
let mut topics = Vec::new();
let mut in_topics = false;
let mut group = None;
for node in root.children() {
let value = node.data.borrow().value.clone();
match value {
NodeValue::Heading(heading) if heading.level <= 2 => {
in_topics = heading.level == 2 && text(node).trim() == "Topics";
group = None;
}
NodeValue::Heading(heading) if in_topics && heading.level == 3 => {
group = Some(text(node).trim().to_string());
}
NodeValue::List(_) if in_topics => {
for item in node.children() {
if let Some((label, href)) = first_link(item) {
topics.push(Topic {
group: group.clone(),
label,
href,
});
}
}
}
_ => {}
}
}
topics
}
fn first_link<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
node.descendants()
.find_map(|descendant| match &descendant.data.borrow().value {
NodeValue::Link(link) => Some((text(descendant).trim().to_string(), link.url.clone())),
_ => None,
})
}
fn text<'a>(node: &'a AstNode<'a>) -> String {
node.descendants()
.filter_map(|descendant| match &descendant.data.borrow().value {
NodeValue::Text(text) => Some(text.to_string()),
NodeValue::Code(code) => Some(code.literal.clone()),
_ => None,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_links_and_groups_from_the_topics_section_only() {
let source = "# Home\n\nSee the [viewer](viewer.md) in passing.\n\n## Topics\n\n\
- [Vision](vision.md) — why\n\n### The system\n\n- [Viewer](boxes/viewer/README.md)\n\
- [Engineering](boxes/engineering/README.md)\n\n## Afterwards\n\n- [Ignored](x.md)\n";
let found = topics(source);
let labels: Vec<_> = found
.iter()
.map(|t| (t.group.as_deref(), t.label.as_str(), t.href.as_str()))
.collect();
assert_eq!(
labels,
vec![
(None, "Vision", "vision.md"),
(Some("The system"), "Viewer", "boxes/viewer/README.md"),
(
Some("The system"),
"Engineering",
"boxes/engineering/README.md"
),
]
);
}
#[test]
fn a_page_listed_twice_has_its_position_at_its_shallowest_listing() {
let dir = std::env::temp_dir().join(format!("devpage-nav-home-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("devpage.yml"),
"kind: site\ntitle: T\nroot: README.md\n",
)
.unwrap();
std::fs::write(
dir.join("README.md"),
"# Home\n\n## Topics\n\n- [System](system.md)\n- [Start](start.md)\n",
)
.unwrap();
std::fs::write(
dir.join("system.md"),
"# System\n\n## Topics\n\n- [Start](start.md)\n",
)
.unwrap();
std::fs::write(
dir.join("start.md"),
"# Start\n\n## Topics\n\n- [First](first.md)\n",
)
.unwrap();
std::fs::write(dir.join("first.md"), "# First\n").unwrap();
let document = Document::load(&dir, &dir).unwrap();
let tree = tree(&document);
let [system, start] = &tree[0].entries[..] else {
panic!("two top-level entries");
};
assert!(start.home, "the top-level listing is the position");
assert_eq!(start.children[0].entries[0].label, "First");
let listed_again = &system.children[0].entries[0];
assert_eq!(listed_again.label, "Start");
assert!(!listed_again.home);
assert!(
listed_again.children.is_empty(),
"a second listing is a plain link"
);
}
#[test]
fn builds_the_tree_and_stops_at_cycles() {
let dir = std::env::temp_dir().join(format!("devpage-nav-{}", std::process::id()));
std::fs::create_dir_all(dir.join("guide")).unwrap();
std::fs::write(
dir.join("devpage.yml"),
"kind: site\ntitle: T\nroot: README.md\n",
)
.unwrap();
std::fs::write(
dir.join("README.md"),
"# Home\n\n## Topics\n\n- [Guide](guide/README.md)\n- [Missing](nope.md)\n- [Talk](talk.yml)\n",
)
.unwrap();
std::fs::write(
dir.join("talk.yml"),
"kind: slides\ntitle: T\nslides: [README.md]\n",
)
.unwrap();
std::fs::write(
dir.join("guide/README.md"),
"# Guide\n\n## Topics\n\n- [Back home](../README.md)\n- [Step](step.md)\n",
)
.unwrap();
std::fs::write(dir.join("guide/step.md"), "# Step\n").unwrap();
let document = Document::load(&dir, &dir).unwrap();
let tree = tree(&document);
assert_eq!(tree.len(), 1);
let talk = &tree[0].entries[1];
assert_eq!(talk.label, "Talk", "a document file is an entry");
assert!(talk.children.is_empty());
let guide = &tree[0].entries[0];
assert_eq!(guide.label, "Guide");
let under_guide: Vec<_> = guide.children[0]
.entries
.iter()
.map(|e| e.label.as_str())
.collect();
assert_eq!(under_guide, vec!["Back home", "Step"]);
assert!(
guide.children[0].entries[0].children.is_empty(),
"the root is not expanded again"
);
}
#[test]
fn a_page_outside_the_site_or_a_missing_one_is_a_problem() {
let dir = std::env::temp_dir().join(format!("devpage-nav-out-{}", std::process::id()));
std::fs::create_dir_all(dir.join("site")).unwrap();
let dir = dir.canonicalize().unwrap();
std::fs::write(dir.join("outside.md"), "# Out\n").unwrap();
std::fs::write(dir.join("site/README.md"), "# Home\n").unwrap();
std::fs::write(
dir.join("site/devpage.yml"),
"kind: site\ntitle: T\nroot: README.md\n",
)
.unwrap();
let document = Document::load(&dir.join("site"), &dir).unwrap();
let page = dir.join("site/README.md");
assert!(matches!(
resolve(&document, &page, "../outside.md"),
Target::Problem(message) if message.contains("outside the site's folder")
));
assert!(matches!(
resolve(&document, &page, "nope.md"),
Target::Problem(_)
));
assert_eq!(
resolve(&document, &page, "https://example.com"),
Target::None
);
assert_eq!(resolve(&document, &page, "#top"), Target::None);
}
}