1use std::fmt::Write as _;
12
13use pulldown_cmark::{CowStr, Event, Options, Parser, Tag, 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 {
46 let pre = rewrite_wiki_links(md, adr_prefix);
47 let mut opts = Options::empty();
48 opts.insert(Options::ENABLE_TABLES);
49 opts.insert(Options::ENABLE_STRIKETHROUGH);
50 let parser = Parser::new_ext(&pre, opts).map(|event| match event {
53 Event::Start(Tag::Link {
54 link_type,
55 dest_url,
56 title,
57 id,
58 }) => Event::Start(Tag::Link {
59 link_type,
60 dest_url: rewrite_doc_link(&dest_url).map_or(dest_url, CowStr::from),
61 title,
62 id,
63 }),
64 other => other,
65 });
66 let mut out = String::new();
67 html::push_html(&mut out, parser);
68 out
69}
70
71fn rewrite_doc_link(dest: &str) -> Option<String> {
76 if dest.starts_with("http://")
77 || dest.starts_with("https://")
78 || dest.starts_with("//")
79 || dest.starts_with("mailto:")
80 || dest.starts_with('#')
81 {
82 return None;
83 }
84 let (path, frag) = dest
85 .split_once('#')
86 .map_or((dest, None), |(p, f)| (p, Some(f)));
87 let stem = path.strip_suffix(".md")?;
88 Some(match frag {
89 Some(frag) => format!("{stem}.html#{frag}"),
90 None => format!("{stem}.html"),
91 })
92}
93
94#[must_use]
98pub fn render_adr(markdown: &str, fallback_title: &str) -> RenderedAdr {
99 let body = strip_frontmatter(markdown);
100 let title = first_heading(body).unwrap_or_else(|| fallback_title.to_owned());
101 let content = render_markdown(body, "");
102 let nav = "<p class=\"nav\"><a href=\"../\">← Roteiro home</a> · \
103 <a href=\"./\">All ADRs</a> · <a href=\"../build-plan.html\">Build Plan</a></p>";
104 let html = page(&format!("{title} — Roteiro"), "../", nav, &content);
105 RenderedAdr { title, html }
106}
107
108#[must_use]
111pub fn render_doc(markdown: &str, fallback_title: &str) -> RenderedAdr {
112 let body = strip_frontmatter(markdown);
113 let title = first_heading(body).unwrap_or_else(|| fallback_title.to_owned());
114 let content = render_markdown(body, "adr/");
115 let nav = "<p class=\"nav\"><a href=\"./\">← Roteiro home</a> · \
116 <a href=\"adr/\">ADRs</a></p>";
117 let html = page(&format!("{title} — Roteiro"), "./", nav, &content);
118 RenderedAdr { title, html }
119}
120
121#[must_use]
123pub fn render_adr_index(lifetime: &[IndexEntry], entries: &[IndexEntry]) -> String {
124 let mut list = String::new();
125 if !lifetime.is_empty() {
126 list.push_str("<h1>Documentation</h1><ul>");
127 for e in lifetime {
128 let _ = write!(
129 list,
130 "<li><a href=\"{}\">{}</a></li>",
131 escape_attr(&e.href),
132 escape_html(&e.title)
133 );
134 }
135 list.push_str("</ul>");
136 }
137 list.push_str("<h1>Architecture Decision Records</h1><ul>");
138 for e in entries {
139 let _ = write!(
140 list,
141 "<li><a href=\"{}\">{}</a></li>",
142 escape_attr(&e.href),
143 escape_html(&e.title)
144 );
145 }
146 list.push_str("</ul>");
147 let nav = "<p class=\"nav\"><a href=\"../\">← Roteiro home</a></p>";
148 page("Documentation — Roteiro", "../", nav, &list)
149}
150
151fn rewrite_wiki_links(md: &str, adr_prefix: &str) -> String {
157 let mut out = String::new();
158 let mut in_fence = false;
159 for line in md.lines() {
160 let trimmed = line.trim_start();
161 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
162 in_fence = !in_fence;
163 out.push_str(line);
164 out.push('\n');
165 continue;
166 }
167 if in_fence {
168 out.push_str(line);
169 out.push('\n');
170 continue;
171 }
172 rewrite_line_outside_code(line, adr_prefix, &mut out);
173 out.push('\n');
174 }
175 out
176}
177
178fn rewrite_line_outside_code(line: &str, adr_prefix: &str, out: &mut String) {
184 let bytes = line.as_bytes();
185 let mut text_start = 0;
186 let mut i = 0;
187 while i < bytes.len() {
188 if bytes[i] != b'`' {
189 i += 1;
190 continue;
191 }
192 let run_start = i;
193 while i < bytes.len() && bytes[i] == b'`' {
194 i += 1;
195 }
196 let run = i - run_start;
197 if let Some(rel) = find_closing_run(&bytes[i..], run) {
198 rewrite_wiki_in(&line[text_start..run_start], adr_prefix, out);
200 let code_end = i + rel + run;
201 out.push_str(&line[run_start..code_end]); i = code_end;
203 text_start = i;
204 }
205 }
208 rewrite_wiki_in(&line[text_start..], adr_prefix, out);
209}
210
211fn find_closing_run(bytes: &[u8], run: usize) -> Option<usize> {
214 let mut i = 0;
215 while i < bytes.len() {
216 if bytes[i] != b'`' {
217 i += 1;
218 continue;
219 }
220 let start = i;
221 while i < bytes.len() && bytes[i] == b'`' {
222 i += 1;
223 }
224 if i - start == run {
225 return Some(start);
226 }
227 }
228 None
229}
230
231fn rewrite_wiki_in(seg: &str, adr_prefix: &str, out: &mut String) {
233 let mut rest = seg;
234 while let Some(open) = rest.find("[[") {
235 out.push_str(&rest[..open]);
236 let after = &rest[open + 2..];
237 if let Some(close) = after.find("]]") {
238 out.push_str(&wiki_target(&after[..close], adr_prefix));
239 rest = &after[close + 2..];
240 } else {
241 out.push_str("[[");
242 rest = after;
243 }
244 }
245 out.push_str(rest);
246}
247
248fn wiki_target(inner: &str, adr_prefix: &str) -> String {
250 let inner = inner.trim();
251 let path = inner.split_once('#').map_or(inner, |(p, _)| p.trim());
252 if let Some(rest) = path.strip_prefix("docs/adr/")
253 && let Some(stem) = rest.strip_suffix(".md")
254 {
255 return format!("[{}]({adr_prefix}{stem}.html)", adr_label(stem));
256 }
257 format!("`{inner}`")
259}
260
261fn adr_label(stem: &str) -> String {
263 let digits: String = stem.chars().take_while(char::is_ascii_digit).collect();
264 if digits.is_empty() {
265 stem.to_owned()
266 } else {
267 format!("ADR-{digits}")
268 }
269}
270
271fn page(title: &str, root: &str, nav: &str, body: &str) -> String {
274 format!(
275 "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
276 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
277 <link rel=\"icon\" href=\"{root}favicon.svg\" type=\"image/svg+xml\">\
278 <link rel=\"icon\" href=\"{root}favicon.ico\" type=\"image/x-icon\" sizes=\"16x16 32x32 48x48\">\
279 <link rel=\"apple-touch-icon\" href=\"{root}apple-touch-icon.png\">\
280 <link rel=\"stylesheet\" href=\"{root}style.css\">\
281 <title>{title}</title></head><body>\
282 {nav}{body}\
283 <p class=\"backlink\"><a href=\"{root}\">← Back to roteiro.dev</a></p>\
284 <footer>Dual-licensed MIT OR Apache-2.0 · The Roteiro Project Team</footer>\
285 </body></html>",
286 title = escape_html(title),
287 )
288}
289
290fn strip_frontmatter(text: &str) -> &str {
292 let Some(rest) = text.strip_prefix("---\n") else {
293 return text;
294 };
295 match rest.find("\n---\n") {
296 Some(end) => &rest[end + 5..],
297 None => rest.strip_suffix("\n---").unwrap_or(text),
298 }
299}
300
301fn first_heading(body: &str) -> Option<String> {
303 body.lines()
304 .find_map(|l| l.strip_prefix("# ").map(|h| h.trim().to_owned()))
305}
306
307fn escape_html(s: &str) -> String {
308 s.replace('&', "&")
309 .replace('<', "<")
310 .replace('>', ">")
311}
312
313fn escape_attr(s: &str) -> String {
314 escape_html(s).replace('"', """)
315}
316
317#[cfg(test)]
318mod tests {
319 use super::{IndexEntry, markdown_to_html, render_adr, render_adr_index, render_doc};
320
321 #[test]
322 fn markdown_renders_headings_and_tables() {
323 let html = markdown_to_html("# Title\n\n| a | b |\n|---|---|\n| 1 | 2 |\n");
324 assert!(html.contains("<h1>Title</h1>"));
325 assert!(html.contains("<table>"));
326 assert!(html.contains("<td>1</td>"));
327 }
328
329 #[test]
330 fn adr_wiki_links_become_sibling_page_links() {
331 let md = "See [[docs/adr/0001-build-roteiro.md]] and \
334 [[crates/rto-graph/src/store.rs#Store]] here.\n";
335 let html = markdown_to_html(md);
336 assert!(
337 html.contains("<a href=\"0001-build-roteiro.html\">ADR-0001</a>"),
338 "ADR wiki-link → sibling page: {html}"
339 );
340 assert!(
341 html.contains("<code>crates/rto-graph/src/store.rs#Store</code>"),
342 "code reference → inline code: {html}"
343 );
344 assert!(
345 !html.contains("[["),
346 "no literal wiki brackets leak: {html}"
347 );
348 }
349
350 #[test]
351 fn wiki_links_inside_code_are_left_literal() {
352 let inline = markdown_to_html("use `[[docs/adr/0001-x.md]]` in prose\n");
355 assert!(
356 inline.contains("<code>[[docs/adr/0001-x.md]]</code>"),
357 "{inline}"
358 );
359 let fenced = markdown_to_html("```\n[[docs/adr/0001-x.md]]\n```\n");
360 assert!(
361 fenced.contains("[[docs/adr/0001-x.md]]"),
362 "fence literal: {fenced}"
363 );
364 }
365
366 #[test]
367 fn multi_backtick_code_spans_are_honoured() {
368 let tight = markdown_to_html("say ``[[docs/adr/0001-x.md]]`` please\n");
372 assert!(
373 tight.contains("<code>[[docs/adr/0001-x.md]]</code>"),
374 "{tight}"
375 );
376 assert!(!tight.contains("<a "), "no link inside code span: {tight}");
377
378 let nested = markdown_to_html("its `` `[[path#Symbol]]` `` example\n");
379 assert!(
380 nested.contains("<code>`[[path#Symbol]]`</code>"),
381 "{nested}"
382 );
383 assert!(
384 !nested.contains("<a "),
385 "no link inside nested span: {nested}"
386 );
387
388 let stray = markdown_to_html("a ` stray tick then [[docs/adr/0001-x.md]]\n");
390 assert!(
391 stray.contains("<a href=\"0001-x.html\">ADR-0001</a>"),
392 "unterminated backtick must not shield: {stray}"
393 );
394 }
395
396 #[test]
397 fn markdown_md_links_are_rewritten_to_html() {
398 let html = markdown_to_html(
401 "See [ADR-1](adr/0001-x.md) and [§2](adr/0001-x.md#context) and \
402 [home](https://x.dev) and [top](#intro).\n",
403 );
404 assert!(html.contains("href=\"adr/0001-x.html\""), "{html}");
405 assert!(html.contains("href=\"adr/0001-x.html#context\""), "{html}");
406 assert!(
407 html.contains("href=\"https://x.dev\""),
408 "external unchanged: {html}"
409 );
410 assert!(html.contains("href=\"#intro\""), "anchor unchanged: {html}");
411 assert!(!html.contains(".md\""), "no raw .md hrefs remain: {html}");
412 }
413
414 #[test]
415 fn render_doc_links_adrs_into_subdir() {
416 let r = render_doc(
418 "# Build Plan\n\nGoverned by [[docs/adr/0001-x.md]].\n",
419 "Build Plan",
420 );
421 assert_eq!(r.title, "Build Plan");
422 assert!(
423 r.html.contains("<a href=\"adr/0001-x.html\">ADR-0001</a>"),
424 "root doc → adr/ prefix: {}",
425 r.html
426 );
427 assert!(r.html.contains("href=\"./style.css\""));
429 assert!(r.html.contains("href=\"./favicon.svg\""));
431 assert!(r.html.contains("href=\"./favicon.ico\""));
432 assert!(
433 r.html
434 .contains("rel=\"apple-touch-icon\" href=\"./apple-touch-icon.png\"")
435 );
436 }
437
438 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";
439
440 #[test]
441 fn render_adr_strips_frontmatter_and_themes() {
442 let r = render_adr(ADR, "fallback");
443 assert_eq!(r.title, "ADR-0001: Example");
444 assert!(!r.html.contains("adr-id"));
446 assert!(r.html.contains("<h1>ADR-0001: Example</h1>"));
447 assert!(r.html.contains("<h2>Context</h2>"));
448 assert!(r.html.contains("<code>code</code>"));
449 assert!(
451 r.html
452 .contains("<link rel=\"stylesheet\" href=\"../style.css\">")
453 );
454 assert!(r.html.contains("href=\"../favicon.svg\""));
457 assert!(r.html.contains("href=\"../favicon.ico\""));
458 assert!(
459 r.html
460 .contains("rel=\"apple-touch-icon\" href=\"../apple-touch-icon.png\"")
461 );
462 assert!(r.html.contains("← Roteiro home"));
463 assert!(r.html.contains("← Back to roteiro.dev"));
464 assert!(r.html.starts_with("<!doctype html>"));
465 }
466
467 #[test]
468 fn render_adr_falls_back_without_h1() {
469 let r = render_adr("no frontmatter, no heading\n", "slug-name");
470 assert_eq!(r.title, "slug-name");
471 }
472
473 #[test]
474 fn index_lists_entries_and_escapes() {
475 let entries = [
476 IndexEntry {
477 href: "0001-x.html".into(),
478 title: "First & <best>".into(),
479 },
480 IndexEntry {
481 href: "0002-y.html".into(),
482 title: "Second".into(),
483 },
484 ];
485 let lifetime = [IndexEntry {
486 href: "../build-plan.html".into(),
487 title: "Build Plan".into(),
488 }];
489 let html = render_adr_index(&lifetime, &entries);
490 assert!(html.contains("<a href=\"../build-plan.html\">Build Plan</a>"));
491 assert!(html.contains("<a href=\"0001-x.html\">First & <best></a>"));
492 assert!(html.contains("<a href=\"0002-y.html\">Second</a>"));
493 assert!(html.find("0001-x").unwrap() < html.find("0002-y").unwrap());
495 assert!(html.find("build-plan").unwrap() < html.find("0001-x").unwrap());
497 }
498
499 #[test]
500 fn rendering_is_deterministic() {
501 assert_eq!(render_adr(ADR, "f"), render_adr(ADR, "f"));
502 }
503}