//! 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"); } }