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]
38pub fn markdown_to_html(md: &str) -> String {
39 render_markdown(md, "")
40}
41
42fn render_markdown(md: &str, adr_prefix: &str) -> String {
45 let pre = rewrite_wiki_links(md, adr_prefix);
46 let mut opts = Options::empty();
47 opts.insert(Options::ENABLE_TABLES);
48 opts.insert(Options::ENABLE_STRIKETHROUGH);
49 let parser = Parser::new_ext(&pre, opts);
50 let mut out = String::new();
51 html::push_html(&mut out, parser);
52 out
53}
54
55#[must_use]
59pub fn render_adr(markdown: &str, fallback_title: &str) -> RenderedAdr {
60 let body = strip_frontmatter(markdown);
61 let title = first_heading(body).unwrap_or_else(|| fallback_title.to_owned());
62 let content = render_markdown(body, "");
63 let nav = "<p class=\"nav\"><a href=\"../\">← Roteiro home</a> · \
64 <a href=\"./\">All ADRs</a> · <a href=\"../build-plan.html\">Build Plan</a></p>";
65 let html = page(&format!("{title} — Roteiro"), "../", nav, &content);
66 RenderedAdr { title, html }
67}
68
69#[must_use]
72pub fn render_doc(markdown: &str, fallback_title: &str) -> RenderedAdr {
73 let body = strip_frontmatter(markdown);
74 let title = first_heading(body).unwrap_or_else(|| fallback_title.to_owned());
75 let content = render_markdown(body, "adr/");
76 let nav = "<p class=\"nav\"><a href=\"./\">← Roteiro home</a> · \
77 <a href=\"adr/\">ADRs</a></p>";
78 let html = page(&format!("{title} — Roteiro"), "./", nav, &content);
79 RenderedAdr { title, html }
80}
81
82#[must_use]
84pub fn render_adr_index(lifetime: &[IndexEntry], entries: &[IndexEntry]) -> String {
85 let mut list = String::new();
86 if !lifetime.is_empty() {
87 list.push_str("<h1>Documentation</h1><ul>");
88 for e in lifetime {
89 let _ = write!(
90 list,
91 "<li><a href=\"{}\">{}</a></li>",
92 escape_attr(&e.href),
93 escape_html(&e.title)
94 );
95 }
96 list.push_str("</ul>");
97 }
98 list.push_str("<h1>Architecture Decision Records</h1><ul>");
99 for e in entries {
100 let _ = write!(
101 list,
102 "<li><a href=\"{}\">{}</a></li>",
103 escape_attr(&e.href),
104 escape_html(&e.title)
105 );
106 }
107 list.push_str("</ul>");
108 let nav = "<p class=\"nav\"><a href=\"../\">← Roteiro home</a></p>";
109 page("Documentation — Roteiro", "../", nav, &list)
110}
111
112fn rewrite_wiki_links(md: &str, adr_prefix: &str) -> String {
118 let mut out = String::new();
119 let mut in_fence = false;
120 for line in md.lines() {
121 let trimmed = line.trim_start();
122 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
123 in_fence = !in_fence;
124 out.push_str(line);
125 out.push('\n');
126 continue;
127 }
128 if in_fence {
129 out.push_str(line);
130 out.push('\n');
131 continue;
132 }
133 rewrite_line_outside_code(line, adr_prefix, &mut out);
134 out.push('\n');
135 }
136 out
137}
138
139fn rewrite_line_outside_code(line: &str, adr_prefix: &str, out: &mut String) {
145 let bytes = line.as_bytes();
146 let mut text_start = 0;
147 let mut i = 0;
148 while i < bytes.len() {
149 if bytes[i] != b'`' {
150 i += 1;
151 continue;
152 }
153 let run_start = i;
154 while i < bytes.len() && bytes[i] == b'`' {
155 i += 1;
156 }
157 let run = i - run_start;
158 if let Some(rel) = find_closing_run(&bytes[i..], run) {
159 rewrite_wiki_in(&line[text_start..run_start], adr_prefix, out);
161 let code_end = i + rel + run;
162 out.push_str(&line[run_start..code_end]); i = code_end;
164 text_start = i;
165 }
166 }
169 rewrite_wiki_in(&line[text_start..], adr_prefix, out);
170}
171
172fn find_closing_run(bytes: &[u8], run: usize) -> Option<usize> {
175 let mut i = 0;
176 while i < bytes.len() {
177 if bytes[i] != b'`' {
178 i += 1;
179 continue;
180 }
181 let start = i;
182 while i < bytes.len() && bytes[i] == b'`' {
183 i += 1;
184 }
185 if i - start == run {
186 return Some(start);
187 }
188 }
189 None
190}
191
192fn rewrite_wiki_in(seg: &str, adr_prefix: &str, out: &mut String) {
194 let mut rest = seg;
195 while let Some(open) = rest.find("[[") {
196 out.push_str(&rest[..open]);
197 let after = &rest[open + 2..];
198 if let Some(close) = after.find("]]") {
199 out.push_str(&wiki_target(&after[..close], adr_prefix));
200 rest = &after[close + 2..];
201 } else {
202 out.push_str("[[");
203 rest = after;
204 }
205 }
206 out.push_str(rest);
207}
208
209fn wiki_target(inner: &str, adr_prefix: &str) -> String {
211 let inner = inner.trim();
212 let path = inner.split_once('#').map_or(inner, |(p, _)| p.trim());
213 if let Some(rest) = path.strip_prefix("docs/adr/")
214 && let Some(stem) = rest.strip_suffix(".md")
215 {
216 return format!("[{}]({adr_prefix}{stem}.html)", adr_label(stem));
217 }
218 format!("`{inner}`")
220}
221
222fn adr_label(stem: &str) -> String {
224 let digits: String = stem.chars().take_while(char::is_ascii_digit).collect();
225 if digits.is_empty() {
226 stem.to_owned()
227 } else {
228 format!("ADR-{digits}")
229 }
230}
231
232fn page(title: &str, root: &str, nav: &str, body: &str) -> String {
235 format!(
236 "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
237 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
238 <link rel=\"icon\" href=\"{root}favicon.svg\" type=\"image/svg+xml\">\
239 <link rel=\"stylesheet\" href=\"{root}style.css\">\
240 <title>{title}</title></head><body>\
241 {nav}{body}\
242 <p class=\"backlink\"><a href=\"{root}\">← Back to roteiro.dev</a></p>\
243 <footer>Dual-licensed MIT OR Apache-2.0 · The Roteiro Project Team</footer>\
244 </body></html>",
245 title = escape_html(title),
246 )
247}
248
249fn strip_frontmatter(text: &str) -> &str {
251 let Some(rest) = text.strip_prefix("---\n") else {
252 return text;
253 };
254 match rest.find("\n---\n") {
255 Some(end) => &rest[end + 5..],
256 None => rest.strip_suffix("\n---").unwrap_or(text),
257 }
258}
259
260fn first_heading(body: &str) -> Option<String> {
262 body.lines()
263 .find_map(|l| l.strip_prefix("# ").map(|h| h.trim().to_owned()))
264}
265
266fn escape_html(s: &str) -> String {
267 s.replace('&', "&")
268 .replace('<', "<")
269 .replace('>', ">")
270}
271
272fn escape_attr(s: &str) -> String {
273 escape_html(s).replace('"', """)
274}
275
276#[cfg(test)]
277mod tests {
278 use super::{IndexEntry, markdown_to_html, render_adr, render_adr_index, render_doc};
279
280 #[test]
281 fn markdown_renders_headings_and_tables() {
282 let html = markdown_to_html("# Title\n\n| a | b |\n|---|---|\n| 1 | 2 |\n");
283 assert!(html.contains("<h1>Title</h1>"));
284 assert!(html.contains("<table>"));
285 assert!(html.contains("<td>1</td>"));
286 }
287
288 #[test]
289 fn adr_wiki_links_become_sibling_page_links() {
290 let md = "See [[docs/adr/0001-build-roteiro.md]] and \
293 [[crates/rto-graph/src/store.rs#Store]] here.\n";
294 let html = markdown_to_html(md);
295 assert!(
296 html.contains("<a href=\"0001-build-roteiro.html\">ADR-0001</a>"),
297 "ADR wiki-link → sibling page: {html}"
298 );
299 assert!(
300 html.contains("<code>crates/rto-graph/src/store.rs#Store</code>"),
301 "code reference → inline code: {html}"
302 );
303 assert!(
304 !html.contains("[["),
305 "no literal wiki brackets leak: {html}"
306 );
307 }
308
309 #[test]
310 fn wiki_links_inside_code_are_left_literal() {
311 let inline = markdown_to_html("use `[[docs/adr/0001-x.md]]` in prose\n");
314 assert!(
315 inline.contains("<code>[[docs/adr/0001-x.md]]</code>"),
316 "{inline}"
317 );
318 let fenced = markdown_to_html("```\n[[docs/adr/0001-x.md]]\n```\n");
319 assert!(
320 fenced.contains("[[docs/adr/0001-x.md]]"),
321 "fence literal: {fenced}"
322 );
323 }
324
325 #[test]
326 fn multi_backtick_code_spans_are_honoured() {
327 let tight = markdown_to_html("say ``[[docs/adr/0001-x.md]]`` please\n");
331 assert!(
332 tight.contains("<code>[[docs/adr/0001-x.md]]</code>"),
333 "{tight}"
334 );
335 assert!(!tight.contains("<a "), "no link inside code span: {tight}");
336
337 let nested = markdown_to_html("its `` `[[path#Symbol]]` `` example\n");
338 assert!(
339 nested.contains("<code>`[[path#Symbol]]`</code>"),
340 "{nested}"
341 );
342 assert!(
343 !nested.contains("<a "),
344 "no link inside nested span: {nested}"
345 );
346
347 let stray = markdown_to_html("a ` stray tick then [[docs/adr/0001-x.md]]\n");
349 assert!(
350 stray.contains("<a href=\"0001-x.html\">ADR-0001</a>"),
351 "unterminated backtick must not shield: {stray}"
352 );
353 }
354
355 #[test]
356 fn render_doc_links_adrs_into_subdir() {
357 let r = render_doc(
359 "# Build Plan\n\nGoverned by [[docs/adr/0001-x.md]].\n",
360 "Build Plan",
361 );
362 assert_eq!(r.title, "Build Plan");
363 assert!(
364 r.html.contains("<a href=\"adr/0001-x.html\">ADR-0001</a>"),
365 "root doc → adr/ prefix: {}",
366 r.html
367 );
368 assert!(r.html.contains("href=\"./style.css\""));
370 }
371
372 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";
373
374 #[test]
375 fn render_adr_strips_frontmatter_and_themes() {
376 let r = render_adr(ADR, "fallback");
377 assert_eq!(r.title, "ADR-0001: Example");
378 assert!(!r.html.contains("adr-id"));
380 assert!(r.html.contains("<h1>ADR-0001: Example</h1>"));
381 assert!(r.html.contains("<h2>Context</h2>"));
382 assert!(r.html.contains("<code>code</code>"));
383 assert!(
385 r.html
386 .contains("<link rel=\"stylesheet\" href=\"../style.css\">")
387 );
388 assert!(r.html.contains("← Roteiro home"));
389 assert!(r.html.contains("← Back to roteiro.dev"));
390 assert!(r.html.starts_with("<!doctype html>"));
391 }
392
393 #[test]
394 fn render_adr_falls_back_without_h1() {
395 let r = render_adr("no frontmatter, no heading\n", "slug-name");
396 assert_eq!(r.title, "slug-name");
397 }
398
399 #[test]
400 fn index_lists_entries_and_escapes() {
401 let entries = [
402 IndexEntry {
403 href: "0001-x.html".into(),
404 title: "First & <best>".into(),
405 },
406 IndexEntry {
407 href: "0002-y.html".into(),
408 title: "Second".into(),
409 },
410 ];
411 let lifetime = [IndexEntry {
412 href: "../build-plan.html".into(),
413 title: "Build Plan".into(),
414 }];
415 let html = render_adr_index(&lifetime, &entries);
416 assert!(html.contains("<a href=\"../build-plan.html\">Build Plan</a>"));
417 assert!(html.contains("<a href=\"0001-x.html\">First & <best></a>"));
418 assert!(html.contains("<a href=\"0002-y.html\">Second</a>"));
419 assert!(html.find("0001-x").unwrap() < html.find("0002-y").unwrap());
421 assert!(html.find("build-plan").unwrap() < html.find("0001-x").unwrap());
423 }
424
425 #[test]
426 fn rendering_is_deterministic() {
427 assert_eq!(render_adr(ADR, "f"), render_adr(ADR, "f"));
428 }
429}