use std::fmt::Write as _;
use pulldown_cmark::{Options, Parser, 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 {
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(md, opts);
let mut out = String::new();
html::push_html(&mut out, parser);
out
}
#[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 = markdown_to_html(body);
let nav = "<p class=\"nav\"><a href=\"../\">← Roteiro home</a> · \
<a href=\"./\">All ADRs</a></p>";
let html = page(&format!("{title} — Roteiro"), "../", nav, &content);
RenderedAdr { title, html }
}
#[must_use]
pub fn render_adr_index(entries: &[IndexEntry]) -> String {
let mut list = String::from("<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("Architecture Decision Records — Roteiro", "../", nav, &list)
}
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=\"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};
#[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>"));
}
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("← 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 html = render_adr_index(&entries);
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());
}
#[test]
fn rendering_is_deterministic() {
assert_eq!(render_adr(ADR, "f"), render_adr(ADR, "f"));
}
}