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, so
33/// `docs/history/BUILD_PLAN_V2.md` is served as whatever its slug says. The
34/// rewrite then pointed four correct repository links at a page that is never
35/// emitted — issue #446, live on roteiro.dev.
36///
37/// A slug may also name a **path** — `history/build-plan-v2` serves at
38/// `/history/build-plan-v2.html` — so a served name is not necessarily a bare
39/// filename. When it contains a `/` it is a path from the site root, and
40/// [`rewrite_doc_link`] replaces the link's own directory hops with the climb
41/// back to the root rather than keeping them; keeping them doubled the
42/// directory. A bare served name still keeps the link's hops, which is what
43/// every ADR-to-ADR link depends on.
44///
45/// So the served name is *looked up* rather than guessed. The renderer is handed
46/// the index of what the site emits, which is the only thing that knows the
47/// answer.
48///
49/// Keyed by file name rather than by full path because the site mirrors the
50/// repository's layout — `docs/*.md` at the root, `docs/adr/*.md` under `adr/` —
51/// so a link's directory hops are already correct and only the final segment can
52/// differ. A file name claimed by two published documents is recorded as
53/// **ambiguous** and left unrewritten: guessing which one a link meant is how a
54/// link silently points at the wrong page, which is worse than the 404 it
55/// replaces.
56#[derive(Debug, Default, Clone, PartialEq, Eq)]
57pub struct PublishedPages(BTreeMap<String, Option<String>>);
58
59impl PublishedPages {
60    /// An empty index: every `.md` link falls back to its own stem.
61    #[must_use]
62    pub fn new() -> Self {
63        Self::default()
64    }
65
66    /// Record that `source_file` (a markdown file name, e.g. `BUILD_PLAN_V2.md`)
67    /// is served as `served_as` (e.g. `build-plan-v2.html`).
68    ///
69    /// A second, differing claim on one file name makes it ambiguous; see the
70    /// type's documentation for why that is left unrewritten.
71    pub fn publish(&mut self, source_file: &str, served_as: &str) {
72        self.0
73            .entry(source_file.to_owned())
74            .and_modify(|slot| {
75                if slot.as_deref() != Some(served_as) {
76                    *slot = None;
77                }
78            })
79            .or_insert_with(|| Some(served_as.to_owned()));
80    }
81
82    /// The file `source_file` is served as, or `None` when it is unknown or
83    /// ambiguous.
84    fn served(&self, source_file: &str) -> Option<&str> {
85        self.0.get(source_file)?.as_deref()
86    }
87}
88
89/// Where a link that leaves the site points instead: the repository's own web
90/// view, at the commit the site was built from.
91///
92/// The Build Plan cites code as evidence for its claims — `[sync](../crates/…
93/// /sync.rs)` — which is correct in a checkout and dead on roteiro.dev, because
94/// `render docs` publishes documents and not source. Six such links were live on
95/// the site (issue #456). This is the answer chosen for them: keep the link's
96/// affordance and move its target to the one place the file is actually served.
97///
98/// # Pinned to a commit, not to a branch
99///
100/// `blob` carries a sha (`…/blob/<sha>`), not `…/blob/main`. GitHub serves a
101/// blob by sha forever, so the link keeps resolving after the file is renamed or
102/// deleted; a `main` link 404s on the next rename, and this is a *retired* plan
103/// whose citations describe the code as it stood, so drifting them onto today's
104/// `main` would be wrong even when it resolved. It is also the rule the vault
105/// renderer already ships (`source_blob_base` + `head_commit_id`), and one repo
106/// with two answers to "which commit does a source link mean" is its own defect.
107///
108/// The cost is stated rather than hidden: a site rendered from a commit that was
109/// never pushed yields links the host has never heard of. That is a local
110/// preview, not the published site — the Website workflow renders from a commit
111/// GitHub already has.
112///
113/// # No mappable origin
114///
115/// Construction goes through [`SourceBase::new`], which yields `None` when the
116/// caller has no blob base to offer — no `origin` remote, or a remote whose URL
117/// does not map to a web view. Links are then left exactly as they are: still
118/// correct in a checkout, still dead on the site. That is deliberate and is the
119/// least-bad of the three: refusing to render would break `render docs` in any
120/// repository without an `origin` (every test fixture, every fresh `git init`),
121/// and demoting the link to plain text would destroy information to hide a
122/// problem the reader can otherwise route around.
123///
124/// # Can the class recur?
125///
126/// Not while a base exists: the rule is structural, not a list of the six links
127/// that were found. *Any* link climbing above the site root is re-aimed, so a new
128/// citation added to any rendered document is handled the day it is written, and
129/// `a_link_out_of_the_site_goes_to_the_repository` is what fails if that stops.
130///
131/// It recurs silently in exactly one case — a site rendered where no base can be
132/// derived — and nothing catches that, because the output is the *authored* link
133/// and there is no rendered-site link gate (issue #459) to notice. That case is
134/// the one the deploy does not hit: the Website workflow checks out with an
135/// `origin` on `github.com`, and `without_an_origin_remote_a_source_link_is_left_as_authored`
136/// pins the behaviour rather than the absence of a gate.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct SourceBase {
139    /// Web blob base, no trailing slash — e.g.
140    /// `https://github.com/owner/repo/blob/<sha>`.
141    blob: String,
142    /// The rendered document's own directory, repository-relative and with no
143    /// trailing slash: `docs`, `docs/adr`, `website/pages`. A link is resolved
144    /// against this to get the path the repository serves.
145    dir: String,
146}
147
148impl SourceBase {
149    /// A source base for a document at repository-relative directory `dir`,
150    /// served from `blob`. `None` when `blob` is `None` — see the type's
151    /// documentation for why that leaves links alone rather than failing.
152    #[must_use]
153    pub fn new(blob: Option<&str>, dir: &str) -> Option<Self> {
154        Some(Self {
155            blob: blob?.trim_end_matches('/').to_owned(),
156            dir: dir.trim_matches('/').to_owned(),
157        })
158    }
159
160    /// The web URL for `path` — a link written relative to this document —
161    /// carrying `frag` through unchanged (`#L12` is a GitHub line anchor, and
162    /// the site has no better guess than the author's).
163    ///
164    /// `None` when `path` climbs out of the repository altogether, which no base
165    /// can name.
166    fn blob_url(&self, path: &str, frag: Option<&str>) -> Option<String> {
167        let joined = format!("{}/{}", self.dir, path);
168        let (up, segs) = resolve_relative(&joined);
169        if up > 0 || segs.is_empty() {
170            return None;
171        }
172        let repo_path = segs.join("/");
173        Some(match frag {
174            Some(frag) => format!("{}/{repo_path}#{frag}", self.blob),
175            None => format!("{}/{repo_path}", self.blob),
176        })
177    }
178}
179
180/// One page in the site navigation bar: where it goes and what it is called.
181///
182/// Built by the caller from the authored site pages (`rto_spec::site_nav` puts
183/// them in order), and passed to [`render_site_page`] whole so every page emits
184/// the *same* bar. A per-page bar assembled independently is a bar that can
185/// disagree with itself, which is how a page ends up unreachable from its
186/// neighbours.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct NavEntry {
189    /// Root-relative href (e.g. `modes.html`, or `./` for the landing page).
190    pub href: String,
191    /// Short label shown in the bar.
192    pub label: String,
193}
194
195/// An entry in the ADR/docs index page.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct IndexEntry {
198    /// Relative href (e.g. `0001-….html`).
199    pub href: String,
200    /// Display title.
201    pub title: String,
202}
203
204/// Convert `CommonMark` `md` to an HTML fragment (GitHub tables + strikethrough,
205/// and Roteiro `[[wiki-links]]` resolved). Resolves ADR links relative to the
206/// ADR directory; use [`render_doc`] for root-level pages.
207///
208/// A fragment renderer has no site to be a part of, so it carries neither
209/// [`PublishedPages`] nor [`SourceBase`]: a `.md` link is rewritten to its own
210/// stem, which is right for an ADR and a guess for anything published under a
211/// slug, and a link out of the site is left alone.
212#[must_use]
213pub fn markdown_to_html(md: &str) -> String {
214    render_markdown(md, "", &PublishedPages::new(), None, 0)
215}
216
217/// Render `md` to HTML: resolve `[[wiki-links]]` (ADR links use `adr_prefix` as
218/// their href prefix), rewrite ordinary `[…](*.md)` links to their rendered
219/// `.html` targets and links out of the site to `source`, then run `CommonMark`
220/// with GitHub tables/strikethrough. `depth` is the page's own depth below the
221/// site root; see [`rewrite_doc_link`].
222fn render_markdown(
223    md: &str,
224    adr_prefix: &str,
225    pages: &PublishedPages,
226    source: Option<&SourceBase>,
227    depth: usize,
228) -> String {
229    let pre = rewrite_wiki_links(md, adr_prefix);
230    let ids = heading_ids(&pre);
231    let mut next_id = 0usize;
232    // Rewrite link destinations pointing at local Markdown files to the HTML the
233    // site actually serves (e.g. `adr/0001-….md` → `adr/0001-….html`), and give
234    // every heading a stable `id` so it can be linked to.
235    let parser = Parser::new_ext(&pre, options()).map(|event| match event {
236        Event::Start(Tag::Link {
237            link_type,
238            dest_url,
239            title,
240            id,
241        }) => Event::Start(Tag::Link {
242            link_type,
243            dest_url: rewrite_doc_link(&dest_url, pages, source, depth)
244                .map_or(dest_url, CowStr::from),
245            title,
246            id,
247        }),
248        Event::Start(Tag::Heading {
249            level,
250            classes,
251            attrs,
252            ..
253        }) => {
254            let id = ids.get(next_id).cloned().map(CowStr::from);
255            next_id += 1;
256            Event::Start(Tag::Heading {
257                level,
258                id,
259                classes,
260                attrs,
261            })
262        }
263        other => other,
264    });
265    let mut out = String::new();
266    html::push_html(&mut out, parser);
267    out
268}
269
270/// The `CommonMark` dialect the whole site is parsed with — [`rto_graph`]'s, not
271/// a copy of it.
272///
273/// This used to build its own `Options` with the same three flags. It was the
274/// same set by agreement rather than by construction, which is the arrangement
275/// [`rto_graph::markdown_dialect`] exists to end: a different option set is a
276/// different language, and two parsers that disagree about it do not fail — they
277/// quietly disagree about where a heading's text ends, which is the defect #469
278/// was. That crate could not finish the consolidation while this file was held
279/// open by the work in #456/#457/#508; this is the remaining half.
280///
281/// What the dialect buys *this* renderer, and why the heading-attribute flag in
282/// particular is load-bearing here: **heading attributes are how a URL outlives a
283/// restructure.** A page split out of the old single-page site keeps the anchor
284/// the old page published — the heading declares `{#modes}` and lands at
285/// `#modes` — instead of silently becoming whatever the new heading text happens
286/// to slugify to. External links point at those anchors and cannot be updated, so
287/// the alternative is not a tidier URL; it is a dead one.
288fn options() -> Options {
289    // Keep this body a single delegation. It is the shape that invites a
290    // "just for rendering" flag, and the shape where adding one leaves nothing
291    // to notice it by — the duplicate that used to sit here is gone, so a
292    // divergence introduced now is invisible rather than merely unnoticed.
293    //
294    // A flag this renderer sets and `rto-graph`'s extractors do not is a flag
295    // that changes what a heading's text *is* on one surface and not the other.
296    // That is the #469 defect again, with the evidence removed.
297    //
298    // So a renderer-only flag belongs in `markdown_dialect` or nowhere, and "or
299    // nowhere" is not rhetoric: the claim that some future flag cannot reach
300    // heading text is an argument, not an observation, and an argument belongs
301    // in `rto-graph` beside the flag it licenses, where every surface reads it.
302    //
303    // `the_dialect_is_not_extended_here` fails if this body grows a flag.
304    rto_graph::markdown_dialect()
305}
306
307/// The `id` for every heading in `md`, in document order.
308///
309/// An explicit `{#anchor}` wins; otherwise the id is [`rto_graph::slugify`] of
310/// the heading text. Both branches go through [`rto_graph::heading_id_from`] —
311/// the *same* function `rto_spec` builds the section's node key with, for ADRs,
312/// blueprints and site pages alike — so an authored link to `site:modes#offline`
313/// lands on the heading the graph says it does.
314///
315/// That sentence used to be here and was only half true: this honoured an
316/// explicit `{#anchor}` and `rto_spec` slugified the heading text regardless, so
317/// a heading that declared its own address had one id in the page and a
318/// different key in the graph (#524). The claim is now enforced by construction
319/// rather than asserted in prose, which is the difference that matters.
320///
321/// The two **document-level** rules — a heading whose id would be empty
322/// (`## ###`) falls back to its position, and a repeat gets a `-2`, `-3`, …
323/// suffix — used to stay here, on the argument that only a renderer emits
324/// elements and so only a renderer can have two share an `id`. They are in
325/// [`rto_graph::headings`] now, because that argument was wrong in its
326/// consequence: `rto_spec` did neither, so `## A {#same}` / `## B {#same}`
327/// rendered two anchors and upserted into **one** graph node (#629).
328///
329/// Computed from a *first parse* rather than a line scan: heading text can be
330/// spread over several inline events, and `#` inside a fenced block is not a
331/// heading at all. Parsing twice costs a document-sized pass and cannot be wrong
332/// about what the renderer will see, because it is the same parser — and now
333/// literally the same call, so "the same parser" has stopped being a claim.
334///
335/// # This rule is not GitHub's, and deliberately stays that way
336///
337/// A document in `docs/` is read in two places under two slug rules: GitHub
338/// renders `**v0.10.x**` as `v010x` and this renders it as `v0-10-x`, so an
339/// anchor hand-written against one is dead in the other. That is real, and it is
340/// the *second* half of issue #457 — the six anchors that issue found were dead
341/// under **both** rules, because the heading text had changed under them.
342///
343/// It is not fixed by aligning this rule to GitHub's, and that is not a
344/// deferral. [`rto_graph::slugify`] is the *one* rule, shared on purpose:
345/// `rto_spec` builds every section node key with it (`adr:0001#design`,
346/// `site:modes#offline-mode`) and this builds the matching `id`, which is the
347/// only reason a `[[doc#section]]` wiki-link resolves through one and lands
348/// through the other. Re-keying it to GitHub's would re-key every section node in
349/// the graph and break every authored wiki-link `roteiro check` gates — to fix
350/// anchors in one retired document. rustdoc is a *third* rule already in play,
351/// so there is no single rule to converge on in any case.
352///
353/// Note what does **not** protect this: #397's guard (`doc_anchor_fragments.rs`)
354/// replicates rustdoc's rule in its own private `slugify` and never calls
355/// [`rto_graph::slugify`], so changing this rule would not have made it fail. It
356/// is not a guard on this code path, and treating it as one was the mistake worth
357/// recording here.
358///
359/// So the divergence is left, documented, and the affected anchors were given
360/// explicit ids that neither rule touches (see `docs/history/BUILD_PLAN.md`). **Nothing
361/// currently checks an intra-document anchor** in either rendering — not
362/// `roteiro check`, not this crate. Issue #459 is where that check belongs.
363fn heading_ids(md: &str) -> Vec<String> {
364    // Keep this body a single delegation, for the reason [`options`] gives about
365    // the dialect. This used to parse the document itself and own two rules the
366    // graph did not have — the `section-N` fallback for a heading that names
367    // nothing, and the `-2` suffix for one whose id is taken. Owning them here
368    // was justified on the grounds that only a renderer emits elements and so
369    // only a renderer can have two share an `id`. What it actually produced was
370    // #629: this emitted `same` and `same-2` while `rto_spec` keyed both sections
371    // `same` and upserted one over the other, so the graph's surviving node named
372    // a place the page addressed as something else.
373    //
374    // Both rules live in `rto_graph::headings` now, applied over **every** level
375    // — which is the half a local dedup cannot reproduce, because `rto_spec`
376    // records only `##` and `# Same` before `## Same` still pushes the h2 to
377    // `same-2` here.
378    rto_graph::headings(md).into_iter().map(|h| h.id).collect()
379}
380
381/// Split a relative path into the number of hops it takes **above** its own
382/// directory and the segments that remain, resolving `.` and `..` the way a
383/// browser and a filesystem both do.
384///
385/// `../crates/x.rs` is `(1, ["crates", "x.rs"])`; `adr/../guide.md` is
386/// `(0, ["guide.md"])`. The hop count is the whole escape test in
387/// [`rewrite_doc_link`]: a link that climbs further than the page sits below the
388/// site root is a link to something outside the site.
389fn resolve_relative(path: &str) -> (usize, Vec<&str>) {
390    let mut up = 0usize;
391    let mut segs: Vec<&str> = Vec::new();
392    for seg in path.split('/') {
393        match seg {
394            "" | "." => {}
395            ".." => {
396                if segs.pop().is_none() {
397                    up += 1;
398                }
399            }
400            s => segs.push(s),
401        }
402    }
403    (up, segs)
404}
405
406/// Rewrite a relative link so it points at what the **site** serves, preserving
407/// any `#fragment`. Returns `None` for external, protocol-relative, `mailto:`,
408/// pure-anchor and root-relative links, and for anything the site already serves
409/// under the spelling the link uses — all of which are left unchanged.
410///
411/// `depth` is how far below the site root the page being rendered sits: 0 for a
412/// root-level page, 1 for an ADR under `adr/`. It is supplied by the entry point
413/// rather than by the caller, because the entry point is the thing that knows.
414///
415/// Two rewrites live here, and the order between them is the interesting part:
416///
417/// * a `.md` link is aimed at the page the site publishes it as
418///   ([`PublishedPages`]) — checked **first**, so a document that happens to be
419///   reached by a path climbing out of its own directory still lands on its
420///   published page rather than being treated as unpublished (issue #446);
421/// * a link that climbs above the site root and is *not* published is aimed at
422///   the repository's web view ([`SourceBase`]) — issue #456.
423fn rewrite_doc_link(
424    dest: &str,
425    pages: &PublishedPages,
426    source: Option<&SourceBase>,
427    depth: usize,
428) -> Option<String> {
429    if dest.starts_with("http://")
430        || dest.starts_with("https://")
431        || dest.starts_with("//")
432        || dest.starts_with("mailto:")
433        || dest.starts_with('#')
434        || dest.starts_with('/')
435    {
436        return None;
437    }
438    let (path, frag) = dest
439        .split_once('#')
440        .map_or((dest, None), |(p, f)| (p, Some(f)));
441    // Split so the two halves can be recombined differently depending on what the
442    // site serves. This *used* to be the whole rule — only the final segment could
443    // differ between repository and site, so the link's own directory hops were
444    // kept verbatim — and it is still what happens for a served name without a
445    // directory. It is no longer true in general: a slug may name a path, and for
446    // those the hops are replaced rather than kept. See the published-page branch
447    // below, and [`PublishedPages`].
448    let (dir, file) = path.rsplit_once('/').map_or(("", path), |(d, f)| (d, f));
449    // `strip_suffix` rather than `ends_with`: the extension is matched exactly as
450    // it is written, which is the rule every document in this repository follows.
451    let is_markdown = path.strip_suffix(".md").is_some();
452    let sep = if dir.is_empty() { "" } else { "/" };
453
454    // A page the site publishes, under the name it publishes it as (issue #446).
455    // First, so a document reached by a path that climbs out of its own
456    // directory still lands on its page rather than being read as unpublished.
457    if let Some(served) = is_markdown.then(|| pages.served(file)).flatten() {
458        // A served name containing `/` is a path from the **site root**
459        // (`history/build-plan-v2.html`), not a sibling of whatever directory
460        // the link happens to be written in. Keeping the link's own hops would
461        // double the directory — `../history/` + `history/…` — so for those the
462        // hops are replaced by the climb back to the root. A bare filename keeps
463        // the previous behaviour exactly, which every ADR-to-ADR link relies on.
464        let prefix = if served.contains('/') {
465            "../".repeat(depth)
466        } else {
467            format!("{dir}{sep}")
468        };
469        return Some(match frag {
470            Some(frag) => format!("{prefix}{served}#{frag}"),
471            None => format!("{prefix}{served}"),
472        });
473    }
474
475    // Not published, and climbing above the site root: no rewrite *within* the
476    // site can make this resolve, so hand it to the repository (issue #456).
477    // When there is no base to hand it to, fall through — everything below is
478    // the behaviour that predates this, unchanged, so a repository with no
479    // mappable `origin` renders exactly the site it rendered before.
480    if resolve_relative(path).0 > depth
481        && let Some(url) = source.and_then(|s| s.blob_url(path, frag))
482    {
483        return Some(url);
484    }
485
486    if !is_markdown {
487        return None;
488    }
489    // Unknown or ambiguous: fall back to the stem rewrite this has always done,
490    // which is right for every ADR (each is served under its own stem) and no
491    // worse than before for anything else.
492    let served = format!("{}.html", file.trim_end_matches(".md"));
493    Some(match frag {
494        Some(frag) => format!("{dir}{sep}{served}#{frag}"),
495        None => format!("{dir}{sep}{served}"),
496    })
497}
498
499/// Render one ADR markdown document to a themed HTML page. Leading YAML
500/// frontmatter is stripped; the title is the first `# ` heading, or `fallback`
501/// if there is none. ADR `[[…]]` links resolve to sibling ADR pages.
502///
503/// An ADR page is served one directory below the site root (`adr/`), which is
504/// the `1` below: a `../…` link from an ADR still lands inside the site, and only
505/// a second hop leaves it. See [`SourceBase`] for `source`.
506#[must_use]
507pub fn render_adr(
508    markdown: &str,
509    fallback_title: &str,
510    pages: &PublishedPages,
511    source: Option<&SourceBase>,
512) -> RenderedAdr {
513    let body = strip_frontmatter(markdown);
514    let title = first_heading(body).unwrap_or_else(|| fallback_title.to_owned());
515    let content = render_markdown(body, "", pages, source, 1);
516    // The Build Plan used to sit here. It was archived in 2026-09 and is no
517    // longer current; a nav is for where a reader should go next, not for
518    // everything that exists. The document is still served, still on the ADR
519    // index, and still cited by the ADRs it sequenced.
520    let nav = "<p class=\"nav\"><a href=\"../\">← Roteiro home</a> · \
521               <a href=\"./\">All ADRs</a></p>";
522    let html = page(&format!("{title} — Roteiro"), "../", nav, &content);
523    RenderedAdr { title, html }
524}
525
526/// Render a root-level "lifetime doc" (e.g. the Build Plan) to a themed page.
527/// Its `[[docs/adr/…]]` links resolve into the `adr/` subdirectory.
528///
529/// The page is served *at* the site root — the `0` below — so any `../…` link
530/// leaves the site; see [`SourceBase`] for where those go.
531#[must_use]
532pub fn render_doc(
533    markdown: &str,
534    fallback_title: &str,
535    pages: &PublishedPages,
536    source: Option<&SourceBase>,
537) -> RenderedAdr {
538    render_doc_at(markdown, fallback_title, pages, source, 0)
539}
540
541/// [`render_doc`] for a page that does **not** sit at the site root.
542///
543/// `depth` is how many directories down it is served — 1 for
544/// `history/build-plan.html`. Everything pointing out of the page is relative to
545/// where it sits, so a lifetime document that has been archived into a
546/// subdirectory still finds the theme, the ADR index and its siblings.
547///
548/// Split from [`render_doc`] rather than adding a parameter to it: twelve
549/// callers render at the root and should not have to say so.
550#[must_use]
551pub fn render_doc_at(
552    markdown: &str,
553    fallback_title: &str,
554    pages: &PublishedPages,
555    source: Option<&SourceBase>,
556    depth: usize,
557) -> RenderedAdr {
558    let body = strip_frontmatter(markdown);
559    let title = first_heading(body).unwrap_or_else(|| fallback_title.to_owned());
560    let up = "../".repeat(depth);
561    // Assets have always been `./favicon.svg` at the root, so depth 0 renders
562    // byte-identically to before.
563    let assets = if depth == 0 {
564        "./".to_owned()
565    } else {
566        up.clone()
567    };
568    let content = render_markdown(body, &format!("{up}adr/"), pages, source, depth);
569    let nav = format!(
570        "<p class=\"nav\"><a href=\"{assets}\">← Roteiro home</a> · \
571         <a href=\"{up}adr/\">ADRs</a></p>"
572    );
573    let html = page(&format!("{title} — Roteiro"), &assets, &nav, &content);
574    RenderedAdr { title, html }
575}
576
577/// Render one **site page** — a document that declared itself published — to a
578/// themed root-level page carrying the site navigation bar.
579///
580/// `nav` is the whole bar, in order; `current_href` is this page's own entry,
581/// which is marked `aria-current="page"` and rendered unlinked so the reader can
582/// see where they are. A `current_href` that matches nothing in `nav` simply
583/// yields a bar with nothing marked, which is what a preview of an unlisted page
584/// should look like rather than an error.
585///
586/// The title is the first `# ` heading, or `fallback_title`. `[[docs/adr/…]]`
587/// links resolve into the `adr/` subdirectory, exactly as they do for the Build
588/// Plan: a site page is a root-level document.
589#[must_use]
590pub fn render_site_page(
591    markdown: &str,
592    fallback_title: &str,
593    nav: &[NavEntry],
594    current_href: &str,
595    pages: &PublishedPages,
596    source: Option<&SourceBase>,
597) -> RenderedAdr {
598    let body = strip_frontmatter(markdown);
599    let title = first_heading(body).unwrap_or_else(|| fallback_title.to_owned());
600    // A site page's slug may name a path (`history/build-plan-v2`), so the page
601    // is not necessarily at the root any more. Everything pointing *out* of it —
602    // theme assets, the ADR directory, the nav — is relative to where it sits,
603    // and that is derivable from the href rather than something a caller should
604    // have to pass in and get wrong.
605    //
606    // The two prefixes differ at depth 0 on purpose: assets have always been
607    // `./favicon.svg` and nav entries have always been bare `build.html`, so a
608    // root-level page renders byte-identically to before.
609    let depth = current_href.matches('/').count();
610    let assets = if depth == 0 {
611        "./".to_owned()
612    } else {
613        "../".repeat(depth)
614    };
615    let up = "../".repeat(depth);
616    let content = render_markdown(body, &format!("{up}adr/"), pages, source, depth);
617    let bar = render_nav(nav, current_href, &up);
618    let html = page(&format!("{title} — Roteiro"), &assets, &bar, &content);
619    RenderedAdr { title, html }
620}
621
622/// The site navigation bar: one link per page, the current one marked.
623///
624/// Plain anchors in a `<nav>`, styled by `website/public/style.css`. No script:
625/// the explorer is deliberately vendored with no build step (ADR-0010), and a
626/// navigation bar that needs JavaScript to be a navigation bar would be the
627/// first thing on this site that does.
628#[must_use]
629/// `root` is the climb from the page being rendered back to the site root:
630/// `""` for a root-level page, `"../"` for one a directory down. Nav hrefs are
631/// stored root-relative, so a nested page has to prefix them or every entry
632/// resolves beside the page rather than beside the root.
633pub fn render_nav(nav: &[NavEntry], current_href: &str, root: &str) -> String {
634    let mut out = String::from("<nav class=\"sitenav\">");
635    for entry in nav {
636        if entry.href == current_href {
637            let _ = write!(
638                out,
639                "<span aria-current=\"page\">{}</span>",
640                escape_html(&entry.label)
641            );
642        } else {
643            let _ = write!(
644                out,
645                "<a href=\"{}{}\">{}</a>",
646                escape_attr(root),
647                escape_attr(&entry.href),
648                escape_html(&entry.label)
649            );
650        }
651    }
652    out.push_str("</nav>");
653    out
654}
655
656/// The marker whose contents [`replace_site_nav`] owns.
657const SITENAV_OPEN: &str = "<nav class=\"sitenav\">";
658
659/// Replace the `<nav class="sitenav">…</nav>` block in a **hand-written** page
660/// with the bar the renderer computes, returning `None` when the page carries no
661/// such block.
662///
663/// # Why this exists
664///
665/// `website/public/index.html` is the one page of roteiro.dev nothing renders —
666/// it is copied verbatim — and it used to carry a *hand-maintained copy* of the
667/// list the renderer derives from `site-order` (issue #508). Adding
668/// `docs/SERVING.md` appeared in every rendered page's bar automatically and had
669/// to be typed into the landing page by hand.
670///
671/// The failure that made it worth removing rather than remembering is silent and
672/// points the wrong way: a new page is published, reachable, and linked from
673/// every page **except the front one**. Nothing errors and `roteiro check`
674/// passes, because everything that is there resolves.
675///
676/// **That is also why no link auditor could have caught it, and why none will
677/// catch the next one of its shape.** The defect is a link that does *not*
678/// exist; auditing what is there cannot find what is missing. This removes the
679/// possibility instead — after this, the landing page has no independent list to
680/// disagree with. What still guards the seam is
681/// `the_landing_page_carries_the_bar_the_renderer_emits`, which now checks that
682/// the replacement *happened*: a landing page whose marker was renamed away
683/// keeps its stale bar silently, and that test is what fails.
684///
685/// # Why `None` rather than an error
686///
687/// A landing page with no `sitenav` is not claiming a bar, and a site is allowed
688/// not to have one — every `render docs` fixture writes a one-line `index.html`.
689/// The caller leaves such a page alone.
690#[must_use]
691pub fn replace_site_nav(html: &str, nav: &[NavEntry], current_href: &str) -> Option<String> {
692    let open = html.find(SITENAV_OPEN)?;
693    let close = html[open..].find("</nav>")? + open + "</nav>".len();
694    let mut out = String::with_capacity(html.len());
695    out.push_str(&html[..open]);
696    // The hand-written page this rewrites is the site root, so its nav hrefs
697    // need no climb.
698    out.push_str(&render_nav(nav, current_href, ""));
699    out.push_str(&html[close..]);
700    Some(out)
701}
702
703/// Render the docs index: any `lifetime` docs (Build Plan, …) then the ADRs.
704#[must_use]
705pub fn render_adr_index(lifetime: &[IndexEntry], entries: &[IndexEntry]) -> String {
706    let mut list = String::new();
707    if !lifetime.is_empty() {
708        list.push_str("<h1>Documentation</h1><ul>");
709        for e in lifetime {
710            let _ = write!(
711                list,
712                "<li><a href=\"{}\">{}</a></li>",
713                escape_attr(&e.href),
714                escape_html(&e.title)
715            );
716        }
717        list.push_str("</ul>");
718    }
719    list.push_str("<h1>Architecture Decision Records</h1><ul>");
720    for e in entries {
721        let _ = write!(
722            list,
723            "<li><a href=\"{}\">{}</a></li>",
724            escape_attr(&e.href),
725            escape_html(&e.title)
726        );
727    }
728    list.push_str("</ul>");
729    let nav = "<p class=\"nav\"><a href=\"../\">← Roteiro home</a></p>";
730    page("Documentation — Roteiro", "../", nav, &list)
731}
732
733/// Rewrite Roteiro `[[wiki-links]]` into Markdown, honouring code spans/fences:
734/// `[[docs/adr/<slug>.md]]` (optionally `#section`) becomes a link to that ADR
735/// page (`<adr_prefix><slug>.html`); any other `[[…]]` (code/file references,
736/// for which the site has no page) becomes inline code so it renders cleanly
737/// instead of leaking literal brackets.
738fn rewrite_wiki_links(md: &str, adr_prefix: &str) -> String {
739    let mut out = String::new();
740    let mut in_fence = false;
741    for line in md.lines() {
742        let trimmed = line.trim_start();
743        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
744            in_fence = !in_fence;
745            out.push_str(line);
746            out.push('\n');
747            continue;
748        }
749        if in_fence {
750            out.push_str(line);
751            out.push('\n');
752            continue;
753        }
754        rewrite_line_outside_code(line, adr_prefix, &mut out);
755        out.push('\n');
756    }
757    out
758}
759
760/// Rewrite wiki-links in one line, leaving `CommonMark` inline code spans
761/// untouched. A code span opens with a run of *n* backticks and closes with the
762/// next run of *exactly* *n* backticks; anything between (including `[[…]]`
763/// examples) is emitted verbatim. Backtick runs with no matching close are
764/// literal text and do not shield what follows.
765fn rewrite_line_outside_code(line: &str, adr_prefix: &str, out: &mut String) {
766    let bytes = line.as_bytes();
767    let mut text_start = 0;
768    let mut i = 0;
769    while i < bytes.len() {
770        if bytes[i] != b'`' {
771            i += 1;
772            continue;
773        }
774        let run_start = i;
775        while i < bytes.len() && bytes[i] == b'`' {
776            i += 1;
777        }
778        let run = i - run_start;
779        if let Some(rel) = find_closing_run(&bytes[i..], run) {
780            // Text before the opening delimiter is ordinary prose.
781            rewrite_wiki_in(&line[text_start..run_start], adr_prefix, out);
782            let code_end = i + rel + run;
783            out.push_str(&line[run_start..code_end]); // span, delimiters included
784            i = code_end;
785            text_start = i;
786        }
787        // No close → treat the run as literal text; keep it in the pending
788        // buffer (rewrite_wiki_in leaves backticks alone) and keep scanning.
789    }
790    rewrite_wiki_in(&line[text_start..], adr_prefix, out);
791}
792
793/// Byte offset (within `bytes`) of the next backtick run of *exactly* `run`
794/// backticks, or `None`. Longer or shorter runs are skipped, per `CommonMark`.
795fn find_closing_run(bytes: &[u8], run: usize) -> Option<usize> {
796    let mut i = 0;
797    while i < bytes.len() {
798        if bytes[i] != b'`' {
799            i += 1;
800            continue;
801        }
802        let start = i;
803        while i < bytes.len() && bytes[i] == b'`' {
804            i += 1;
805        }
806        if i - start == run {
807            return Some(start);
808        }
809    }
810    None
811}
812
813/// Rewrite every `[[…]]` in one non-code text segment.
814fn rewrite_wiki_in(seg: &str, adr_prefix: &str, out: &mut String) {
815    let mut rest = seg;
816    while let Some(open) = rest.find("[[") {
817        out.push_str(&rest[..open]);
818        let after = &rest[open + 2..];
819        if let Some(close) = after.find("]]") {
820            out.push_str(&wiki_target(&after[..close], adr_prefix));
821            rest = &after[close + 2..];
822        } else {
823            out.push_str("[[");
824            rest = after;
825        }
826    }
827    out.push_str(rest);
828}
829
830/// Resolve one wiki-link's inner text to Markdown.
831fn wiki_target(inner: &str, adr_prefix: &str) -> String {
832    let inner = inner.trim();
833    let path = inner.split_once('#').map_or(inner, |(p, _)| p.trim());
834    if let Some(rest) = path.strip_prefix("docs/adr/")
835        && let Some(stem) = rest.strip_suffix(".md")
836    {
837        return format!("[{}]({adr_prefix}{stem}.html)", adr_label(stem));
838    }
839    // Code/file reference — the site has no page for it; show it as code.
840    format!("`{inner}`")
841}
842
843/// A display label for an ADR filename stem: `0001-build-…` → `ADR-0001`.
844fn adr_label(stem: &str) -> String {
845    let digits: String = stem.chars().take_while(char::is_ascii_digit).collect();
846    if digits.is_empty() {
847        stem.to_owned()
848    } else {
849        format!("ADR-{digits}")
850    }
851}
852
853/// Wrap body HTML in the themed page chrome. `root` is the relative path to the
854/// site root (e.g. `"../"` for pages under `adr/`).
855fn page(title: &str, root: &str, nav: &str, body: &str) -> String {
856    format!(
857        "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
858         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
859         <link rel=\"icon\" href=\"{root}favicon.svg\" type=\"image/svg+xml\">\
860         <link rel=\"icon\" href=\"{root}favicon.ico\" type=\"image/x-icon\" sizes=\"16x16 32x32 48x48\">\
861         <link rel=\"apple-touch-icon\" href=\"{root}apple-touch-icon.png\">\
862         <link rel=\"stylesheet\" href=\"{root}style.css\">\
863         <title>{title}</title></head><body>\
864         {nav}{body}\
865         <p class=\"backlink\"><a href=\"{root}\">← Back to roteiro.dev</a></p>\
866         <footer>Dual-licensed MIT OR Apache-2.0 · The Roteiro Project Team · <a href=\"https://discord.gg/bxgj4w6KM\">Discord</a></footer>\
867         </body></html>",
868        title = escape_html(title),
869    )
870}
871
872/// Strip a leading `---`-delimited YAML frontmatter block.
873fn strip_frontmatter(text: &str) -> &str {
874    let Some(rest) = text.strip_prefix("---\n") else {
875        return text;
876    };
877    match rest.find("\n---\n") {
878        Some(end) => &rest[end + 5..],
879        None => rest.strip_suffix("\n---").unwrap_or(text),
880    }
881}
882
883/// The visible text of the document's first level-1 heading — what the reader
884/// sees in the rendered `<h1>` — or `None` when the document has none.
885///
886/// Read from a **parse**, for the same reason [`heading_ids`] is: the heading's
887/// raw line is source, not text. A line scan cannot tell `{#modes}` (a heading
888/// attribute this renderer deliberately enables, see [`options`]) from the words
889/// of the heading, so it read `# The five ways to run it {#modes}` back as a
890/// title and put the markup in the `<title>` element of every page moved by the
891/// site split — issue #460, live on roteiro.dev. The `<h1>` on the same page was
892/// already right, because that side went through the parser.
893///
894/// The fix is *not* a second place that knows how to strip `{#…}`. A rule
895/// spelled out twice is a rule that can disagree with itself, and this one
896/// already disagrees once: the anchor is markup to the parser and text to the
897/// scanner. Asking the parser removes the second opinion rather than aligning
898/// it, and carries the rest of the dialect along for free — a fenced `# …` is
899/// not a title, a setext underline is one, and inline markup (`` `code` ``,
900/// emphasis, a link label) contributes its text and not its punctuation.
901///
902/// The parse stops at the first `</h1>`; nothing walks the rest of the document.
903fn first_heading(body: &str) -> Option<String> {
904    let mut text: Option<String> = None;
905    for event in Parser::new_ext(body, options()) {
906        match event {
907            Event::Start(Tag::Heading {
908                level: HeadingLevel::H1,
909                ..
910            }) => text = Some(String::new()),
911            // Only accumulates once an H1 has opened; a code span is part of the
912            // heading's text, exactly as it is for the heading's id.
913            Event::Text(t) | Event::Code(t) => {
914                if let Some(text) = text.as_mut() {
915                    text.push_str(&t);
916                }
917            }
918            Event::End(TagEnd::Heading(HeadingLevel::H1)) => break,
919            _ => {}
920        }
921    }
922    // An empty `#` heading names nothing, so it defers to the caller's fallback
923    // rather than rendering `<title> — Roteiro</title>`.
924    text.map(|t| t.trim().to_owned()).filter(|t| !t.is_empty())
925}
926
927fn escape_html(s: &str) -> String {
928    s.replace('&', "&amp;")
929        .replace('<', "&lt;")
930        .replace('>', "&gt;")
931}
932
933fn escape_attr(s: &str) -> String {
934    escape_html(s).replace('"', "&quot;")
935}
936
937#[cfg(test)]
938mod tests {
939    use super::{
940        IndexEntry, NavEntry, PublishedPages, SourceBase, escape_html, heading_ids,
941        markdown_to_html, options, render_adr, render_adr_index, render_doc, render_markdown,
942        render_nav, render_site_page, replace_site_nav,
943    };
944
945    /// The site index most tests do not exercise: with it empty, a `.md` link
946    /// falls back to its own stem, which is what every assertion below predates.
947    fn no_pages() -> PublishedPages {
948        PublishedPages::new()
949    }
950
951    fn nav() -> Vec<NavEntry> {
952        vec![
953            NavEntry {
954                href: "./".into(),
955                label: "Home".into(),
956            },
957            NavEntry {
958                href: "modes.html".into(),
959                label: "Modes & Co".into(),
960            },
961        ]
962    }
963
964    #[test]
965    fn markdown_renders_headings_and_tables() {
966        let html = markdown_to_html("# Title\n\n| a | b |\n|---|---|\n| 1 | 2 |\n");
967        assert!(html.contains("<h1 id=\"title\">Title</h1>"), "{html}");
968        assert!(html.contains("<table>"));
969        assert!(html.contains("<td>1</td>"));
970    }
971
972    #[test]
973    fn adr_wiki_links_become_sibling_page_links() {
974        // An ADR-to-ADR wiki link resolves to the sibling .html; a code/file
975        // reference becomes inline code; both stop leaking literal `[[ ]]`.
976        let md = "See [[docs/adr/0001-build-roteiro.md]] and \
977                  [[crates/rto-graph/src/store.rs#Store]] here.\n";
978        let html = markdown_to_html(md);
979        assert!(
980            html.contains("<a href=\"0001-build-roteiro.html\">ADR-0001</a>"),
981            "ADR wiki-link → sibling page: {html}"
982        );
983        assert!(
984            html.contains("<code>crates/rto-graph/src/store.rs#Store</code>"),
985            "code reference → inline code: {html}"
986        );
987        assert!(
988            !html.contains("[["),
989            "no literal wiki brackets leak: {html}"
990        );
991    }
992
993    #[test]
994    fn wiki_links_inside_code_are_left_literal() {
995        // A documented example of the syntax, in backticks or a fence, must not
996        // be rewritten.
997        let inline = markdown_to_html("use `[[docs/adr/0001-x.md]]` in prose\n");
998        assert!(
999            inline.contains("<code>[[docs/adr/0001-x.md]]</code>"),
1000            "{inline}"
1001        );
1002        let fenced = markdown_to_html("```\n[[docs/adr/0001-x.md]]\n```\n");
1003        assert!(
1004            fenced.contains("[[docs/adr/0001-x.md]]"),
1005            "fence literal: {fenced}"
1006        );
1007    }
1008
1009    #[test]
1010    fn multi_backtick_code_spans_are_honoured() {
1011        // A tight double-backtick span (`` ``…`` ``) and the Build Plan's
1012        // nested-backtick example must both survive verbatim — the previous
1013        // single-backtick split rewrote the wiki-link inside them.
1014        let tight = markdown_to_html("say ``[[docs/adr/0001-x.md]]`` please\n");
1015        assert!(
1016            tight.contains("<code>[[docs/adr/0001-x.md]]</code>"),
1017            "{tight}"
1018        );
1019        assert!(!tight.contains("<a "), "no link inside code span: {tight}");
1020
1021        let nested = markdown_to_html("its `` `[[path#Symbol]]` `` example\n");
1022        assert!(
1023            nested.contains("<code>`[[path#Symbol]]`</code>"),
1024            "{nested}"
1025        );
1026        assert!(
1027            !nested.contains("<a "),
1028            "no link inside nested span: {nested}"
1029        );
1030
1031        // An unterminated run is literal and does not shield a later real link.
1032        let stray = markdown_to_html("a ` stray tick then [[docs/adr/0001-x.md]]\n");
1033        assert!(
1034            stray.contains("<a href=\"0001-x.html\">ADR-0001</a>"),
1035            "unterminated backtick must not shield: {stray}"
1036        );
1037    }
1038
1039    #[test]
1040    fn markdown_md_links_are_rewritten_to_html() {
1041        // Ordinary `[text](path.md)` links must point at the rendered `.html`,
1042        // preserving fragments; external and anchor links are left alone.
1043        let html = markdown_to_html(
1044            "See [ADR-1](adr/0001-x.md) and [§2](adr/0001-x.md#context) and \
1045             [home](https://x.dev) and [top](#intro).\n",
1046        );
1047        assert!(html.contains("href=\"adr/0001-x.html\""), "{html}");
1048        assert!(html.contains("href=\"adr/0001-x.html#context\""), "{html}");
1049        assert!(
1050            html.contains("href=\"https://x.dev\""),
1051            "external unchanged: {html}"
1052        );
1053        assert!(html.contains("href=\"#intro\""), "anchor unchanged: {html}");
1054        assert!(!html.contains(".md\""), "no raw .md hrefs remain: {html}");
1055    }
1056
1057    #[test]
1058    fn render_doc_links_adrs_into_subdir() {
1059        // A root-level lifetime doc (Build Plan) resolves ADR links into `adr/`.
1060        let r = render_doc(
1061            "# Build Plan\n\nGoverned by [[docs/adr/0001-x.md]].\n",
1062            "Build Plan",
1063            &no_pages(),
1064            None,
1065        );
1066        assert_eq!(r.title, "Build Plan");
1067        assert!(
1068            r.html.contains("<a href=\"adr/0001-x.html\">ADR-0001</a>"),
1069            "root doc → adr/ prefix: {}",
1070            r.html
1071        );
1072        // Root-level chrome: assets/back-link relative to site root.
1073        assert!(r.html.contains("href=\"./style.css\""));
1074        // Full favicon set — root-relative from the site root.
1075        assert!(r.html.contains("href=\"./favicon.svg\""));
1076        assert!(r.html.contains("href=\"./favicon.ico\""));
1077        assert!(
1078            r.html
1079                .contains("rel=\"apple-touch-icon\" href=\"./apple-touch-icon.png\"")
1080        );
1081    }
1082
1083    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";
1084
1085    #[test]
1086    fn render_adr_strips_frontmatter_and_themes() {
1087        let r = render_adr(ADR, "fallback", &no_pages(), None);
1088        assert_eq!(r.title, "ADR-0001: Example");
1089        // Frontmatter is gone; heading + section rendered.
1090        assert!(!r.html.contains("adr-id"));
1091        assert!(
1092            r.html
1093                .contains("<h1 id=\"adr-0001-example\">ADR-0001: Example</h1>")
1094        );
1095        // The section anchor matches the section's node key (`adr:0001#context`),
1096        // so a link through the graph lands on the heading in the browser.
1097        assert!(r.html.contains("<h2 id=\"context\">Context</h2>"));
1098        assert!(r.html.contains("<code>code</code>"));
1099        // Themed chrome present.
1100        assert!(
1101            r.html
1102                .contains("<link rel=\"stylesheet\" href=\"../style.css\">")
1103        );
1104        // Full favicon set (SVG + `.ico` fallback for browsers without SVG-favicon
1105        // support, e.g. Safari) — root-relative from a sub-page.
1106        assert!(r.html.contains("href=\"../favicon.svg\""));
1107        assert!(r.html.contains("href=\"../favicon.ico\""));
1108        assert!(
1109            r.html
1110                .contains("rel=\"apple-touch-icon\" href=\"../apple-touch-icon.png\"")
1111        );
1112        assert!(r.html.contains("← Roteiro home"));
1113        assert!(r.html.contains("← Back to roteiro.dev"));
1114        assert!(r.html.starts_with("<!doctype html>"));
1115    }
1116
1117    #[test]
1118    fn render_adr_falls_back_without_h1() {
1119        let r = render_adr(
1120            "no frontmatter, no heading\n",
1121            "slug-name",
1122            &no_pages(),
1123            None,
1124        );
1125        assert_eq!(r.title, "slug-name");
1126    }
1127
1128    #[test]
1129    fn index_lists_entries_and_escapes() {
1130        let entries = [
1131            IndexEntry {
1132                href: "0001-x.html".into(),
1133                title: "First & <best>".into(),
1134            },
1135            IndexEntry {
1136                href: "0002-y.html".into(),
1137                title: "Second".into(),
1138            },
1139        ];
1140        let lifetime = [IndexEntry {
1141            href: "../history/build-plan.html".into(),
1142            title: "Build Plan".into(),
1143        }];
1144        let html = render_adr_index(&lifetime, &entries);
1145        assert!(html.contains("<a href=\"../history/build-plan.html\">Build Plan</a>"));
1146        assert!(html.contains("<a href=\"0001-x.html\">First &amp; &lt;best&gt;</a>"));
1147        assert!(html.contains("<a href=\"0002-y.html\">Second</a>"));
1148        // First entry precedes second (order preserved).
1149        assert!(html.find("0001-x").unwrap() < html.find("0002-y").unwrap());
1150        // Lifetime docs listed before the ADRs.
1151        assert!(html.find("build-plan").unwrap() < html.find("0001-x").unwrap());
1152    }
1153
1154    #[test]
1155    fn an_explicit_anchor_survives_the_split_that_moved_its_section() {
1156        // The hazard this mechanism exists for. The old single-page site
1157        // published `#modes`, `#crossrepo`, `#remote-tier` — short, hand-chosen
1158        // ids that no heading text slugifies to. External links point at them and
1159        // cannot be updated, so a page that inherits a section must be able to
1160        // inherit its anchor verbatim.
1161        let html = markdown_to_html(
1162            "## The five ways to run it {#modes}\n\n## Cross-repo: a hub and its spokes {#crossrepo}\n",
1163        );
1164        assert!(
1165            html.contains("<h2 id=\"modes\">The five ways to run it</h2>"),
1166            "{html}"
1167        );
1168        assert!(
1169            html.contains("<h2 id=\"crossrepo\">Cross-repo: a hub and its spokes</h2>"),
1170            "{html}"
1171        );
1172        // The attribute is markup, not part of the heading's text.
1173        assert!(!html.contains("{#"), "no literal attribute leaks: {html}");
1174    }
1175
1176    #[test]
1177    fn generated_anchors_match_the_graph_s_section_keys_and_stay_unique() {
1178        // `rto_spec` builds `<doc>#<slugify(heading)>` section keys from the same
1179        // function, so a link that resolves in the graph lands on the heading.
1180        let html = markdown_to_html("## Install & build\n\n## Install & build\n\n## ###\n");
1181        assert!(html.contains("id=\"install-build\""), "{html}");
1182        // A repeat is suffixed rather than duplicated: two elements sharing an
1183        // `id` makes one of them unreachable.
1184        assert!(html.contains("id=\"install-build-2\""), "{html}");
1185        // A heading that slugifies to nothing still gets a usable anchor.
1186        assert!(html.contains("id=\"section-3\""), "{html}");
1187    }
1188
1189    #[test]
1190    fn inline_code_counts_as_heading_text() {
1191        // The old page's headings look like `What <code>init</code> sets up`.
1192        // Dropping the code span would slugify only the prose around it and give
1193        // the section an anchor nobody would guess.
1194        let html = markdown_to_html("### What `init` sets up\n");
1195        assert!(
1196            html.contains("<h3 id=\"what-init-sets-up\">"),
1197            "code span is part of the heading's text: {html}"
1198        );
1199    }
1200
1201    #[test]
1202    fn a_hash_inside_a_fence_is_not_a_heading() {
1203        // The id list is computed from a real parse, so fenced content cannot
1204        // shift every subsequent heading's anchor by one.
1205        let html = markdown_to_html("```\n## Not a heading\n```\n\n## Real\n");
1206        assert!(html.contains("<h2 id=\"real\">Real</h2>"), "{html}");
1207    }
1208
1209    #[test]
1210    fn a_heading_s_anchor_never_reaches_the_title() {
1211        // Issue #460, live on roteiro.dev: every page the site split moved
1212        // carries `{#…}` on its H1, and the title was read off the raw line.
1213        let r = render_site_page(
1214            "---\nsite-page: modes\n---\n\n# The five ways to run it {#modes}\n\nBody.\n",
1215            "fallback",
1216            &nav(),
1217            "modes.html",
1218            &no_pages(),
1219            None,
1220        );
1221        // The heading was always right; the title is the side that was wrong.
1222        assert!(
1223            r.html
1224                .contains("<h1 id=\"modes\">The five ways to run it</h1>"),
1225            "{}",
1226            r.html
1227        );
1228        assert_eq!(r.title, "The five ways to run it");
1229        assert!(
1230            r.html
1231                .contains("<title>The five ways to run it — Roteiro</title>"),
1232            "{}",
1233            r.html
1234        );
1235        // The most-seen string a page has: the tab, the bookmark, the search
1236        // result, the social preview. Nothing of the attribute survives anywhere.
1237        assert!(
1238            !r.html.contains("{#"),
1239            "no literal attribute leaks: {}",
1240            r.html
1241        );
1242    }
1243
1244    #[test]
1245    fn the_same_holds_for_an_adr_and_for_a_root_level_doc() {
1246        // One extractor serves all three renderers, so all three are checked:
1247        // a fix that reached only the page the issue named would leave the ADR
1248        // index quoting `{#…}` back at the reader.
1249        let adr = render_adr("# ADR-0001: Example {#adr1}\n", "slug", &no_pages(), None);
1250        assert_eq!(adr.title, "ADR-0001: Example");
1251        assert!(
1252            adr.html
1253                .contains("<title>ADR-0001: Example — Roteiro</title>"),
1254            "{}",
1255            adr.html
1256        );
1257        let doc = render_doc(
1258            "# Roteiro — Build Plan {#plan}\n",
1259            "Build Plan",
1260            &no_pages(),
1261            None,
1262        );
1263        assert_eq!(doc.title, "Roteiro — Build Plan");
1264        assert!(!doc.html.contains("{#"), "{}", doc.html);
1265    }
1266
1267    #[test]
1268    fn a_title_that_legitimately_spells_the_anchor_syntax_keeps_it() {
1269        // The other half of the rule, and the reason the fix is a parse and not
1270        // a strip: `{#…}` is an attribute only where the dialect says it is, and
1271        // a rule spelled out by hand does not know where that is. Inside a code
1272        // span it is prose, and a stripper blind to code spans mangles a page
1273        // whose subject *is* this syntax — which is most of the pages that
1274        // document it.
1275        let coded = render_doc(
1276            "# Why `{#anchor}` outlives a restructure\n",
1277            "fallback",
1278            &no_pages(),
1279            None,
1280        );
1281        assert_eq!(coded.title, "Why {#anchor} outlives a restructure");
1282        assert!(
1283            coded
1284                .html
1285                .contains("<title>Why {#anchor} outlives a restructure — Roteiro</title>"),
1286            "{}",
1287            coded.html
1288        );
1289        // Mid-heading and uncoded, it is still prose: an attribute block is
1290        // trailing or it is nothing.
1291        let mid = render_doc(
1292            "# Anchors are written {#id}, in prose\n",
1293            "fallback",
1294            &no_pages(),
1295            None,
1296        );
1297        assert_eq!(mid.title, "Anchors are written {#id}, in prose");
1298    }
1299
1300    #[test]
1301    fn the_title_and_the_heading_never_disagree() {
1302        // The invariant underneath #460, stated directly. Where the attribute
1303        // block ends is the dialect's call, not this module's — braces the
1304        // parser eats are gone from *both* surfaces, braces it keeps are on
1305        // both. Reading the title from the same parse is what makes that true by
1306        // construction rather than by two rules that happen to match today.
1307        for md in [
1308            "# The five ways to run it {#modes}\n",
1309            "# Why `{#anchor}` outlives a restructure\n",
1310            "# Anchors are written {#id}, in prose\n",
1311            "# Install & build {#build}\n",
1312            "# What `init` sets up\n",
1313            "# Sets like {#1, #2}\n",
1314        ] {
1315            let r = render_doc(md, "fallback", &no_pages(), None);
1316            let inner = r
1317                .html
1318                .split_once("<h1")
1319                .and_then(|(_, rest)| rest.split_once('>'))
1320                .and_then(|(_, rest)| rest.split_once("</h1>"))
1321                .map(|(text, _)| text.to_owned())
1322                .unwrap_or_default();
1323            // The heading carries inline markup (`<code>`, emphasis); the title
1324            // is the words inside it. Dropping the tags — and nothing else, so
1325            // entities still have to match — is what makes them comparable.
1326            let mut heading = String::new();
1327            let mut depth = 0usize;
1328            for c in inner.chars() {
1329                match c {
1330                    '<' => depth += 1,
1331                    '>' => depth = depth.saturating_sub(1),
1332                    _ if depth == 0 => heading.push(c),
1333                    _ => {}
1334                }
1335            }
1336            assert_eq!(
1337                heading,
1338                escape_html(&r.title),
1339                "title and heading disagree for {md:?}: {}",
1340                r.html
1341            );
1342        }
1343    }
1344
1345    #[test]
1346    fn the_title_is_the_heading_the_reader_sees() {
1347        // Inline markup contributes its text, not its punctuation — the same
1348        // rule the heading's own id already follows.
1349        let code = render_doc("# What `init` sets up\n", "fallback", &no_pages(), None);
1350        assert_eq!(code.title, "What init sets up");
1351        // A line scan called this document's title `Not a title`; the parser
1352        // knows a fenced hash is not a heading at all.
1353        let fenced = render_doc(
1354            "```\n# Not a title\n```\n\n# The real one\n",
1355            "fallback",
1356            &no_pages(),
1357            None,
1358        );
1359        assert_eq!(fenced.title, "The real one");
1360        // And a heading spelled the other way is still a heading: the page shows
1361        // an `<h1>`, so the tab has to show its words rather than the file stem.
1362        let setext = render_doc("Underlined\n==========\n", "fallback", &no_pages(), None);
1363        assert!(
1364            setext.html.contains("<h1 id=\"underlined\">"),
1365            "{}",
1366            setext.html
1367        );
1368        assert_eq!(setext.title, "Underlined");
1369    }
1370
1371    #[test]
1372    fn a_document_with_no_h1_falls_back_and_the_fallback_is_used_verbatim() {
1373        // The fallback is the caller's string, not markdown: it is never parsed,
1374        // so it cannot be stripped and cannot leak markup it does not contain.
1375        // Callers pass a file stem or a declared slug.
1376        let none = render_site_page(
1377            "---\nsite-page: modes\n---\n\nNo heading at all.\n",
1378            "The five ways to run it",
1379            &nav(),
1380            "modes.html",
1381            &no_pages(),
1382            None,
1383        );
1384        assert_eq!(none.title, "The five ways to run it");
1385        assert!(
1386            none.html
1387                .contains("<title>The five ways to run it — Roteiro</title>"),
1388            "{}",
1389            none.html
1390        );
1391        // An H1 with nothing in it names nothing, so it defers to the fallback
1392        // rather than emitting `<title> — Roteiro</title>`.
1393        let empty = render_doc("#\n\nBody.\n", "build-plan", &no_pages(), None);
1394        assert_eq!(empty.title, "build-plan");
1395        // A lower heading is not the document's title.
1396        let sub = render_doc("## Only a section {#s}\n", "build-plan", &no_pages(), None);
1397        assert_eq!(sub.title, "build-plan");
1398    }
1399
1400    #[test]
1401    fn a_site_page_carries_the_bar_with_itself_marked() {
1402        let r = render_site_page(
1403            "---\nsite-page: modes\n---\n\n# The five ways to run it\n\nSee [[docs/adr/0019-remote.md]].\n",
1404            "fallback",
1405            &nav(),
1406            "modes.html",
1407            &no_pages(),
1408            None,
1409        );
1410        assert_eq!(r.title, "The five ways to run it");
1411        // Frontmatter is chrome for the graph, not content for the reader.
1412        assert!(!r.html.contains("site-page"), "{}", r.html);
1413        // The current page is unlinked and marked; its neighbour is a link.
1414        assert!(
1415            r.html
1416                .contains("<span aria-current=\"page\">Modes &amp; Co</span>"),
1417            "{}",
1418            r.html
1419        );
1420        assert!(r.html.contains("<a href=\"./\">Home</a>"), "{}", r.html);
1421        // A root-level page: assets and ADR links resolve from the site root.
1422        assert!(r.html.contains("href=\"./style.css\""), "{}", r.html);
1423        assert!(
1424            r.html
1425                .contains("<a href=\"adr/0019-remote.html\">ADR-0019</a>"),
1426            "{}",
1427            r.html
1428        );
1429    }
1430
1431    #[test]
1432    fn the_bar_is_plain_anchors_and_escapes_its_labels() {
1433        let bar = render_nav(&nav(), "nothing.html", "");
1434        assert!(bar.starts_with("<nav class=\"sitenav\">"), "{bar}");
1435        // Nothing marked when the current page is not in the bar — a preview of
1436        // an unlisted page, not an error.
1437        assert!(!bar.contains("aria-current"), "{bar}");
1438        assert!(bar.contains("Modes &amp; Co"), "escaped label: {bar}");
1439        // No script: the site has no build step and this must not introduce one.
1440        assert!(!bar.contains("<script"), "{bar}");
1441    }
1442
1443    #[test]
1444    fn a_link_resolves_to_the_page_the_site_actually_serves() {
1445        // Issue #446: four ADRs link `../history/BUILD_PLAN_V2.md`, which is
1446        // correct in the repository. Published under a `site-page:` slug, the
1447        // document is served somewhere else entirely, so rewriting the link to
1448        // its own stem aims it at a page that is never emitted.
1449        //
1450        // Two substitution rules are covered here, and the difference between
1451        // them is whether the served name contains a directory.
1452        let mut pages = PublishedPages::new();
1453        pages.publish("BUILD_PLAN_V2.md", "history/build-plan-v2.html");
1454
1455        // A served name with a directory is a path from the **site root**, so
1456        // the link's own hops are dropped: this page is at the root, so no climb.
1457        let html = render_markdown(
1458            "See [V2](../history/BUILD_PLAN_V2.md).\n",
1459            "",
1460            &pages,
1461            None,
1462            0,
1463        );
1464        assert!(
1465            html.contains("href=\"history/build-plan-v2.html\""),
1466            "root-relative, hops replaced rather than doubled: {html}"
1467        );
1468
1469        // The same link from a page one directory down climbs back first.
1470        let deep = render_markdown(
1471            "See [V2](../history/BUILD_PLAN_V2.md).\n",
1472            "",
1473            &pages,
1474            None,
1475            1,
1476        );
1477        assert!(
1478            deep.contains("href=\"../history/build-plan-v2.html\""),
1479            "one climb for one level of depth: {deep}"
1480        );
1481
1482        // A fragment survives the substitution.
1483        let frag = render_markdown(
1484            "[s](../history/BUILD_PLAN_V2.md#stage-21)\n",
1485            "",
1486            &pages,
1487            None,
1488            0,
1489        );
1490        assert!(
1491            frag.contains("href=\"history/build-plan-v2.html#stage-21\""),
1492            "{frag}"
1493        );
1494
1495        // A **bare** served name keeps the link's own hops, which is what every
1496        // ADR-to-ADR link depends on. This is the rule the directory case must
1497        // not have broken.
1498        let mut flat = PublishedPages::new();
1499        flat.publish("0002-x.md", "0002-x.html");
1500        let sibling = render_markdown("[a](0002-x.md)\n", "", &flat, None, 1);
1501        assert!(
1502            sibling.contains("href=\"0002-x.html\""),
1503            "sibling link stays a sibling: {sibling}"
1504        );
1505
1506        // An unpublished document still falls back to its stem, unchanged.
1507        let other = render_markdown("[x](../REVIEW_CHECKLIST.md)\n", "", &pages, None, 0);
1508        assert!(
1509            other.contains("href=\"../REVIEW_CHECKLIST.html\""),
1510            "{other}"
1511        );
1512    }
1513
1514    #[test]
1515    fn a_file_name_two_documents_claim_is_left_alone() {
1516        // Guessing which one a link meant would silently point it at the wrong
1517        // page — worse than the 404 the lookup exists to remove.
1518        let mut pages = PublishedPages::new();
1519        pages.publish("GUIDE.md", "guide.html");
1520        pages.publish("GUIDE.md", "other-guide.html");
1521        let html = render_markdown("[g](GUIDE.md)\n", "", &pages, None, 0);
1522        assert!(html.contains("href=\"GUIDE.html\""), "unrewritten: {html}");
1523        // Re-publishing the *same* target is not a conflict.
1524        let mut same = PublishedPages::new();
1525        same.publish("GUIDE.md", "guide.html");
1526        same.publish("GUIDE.md", "guide.html");
1527        let html = render_markdown("[g](GUIDE.md)\n", "", &same, None, 0);
1528        assert!(html.contains("href=\"guide.html\""), "{html}");
1529    }
1530
1531    #[test]
1532    fn the_dialect_is_not_extended_here() {
1533        // `options` exists to hold renderer-specific rationale, not to add
1534        // flags. This reads as a tautology against the body as written, and that
1535        // is exactly its job: it has no failure mode until someone gives the
1536        // body one, and that single edit is the only thing the comment beside it
1537        // can ask against rather than prevent.
1538        //
1539        // Note what it pins — the *dialect*, not the shape of the body. A
1540        // rewrite that still yields this option set is harmless and keeps
1541        // passing; every divergence that would change what a heading's text is
1542        // fails. That is the invariant worth holding, and it is a wider one than
1543        // "stay a single delegation".
1544        assert_eq!(options(), rto_graph::markdown_dialect());
1545    }
1546
1547    #[test]
1548    fn the_heading_id_rule_is_not_reimplemented_here() {
1549        // The counterpart of the test directly above, and the same argument:
1550        // `heading_ids` exists to hold this file's rationale about ids, not a
1551        // second copy of the rule. This reads as a tautology against the body as
1552        // written, which is exactly its job — it has no failure mode until
1553        // someone gives the body one.
1554        //
1555        // The fixture **contains the difference** rather than being a plain
1556        // document: a repeat, a claim an `h1` already took, and a heading that
1557        // names nothing. A local copy re-grown here would have to get all three
1558        // right to pass, and the one that used to be here got the second wrong —
1559        // it counted all levels while `rto_spec` counted `##` only, which is
1560        // what #629 was.
1561        let md = "# Same\n\n## Same\n\n## Dup\n\n## Dup\n\n### ###\n";
1562        assert_eq!(
1563            heading_ids(md),
1564            rto_graph::headings(md)
1565                .into_iter()
1566                .map(|h| h.id)
1567                .collect::<Vec<_>>()
1568        );
1569        // Agreement is necessary and not sufficient: two implementations that
1570        // both dropped the dedup would satisfy the assertion above. So pin what
1571        // the shared rule answers, once, here.
1572        assert_eq!(
1573            heading_ids(md),
1574            ["same", "same-2", "dup", "dup-2", "section-5"]
1575        );
1576    }
1577
1578    /// A source base for a document in `dir`, at a fixed sha.
1579    fn source(dir: &str) -> SourceBase {
1580        SourceBase::new(Some("https://github.com/o/r/blob/abc123"), dir).expect("base")
1581    }
1582
1583    #[test]
1584    fn a_link_out_of_the_site_goes_to_the_repository() {
1585        // Issue #456: the Build Plan cites code as evidence — correct in a
1586        // checkout, dead on the site, which publishes documents and not source.
1587        let base = source("docs");
1588        let html = render_markdown(
1589            "[sync](../crates/rto-graph/src/sync.rs) and [wf](../.github/workflows/website.yml)\n",
1590            "adr/",
1591            &no_pages(),
1592            Some(&base),
1593            0,
1594        );
1595        assert!(
1596            html.contains(
1597                "href=\"https://github.com/o/r/blob/abc123/crates/rto-graph/src/sync.rs\""
1598            ),
1599            "resolved against the document's own directory: {html}"
1600        );
1601        assert!(
1602            html.contains(
1603                "href=\"https://github.com/o/r/blob/abc123/.github/workflows/website.yml\""
1604            ),
1605            "a dotted directory is a directory, not a `.` segment: {html}"
1606        );
1607        // A line anchor is the author's, and travels.
1608        let frag = render_markdown(
1609            "[l](../crates/roteiro/src/init.rs#L12)\n",
1610            "adr/",
1611            &no_pages(),
1612            Some(&base),
1613            0,
1614        );
1615        assert!(
1616            frag.contains("blob/abc123/crates/roteiro/src/init.rs#L12\""),
1617            "{frag}"
1618        );
1619    }
1620
1621    #[test]
1622    fn a_link_that_stays_inside_the_site_is_left_alone() {
1623        // The whole discrimination is the hop count: `ask.html` and `adr/` are
1624        // written *for* the site and are correct there, so rewriting them to the
1625        // repository would break links that work today.
1626        let base = source("docs");
1627        let html = render_markdown(
1628            "[a](ask.html), [d](adr/), [s](./style.css) and [r](/abs.html)\n",
1629            "adr/",
1630            &no_pages(),
1631            Some(&base),
1632            0,
1633        );
1634        assert!(!html.contains("github.com"), "none rewritten: {html}");
1635        for href in [
1636            "\"ask.html\"",
1637            "\"adr/\"",
1638            "\"./style.css\"",
1639            "\"/abs.html\"",
1640        ] {
1641            assert!(html.contains(href), "{href} kept verbatim: {html}");
1642        }
1643    }
1644
1645    #[test]
1646    fn an_adr_may_climb_one_level_and_still_be_inside_the_site() {
1647        // An ADR page is served at `adr/<slug>.html`, so `../x` lands at the site
1648        // root. Treating that as an escape would send every ADR's back-link to
1649        // GitHub. The second hop does leave.
1650        let base = source("docs/adr");
1651        let inside = render_markdown("[b](../build-plan.html)\n", "", &no_pages(), Some(&base), 1);
1652        assert!(!inside.contains("github.com"), "{inside}");
1653        let outside = render_markdown("[c](../../Cargo.toml)\n", "", &no_pages(), Some(&base), 1);
1654        assert!(
1655            outside.contains("href=\"https://github.com/o/r/blob/abc123/Cargo.toml\""),
1656            "{outside}"
1657        );
1658    }
1659
1660    #[test]
1661    fn a_published_page_beats_the_escape_rule() {
1662        // Order matters: #446's lookup runs first, so a document reached by a
1663        // path that climbs out of its own directory still lands on the page the
1664        // site publishes it as, rather than being handed to the repository.
1665        let mut pages = PublishedPages::new();
1666        pages.publish("BUILD_PLAN_V2.md", "history/build-plan-v2.html");
1667        let base = source("website/pages");
1668        let html = render_markdown(
1669            "[v2](../../docs/history/BUILD_PLAN_V2.md)\n",
1670            "adr/",
1671            &pages,
1672            Some(&base),
1673            0,
1674        );
1675        assert!(
1676            html.contains("href=\"history/build-plan-v2.html\""),
1677            "still the site's page, and at the path the site serves it from: {html}"
1678        );
1679        assert!(!html.contains("github.com"), "{html}");
1680    }
1681
1682    #[test]
1683    fn without_a_source_base_the_link_is_left_as_authored() {
1684        // No `origin`, or one that maps to no web view. Leaving the link is the
1685        // deliberate choice: it stays correct in a checkout, and a rewrite that
1686        // silently produced a broken URL would be worse than the link it replaced.
1687        assert_eq!(SourceBase::new(None, "docs"), None);
1688        let html = render_markdown(
1689            "[s](../crates/rto-graph/src/sync.rs)\n",
1690            "adr/",
1691            &no_pages(),
1692            None,
1693            0,
1694        );
1695        assert!(
1696            html.contains("href=\"../crates/rto-graph/src/sync.rs\""),
1697            "{html}"
1698        );
1699    }
1700
1701    #[test]
1702    fn the_bar_on_the_landing_page_is_replaced_rather_than_maintained() {
1703        // Issue #508. The stale copy is overwritten wholesale, so there is no
1704        // second list left to drift out of `site-order`.
1705        let stale = "<h1>Roteiro</h1>\n<nav class=\"sitenav\">\n<a href=\"old.html\">Old</a>\n\
1706                     </nav>\n<p>after</p>\n";
1707        let out = replace_site_nav(stale, &nav(), "./").expect("marker found");
1708        assert!(
1709            !out.contains("old.html"),
1710            "the hand-written list is gone: {out}"
1711        );
1712        assert!(
1713            out.contains("<a href=\"modes.html\">Modes &amp; Co</a>"),
1714            "the computed bar took its place: {out}"
1715        );
1716        assert!(
1717            out.starts_with("<h1>Roteiro</h1>\n") && out.ends_with("<p>after</p>\n"),
1718            "only the bar is touched: {out}"
1719        );
1720        // A page that claims no bar is left alone rather than failing: every
1721        // `render docs` fixture writes a one-line landing page.
1722        assert_eq!(replace_site_nav("<h1>Home</h1>\n", &nav(), "./"), None);
1723    }
1724
1725    #[test]
1726    fn site_pages_render_deterministically() {
1727        let md = "---\nsite-page: a\n---\n\n# A\n\n## S\n";
1728        assert_eq!(
1729            render_site_page(md, "f", &nav(), "a.html", &no_pages(), None),
1730            render_site_page(md, "f", &nav(), "a.html", &no_pages(), None)
1731        );
1732    }
1733
1734    #[test]
1735    fn rendering_is_deterministic() {
1736        assert_eq!(
1737            render_adr(ADR, "f", &no_pages(), None),
1738            render_adr(ADR, "f", &no_pages(), None)
1739        );
1740    }
1741}