use rto_graph::{Edge, EdgeKind, FactSet, Node, NodeKind, Provenance};
use crate::adr::{Section, WikiLink, clean_value, resolve_target, split_frontmatter};
use crate::text::{first_h1, heading_text};
pub const MARKER_FIELD: &str = "site-page";
const DEFAULT_ORDER: u32 = 10_000;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ParseError {
#[error("site-page slug `{0}` is not URL-safe (expected lowercase a-z, 0-9 and `-`)")]
InvalidSlug(String),
#[error("site-order `{0}` is not a non-negative integer")]
InvalidOrder(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SitePage {
pub path: String,
pub slug: String,
pub title: String,
pub nav: String,
pub order: u32,
pub sections: Vec<Section>,
pub links: Vec<WikiLink>,
}
impl SitePage {
#[must_use]
pub fn key(&self) -> String {
format!("site:{}", self.slug)
}
#[must_use]
pub fn href(&self) -> String {
format!("{}.html", self.slug)
}
#[must_use]
pub fn facts(&self) -> FactSet {
let key = self.key();
let mut node = Node::new(
key.clone(),
NodeKind::Other("site_page".into()),
self.title.clone(),
)
.with_provenance(Provenance::Authored);
node.path = Some(self.path.clone());
node.meta = serde_json::json!({ "slug": self.slug, "nav": self.nav, "order": self.order });
let mut fs = FactSet::new().with_node(node);
for section in &self.sections {
let skey = format!("{key}#{}", section.slug);
let mut snode = Node::new(
skey.clone(),
NodeKind::Other("site_section".into()),
section.title.clone(),
)
.with_provenance(Provenance::Authored);
snode.path = Some(self.path.clone());
fs = fs.with_node(snode).with_edge(Edge::authored(
key.clone(),
skey,
EdgeKind::Contains,
));
}
fs
}
}
#[must_use]
pub fn is_site_page(text: &str) -> bool {
field(text, MARKER_FIELD).is_some_and(|v| !v.is_empty())
}
pub fn parse_site_page(rel_path: &str, text: &str) -> Result<SitePage, ParseError> {
let (_, body) = split_frontmatter(text);
let slug = field(text, MARKER_FIELD).unwrap_or_default().to_owned();
if !is_url_safe_slug(&slug) {
return Err(ParseError::InvalidSlug(slug));
}
let order = match field(text, "site-order").filter(|v| !v.is_empty()) {
Some(raw) => raw
.parse::<u32>()
.map_err(|_| ParseError::InvalidOrder(raw.to_owned()))?,
None => DEFAULT_ORDER,
};
let title = field(text, "title")
.filter(|v| !v.is_empty())
.map(str::to_owned)
.or_else(|| first_h1(body))
.unwrap_or_else(|| slug.clone());
let nav = field(text, "site-nav")
.filter(|v| !v.is_empty())
.map_or_else(|| title.clone(), str::to_owned);
let key = format!("site:{slug}");
let mut sections = Vec::new();
let mut links = Vec::new();
let mut current: Option<String> = None;
let mut in_fence = false;
for line in body.lines() {
if line.trim_start().starts_with("```") {
in_fence = !in_fence;
continue;
}
if in_fence {
continue;
}
if let Some(heading) = line.strip_prefix("## ") {
let title = heading_text(heading);
let slug = crate::text::slugify(&title);
current = Some(slug.clone());
sections.push(Section { slug, title });
}
for raw in crate::text::scan_wiki_links(line) {
let from = match ¤t {
Some(slug) => format!("{key}#{slug}"),
None => key.clone(),
};
if let Some(target_key) = resolve_target(&raw) {
links.push(WikiLink {
from,
raw,
target_key,
});
}
}
}
Ok(SitePage {
path: rel_path.to_owned(),
slug,
title,
nav,
order,
sections,
links,
})
}
#[must_use]
pub fn site_nav(pages: &[SitePage]) -> Vec<&SitePage> {
let mut nav: Vec<&SitePage> = pages.iter().collect();
nav.sort_by(|a, b| a.order.cmp(&b.order).then_with(|| a.slug.cmp(&b.slug)));
nav
}
fn field<'a>(text: &'a str, key: &str) -> Option<&'a str> {
let (frontmatter, _) = split_frontmatter(text);
frontmatter.lines().find_map(|line| {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
let (k, v) = line.split_once(':')?;
k.trim().eq_ignore_ascii_case(key).then(|| clean_value(v))
})
}
fn is_url_safe_slug(slug: &str) -> bool {
!slug.is_empty()
&& !slug.starts_with('-')
&& !slug.ends_with('-')
&& slug
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
#[cfg(test)]
mod tests {
use super::{ParseError, is_site_page, parse_site_page, site_nav};
const PAGE: &str = "---\nsite-page: modes\nsite-nav: Modes\nsite-order: 3\ntitle: The five ways to run it\n---\n\n# The five ways to run it\n\n## Offline mode {#offline}\n\nSee [[crates/roteiro/src/main.rs#run_check]].\n";
#[test]
fn publication_is_declared_by_frontmatter_not_by_path() {
assert!(is_site_page(PAGE));
assert!(!is_site_page("---\nstatus: draft\n---\n\n# Internal\n"));
assert!(!is_site_page("# No frontmatter at all\n"));
assert!(!is_site_page("---\nsite-page:\n---\n\n# X\n"));
}
#[test]
fn parses_slug_nav_order_title_sections_and_links() {
let p = parse_site_page("docs/site/modes.md", PAGE).expect("parse");
assert_eq!(p.slug, "modes");
assert_eq!(p.href(), "modes.html");
assert_eq!(p.key(), "site:modes");
assert_eq!(p.nav, "Modes");
assert_eq!(p.order, 3);
assert_eq!(p.title, "The five ways to run it");
assert_eq!(p.sections.len(), 1);
assert_eq!(p.sections[0].title, "Offline mode");
assert_eq!(p.sections[0].slug, "offline-mode");
assert_eq!(p.links.len(), 1);
assert_eq!(p.links[0].from, "site:modes#offline-mode");
assert_eq!(
p.links[0].target_key,
"sym:rust:crates/roteiro/src/main.rs#run_check"
);
}
#[test]
fn title_falls_back_to_h1_then_slug_and_nav_to_title() {
let h1 = parse_site_page("p.md", "---\nsite-page: build\n---\n\n# Install & build\n")
.expect("parse");
assert_eq!(h1.title, "Install & build");
assert_eq!(h1.nav, "Install & build", "nav defaults to the title");
let bare =
parse_site_page("p.md", "---\nsite-page: build\n---\n\nno heading\n").expect("parse");
assert_eq!(bare.title, "build");
}
#[test]
fn an_anchored_h1_does_not_put_its_anchor_in_the_graph() {
let p = parse_site_page(
"website/pages/modes.md",
"---\nsite-page: modes\n---\n\n# The five ways to run it {#modes}\n",
)
.expect("parse");
assert_eq!(p.title, "The five ways to run it");
assert_eq!(
p.nav, "The five ways to run it",
"nav defaults to the title"
);
assert!(
!p.title.contains("{#"),
"the anchor reached the node title: {:?}",
p.title
);
}
#[test]
fn section_keys_are_unchanged_by_reading_headings_with_the_parser() {
let p = parse_site_page(
"website/pages/modes.md",
"---\nsite-page: modes\n---\n\n# T\n\n## 1 · Offline mode — the default {#offline}\n\n## What `init` sets up\n",
)
.expect("parse");
let slugs: Vec<_> = p.sections.iter().map(|s| s.slug.as_str()).collect();
assert_eq!(slugs, ["1-offline-mode-the-default", "what-init-sets-up"]);
assert_eq!(p.sections[1].title, "What init sets up");
}
#[test]
fn unordered_pages_sort_after_ordered_ones() {
let ordered =
parse_site_page("a.md", "---\nsite-page: a\nsite-order: 9\n---\n# A\n").expect("parse");
let unordered = parse_site_page("b.md", "---\nsite-page: b\n---\n# B\n").expect("parse");
assert!(
ordered.order < unordered.order,
"a page that forgets site-order lands at the end, not tied for first"
);
}
#[test]
fn a_slug_that_is_not_url_safe_is_drift_not_a_silent_rewrite() {
for bad in ["Getting Started", "modes/", "-lead", "trail-", "Modes"] {
let text = format!("---\nsite-page: {bad}\n---\n# X\n");
assert_eq!(
parse_site_page("p.md", &text),
Err(ParseError::InvalidSlug(bad.to_owned())),
"slug `{bad}` must be rejected"
);
}
assert!(parse_site_page("p.md", "---\nsite-page: cross-repo-2\n---\n# X\n").is_ok());
}
#[test]
fn a_non_numeric_order_is_drift() {
assert_eq!(
parse_site_page("p.md", "---\nsite-page: a\nsite-order: first\n---\n# A\n"),
Err(ParseError::InvalidOrder("first".to_owned()))
);
}
#[test]
fn quoted_and_commented_frontmatter_reads_like_an_adr_s() {
let p = parse_site_page(
"p.md",
"---\nsite-page: \"cross-repo\"\nsite-order: 7 # after config\n---\n# X\n",
)
.expect("parse");
assert_eq!(p.slug, "cross-repo");
assert_eq!(p.order, 7);
}
#[test]
fn documented_wiki_link_examples_in_fences_are_not_authored_links() {
let p = parse_site_page(
"p.md",
"---\nsite-page: a\n---\n# A\n\n```\n[[crates/x.rs#Y]]\n```\n",
)
.expect("parse");
assert!(p.links.is_empty(), "{:?}", p.links);
}
#[test]
fn nav_sorts_by_order_then_slug() {
let page = |slug: &str, order: &str| {
parse_site_page(
"p.md",
&format!("---\nsite-page: {slug}\n{order}---\n# {slug}\n"),
)
.expect("parse")
};
let pages = [
page("zulu", "site-order: 1\n"),
page("beta", ""),
page("alpha", ""),
page("mike", "site-order: 1\n"),
];
let order: Vec<&str> = site_nav(&pages).iter().map(|p| p.slug.as_str()).collect();
assert_eq!(order, ["mike", "zulu", "alpha", "beta"]);
}
#[test]
fn facts_carry_the_page_and_its_sections() {
let p = parse_site_page("docs/site/modes.md", PAGE).expect("parse");
let fs = p.facts();
let page = fs
.nodes
.iter()
.find(|n| n.key == "site:modes")
.expect("page node");
assert_eq!(page.kind.as_str(), "site_page");
assert_eq!(page.path.as_deref(), Some("docs/site/modes.md"));
assert_eq!(page.meta["slug"], "modes");
assert!(
fs.nodes.iter().any(|n| n.key == "site:modes#offline-mode"),
"section node"
);
assert!(
fs.edges
.iter()
.any(|e| e.src == "site:modes" && e.dst == "site:modes#offline-mode"),
"contains edge"
);
}
}