diff --git a/.gitea/workflows/traceability-check.yml b/.gitea/workflows/traceability-check.yml index c27a1f0..386a7c0 100644 --- a/.gitea/workflows/traceability-check.yml +++ b/.gitea/workflows/traceability-check.yml @@ -95,6 +95,13 @@ jobs: - name: Regenerate the gesture vocabulary and check it is committed run: cargo run -q -p traceability -- gestures-check + # The manual's page, which the packages carry and the help sheet links + # into. Blocking for the gesture book's reason: it is shown to the user, + # and a page that disagrees with the README is a manual describing an + # application that no longer exists. + - name: Regenerate the manual page and check it is committed + run: cargo run -q -p traceability -- manual-check + # Advisory, not blocking: not every file implements a requirement, and a # tag on every function is noise that rots faster than it helps. Tag the # unit that decides. diff --git a/.githooks/pre-commit b/.githooks/pre-commit index a1c5e2f..36a7586 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -26,7 +26,7 @@ fi # The artefacts are generated from the tree, so regenerating them because one # was itself edited would be circular. case "$(tr -d '[:space:]' <<< "${staged}")" in - docs/dev/traceability.md | docs/gestures.md | ui/dr-ui/src/gesture_book.rs) + docs/dev/traceability.md | docs/gestures.md | ui/dr-ui/src/gesture_book.rs | docs/manual/index.html) exit 0 ;; esac @@ -65,3 +65,17 @@ for f in docs/gestures.md ui/dr-ui/src/gesture_book.rs; do echo "pre-commit: regenerated ${f} and staged it" fi done + +# The manual's page, when its source is part of the commit. Rendered from +# nothing but the README, so there is no reason to pay for it otherwise. +if grep -qx 'docs/manual/README.md' <<< "${staged}"; then + if ! out="$(cargo run -q -p traceability -- manual 2>&1)"; then + echo "pre-commit: the manual would not render" >&2 + echo "${out}" >&2 + exit 1 + fi + if ! git diff --quiet -- docs/manual/index.html; then + git add docs/manual/index.html + echo "pre-commit: regenerated docs/manual/index.html and staged it" + fi +fi diff --git a/Cargo.lock b/Cargo.lock index 9572869..55d831d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7026,6 +7026,7 @@ name = "traceability" version = "0.14.1" dependencies = [ "anyhow", + "pulldown-cmark", "serde", "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 9e50b67..590dfea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -127,6 +127,10 @@ url = "2.5" async-trait = "0.1" serde = { version = "1", features = ["derive"] } serde_json = "1" +# The manual's HTML rendering (tools/traceability). Already in the tree as +# Slint's Markdown parser, so this adds a dependency edge and no crate; only +# the HTML writer is needed, not the command-line front end. +pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] } base64 = "0.23" # Display-server clients, for FR-DSP-8's per-display profile acquisition. diff --git a/docs/dev/traceability.md b/docs/dev/traceability.md index 339f512..84cc316 100644 --- a/docs/dev/traceability.md +++ b/docs/dev/traceability.md @@ -9,7 +9,7 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 441 | +| Source files scanned | 442 | | TRACES tags found | 1749 | | Requirements defined | 190 | | Requirements deferred (post-v1) | 24 | diff --git a/docs/manual/index.html b/docs/manual/index.html new file mode 100644 index 0000000..765537a --- /dev/null +++ b/docs/manual/index.html @@ -0,0 +1,305 @@ + + + + + + + + +DarkRoom, shown + + + +
+ +
+

DarkRoom, shown

+

A tour of what the application does, one picture per thing. Every image on +this page was captured from the desktop build driving itself — nothing is a +mock-up, and nothing has been retouched outside DarkRoom. Where a feature is +better seen moving, it moves.

+

The requirements behind each feature are in requirements.md; +the reasoning is in the design documents linked from each section. This page +is only about what you see.

+

The photographs are the author's. None show a person.

+

Opening a library

+

DarkRoom opens on a library: a folder on this machine, a folder a sync +client keeps, or a Nextcloud account. A folder needs no password and uploads +nothing.

+
The launch screen: a server field, a folder field, and which formats to scan for
The launch screen: a server field, a folder field, and which formats to scan for
+

Once a folder is named, it is the library — you are not asked for it again, +and Open library opens it whole. Subfolder… narrows the scan to part of +it. The formats ticked are what the scan looks for; RAW is on and JPEG off +by default, because a RAW editor's sensible default is the file the camera +wrote first.

+
A folder chosen: the library, whether to scan a subfolder, and the formats
A folder chosen: the library, whether to scan a subfolder, and the formats
+

The library

+
The grid: collections on the left, the timeline beside it, the roll of thumbnails, and the filter bar above
The grid: collections on the left, the timeline beside it, the roll of thumbnails, and the filter bar above
+

Photographs are ordered by capture time, with a month heading where each +begins. The strip on the left is the timeline — drag it to jump to a year. +The bar above the grid filters by rating, flag, colour label and where the +file is (on this device, or only on the server).

+

Rating and flagging

+

Hover a cell and the stars appear; click one. The filter chips above the grid +count what each rating holds, and clicking 3+ shows only those.

+
Rating two photographs, then filtering the grid to three stars and more
Rating two photographs, then filtering the grid to three stars and more
+

Colour labels work as Lightroom's do: 6 red, 7 yellow, 8 green, 9 +blue, on the photograph under the pointer or on the selection, and the same +key again takes the label off. Label on the selection bar offers all five, +purple included, and None. Each label is drawn with its initial on it, so +it reads without telling the colours apart, and the filter bar has a chip for +each. In develop, the top bar names the open photograph's label and sets it, +and the same keys work there.

+

Getting about

+

Drag the timeline to scrub through years; Ctrl and the wheel resize the +thumbnails.

+
Scrubbing the timeline
Scrubbing the timeline
+
Resizing the thumbnails with Ctrl and the wheel
Resizing the thumbnails with Ctrl and the wheel
+

Selecting several

+

Select in the header — or Ctrl-click — starts a selection. Shift-click picks +a range. The bar at the foot of the grid is everything a selection can be done +to: collections, keywords, presets, export, and merging to a panorama.

+
Twelve photographs selected, with the selection bar along the foot of the grid
Twelve photographs selected, with the selection bar along the foot of the grid
+

Keywords on that bar opens a sheet; type a word and press return, and it +is on every photograph selected. The list below the field is every keyword +the library has, ticked where the selection carries it.

+
Keywording twelve frames
Keywording twelve frames
+

Collections

+

+ at the head of the sidebar makes one. Drag a photograph — or the whole +selection — onto its row to file it there; click the row to see it. A +photograph can be in several, and the badge on its cell counts them.

+
Filing photographs in a collection by dragging them onto it
Filing photographs in a collection by dragging them onto it
+

Collections nest. Drag one onto another to put it inside; + with a +collection selected — or New collection inside from its menu — makes a +child. A parent shows everything its children hold, and its count says so. +Right-click a row (hold it, on a tablet) for the menu: rename, nest, +move back to the top level, keep it offline, delete.

+
Making Trips, nesting Alps and New York inside it, and opening the parent
Making Trips, nesting Alps and New York inside it, and opening the parent
+
Trips showing both of its children's photographs
Trips showing both of its children's photographs
+
The menu on a collection
The menu on a collection
+

Developing a photograph

+

Click a thumbnail to open it. The column on the right is every adjustment; +the strip at its head narrows it to one group.

+
The develop view: the photograph, the histogram, and the adjustment column
The develop view: the photograph, the histogram, and the adjustment column
+
Switching between the Optics, Light, Colour, Effects and Detail groups
Switching between the Optics, Light, Colour, Effects and Detail groups
+

Light

+

Exposure, contrast, highlights, shadows, blacks, whites and a tone curve. +Hold Before to see the photograph as it was.

+
Raising exposure, pulling the highlights, lifting the shadows, then holding Before
Raising exposure, pulling the highlights, lifting the shadows, then holding Before
+

Looking closer

+

Double-click for 1:1; drag to move about; double-click again to fit. The +wheel zooms to any amount in between.

+
Zooming to 1:1, panning, and back
Zooming to 1:1, panning, and back
+

White balance from the photograph

+

Press pick in the White Balance group, then click something neutral — +a white wall, a grey card, the air conditioner here. The picker sets the +sliders from the photograph, not from where they were: below, the frame +is dragged cold first and one click puts it right. A blown highlight is +refused, since a clipped pixel has no colour left to balance.

+
Cooling the frame with the slider, then picking a white air conditioner to set the white balance
Cooling the frame with the slider, then picking a white air conditioner to set the white balance
+

Composing

+

Crop by dragging the frame's corners, straighten with the slider, lock a +ratio from the chips. Done composing returns to the photograph.

+
Cropping, straightening and choosing a ratio
Cropping, straightening and choosing a ratio
+

Local adjustments

+

Local in the rail turns the column into a mask stack. Find subjects runs a +segmentation model over the photograph; what it recognises appears as a list +of categories with how much of the frame each covers. Click one and it is a +mask — then every slider below edits only that region.

+
What the model found in an urban scene: ground, architecture, sky, vegetation
What the model found in an urban scene: ground, architecture, sky, vegetation
+
The sky chosen: tinted on the photograph, and the column now scoped to it
The sky chosen: tinted on the photograph, and the column now scoped to it
+

A mask is a stack of parts. Paint into it, subtract a gradient from it, grow +or shrink its edge, choose how it falls off. Show masks as draws the mask +tinted, as alpha, or as an outline; the eye on its row switches it off.

+
Looking at the mask three ways, painting into it, then growing its edge
Looking at the mask three ways, painting into it, then growing its edge
+

Linear and radial gradients, a tone range and a colour range are the other +ways to make one; each can be combined with any other.

+

Repair

+

Repair in the rail: click a mark and it is covered from a source DarkRoom +chooses beside it. Drag either circle to move it; the size, feather and +opacity are in the panel. Heal blends; clone copies.

+
Covering marks on a road
Covering marks on a road
+

Film

+

The Film chooser at the head of Adjust applies a spectral simulation of a +named stock; below it, the print exposure and push controls a film has and a +sensor does not.

+
Choosing Velvia, then holding Before
Choosing Velvia, then holding Before
+

History, snapshots, presets

+

Every change is a step; Undo and the History panel walk them. Snapshot +keeps the current state under a name. Presets… saves the settings to +apply elsewhere, and imports .xmp from other applications.

+
The presets sheet
The presets sheet
+

Merging a panorama

+

Select the frames, then Merge to panorama from the selection bar. The +frames are read, aligned, and drawn on the suggested projection with each one +outlined where it landed — twelve hand-held portrait frames across an alpine +valley, here. Change the projection (a 150° sweep on a flat perspective is +what the middle of the film shows, and why cylindrical is suggested), ask for +the border to be filled rather than cropped, then Merge. The composite is +written beside its sources as a DNG and appears in the grid with the merge +as the first step in its history.

+
Twelve frames aligned, the projections tried, and the border filled
Twelve frames aligned, the projections tried, and the border filled
+
The alignment on a cylinder, each frame outlined where it landed
The alignment on a cylinder, each frame outlined where it landed
+
The same, with the ragged border filled by the model rather than cropped away
The same, with the ragged border filled by the model rather than cropped away
+

Export

+

Export in the develop header, or Export N from a selection. Format, +size, colour space, sharpening, naming and where the file goes are in +Settings, and apply to every export until changed. An export with no folder +set is refused, and the header says so.

+
Export defaults in Settings
Export defaults in Settings
+

Settings

+
The settings page: background activity, indexing, storage, display
The settings page: background activity, indexing, storage, display
+

Background activity with progress, thumbnail and face indexing, storage on +this device, display and colour, export defaults, what is written to XMP +sidecars, and a diagnostics bundle for a bug report.

+

People

+

Face detection and identity run over the library and group faces by person; +the Identity page is where suggestions are confirmed, rejected and split, +and People on the filter bar narrows the grid to someone. Not pictured +here, for the obvious reason — faces.md has the design.

+

Where things are written down

+ + + + + + + + +
FeatureDesign
Local masks and segmentationsegmentation.md, mask-editing.md
Repairspot-removal.md
Panoramapanorama.md
Faces and identityfaces.md
Gestures, generated from the codegestures.md
Navigation and layoutui-navigation.md
Sync and storagestorage.md
+

How this page is made

+

tools/manual/ drives the desktop build on +a private X server and records each scene; record.sh <library> re-makes +every picture here. Run it after a change to the interface and commit what +changed. The pictures are in LFS.

+

Making it the first time turned up nine faults, each fixed in its own +commit before the pictures were taken: the folder picker could not choose +the top level, month headings overprinted each other, a category mask +widened the column off the window, the mask tint outlived its mode, the +first sync uploaded an empty thumbnail shard, a merge that ran out of GPU +memory left the page on Stop for ever, the export settings promised to ask +for a folder and did not, the keyword sheet sent its keys to the grid, and +an empty trash told you to check your library folder.

+
+
+ + diff --git a/tools/traceability/Cargo.toml b/tools/traceability/Cargo.toml index bf95d8a..b856ccd 100644 --- a/tools/traceability/Cargo.toml +++ b/tools/traceability/Cargo.toml @@ -18,3 +18,4 @@ path = "src/main.rs" anyhow.workspace = true serde = { workspace = true } serde_json.workspace = true +pulldown-cmark.workspace = true diff --git a/tools/traceability/src/lib.rs b/tools/traceability/src/lib.rs index 3050aef..6cb4bc6 100644 --- a/tools/traceability/src/lib.rs +++ b/tools/traceability/src/lib.rs @@ -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. /// diff --git a/tools/traceability/src/main.rs b/tools/traceability/src/main.rs index be76394..58cb70c 100644 --- a/tools/traceability/src/main.rs +++ b/tools/traceability/src/main.rs @@ -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. diff --git a/tools/traceability/src/manual.rs b/tools/traceability/src/manual.rs new file mode 100644 index 0000000..5ec330c --- /dev/null +++ b/tools/traceability/src/manual.rs @@ -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 { + let mut out = Vec::new(); + let mut seen: BTreeMap = 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 { + 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("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + _ => 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 = Parser::new_ext(markdown, options()).collect(); + let mut out_events: Vec = 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!("{}", h.id, escape(&h.text)); + let mut toc = String::from("
    \n"); + for (h, children) in &groups { + toc.push_str("
  • "); + toc.push_str(&item(h)); + if !children.is_empty() { + toc.push_str("\n
      \n"); + for c in children { + toc.push_str(&format!("
    • {}
    • \n", item(c))); + } + toc.push_str("
    \n"); + } + toc.push_str("
  • \n"); + } + toc.push_str("
\n"); + + let mut page = String::new(); + page.push_str("\n"); + page.push_str("\n"); + page.push_str( + "\n", + ); + page.push_str("\n\n\n"); + page.push_str("\n"); + page.push_str("\n"); + page.push_str(&format!("{}\n", escape(&title))); + page.push_str("\n\n\n
\n"); + page.push_str( + "\n
\n"); + page.push_str(&body); + page.push_str("
\n
\n\n\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!( + "
\"{}\"{title_attr}\ +
{}
\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("

"), "{html}"); + assert!(html.contains("

"), "{html}"); + assert!(html.contains(""), "{html}"); + assert!(html.contains("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("
Scrubbing the timeline
"), + "{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!("
", h.id)), + "{} missing from the contents", + h.id + ); + } + assert!(!html.contains("src=\"../"), "a picture escaped the manual"); + } +}