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 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, with GitHub-style tables and
35/// strikethrough enabled (the house ADR style uses pipe tables).
36#[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/// Render one ADR markdown document to a themed HTML page. Leading YAML
48/// frontmatter is stripped; the title is the first `# ` heading, or `fallback`
49/// if there is none.
50#[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/// Render the ADR index page listing `entries` in the given order.
62#[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
78/// Wrap body HTML in the themed page chrome. `root` is the relative path to the
79/// site root (e.g. `"../"` for pages under `adr/`).
80fn 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
95/// Strip a leading `---`-delimited YAML frontmatter block.
96fn 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
106/// The text of the first `# ` heading, if any.
107fn 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('&', "&amp;")
114        .replace('<', "&lt;")
115        .replace('>', "&gt;")
116}
117
118fn escape_attr(s: &str) -> String {
119    escape_html(s).replace('"', "&quot;")
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        // Frontmatter is gone; heading + section rendered.
141        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        // Themed chrome present.
146        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 &amp; &lt;best&gt;</a>"));
175        assert!(html.contains("<a href=\"0002-y.html\">Second</a>"));
176        // First entry precedes second (order preserved).
177        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}