1use std::fmt::Write as _;
12
13use pulldown_cmark::{Options, Parser, html};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct RenderedAdr {
18 pub title: String,
21 pub html: String,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct IndexEntry {
28 pub href: String,
30 pub title: String,
32}
33
34#[must_use]
37pub fn markdown_to_html(md: &str) -> String {
38 let mut opts = Options::empty();
39 opts.insert(Options::ENABLE_TABLES);
40 opts.insert(Options::ENABLE_STRIKETHROUGH);
41 let parser = Parser::new_ext(md, opts);
42 let mut out = String::new();
43 html::push_html(&mut out, parser);
44 out
45}
46
47#[must_use]
51pub fn render_adr(markdown: &str, fallback_title: &str) -> RenderedAdr {
52 let body = strip_frontmatter(markdown);
53 let title = first_heading(body).unwrap_or_else(|| fallback_title.to_owned());
54 let content = markdown_to_html(body);
55 let nav = "<p class=\"nav\"><a href=\"../\">← Roteiro home</a> · \
56 <a href=\"./\">All ADRs</a></p>";
57 let html = page(&format!("{title} — Roteiro"), "../", nav, &content);
58 RenderedAdr { title, html }
59}
60
61#[must_use]
63pub fn render_adr_index(entries: &[IndexEntry]) -> String {
64 let mut list = String::from("<h1>Architecture Decision Records</h1><ul>");
65 for e in entries {
66 let _ = write!(
67 list,
68 "<li><a href=\"{}\">{}</a></li>",
69 escape_attr(&e.href),
70 escape_html(&e.title)
71 );
72 }
73 list.push_str("</ul>");
74 let nav = "<p class=\"nav\"><a href=\"../\">← Roteiro home</a></p>";
75 page("Architecture Decision Records — Roteiro", "../", nav, &list)
76}
77
78fn page(title: &str, root: &str, nav: &str, body: &str) -> String {
81 format!(
82 "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
83 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
84 <link rel=\"icon\" href=\"{root}favicon.svg\" type=\"image/svg+xml\">\
85 <link rel=\"stylesheet\" href=\"{root}style.css\">\
86 <title>{title}</title></head><body>\
87 {nav}{body}\
88 <p class=\"backlink\"><a href=\"{root}\">← Back to roteiro.dev</a></p>\
89 <footer>Dual-licensed MIT OR Apache-2.0 · The Roteiro Project Team</footer>\
90 </body></html>",
91 title = escape_html(title),
92 )
93}
94
95fn strip_frontmatter(text: &str) -> &str {
97 let Some(rest) = text.strip_prefix("---\n") else {
98 return text;
99 };
100 match rest.find("\n---\n") {
101 Some(end) => &rest[end + 5..],
102 None => rest.strip_suffix("\n---").unwrap_or(text),
103 }
104}
105
106fn first_heading(body: &str) -> Option<String> {
108 body.lines()
109 .find_map(|l| l.strip_prefix("# ").map(|h| h.trim().to_owned()))
110}
111
112fn escape_html(s: &str) -> String {
113 s.replace('&', "&")
114 .replace('<', "<")
115 .replace('>', ">")
116}
117
118fn escape_attr(s: &str) -> String {
119 escape_html(s).replace('"', """)
120}
121
122#[cfg(test)]
123mod tests {
124 use super::{IndexEntry, markdown_to_html, render_adr, render_adr_index};
125
126 #[test]
127 fn markdown_renders_headings_and_tables() {
128 let html = markdown_to_html("# Title\n\n| a | b |\n|---|---|\n| 1 | 2 |\n");
129 assert!(html.contains("<h1>Title</h1>"));
130 assert!(html.contains("<table>"));
131 assert!(html.contains("<td>1</td>"));
132 }
133
134 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";
135
136 #[test]
137 fn render_adr_strips_frontmatter_and_themes() {
138 let r = render_adr(ADR, "fallback");
139 assert_eq!(r.title, "ADR-0001: Example");
140 assert!(!r.html.contains("adr-id"));
142 assert!(r.html.contains("<h1>ADR-0001: Example</h1>"));
143 assert!(r.html.contains("<h2>Context</h2>"));
144 assert!(r.html.contains("<code>code</code>"));
145 assert!(
147 r.html
148 .contains("<link rel=\"stylesheet\" href=\"../style.css\">")
149 );
150 assert!(r.html.contains("← Roteiro home"));
151 assert!(r.html.contains("← Back to roteiro.dev"));
152 assert!(r.html.starts_with("<!doctype html>"));
153 }
154
155 #[test]
156 fn render_adr_falls_back_without_h1() {
157 let r = render_adr("no frontmatter, no heading\n", "slug-name");
158 assert_eq!(r.title, "slug-name");
159 }
160
161 #[test]
162 fn index_lists_entries_and_escapes() {
163 let entries = [
164 IndexEntry {
165 href: "0001-x.html".into(),
166 title: "First & <best>".into(),
167 },
168 IndexEntry {
169 href: "0002-y.html".into(),
170 title: "Second".into(),
171 },
172 ];
173 let html = render_adr_index(&entries);
174 assert!(html.contains("<a href=\"0001-x.html\">First & <best></a>"));
175 assert!(html.contains("<a href=\"0002-y.html\">Second</a>"));
176 assert!(html.find("0001-x").unwrap() < html.find("0002-y").unwrap());
178 }
179
180 #[test]
181 fn rendering_is_deterministic() {
182 assert_eq!(render_adr(ADR, "f"), render_adr(ADR, "f"));
183 }
184}