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::collections::BTreeMap;
12use std::fmt::Write as _;
13
14use pulldown_cmark::{CowStr, Event, HeadingLevel, Options, Parser, Tag, TagEnd, html};
15
16/// A rendered ADR: its title (for the index) and the full themed HTML page.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct RenderedAdr {
19    /// The ADR title (first `# ` heading, or the fallback passed to
20    /// [`render_adr`]).
21    pub title: String,
22    /// The complete HTML document.
23    pub html: String,
24}
25
26/// Where each source document is **actually published**: the file the site
27/// serves, keyed by the source markdown's file name.
28///
29/// [`rewrite_doc_link`] used to derive a link's target from the link's own
30/// spelling — `../BUILD_PLAN_V2.md` → `../BUILD_PLAN_V2.html` — which is correct
31/// only while every document is served under its own stem. Site pages ended
32/// that: a page is published as its declared `site-page:` slug, and a slug is
33/// URL-safe by construction (`[a-z0-9-]+`), so `docs/BUILD_PLAN_V2.md` is served
34/// as `build-plan-v2.html`. The rewrite then pointed four correct repository
35/// links at a page that is never emitted — issue #446, live on roteiro.dev.
36///
37/// So the served name is *looked up* rather than guessed. The renderer is handed
38/// the index of what the site emits, which is the only thing that knows the
39/// answer.
40///
41/// Keyed by file name rather than by full path because the site mirrors the
42/// repository's layout — `docs/*.md` at the root, `docs/adr/*.md` under `adr/` —
43/// so a link's directory hops are already correct and only the final segment can
44/// differ. A file name claimed by two published documents is recorded as
45/// **ambiguous** and left unrewritten: guessing which one a link meant is how a
46/// link silently points at the wrong page, which is worse than the 404 it
47/// replaces.
48#[derive(Debug, Default, Clone, PartialEq, Eq)]
49pub struct PublishedPages(BTreeMap<String, Option<String>>);
50
51impl PublishedPages {
52    /// An empty index: every `.md` link falls back to its own stem.
53    #[must_use]
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    /// Record that `source_file` (a markdown file name, e.g. `BUILD_PLAN_V2.md`)
59    /// is served as `served_as` (e.g. `build-plan-v2.html`).
60    ///
61    /// A second, differing claim on one file name makes it ambiguous; see the
62    /// type's documentation for why that is left unrewritten.
63    pub fn publish(&mut self, source_file: &str, served_as: &str) {
64        self.0
65            .entry(source_file.to_owned())
66            .and_modify(|slot| {
67                if slot.as_deref() != Some(served_as) {
68                    *slot = None;
69                }
70            })
71            .or_insert_with(|| Some(served_as.to_owned()));
72    }
73
74    /// The file `source_file` is served as, or `None` when it is unknown or
75    /// ambiguous.
76    fn served(&self, source_file: &str) -> Option<&str> {
77        self.0.get(source_file)?.as_deref()
78    }
79}
80
81/// One page in the site navigation bar: where it goes and what it is called.
82///
83/// Built by the caller from the authored site pages (`rto_spec::site_nav` puts
84/// them in order), and passed to [`render_site_page`] whole so every page emits
85/// the *same* bar. A per-page bar assembled independently is a bar that can
86/// disagree with itself, which is how a page ends up unreachable from its
87/// neighbours.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct NavEntry {
90    /// Root-relative href (e.g. `modes.html`, or `./` for the landing page).
91    pub href: String,
92    /// Short label shown in the bar.
93    pub label: String,
94}
95
96/// An entry in the ADR/docs index page.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct IndexEntry {
99    /// Relative href (e.g. `0001-….html`).
100    pub href: String,
101    /// Display title.
102    pub title: String,
103}
104
105/// Convert `CommonMark` `md` to an HTML fragment (GitHub tables + strikethrough,
106/// and Roteiro `[[wiki-links]]` resolved). Resolves ADR links relative to the
107/// ADR directory; use [`render_doc`] for root-level pages.
108///
109/// A fragment renderer has no site to be a part of, so it carries no
110/// [`PublishedPages`]: a `.md` link is rewritten to its own stem, which is right
111/// for an ADR and a guess for anything published under a slug.
112#[must_use]
113pub fn markdown_to_html(md: &str) -> String {
114    render_markdown(md, "", &PublishedPages::new())
115}
116
117/// Render `md` to HTML: resolve `[[wiki-links]]` (ADR links use `adr_prefix` as
118/// their href prefix), rewrite ordinary `[…](*.md)` links to their rendered
119/// `.html` targets, then run `CommonMark` with GitHub tables/strikethrough.
120fn render_markdown(md: &str, adr_prefix: &str, pages: &PublishedPages) -> String {
121    let pre = rewrite_wiki_links(md, adr_prefix);
122    let ids = heading_ids(&pre);
123    let mut next_id = 0usize;
124    // Rewrite link destinations pointing at local Markdown files to the HTML the
125    // site actually serves (e.g. `adr/0001-….md` → `adr/0001-….html`), and give
126    // every heading a stable `id` so it can be linked to.
127    let parser = Parser::new_ext(&pre, options()).map(|event| match event {
128        Event::Start(Tag::Link {
129            link_type,
130            dest_url,
131            title,
132            id,
133        }) => Event::Start(Tag::Link {
134            link_type,
135            dest_url: rewrite_doc_link(&dest_url, pages).map_or(dest_url, CowStr::from),
136            title,
137            id,
138        }),
139        Event::Start(Tag::Heading {
140            level,
141            classes,
142            attrs,
143            ..
144        }) => {
145            let id = ids.get(next_id).cloned().map(CowStr::from);
146            next_id += 1;
147            Event::Start(Tag::Heading {
148                level,
149                id,
150                classes,
151                attrs,
152            })
153        }
154        other => other,
155    });
156    let mut out = String::new();
157    html::push_html(&mut out, parser);
158    out
159}
160
161/// The `CommonMark` dialect the whole site is parsed with: GitHub tables and
162/// strikethrough, plus **heading attributes** (`## Heading {#anchor}`).
163///
164/// Heading attributes are how a URL outlives a restructure. A page split out of
165/// the old single-page site keeps the anchor the old page published — the
166/// heading declares `{#modes}` and lands at `#modes` — instead of silently
167/// becoming whatever the new heading text happens to slugify to. External links
168/// point at those anchors and cannot be updated, so the alternative is not a
169/// tidier URL; it is a dead one.
170fn options() -> Options {
171    let mut opts = Options::empty();
172    opts.insert(Options::ENABLE_TABLES);
173    opts.insert(Options::ENABLE_STRIKETHROUGH);
174    opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
175    opts
176}
177
178/// The `id` for every heading in `md`, in document order.
179///
180/// An explicit `{#anchor}` wins; otherwise the id is [`rto_graph::slugify`] of
181/// the heading text — the same function that builds the section's node key, so
182/// an authored link to `site:modes#offline-mode` lands on the heading the graph
183/// says it does. A heading whose text slugifies to nothing (`## ###`) falls back
184/// to its position, and a repeat gets a `-2`, `-3`, … suffix, because two
185/// elements sharing an `id` means one of them is unreachable.
186///
187/// Computed from a *first parse* rather than a line scan: heading text can be
188/// spread over several inline events, and `#` inside a fenced block is not a
189/// heading at all. Parsing twice costs a document-sized pass and cannot be wrong
190/// about what the renderer will see, because it is the same parser.
191fn heading_ids(md: &str) -> Vec<String> {
192    let mut ids: Vec<String> = Vec::new();
193    let mut seen: BTreeMap<String, usize> = BTreeMap::new();
194    let mut current: Option<(Option<String>, String)> = None;
195    for event in Parser::new_ext(md, options()) {
196        match event {
197            Event::Start(Tag::Heading { id, .. }) => {
198                current = Some((id.map(|i| i.to_string()), String::new()));
199            }
200            Event::Text(t) | Event::Code(t) => {
201                if let Some((_, text)) = current.as_mut() {
202                    text.push_str(&t);
203                }
204            }
205            Event::End(TagEnd::Heading(_)) => {
206                let Some((explicit, text)) = current.take() else {
207                    continue;
208                };
209                let base = explicit
210                    .filter(|e| !e.is_empty())
211                    .unwrap_or_else(|| rto_graph::slugify(&text));
212                let base = if base.is_empty() {
213                    format!("section-{}", ids.len() + 1)
214                } else {
215                    base
216                };
217                let n = seen.entry(base.clone()).or_insert(0);
218                *n += 1;
219                ids.push(if *n == 1 { base } else { format!("{base}-{n}") });
220            }
221            _ => {}
222        }
223    }
224    ids
225}
226
227/// Rewrite a relative link to a local Markdown file so it points at the rendered
228/// HTML page the site serves, preserving any `#fragment`. Returns `None` for
229/// external, protocol-relative, `mailto:`, pure-anchor, or non-`.md` links, which
230/// are left unchanged.
231fn rewrite_doc_link(dest: &str, pages: &PublishedPages) -> Option<String> {
232    if dest.starts_with("http://")
233        || dest.starts_with("https://")
234        || dest.starts_with("//")
235        || dest.starts_with("mailto:")
236        || dest.starts_with('#')
237    {
238        return None;
239    }
240    let (path, frag) = dest
241        .split_once('#')
242        .map_or((dest, None), |(p, f)| (p, Some(f)));
243    path.strip_suffix(".md")?;
244    // Only the final segment can differ between the repository and the site, so
245    // the link's own directory hops are kept verbatim; see [`PublishedPages`].
246    let (dir, file) = path.rsplit_once('/').map_or(("", path), |(d, f)| (d, f));
247    let served = match pages.served(file) {
248        Some(served) => served.to_owned(),
249        // Unknown or ambiguous: fall back to the stem rewrite this has always
250        // done, which is right for every ADR (each is served under its own
251        // stem) and no worse than before for anything else.
252        None => format!("{}.html", file.trim_end_matches(".md")),
253    };
254    let sep = if dir.is_empty() { "" } else { "/" };
255    Some(match frag {
256        Some(frag) => format!("{dir}{sep}{served}#{frag}"),
257        None => format!("{dir}{sep}{served}"),
258    })
259}
260
261/// Render one ADR markdown document to a themed HTML page. Leading YAML
262/// frontmatter is stripped; the title is the first `# ` heading, or `fallback`
263/// if there is none. ADR `[[…]]` links resolve to sibling ADR pages.
264#[must_use]
265pub fn render_adr(markdown: &str, fallback_title: &str, pages: &PublishedPages) -> RenderedAdr {
266    let body = strip_frontmatter(markdown);
267    let title = first_heading(body).unwrap_or_else(|| fallback_title.to_owned());
268    let content = render_markdown(body, "", pages);
269    let nav = "<p class=\"nav\"><a href=\"../\">← Roteiro home</a> · \
270               <a href=\"./\">All ADRs</a> · <a href=\"../build-plan.html\">Build Plan</a></p>";
271    let html = page(&format!("{title} — Roteiro"), "../", nav, &content);
272    RenderedAdr { title, html }
273}
274
275/// Render a root-level "lifetime doc" (e.g. the Build Plan) to a themed page.
276/// Its `[[docs/adr/…]]` links resolve into the `adr/` subdirectory.
277#[must_use]
278pub fn render_doc(markdown: &str, fallback_title: &str, pages: &PublishedPages) -> RenderedAdr {
279    let body = strip_frontmatter(markdown);
280    let title = first_heading(body).unwrap_or_else(|| fallback_title.to_owned());
281    let content = render_markdown(body, "adr/", pages);
282    let nav = "<p class=\"nav\"><a href=\"./\">← Roteiro home</a> · \
283               <a href=\"adr/\">ADRs</a></p>";
284    let html = page(&format!("{title} — Roteiro"), "./", nav, &content);
285    RenderedAdr { title, html }
286}
287
288/// Render one **site page** — a document that declared itself published — to a
289/// themed root-level page carrying the site navigation bar.
290///
291/// `nav` is the whole bar, in order; `current_href` is this page's own entry,
292/// which is marked `aria-current="page"` and rendered unlinked so the reader can
293/// see where they are. A `current_href` that matches nothing in `nav` simply
294/// yields a bar with nothing marked, which is what a preview of an unlisted page
295/// should look like rather than an error.
296///
297/// The title is the first `# ` heading, or `fallback_title`. `[[docs/adr/…]]`
298/// links resolve into the `adr/` subdirectory, exactly as they do for the Build
299/// Plan: a site page is a root-level document.
300#[must_use]
301pub fn render_site_page(
302    markdown: &str,
303    fallback_title: &str,
304    nav: &[NavEntry],
305    current_href: &str,
306    pages: &PublishedPages,
307) -> RenderedAdr {
308    let body = strip_frontmatter(markdown);
309    let title = first_heading(body).unwrap_or_else(|| fallback_title.to_owned());
310    let content = render_markdown(body, "adr/", pages);
311    let bar = render_nav(nav, current_href);
312    let html = page(&format!("{title} — Roteiro"), "./", &bar, &content);
313    RenderedAdr { title, html }
314}
315
316/// The site navigation bar: one link per page, the current one marked.
317///
318/// Plain anchors in a `<nav>`, styled by `website/public/style.css`. No script:
319/// the explorer is deliberately vendored with no build step (ADR-0010), and a
320/// navigation bar that needs JavaScript to be a navigation bar would be the
321/// first thing on this site that does.
322#[must_use]
323pub fn render_nav(nav: &[NavEntry], current_href: &str) -> String {
324    let mut out = String::from("<nav class=\"sitenav\">");
325    for entry in nav {
326        if entry.href == current_href {
327            let _ = write!(
328                out,
329                "<span aria-current=\"page\">{}</span>",
330                escape_html(&entry.label)
331            );
332        } else {
333            let _ = write!(
334                out,
335                "<a href=\"{}\">{}</a>",
336                escape_attr(&entry.href),
337                escape_html(&entry.label)
338            );
339        }
340    }
341    out.push_str("</nav>");
342    out
343}
344
345/// Render the docs index: any `lifetime` docs (Build Plan, …) then the ADRs.
346#[must_use]
347pub fn render_adr_index(lifetime: &[IndexEntry], entries: &[IndexEntry]) -> String {
348    let mut list = String::new();
349    if !lifetime.is_empty() {
350        list.push_str("<h1>Documentation</h1><ul>");
351        for e in lifetime {
352            let _ = write!(
353                list,
354                "<li><a href=\"{}\">{}</a></li>",
355                escape_attr(&e.href),
356                escape_html(&e.title)
357            );
358        }
359        list.push_str("</ul>");
360    }
361    list.push_str("<h1>Architecture Decision Records</h1><ul>");
362    for e in entries {
363        let _ = write!(
364            list,
365            "<li><a href=\"{}\">{}</a></li>",
366            escape_attr(&e.href),
367            escape_html(&e.title)
368        );
369    }
370    list.push_str("</ul>");
371    let nav = "<p class=\"nav\"><a href=\"../\">← Roteiro home</a></p>";
372    page("Documentation — Roteiro", "../", nav, &list)
373}
374
375/// Rewrite Roteiro `[[wiki-links]]` into Markdown, honouring code spans/fences:
376/// `[[docs/adr/<slug>.md]]` (optionally `#section`) becomes a link to that ADR
377/// page (`<adr_prefix><slug>.html`); any other `[[…]]` (code/file references,
378/// for which the site has no page) becomes inline code so it renders cleanly
379/// instead of leaking literal brackets.
380fn rewrite_wiki_links(md: &str, adr_prefix: &str) -> String {
381    let mut out = String::new();
382    let mut in_fence = false;
383    for line in md.lines() {
384        let trimmed = line.trim_start();
385        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
386            in_fence = !in_fence;
387            out.push_str(line);
388            out.push('\n');
389            continue;
390        }
391        if in_fence {
392            out.push_str(line);
393            out.push('\n');
394            continue;
395        }
396        rewrite_line_outside_code(line, adr_prefix, &mut out);
397        out.push('\n');
398    }
399    out
400}
401
402/// Rewrite wiki-links in one line, leaving `CommonMark` inline code spans
403/// untouched. A code span opens with a run of *n* backticks and closes with the
404/// next run of *exactly* *n* backticks; anything between (including `[[…]]`
405/// examples) is emitted verbatim. Backtick runs with no matching close are
406/// literal text and do not shield what follows.
407fn rewrite_line_outside_code(line: &str, adr_prefix: &str, out: &mut String) {
408    let bytes = line.as_bytes();
409    let mut text_start = 0;
410    let mut i = 0;
411    while i < bytes.len() {
412        if bytes[i] != b'`' {
413            i += 1;
414            continue;
415        }
416        let run_start = i;
417        while i < bytes.len() && bytes[i] == b'`' {
418            i += 1;
419        }
420        let run = i - run_start;
421        if let Some(rel) = find_closing_run(&bytes[i..], run) {
422            // Text before the opening delimiter is ordinary prose.
423            rewrite_wiki_in(&line[text_start..run_start], adr_prefix, out);
424            let code_end = i + rel + run;
425            out.push_str(&line[run_start..code_end]); // span, delimiters included
426            i = code_end;
427            text_start = i;
428        }
429        // No close → treat the run as literal text; keep it in the pending
430        // buffer (rewrite_wiki_in leaves backticks alone) and keep scanning.
431    }
432    rewrite_wiki_in(&line[text_start..], adr_prefix, out);
433}
434
435/// Byte offset (within `bytes`) of the next backtick run of *exactly* `run`
436/// backticks, or `None`. Longer or shorter runs are skipped, per `CommonMark`.
437fn find_closing_run(bytes: &[u8], run: usize) -> Option<usize> {
438    let mut i = 0;
439    while i < bytes.len() {
440        if bytes[i] != b'`' {
441            i += 1;
442            continue;
443        }
444        let start = i;
445        while i < bytes.len() && bytes[i] == b'`' {
446            i += 1;
447        }
448        if i - start == run {
449            return Some(start);
450        }
451    }
452    None
453}
454
455/// Rewrite every `[[…]]` in one non-code text segment.
456fn rewrite_wiki_in(seg: &str, adr_prefix: &str, out: &mut String) {
457    let mut rest = seg;
458    while let Some(open) = rest.find("[[") {
459        out.push_str(&rest[..open]);
460        let after = &rest[open + 2..];
461        if let Some(close) = after.find("]]") {
462            out.push_str(&wiki_target(&after[..close], adr_prefix));
463            rest = &after[close + 2..];
464        } else {
465            out.push_str("[[");
466            rest = after;
467        }
468    }
469    out.push_str(rest);
470}
471
472/// Resolve one wiki-link's inner text to Markdown.
473fn wiki_target(inner: &str, adr_prefix: &str) -> String {
474    let inner = inner.trim();
475    let path = inner.split_once('#').map_or(inner, |(p, _)| p.trim());
476    if let Some(rest) = path.strip_prefix("docs/adr/")
477        && let Some(stem) = rest.strip_suffix(".md")
478    {
479        return format!("[{}]({adr_prefix}{stem}.html)", adr_label(stem));
480    }
481    // Code/file reference — the site has no page for it; show it as code.
482    format!("`{inner}`")
483}
484
485/// A display label for an ADR filename stem: `0001-build-…` → `ADR-0001`.
486fn adr_label(stem: &str) -> String {
487    let digits: String = stem.chars().take_while(char::is_ascii_digit).collect();
488    if digits.is_empty() {
489        stem.to_owned()
490    } else {
491        format!("ADR-{digits}")
492    }
493}
494
495/// Wrap body HTML in the themed page chrome. `root` is the relative path to the
496/// site root (e.g. `"../"` for pages under `adr/`).
497fn page(title: &str, root: &str, nav: &str, body: &str) -> String {
498    format!(
499        "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
500         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
501         <link rel=\"icon\" href=\"{root}favicon.svg\" type=\"image/svg+xml\">\
502         <link rel=\"icon\" href=\"{root}favicon.ico\" type=\"image/x-icon\" sizes=\"16x16 32x32 48x48\">\
503         <link rel=\"apple-touch-icon\" href=\"{root}apple-touch-icon.png\">\
504         <link rel=\"stylesheet\" href=\"{root}style.css\">\
505         <title>{title}</title></head><body>\
506         {nav}{body}\
507         <p class=\"backlink\"><a href=\"{root}\">← Back to roteiro.dev</a></p>\
508         <footer>Dual-licensed MIT OR Apache-2.0 · The Roteiro Project Team</footer>\
509         </body></html>",
510        title = escape_html(title),
511    )
512}
513
514/// Strip a leading `---`-delimited YAML frontmatter block.
515fn strip_frontmatter(text: &str) -> &str {
516    let Some(rest) = text.strip_prefix("---\n") else {
517        return text;
518    };
519    match rest.find("\n---\n") {
520        Some(end) => &rest[end + 5..],
521        None => rest.strip_suffix("\n---").unwrap_or(text),
522    }
523}
524
525/// The visible text of the document's first level-1 heading — what the reader
526/// sees in the rendered `<h1>` — or `None` when the document has none.
527///
528/// Read from a **parse**, for the same reason [`heading_ids`] is: the heading's
529/// raw line is source, not text. A line scan cannot tell `{#modes}` (a heading
530/// attribute this renderer deliberately enables, see [`options`]) from the words
531/// of the heading, so it read `# The five ways to run it {#modes}` back as a
532/// title and put the markup in the `<title>` element of every page moved by the
533/// site split — issue #460, live on roteiro.dev. The `<h1>` on the same page was
534/// already right, because that side went through the parser.
535///
536/// The fix is *not* a second place that knows how to strip `{#…}`. A rule
537/// spelled out twice is a rule that can disagree with itself, and this one
538/// already disagrees once: the anchor is markup to the parser and text to the
539/// scanner. Asking the parser removes the second opinion rather than aligning
540/// it, and carries the rest of the dialect along for free — a fenced `# …` is
541/// not a title, a setext underline is one, and inline markup (`` `code` ``,
542/// emphasis, a link label) contributes its text and not its punctuation.
543///
544/// The parse stops at the first `</h1>`; nothing walks the rest of the document.
545fn first_heading(body: &str) -> Option<String> {
546    let mut text: Option<String> = None;
547    for event in Parser::new_ext(body, options()) {
548        match event {
549            Event::Start(Tag::Heading {
550                level: HeadingLevel::H1,
551                ..
552            }) => text = Some(String::new()),
553            // Only accumulates once an H1 has opened; a code span is part of the
554            // heading's text, exactly as it is for the heading's id.
555            Event::Text(t) | Event::Code(t) => {
556                if let Some(text) = text.as_mut() {
557                    text.push_str(&t);
558                }
559            }
560            Event::End(TagEnd::Heading(HeadingLevel::H1)) => break,
561            _ => {}
562        }
563    }
564    // An empty `#` heading names nothing, so it defers to the caller's fallback
565    // rather than rendering `<title> — Roteiro</title>`.
566    text.map(|t| t.trim().to_owned()).filter(|t| !t.is_empty())
567}
568
569fn escape_html(s: &str) -> String {
570    s.replace('&', "&amp;")
571        .replace('<', "&lt;")
572        .replace('>', "&gt;")
573}
574
575fn escape_attr(s: &str) -> String {
576    escape_html(s).replace('"', "&quot;")
577}
578
579#[cfg(test)]
580mod tests {
581    use super::{
582        IndexEntry, NavEntry, PublishedPages, escape_html, markdown_to_html, render_adr,
583        render_adr_index, render_doc, render_markdown, render_nav, render_site_page,
584    };
585
586    /// The site index most tests do not exercise: with it empty, a `.md` link
587    /// falls back to its own stem, which is what every assertion below predates.
588    fn no_pages() -> PublishedPages {
589        PublishedPages::new()
590    }
591
592    fn nav() -> Vec<NavEntry> {
593        vec![
594            NavEntry {
595                href: "./".into(),
596                label: "Home".into(),
597            },
598            NavEntry {
599                href: "modes.html".into(),
600                label: "Modes & Co".into(),
601            },
602        ]
603    }
604
605    #[test]
606    fn markdown_renders_headings_and_tables() {
607        let html = markdown_to_html("# Title\n\n| a | b |\n|---|---|\n| 1 | 2 |\n");
608        assert!(html.contains("<h1 id=\"title\">Title</h1>"), "{html}");
609        assert!(html.contains("<table>"));
610        assert!(html.contains("<td>1</td>"));
611    }
612
613    #[test]
614    fn adr_wiki_links_become_sibling_page_links() {
615        // An ADR-to-ADR wiki link resolves to the sibling .html; a code/file
616        // reference becomes inline code; both stop leaking literal `[[ ]]`.
617        let md = "See [[docs/adr/0001-build-roteiro.md]] and \
618                  [[crates/rto-graph/src/store.rs#Store]] here.\n";
619        let html = markdown_to_html(md);
620        assert!(
621            html.contains("<a href=\"0001-build-roteiro.html\">ADR-0001</a>"),
622            "ADR wiki-link → sibling page: {html}"
623        );
624        assert!(
625            html.contains("<code>crates/rto-graph/src/store.rs#Store</code>"),
626            "code reference → inline code: {html}"
627        );
628        assert!(
629            !html.contains("[["),
630            "no literal wiki brackets leak: {html}"
631        );
632    }
633
634    #[test]
635    fn wiki_links_inside_code_are_left_literal() {
636        // A documented example of the syntax, in backticks or a fence, must not
637        // be rewritten.
638        let inline = markdown_to_html("use `[[docs/adr/0001-x.md]]` in prose\n");
639        assert!(
640            inline.contains("<code>[[docs/adr/0001-x.md]]</code>"),
641            "{inline}"
642        );
643        let fenced = markdown_to_html("```\n[[docs/adr/0001-x.md]]\n```\n");
644        assert!(
645            fenced.contains("[[docs/adr/0001-x.md]]"),
646            "fence literal: {fenced}"
647        );
648    }
649
650    #[test]
651    fn multi_backtick_code_spans_are_honoured() {
652        // A tight double-backtick span (`` ``…`` ``) and the Build Plan's
653        // nested-backtick example must both survive verbatim — the previous
654        // single-backtick split rewrote the wiki-link inside them.
655        let tight = markdown_to_html("say ``[[docs/adr/0001-x.md]]`` please\n");
656        assert!(
657            tight.contains("<code>[[docs/adr/0001-x.md]]</code>"),
658            "{tight}"
659        );
660        assert!(!tight.contains("<a "), "no link inside code span: {tight}");
661
662        let nested = markdown_to_html("its `` `[[path#Symbol]]` `` example\n");
663        assert!(
664            nested.contains("<code>`[[path#Symbol]]`</code>"),
665            "{nested}"
666        );
667        assert!(
668            !nested.contains("<a "),
669            "no link inside nested span: {nested}"
670        );
671
672        // An unterminated run is literal and does not shield a later real link.
673        let stray = markdown_to_html("a ` stray tick then [[docs/adr/0001-x.md]]\n");
674        assert!(
675            stray.contains("<a href=\"0001-x.html\">ADR-0001</a>"),
676            "unterminated backtick must not shield: {stray}"
677        );
678    }
679
680    #[test]
681    fn markdown_md_links_are_rewritten_to_html() {
682        // Ordinary `[text](path.md)` links must point at the rendered `.html`,
683        // preserving fragments; external and anchor links are left alone.
684        let html = markdown_to_html(
685            "See [ADR-1](adr/0001-x.md) and [§2](adr/0001-x.md#context) and \
686             [home](https://x.dev) and [top](#intro).\n",
687        );
688        assert!(html.contains("href=\"adr/0001-x.html\""), "{html}");
689        assert!(html.contains("href=\"adr/0001-x.html#context\""), "{html}");
690        assert!(
691            html.contains("href=\"https://x.dev\""),
692            "external unchanged: {html}"
693        );
694        assert!(html.contains("href=\"#intro\""), "anchor unchanged: {html}");
695        assert!(!html.contains(".md\""), "no raw .md hrefs remain: {html}");
696    }
697
698    #[test]
699    fn render_doc_links_adrs_into_subdir() {
700        // A root-level lifetime doc (Build Plan) resolves ADR links into `adr/`.
701        let r = render_doc(
702            "# Build Plan\n\nGoverned by [[docs/adr/0001-x.md]].\n",
703            "Build Plan",
704            &no_pages(),
705        );
706        assert_eq!(r.title, "Build Plan");
707        assert!(
708            r.html.contains("<a href=\"adr/0001-x.html\">ADR-0001</a>"),
709            "root doc → adr/ prefix: {}",
710            r.html
711        );
712        // Root-level chrome: assets/back-link relative to site root.
713        assert!(r.html.contains("href=\"./style.css\""));
714        // Full favicon set — root-relative from the site root.
715        assert!(r.html.contains("href=\"./favicon.svg\""));
716        assert!(r.html.contains("href=\"./favicon.ico\""));
717        assert!(
718            r.html
719                .contains("rel=\"apple-touch-icon\" href=\"./apple-touch-icon.png\"")
720        );
721    }
722
723    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";
724
725    #[test]
726    fn render_adr_strips_frontmatter_and_themes() {
727        let r = render_adr(ADR, "fallback", &no_pages());
728        assert_eq!(r.title, "ADR-0001: Example");
729        // Frontmatter is gone; heading + section rendered.
730        assert!(!r.html.contains("adr-id"));
731        assert!(
732            r.html
733                .contains("<h1 id=\"adr-0001-example\">ADR-0001: Example</h1>")
734        );
735        // The section anchor matches the section's node key (`adr:0001#context`),
736        // so a link through the graph lands on the heading in the browser.
737        assert!(r.html.contains("<h2 id=\"context\">Context</h2>"));
738        assert!(r.html.contains("<code>code</code>"));
739        // Themed chrome present.
740        assert!(
741            r.html
742                .contains("<link rel=\"stylesheet\" href=\"../style.css\">")
743        );
744        // Full favicon set (SVG + `.ico` fallback for browsers without SVG-favicon
745        // support, e.g. Safari) — root-relative from a sub-page.
746        assert!(r.html.contains("href=\"../favicon.svg\""));
747        assert!(r.html.contains("href=\"../favicon.ico\""));
748        assert!(
749            r.html
750                .contains("rel=\"apple-touch-icon\" href=\"../apple-touch-icon.png\"")
751        );
752        assert!(r.html.contains("← Roteiro home"));
753        assert!(r.html.contains("← Back to roteiro.dev"));
754        assert!(r.html.starts_with("<!doctype html>"));
755    }
756
757    #[test]
758    fn render_adr_falls_back_without_h1() {
759        let r = render_adr("no frontmatter, no heading\n", "slug-name", &no_pages());
760        assert_eq!(r.title, "slug-name");
761    }
762
763    #[test]
764    fn index_lists_entries_and_escapes() {
765        let entries = [
766            IndexEntry {
767                href: "0001-x.html".into(),
768                title: "First & <best>".into(),
769            },
770            IndexEntry {
771                href: "0002-y.html".into(),
772                title: "Second".into(),
773            },
774        ];
775        let lifetime = [IndexEntry {
776            href: "../build-plan.html".into(),
777            title: "Build Plan".into(),
778        }];
779        let html = render_adr_index(&lifetime, &entries);
780        assert!(html.contains("<a href=\"../build-plan.html\">Build Plan</a>"));
781        assert!(html.contains("<a href=\"0001-x.html\">First &amp; &lt;best&gt;</a>"));
782        assert!(html.contains("<a href=\"0002-y.html\">Second</a>"));
783        // First entry precedes second (order preserved).
784        assert!(html.find("0001-x").unwrap() < html.find("0002-y").unwrap());
785        // Lifetime docs listed before the ADRs.
786        assert!(html.find("build-plan").unwrap() < html.find("0001-x").unwrap());
787    }
788
789    #[test]
790    fn an_explicit_anchor_survives_the_split_that_moved_its_section() {
791        // The hazard this mechanism exists for. The old single-page site
792        // published `#modes`, `#crossrepo`, `#remote-tier` — short, hand-chosen
793        // ids that no heading text slugifies to. External links point at them and
794        // cannot be updated, so a page that inherits a section must be able to
795        // inherit its anchor verbatim.
796        let html = markdown_to_html(
797            "## The five ways to run it {#modes}\n\n## Cross-repo: a hub and its spokes {#crossrepo}\n",
798        );
799        assert!(
800            html.contains("<h2 id=\"modes\">The five ways to run it</h2>"),
801            "{html}"
802        );
803        assert!(
804            html.contains("<h2 id=\"crossrepo\">Cross-repo: a hub and its spokes</h2>"),
805            "{html}"
806        );
807        // The attribute is markup, not part of the heading's text.
808        assert!(!html.contains("{#"), "no literal attribute leaks: {html}");
809    }
810
811    #[test]
812    fn generated_anchors_match_the_graph_s_section_keys_and_stay_unique() {
813        // `rto_spec` builds `<doc>#<slugify(heading)>` section keys from the same
814        // function, so a link that resolves in the graph lands on the heading.
815        let html = markdown_to_html("## Install & build\n\n## Install & build\n\n## ###\n");
816        assert!(html.contains("id=\"install-build\""), "{html}");
817        // A repeat is suffixed rather than duplicated: two elements sharing an
818        // `id` makes one of them unreachable.
819        assert!(html.contains("id=\"install-build-2\""), "{html}");
820        // A heading that slugifies to nothing still gets a usable anchor.
821        assert!(html.contains("id=\"section-3\""), "{html}");
822    }
823
824    #[test]
825    fn inline_code_counts_as_heading_text() {
826        // The old page's headings look like `What <code>init</code> sets up`.
827        // Dropping the code span would slugify only the prose around it and give
828        // the section an anchor nobody would guess.
829        let html = markdown_to_html("### What `init` sets up\n");
830        assert!(
831            html.contains("<h3 id=\"what-init-sets-up\">"),
832            "code span is part of the heading's text: {html}"
833        );
834    }
835
836    #[test]
837    fn a_hash_inside_a_fence_is_not_a_heading() {
838        // The id list is computed from a real parse, so fenced content cannot
839        // shift every subsequent heading's anchor by one.
840        let html = markdown_to_html("```\n## Not a heading\n```\n\n## Real\n");
841        assert!(html.contains("<h2 id=\"real\">Real</h2>"), "{html}");
842    }
843
844    #[test]
845    fn a_heading_s_anchor_never_reaches_the_title() {
846        // Issue #460, live on roteiro.dev: every page the site split moved
847        // carries `{#…}` on its H1, and the title was read off the raw line.
848        let r = render_site_page(
849            "---\nsite-page: modes\n---\n\n# The five ways to run it {#modes}\n\nBody.\n",
850            "fallback",
851            &nav(),
852            "modes.html",
853            &no_pages(),
854        );
855        // The heading was always right; the title is the side that was wrong.
856        assert!(
857            r.html
858                .contains("<h1 id=\"modes\">The five ways to run it</h1>"),
859            "{}",
860            r.html
861        );
862        assert_eq!(r.title, "The five ways to run it");
863        assert!(
864            r.html
865                .contains("<title>The five ways to run it — Roteiro</title>"),
866            "{}",
867            r.html
868        );
869        // The most-seen string a page has: the tab, the bookmark, the search
870        // result, the social preview. Nothing of the attribute survives anywhere.
871        assert!(
872            !r.html.contains("{#"),
873            "no literal attribute leaks: {}",
874            r.html
875        );
876    }
877
878    #[test]
879    fn the_same_holds_for_an_adr_and_for_a_root_level_doc() {
880        // One extractor serves all three renderers, so all three are checked:
881        // a fix that reached only the page the issue named would leave the ADR
882        // index quoting `{#…}` back at the reader.
883        let adr = render_adr("# ADR-0001: Example {#adr1}\n", "slug", &no_pages());
884        assert_eq!(adr.title, "ADR-0001: Example");
885        assert!(
886            adr.html
887                .contains("<title>ADR-0001: Example — Roteiro</title>"),
888            "{}",
889            adr.html
890        );
891        let doc = render_doc(
892            "# Roteiro — Build Plan {#plan}\n",
893            "Build Plan",
894            &no_pages(),
895        );
896        assert_eq!(doc.title, "Roteiro — Build Plan");
897        assert!(!doc.html.contains("{#"), "{}", doc.html);
898    }
899
900    #[test]
901    fn a_title_that_legitimately_spells_the_anchor_syntax_keeps_it() {
902        // The other half of the rule, and the reason the fix is a parse and not
903        // a strip: `{#…}` is an attribute only where the dialect says it is, and
904        // a rule spelled out by hand does not know where that is. Inside a code
905        // span it is prose, and a stripper blind to code spans mangles a page
906        // whose subject *is* this syntax — which is most of the pages that
907        // document it.
908        let coded = render_doc(
909            "# Why `{#anchor}` outlives a restructure\n",
910            "fallback",
911            &no_pages(),
912        );
913        assert_eq!(coded.title, "Why {#anchor} outlives a restructure");
914        assert!(
915            coded
916                .html
917                .contains("<title>Why {#anchor} outlives a restructure — Roteiro</title>"),
918            "{}",
919            coded.html
920        );
921        // Mid-heading and uncoded, it is still prose: an attribute block is
922        // trailing or it is nothing.
923        let mid = render_doc(
924            "# Anchors are written {#id}, in prose\n",
925            "fallback",
926            &no_pages(),
927        );
928        assert_eq!(mid.title, "Anchors are written {#id}, in prose");
929    }
930
931    #[test]
932    fn the_title_and_the_heading_never_disagree() {
933        // The invariant underneath #460, stated directly. Where the attribute
934        // block ends is the dialect's call, not this module's — braces the
935        // parser eats are gone from *both* surfaces, braces it keeps are on
936        // both. Reading the title from the same parse is what makes that true by
937        // construction rather than by two rules that happen to match today.
938        for md in [
939            "# The five ways to run it {#modes}\n",
940            "# Why `{#anchor}` outlives a restructure\n",
941            "# Anchors are written {#id}, in prose\n",
942            "# Install & build {#build}\n",
943            "# What `init` sets up\n",
944            "# Sets like {#1, #2}\n",
945        ] {
946            let r = render_doc(md, "fallback", &no_pages());
947            let inner = r
948                .html
949                .split_once("<h1")
950                .and_then(|(_, rest)| rest.split_once('>'))
951                .and_then(|(_, rest)| rest.split_once("</h1>"))
952                .map(|(text, _)| text.to_owned())
953                .unwrap_or_default();
954            // The heading carries inline markup (`<code>`, emphasis); the title
955            // is the words inside it. Dropping the tags — and nothing else, so
956            // entities still have to match — is what makes them comparable.
957            let mut heading = String::new();
958            let mut depth = 0usize;
959            for c in inner.chars() {
960                match c {
961                    '<' => depth += 1,
962                    '>' => depth = depth.saturating_sub(1),
963                    _ if depth == 0 => heading.push(c),
964                    _ => {}
965                }
966            }
967            assert_eq!(
968                heading,
969                escape_html(&r.title),
970                "title and heading disagree for {md:?}: {}",
971                r.html
972            );
973        }
974    }
975
976    #[test]
977    fn the_title_is_the_heading_the_reader_sees() {
978        // Inline markup contributes its text, not its punctuation — the same
979        // rule the heading's own id already follows.
980        let code = render_doc("# What `init` sets up\n", "fallback", &no_pages());
981        assert_eq!(code.title, "What init sets up");
982        // A line scan called this document's title `Not a title`; the parser
983        // knows a fenced hash is not a heading at all.
984        let fenced = render_doc(
985            "```\n# Not a title\n```\n\n# The real one\n",
986            "fallback",
987            &no_pages(),
988        );
989        assert_eq!(fenced.title, "The real one");
990        // And a heading spelled the other way is still a heading: the page shows
991        // an `<h1>`, so the tab has to show its words rather than the file stem.
992        let setext = render_doc("Underlined\n==========\n", "fallback", &no_pages());
993        assert!(
994            setext.html.contains("<h1 id=\"underlined\">"),
995            "{}",
996            setext.html
997        );
998        assert_eq!(setext.title, "Underlined");
999    }
1000
1001    #[test]
1002    fn a_document_with_no_h1_falls_back_and_the_fallback_is_used_verbatim() {
1003        // The fallback is the caller's string, not markdown: it is never parsed,
1004        // so it cannot be stripped and cannot leak markup it does not contain.
1005        // Callers pass a file stem or a declared slug.
1006        let none = render_site_page(
1007            "---\nsite-page: modes\n---\n\nNo heading at all.\n",
1008            "The five ways to run it",
1009            &nav(),
1010            "modes.html",
1011            &no_pages(),
1012        );
1013        assert_eq!(none.title, "The five ways to run it");
1014        assert!(
1015            none.html
1016                .contains("<title>The five ways to run it — Roteiro</title>"),
1017            "{}",
1018            none.html
1019        );
1020        // An H1 with nothing in it names nothing, so it defers to the fallback
1021        // rather than emitting `<title> — Roteiro</title>`.
1022        let empty = render_doc("#\n\nBody.\n", "build-plan", &no_pages());
1023        assert_eq!(empty.title, "build-plan");
1024        // A lower heading is not the document's title.
1025        let sub = render_doc("## Only a section {#s}\n", "build-plan", &no_pages());
1026        assert_eq!(sub.title, "build-plan");
1027    }
1028
1029    #[test]
1030    fn a_site_page_carries_the_bar_with_itself_marked() {
1031        let r = render_site_page(
1032            "---\nsite-page: modes\n---\n\n# The five ways to run it\n\nSee [[docs/adr/0019-remote.md]].\n",
1033            "fallback",
1034            &nav(),
1035            "modes.html",
1036            &no_pages(),
1037        );
1038        assert_eq!(r.title, "The five ways to run it");
1039        // Frontmatter is chrome for the graph, not content for the reader.
1040        assert!(!r.html.contains("site-page"), "{}", r.html);
1041        // The current page is unlinked and marked; its neighbour is a link.
1042        assert!(
1043            r.html
1044                .contains("<span aria-current=\"page\">Modes &amp; Co</span>"),
1045            "{}",
1046            r.html
1047        );
1048        assert!(r.html.contains("<a href=\"./\">Home</a>"), "{}", r.html);
1049        // A root-level page: assets and ADR links resolve from the site root.
1050        assert!(r.html.contains("href=\"./style.css\""), "{}", r.html);
1051        assert!(
1052            r.html
1053                .contains("<a href=\"adr/0019-remote.html\">ADR-0019</a>"),
1054            "{}",
1055            r.html
1056        );
1057    }
1058
1059    #[test]
1060    fn the_bar_is_plain_anchors_and_escapes_its_labels() {
1061        let bar = render_nav(&nav(), "nothing.html");
1062        assert!(bar.starts_with("<nav class=\"sitenav\">"), "{bar}");
1063        // Nothing marked when the current page is not in the bar — a preview of
1064        // an unlisted page, not an error.
1065        assert!(!bar.contains("aria-current"), "{bar}");
1066        assert!(bar.contains("Modes &amp; Co"), "escaped label: {bar}");
1067        // No script: the site has no build step and this must not introduce one.
1068        assert!(!bar.contains("<script"), "{bar}");
1069    }
1070
1071    #[test]
1072    fn a_link_resolves_to_the_page_the_site_actually_serves() {
1073        // Issue #446: four ADRs link `../BUILD_PLAN_V2.md`, which is correct in
1074        // the repository. Published under a `site-page:` slug, that document is
1075        // served as `build-plan-v2.html` — so rewriting the link to its own stem
1076        // aims it at a page that is never emitted.
1077        let mut pages = PublishedPages::new();
1078        pages.publish("BUILD_PLAN_V2.md", "build-plan-v2.html");
1079        let html = render_markdown("See [V2](../BUILD_PLAN_V2.md).\n", "", &pages);
1080        assert!(
1081            html.contains("href=\"../build-plan-v2.html\""),
1082            "served name, and the link's own hop kept: {html}"
1083        );
1084        // A fragment survives the substitution.
1085        let frag = render_markdown("[s](../BUILD_PLAN_V2.md#stage-21)\n", "", &pages);
1086        assert!(
1087            frag.contains("href=\"../build-plan-v2.html#stage-21\""),
1088            "{frag}"
1089        );
1090        // An unpublished document still falls back to its stem, unchanged.
1091        let other = render_markdown("[x](../REVIEW_CHECKLIST.md)\n", "", &pages);
1092        assert!(
1093            other.contains("href=\"../REVIEW_CHECKLIST.html\""),
1094            "{other}"
1095        );
1096    }
1097
1098    #[test]
1099    fn a_file_name_two_documents_claim_is_left_alone() {
1100        // Guessing which one a link meant would silently point it at the wrong
1101        // page — worse than the 404 the lookup exists to remove.
1102        let mut pages = PublishedPages::new();
1103        pages.publish("GUIDE.md", "guide.html");
1104        pages.publish("GUIDE.md", "other-guide.html");
1105        let html = render_markdown("[g](GUIDE.md)\n", "", &pages);
1106        assert!(html.contains("href=\"GUIDE.html\""), "unrewritten: {html}");
1107        // Re-publishing the *same* target is not a conflict.
1108        let mut same = PublishedPages::new();
1109        same.publish("GUIDE.md", "guide.html");
1110        same.publish("GUIDE.md", "guide.html");
1111        let html = render_markdown("[g](GUIDE.md)\n", "", &same);
1112        assert!(html.contains("href=\"guide.html\""), "{html}");
1113    }
1114
1115    #[test]
1116    fn site_pages_render_deterministically() {
1117        let md = "---\nsite-page: a\n---\n\n# A\n\n## S\n";
1118        assert_eq!(
1119            render_site_page(md, "f", &nav(), "a.html", &no_pages()),
1120            render_site_page(md, "f", &nav(), "a.html", &no_pages())
1121        );
1122    }
1123
1124    #[test]
1125    fn rendering_is_deterministic() {
1126        assert_eq!(
1127            render_adr(ADR, "f", &no_pages()),
1128            render_adr(ADR, "f", &no_pages())
1129        );
1130    }
1131}