use std::collections::BTreeMap;
use std::fmt::Write as _;
use pulldown_cmark::{CowStr, Event, HeadingLevel, Options, Parser, Tag, TagEnd, html};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedAdr {
pub title: String,
pub html: String,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct PublishedPages(BTreeMap<String, Option<String>>);
impl PublishedPages {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn publish(&mut self, source_file: &str, served_as: &str) {
self.0
.entry(source_file.to_owned())
.and_modify(|slot| {
if slot.as_deref() != Some(served_as) {
*slot = None;
}
})
.or_insert_with(|| Some(served_as.to_owned()));
}
fn served(&self, source_file: &str) -> Option<&str> {
self.0.get(source_file)?.as_deref()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceBase {
blob: String,
dir: String,
}
impl SourceBase {
#[must_use]
pub fn new(blob: Option<&str>, dir: &str) -> Option<Self> {
Some(Self {
blob: blob?.trim_end_matches('/').to_owned(),
dir: dir.trim_matches('/').to_owned(),
})
}
fn blob_url(&self, path: &str, frag: Option<&str>) -> Option<String> {
let joined = format!("{}/{}", self.dir, path);
let (up, segs) = resolve_relative(&joined);
if up > 0 || segs.is_empty() {
return None;
}
let repo_path = segs.join("/");
Some(match frag {
Some(frag) => format!("{}/{repo_path}#{frag}", self.blob),
None => format!("{}/{repo_path}", self.blob),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NavEntry {
pub href: String,
pub label: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexEntry {
pub href: String,
pub title: String,
}
#[must_use]
pub fn markdown_to_html(md: &str) -> String {
render_markdown(md, "", &PublishedPages::new(), None, 0)
}
fn render_markdown(
md: &str,
adr_prefix: &str,
pages: &PublishedPages,
source: Option<&SourceBase>,
depth: usize,
) -> String {
let pre = rewrite_wiki_links(md, adr_prefix);
let ids = heading_ids(&pre);
let mut next_id = 0usize;
let parser = Parser::new_ext(&pre, options()).map(|event| match event {
Event::Start(Tag::Link {
link_type,
dest_url,
title,
id,
}) => Event::Start(Tag::Link {
link_type,
dest_url: rewrite_doc_link(&dest_url, pages, source, depth)
.map_or(dest_url, CowStr::from),
title,
id,
}),
Event::Start(Tag::Heading {
level,
classes,
attrs,
..
}) => {
let id = ids.get(next_id).cloned().map(CowStr::from);
next_id += 1;
Event::Start(Tag::Heading {
level,
id,
classes,
attrs,
})
}
other => other,
});
let mut out = String::new();
html::push_html(&mut out, parser);
out
}
fn options() -> Options {
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
opts
}
fn heading_ids(md: &str) -> Vec<String> {
let mut ids: Vec<String> = Vec::new();
let mut seen: BTreeMap<String, usize> = BTreeMap::new();
let mut current: Option<(Option<String>, String)> = None;
for event in Parser::new_ext(md, options()) {
match event {
Event::Start(Tag::Heading { id, .. }) => {
current = Some((id.map(|i| i.to_string()), String::new()));
}
Event::Text(t) | Event::Code(t) => {
if let Some((_, text)) = current.as_mut() {
text.push_str(&t);
}
}
Event::End(TagEnd::Heading(_)) => {
let Some((explicit, text)) = current.take() else {
continue;
};
let base = explicit
.filter(|e| !e.is_empty())
.unwrap_or_else(|| rto_graph::slugify(&text));
let base = if base.is_empty() {
format!("section-{}", ids.len() + 1)
} else {
base
};
let n = seen.entry(base.clone()).or_insert(0);
*n += 1;
ids.push(if *n == 1 { base } else { format!("{base}-{n}") });
}
_ => {}
}
}
ids
}
fn resolve_relative(path: &str) -> (usize, Vec<&str>) {
let mut up = 0usize;
let mut segs: Vec<&str> = Vec::new();
for seg in path.split('/') {
match seg {
"" | "." => {}
".." => {
if segs.pop().is_none() {
up += 1;
}
}
s => segs.push(s),
}
}
(up, segs)
}
fn rewrite_doc_link(
dest: &str,
pages: &PublishedPages,
source: Option<&SourceBase>,
depth: usize,
) -> Option<String> {
if dest.starts_with("http://")
|| dest.starts_with("https://")
|| dest.starts_with("//")
|| dest.starts_with("mailto:")
|| dest.starts_with('#')
|| dest.starts_with('/')
{
return None;
}
let (path, frag) = dest
.split_once('#')
.map_or((dest, None), |(p, f)| (p, Some(f)));
let (dir, file) = path.rsplit_once('/').map_or(("", path), |(d, f)| (d, f));
let is_markdown = path.strip_suffix(".md").is_some();
let sep = if dir.is_empty() { "" } else { "/" };
if let Some(served) = is_markdown.then(|| pages.served(file)).flatten() {
return Some(match frag {
Some(frag) => format!("{dir}{sep}{served}#{frag}"),
None => format!("{dir}{sep}{served}"),
});
}
if resolve_relative(path).0 > depth
&& let Some(url) = source.and_then(|s| s.blob_url(path, frag))
{
return Some(url);
}
if !is_markdown {
return None;
}
let served = format!("{}.html", file.trim_end_matches(".md"));
Some(match frag {
Some(frag) => format!("{dir}{sep}{served}#{frag}"),
None => format!("{dir}{sep}{served}"),
})
}
#[must_use]
pub fn render_adr(
markdown: &str,
fallback_title: &str,
pages: &PublishedPages,
source: Option<&SourceBase>,
) -> RenderedAdr {
let body = strip_frontmatter(markdown);
let title = first_heading(body).unwrap_or_else(|| fallback_title.to_owned());
let content = render_markdown(body, "", pages, source, 1);
let nav = "<p class=\"nav\"><a href=\"../\">← Roteiro home</a> · \
<a href=\"./\">All ADRs</a> · <a href=\"../build-plan.html\">Build Plan</a></p>";
let html = page(&format!("{title} — Roteiro"), "../", nav, &content);
RenderedAdr { title, html }
}
#[must_use]
pub fn render_doc(
markdown: &str,
fallback_title: &str,
pages: &PublishedPages,
source: Option<&SourceBase>,
) -> RenderedAdr {
let body = strip_frontmatter(markdown);
let title = first_heading(body).unwrap_or_else(|| fallback_title.to_owned());
let content = render_markdown(body, "adr/", pages, source, 0);
let nav = "<p class=\"nav\"><a href=\"./\">← Roteiro home</a> · \
<a href=\"adr/\">ADRs</a></p>";
let html = page(&format!("{title} — Roteiro"), "./", nav, &content);
RenderedAdr { title, html }
}
#[must_use]
pub fn render_site_page(
markdown: &str,
fallback_title: &str,
nav: &[NavEntry],
current_href: &str,
pages: &PublishedPages,
source: Option<&SourceBase>,
) -> RenderedAdr {
let body = strip_frontmatter(markdown);
let title = first_heading(body).unwrap_or_else(|| fallback_title.to_owned());
let content = render_markdown(body, "adr/", pages, source, 0);
let bar = render_nav(nav, current_href);
let html = page(&format!("{title} — Roteiro"), "./", &bar, &content);
RenderedAdr { title, html }
}
#[must_use]
pub fn render_nav(nav: &[NavEntry], current_href: &str) -> String {
let mut out = String::from("<nav class=\"sitenav\">");
for entry in nav {
if entry.href == current_href {
let _ = write!(
out,
"<span aria-current=\"page\">{}</span>",
escape_html(&entry.label)
);
} else {
let _ = write!(
out,
"<a href=\"{}\">{}</a>",
escape_attr(&entry.href),
escape_html(&entry.label)
);
}
}
out.push_str("</nav>");
out
}
const SITENAV_OPEN: &str = "<nav class=\"sitenav\">";
#[must_use]
pub fn replace_site_nav(html: &str, nav: &[NavEntry], current_href: &str) -> Option<String> {
let open = html.find(SITENAV_OPEN)?;
let close = html[open..].find("</nav>")? + open + "</nav>".len();
let mut out = String::with_capacity(html.len());
out.push_str(&html[..open]);
out.push_str(&render_nav(nav, current_href));
out.push_str(&html[close..]);
Some(out)
}
#[must_use]
pub fn render_adr_index(lifetime: &[IndexEntry], entries: &[IndexEntry]) -> String {
let mut list = String::new();
if !lifetime.is_empty() {
list.push_str("<h1>Documentation</h1><ul>");
for e in lifetime {
let _ = write!(
list,
"<li><a href=\"{}\">{}</a></li>",
escape_attr(&e.href),
escape_html(&e.title)
);
}
list.push_str("</ul>");
}
list.push_str("<h1>Architecture Decision Records</h1><ul>");
for e in entries {
let _ = write!(
list,
"<li><a href=\"{}\">{}</a></li>",
escape_attr(&e.href),
escape_html(&e.title)
);
}
list.push_str("</ul>");
let nav = "<p class=\"nav\"><a href=\"../\">← Roteiro home</a></p>";
page("Documentation — Roteiro", "../", nav, &list)
}
fn rewrite_wiki_links(md: &str, adr_prefix: &str) -> String {
let mut out = String::new();
let mut in_fence = false;
for line in md.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_fence = !in_fence;
out.push_str(line);
out.push('\n');
continue;
}
if in_fence {
out.push_str(line);
out.push('\n');
continue;
}
rewrite_line_outside_code(line, adr_prefix, &mut out);
out.push('\n');
}
out
}
fn rewrite_line_outside_code(line: &str, adr_prefix: &str, out: &mut String) {
let bytes = line.as_bytes();
let mut text_start = 0;
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'`' {
i += 1;
continue;
}
let run_start = i;
while i < bytes.len() && bytes[i] == b'`' {
i += 1;
}
let run = i - run_start;
if let Some(rel) = find_closing_run(&bytes[i..], run) {
rewrite_wiki_in(&line[text_start..run_start], adr_prefix, out);
let code_end = i + rel + run;
out.push_str(&line[run_start..code_end]); i = code_end;
text_start = i;
}
}
rewrite_wiki_in(&line[text_start..], adr_prefix, out);
}
fn find_closing_run(bytes: &[u8], run: usize) -> Option<usize> {
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'`' {
i += 1;
continue;
}
let start = i;
while i < bytes.len() && bytes[i] == b'`' {
i += 1;
}
if i - start == run {
return Some(start);
}
}
None
}
fn rewrite_wiki_in(seg: &str, adr_prefix: &str, out: &mut String) {
let mut rest = seg;
while let Some(open) = rest.find("[[") {
out.push_str(&rest[..open]);
let after = &rest[open + 2..];
if let Some(close) = after.find("]]") {
out.push_str(&wiki_target(&after[..close], adr_prefix));
rest = &after[close + 2..];
} else {
out.push_str("[[");
rest = after;
}
}
out.push_str(rest);
}
fn wiki_target(inner: &str, adr_prefix: &str) -> String {
let inner = inner.trim();
let path = inner.split_once('#').map_or(inner, |(p, _)| p.trim());
if let Some(rest) = path.strip_prefix("docs/adr/")
&& let Some(stem) = rest.strip_suffix(".md")
{
return format!("[{}]({adr_prefix}{stem}.html)", adr_label(stem));
}
format!("`{inner}`")
}
fn adr_label(stem: &str) -> String {
let digits: String = stem.chars().take_while(char::is_ascii_digit).collect();
if digits.is_empty() {
stem.to_owned()
} else {
format!("ADR-{digits}")
}
}
fn page(title: &str, root: &str, nav: &str, body: &str) -> String {
format!(
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
<link rel=\"icon\" href=\"{root}favicon.svg\" type=\"image/svg+xml\">\
<link rel=\"icon\" href=\"{root}favicon.ico\" type=\"image/x-icon\" sizes=\"16x16 32x32 48x48\">\
<link rel=\"apple-touch-icon\" href=\"{root}apple-touch-icon.png\">\
<link rel=\"stylesheet\" href=\"{root}style.css\">\
<title>{title}</title></head><body>\
{nav}{body}\
<p class=\"backlink\"><a href=\"{root}\">← Back to roteiro.dev</a></p>\
<footer>Dual-licensed MIT OR Apache-2.0 · The Roteiro Project Team</footer>\
</body></html>",
title = escape_html(title),
)
}
fn strip_frontmatter(text: &str) -> &str {
let Some(rest) = text.strip_prefix("---\n") else {
return text;
};
match rest.find("\n---\n") {
Some(end) => &rest[end + 5..],
None => rest.strip_suffix("\n---").unwrap_or(text),
}
}
fn first_heading(body: &str) -> Option<String> {
let mut text: Option<String> = None;
for event in Parser::new_ext(body, options()) {
match event {
Event::Start(Tag::Heading {
level: HeadingLevel::H1,
..
}) => text = Some(String::new()),
Event::Text(t) | Event::Code(t) => {
if let Some(text) = text.as_mut() {
text.push_str(&t);
}
}
Event::End(TagEnd::Heading(HeadingLevel::H1)) => break,
_ => {}
}
}
text.map(|t| t.trim().to_owned()).filter(|t| !t.is_empty())
}
fn escape_html(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
fn escape_attr(s: &str) -> String {
escape_html(s).replace('"', """)
}
#[cfg(test)]
mod tests {
use super::{
IndexEntry, NavEntry, PublishedPages, SourceBase, escape_html, markdown_to_html,
render_adr, render_adr_index, render_doc, render_markdown, render_nav, render_site_page,
replace_site_nav,
};
fn no_pages() -> PublishedPages {
PublishedPages::new()
}
fn nav() -> Vec<NavEntry> {
vec![
NavEntry {
href: "./".into(),
label: "Home".into(),
},
NavEntry {
href: "modes.html".into(),
label: "Modes & Co".into(),
},
]
}
#[test]
fn markdown_renders_headings_and_tables() {
let html = markdown_to_html("# Title\n\n| a | b |\n|---|---|\n| 1 | 2 |\n");
assert!(html.contains("<h1 id=\"title\">Title</h1>"), "{html}");
assert!(html.contains("<table>"));
assert!(html.contains("<td>1</td>"));
}
#[test]
fn adr_wiki_links_become_sibling_page_links() {
let md = "See [[docs/adr/0001-build-roteiro.md]] and \
[[crates/rto-graph/src/store.rs#Store]] here.\n";
let html = markdown_to_html(md);
assert!(
html.contains("<a href=\"0001-build-roteiro.html\">ADR-0001</a>"),
"ADR wiki-link → sibling page: {html}"
);
assert!(
html.contains("<code>crates/rto-graph/src/store.rs#Store</code>"),
"code reference → inline code: {html}"
);
assert!(
!html.contains("[["),
"no literal wiki brackets leak: {html}"
);
}
#[test]
fn wiki_links_inside_code_are_left_literal() {
let inline = markdown_to_html("use `[[docs/adr/0001-x.md]]` in prose\n");
assert!(
inline.contains("<code>[[docs/adr/0001-x.md]]</code>"),
"{inline}"
);
let fenced = markdown_to_html("```\n[[docs/adr/0001-x.md]]\n```\n");
assert!(
fenced.contains("[[docs/adr/0001-x.md]]"),
"fence literal: {fenced}"
);
}
#[test]
fn multi_backtick_code_spans_are_honoured() {
let tight = markdown_to_html("say ``[[docs/adr/0001-x.md]]`` please\n");
assert!(
tight.contains("<code>[[docs/adr/0001-x.md]]</code>"),
"{tight}"
);
assert!(!tight.contains("<a "), "no link inside code span: {tight}");
let nested = markdown_to_html("its `` `[[path#Symbol]]` `` example\n");
assert!(
nested.contains("<code>`[[path#Symbol]]`</code>"),
"{nested}"
);
assert!(
!nested.contains("<a "),
"no link inside nested span: {nested}"
);
let stray = markdown_to_html("a ` stray tick then [[docs/adr/0001-x.md]]\n");
assert!(
stray.contains("<a href=\"0001-x.html\">ADR-0001</a>"),
"unterminated backtick must not shield: {stray}"
);
}
#[test]
fn markdown_md_links_are_rewritten_to_html() {
let html = markdown_to_html(
"See [ADR-1](adr/0001-x.md) and [§2](adr/0001-x.md#context) and \
[home](https://x.dev) and [top](#intro).\n",
);
assert!(html.contains("href=\"adr/0001-x.html\""), "{html}");
assert!(html.contains("href=\"adr/0001-x.html#context\""), "{html}");
assert!(
html.contains("href=\"https://x.dev\""),
"external unchanged: {html}"
);
assert!(html.contains("href=\"#intro\""), "anchor unchanged: {html}");
assert!(!html.contains(".md\""), "no raw .md hrefs remain: {html}");
}
#[test]
fn render_doc_links_adrs_into_subdir() {
let r = render_doc(
"# Build Plan\n\nGoverned by [[docs/adr/0001-x.md]].\n",
"Build Plan",
&no_pages(),
None,
);
assert_eq!(r.title, "Build Plan");
assert!(
r.html.contains("<a href=\"adr/0001-x.html\">ADR-0001</a>"),
"root doc → adr/ prefix: {}",
r.html
);
assert!(r.html.contains("href=\"./style.css\""));
assert!(r.html.contains("href=\"./favicon.svg\""));
assert!(r.html.contains("href=\"./favicon.ico\""));
assert!(
r.html
.contains("rel=\"apple-touch-icon\" href=\"./apple-touch-icon.png\"")
);
}
const ADR: &str = "---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n# ADR-0001: Example\n\n## Context\n\nSome `code` and a [link](https://x).\n";
#[test]
fn render_adr_strips_frontmatter_and_themes() {
let r = render_adr(ADR, "fallback", &no_pages(), None);
assert_eq!(r.title, "ADR-0001: Example");
assert!(!r.html.contains("adr-id"));
assert!(
r.html
.contains("<h1 id=\"adr-0001-example\">ADR-0001: Example</h1>")
);
assert!(r.html.contains("<h2 id=\"context\">Context</h2>"));
assert!(r.html.contains("<code>code</code>"));
assert!(
r.html
.contains("<link rel=\"stylesheet\" href=\"../style.css\">")
);
assert!(r.html.contains("href=\"../favicon.svg\""));
assert!(r.html.contains("href=\"../favicon.ico\""));
assert!(
r.html
.contains("rel=\"apple-touch-icon\" href=\"../apple-touch-icon.png\"")
);
assert!(r.html.contains("← Roteiro home"));
assert!(r.html.contains("← Back to roteiro.dev"));
assert!(r.html.starts_with("<!doctype html>"));
}
#[test]
fn render_adr_falls_back_without_h1() {
let r = render_adr(
"no frontmatter, no heading\n",
"slug-name",
&no_pages(),
None,
);
assert_eq!(r.title, "slug-name");
}
#[test]
fn index_lists_entries_and_escapes() {
let entries = [
IndexEntry {
href: "0001-x.html".into(),
title: "First & <best>".into(),
},
IndexEntry {
href: "0002-y.html".into(),
title: "Second".into(),
},
];
let lifetime = [IndexEntry {
href: "../build-plan.html".into(),
title: "Build Plan".into(),
}];
let html = render_adr_index(&lifetime, &entries);
assert!(html.contains("<a href=\"../build-plan.html\">Build Plan</a>"));
assert!(html.contains("<a href=\"0001-x.html\">First & <best></a>"));
assert!(html.contains("<a href=\"0002-y.html\">Second</a>"));
assert!(html.find("0001-x").unwrap() < html.find("0002-y").unwrap());
assert!(html.find("build-plan").unwrap() < html.find("0001-x").unwrap());
}
#[test]
fn an_explicit_anchor_survives_the_split_that_moved_its_section() {
let html = markdown_to_html(
"## The five ways to run it {#modes}\n\n## Cross-repo: a hub and its spokes {#crossrepo}\n",
);
assert!(
html.contains("<h2 id=\"modes\">The five ways to run it</h2>"),
"{html}"
);
assert!(
html.contains("<h2 id=\"crossrepo\">Cross-repo: a hub and its spokes</h2>"),
"{html}"
);
assert!(!html.contains("{#"), "no literal attribute leaks: {html}");
}
#[test]
fn generated_anchors_match_the_graph_s_section_keys_and_stay_unique() {
let html = markdown_to_html("## Install & build\n\n## Install & build\n\n## ###\n");
assert!(html.contains("id=\"install-build\""), "{html}");
assert!(html.contains("id=\"install-build-2\""), "{html}");
assert!(html.contains("id=\"section-3\""), "{html}");
}
#[test]
fn inline_code_counts_as_heading_text() {
let html = markdown_to_html("### What `init` sets up\n");
assert!(
html.contains("<h3 id=\"what-init-sets-up\">"),
"code span is part of the heading's text: {html}"
);
}
#[test]
fn a_hash_inside_a_fence_is_not_a_heading() {
let html = markdown_to_html("```\n## Not a heading\n```\n\n## Real\n");
assert!(html.contains("<h2 id=\"real\">Real</h2>"), "{html}");
}
#[test]
fn a_heading_s_anchor_never_reaches_the_title() {
let r = render_site_page(
"---\nsite-page: modes\n---\n\n# The five ways to run it {#modes}\n\nBody.\n",
"fallback",
&nav(),
"modes.html",
&no_pages(),
None,
);
assert!(
r.html
.contains("<h1 id=\"modes\">The five ways to run it</h1>"),
"{}",
r.html
);
assert_eq!(r.title, "The five ways to run it");
assert!(
r.html
.contains("<title>The five ways to run it — Roteiro</title>"),
"{}",
r.html
);
assert!(
!r.html.contains("{#"),
"no literal attribute leaks: {}",
r.html
);
}
#[test]
fn the_same_holds_for_an_adr_and_for_a_root_level_doc() {
let adr = render_adr("# ADR-0001: Example {#adr1}\n", "slug", &no_pages(), None);
assert_eq!(adr.title, "ADR-0001: Example");
assert!(
adr.html
.contains("<title>ADR-0001: Example — Roteiro</title>"),
"{}",
adr.html
);
let doc = render_doc(
"# Roteiro — Build Plan {#plan}\n",
"Build Plan",
&no_pages(),
None,
);
assert_eq!(doc.title, "Roteiro — Build Plan");
assert!(!doc.html.contains("{#"), "{}", doc.html);
}
#[test]
fn a_title_that_legitimately_spells_the_anchor_syntax_keeps_it() {
let coded = render_doc(
"# Why `{#anchor}` outlives a restructure\n",
"fallback",
&no_pages(),
None,
);
assert_eq!(coded.title, "Why {#anchor} outlives a restructure");
assert!(
coded
.html
.contains("<title>Why {#anchor} outlives a restructure — Roteiro</title>"),
"{}",
coded.html
);
let mid = render_doc(
"# Anchors are written {#id}, in prose\n",
"fallback",
&no_pages(),
None,
);
assert_eq!(mid.title, "Anchors are written {#id}, in prose");
}
#[test]
fn the_title_and_the_heading_never_disagree() {
for md in [
"# The five ways to run it {#modes}\n",
"# Why `{#anchor}` outlives a restructure\n",
"# Anchors are written {#id}, in prose\n",
"# Install & build {#build}\n",
"# What `init` sets up\n",
"# Sets like {#1, #2}\n",
] {
let r = render_doc(md, "fallback", &no_pages(), None);
let inner = r
.html
.split_once("<h1")
.and_then(|(_, rest)| rest.split_once('>'))
.and_then(|(_, rest)| rest.split_once("</h1>"))
.map(|(text, _)| text.to_owned())
.unwrap_or_default();
let mut heading = String::new();
let mut depth = 0usize;
for c in inner.chars() {
match c {
'<' => depth += 1,
'>' => depth = depth.saturating_sub(1),
_ if depth == 0 => heading.push(c),
_ => {}
}
}
assert_eq!(
heading,
escape_html(&r.title),
"title and heading disagree for {md:?}: {}",
r.html
);
}
}
#[test]
fn the_title_is_the_heading_the_reader_sees() {
let code = render_doc("# What `init` sets up\n", "fallback", &no_pages(), None);
assert_eq!(code.title, "What init sets up");
let fenced = render_doc(
"```\n# Not a title\n```\n\n# The real one\n",
"fallback",
&no_pages(),
None,
);
assert_eq!(fenced.title, "The real one");
let setext = render_doc("Underlined\n==========\n", "fallback", &no_pages(), None);
assert!(
setext.html.contains("<h1 id=\"underlined\">"),
"{}",
setext.html
);
assert_eq!(setext.title, "Underlined");
}
#[test]
fn a_document_with_no_h1_falls_back_and_the_fallback_is_used_verbatim() {
let none = render_site_page(
"---\nsite-page: modes\n---\n\nNo heading at all.\n",
"The five ways to run it",
&nav(),
"modes.html",
&no_pages(),
None,
);
assert_eq!(none.title, "The five ways to run it");
assert!(
none.html
.contains("<title>The five ways to run it — Roteiro</title>"),
"{}",
none.html
);
let empty = render_doc("#\n\nBody.\n", "build-plan", &no_pages(), None);
assert_eq!(empty.title, "build-plan");
let sub = render_doc("## Only a section {#s}\n", "build-plan", &no_pages(), None);
assert_eq!(sub.title, "build-plan");
}
#[test]
fn a_site_page_carries_the_bar_with_itself_marked() {
let r = render_site_page(
"---\nsite-page: modes\n---\n\n# The five ways to run it\n\nSee [[docs/adr/0019-remote.md]].\n",
"fallback",
&nav(),
"modes.html",
&no_pages(),
None,
);
assert_eq!(r.title, "The five ways to run it");
assert!(!r.html.contains("site-page"), "{}", r.html);
assert!(
r.html
.contains("<span aria-current=\"page\">Modes & Co</span>"),
"{}",
r.html
);
assert!(r.html.contains("<a href=\"./\">Home</a>"), "{}", r.html);
assert!(r.html.contains("href=\"./style.css\""), "{}", r.html);
assert!(
r.html
.contains("<a href=\"adr/0019-remote.html\">ADR-0019</a>"),
"{}",
r.html
);
}
#[test]
fn the_bar_is_plain_anchors_and_escapes_its_labels() {
let bar = render_nav(&nav(), "nothing.html");
assert!(bar.starts_with("<nav class=\"sitenav\">"), "{bar}");
assert!(!bar.contains("aria-current"), "{bar}");
assert!(bar.contains("Modes & Co"), "escaped label: {bar}");
assert!(!bar.contains("<script"), "{bar}");
}
#[test]
fn a_link_resolves_to_the_page_the_site_actually_serves() {
let mut pages = PublishedPages::new();
pages.publish("BUILD_PLAN_V2.md", "build-plan-v2.html");
let html = render_markdown("See [V2](../BUILD_PLAN_V2.md).\n", "", &pages, None, 0);
assert!(
html.contains("href=\"../build-plan-v2.html\""),
"served name, and the link's own hop kept: {html}"
);
let frag = render_markdown("[s](../BUILD_PLAN_V2.md#stage-21)\n", "", &pages, None, 0);
assert!(
frag.contains("href=\"../build-plan-v2.html#stage-21\""),
"{frag}"
);
let other = render_markdown("[x](../REVIEW_CHECKLIST.md)\n", "", &pages, None, 0);
assert!(
other.contains("href=\"../REVIEW_CHECKLIST.html\""),
"{other}"
);
}
#[test]
fn a_file_name_two_documents_claim_is_left_alone() {
let mut pages = PublishedPages::new();
pages.publish("GUIDE.md", "guide.html");
pages.publish("GUIDE.md", "other-guide.html");
let html = render_markdown("[g](GUIDE.md)\n", "", &pages, None, 0);
assert!(html.contains("href=\"GUIDE.html\""), "unrewritten: {html}");
let mut same = PublishedPages::new();
same.publish("GUIDE.md", "guide.html");
same.publish("GUIDE.md", "guide.html");
let html = render_markdown("[g](GUIDE.md)\n", "", &same, None, 0);
assert!(html.contains("href=\"guide.html\""), "{html}");
}
fn source(dir: &str) -> SourceBase {
SourceBase::new(Some("https://github.com/o/r/blob/abc123"), dir).expect("base")
}
#[test]
fn a_link_out_of_the_site_goes_to_the_repository() {
let base = source("docs");
let html = render_markdown(
"[sync](../crates/rto-graph/src/sync.rs) and [wf](../.github/workflows/website.yml)\n",
"adr/",
&no_pages(),
Some(&base),
0,
);
assert!(
html.contains(
"href=\"https://github.com/o/r/blob/abc123/crates/rto-graph/src/sync.rs\""
),
"resolved against the document's own directory: {html}"
);
assert!(
html.contains(
"href=\"https://github.com/o/r/blob/abc123/.github/workflows/website.yml\""
),
"a dotted directory is a directory, not a `.` segment: {html}"
);
let frag = render_markdown(
"[l](../crates/roteiro/src/init.rs#L12)\n",
"adr/",
&no_pages(),
Some(&base),
0,
);
assert!(
frag.contains("blob/abc123/crates/roteiro/src/init.rs#L12\""),
"{frag}"
);
}
#[test]
fn a_link_that_stays_inside_the_site_is_left_alone() {
let base = source("docs");
let html = render_markdown(
"[a](ask.html), [d](adr/), [s](./style.css) and [r](/abs.html)\n",
"adr/",
&no_pages(),
Some(&base),
0,
);
assert!(!html.contains("github.com"), "none rewritten: {html}");
for href in [
"\"ask.html\"",
"\"adr/\"",
"\"./style.css\"",
"\"/abs.html\"",
] {
assert!(html.contains(href), "{href} kept verbatim: {html}");
}
}
#[test]
fn an_adr_may_climb_one_level_and_still_be_inside_the_site() {
let base = source("docs/adr");
let inside = render_markdown("[b](../build-plan.html)\n", "", &no_pages(), Some(&base), 1);
assert!(!inside.contains("github.com"), "{inside}");
let outside = render_markdown("[c](../../Cargo.toml)\n", "", &no_pages(), Some(&base), 1);
assert!(
outside.contains("href=\"https://github.com/o/r/blob/abc123/Cargo.toml\""),
"{outside}"
);
}
#[test]
fn a_published_page_beats_the_escape_rule() {
let mut pages = PublishedPages::new();
pages.publish("BUILD_PLAN_V2.md", "build-plan-v2.html");
let base = source("website/pages");
let html = render_markdown(
"[v2](../../docs/BUILD_PLAN_V2.md)\n",
"adr/",
&pages,
Some(&base),
0,
);
assert!(
html.contains("href=\"../../docs/build-plan-v2.html\""),
"still the site's page: {html}"
);
assert!(!html.contains("github.com"), "{html}");
}
#[test]
fn without_a_source_base_the_link_is_left_as_authored() {
assert_eq!(SourceBase::new(None, "docs"), None);
let html = render_markdown(
"[s](../crates/rto-graph/src/sync.rs)\n",
"adr/",
&no_pages(),
None,
0,
);
assert!(
html.contains("href=\"../crates/rto-graph/src/sync.rs\""),
"{html}"
);
}
#[test]
fn the_landing_pages_bar_is_replaced_rather_than_maintained() {
let stale = "<h1>Roteiro</h1>\n<nav class=\"sitenav\">\n<a href=\"old.html\">Old</a>\n\
</nav>\n<p>after</p>\n";
let out = replace_site_nav(stale, &nav(), "./").expect("marker found");
assert!(
!out.contains("old.html"),
"the hand-written list is gone: {out}"
);
assert!(
out.contains("<a href=\"modes.html\">Modes & Co</a>"),
"the computed bar took its place: {out}"
);
assert!(
out.starts_with("<h1>Roteiro</h1>\n") && out.ends_with("<p>after</p>\n"),
"only the bar is touched: {out}"
);
assert_eq!(replace_site_nav("<h1>Home</h1>\n", &nav(), "./"), None);
}
#[test]
fn site_pages_render_deterministically() {
let md = "---\nsite-page: a\n---\n\n# A\n\n## S\n";
assert_eq!(
render_site_page(md, "f", &nav(), "a.html", &no_pages(), None),
render_site_page(md, "f", &nav(), "a.html", &no_pages(), None)
);
}
#[test]
fn rendering_is_deterministic() {
assert_eq!(
render_adr(ADR, "f", &no_pages(), None),
render_adr(ADR, "f", &no_pages(), None)
);
}
}