Skip to main content

rto_render/
docs.rs

1//! The documentation-site renderer: ADR markdown → themed HTML pages, produced
2//! deterministically so CI diffs are meaningful. Replaces the shell
3//! `md2html.awk` stopgap with a real `CommonMark` parser (`pulldown-cmark`),
4//! fixing the whole class of hand-rolled-parser bugs (backtick runs, tables,
5//! heading edge cases) we hit before.
6//!
7//! Page chrome (theme, nav, back-link, footer) matches the previous site so the
8//! switch is drop-in. This module is pure string generation; the `roteiro`
9//! binary owns walking `docs/adr` and copying static assets.
10
11use std::fmt::Write as _;
12
13use pulldown_cmark::{CowStr, Event, Options, Parser, Tag, html};
14
15/// A rendered ADR: its title (for the index) and the full themed HTML page.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct RenderedAdr {
18    /// The ADR title (first `# ` heading, or the fallback passed to
19    /// [`render_adr`]).
20    pub title: String,
21    /// The complete HTML document.
22    pub html: String,
23}
24
25/// An entry in the ADR/docs index page.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct IndexEntry {
28    /// Relative href (e.g. `0001-….html`).
29    pub href: String,
30    /// Display title.
31    pub title: String,
32}
33
34/// Convert `CommonMark` `md` to an HTML fragment (GitHub tables + strikethrough,
35/// and Roteiro `[[wiki-links]]` resolved). Resolves ADR links relative to the
36/// ADR directory; use [`render_doc`] for root-level pages.
37#[must_use]
38pub fn markdown_to_html(md: &str) -> String {
39    render_markdown(md, "")
40}
41
42/// Render `md` to HTML: resolve `[[wiki-links]]` (ADR links use `adr_prefix` as
43/// their href prefix), rewrite ordinary `[…](*.md)` links to their rendered
44/// `.html` targets, then run `CommonMark` with GitHub tables/strikethrough.
45fn 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    // Rewrite link destinations pointing at local Markdown files to the HTML the
51    // site actually serves (e.g. `adr/0001-….md` → `adr/0001-….html`).
52    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
71/// Rewrite a relative link to a local Markdown file so it points at the rendered
72/// HTML page the site serves, preserving any `#fragment`. Returns `None` for
73/// external, protocol-relative, `mailto:`, pure-anchor, or non-`.md` links, which
74/// are left unchanged.
75fn 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/// Render one ADR markdown document to a themed HTML page. Leading YAML
95/// frontmatter is stripped; the title is the first `# ` heading, or `fallback`
96/// if there is none. ADR `[[…]]` links resolve to sibling ADR pages.
97#[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/// Render a root-level "lifetime doc" (e.g. the Build Plan) to a themed page.
109/// Its `[[docs/adr/…]]` links resolve into the `adr/` subdirectory.
110#[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/// Render the docs index: any `lifetime` docs (Build Plan, …) then the ADRs.
122#[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
151/// Rewrite Roteiro `[[wiki-links]]` into Markdown, honouring code spans/fences:
152/// `[[docs/adr/<slug>.md]]` (optionally `#section`) becomes a link to that ADR
153/// page (`<adr_prefix><slug>.html`); any other `[[…]]` (code/file references,
154/// for which the site has no page) becomes inline code so it renders cleanly
155/// instead of leaking literal brackets.
156fn 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
178/// Rewrite wiki-links in one line, leaving `CommonMark` inline code spans
179/// untouched. A code span opens with a run of *n* backticks and closes with the
180/// next run of *exactly* *n* backticks; anything between (including `[[…]]`
181/// examples) is emitted verbatim. Backtick runs with no matching close are
182/// literal text and do not shield what follows.
183fn 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            // Text before the opening delimiter is ordinary prose.
199            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]); // span, delimiters included
202            i = code_end;
203            text_start = i;
204        }
205        // No close → treat the run as literal text; keep it in the pending
206        // buffer (rewrite_wiki_in leaves backticks alone) and keep scanning.
207    }
208    rewrite_wiki_in(&line[text_start..], adr_prefix, out);
209}
210
211/// Byte offset (within `bytes`) of the next backtick run of *exactly* `run`
212/// backticks, or `None`. Longer or shorter runs are skipped, per `CommonMark`.
213fn 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
231/// Rewrite every `[[…]]` in one non-code text segment.
232fn 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
248/// Resolve one wiki-link's inner text to Markdown.
249fn 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    // Code/file reference — the site has no page for it; show it as code.
258    format!("`{inner}`")
259}
260
261/// A display label for an ADR filename stem: `0001-build-…` → `ADR-0001`.
262fn 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
271/// Wrap body HTML in the themed page chrome. `root` is the relative path to the
272/// site root (e.g. `"../"` for pages under `adr/`).
273fn 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
290/// Strip a leading `---`-delimited YAML frontmatter block.
291fn 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
301/// The text of the first `# ` heading, if any.
302fn 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('&', "&amp;")
309        .replace('<', "&lt;")
310        .replace('>', "&gt;")
311}
312
313fn escape_attr(s: &str) -> String {
314    escape_html(s).replace('"', "&quot;")
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        // An ADR-to-ADR wiki link resolves to the sibling .html; a code/file
332        // reference becomes inline code; both stop leaking literal `[[ ]]`.
333        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        // A documented example of the syntax, in backticks or a fence, must not
353        // be rewritten.
354        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        // A tight double-backtick span (`` ``…`` ``) and the Build Plan's
369        // nested-backtick example must both survive verbatim — the previous
370        // single-backtick split rewrote the wiki-link inside them.
371        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        // An unterminated run is literal and does not shield a later real link.
389        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        // Ordinary `[text](path.md)` links must point at the rendered `.html`,
399        // preserving fragments; external and anchor links are left alone.
400        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        // A root-level lifetime doc (Build Plan) resolves ADR links into `adr/`.
417        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        // Root-level chrome: assets/back-link relative to site root.
428        assert!(r.html.contains("href=\"./style.css\""));
429        // Full favicon set — root-relative from the site root.
430        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        // Frontmatter is gone; heading + section rendered.
445        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        // Themed chrome present.
450        assert!(
451            r.html
452                .contains("<link rel=\"stylesheet\" href=\"../style.css\">")
453        );
454        // Full favicon set (SVG + `.ico` fallback for browsers without SVG-favicon
455        // support, e.g. Safari) — root-relative from a sub-page.
456        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 &amp; &lt;best&gt;</a>"));
492        assert!(html.contains("<a href=\"0002-y.html\">Second</a>"));
493        // First entry precedes second (order preserved).
494        assert!(html.find("0001-x").unwrap() < html.find("0002-y").unwrap());
495        // Lifetime docs listed before the ADRs.
496        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}