Render the manual as one HTML page the application can carry

The manual existed only as docs/manual/README.md, which the forge renders
and nothing else does. An installed copy of the application, on a laptop
with no network or on a tablet, had no manual it could open.

`traces manual` renders the README to docs/manual/index.html with
pulldown-cmark (already in the tree as Slint's Markdown parser, so this
adds a dependency edge and no crate). The page is one file with an inline
stylesheet that follows the system's light or dark preference, a
contents list of every section and subsection, and the pictures by their
relative media/ paths. Each heading carries the id the forge gives it, so
README.md#rating-and-flagging and index.html#rating-and-flagging are the
same link. A picture alone in its paragraph becomes a figure whose alt
text is shown as the caption, and every picture reserves its 16:11 box
before it loads, so a jump into the middle of the page lands where it
aimed rather than a screenful above. Links to design documents, which the
installed page has no copy of, point at the forge.

The page is committed rather than rendered at build time, as the gesture
book is: it is user-facing text reviewed in the diff, and the three
packagers then only copy it. `traces manual-check` fails in CI when the
committed page is not the render of the README, and the pre-commit hook
regenerates it when the README is staged.
This commit is contained in:
2026-09-24 22:32:30 -04:00
parent d8e031888e
commit 10216355c1
10 changed files with 872 additions and 2 deletions
+1
View File
@@ -39,6 +39,7 @@ use std::path::{Path, PathBuf};
use serde::Serialize;
pub mod gestures;
pub mod manual;
/// Requirement ID prefixes that participate in coverage.
///
+34
View File
@@ -6,6 +6,8 @@
//! traces check # gate: non-zero exit on failure
//! traces gestures # write docs/gestures.md and the app's gesture table
//! traces gestures-check # gate: non-zero exit if either has drifted
//! traces manual # write docs/manual/index.html from its README
//! traces manual-check # gate: non-zero exit if the page has drifted
//! ```
//!
//! The gesture half scans a different tag out of the same files — see
@@ -59,6 +61,12 @@ const GESTURE_TABLE: &str = "ui/dr-ui/src/gesture_book.rs";
/// its own examples is a scanner nobody can document.
const GESTURE_ROOTS: &[&str] = &["ui", "apps"];
/// The manual's source, and the page rendered from it that the application
/// carries. Committed and gated like the gesture book — see
/// [`traceability::manual`] for why it is not rendered at build time.
const MANUAL_SOURCE: &str = "docs/manual/README.md";
const MANUAL_PAGE: &str = "docs/manual/index.html";
fn main() -> Result<()> {
let base = repo_root()?;
let mode = std::env::args().nth(1).unwrap_or_else(|| "report".into());
@@ -66,6 +74,9 @@ fn main() -> Result<()> {
if mode.starts_with("gestures") {
return run_gestures(&base, mode == "gestures-check");
}
if mode.starts_with("manual") {
return run_manual(&base, mode == "manual-check");
}
let req_path = base.join("docs/dev/requirements.md");
let markdown = std::fs::read_to_string(&req_path)
@@ -191,6 +202,29 @@ fn run_gestures(base: &Path, check: bool) -> Result<()> {
Ok(())
}
/// Render the manual to its page, or check the committed page is that render.
fn run_manual(base: &Path, check: bool) -> Result<()> {
let source = std::fs::read_to_string(base.join(MANUAL_SOURCE))
.with_context(|| format!("reading {MANUAL_SOURCE}"))?;
let page = manual::render_html(&source);
let heads = manual::headings(&source);
println!("headings {}", heads.len());
if heads.is_empty() {
bail!("no headings parsed from {MANUAL_SOURCE} — misconfigured, not an empty manual");
}
if check {
let have = std::fs::read_to_string(base.join(MANUAL_PAGE)).unwrap_or_default();
if have != page {
bail!("{MANUAL_PAGE} is stale — run: cargo run -p traceability -- manual");
}
println!("\nmanual gate: PASS");
return Ok(());
}
std::fs::write(base.join(MANUAL_PAGE), &page)?;
println!("\nwrote {MANUAL_PAGE}");
Ok(())
}
/// Structural checks that fail regardless of the coverage threshold.
fn gate(files: &[PathBuf], defined: &DefinedRequirements, cov: &Coverage) -> Result<()> {
// A run that parsed nothing is misconfigured, not passing.
+503
View File
@@ -0,0 +1,503 @@
//! The manual, as a page the application can carry.
//!
//! # Why it is rendered at all
//!
//! `docs/manual/README.md` is read on the forge, where Markdown is rendered for
//! free. The application has no forge: an installed copy — on a laptop with no
//! network, or on a tablet — can only show the manual if it ships as something
//! a browser opens by itself. One HTML page with its pictures beside it is the
//! least that does, and it is what the help sheet's "See it" links land on.
//!
//! # Why it is committed, like the gesture book
//!
//! The same argument `gestures::render_rust` makes, with one more reason. The
//! page is user-facing text and is reviewed like any: a rendering change shows
//! in the diff. And the three packagers — makepkg, the Windows container and
//! the APK assembler — then only *copy* it. Generating it at build time would
//! mean each of them running this tool, and two of them run in images that do
//! not build the tool today. CI already fails when a generated artefact drifts
//! from its source (`manual-check`), so committing costs nothing in freshness.
//!
//! # Anchors
//!
//! Every heading gets the id the forges give it — lower case, punctuation
//! dropped, spaces to hyphens, `-1`, `-2` on a repeat — so a link written
//! against `README.md#rating-and-flagging` on the forge and one written against
//! the bundled page are the same link. [`anchors`] is the list the gesture
//! scan checks its `manual:` fields against, computed by the same function the
//! page is rendered with, so the two cannot disagree about what exists.
use std::collections::BTreeMap;
use pulldown_cmark::{CowStr, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
/// Where a link that leaves the manual's own directory goes.
///
/// The bundled page has no `docs/dev/` beside it — only the manual and its
/// pictures are installed — so a relative link to a design document would be
/// a dead link on every installed copy. It goes to the forge instead, which
/// is where the reader of a design document is anyway.
pub const FORGE_TREE: &str = "https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/";
/// The manual's directory inside the repository, for resolving its links.
const MANUAL_DIR: &str = "docs/manual";
/// One heading of the manual, with the id the page gives it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Heading {
pub level: u8,
pub text: String,
pub id: String,
}
fn options() -> Options {
Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH
}
fn level_number(level: HeadingLevel) -> u8 {
match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
}
}
/// The forge's slug for a heading's text.
///
/// Letters and digits kept (lower-cased), hyphens and underscores kept, a space
/// becomes a hyphen, everything else — commas, backticks, colons — is dropped.
pub fn slug(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.trim().chars() {
if c.is_alphanumeric() || c == '-' || c == '_' {
out.extend(c.to_lowercase());
} else if c == ' ' {
out.push('-');
}
}
out
}
/// Every heading in document order, each with a unique id.
pub fn headings(markdown: &str) -> Vec<Heading> {
let mut out = Vec::new();
let mut seen: BTreeMap<String, usize> = BTreeMap::new();
let mut current: Option<(u8, String)> = None;
for event in Parser::new_ext(markdown, options()) {
match event {
Event::Start(Tag::Heading { level, .. }) => {
current = Some((level_number(level), String::new()));
}
Event::Text(t) | Event::Code(t) => {
if let Some((_, text)) = current.as_mut() {
text.push_str(&t);
}
}
Event::End(TagEnd::Heading(_)) => {
if let Some((level, text)) = current.take() {
let base = slug(&text);
let n = seen.entry(base.clone()).or_insert(0);
let id = if *n == 0 {
base.clone()
} else {
format!("{base}-{n}")
};
*n += 1;
out.push(Heading { level, text, id });
}
}
_ => {}
}
}
out
}
/// Every anchor the rendered page has.
pub fn anchors(markdown: &str) -> Vec<String> {
headings(markdown).into_iter().map(|h| h.id).collect()
}
/// A link as the bundled page needs it.
///
/// Absolute URLs, in-page fragments and the manual's own pictures stay as
/// written. Anything else is a path relative to `docs/manual/` that the
/// installed page does not have, and becomes the same file on the forge.
fn rewrite_link(dest: &str) -> String {
if dest.contains("://") || dest.starts_with('#') || dest.starts_with("mailto:") {
return dest.to_string();
}
if dest.starts_with("media/") {
return dest.to_string();
}
let (path, fragment) = match dest.split_once('#') {
Some((p, f)) => (p, Some(f)),
None => (dest, None),
};
let mut parts: Vec<&str> = MANUAL_DIR.split('/').collect();
for seg in path.split('/') {
match seg {
"" | "." => {}
".." => {
parts.pop();
}
s => parts.push(s),
}
}
let mut out = format!("{FORGE_TREE}{}", parts.join("/"));
if let Some(f) = fragment {
out.push('#');
out.push_str(f);
}
out
}
fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
_ => out.push(c),
}
}
out
}
/// The page: `docs/manual/index.html`.
pub fn render_html(markdown: &str) -> String {
let heads = headings(markdown);
let title = heads
.iter()
.find(|h| h.level == 1)
.map(|h| h.text.clone())
.unwrap_or_else(|| "DarkRoom".into());
let events: Vec<Event> = Parser::new_ext(markdown, options()).collect();
let mut out_events: Vec<Event> = Vec::with_capacity(events.len());
let mut ids = heads.iter().map(|h| h.id.clone());
let mut i = 0;
while i < events.len() {
match &events[i] {
Event::Start(Tag::Heading {
level,
classes,
attrs,
..
}) => {
out_events.push(Event::Start(Tag::Heading {
level: *level,
id: ids.next().map(CowStr::from),
classes: classes.clone(),
attrs: attrs.clone(),
}));
i += 1;
}
// A paragraph that is one picture and nothing else is a figure,
// and its alt text — written in this manual as a caption, "Rating
// two photographs, then filtering…" — is shown as one. Alt text is
// otherwise only read by a screen reader, and here it is the only
// sentence saying what a moving picture is doing.
Event::Start(Tag::Paragraph) => {
if let Some((html, used)) = figure(&events[i..]) {
out_events.push(Event::Html(html.into()));
i += used;
} else {
out_events.push(events[i].clone());
i += 1;
}
}
Event::Start(Tag::Link {
link_type,
dest_url,
title,
id,
}) => {
out_events.push(Event::Start(Tag::Link {
link_type: *link_type,
dest_url: rewrite_link(dest_url).into(),
title: title.clone(),
id: id.clone(),
}));
i += 1;
}
other => {
out_events.push(other.clone());
i += 1;
}
}
}
let mut body = String::new();
pulldown_cmark::html::push_html(&mut body, out_events.into_iter());
// The contents: each section, and its subsections under it. Level 1 is
// the page's title and level 4 and below too fine to navigate by.
let mut groups: Vec<(&Heading, Vec<&Heading>)> = Vec::new();
for h in &heads {
match (h.level, groups.last_mut()) {
(2, _) | (3, None) => groups.push((h, Vec::new())),
(3, Some((_, children))) => children.push(h),
_ => {}
}
}
let item = |h: &Heading| format!("<a href=\"#{}\">{}</a>", h.id, escape(&h.text));
let mut toc = String::from("<ul>\n");
for (h, children) in &groups {
toc.push_str("<li>");
toc.push_str(&item(h));
if !children.is_empty() {
toc.push_str("\n<ul>\n");
for c in children {
toc.push_str(&format!("<li>{}</li>\n", item(c)));
}
toc.push_str("</ul>\n");
}
toc.push_str("</li>\n");
}
toc.push_str("</ul>\n");
let mut page = String::new();
page.push_str("<!DOCTYPE html>\n");
page.push_str("<!-- GENERATED FILE — do not edit by hand. -->\n");
page.push_str(
"<!-- Source: docs/manual/README.md. Regenerate: cargo run -p traceability -- manual -->\n",
);
page.push_str("<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n");
page.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
page.push_str("<meta name=\"color-scheme\" content=\"light dark\">\n");
page.push_str(&format!("<title>{}</title>\n", escape(&title)));
page.push_str("<style>\n");
page.push_str(STYLE);
page.push_str("</style>\n</head>\n<body>\n<div class=\"page\">\n");
page.push_str(
"<nav class=\"toc\" aria-label=\"Contents\">\n<p class=\"toc-title\">Contents</p>\n",
);
page.push_str(&toc);
page.push_str("</nav>\n<main>\n");
page.push_str(&body);
page.push_str("</main>\n</div>\n</body>\n</html>\n");
page
}
/// `Start(Paragraph) Start(Image) Text* End(Image) End(Paragraph)`, as one
/// figure, and how many events that was.
fn figure(events: &[Event]) -> Option<(String, usize)> {
let Some(Event::Start(Tag::Image {
dest_url, title, ..
})) = events.get(1)
else {
return None;
};
let mut alt = String::new();
let mut j = 2;
loop {
match events.get(j)? {
Event::Text(t) | Event::Code(t) => alt.push_str(t),
Event::End(TagEnd::Image) => break,
_ => return None,
}
j += 1;
}
if !matches!(events.get(j + 1)?, Event::End(TagEnd::Paragraph)) {
return None;
}
let title_attr = if title.is_empty() {
String::new()
} else {
format!(" title=\"{}\"", escape(title))
};
let html = format!(
"<figure><img loading=\"lazy\" src=\"{}\" alt=\"{}\"{title_attr}>\
<figcaption>{}</figcaption></figure>\n",
escape(&rewrite_link(dest_url)),
escape(&alt),
escape(&alt),
);
Some((html, j + 2))
}
/// The page's stylesheet, inline so the page is one file beside its pictures.
///
/// Light and dark from the system's own preference, because the page opens in
/// whatever browser the system has and should match it rather than the app.
/// `:target` marks the heading a "See it" link landed on — a jump to the middle
/// of a long page otherwise leaves the reader to find which paragraph was
/// meant.
///
/// **Every picture reserves its box before it loads** (`aspect-ratio`), which
/// is what makes that jump land. The pictures load lazily, and a lazy picture
/// above the target is zero pixels tall until it is scrolled to — so without a
/// reserved box the target moves down the page by every picture above it, a
/// screenful at a time, after the browser has already scrolled. 16:11 is the
/// window `tools/manual/record.sh` records at, so it is exact for every picture
/// today; `auto` lets one of a different shape take its own once it arrives.
/// Not `width`/`height` attributes read from the files: the pictures are in LFS
/// and CI does not fetch them, so the page would render differently there.
const STYLE: &str = r#":root {
--bg: #fbfaf8;
--ink: #1d1c1a;
--ink-dim: #5c5955;
--rule: #dedad4;
--accent: #8a4b12;
--panel: #f1eee9;
--mark: #f6e3c7;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #161514;
--ink: #e9e6e1;
--ink-dim: #a39e97;
--rule: #34312d;
--accent: #e8a25c;
--panel: #201e1c;
--mark: #43321f;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 17px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.page {
display: grid;
grid-template-columns: 15rem minmax(0, 46rem);
gap: 3rem;
justify-content: center;
padding: 2rem 1.5rem 4rem;
}
.toc {
position: sticky;
top: 1.5rem;
align-self: start;
max-height: calc(100vh - 3rem);
overflow-y: auto;
font-size: 0.9rem;
}
.toc-title {
margin: 0 0 0.5rem;
color: var(--ink-dim);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.toc ul { list-style: none; margin: 0; padding: 0; }
.toc ul ul { padding-left: 0.9rem; }
.toc li { margin: 0.2rem 0; }
.toc a { color: var(--ink-dim); text-decoration: none; }
.toc a:hover { color: var(--accent); }
main { min-width: 0; }
h1, h2, h3 { line-height: 1.25; scroll-margin-top: 1rem; }
h1 { font-size: 2.1rem; margin: 0 0 1rem; }
h2 { font-size: 1.5rem; margin: 2.6rem 0 0.8rem; padding-top: 1rem; border-top: 1px solid var(--rule); }
h3 { font-size: 1.15rem; margin: 1.8rem 0 0.6rem; }
h2:target, h3:target { background: var(--mark); border-radius: 4px; padding-left: 0.3rem; margin-left: -0.3rem; }
a { color: var(--accent); }
code {
font: 0.88em ui-monospace, "Cascadia Mono", "DejaVu Sans Mono", monospace;
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 4px;
padding: 0.05em 0.3em;
}
figure { margin: 1.4rem 0; }
figure img, main img {
display: block;
width: 100%;
height: auto;
aspect-ratio: auto 16 / 11;
border-radius: 6px;
border: 1px solid var(--rule);
}
figcaption { margin-top: 0.4rem; color: var(--ink-dim); font-size: 0.9rem; }
table { border-collapse: collapse; width: 100%; font-size: 0.95rem; }
th, td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--rule); vertical-align: top; }
th { color: var(--ink-dim); font-weight: 600; }
@media (max-width: 52rem) {
.page { grid-template-columns: minmax(0, 1fr); gap: 1rem; padding: 1rem 16px 3rem; }
.toc { position: static; max-height: none; border: 1px solid var(--rule); border-radius: 6px; padding: 0.8rem 1rem; background: var(--panel); }
body { font-size: 16px; }
}
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_slug_is_what_the_forge_makes() {
assert_eq!(slug("Rating and flagging"), "rating-and-flagging");
assert_eq!(
slug("History, snapshots, presets"),
"history-snapshots-presets"
);
assert_eq!(slug("The `Local` rail"), "the-local-rail");
}
#[test]
fn a_repeated_heading_gets_a_numbered_anchor() {
let md = "## Light\n\ntext\n\n## Light\n";
assert_eq!(anchors(md), ["light", "light-1"]);
}
#[test]
fn every_heading_carries_its_anchor_in_the_page() {
let md = "# Title\n\n## Getting about\n\n### Looking closer\n";
let html = render_html(md);
assert!(html.contains("<h2 id=\"getting-about\">"), "{html}");
assert!(html.contains("<h3 id=\"looking-closer\">"), "{html}");
assert!(html.contains("<a href=\"#looking-closer\">"), "{html}");
assert!(html.contains("<title>Title</title>"));
}
#[test]
fn a_lone_picture_is_a_captioned_figure_with_its_relative_path() {
let md = "![Scrubbing the timeline](media/library-timeline.gif)\n";
let html = render_html(md);
assert!(
html.contains("src=\"media/library-timeline.gif\""),
"{html}"
);
assert!(
html.contains("<figcaption>Scrubbing the timeline</figcaption>"),
"{html}"
);
}
#[test]
fn a_link_out_of_the_manual_goes_to_the_forge() {
assert_eq!(
rewrite_link("../dev/faces.md"),
format!("{FORGE_TREE}docs/dev/faces.md")
);
assert_eq!(
rewrite_link("../../tools/manual/README.md#x"),
format!("{FORGE_TREE}tools/manual/README.md#x")
);
assert_eq!(rewrite_link("media/a.png"), "media/a.png");
assert_eq!(rewrite_link("#top"), "#top");
assert_eq!(rewrite_link("https://x.org/"), "https://x.org/");
}
/// The real manual: one page, every section in the contents, nothing lost.
#[test]
fn the_real_manual_renders_with_its_contents() {
let md = include_str!("../../../docs/manual/README.md");
let html = render_html(md);
for h in headings(md).iter().filter(|h| h.level == 2) {
assert!(
html.contains(&format!("<a href=\"#{}\">", h.id)),
"{} missing from the contents",
h.id
);
}
assert!(!html.contains("src=\"../"), "a picture escaped the manual");
}
}