use std::fmt::Write as _;
use pulldown_cmark::{CowStr, Event, Options, Parser, Tag, html};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedAdr {
pub title: String,
pub html: 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, "")
}
fn render_markdown(md: &str, adr_prefix: &str) -> String {
let pre = rewrite_wiki_links(md, adr_prefix);
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(&pre, opts).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).map_or(dest_url, CowStr::from),
title,
id,
}),
other => other,
});
let mut out = String::new();
html::push_html(&mut out, parser);
out
}
fn rewrite_doc_link(dest: &str) -> Option<String> {
if dest.starts_with("http://")
|| dest.starts_with("https://")
|| dest.starts_with("//")
|| dest.starts_with("mailto:")
|| dest.starts_with('#')
{
return None;
}
let (path, frag) = dest
.split_once('#')
.map_or((dest, None), |(p, f)| (p, Some(f)));
let stem = path.strip_suffix(".md")?;
Some(match frag {
Some(frag) => format!("{stem}.html#{frag}"),
None => format!("{stem}.html"),
})
}
#[must_use]
pub fn render_adr(markdown: &str, fallback_title: &str) -> RenderedAdr {
let body = strip_frontmatter(markdown);
let title = first_heading(body).unwrap_or_else(|| fallback_title.to_owned());
let content = render_markdown(body, "");
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) -> RenderedAdr {
let body = strip_frontmatter(markdown);
let title = first_heading(body).unwrap_or_else(|| fallback_title.to_owned());
let content = render_markdown(body, "adr/");
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_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> {
body.lines()
.find_map(|l| l.strip_prefix("# ").map(|h| h.trim().to_owned()))
}
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, markdown_to_html, render_adr, render_adr_index, render_doc};
#[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>Title</h1>"));
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",
);
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");
assert_eq!(r.title, "ADR-0001: Example");
assert!(!r.html.contains("adr-id"));
assert!(r.html.contains("<h1>ADR-0001: Example</h1>"));
assert!(r.html.contains("<h2>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");
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 rendering_is_deterministic() {
assert_eq!(render_adr(ADR, "f"), render_adr(ADR, "f"));
}
}