Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/dwarves

xtask/src/docs.rs

//! `cargo xtask-docs docs` — render dwarves' real `.nornir/*.md` doc set to a
//! **typeset SVG book** via nornir's docs-export pipeline (the SAME
//! markdown→typst→SVG path `nornir docs book --format svg` drives), and write the
//! committed SVG page assets the `dwarves-gui` Manual pane embeds for its default
//! **Typeset (SVG)** variant.
//!
//! Pure Rust, zero shell: the render is `nornir_docbook::export` (typst-as-lib +
//! typst-svg) called in-process — the edda render leaf, not the whole nornir
//! graph; no `nornir` binary shelled, no warehouse touched
//! (this bypasses the marker-expanding book assembler and feeds the raw `.nornir`
//! sources straight to the per-chapter SVG renderer, so it never reaches the locked
//! warehouse). Output: `dwarves-gui/assets/manual-svg/page-NNN.svg` (one crisp
//! vector page each) + `toc.tsv` (`<0-based-page-index>\t<chapter title>`), both
//! committed and `include`d by `dwarves-gui/src/manual.rs`.
//!
//! Gated behind the xtask `docs` feature so the lean `demo-wasm` / `deploy-pages`
//! verbs never drag nornir's typst stack into the automation crate. Run it via the
//! dedicated alias: `cargo xtask-docs docs`.

use std::path::{Path, PathBuf};

use nornir_docbook::export::{
    export_svg_pages, export_svg_pages_per_chapter, ChapterInput, ExportMeta,
};

/// The first `# ` heading in `md`, if any (the chapter's display title).
fn first_h1(md: &str) -> Option<String> {
    for line in md.lines() {
        let t = line.trim_start();
        if let Some(rest) = t.strip_prefix("# ") {
            let h = rest.trim();
            if !h.is_empty() {
                return Some(h.to_string());
            }
        }
    }
    None
}

/// `holger-build-thing` → `Holger Build Thing` (fallback title when a doc has no H1).
fn titleize(stem: &str) -> String {
    stem.split(['-', '_'])
        .filter(|w| !w.is_empty())
        .map(|w| {
            let mut c = w.chars();
            match c.next() {
                Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
                None => String::new(),
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

fn starts_with_h1(md: &str) -> bool {
    md.lines()
        .map(str::trim_start)
        .find(|l| !l.is_empty())
        .map(|l| l.starts_with("# "))
        .unwrap_or(false)
}

/// Ensure the chapter body opens with a level-1 heading (typst pagebreaks on H1, so
/// every chapter starts on a fresh page) — mirrors nornir's book assembler.
fn ensure_h1(title: &str, raw: &str) -> String {
    let mut out = String::new();
    if !starts_with_h1(raw) {
        out.push_str("# ");
        out.push_str(title);
        out.push_str("\n\n");
    }
    out.push_str(raw.trim_end());
    out.push('\n');
    out
}

struct Doc {
    title: String,
    /// Body with a leading H1 ensured and image paths rewritten root-relative.
    body: String,
}

/// Whether `name` is a **start-here manual** chapter — the `README` and the beginner
/// guides (`*-for-idiots.md` / `for-idiots.md`): exactly the "🔰 For idiots (start
/// here)" set the Manual pane surfaces first, and the set a live demo is driven from.
///
/// The design / plan / spec / guide / reasoning / history docs stay in the Markdown
/// variant (still embedded, still searchable — the toggle carries the FULL doc set)
/// but are kept OUT of the typeset book. They are internal design notes rather than
/// "manual" reading, and — critically — typst outlines every glyph to a vector path,
/// so the committed SVG book (and the wasm web-demo bundle that embeds it) grows by
/// hundreds of KB per rendered page: the full set is ~90 MB, the guide set ~27 MB.
/// The curated start-here book keeps the crisp typeset default lean; the Markdown
/// toggle is the complete manual.
fn is_manual_chapter(name: &str) -> bool {
    let lower = name.to_ascii_lowercase();
    let Some(stem) = lower.strip_suffix(".md") else {
        return false;
    };
    stem == "readme" || stem == "for-idiots" || stem.ends_with("-for-idiots")
}

/// Collect dwarves' reader-facing manual doc set from `.nornir/*.md` (see
/// [`is_manual_chapter`]) — README first, then alphabetical (a stable, deterministic
/// book order). Template scaffolds (`_*`) are excluded, and each doc's relative image
/// links are rewritten to repo-root-relative paths that resolve off the mounted typst
/// filesystem root (nornir's book assembler does the same).
fn collect_docs(nornir_dir: &Path, repo_root: &Path) -> Vec<Doc> {
    let mut files: Vec<PathBuf> = std::fs::read_dir(nornir_dir)
        .expect("read .nornir dir")
        .flatten()
        .map(|e| e.path())
        .filter(|p| {
            let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
            p.extension().and_then(|e| e.to_str()) == Some("md")
                && !name.starts_with('_')
                && is_manual_chapter(name)
        })
        .collect();
    files.sort_by(|a, b| {
        let rank = |p: &Path| -> u8 {
            match p.file_name().and_then(|n| n.to_str()) {
                Some("README.md") => 0,
                _ => 1,
            }
        };
        rank(a).cmp(&rank(b)).then_with(|| a.cmp(b))
    });
    files
        .into_iter()
        .map(|p| {
            let raw = std::fs::read_to_string(&p).expect("read doc");
            let stem = p
                .file_stem()
                .and_then(|s| s.to_str())
                .unwrap_or("doc")
                .to_string();
            let title = first_h1(&raw).unwrap_or_else(|| titleize(&stem));
            let rewritten = rewrite_image_paths(&raw, &p, repo_root);
            let body = ensure_h1(&title, &rewritten);
            Doc { title, body }
        })
        .collect()
}

/// Rewrite each markdown image's relative path to a repo-root-relative path that
/// exists on disk: try source-relative first (web/markdown semantics — a `.nornir`
/// doc's `assets/x.svg` means `.nornir/assets/x.svg`), then root-relative (the value
/// as written). Absolute / `http(s):` / `data:` refs and unknown paths are left
/// untouched. Ported from nornir's `docs::book::rewrite_image_paths` (which the xtask
/// bypasses by feeding raw sources straight to the SVG renderer).
fn rewrite_image_paths(body: &str, source: &Path, repo_root: &Path) -> String {
    let src_dir = source.parent().unwrap_or(repo_root);
    let mut out = String::with_capacity(body.len());
    let bytes = body.as_bytes();
    let mut i = 0;
    while i < body.len() {
        if bytes[i] == b'!' && i + 1 < body.len() && bytes[i + 1] == b'[' {
            if let Some(close_alt) = body[i..].find("](") {
                let lp = i + close_alt + 2;
                if let Some(rel_close) = body[lp..].find(')') {
                    let inner = &body[lp..lp + rel_close];
                    let (raw_path, title) = match inner.find(char::is_whitespace) {
                        Some(sp) => (&inner[..sp], &inner[sp..]),
                        None => (inner, ""),
                    };
                    let skip = raw_path.is_empty()
                        || raw_path.starts_with('/')
                        || raw_path.starts_with("http://")
                        || raw_path.starts_with("https://")
                        || raw_path.starts_with("data:");
                    let resolved = if skip {
                        None
                    } else {
                        let src_rel = src_dir.join(raw_path);
                        if src_rel.is_file() {
                            src_rel
                                .strip_prefix(repo_root)
                                .ok()
                                .map(|p| p.to_string_lossy().replace('\\', "/"))
                        } else {
                            None
                        }
                    };
                    if let Some(newp) = resolved {
                        out.push_str(&body[i..lp]);
                        out.push_str(&newp);
                        out.push_str(title);
                        out.push(')');
                        i = lp + rel_close + 1;
                        continue;
                    }
                }
            }
        }
        let ch = body[i..].chars().next().unwrap();
        out.push(ch);
        i += ch.len_utf8();
    }
    out
}

/// Render the doc set to per-page SVG + TOC and write the committed assets.
pub fn run() {
    // Workspace root = parent of the xtask crate dir.
    let ws_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("xtask dir has a parent")
        .to_path_buf();
    let nornir_dir = ws_root.join(".nornir");
    let out_dir = ws_root.join("dwarves-gui/assets/manual-svg");

    let meta = ExportMeta {
        title: "dwarves — manual".to_string(),
        version: workspace_version(&ws_root),
        generated: chrono_date(),
        cover_image: String::new(),
    };

    let mut docs = collect_docs(&nornir_dir, &ws_root);
    println!("docs: collected {} real .nornir manual sources", docs.len());

    // Build the chapter inputs once (title + H1-ensured body + a first-page text
    // marker so each chapter's start page is recoverable from the sidecar).
    let mk_chapters = |docs: &[Doc]| -> Vec<ChapterInput> {
        docs.iter()
            .map(|d| ChapterInput {
                title: d.title.clone(),
                markdown: d.body.clone(),
                text: Some(d.title.clone()),
            })
            .collect()
    };

    // Optimistic: one per-chapter render over the whole set (each chapter is its own
    // small typst compile → ~linear). If typst rejects a construct in some doc, fall
    // back to validating each doc alone and dropping the offender(s) — one bad doc
    // must not sink the whole book.
    let (pages, page_texts) =
        match export_svg_pages_per_chapter(&mk_chapters(&docs), &meta, None, Some(&ws_root)) {
            Ok(out) => out,
            Err(e) => {
                eprintln!("docs: whole-set render failed ({e}); validating per-doc…");
                docs.retain(
                    |d| match export_svg_pages(&d.body, &meta, None, Some(&ws_root)) {
                        Ok(_) => true,
                        Err(err) => {
                            eprintln!("docs: SKIP `{}` — typst rejected it: {err}", d.title);
                            false
                        }
                    },
                );
                export_svg_pages_per_chapter(&mk_chapters(&docs), &meta, None, Some(&ws_root))
                    .expect("per-chapter render after dropping bad docs")
            }
        };

    // Chapter start pages are exactly the indices whose text sidecar is `Some`
    // (front-matter + continuation pages are `None`), aligned to chapter order.
    let chapters = mk_chapters(&docs);
    let starts: Vec<usize> = page_texts
        .iter()
        .enumerate()
        .filter(|(_, t)| t.is_some())
        .map(|(i, _)| i)
        .collect();
    assert_eq!(starts.len(), chapters.len(), "one start page per chapter");

    // Fresh output dir (drop any stale pages from a previous run).
    if out_dir.exists() {
        for entry in std::fs::read_dir(&out_dir).unwrap().flatten() {
            let p = entry.path();
            if p.extension().and_then(|e| e.to_str()) == Some("svg")
                || p.file_name().and_then(|n| n.to_str()) == Some("toc.tsv")
            {
                let _ = std::fs::remove_file(p);
            }
        }
    }
    std::fs::create_dir_all(&out_dir).unwrap();

    let mut total = 0usize;
    for (i, p) in pages.iter().enumerate() {
        let name = format!("page-{:03}.svg", i + 1);
        std::fs::write(out_dir.join(&name), p).unwrap();
        total += p.len();
    }

    let mut toc = String::new();
    for (start, ch) in starts.iter().zip(&chapters) {
        toc.push_str(&format!("{}\t{}\n", start, ch.title));
    }
    std::fs::write(out_dir.join("toc.tsv"), &toc).unwrap();

    println!(
        "docs: wrote {} SVG pages ({} chapters, {:.2} MB) + toc.tsv → {}",
        pages.len(),
        chapters.len(),
        total as f64 / 1_048_576.0,
        out_dir.strip_prefix(&ws_root).unwrap_or(&out_dir).display()
    );
}

/// The real workspace version (`[workspace.package] version` in the root
/// `Cargo.toml`) for the book's cover stamp — read at runtime, dependency-free,
/// so the manual can never carry a stale hardcoded constant. Fails loudly if
/// the manifest cannot be read or the key is missing: a wrong stamp is worse
/// than no book.
fn workspace_version(ws_root: &Path) -> String {
    let manifest = ws_root.join("Cargo.toml");
    let text = std::fs::read_to_string(&manifest)
        .unwrap_or_else(|e| panic!("docs: read {}: {e}", manifest.display()));
    let mut in_pkg = false;
    for line in text.lines() {
        let t = line.trim();
        if t.starts_with('[') {
            in_pkg = t == "[workspace.package]";
        } else if in_pkg {
            if let Some(v) = t
                .strip_prefix("version")
                .map(str::trim_start)
                .and_then(|r| r.strip_prefix('='))
                .and_then(|r| r.trim_start().strip_prefix('"'))
                .and_then(|r| r.split('"').next())
            {
                return v.to_string();
            }
        }
    }
    panic!(
        "docs: no [workspace.package] version in {}",
        manifest.display()
    );
}

/// Today's date (`YYYY-MM-DD`) without pulling a date crate — reads it off the
/// system clock via `SystemTime`. Good enough for the doc front-matter stamp.
fn chrono_date() -> String {
    use std::time::{SystemTime, UNIX_EPOCH};
    let secs = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs();
    // Days since epoch → civil date (Howard Hinnant's algorithm).
    let days = (secs / 86_400) as i64;
    let z = days + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = z - era * 146_097;
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if m <= 2 { y + 1 } else { y };
    format!("{:04}-{:02}-{:02}", y, m, d)
}