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