crates/nornir-docgen/src/book.rs
//! Whole-documentation "book" — assemble *every* markdown source in a repo
//! into one typst-rendered document (PDF / HTML / MD).
//!
//! Where [`super::export::export_repo`] renders a single file (the generated
//! `README.md`), the book gathers the repo's entire doc set:
//!
//! 1. every `*.md` under `.nornir/` (the editable sources — README, CHANGELOG,
//! design notes, this crate's docs-generation reference, …);
//! 2. every other top-level `<repo>/*.md` (e.g. `plan.md`), *excluding* the
//! generated managed artifacts (`README.md` / `CHANGELOG.md`) whose source
//! already comes from `.nornir/`, so nothing is duplicated.
//!
//! Each file becomes a chapter. Marker sections (`<!-- nornir:gen:start:… -->`)
//! are expanded per file via [`super::sections`], so bench tables / dep-graphs
//! are fresh. The chapters are concatenated into one markdown string and handed
//! to the existing typst pipeline with the repo root mounted as the filesystem
//! root, so local images (`.nornir/assets/…`) embed.
//!
//! Everything goes through **typst** — no second rendering engine.
//!
//! Cargo feature: `export` (nornir maps its `docs-export` onto it).
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use super::export::{self, ChapterInput, DocFormat, ExportMeta};
use super::layout::MANAGED_DOCS;
use super::sections::{Ctx, rewrite_str};
/// One assembled chapter of the book.
#[derive(Debug)]
pub struct Chapter {
/// Source file the chapter came from.
pub source: PathBuf,
/// Display title (the file's own leading `# H1`, or derived from its name).
pub title: String,
/// Markdown body with marker sections already expanded.
pub markdown: String,
/// True for a synthetic section-divider (a manifest section header) rather
/// than a real source file — see [`book_plan`].
pub is_section: bool,
}
/// The catch-all back-matter section: every `.nornir/*.md` not placed by the
/// manifest lands here. Working notes / musings, explicitly non-authoritative.
const TANKEGANGAR: &str = "Tankegångar";
/// Section heading for the per-crate cards gathered from non-root `.nornir/`
/// directories (cards that travel with their crate).
const CRATES_SECTION: &str = "Crates";
/// One entry in the ordered book plan: either a section-divider page or a real
/// source file.
enum PlanItem {
Divider(String),
File(PathBuf),
}
/// A `.nornir/book.toml` manifest: ordered curated sections. Any `.nornir/*.md`
/// not named by a section falls through to [`TANKEGANGAR`], so nothing is lost
/// and nothing aspirational leaks into the curated body.
#[derive(serde::Deserialize)]
struct BookManifest {
#[serde(default)]
section: Vec<ManifestSection>,
}
#[derive(serde::Deserialize)]
struct ManifestSection {
title: String,
#[serde(default)]
files: Vec<String>,
}
/// Discover and assemble every doc source in `repo_root` into ordered chapters.
///
/// Order: `.nornir/README.md`, `.nornir/CHANGELOG.md`, then the remaining
/// `.nornir/*.md` alphabetically, then the non-generated top-level `*.md`
/// alphabetically.
pub fn collect_chapters(repo_root: &Path, ctx: &Ctx) -> Result<Vec<Chapter>> {
let mut chapters = Vec::new();
for item in book_plan(repo_root, ctx.manual) {
let path = match item {
PlanItem::Divider(title) => {
// A synthetic section cover page. Tankegångar carries an explicit
// "not authoritative" caveat so the reader knows the curated body
// (Reference + Design) is the source of truth, not these notes.
let markdown = if title == TANKEGANGAR {
format!(
"# {title}\n\n_Working notes, design musings and history — \
**not authoritative**. The source of truth is the code and \
the curated chapters above._\n"
)
} else {
format!("# {title}\n")
};
chapters.push(Chapter {
source: repo_root.join(".nornir"),
title,
markdown,
is_section: true,
});
continue;
}
PlanItem::File(path) => path,
};
chapters.push(build_chapter(&path, repo_root, ctx)?);
}
Ok(chapters)
}
/// Read one source file into a [`Chapter`]: expand its marker sections, rewrite
/// its image paths to repo-root-relative, and derive its title. Shared by the
/// sectioned book ([`collect_chapters`]) and the class-routed books
/// ([`build_books`]).
fn build_chapter(path: &Path, repo_root: &Path, ctx: &Ctx) -> Result<Chapter> {
let raw = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
// Expand marker sections. A file that isn't a nornir source (no markers,
// or a stray marker-looking line) must not sink the whole book — fall
// back to its raw text on any rewrite error.
let body = match rewrite_str(&raw, ctx) {
Ok((filled, _)) => filled,
Err(_) => raw,
};
// Resolve image paths so they embed regardless of which convention a
// doc uses. The book mounts repo-root as the typst FS root, so image
// paths must be ROOT-relative — but a `.nornir/*.md` file may reference
// an asset SOURCE-relative (`assets/x.svg`, the markdown/web convention,
// = `.nornir/assets/x.svg`) OR already root-relative (`.nornir/assets/x.svg`).
// Try source-relative first, then root-relative, and emit whichever
// exists as a root-relative path. Fixes nornir's `assets/…` refs without
// breaking holger's `.nornir/assets/…` refs.
let body = rewrite_image_paths(&body, path, repo_root);
let title = chapter_title(path, &body);
Ok(Chapter {
source: path.to_path_buf(),
title,
markdown: body,
is_section: false,
})
}
/// The ordered book plan. With a `.nornir/book.toml` manifest the book is
/// **sectioned** — README/CHANGELOG front matter, then each curated section, then
/// a `Tankegångar` appendix of everything unlisted. Without a manifest it stays
/// the legacy **flat** discovery (so only repos that opt in change).
fn book_plan(repo_root: &Path, manual: bool) -> Vec<PlanItem> {
match load_manifest(repo_root) {
Some(sections) if !sections.is_empty() => sectioned_plan(repo_root, §ions, manual),
_ => discover_sources(repo_root)
.into_iter()
.filter(|p| !manual || is_manual_chapter(p))
.map(PlanItem::File)
.collect(),
}
}
/// In **manual** (thin-book) mode, whether a source file belongs in the shipped
/// manual. Front matter (README/CHANGELOG) and design/guide/benchmark chapters
/// stay; the non-manual classes — reasoning, history, unsorted, the idiot/dummy
/// guides, and the `docs-generation` doctrine reference — are excluded so the
/// `docs/book.pdf` stays thin. Nothing is deleted; the sources remain in `.nornir/`.
fn is_manual_chapter(path: &Path) -> bool {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_ascii_lowercase();
if name == "docs-generation.md" {
return false;
}
const NON_MANUAL_SUFFIXES: &[&str] = &[
"-reasoning.md",
"-history.md",
"-unsorted.md",
"-for-idiots.md",
"-for-dummies.md",
"-idiots.md",
"-idiot-guide.md",
];
if let Some(stem) = name.strip_suffix(".md") {
// Bare class files: reasoning.md / history.md / unsorted.md.
if matches!(stem, "reasoning" | "history" | "unsorted") {
return false;
}
}
!NON_MANUAL_SUFFIXES.iter().any(|s| name.ends_with(s))
}
/// Build the sectioned plan from a manifest: front matter (README, CHANGELOG),
/// each non-empty section under a divider (files in manifest order), then every
/// remaining `.nornir/*.md` and non-generated top-level `*.md` under `Tankegångar`.
fn sectioned_plan(repo_root: &Path, sections: &[ManifestSection], manual: bool) -> Vec<PlanItem> {
let nornir_md = list_md(&repo_root.join(".nornir"));
let root_md: Vec<PathBuf> = list_md(repo_root)
.into_iter()
.filter(|p| {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
!MANAGED_DOCS.contains(&name) && is_book_chapter(name)
})
.collect();
let find = |name: &str| -> Option<PathBuf> {
nornir_md
.iter()
.find(|p| p.file_name().and_then(|n| n.to_str()) == Some(name))
.cloned()
};
let mut plan = Vec::new();
let mut used: HashSet<PathBuf> = HashSet::new();
// Front matter first, no divider.
for lead in MANAGED_DOCS {
if let Some(p) = find(lead) {
used.insert(p.clone());
plan.push(PlanItem::File(p));
}
}
// Curated sections, in manifest order. A section with no resolvable files
// emits nothing (no empty divider).
for sec in sections {
let mut files = Vec::new();
for fname in &sec.files {
if let Some(p) = find(fname) {
if manual && !is_manual_chapter(&p) {
continue; // thin manual: drop reasoning/history/etc. even if curated
}
if used.insert(p.clone()) {
files.push(p);
}
}
}
if !files.is_empty() {
plan.push(PlanItem::Divider(sec.title.clone()));
plan.extend(files.into_iter().map(PlanItem::File));
}
}
// Crates: per-crate cards from non-root `.nornir/` dirs, their own section.
let mut cards: Vec<PathBuf> = discover_crate_cards(repo_root)
.into_iter()
.filter(|p| !used.contains(p))
.filter(|p| !manual || is_manual_chapter(p))
.collect();
cards.sort();
if !cards.is_empty() {
plan.push(PlanItem::Divider(CRATES_SECTION.to_string()));
for c in &cards {
used.insert(c.clone());
}
plan.extend(cards.into_iter().map(PlanItem::File));
}
// Tankegångar: whatever the manifest didn't claim. Skipped entirely in manual
// (thin-book) mode — the catch-all back-matter is exactly what the manual drops.
if !manual {
let mut leftover: Vec<PathBuf> = nornir_md
.into_iter()
.chain(root_md)
.filter(|p| !used.contains(p))
.collect();
leftover.sort();
if !leftover.is_empty() {
plan.push(PlanItem::Divider(TANKEGANGAR.to_string()));
plan.extend(leftover.into_iter().map(PlanItem::File));
}
}
plan
}
/// Load `.nornir/book.toml` if present. A missing file → `None` (flat book); a
/// malformed one is logged and treated as absent so a typo never breaks the book.
fn load_manifest(repo_root: &Path) -> Option<Vec<ManifestSection>> {
let path = repo_root.join(".nornir/book.toml");
let text = std::fs::read_to_string(&path).ok()?;
match toml::from_str::<BookManifest>(&text) {
Ok(m) => Some(m.section),
Err(e) => {
eprintln!("nornir docs: ignoring malformed .nornir/book.toml: {e}");
None
}
}
}
/// Rewrite each markdown image's relative path to a repo-root-relative path that
/// actually exists on disk: source-relative first (web semantics), then
/// root-relative (the as-written value). Absolute / `http(s):` / `data:` refs
/// and non-existent paths are left untouched.
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() {
// Find the next image start `![`.
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; // index just after "]("
if let Some(rel_close) = body[lp..].find(')') {
let inner = &body[lp..lp + rel_close]; // path [+ optional "title"]
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);
let root_rel = repo_root.join(raw_path);
if src_rel.is_file() {
src_rel
.strip_prefix(repo_root)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"))
} else if root_rel.is_file() {
None // already correct as-written
} else {
None // unknown — leave it
}
};
if let Some(newp) = resolved {
out.push_str(&body[i..lp]); // through "]("
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
}
/// Concatenate chapters into one markdown document. Each chapter that does not
/// already begin with a level-1 heading gets one prepended (so every chapter is
/// a top-level section / page break in the rendered output).
pub fn assemble_markdown(chapters: &[Chapter]) -> String {
let mut out = String::new();
for ch in chapters {
if !starts_with_h1(&ch.markdown) {
out.push_str("# ");
out.push_str(&ch.title);
out.push_str("\n\n");
}
out.push_str(ch.markdown.trim_end());
out.push_str("\n\n");
}
out
}
// ----- class routing → N books (the docs.toml "three books") -----------------
/// One rendered book produced by [`build_books`] — a routing entry realised.
pub struct BuiltBook {
/// Routing-entry name (`manual`, `idiot_guide`, `unsorted`, …).
pub name: String,
/// Output path from the `[book.*].out` key, with its extension swapped to
/// the render format; repo-root-relative exactly as written in `docs.toml`.
pub out: PathBuf,
/// Rendered document bytes.
pub bytes: Vec<u8>,
/// The real source files routed into this book, in assembly order.
pub sources: Vec<PathBuf>,
}
/// A `[book.<name>]` routing entry: which section classes it collects and where
/// its PDF lands.
#[derive(Debug, Clone)]
pub struct BookRoute {
pub name: String,
pub classes: Vec<String>,
pub out: String,
}
#[derive(serde::Deserialize)]
struct DocsRoutingToml {
#[serde(default)]
book: std::collections::BTreeMap<String, BookRouteRaw>,
}
#[derive(serde::Deserialize)]
struct BookRouteRaw {
#[serde(default)]
classes: Vec<String>,
out: String,
}
/// The routing *class* of a doc source, for [`build_books`]. Read from an
/// explicit `<!-- nornir:class: X -->` marker if present, else the filename
/// **suffix** (`-design.md` → `design`, `-for-idiots.md` → `idiot`, …), else the
/// bare class / front-matter name (`design.md` → `design`, `README.md` →
/// `readme`), else `"other"` (which no book claims → the file is omitted and the
/// omission is logged). Suffixes and names per `.nornir/docs-generation.md`.
pub fn classify(path: &Path, body: &str) -> &'static str {
// 1. An explicit in-file override wins over any filename convention.
if let Some(c) = explicit_class(body) {
return c;
}
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_ascii_lowercase();
// 2. Filename suffix (the primary convention).
const SUFFIX: &[(&str, &str)] = &[
("-design.md", "design"),
("-guide.md", "guide"),
("-for-idiots.md", "idiot"),
("-for-dummies.md", "idiot"),
("-idiots.md", "idiot"),
("-idiot-guide.md", "idiot"),
("-reasoning.md", "reasoning"),
("-history.md", "history"),
("-unsorted.md", "unsorted"),
("-benchmarks.md", "benchmarks"),
("-bench.md", "benchmarks"),
];
for (suf, class) in SUFFIX {
if name.ends_with(suf) {
return class;
}
}
// 3. Bare class-name / front-matter files (the `f0` files + README/CHANGELOG).
if let Some(stem) = name.strip_suffix(".md") {
if let Some(c) = canonical_class(stem) {
return c;
}
if stem == "readme" {
return "readme";
}
if stem == "changelog" {
return "changelog";
}
}
"other"
}
/// Canonicalise a class token (from a marker or a bare filename) to one of the
/// known static class names, or `None` if it names no class.
fn canonical_class(s: &str) -> Option<&'static str> {
Some(match s.to_ascii_lowercase().as_str() {
"design" => "design",
"guide" => "guide",
"idiot" | "for-idiots" | "for-dummies" => "idiot",
"reasoning" => "reasoning",
"history" => "history",
"unsorted" => "unsorted",
"benchmarks" | "bench" => "benchmarks",
"readme" => "readme",
"changelog" => "changelog",
_ => return None,
})
}
/// Parse an explicit class override from the head of a file: any
/// `nornir:class: <name>` / `nornir:class = "<name>"` token in the first ~600
/// bytes (typically inside a leading `<!-- … -->` comment). Returns the
/// canonical class, or `None` if absent/unknown.
fn explicit_class(body: &str) -> Option<&'static str> {
// Clamp to a char boundary so a multibyte char (e.g. `→`) straddling byte
// 600 can't panic the whole book build.
let mut cut = body.len().min(600);
while cut > 0 && !body.is_char_boundary(cut) {
cut -= 1;
}
let head = &body[..cut];
let idx = head.find("nornir:class")?;
let rest = &head[idx + "nornir:class".len()..];
let val: String = rest
.trim_start_matches(|c: char| c == ':' || c == '=' || c == '"' || c.is_whitespace())
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect();
canonical_class(&val)
}
/// Load `.nornir/docs.toml` routing, or the built-in three-book default when the
/// file is absent, empty, or malformed (a typo never breaks the docs build).
pub fn load_routing(repo_root: &Path) -> Vec<BookRoute> {
let path = repo_root.join(".nornir/docs.toml");
if let Ok(text) = std::fs::read_to_string(&path) {
match toml::from_str::<DocsRoutingToml>(&text) {
Ok(cfg) if !cfg.book.is_empty() => {
return cfg
.book
.into_iter()
.map(|(name, r)| BookRoute {
name,
classes: r.classes,
out: r.out,
})
.collect();
}
Ok(_) => {}
Err(e) => eprintln!("nornir docs: ignoring malformed .nornir/docs.toml: {e}"),
}
}
default_routing()
}
/// The doctrine's three-book default (see `.nornir/docs-generation.md`): a crisp
/// `manual.pdf`, a standalone `idiot_guide.pdf`, and the `unsorted.pdf` holding
/// pen — used verbatim when a repo has no `.nornir/docs.toml`.
pub fn default_routing() -> Vec<BookRoute> {
let v = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect();
vec![
BookRoute {
name: "manual".into(),
classes: v(&["readme", "changelog", "design", "guide", "benchmarks"]),
out: "docs/manual.pdf".into(),
},
BookRoute {
name: "idiot_guide".into(),
classes: v(&["idiot"]),
out: "docs/idiot_guide.pdf".into(),
},
BookRoute {
name: "unsorted".into(),
classes: v(&["reasoning", "history", "unsorted"]),
out: "docs/unsorted.pdf".into(),
},
]
}
/// Render **every** routed book for `repo_root` per `.nornir/docs.toml` (or the
/// three-book default). Each `[book.*]` entry selects sources by class and emits
/// one document; a book whose class set matches no source is skipped (no empty
/// PDF). Sources whose class no book claims are logged, never silently dropped.
///
/// This is the multi-PDF successor to [`build_book`]: `manual` + `idiot_guide` +
/// `unsorted` together are the whole doc set, split by audience.
pub fn build_books(repo_root: &Path, ctx: &Ctx, format: DocFormat) -> Result<Vec<BuiltBook>> {
// ★ THE CLAIM GATE. The book binds every `.nornir/*.md` source into ONE
// publication, so a false claimed sentence in any of them refuses the whole
// build rather than shipping a PDF that re-blesses it. See `super::claims`.
super::claims::gate_repo(repo_root)?;
let routing = load_routing(repo_root);
// Classify every discovered source once (read + expand markers + rewrite
// images), preserving discovery order (README first, CHANGELOG, then alpha).
let mut classed: Vec<(&'static str, Chapter)> = Vec::new();
for path in discover_sources(repo_root) {
let ch = build_chapter(&path, repo_root, ctx)?;
let class = classify(&path, &ch.markdown);
classed.push((class, ch));
}
// No silent caps: warn about any source that no book routes.
let routed: HashSet<&str> = routing
.iter()
.flat_map(|b| b.classes.iter().map(|s| s.as_str()))
.collect();
for (class, ch) in &classed {
if !routed.contains(class) {
eprintln!(
"nornir docs: {} (class `{class}`) is routed to no book — omitted",
ch.source
.strip_prefix(repo_root)
.unwrap_or(&ch.source)
.display(),
);
}
}
let (title, version) = read_meta(repo_root);
let cover_image = detect_cover_image(repo_root).unwrap_or_default();
let cache_dir = repo_root.join(".nornir/cache/images");
let ext = format.extension();
let mut out_books = Vec::new();
for route in &routing {
let wanted: HashSet<&str> = route.classes.iter().map(|s| s.as_str()).collect();
let chapters: Vec<&Chapter> = classed
.iter()
.filter(|(c, _)| wanted.contains(c))
.map(|(_, ch)| ch)
.collect();
if chapters.is_empty() {
continue; // never emit an empty PDF for an unmatched route
}
let md = assemble_markdown_refs(&chapters);
let meta = ExportMeta {
title: format!("{title} — {}", titleize(&route.name)),
version: version.clone(),
generated: chrono::Utc::now().format("%Y-%m-%d").to_string(),
cover_image: cover_image.clone(),
};
let bytes = export::export(&md, &meta, format, Some(&cache_dir), Some(repo_root))?;
let out = PathBuf::from(&route.out).with_extension(ext);
let sources = chapters.iter().map(|c| c.source.clone()).collect();
out_books.push(BuiltBook {
name: route.name.clone(),
out,
bytes,
sources,
});
}
Ok(out_books)
}
/// Like [`assemble_markdown`] but over borrowed chapters (the routed subset).
fn assemble_markdown_refs(chapters: &[&Chapter]) -> String {
let mut out = String::new();
for ch in chapters {
if !starts_with_h1(&ch.markdown) {
out.push_str("# ");
out.push_str(&ch.title);
out.push_str("\n\n");
}
out.push_str(ch.markdown.trim_end());
out.push_str("\n\n");
}
out
}
/// Assemble + render the whole book for `repo_root` to `format`.
///
/// Returns the rendered bytes plus the list of source files that went into it
/// (in order), for reporting. `ctx` supplies the marker-render context
/// (repo/workspace roots + optional bench run).
pub fn build_book(
repo_root: &Path,
ctx: &Ctx,
format: DocFormat,
) -> Result<(Vec<u8>, Vec<PathBuf>)> {
// ★ THE CLAIM GATE. The book binds every `.nornir/*.md` source into ONE
// publication, so a false claimed sentence in any of them refuses the whole
// build rather than shipping a PDF that re-blesses it. See `super::claims`.
super::claims::gate_repo(repo_root)?;
let (md, meta, sources, _titles) = assemble_book(repo_root, ctx)?;
let cache_dir = repo_root.join(".nornir/cache/images");
// Mount repo root so local `.nornir/assets/…` images resolve and embed.
let bytes = export::export(&md, &meta, format, Some(&cache_dir), Some(repo_root))?;
Ok((bytes, sources))
}
/// Build the whole-doc book as **one crisp SVG per page** plus the chapter titles
/// (a flat TOC, in chapter order) and the source list. Feeds the manual viewer
/// (`facett-docview`) via the on-disk `docs/book-svg/` contract written by
/// `nornir docs book --format svg`.
///
/// Renders **per chapter** — each chapter is compiled as its own small typst
/// document and the resulting SVG pages are concatenated in chapter order (see
/// [`export::export_svg_pages_per_chapter`]). This keeps every typst input small,
/// so the total cost is ~linear in chapters instead of superlinear in one giant
/// document (a big repo's ~600 KB book dropped from ~11 min to seconds). The page
/// set is equivalent to the single-doc path: cover, TOC, then each chapter on its
/// own fresh page, numbered continuously.
///
/// The 4th return, `page_texts`, is the per-page plain-text search sidecar (same
/// length/order as the pages): the SVG pages carry only outlined glyph paths (no
/// `<text>`), so facett-docview search reads this instead. Each chapter's text is
/// attributed to its first page; front-matter and continuation pages are `None`.
pub fn build_book_svg_pages(
repo_root: &Path,
ctx: &Ctx,
) -> Result<(Vec<String>, Vec<String>, Vec<PathBuf>, Vec<Option<String>>)> {
// ★ THE CLAIM GATE. The book binds every `.nornir/*.md` source into ONE
// publication, so a false claimed sentence in any of them refuses the whole
// build rather than shipping a PDF that re-blesses it. See `super::claims`.
super::claims::gate_repo(repo_root)?;
// Collect ONCE — a second `collect_chapters` would re-run every marker
// (depgraph `cargo metadata`, per-file bench probes) a whole extra time.
let chapters = collect_chapters(repo_root, ctx)?;
// Report only real files, not synthetic section dividers.
let sources: Vec<PathBuf> = chapters
.iter()
.filter(|c| !c.is_section)
.map(|c| c.source.clone())
.collect();
// Flat TOC in chapter order (titles of every chapter, dividers included).
let chapter_titles: Vec<String> = chapters.iter().map(|c| c.title.clone()).collect();
// One `ChapterInput` per chapter: the body with its leading H1 ensured (the
// exact fragment the single-doc `assemble_markdown` would emit for it), plus a
// plain-text rendering of that body for the search sidecar.
let inputs: Vec<ChapterInput> = chapters
.iter()
.map(|c| {
let markdown = chapter_body_markdown(c);
let text = chapter_plaintext(&markdown);
ChapterInput {
title: c.title.clone(),
markdown,
text,
}
})
.collect();
let meta = book_meta(repo_root);
let cache_dir = repo_root.join(".nornir/cache/images");
let (pages, page_texts) =
export::export_svg_pages_per_chapter(&inputs, &meta, Some(&cache_dir), Some(repo_root))?;
Ok((pages, chapter_titles, sources, page_texts))
}
/// A chapter's markdown reduced to plain, searchable text: markdown structure and
/// syntax stripped via a pulldown-cmark pass (headings, emphasis, links, code and
/// image ALT all contribute their text; fences keep their code). Returns `None`
/// when nothing textual remains (an image-only page) so search honestly skips it
/// rather than matching empty text.
fn chapter_plaintext(markdown: &str) -> Option<String> {
use pulldown_cmark::{Event, Options, Parser};
let mut out = String::new();
for ev in Parser::new_ext(markdown, Options::empty()) {
match ev {
Event::Text(t) | Event::Code(t) => out.push_str(&t),
Event::SoftBreak | Event::HardBreak | Event::End(_) => out.push(' '),
_ => {}
}
}
let trimmed = out.split_whitespace().collect::<Vec<_>>().join(" ");
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
/// One chapter's markdown as the single-doc assembler would emit it: its own
/// leading `# H1` prepended only when missing (mirrors [`assemble_markdown`]), so
/// the per-chapter render matches the concatenated render page-for-page.
fn chapter_body_markdown(ch: &Chapter) -> String {
let mut out = String::new();
if !starts_with_h1(&ch.markdown) {
out.push_str("# ");
out.push_str(&ch.title);
out.push_str("\n\n");
}
out.push_str(ch.markdown.trim_end());
out.push('\n');
out
}
/// Shared assembly: collect chapters → markdown + the export metadata (title,
/// version, cover image) + the flat chapter-title TOC. Used by both the paged
/// (PDF/SVG) and per-page-SVG paths. Collects (and marker-expands) exactly once.
fn assemble_book(
repo_root: &Path,
ctx: &Ctx,
) -> Result<(String, ExportMeta, Vec<PathBuf>, Vec<String>)> {
let chapters = collect_chapters(repo_root, ctx)?;
// Report only real files, not synthetic section dividers.
let sources: Vec<PathBuf> = chapters
.iter()
.filter(|c| !c.is_section)
.map(|c| c.source.clone())
.collect();
// Flat TOC in chapter order (titles of every chapter, dividers included).
let chapter_titles: Vec<String> = chapters.iter().map(|c| c.title.clone()).collect();
let md = assemble_markdown(&chapters);
Ok((md, book_meta(repo_root), sources, chapter_titles))
}
/// The book's export metadata: title (`<name> — documentation`), version, the
/// generation date, and an auto-detected cover/hero image under `.nornir/assets/`
/// (`<reponame>.<ext>` or `cover.<ext>`, repo-root-relative so the mounted typst
/// root resolves it). Shared by the paged (PDF), single-doc SVG, and per-chapter
/// SVG paths so every book carries identical front-matter.
fn book_meta(repo_root: &Path) -> ExportMeta {
let (title, version) = read_meta(repo_root);
let cover_image = detect_cover_image(repo_root).unwrap_or_default();
ExportMeta {
title: format!("{title} — documentation"),
version,
generated: chrono::Utc::now().format("%Y-%m-%d").to_string(),
cover_image,
}
}
/// Find a cover/hero image under `.nornir/assets/`: `<reponame>.<ext>` (e.g.
/// `nornir.webp`) or `cover.<ext>`, ext ∈ {svg,png,webp,jpg,jpeg}. Returns it
/// repo-root-relative (forward slashes) so the typst root mount resolves it.
fn detect_cover_image(repo_root: &Path) -> Option<String> {
let assets = repo_root.join(".nornir/assets");
let name = repo_root.file_name().and_then(|n| n.to_str()).unwrap_or("");
let exts = ["svg", "png", "webp", "jpg", "jpeg"];
let mut stems = Vec::new();
if !name.is_empty() {
stems.push(name.to_string());
}
stems.push("cover".to_string());
for stem in stems {
for ext in exts {
let p = assets.join(format!("{stem}.{ext}"));
if p.is_file() {
return p
.strip_prefix(repo_root)
.ok()
.map(|r| r.to_string_lossy().replace('\\', "/"));
}
}
}
None
}
// ----- discovery -------------------------------------------------------------
fn discover_sources(repo_root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
// .nornir/*.md — the editable sources.
let nornir_dir = repo_root.join(".nornir");
let mut nornir_md = list_md(&nornir_dir);
// README first, CHANGELOG second, then the rest alphabetically.
nornir_md.sort_by(|a, b| source_rank(a).cmp(&source_rank(b)).then_with(|| a.cmp(b)));
out.extend(nornir_md);
// <repo>/*.md, minus the generated managed artifacts (their source is in
// .nornir/ and is already included above) and minus non-doc scratch /
// agent-instruction files (see `is_book_chapter`).
let mut root_md: Vec<PathBuf> = list_md(repo_root)
.into_iter()
.filter(|p| {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
!MANAGED_DOCS.contains(&name) && is_book_chapter(name)
})
.collect();
root_md.sort();
out.extend(root_md);
// Per-crate cards from non-root `.nornir/` dirs (cards that travel with their
// crate) — appended after the root docs so the flat book still includes them.
out.extend(discover_crate_cards(repo_root));
out
}
/// Whether a top-level `<repo>/<name>.md` belongs in the rendered doc-book.
///
/// Discovery is otherwise "every top-level `*.md`", which sweeps in files that
/// are not documentation: `CLAUDE.md` (agent instructions) and `*-inbox.md`
/// scratch pads. These are skipped by convention so the book is the project's
/// actual docs, not its working notes. (`.nornir/*.md` sources are always
/// included — that directory is nornir's curated namespace.)
fn is_book_chapter(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
if lower == "claude.md" {
return false;
}
// `*-inbox.md` / `*_inbox.md` and a bare `inbox.md`.
if let Some(stem) = lower.strip_suffix(".md") {
if stem == "inbox" || stem.ends_with("-inbox") || stem.ends_with("_inbox") {
return false;
}
}
true
}
/// Sort key so README.md sorts first, CHANGELOG.md second, everything else after.
fn source_rank(p: &Path) -> u8 {
match p.file_name().and_then(|n| n.to_str()) {
Some("README.md") => 0,
Some("CHANGELOG.md") => 1,
_ => 2,
}
}
/// Top-level `*.md` files in `dir` (non-recursive), as sorted absolute paths.
/// Per-crate doc cards: every `*.md` under a **non-root** `.nornir/` directory
/// (e.g. `crates/nornir-saga/.nornir/…`, or a workspace member's own `.nornir/`).
/// The root `.nornir/` is handled by normal source discovery; this recurses the
/// rest of the tree so a card that travels with its crate is still swept into the
/// book. Prunes `target/`, `node_modules/`, and any other hidden dir (but NOT
/// `.nornir` itself), and does not descend into `.nornir/` subdirs (assets/cache).
fn discover_crate_cards(repo_root: &Path) -> Vec<PathBuf> {
let root_nornir = repo_root.join(".nornir");
let mut out = Vec::new();
let mut stack = vec![repo_root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&dir) else {
continue;
};
for entry in rd.flatten() {
let path = entry.path();
// `file_type()` reads the dirent itself (lstat semantics), so a
// symlinked directory is NOT a dir here and is never descended —
// a `.claude/worktrees/<repo> → <repo>` self-symlink can't send the
// walk into a stat storm. (A `.nornir` handled below is a real dir.)
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
if !is_dir {
continue;
}
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name == ".nornir" {
if path != root_nornir {
out.extend(list_md(&path));
}
continue; // never descend into a .nornir/ (assets/cache live there)
}
// Prune build outputs (`target`, `target-flight`/`target-*`),
// node_modules, and every hidden dir (`.git`, `.claude` worktree
// farms, …) — none carry per-crate doc cards.
if name == "target"
|| name.starts_with("target-")
|| name == "node_modules"
|| name.starts_with('.')
{
continue;
}
stack.push(path);
}
}
out.sort();
out
}
fn list_md(dir: &Path) -> Vec<PathBuf> {
let mut v = Vec::new();
let Ok(rd) = std::fs::read_dir(dir) else {
return v;
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_file()
&& path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("md"))
.unwrap_or(false)
{
v.push(path);
}
}
v.sort();
v
}
// ----- chapter helpers -------------------------------------------------------
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)
}
/// A chapter's title: the file's own leading `# H1` if present, else a name
/// derived from the file stem (`docs-generation` → `Docs Generation`).
fn chapter_title(path: &Path, body: &str) -> String {
if let Some(line) = body.lines().map(str::trim_start).find(|l| !l.is_empty()) {
if let Some(h) = line.strip_prefix("# ") {
return h.trim().to_string();
}
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("untitled");
titleize(stem)
}
fn titleize(stem: &str) -> String {
stem.split(['-', '_'])
.filter(|w| !w.is_empty())
.map(|w| {
let mut c = w.chars();
match c.next() {
Some(first) => first.to_uppercase().collect::<String>() + c.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
/// Read `(name, version)` from the repo's `Cargo.toml`; falls back to the
/// directory name and [`resolve_version`].
fn read_meta(repo_root: &Path) -> (String, String) {
let dir_name = repo_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("project")
.to_string();
let name = std::fs::read_to_string(repo_root.join("Cargo.toml"))
.ok()
.and_then(|c| toml::from_str::<toml::Value>(&c).ok())
.and_then(|p| {
p.get("package")
.or_else(|| p.get("workspace").and_then(|w| w.get("package")))
.and_then(|p| p.get("name"))
.and_then(|v| v.as_str())
.map(str::to_string)
})
.unwrap_or(dir_name);
(name, resolve_version(repo_root))
}
/// Resolve a display version for a repo from its root `Cargo.toml`.
///
/// Order: `[package].version`, then `[workspace.package].version`. A **virtual
/// workspace** (e.g. holger, znippy) declares neither, so we fall back to the
/// *modal* version across its members — the version most of the crates share is
/// the workspace's release version. Returns `0.0.0` only when nothing resolves.
pub fn resolve_version(repo_root: &Path) -> String {
let Ok(content) = std::fs::read_to_string(repo_root.join("Cargo.toml")) else {
return "0.0.0".to_string();
};
let Ok(parsed) = toml::from_str::<toml::Value>(&content) else {
return "0.0.0".to_string();
};
// Direct package / workspace-package version.
if let Some(v) = parsed
.get("package")
.or_else(|| parsed.get("workspace").and_then(|w| w.get("package")))
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str())
{
return v.to_string();
}
// Virtual workspace: take the modal member version.
let ws_pkg_version = parsed
.get("workspace")
.and_then(|w| w.get("package"))
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str());
let members = parsed
.get("workspace")
.and_then(|w| w.get("members"))
.and_then(|m| m.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
.unwrap_or_default();
modal_member_version(repo_root, &members, ws_pkg_version).unwrap_or_else(|| "0.0.0".into())
}
/// The most frequent concrete version across workspace `members` (each a path,
/// possibly a trailing-`/*` glob). `version.workspace = true` resolves to
/// `ws_pkg_version`. Ties break toward the version seen first in member order.
fn modal_member_version(
repo_root: &Path,
members: &[&str],
ws_pkg_version: Option<&str>,
) -> Option<String> {
let mut counts: Vec<(String, usize)> = Vec::new();
let mut bump = |v: String| {
if let Some(e) = counts.iter_mut().find(|(k, _)| *k == v) {
e.1 += 1;
} else {
counts.push((v, 1));
}
};
for m in members {
let dirs = if let Some(prefix) = m.strip_suffix("/*") {
// Expand a simple trailing glob one level deep.
std::fs::read_dir(repo_root.join(prefix))
.map(|rd| {
rd.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect::<Vec<_>>()
})
.unwrap_or_default()
} else {
vec![repo_root.join(m)]
};
for dir in dirs {
let Ok(c) = std::fs::read_to_string(dir.join("Cargo.toml")) else {
continue;
};
let Ok(p) = toml::from_str::<toml::Value>(&c) else {
continue;
};
let ver = p.get("package").and_then(|pkg| pkg.get("version"));
let resolved = match ver {
Some(toml::Value::String(s)) => Some(s.clone()),
// `version.workspace = true`
Some(toml::Value::Table(t)) if t.get("workspace").is_some() => {
ws_pkg_version.map(str::to_string)
}
_ => None,
};
if let Some(v) = resolved {
bump(v);
}
}
}
counts.into_iter().max_by_key(|(_, n)| *n).map(|(v, _)| v)
}
#[cfg(test)]
mod tests {
use super::*;
fn write(p: &Path, s: &str) {
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, s).unwrap();
}
#[test]
fn crate_cards_recurse_but_skip_root_and_target() {
let t = tempfile::tempdir().unwrap();
let root = t.path();
// Root .nornir is handled elsewhere — must NOT be returned here.
write(&root.join(".nornir/design.md"), "# Design\n");
// A per-crate card in a non-root .nornir — MUST be found.
write(&root.join("crates/foo/.nornir/foo.md"), "# Foo\n");
write(&root.join("member/bar/.nornir/bar.md"), "# Bar\n");
// A card under target/ — MUST be pruned.
write(&root.join("target/pkg/.nornir/junk.md"), "# Junk\n");
// assets under a .nornir — must NOT be descended into.
write(&root.join("crates/foo/.nornir/assets/skip.md"), "# Skip\n");
let got: Vec<String> = discover_crate_cards(root)
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert!(
got.contains(&"foo.md".to_string()),
"per-crate card found: {got:?}"
);
assert!(
got.contains(&"bar.md".to_string()),
"workspace-member card found: {got:?}"
);
assert!(
!got.contains(&"design.md".to_string()),
"root .nornir excluded: {got:?}"
);
assert!(
!got.contains(&"junk.md".to_string()),
"target/ pruned: {got:?}"
);
assert!(
!got.contains(&"skip.md".to_string()),
".nornir/assets not descended: {got:?}"
);
}
#[test]
fn discovers_and_orders_sources() {
let t = tempfile::tempdir().unwrap();
let root = t.path();
write(&root.join(".nornir/README.md"), "# Readme\n\nbody\n");
write(&root.join(".nornir/CHANGELOG.md"), "# Changelog\n");
write(&root.join(".nornir/design.md"), "# Design\n");
write(&root.join("plan.md"), "# Plan\n");
// Generated managed artifact at the root — must be skipped (dedup).
write(&root.join("README.md"), "generated\n");
// Non-doc files — must be skipped by the chapter convention.
write(&root.join("CLAUDE.md"), "agent instructions\n");
write(&root.join("znippy-inbox.md"), "scratch\n");
let got: Vec<String> = discover_sources(root)
.iter()
.map(|p| {
let parent = p.parent().unwrap().file_name().unwrap().to_str().unwrap();
let name = p.file_name().unwrap().to_str().unwrap();
format!("{parent}/{name}")
})
.collect();
let root_name = root.file_name().unwrap().to_str().unwrap();
assert_eq!(
got,
vec![
".nornir/README.md".to_string(),
".nornir/CHANGELOG.md".to_string(),
".nornir/design.md".to_string(),
// top-level README.md excluded; plan.md kept
format!("{root_name}/plan.md"),
]
);
}
#[test]
fn is_book_chapter_skips_non_docs() {
assert!(!is_book_chapter("CLAUDE.md"));
assert!(!is_book_chapter("claude.md"));
assert!(!is_book_chapter("znippy-inbox.md"));
assert!(!is_book_chapter("inbox.md"));
assert!(!is_book_chapter("notes_inbox.md"));
// Real docs stay.
assert!(is_book_chapter("WORKSPACE.md"));
assert!(is_book_chapter("plan.md"));
assert!(is_book_chapter("design.md"));
}
#[test]
fn assemble_prepends_h1_only_when_missing() {
let chapters = vec![
Chapter {
source: "a.md".into(),
title: "Alpha".into(),
markdown: "# Alpha\n\nhas its own h1\n".into(),
is_section: false,
},
Chapter {
source: "b.md".into(),
title: "Beta".into(),
markdown: "## sub only\n\nno h1\n".into(),
is_section: false,
},
];
let md = assemble_markdown(&chapters);
// First chapter keeps its single H1 (not doubled).
assert_eq!(md.matches("# Alpha").count(), 1);
// Second chapter gets a synthesized H1 from its title.
assert!(md.contains("# Beta"));
assert!(md.contains("## sub only"));
}
#[test]
fn resolve_version_prefers_package_then_modal_member() {
// Concrete [package].version wins outright.
let t = tempfile::tempdir().unwrap();
write(
&t.path().join("Cargo.toml"),
"[package]\nname='x'\nversion='1.2.3'\n",
);
assert_eq!(resolve_version(t.path()), "1.2.3");
// Virtual workspace: modal member version (3×0.9.0 vs 1×0.1.0).
let w = tempfile::tempdir().unwrap();
write(
&w.path().join("Cargo.toml"),
"[workspace]\nmembers=['a','b','c','d']\n",
);
for (m, v) in [
("a", "0.9.0"),
("b", "0.9.0"),
("c", "0.9.0"),
("d", "0.1.0"),
] {
write(
&w.path().join(m).join("Cargo.toml"),
&format!("[package]\nname='{m}'\nversion='{v}'\n"),
);
}
assert_eq!(resolve_version(w.path()), "0.9.0");
// Nothing resolvable → 0.0.0.
let e = tempfile::tempdir().unwrap();
write(&e.path().join("Cargo.toml"), "[workspace]\nmembers=[]\n");
assert_eq!(resolve_version(e.path()), "0.0.0");
}
#[test]
fn titleize_basic() {
assert_eq!(titleize("docs-generation"), "Docs Generation");
assert_eq!(titleize("design_notes"), "Design Notes");
}
#[test]
fn classify_by_suffix_bare_and_marker() {
let p = |n: &str| PathBuf::from(n);
// Suffix (the function-axis class files).
assert_eq!(classify(&p("release-design.md"), ""), "design");
assert_eq!(classify(&p("warehouse-guide.md"), ""), "guide");
assert_eq!(classify(&p("build-for-idiots.md"), ""), "idiot");
assert_eq!(classify(&p("map-reasoning.md"), ""), "reasoning");
assert_eq!(classify(&p("test-history.md"), ""), "history");
assert_eq!(classify(&p("jobs-unsorted.md"), ""), "unsorted");
assert_eq!(classify(&p("znippy-benchmarks.md"), ""), "benchmarks");
// Bare `f0` files + front matter.
assert_eq!(classify(&p("design.md"), ""), "design");
assert_eq!(classify(&p("for-idiots.md"), ""), "idiot");
assert_eq!(classify(&p("README.md"), ""), "readme");
assert_eq!(classify(&p("CHANGELOG.md"), ""), "changelog");
// Explicit marker overrides the filename.
assert_eq!(
classify(
&p("notes.md"),
"<!-- nornir:class: reasoning -->\n# Notes\n"
),
"reasoning"
);
assert_eq!(
classify(
&p("release-design.md"),
"<!-- nornir:class = \"guide\" -->\n"
),
"guide"
);
// Unknown → other (routed to no book).
assert_eq!(classify(&p("docs-generation.md"), ""), "other");
}
#[test]
fn load_routing_falls_back_to_default() {
let t = tempfile::tempdir().unwrap();
// No docs.toml → the three-book default.
let names: Vec<String> = load_routing(t.path()).into_iter().map(|b| b.name).collect();
assert_eq!(names, vec!["manual", "idiot_guide", "unsorted"]);
// A docs.toml overrides it (alpha-ordered by name).
write(
&t.path().join(".nornir/docs.toml"),
"[book.manual]\nclasses=[\"design\"]\nout=\"docs/m.pdf\"\n\
[book.zzz]\nclasses=[\"guide\"]\nout=\"docs/z.pdf\"\n",
);
let routes = load_routing(t.path());
assert_eq!(routes.len(), 2);
assert_eq!(routes[0].name, "manual");
assert_eq!(routes[0].out, "docs/m.pdf");
assert_eq!(routes[1].name, "zzz");
}
/// The heart of the multi-PDF doctrine: a repo's classed sources route into
/// distinct books — design/guide → manual, idiot → idiot_guide, reasoning →
/// unsorted — and a class no book claims is omitted (not crashed on).
#[cfg(feature = "export")]
#[test]
fn build_books_routes_by_class_into_distinct_pdfs() {
let t = tempfile::tempdir().unwrap();
let root = t.path();
write(
&root.join("Cargo.toml"),
"[package]\nname='demo'\nversion='1.0.0'\n",
);
let body = "Lorem ipsum dolor sit amet. ".repeat(60);
write(
&root.join(".nornir/README.md"),
&format!("# Demo\n\n{body}"),
);
write(
&root.join(".nornir/release-design.md"),
&format!("# Release design\n\n{body}"),
);
write(
&root.join(".nornir/release-guide.md"),
&format!("# Release guide\n\n{body}"),
);
write(
&root.join(".nornir/release-for-idiots.md"),
&format!("# Release idiot\n\n{body}"),
);
write(
&root.join(".nornir/release-reasoning.md"),
&format!("# Release why\n\n{body}"),
);
// `other` class — must be omitted from every book, not crash the build.
write(
&root.join(".nornir/docs-generation.md"),
&format!("# Doctrine\n\n{body}"),
);
let ctx = Ctx::new(root, root, None);
let books = build_books(root, &ctx, DocFormat::parse("md").unwrap()).unwrap();
let by = |name: &str| books.iter().find(|b| b.name == name).unwrap();
// Exactly the three default books, each non-empty.
let mut names: Vec<&str> = books.iter().map(|b| b.name.as_str()).collect();
names.sort();
assert_eq!(names, vec!["idiot_guide", "manual", "unsorted"]);
// Manual = README + design + guide (readme/design/guide classes).
let manual = String::from_utf8(by("manual").bytes.clone()).unwrap();
assert!(manual.contains("Release design"), "manual has design");
assert!(manual.contains("Release guide"), "manual has guide");
assert!(!manual.contains("Release idiot"), "manual excludes idiot");
assert!(!manual.contains("Release why"), "manual excludes reasoning");
assert!(!manual.contains("Doctrine"), "manual excludes `other`");
// Idiot guide = only the idiot class.
let idiot = String::from_utf8(by("idiot_guide").bytes.clone()).unwrap();
assert!(idiot.contains("Release idiot"));
assert!(!idiot.contains("Release design"));
// Unsorted = reasoning (+ history/unsorted).
let unsorted = String::from_utf8(by("unsorted").bytes.clone()).unwrap();
assert!(unsorted.contains("Release why"));
assert!(!unsorted.contains("Release guide"));
// Output paths are the doctrine defaults with the md extension.
assert_eq!(by("manual").out, PathBuf::from("docs/manual.md"));
assert_eq!(by("idiot_guide").out, PathBuf::from("docs/idiot_guide.md"));
}
/// The per-chapter SVG book must produce the **same page set** as the
/// single-doc path: rendering each chapter as its own small typst document and
/// concatenating the pages yields the same count, in the same order (cover,
/// TOC, then one fresh page per chapter), as feeding typst one concatenated
/// document. This is the invariant that lets the split kill typst's
/// superlinearity without changing what the manual viewer reads.
#[cfg(feature = "export")]
#[test]
fn per_chapter_svg_matches_single_doc_page_set() {
let t = tempfile::tempdir().unwrap();
let root = t.path();
write(
&root.join("Cargo.toml"),
"[package]\nname='demo'\nversion='1.0.0'\n",
);
// H1-only chapters (no sub-headings) so the single-doc depth-3 `#outline`
// and the per-chapter chapter-level TOC list the same entries → identical
// front-matter page count, and the comparison is about the split itself.
let body = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(40);
write(
&root.join(".nornir/README.md"),
&format!("# Readme\n\n{body}"),
);
write(
&root.join(".nornir/design.md"),
&format!("# Design\n\n{body}"),
);
write(
&root.join(".nornir/guide.md"),
&format!("# Guide\n\n{body}"),
);
let ctx = Ctx::new(root, root, None);
// Per-chapter path — exactly what `nornir docs book --format svg` uses.
let (per_pages, titles, sources, page_texts) = build_book_svg_pages(root, &ctx).unwrap();
// Single-doc path — assemble everything, ONE typst compile.
let (md, meta, _s, _t) = assemble_book(root, &ctx).unwrap();
let single = export::export_svg_pages(&md, &meta, None, Some(root)).unwrap();
// Same page SET: identical count, same order. cover(1) + TOC(1) + 3 chapters.
assert_eq!(
per_pages.len(),
single.len(),
"per-chapter ({}) vs single-doc ({}) page count must match",
per_pages.len(),
single.len(),
);
// cover + TOC + one fresh page per chapter (H1-only bodies keep the
// depth-3 outline and the chapter-level TOC the same length → same count).
assert!(
per_pages.len() >= 5,
"cover + TOC + ≥3 chapters, got {}",
per_pages.len()
);
// Every page is a well-formed SVG carrying outlined vector paths (glyphs).
for (i, p) in per_pages.iter().enumerate() {
assert!(p.contains("<svg"), "page {i} is an <svg>");
assert!(p.contains("</svg>"), "page {i} is closed");
assert!(p.contains("<path"), "page {i} carries vector paths");
}
// Titles + sources track the three chapters, in order.
assert_eq!(titles, vec!["Readme", "Design", "Guide"]);
assert_eq!(sources.len(), 3);
// Search sidecar: one plain-text page per chapter (its first), front
// matter carries none; aligned to the page vector.
assert_eq!(
page_texts.len(),
per_pages.len(),
"page_texts aligns with pages"
);
assert_eq!(
page_texts.iter().filter(|t| t.is_some()).count(),
3,
"one text page per chapter",
);
assert!(
page_texts[0].is_none() && page_texts[1].is_none(),
"front matter has no text"
);
assert!(
page_texts[2].as_deref().unwrap_or("").contains("Readme"),
"chapter text present"
);
}
/// Regression: rendering the book for a repo whose key matches the running
/// self-repo (`nornir`) but whose checkout lives in a differently-named dir
/// must land the PDF at the *configured* `<repo>/docs/book.pdf` — the real
/// path — assembling the repo's real `.nornir/*.md`, NOT an empty stub under
/// a temp dir.
///
/// The historical bug: `[repo.nornir] path = "nornir-orch"` was ignored, so
/// `docs book nornir` resolved by-name to the wrong (or absent) checkout and
/// wrote an empty ~16KB `book.pdf`. This drives the same seam end-to-end:
/// `repo_dir_for` (override) → `build_book` (real sources) →
/// `RepoLayout::export_path` (real `docs/`), asserting a non-trivial PDF at
/// the real path and explicitly NOT under `/tmp`.
#[cfg(feature = "export")]
#[test]
fn self_repo_book_lands_at_real_docs_path_not_tmp() {
use nornir_config::{Nornir, Repo};
use crate::RepoLayout;
let ws = tempfile::tempdir().unwrap();
let ws_root = ws.path();
// The self-repo's *key* is `nornir`, but its checkout dir is named
// `nornir-orch` — exactly the dogfooding case. By-name resolution would
// miss it; the `path` override must redirect to it.
let repo_dir = ws_root.join("nornir-orch");
write(
&repo_dir.join("Cargo.toml"),
"[package]\nname='nornir'\nversion='9.9.9'\n",
);
// Several real `.nornir/*.md` sources, each with real prose, so the
// assembled book is non-trivial (many pages) rather than an empty stub.
let body = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. \
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. \
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.\n\n"
.repeat(40);
for (name, title) in [
("README.md", "Nornir"),
("design.md", "Design"),
("guide.md", "Guide"),
("bencher.md", "Bencher"),
("warehouse.md", "Warehouse"),
] {
write(
&repo_dir.join(format!(".nornir/{name}")),
&format!("# {title}\n\n{body}"),
);
}
let mut nornir = Nornir::default();
nornir.repo.insert(
"nornir".into(),
Repo {
path: "nornir-orch".into(),
..Default::default()
},
);
// Resolve through the same seam the CLI handler uses.
let resolved = nornir.repo_dir_for(ws_root, "nornir");
assert_eq!(
resolved, repo_dir,
"override must redirect `nornir` → nornir-orch dir"
);
// Build the book PDF from the resolved (real) checkout and write it to
// the repo's `docs/` via the layout — the exact CLI write path.
let ctx = Ctx::new(&resolved, ws_root, None);
let format = DocFormat::parse("pdf").unwrap();
let (bytes, sources) = build_book(&resolved, &ctx, format).unwrap();
let layout = RepoLayout::new(&resolved);
let out = layout.export_path("book", format.extension());
std::fs::create_dir_all(out.parent().unwrap()).unwrap();
std::fs::write(&out, &bytes).unwrap();
// Lands at the configured repo's real docs/book.pdf — under the
// resolved repo root, NOT at the global self-stub the bug produced
// (`<temp_dir>/nornir/docs/book.pdf`).
assert_eq!(out, repo_dir.join("docs/book.pdf"));
assert!(
out.starts_with(&repo_dir),
"book must land under the resolved repo root {}, got {}",
repo_dir.display(),
out.display(),
);
let self_stub = std::env::temp_dir().join("nornir/docs/book.pdf");
assert_ne!(
out, self_stub,
"book must not be written to the global self-stub"
);
// Assembled from the real multi-source set, not an empty stub.
assert!(
sources.len() > 1,
"expected >1 source, got {}",
sources.len()
);
assert!(out.is_file(), "book.pdf must exist at {}", out.display());
let size = std::fs::metadata(&out).unwrap().len();
// Baseline: the same pipeline over an *empty* repo (no `.nornir/*.md`)
// is the "empty-stub" the bug produced. The real book must dwarf it.
let empty = tempfile::tempdir().unwrap();
write(
&empty.path().join("Cargo.toml"),
"[package]\nname='nornir'\nversion='9.9.9'\n",
);
let empty_ctx = Ctx::new(empty.path(), empty.path(), None);
let (empty_bytes, empty_sources) = build_book(empty.path(), &empty_ctx, format).unwrap();
assert!(empty_sources.is_empty(), "empty repo must yield no sources");
assert!(
size as usize > empty_bytes.len() * 2,
"book.pdf ({size} bytes) must dwarf the empty stub ({} bytes) — \
empty-stub regression",
empty_bytes.len(),
);
assert!(
size > 50_000,
"book.pdf must be non-trivial, got {size} bytes — empty-stub regression",
);
}
}