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::{Options, Parser, 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), then run `CommonMark` with GitHub tables/strikethrough.
44fn 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/// Render one ADR markdown document to a themed HTML page. Leading YAML
56/// frontmatter is stripped; the title is the first `# ` heading, or `fallback`
57/// if there is none. ADR `[[…]]` links resolve to sibling ADR pages.
58#[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/// Render a root-level "lifetime doc" (e.g. the Build Plan) to a themed page.
70/// Its `[[docs/adr/…]]` links resolve into the `adr/` subdirectory.
71#[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/// Render the docs index: any `lifetime` docs (Build Plan, …) then the ADRs.
83#[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
112/// Rewrite Roteiro `[[wiki-links]]` into Markdown, honouring code spans/fences:
113/// `[[docs/adr/<slug>.md]]` (optionally `#section`) becomes a link to that ADR
114/// page (`<adr_prefix><slug>.html`); any other `[[…]]` (code/file references,
115/// for which the site has no page) becomes inline code so it renders cleanly
116/// instead of leaking literal brackets.
117fn 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
139/// Rewrite wiki-links in one line, leaving `CommonMark` inline code spans
140/// untouched. A code span opens with a run of *n* backticks and closes with the
141/// next run of *exactly* *n* backticks; anything between (including `[[…]]`
142/// examples) is emitted verbatim. Backtick runs with no matching close are
143/// literal text and do not shield what follows.
144fn 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            // Text before the opening delimiter is ordinary prose.
160            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]); // span, delimiters included
163            i = code_end;
164            text_start = i;
165        }
166        // No close → treat the run as literal text; keep it in the pending
167        // buffer (rewrite_wiki_in leaves backticks alone) and keep scanning.
168    }
169    rewrite_wiki_in(&line[text_start..], adr_prefix, out);
170}
171
172/// Byte offset (within `bytes`) of the next backtick run of *exactly* `run`
173/// backticks, or `None`. Longer or shorter runs are skipped, per `CommonMark`.
174fn 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
192/// Rewrite every `[[…]]` in one non-code text segment.
193fn 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
209/// Resolve one wiki-link's inner text to Markdown.
210fn 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    // Code/file reference — the site has no page for it; show it as code.
219    format!("`{inner}`")
220}
221
222/// A display label for an ADR filename stem: `0001-build-…` → `ADR-0001`.
223fn 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
232/// Wrap body HTML in the themed page chrome. `root` is the relative path to the
233/// site root (e.g. `"../"` for pages under `adr/`).
234fn 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
249/// Strip a leading `---`-delimited YAML frontmatter block.
250fn 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
260/// The text of the first `# ` heading, if any.
261fn 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('&', "&amp;")
268        .replace('<', "&lt;")
269        .replace('>', "&gt;")
270}
271
272fn escape_attr(s: &str) -> String {
273    escape_html(s).replace('"', "&quot;")
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        // An ADR-to-ADR wiki link resolves to the sibling .html; a code/file
291        // reference becomes inline code; both stop leaking literal `[[ ]]`.
292        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        // A documented example of the syntax, in backticks or a fence, must not
312        // be rewritten.
313        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        // A tight double-backtick span (`` ``…`` ``) and the Build Plan's
328        // nested-backtick example must both survive verbatim — the previous
329        // single-backtick split rewrote the wiki-link inside them.
330        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        // An unterminated run is literal and does not shield a later real link.
348        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        // A root-level lifetime doc (Build Plan) resolves ADR links into `adr/`.
358        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        // Root-level chrome: assets/back-link relative to site root.
369        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        // Frontmatter is gone; heading + section rendered.
379        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        // Themed chrome present.
384        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 &amp; &lt;best&gt;</a>"));
418        assert!(html.contains("<a href=\"0002-y.html\">Second</a>"));
419        // First entry precedes second (order preserved).
420        assert!(html.find("0001-x").unwrap() < html.find("0002-y").unwrap());
421        // Lifetime docs listed before the ADRs.
422        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}