use std::collections::{BTreeMap, BTreeSet};
use crate::domain::model::body::Body;
use crate::domain::model::page::{Page, Slug, SlugError};
use crate::domain::model::site::{MarkdownEntry, Source};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PagesError {
MissingTitle {
source: Source,
},
BadFrontmatter {
source: Source,
reason: String,
},
BadSlug {
source: Source,
segment: String,
offending: char,
},
EmptySlug {
source: Source,
segment: String,
},
BundleClash {
directory: String,
},
DuplicateUrl {
url: String,
sources: Vec<Source>,
},
}
pub fn build_pages(
entries: Vec<MarkdownEntry>,
publish_prefix: &str,
) -> Result<Vec<Page>, PagesError> {
let leaf_dirs = collect_dirs_with(&entries, "index.md");
let branch_dirs = collect_dirs_with(&entries, "_index.md");
if let Some(clash) = leaf_dirs.intersection(&branch_dirs).next() {
return Err(PagesError::BundleClash {
directory: clash.clone(),
});
}
let prefix = normalise_prefix(publish_prefix);
let mut pages: Vec<Page> = Vec::new();
for entry in entries {
let path = entry.source.as_str();
let dir = parent_dir(path);
let filename = file_name(path);
if !emits(&dir, filename, &leaf_dirs) {
continue;
}
let url = compute_url(&entry.source, &prefix, filename)?;
let (title, body) = parse_frontmatter(entry.content.as_str(), &entry.source)?;
pages.push(Page {
source: entry.source,
url,
title,
body: Body::new(body),
});
}
detect_duplicate_urls(&pages)?;
Ok(pages)
}
fn collect_dirs_with(entries: &[MarkdownEntry], filename: &str) -> BTreeSet<String> {
entries
.iter()
.filter(|e| file_name(e.source.as_str()) == filename)
.map(|e| parent_dir(e.source.as_str()))
.collect()
}
fn parent_dir(path: &str) -> String {
match path.rfind('/') {
Some(idx) => path[..idx].to_string(),
None => String::new(),
}
}
fn file_name(path: &str) -> &str {
match path.rfind('/') {
Some(idx) => &path[idx + 1..],
None => path,
}
}
fn emits(dir: &str, filename: &str, leaf_dirs: &BTreeSet<String>) -> bool {
if leaf_dirs.contains(dir) {
return filename == "index.md";
}
for ancestor in ancestors_of(dir) {
if leaf_dirs.contains(&ancestor) {
return false;
}
}
true
}
fn ancestors_of(dir: &str) -> Vec<String> {
let mut out = Vec::new();
let mut current = dir;
while let Some(idx) = current.rfind('/') {
current = ¤t[..idx];
out.push(current.to_string());
}
out
}
fn compute_url(
source: &Source,
publish_prefix: &str,
filename: &str,
) -> Result<String, PagesError> {
let path = source.as_str();
let dir = parent_dir(path);
let mut url = String::from(publish_prefix);
for segment in dir.split('/').filter(|s| !s.is_empty()) {
let slug = slug_or_error(segment, source)?;
url.push_str(slug.as_str());
url.push('/');
}
if filename != "index.md" && filename != "_index.md" {
let slug = slug_or_error(filename, source)?;
url.push_str(slug.as_str());
url.push('/');
}
Ok(url)
}
fn slug_or_error(segment: &str, source: &Source) -> Result<Slug, PagesError> {
Slug::from_segment(segment).map_err(|e| match e {
SlugError::Empty { segment } => PagesError::EmptySlug {
source: source.clone(),
segment,
},
SlugError::Rejected { segment, offending } => PagesError::BadSlug {
source: source.clone(),
segment,
offending,
},
})
}
fn normalise_prefix(prefix: &str) -> String {
let trimmed = prefix.trim_end_matches('/');
let with_leading = if trimmed.starts_with('/') {
trimmed.to_string()
} else {
format!("/{trimmed}")
};
format!("{with_leading}/")
}
fn parse_frontmatter(content: &str, source: &Source) -> Result<(String, String), PagesError> {
let (fm, body) = split_frontmatter(content).ok_or_else(|| PagesError::BadFrontmatter {
source: source.clone(),
reason: "missing `---` delimiters".to_string(),
})?;
let title = extract_title(fm).ok_or_else(|| PagesError::MissingTitle {
source: source.clone(),
})?;
Ok((title, body.to_string()))
}
fn split_frontmatter(source: &str) -> Option<(&str, &str)> {
let after = source.strip_prefix("---\n")?;
let end = after.find("\n---")?;
let fm = &after[..end];
let body = after[end + 4..].trim_start_matches('\n');
Some((fm, body))
}
fn extract_title(frontmatter: &str) -> Option<String> {
for line in frontmatter.lines() {
let Some(rest) = line.strip_prefix("title:") else {
continue;
};
let value = rest
.trim()
.trim_matches('"')
.trim_matches('\'')
.trim()
.to_string();
if !value.is_empty() {
return Some(value);
}
}
None
}
fn detect_duplicate_urls(pages: &[Page]) -> Result<(), PagesError> {
let mut by_url: BTreeMap<&str, Vec<&Source>> = BTreeMap::new();
for page in pages {
by_url
.entry(page.url.as_str())
.or_default()
.push(&page.source);
}
for (url, sources) in by_url {
if sources.len() > 1 {
return Err(PagesError::DuplicateUrl {
url: url.to_string(),
sources: sources.into_iter().cloned().collect(),
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(path: &str, content: &str) -> MarkdownEntry {
MarkdownEntry::new(Source::relative_path(path).unwrap(), Body::new(content))
}
fn raw(title: &str, body: &str) -> String {
format!("---\ntitle: {title}\n---\n{body}")
}
#[test]
fn root_level_file_gets_a_dir_url() {
let pages = build_pages(vec![entry("foo.md", &raw("Foo", "body"))], "/pages").unwrap();
assert_eq!(pages.len(), 1);
assert_eq!(pages[0].url, "/pages/foo/");
assert_eq!(pages[0].title, "Foo");
assert_eq!(pages[0].body.as_str(), "body");
}
#[test]
fn nested_file_keeps_directory_segments() {
let pages =
build_pages(vec![entry("guides/intro.md", &raw("Intro", ""))], "/pages").unwrap();
assert_eq!(pages[0].url, "/pages/guides/intro/");
}
#[test]
fn numeric_prefix_is_preserved_in_url() {
let pages = build_pages(vec![entry("01-intro.md", &raw("Intro", ""))], "/pages").unwrap();
assert_eq!(pages[0].url, "/pages/01-intro/");
}
#[test]
fn prefix_without_leading_slash_is_normalised() {
let pages = build_pages(vec![entry("a.md", &raw("A", ""))], "pages").unwrap();
assert_eq!(pages[0].url, "/pages/a/");
}
#[test]
fn prefix_with_trailing_slash_is_normalised() {
let pages = build_pages(vec![entry("a.md", &raw("A", ""))], "/pages/").unwrap();
assert_eq!(pages[0].url, "/pages/a/");
}
#[test]
fn leaf_bundle_publishes_only_index() {
let pages = build_pages(
vec![
entry("topic/index.md", &raw("Topic", "main")),
entry("topic/plan.md", &raw("Plan", "ignored")),
],
"/pages",
)
.unwrap();
assert_eq!(pages.len(), 1);
assert_eq!(pages[0].url, "/pages/topic/");
assert_eq!(pages[0].title, "Topic");
}
#[test]
fn branch_bundle_publishes_index_and_siblings() {
let pages = build_pages(
vec![
entry("section/_index.md", &raw("Section", "")),
entry("section/page.md", &raw("Page", "")),
],
"/pages",
)
.unwrap();
assert_eq!(pages.len(), 2);
let urls: Vec<&str> = pages.iter().map(|p| p.url.as_str()).collect();
assert!(urls.contains(&"/pages/section/"));
assert!(urls.contains(&"/pages/section/page/"));
}
#[test]
fn nested_descendants_of_leaf_are_skipped() {
let pages = build_pages(
vec![
entry("topic/index.md", &raw("Topic", "")),
entry("topic/attachments/diagram.md", &raw("Diagram", "")),
entry("topic/sub/page.md", &raw("Sub", "")),
],
"/pages",
)
.unwrap();
assert_eq!(pages.len(), 1);
assert_eq!(pages[0].url, "/pages/topic/");
}
#[test]
fn directory_with_no_bundle_marker_publishes_every_file() {
let pages = build_pages(
vec![
entry("notes/a.md", &raw("A", "")),
entry("notes/b.md", &raw("B", "")),
],
"/pages",
)
.unwrap();
assert_eq!(pages.len(), 2);
}
#[test]
fn coexisting_index_and_underscore_index_is_a_clash() {
let err = build_pages(
vec![
entry("section/index.md", &raw("Leaf", "")),
entry("section/_index.md", &raw("Branch", "")),
],
"/pages",
)
.unwrap_err();
assert!(matches!(err, PagesError::BundleClash { .. }));
}
#[test]
fn missing_title_is_rejected() {
let err =
build_pages(vec![entry("a.md", "---\nfoo: bar\n---\nbody")], "/pages").unwrap_err();
assert!(matches!(err, PagesError::MissingTitle { .. }));
}
#[test]
fn missing_frontmatter_delimiters_is_rejected() {
let err = build_pages(vec![entry("a.md", "no frontmatter here")], "/pages").unwrap_err();
assert!(matches!(err, PagesError::BadFrontmatter { .. }));
}
#[test]
fn bad_slug_segment_is_rejected() {
let err = build_pages(vec![entry("a?b.md", &raw("X", ""))], "/pages").unwrap_err();
assert!(matches!(err, PagesError::BadSlug { offending: '?', .. }));
}
#[test]
fn quoted_title_is_unquoted() {
let pages = build_pages(
vec![entry("a.md", "---\ntitle: \"Hello World\"\n---\nbody")],
"/pages",
)
.unwrap();
assert_eq!(pages[0].title, "Hello World");
}
#[test]
fn two_files_producing_same_url_is_a_clash() {
let err = build_pages(
vec![
entry("Foo.md", &raw("Foo", "")),
entry("foo.md", &raw("Foo", "")),
],
"/pages",
)
.unwrap_err();
assert!(matches!(err, PagesError::DuplicateUrl { .. }));
}
}