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