krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

ba4152c75ebfc45fa02279a0d355613465d42a0a

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T04:20:59Z

Phase 0: corpus audit and emacs --batch oracle, and honor #+SLUG:

Replaces the two guesses the v1 scope rested on with measurements. The corpus is the
179 files behind cleberg.net, published today by weblorg — a wrapper around org's own
HTML exporter, so it is both the workload and the incumbent.

Corpus audit (src/audit.rs, `org-ssg audit <dir>`):
- Reports construct frequencies classified against the IN/OUT line, plus a census of
  every keyword, block type, drawer and link scheme seen, so unrecognized names
  self-report instead of hiding. Deliberately a separate line scanner rather than a
  reuse of the parser: auditing with the parser could only find constructs the parser
  already knows, which is the wrong instrument for finding blind spots.
- Reports names, counts and file:line only, never document text, so auditing private
  notes stays publishable.

What it found:
- The scope guess was sound: 99.9% of construct uses are in scope. The entire
  out-of-scope tail is 8 uses.
- #+SLUG: was missing entirely, and it decides the published URL: 178 of 179 files set
  one, and 2018-11-28-aes-encryption.org is served at blog/aes-encryption.html. Output
  paths came from source filenames, so 169 of 179 pages would have been published at
  the wrong URL by a build that reported success. Output paths now come from the slug
  (util::output_path), threaded through INDEX/RESOLVE/nav so links follow it. Slugs are
  sanitized — an author-supplied ../../etc/x cannot escape the output directory — and
  two pages claiming one URL is a build error, not a silently dropped page. Building
  the real corpus now reproduces all 179 live URLs exactly.
- INDEX/RESOLVE is speculative against this corpus: it contains no id:, #custom-id or
  *Heading links at all.
- The audit's own first run lied, reporting 23 custom TODO keyword sequences. All were
  false — it read the leading word of "* CSS Variables" as the keyword "CSS", and the
  corpus defines no #+TODO: sequences. Now matched against conventional names only.

Emacs oracle (tests/oracle.el, tests/oracle.rs):
- Exports each fixture with org's own exporter and reduces both sides to a semantic
  skeleton, dropping layout divs, inline spans and all attributes but href/src. Byte
  equality was never the goal; org wraps every section in outline-container divs keyed
  by generated ids.
- Snapshots the disagreement rather than asserting agreement: a checked-in divergence
  report gets reviewed and shows up as a diff, where a permanently red test gets
  ignored. Three invariants are asserted outright and all hold — heading structure,
  list nesting and source block text match Emacs exactly.
- Skips cleanly when emacs is absent, so it never blocks CI.

It found no bugs in org-ssg. Every divergence is a deliberate choice to emit better
HTML: <em>/<strong> over <i>/<b>, <figure>/<figcaption> over "Figure 1:", <time
datetime> over a literal timestamp, <section><ol> footnotes over an <h2>, slugged
heading anchors over org1a2b3c4, <pre><code> over bare <pre>. One real semantic
difference is kept on measurement rather than taste: org merges a 1. list and a
following - list separated by one blank line into a single list, and that pattern
occurs zero times in the corpus.

Its best catch was three bugs in itself: normalization that trimmed each of syntect's
per-token text runs reported code as corrupted (def greet -> defgreet), and keeping
syntect's spans put blocks.org at 36% agreement. Both were measurement artifacts.
 README.md                                         |  90 +++-
 fixtures/slugsite/2024-02-11-long-source-name.org |  11 +
 fixtures/slugsite/index.org                       |   3 +
 src/audit.rs                                      | 620 ++++++++++++++++++++++
 src/index.rs                                      |  44 +-
 src/lib.rs                                        |   1 +
 src/main.rs                                       |  11 +
 src/resolve.rs                                    |   8 +-
 src/site.rs                                       |  31 +-
 src/util.rs                                       |  64 ++-
 tests/oracle.el                                   |  42 ++
 tests/oracle.rs                                   | 454 ++++++++++++++++
 tests/site.rs                                     |  83 +++
 tests/snapshots/oracle__oracle_blocks.snap        |  68 +++
 tests/snapshots/oracle__oracle_core.snap          |  70 +++
 tests/snapshots/oracle__oracle_elements.snap      | 103 ++++
 tests/snapshots/oracle__oracle_footnote.snap      |  83 +++
 tests/snapshots/oracle__oracle_headings.snap      |  39 ++
 tests/snapshots/oracle__oracle_images.snap        |  63 +++
 tests/snapshots/oracle__oracle_lists.snap         | 123 +++++
 tests/snapshots/oracle__oracle_minimal.snap       |  53 ++
 tests/snapshots/oracle__oracle_table.snap         |  41 ++
 tests/snapshots/oracle__oracle_timestamps.snap    |  74 +++
 23 files changed, 2133 insertions(+), 46 deletions(-)

diff --git a/README.md b/README.md
index 4aec899..7cd1292 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,7 @@ is the only inherently global stage — it is where the link dependency graph is
 | Stage | Module | Notes |
 |---|---|---|
 | PARSE | `src/parser.rs` | Hand-written recursive descent: line lexer → element builder → inline tokenizer. |
+| audit | `src/audit.rs` | Phase 0 corpus audit: construct frequencies against the IN/OUT line. |
 | model | `src/model.rs` | The org element tree — Elements (block) vs Objects (inline). |
 | INDEX | `src/index.rs` | Collect link targets into a symbol table. |
 | RESOLVE | `src/resolve.rs` | Rewrite links to URLs; return the used-target list (dependency edges). |
@@ -50,8 +51,9 @@ semantics; non-HTML export blocks; the full Unicode entity set.
 **Scope guardrail:** every IN item gets a golden-file fixture; every OUT item gets a test
 asserting it degrades predictably (ignored, no crash). The IN/OUT line is enforced by
 `tests/constructs.rs`, defending against the project's #1 risk: scope creep back toward
-all-of-org. The fixtures are hand-written today; deriving them from a real corpus is
-Phase 0.
+all-of-org. Phase 0 checked this line against a real 179-file corpus and found it sound
+(99.9% of construct uses in scope) — but also found one thing missing from it entirely:
+`#+SLUG:`. See [Phase 0](#phase-0-the-corpus-audit-and-the-emacs-oracle).
 
 ## Phase plan
 
@@ -62,7 +64,7 @@ Phase 0.
 | **v0.2** | **Multi-file SITE build: INDEX + RESOLVE internal links, minijinja templates, `build <src-dir> <out-dir>`, tables + footnotes** | **done** |
 | **v0.3** | **Incremental build layer: content/config/template hashing, dependency graph, per-page render keys, persisted cache manifest, invalidation** | **done** |
 | **v0.4** | **MVP: the full v1 construct scope — heading metadata, nested/description lists, block types, timestamps, images, syntect highlighting — with the IN/OUT line under test** | **done** |
-| 0 | Corpus audit + `emacs --batch` ground-truth oracle | todo |
+| **0** | **Corpus audit + `emacs --batch` ground-truth oracle** | **done** |
 | 1 | Line lexer + heading/section skeleton | done |
 | 2 | Block elements — lists, source blocks, tables, footnote defs, blocks by type, drawers | done |
 | 3 | Inline objects — emphasis, links, bare URLs, footnote refs, timestamps | done |
@@ -162,11 +164,82 @@ excluded construct to a specific degradation: babel is never executed *and* a ch
 as literal text; drawers other than PROPERTIES are captured and dropped; unmodelled block
 types keep their content verbatim.
 
-**Still out at v0.4:** the Phase 0 corpus audit and `emacs --batch` oracle (the fixtures are
-hand-written, so "matches Emacs" is asserted by construction, not measured); rayon
-parallelism; parse errors carrying source locations; `#+TODO:` per-file keyword sequences;
-planning lines (`SCHEDULED:`/`DEADLINE:`), which render as ordinary paragraphs; fixed-width
-`: ` lines; and the `watch` fs-notify integration.
+**Still out at v0.4:** rayon parallelism; parse errors carrying source locations; `#+TODO:`
+per-file keyword sequences; planning lines (`SCHEDULED:`/`DEADLINE:`), which render as
+ordinary paragraphs; fixed-width `: ` lines; and the `watch` fs-notify integration.
+
+## Phase 0: the corpus audit and the Emacs oracle
+
+The v1 scope was, by its own admission, *recommended* — a guess about which slice of org
+matters. Phase 0 replaces both halves of that guess with a measurement: an audit that asks
+what a real corpus actually uses, and an oracle that asks whether we render it the way
+Emacs does. The corpus is the 179 files behind [cleberg.net](https://cleberg.net), which is
+published today by weblorg — a wrapper around org's own HTML exporter. That makes it both
+the workload and the incumbent.
+
+```
+cargo run -- audit <src-dir>   # what does this corpus use, and is it in scope?
+cargo test --test oracle       # how does our HTML differ from Emacs' own export?
+```
+
+### What the audit found
+
+**The scope guess was sound.** 99.9% of construct uses in the corpus are in scope. The
+whole out-of-scope tail is 8 uses: four `#+TBLFM:` in a post *about* org-mode, three
+`\name` entities, and one `#+BEGIN_NOTE`.
+
+**`#+SLUG:` was a hole big enough to sink the project.** 178 of 179 files set it, and the
+published URL comes from it, not from the filename: `2018-11-28-aes-encryption.org` is
+served at `blog/aes-encryption.html`. org-ssg derived output paths from source filenames,
+so **169 of 179 pages would have been published at the wrong URL** — every inbound link and
+every search result, broken, by a tool that reported a clean build. Output paths now come
+from `#+SLUG:` when present ([`util::output_path`](src/util.rs)); slugs are sanitized so an
+author-supplied `../../etc/x` cannot escape the output directory, and two pages claiming one
+URL is a build error rather than a silently dropped page. Building the real corpus now
+reproduces all 179 of the live site's URLs exactly.
+
+**Some machinery is speculative.** The corpus contains no `id:`, `#custom-id` or `*Heading`
+links at all — its cross-page links are hand-written relative URLs. The INDEX/RESOLVE
+symbol table that v0.2 was built around is, against this corpus, unexercised.
+
+**An audit can lie too.** The first run reported 23 uses of a custom TODO keyword sequence.
+All 23 were false: the detector read the leading word of `* CSS Variables` as the keyword
+`CSS`. The corpus defines no `#+TODO:` sequences at all, so the true count was zero. The
+detector now matches conventional keyword names only — a tool that overstates a gap argues
+for work nobody needs.
+
+### What the oracle found
+
+`tests/oracle.rs` exports each fixture with org's own exporter via `emacs --batch`, reduces
+both sides to a semantic skeleton (element opens, closes and text, with layout `div`s,
+inline `span`s and all attributes but `href`/`src` dropped), and **snapshots the
+disagreement**. Snapshotting rather than asserting is deliberate: a checked-in divergence
+report gets reviewed and shows up as a diff, where a permanently red test gets ignored.
+Three invariants are asserted outright, and all three hold — heading structure, list
+nesting, and source-block text match Emacs exactly.
+
+**No bugs in org-ssg.** Every remaining divergence is a deliberate choice to emit better
+HTML than org does:
+
+| | org-ssg | Emacs | why |
+|---|---|---|---|
+| emphasis | `<em>`/`<strong>` | `<i>`/`<b>` | semantic, not presentational |
+| captioned image | `<figure>`/`<figcaption>` | `<p>` + `"Figure 1: …"` | real figure semantics |
+| timestamp | `<time datetime="…">` | literal `<2024-01-15 Mon>` | machine-readable |
+| footnotes | `<section><ol>` | `<h2>Footnotes:</h2>` | a list of notes is a list |
+| heading anchor | slug of the text | `org1a2b3c4` | stable, and what the live site serves |
+| code | `<pre><code>` | `<pre>` | the HTML5 idiom |
+
+One genuine semantic difference: org treats a single blank line between a `1.` list and a
+`-` list as *one* list and keeps the first item's bullet type, while we start a second list.
+We keep ours, on measurement rather than taste — the pattern occurs **zero** times in the
+corpus, so matching an org quirk would buy nothing and cost the more obvious reading.
+
+**The oracle's best catch was three bugs in itself.** Naive normalization reported code as
+corrupted (it trimmed each of syntect's per-token text runs, turning `def greet` into
+`defgreet`) and reported blocks at 36% agreement (syntect's spans flooded the diff). Both
+were measurement artifacts. A differential harness is a piece of software like any other,
+and the first divergences it reports are usually its own.
 
 **From v0.1 (core subset):** headings with nesting and anchors (every heading is now
 anchored — `:CUSTOM_ID:`/`:ID:` else a slug of its text) and trailing tags; paragraphs;
@@ -188,6 +261,7 @@ cargo build
 cargo test
 cargo run -- build fixtures/minimal.org -o minimal.html   # single file
 cargo run -- build fixtures/site -o _site                 # whole site (incremental)
+cargo run -- audit fixtures/site                          # corpus audit (Phase 0)
 cargo run -- build fixtures/site -o _site --no-cache      # force a full rebuild
 cargo run -- watch fixtures/site -o _site                 # poll + rebuild on change
 cargo run -- clean _site                                  # remove output + cache
diff --git a/fixtures/slugsite/2024-02-11-long-source-name.org b/fixtures/slugsite/2024-02-11-long-source-name.org
new file mode 100644
index 0000000..933dcf2
--- /dev/null
+++ b/fixtures/slugsite/2024-02-11-long-source-name.org
@@ -0,0 +1,11 @@
+#+TITLE: The Post
+#+SLUG: short-url
+
+The source filename carries a date; the published URL does not.
+
+* Setup
+:PROPERTIES:
+:CUSTOM_ID: setup
+:END:
+
+Linking into this heading must land on the slugged page, not the source name.
diff --git a/fixtures/slugsite/index.org b/fixtures/slugsite/index.org
new file mode 100644
index 0000000..563e3d5
--- /dev/null
+++ b/fixtures/slugsite/index.org
@@ -0,0 +1,3 @@
+#+TITLE: Home
+
+Read [[file:2024-02-11-long-source-name.org][the post]], or jump to its [[#setup][setup section]].
diff --git a/src/audit.rs b/src/audit.rs
new file mode 100644
index 0000000..50ef7ec
--- /dev/null
+++ b/src/audit.rs
@@ -0,0 +1,620 @@
+//! Corpus audit (spec §5, Phase 0): measure which org constructs a real corpus actually
+//! uses, and classify each against the v1 IN/OUT line.
+//!
+//! This exists because the v1 scope was, on the README's own admission, *recommended*
+//! rather than measured — a guess about which slice of org matters. A guess about a
+//! corpus is a hypothesis, and this is the experiment. It answers two questions:
+//!
+//! 1. **Coverage** — of the constructs this corpus uses, which do we handle? A construct
+//!    that is common here and out of scope is a scope bug, not a corpus quirk.
+//! 2. **Blind spots** — which constructs are here that the implementation has no opinion
+//!    about at all? These are the dangerous ones: not "known unsupported" but unknown.
+//!
+//! The audit is deliberately a *separate, line-oriented scanner* rather than a reuse of
+//! [`crate::parser`]. Auditing with the parser could only ever find constructs the parser
+//! already knows about, which is precisely the wrong instrument for question 2 — it would
+//! report a blind spot as clean.
+//!
+//! Nothing here reports document *text*. Counts, construct names, and `file:line`
+//! locations only, so an audit of private notes stays publishable.
+
+use std::collections::BTreeMap;
+
+use anyhow::{Context, Result};
+use camino::{Utf8Path, Utf8PathBuf};
+use walkdir::WalkDir;
+
+/// Where a construct sits relative to the v1 scope line (README §"v1 scope").
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub enum Scope {
+    /// v1 handles this.
+    In,
+    /// v1 deliberately excludes this; it degrades predictably.
+    Out,
+}
+
+impl Scope {
+    fn label(self) -> &'static str {
+        match self {
+            Scope::In => "IN ",
+            Scope::Out => "OUT",
+        }
+    }
+}
+
+/// One construct's tally across the corpus.
+#[derive(Debug, Default, Clone)]
+pub struct Tally {
+    pub occurrences: usize,
+    pub files: usize,
+    /// First `file:line` the construct was seen at, to make a finding actionable.
+    pub first_seen: Option<String>,
+    /// Set while scanning one file, to count each file once.
+    seen_in_current_file: bool,
+}
+
+/// The audit result: the fixed construct catalog plus the dynamic name censuses.
+#[derive(Debug, Default)]
+pub struct Audit {
+    pub files: usize,
+    pub lines: usize,
+    /// Catalogued constructs → tally.
+    pub constructs: BTreeMap<(Scope, &'static str), Tally>,
+    /// Every distinct `#+KEYWORD:` seen, by name.
+    pub keywords: BTreeMap<String, Tally>,
+    /// Every distinct `#+BEGIN_<TYPE>` seen, by type.
+    pub blocks: BTreeMap<String, Tally>,
+    /// Every distinct `:DRAWER:` seen, by name.
+    pub drawers: BTreeMap<String, Tally>,
+    /// Every distinct link scheme seen (`https`, `file`, `id`, `denote`, ...).
+    pub link_schemes: BTreeMap<String, Tally>,
+}
+
+/// Names the implementation understands, so the census can flag everything else. These
+/// are the *recognized* sets, not the supported ones: `INCLUDE` is recognized (it is
+/// deliberately inert) while an unlisted keyword is a genuine blind spot.
+const KNOWN_KEYWORDS: &[&str] = &[
+    "TITLE", "AUTHOR", "DATE", "EMAIL", "LANGUAGE", "OPTIONS", "FILETAGS", "DESCRIPTION",
+    "KEYWORDS", "CAPTION", "NAME", "ATTR_HTML", "RESULTS", "TBLFM", "INCLUDE", "TODO",
+    "STARTUP", "SUBTITLE", "SETUPFILE", "MACRO", "PROPERTY", "HTML_HEAD", "EXCLUDE_TAGS",
+];
+const KNOWN_BLOCKS: &[&str] = &["SRC", "QUOTE", "EXAMPLE", "CENTER", "EXPORT"];
+const KNOWN_DRAWERS: &[&str] = &["PROPERTIES", "LOGBOOK", "END"];
+/// Keyword names conventional enough to be worth flagging when they lead a heading.
+/// A custom sequence is only *real* if some `#+TODO:` declares it, which the census
+/// reports separately — this list keeps the heading-level signal honest.
+const CONVENTIONAL_TODO_KEYWORDS: &[&str] = &[
+    "NEXT", "WAITING", "HOLD", "CANCELLED", "CANCELED", "STARTED", "SOMEDAY", "PROJ",
+    "IN-PROGRESS", "BLOCKED", "REVIEW",
+];
+const KNOWN_SCHEMES: &[&str] = &[
+    "http", "https", "mailto", "ftp", "news", "tel", "file", "id", "custom-id", "heading",
+    "relative",
+];
+
+impl Audit {
+    /// Is this name one the implementation recognizes?
+    pub fn is_known(kind: Census, name: &str) -> bool {
+        let known = match kind {
+            Census::Keyword => KNOWN_KEYWORDS,
+            Census::Block => KNOWN_BLOCKS,
+            Census::Drawer => KNOWN_DRAWERS,
+            Census::Scheme => KNOWN_SCHEMES,
+        };
+        known.iter().any(|k| k.eq_ignore_ascii_case(name))
+    }
+}
+
+/// Which dynamic census a name belongs to.
+#[derive(Debug, Clone, Copy)]
+pub enum Census {
+    Keyword,
+    Block,
+    Drawer,
+    Scheme,
+}
+
+/// Walk `root`, auditing every `.org` file.
+pub fn audit(root: &Utf8Path) -> Result<Audit> {
+    let mut audit = Audit::default();
+    let mut paths: Vec<Utf8PathBuf> = Vec::new();
+
+    if root.is_file() {
+        paths.push(root.to_owned());
+    } else {
+        for entry in WalkDir::new(root).sort_by_file_name() {
+            let entry = entry.with_context(|| format!("walking {root}"))?;
+            if !entry.file_type().is_file() {
+                continue;
+            }
+            let path = Utf8PathBuf::from_path_buf(entry.into_path())
+                .map_err(|p| anyhow::anyhow!("non-UTF-8 path: {}", p.display()))?;
+            if path.extension() == Some("org") {
+                paths.push(path);
+            }
+        }
+    }
+
+    for path in &paths {
+        // A file that cannot be read is reported and skipped: an audit of 179 files
+        // should not be lost to one unreadable one.
+        let source = match std::fs::read_to_string(path) {
+            Ok(s) => s,
+            Err(e) => {
+                eprintln!("warning: skipping {path}: {e}");
+                continue;
+            }
+        };
+        let rel = path.strip_prefix(root).unwrap_or(path).to_owned();
+        audit.scan_file(&rel, &source);
+        audit.files += 1;
+    }
+    Ok(audit)
+}
+
+impl Audit {
+    fn scan_file(&mut self, path: &Utf8Path, source: &str) {
+        // Reset the per-file flags so each construct counts this file at most once.
+        for tally in self.constructs.values_mut() {
+            tally.seen_in_current_file = false;
+        }
+        for map in [
+            &mut self.keywords,
+            &mut self.blocks,
+            &mut self.drawers,
+            &mut self.link_schemes,
+        ] {
+            for tally in map.values_mut() {
+                tally.seen_in_current_file = false;
+            }
+        }
+
+        let mut in_block: Option<String> = None;
+        for (idx, line) in source.lines().enumerate() {
+            self.lines += 1;
+            let at = format!("{path}:{}", idx + 1);
+            let trimmed = line.trim_start();
+
+            // Inside a verbatim block only the terminator matters — a `*` in a source
+            // block is not a heading, and counting it as one would corrupt the audit.
+            if let Some(kind) = &in_block {
+                if trimmed.to_ascii_uppercase().starts_with("#+END_") {
+                    in_block = None;
+                } else if kind.eq_ignore_ascii_case("SRC") || kind.eq_ignore_ascii_case("EXAMPLE") {
+                    continue;
+                }
+                continue;
+            }
+            if let Some(rest) = trimmed.to_ascii_uppercase().strip_prefix("#+BEGIN_") {
+                let kind = rest.split_whitespace().next().unwrap_or("").to_string();
+                self.count_census(Census::Block, &kind, &at);
+                self.count(scope_of_block(&kind), block_construct(&kind), &at);
+                if trimmed.to_ascii_uppercase().contains(":RESULTS") {
+                    self.count(Scope::Out, "babel header args (:results)", &at);
+                }
+                in_block = Some(kind);
+                continue;
+            }
+
+            self.scan_line(line, trimmed, &at);
+        }
+    }
+
+    fn scan_line(&mut self, line: &str, trimmed: &str, at: &str) {
+        // --- headings and their metadata ---
+        if let Some(stars) = heading_stars(line) {
+            self.count(Scope::In, "heading", at);
+            let rest = line[stars..].trim();
+            let word = rest.split_whitespace().next().unwrap_or("");
+            if word == "TODO" || word == "DONE" {
+                self.count(Scope::In, "TODO keyword (default set)", at);
+            } else if CONVENTIONAL_TODO_KEYWORDS.contains(&word) {
+                // Only conventional keyword names count. "Any all-caps first word" is
+                // the tempting rule and it is wrong: it reads `* CSS Variables` as the
+                // keyword `CSS`, which on this corpus produced 23 false positives and
+                // zero true ones. An audit that overstates a gap is worse than no audit,
+                // because it argues for work nobody needs.
+                self.count(Scope::Out, "TODO keyword (custom sequence)", at);
+            }
+            if rest.contains("[#") {
+                self.count(Scope::In, "priority cookie", at);
+            }
+            if rest.trim_end().ends_with(':') && rest.trim_end().matches(':').count() >= 2 {
+                self.count(Scope::In, "heading tags", at);
+            }
+            if rest.contains("[/") || rest.contains("[%") {
+                self.count(Scope::Out, "statistics cookie", at);
+            }
+            return;
+        }
+
+        // --- planning and clocking ---
+        for marker in ["SCHEDULED:", "DEADLINE:", "CLOSED:"] {
+            if trimmed.starts_with(marker) {
+                self.count(Scope::Out, "planning line", at);
+            }
+        }
+        if trimmed.starts_with("CLOCK:") {
+            self.count(Scope::Out, "clock entry", at);
+        }
+
+        // --- keywords and drawers ---
+        if let Some(rest) = trimmed.strip_prefix("#+") {
+            if let Some(colon) = rest.find(':') {
+                let key = rest[..colon].trim().to_ascii_uppercase();
+                if !key.is_empty() && !key.contains(char::is_whitespace) {
+                    self.count_census(Census::Keyword, &key, at);
+                    match key.as_str() {
+                        "CAPTION" | "NAME" | "ATTR_HTML" => {
+                            self.count(Scope::In, "affiliated keyword", at)
+                        }
+                        "TBLFM" => self.count(Scope::Out, "table formula (#+TBLFM:)", at),
+                        "INCLUDE" => self.count(Scope::Out, "#+INCLUDE:", at),
+                        "RESULTS" => self.count(Scope::Out, "babel results block", at),
+                        "TODO" => self.count(Scope::Out, "#+TODO: keyword sequence", at),
+                        "MACRO" => self.count(Scope::Out, "macro definition", at),
+                        _ => self.count(Scope::In, "#+ keyword", at),
+                    }
+                }
+            }
+        } else if is_drawer(trimmed) {
+            let name = trimmed[1..trimmed.len() - 1].to_ascii_uppercase();
+            if name != "END" {
+                self.count_census(Census::Drawer, &name, at);
+                match name.as_str() {
+                    "PROPERTIES" => self.count(Scope::In, "property drawer", at),
+                    _ => self.count(Scope::Out, "non-PROPERTIES drawer", at),
+                }
+            }
+        }
+
+        // --- lists, tables, rules ---
+        if let Some(bullet) = list_bullet(trimmed) {
+            self.count(Scope::In, "list item", at);
+            if bullet == Bullet::Ordered {
+                self.count(Scope::In, "ordered list", at);
+            }
+            let indent = line.len() - trimmed.len();
+            if indent > 0 {
+                self.count(Scope::In, "nested list item", at);
+            }
+            if trimmed.contains(" :: ") {
+                self.count(Scope::In, "description list", at);
+            }
+            let after = trimmed.trim_start_matches(['-', '+', '*', ' ']);
+            if after.starts_with("[ ]") || after.starts_with("[X]") || after.starts_with("[-]") {
+                self.count(Scope::In, "checkbox", at);
+            }
+        }
+        if trimmed.starts_with('|') {
+            self.count(Scope::In, "table row", at);
+        }
+        if trimmed.starts_with(':') && !is_drawer(trimmed) && trimmed.starts_with(": ") {
+            self.count(Scope::Out, "fixed-width line", at);
+        }
+
+        // --- footnotes ---
+        if trimmed.starts_with("[fn:") {
+            self.count(Scope::In, "footnote definition", at);
+        } else if line.contains("[fn:") {
+            self.count(Scope::In, "footnote reference", at);
+        }
+
+        // --- inline objects ---
+        self.scan_inline(line, at);
+    }
+
+    fn scan_inline(&mut self, line: &str, at: &str) {
+        // Links: count each `[[target]]`, censusing its scheme.
+        let mut rest = line;
+        while let Some(start) = rest.find("[[") {
+            let after = &rest[start + 2..];
+            let Some(end) = after.find("]]") else { break };
+            let inner = &after[..end];
+            let target = inner.split("][").next().unwrap_or(inner);
+            self.count(Scope::In, "link", at);
+            self.count_census(Census::Scheme, &link_scheme(target), at);
+            rest = &after[end..];
+        }
+
+        if has_timestamp(line) {
+            self.count(Scope::In, "timestamp", at);
+        }
+        if line.contains("{{{") {
+            self.count(Scope::Out, "macro call", at);
+        }
+        if line.contains("<<<") {
+            self.count(Scope::Out, "radio target", at);
+        } else if line.contains("<<") && line.contains(">>") {
+            self.count(Scope::Out, "internal target", at);
+        }
+        if line.contains("\\begin{") || latex_inline(line) {
+            self.count(Scope::Out, "LaTeX fragment", at);
+        }
+        if entity_ref(line) {
+            self.count(Scope::Out, "entity (\\name)", at);
+        }
+        for (marker, name) in [
+            ('*', "bold"),
+            ('/', "italic"),
+            ('_', "underline"),
+            ('+', "strike-through"),
+            ('=', "verbatim"),
+            ('~', "code"),
+        ] {
+            if emphasis_pair(line, marker) {
+                self.count(Scope::In, name, at);
+            }
+        }
+    }
+
+    fn count(&mut self, scope: Scope, name: &'static str, at: &str) {
+        let tally = self.constructs.entry((scope, name)).or_default();
+        bump(tally, at);
+    }
+
+    fn count_census(&mut self, kind: Census, name: &str, at: &str) {
+        let map = match kind {
+            Census::Keyword => &mut self.keywords,
+            Census::Block => &mut self.blocks,
+            Census::Drawer => &mut self.drawers,
+            Census::Scheme => &mut self.link_schemes,
+        };
+        let tally = map.entry(name.to_string()).or_default();
+        bump(tally, at);
+    }
+}
+
+fn bump(tally: &mut Tally, at: &str) {
+    tally.occurrences += 1;
+    if !tally.seen_in_current_file {
+        tally.seen_in_current_file = true;
+        tally.files += 1;
+    }
+    if tally.first_seen.is_none() {
+        tally.first_seen = Some(at.to_string());
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Line-level detectors. Deliberately independent of the parser (see module docs).
+// ---------------------------------------------------------------------------
+
+fn heading_stars(line: &str) -> Option<usize> {
+    if !line.starts_with('*') {
+        return None;
+    }
+    let stars = line.chars().take_while(|c| *c == '*').count();
+    let after = &line[stars..];
+    (after.starts_with(' ') || after.is_empty()).then_some(stars)
+}
+
+#[derive(PartialEq)]
+enum Bullet {
+    Unordered,
+    Ordered,
+}
+
+fn list_bullet(trimmed: &str) -> Option<Bullet> {
+    let bytes = trimmed.as_bytes();
+    if bytes.is_empty() {
+        return None;
+    }
+    if (bytes[0] == b'-' || bytes[0] == b'+') && (bytes.len() == 1 || bytes[1] == b' ') {
+        return Some(Bullet::Unordered);
+    }
+    let digits = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
+    if digits > 0 {
+        let after = &trimmed[digits..];
+        if (after.starts_with('.') || after.starts_with(')'))
+            && (after.len() == 1 || after.as_bytes()[1] == b' ')
+        {
+            return Some(Bullet::Ordered);
+        }
+    }
+    None
+}
+
+fn is_drawer(trimmed: &str) -> bool {
+    let t = trimmed.trim_end();
+    t.len() >= 3
+        && t.starts_with(':')
+        && t.ends_with(':')
+        && t[1..t.len() - 1]
+            .chars()
+            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
+        && t.len() > 2
+}
+
+fn scope_of_block(kind: &str) -> Scope {
+    if KNOWN_BLOCKS.iter().any(|k| k.eq_ignore_ascii_case(kind)) {
+        Scope::In
+    } else {
+        Scope::Out
+    }
+}
+
+fn block_construct(kind: &str) -> &'static str {
+    match kind.to_ascii_uppercase().as_str() {
+        "SRC" => "source block",
+        "QUOTE" => "quote block",
+        "EXAMPLE" => "example block",
+        "CENTER" => "center block",
+        "EXPORT" => "export block",
+        _ => "unmodelled block type",
+    }
+}
+
+/// The scheme of a link target, normalized into the census's vocabulary.
+fn link_scheme(target: &str) -> String {
+    if let Some(rest) = target.split_once(':') {
+        let scheme = rest.0;
+        if !scheme.is_empty()
+            && scheme
+                .chars()
+                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '+')
+        {
+            return scheme.to_ascii_lowercase();
+        }
+    }
+    if target.starts_with('#') {
+        return "custom-id".to_string();
+    }
+    if target.starts_with('*') {
+        return "heading".to_string();
+    }
+    "relative".to_string()
+}
+
+/// A `<...>`/`[...]` span opening with an ISO date is a timestamp.
+fn has_timestamp(line: &str) -> bool {
+    let bytes = line.as_bytes();
+    for (i, c) in line.char_indices() {
+        if c != '<' && c != '[' {
+            continue;
+        }
+        let rest = &bytes[i + 1..];
+        if rest.len() >= 10
+            && rest[..4].iter().all(u8::is_ascii_digit)
+            && rest[4] == b'-'
+            && rest[5..7].iter().all(u8::is_ascii_digit)
+            && rest[7] == b'-'
+            && rest[8..10].iter().all(u8::is_ascii_digit)
+        {
+            return true;
+        }
+    }
+    false
+}
+
+/// `$x$` or `\(x\)` inline math. `$` alone (a price, a shell prompt) is not math.
+fn latex_inline(line: &str) -> bool {
+    if line.contains("\\(") && line.contains("\\)") {
+        return true;
+    }
+    let dollars = line.matches('$').count();
+    dollars >= 2 && line.contains("$\\")
+}
+
+/// A `\name` entity reference such as `\alpha`, excluding LaTeX environment commands.
+fn entity_ref(line: &str) -> bool {
+    for (i, c) in line.char_indices() {
+        if c != '\\' {
+            continue;
+        }
+        let rest = &line[i + 1..];
+        let name: String = rest.chars().take_while(|c| c.is_ascii_alphabetic()).collect();
+        if name.len() >= 3 && !matches!(name.as_str(), "begin" | "end") {
+            return true;
+        }
+    }
+    false
+}
+
+/// A plausible `*bold*`-style emphasis pair: two markers on one line with non-space
+/// content between them. Approximate by design — the audit measures prevalence, and the
+/// parser owns the exact pre/post-character rules.
+fn emphasis_pair(line: &str, marker: char) -> bool {
+    let positions: Vec<usize> = line
+        .char_indices()
+        .filter(|(_, c)| *c == marker)
+        .map(|(i, _)| i)
+        .collect();
+    if positions.len() < 2 {
+        return false;
+    }
+    // A leading `*` is a heading, and `-`/`+` at line start is a bullet.
+    let trimmed = line.trim_start();
+    if trimmed.starts_with(marker) {
+        return false;
+    }
+    positions.windows(2).any(|w| w[1] > w[0] + 1)
+}
+
+// ---------------------------------------------------------------------------
+// Report
+// ---------------------------------------------------------------------------
+
+/// Render the audit as a readable report. Names, counts and locations only — never
+/// document text, so an audit of private notes is safe to paste into an issue.
+pub fn report(audit: &Audit) -> String {
+    let mut out = String::new();
+    out.push_str(&format!(
+        "corpus: {} file(s), {} line(s)\n",
+        audit.files, audit.lines
+    ));
+
+    let mut rows: Vec<(&(Scope, &str), &Tally)> = audit.constructs.iter().collect();
+    rows.sort_by(|a, b| {
+        b.1.occurrences
+            .cmp(&a.1.occurrences)
+            .then_with(|| a.0 .1.cmp(b.0 .1))
+    });
+
+    out.push_str("\nCONSTRUCTS (by frequency)\n");
+    out.push_str(&format!(
+        "{:<4} {:<32} {:>8} {:>7}  {}\n",
+        "", "construct", "uses", "files", "first seen"
+    ));
+    for ((scope, name), tally) in &rows {
+        out.push_str(&format!(
+            "{:<4} {:<32} {:>8} {:>7}  {}\n",
+            scope.label(),
+            name,
+            tally.occurrences,
+            tally.files,
+            tally.first_seen.as_deref().unwrap_or("")
+        ));
+    }
+
+    let in_uses: usize = rows
+        .iter()
+        .filter(|((s, _), _)| *s == Scope::In)
+        .map(|(_, t)| t.occurrences)
+        .sum();
+    let out_uses: usize = rows
+        .iter()
+        .filter(|((s, _), _)| *s == Scope::Out)
+        .map(|(_, t)| t.occurrences)
+        .sum();
+    let total = in_uses + out_uses;
+    let pct = |n: usize| {
+        if total == 0 {
+            0.0
+        } else {
+            100.0 * n as f64 / total as f64
+        }
+    };
+    out.push_str(&format!(
+        "\ncoverage: {in_uses} in-scope use(s) ({:.1}%), {out_uses} out-of-scope ({:.1}%)\n",
+        pct(in_uses),
+        pct(out_uses)
+    ));
+
+    for (title, kind, map) in [
+        ("KEYWORDS", Census::Keyword, &audit.keywords),
+        ("BLOCK TYPES", Census::Block, &audit.blocks),
+        ("DRAWERS", Census::Drawer, &audit.drawers),
+        ("LINK SCHEMES", Census::Scheme, &audit.link_schemes),
+    ] {
+        let mut names: Vec<(&String, &Tally)> = map.iter().collect();
+        names.sort_by(|a, b| b.1.occurrences.cmp(&a.1.occurrences).then(a.0.cmp(b.0)));
+        out.push_str(&format!("\n{title}\n"));
+        for (name, tally) in names {
+            let flag = if Audit::is_known(kind, name) {
+                "   "
+            } else {
+                "??? "
+            };
+            out.push_str(&format!(
+                "{flag}{:<32} {:>8} {:>7}  {}\n",
+                name,
+                tally.occurrences,
+                tally.files,
+                tally.first_seen.as_deref().unwrap_or("")
+            ));
+        }
+    }
+    out.push_str("\n`???` marks a name the implementation does not recognize at all.\n");
+    out
+}
diff --git a/src/index.rs b/src/index.rs
index 5bf859c..26dac10 100644
--- a/src/index.rs
+++ b/src/index.rs
@@ -7,7 +7,7 @@ use camino::{Utf8Path, Utf8PathBuf};
 use serde::{Deserialize, Serialize};
 
 use crate::model::{Document, Section};
-use crate::util::{plain_text, slugify};
+use crate::util::{output_path, plain_text, slugify};
 
 /// Identity of a link target. A target is owned by exactly one file (spec §4.3).
 ///
@@ -51,6 +51,9 @@ impl TargetId {
 #[derive(Debug, Clone)]
 pub struct TargetLocation {
     pub source_path: Utf8PathBuf,
+    /// The page this target is emitted into. Recorded at INDEX time because it depends
+    /// on the defining document's `#+SLUG:`, which only that document knows.
+    pub output_path: Utf8PathBuf,
     /// Final URL fragment/anchor for the target, filled during resolution.
     pub anchor: Option<String>,
 }
@@ -71,14 +74,16 @@ impl SymbolTable {
     /// the renderer emits for that target's heading.
     pub fn index_document(&mut self, doc: &Document) {
         let path = &doc.source_path;
+        let out = output_path(path, &doc.keywords);
         self.targets.insert(
             TargetId::File(path.clone()),
             TargetLocation {
                 source_path: path.clone(),
+                output_path: out.clone(),
                 anchor: None,
             },
         );
-        index_section(&doc.root, path, &mut self.targets);
+        index_section(&doc.root, path, &out, &mut self.targets);
     }
 }
 
@@ -107,40 +112,37 @@ fn collect_targets(section: &Section, out: &mut Vec<TargetId>) {
     }
 }
 
-fn index_section(section: &Section, path: &Utf8Path, targets: &mut HashMap<TargetId, TargetLocation>) {
+fn index_section(
+    section: &Section,
+    path: &Utf8Path,
+    out: &Utf8Path,
+    targets: &mut HashMap<TargetId, TargetLocation>,
+) {
     if let Some(h) = &section.heading {
         let anchor = h
             .custom_id
             .clone()
             .or_else(|| h.id.clone())
             .unwrap_or_else(|| slugify(&plain_text(&h.title)));
-        if let Some(cid) = &h.custom_id {
+        let mut record = |id: TargetId, anchor: Option<String>| {
             targets.insert(
-                TargetId::CustomId(cid.clone()),
+                id,
                 TargetLocation {
                     source_path: path.to_owned(),
-                    anchor: Some(cid.clone()),
+                    output_path: out.to_owned(),
+                    anchor,
                 },
             );
+        };
+        if let Some(cid) = &h.custom_id {
+            record(TargetId::CustomId(cid.clone()), Some(cid.clone()));
         }
         if let Some(id) = &h.id {
-            targets.insert(
-                TargetId::Id(id.clone()),
-                TargetLocation {
-                    source_path: path.to_owned(),
-                    anchor: Some(id.clone()),
-                },
-            );
+            record(TargetId::Id(id.clone()), Some(id.clone()));
         }
-        targets.insert(
-            TargetId::Heading(plain_text(&h.title)),
-            TargetLocation {
-                source_path: path.to_owned(),
-                anchor: Some(anchor),
-            },
-        );
+        record(TargetId::Heading(plain_text(&h.title)), Some(anchor));
     }
     for child in &section.children {
-        index_section(child, path, targets);
+        index_section(child, path, out, targets);
     }
 }
diff --git a/src/lib.rs b/src/lib.rs
index 20fe144..4d11b08 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -8,6 +8,7 @@
 //! [`render`] (RENDER) → [`template`] (TEMPLATE) → EMIT, with [`incremental`]
 //! deciding which pages actually need rewriting.
 
+pub mod audit;
 pub mod incremental;
 pub mod index;
 pub mod model;
diff --git a/src/main.rs b/src/main.rs
index a86cd00..68980a8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -49,6 +49,12 @@ enum Command {
         /// Output directory to remove.
         output: Utf8PathBuf,
     },
+    /// Audit a corpus: report which org constructs it uses and how they land against
+    /// the v1 scope line. Reports names, counts and locations — never document text.
+    Audit {
+        /// Source directory (or single `.org` file) to audit.
+        input: Utf8PathBuf,
+    },
 }
 
 fn main() -> Result<()> {
@@ -86,6 +92,11 @@ fn main() -> Result<()> {
         // 6 lists `watch`; the real fs-notify integration is deferred). It rebuilds
         // incrementally whenever any source file's mtime advances.
         Command::Watch { input, output } => watch(&input, &output),
+        Command::Audit { input } => {
+            let result = org_ssg::audit::audit(&input)?;
+            print!("{}", org_ssg::audit::report(&result));
+            Ok(())
+        }
         Command::Clean { output } => {
             if output.exists() {
                 fs::remove_dir_all(&output)
diff --git a/src/resolve.rs b/src/resolve.rs
index 82fd742..2db8aa2 100644
--- a/src/resolve.rs
+++ b/src/resolve.rs
@@ -15,7 +15,7 @@ use camino::Utf8Path;
 
 use crate::index::{SymbolTable, TargetId};
 use crate::model::{Document, Element, Link, LinkTarget, Object, Section, TableRow};
-use crate::util::{normalize_link_path, output_url};
+use crate::util::{normalize_link_path, output_path, output_url};
 
 /// A document whose links have been rewritten to concrete URLs.
 #[derive(Debug, Clone)]
@@ -42,10 +42,13 @@ pub struct ResolveOutput {
 pub fn resolve(doc: &Document, symbols: &SymbolTable) -> ResolveOutput {
     let mut document = doc.clone();
     let from = doc.source_path.clone();
+    // URLs are computed between *output* paths, which `#+SLUG:` can rename.
+    let from_out = output_path(&from, &doc.keywords);
     let mut used = Vec::new();
     let mut broken = Vec::new();
     let mut cx = Cx {
         from: &from,
+        from_out: &from_out,
         symbols,
         used: &mut used,
         broken: &mut broken,
@@ -70,6 +73,7 @@ fn human_text(target: &LinkTarget) -> Option<String> {
 
 struct Cx<'a> {
     from: &'a Utf8Path,
+    from_out: &'a Utf8Path,
     symbols: &'a SymbolTable,
     used: &'a mut Vec<TargetId>,
     broken: &'a mut Vec<BrokenLink>,
@@ -167,7 +171,7 @@ impl Cx<'_> {
                         link.description = Some(vec![Object::Text(text)]);
                     }
                 }
-                let url = output_url(self.from, &loc.source_path, loc.anchor.as_deref());
+                let url = output_url(self.from_out, &loc.output_path, loc.anchor.as_deref());
                 link.target = LinkTarget::External(url);
             }
             None => {
diff --git a/src/site.rs b/src/site.rs
index b9175d1..a8a1c4c 100644
--- a/src/site.rs
+++ b/src/site.rs
@@ -26,7 +26,7 @@ use crate::parser::parse;
 use crate::render::{render, syntax_css, Html, SyntectHighlighter};
 use crate::resolve::resolve;
 use crate::template::{template_sources, NavItem, Templater};
-use crate::util::{output_url, relative_root};
+use crate::util::{output_path, output_url, relative_root};
 
 /// A fully built page: source and output paths (relative to their roots) and its
 /// final templated HTML.
@@ -100,12 +100,27 @@ fn prepare_pages(src: &Utf8Path) -> Result<(Vec<PagePrep>, SymbolTable)> {
         symbols.index_document(doc);
     }
 
-    // Nav is global; titles come from #+TITLE (falling back to the file stem).
+    // Nav is global; titles come from #+TITLE (falling back to the file stem) and URLs
+    // from each page's output path, which `#+SLUG:` can rename.
     let entries: Vec<(Utf8PathBuf, String)> = docs
         .iter()
-        .map(|d| (d.source_path.clone(), page_title(d)))
+        .map(|d| (output_path(&d.source_path, &d.keywords), page_title(d)))
         .collect();
 
+    // Two sources emitting one page would silently drop a page — and with slugs, a
+    // collision is a typo away and invisible in the source filenames.
+    let mut claimed: std::collections::HashMap<&Utf8PathBuf, &Utf8PathBuf> =
+        std::collections::HashMap::new();
+    for (doc, (out, _)) in docs.iter().zip(&entries) {
+        if let Some(other) = claimed.insert(out, &doc.source_path) {
+            anyhow::bail!(
+                "output collision: {} and {} both build to {out} (check their #+SLUG:)",
+                other,
+                doc.source_path
+            );
+        }
+    }
+
     let mut pages = Vec::new();
     for doc in &docs {
         let out = resolve(doc, &symbols);
@@ -113,18 +128,20 @@ fn prepare_pages(src: &Utf8Path) -> Result<(Vec<PagePrep>, SymbolTable)> {
         let broken: Vec<TargetId> = out.broken.iter().map(|b| b.target.clone()).collect();
         let defines: HashSet<TargetId> = document_targets(doc).into_iter().collect();
 
+        let output = output_path(&doc.source_path, &doc.keywords);
+
         // Nav links are relative to *this* page (spec URL scheme, §8 Q3).
         let nav: Vec<NavItem> = entries
             .iter()
             .map(|(path, title)| NavItem {
                 title: title.clone(),
-                url: output_url(&doc.source_path, path, None),
+                url: output_url(&output, path, None),
             })
             .collect();
 
         pages.push(PagePrep {
             source: doc.source_path.clone(),
-            output: doc.source_path.with_extension("html"),
+            output,
             title: page_title(doc),
             content_hash: doc.content_hash,
             resolved: out.resolved,
@@ -190,9 +207,11 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
     // chrome on every page — is built from every page's (path, title), so a title/path
     // change or a page add/remove must re-render every page (else stale nav on disk).
     let cfg = BuildConfig::default();
+    // Keyed on the *output* path: a `#+SLUG:` change moves a page's URL, which changes
+    // the nav on every other page even though no source filename moved.
     let nav_entries: Vec<(String, String)> = preps
         .iter()
-        .map(|p| (p.source.to_string(), p.title.clone()))
+        .map(|p| (p.output.to_string(), p.title.clone()))
         .collect();
     let cfg_hash = combine(config_hash(&cfg), site_structure_hash(&nav_entries));
     let tmpl_hash = template_hash(template_sources());
diff --git a/src/util.rs b/src/util.rs
index e7eff35..c1506ec 100644
--- a/src/util.rs
+++ b/src/util.rs
@@ -4,7 +4,50 @@
 
 use camino::{Utf8Path, Utf8PathBuf};
 
-use crate::model::Object;
+use crate::model::{Keywords, Object};
+
+/// The output path for a document, relative to the site root.
+///
+/// Normally this is the source path with `.org` swapped for `.html`, but a `#+SLUG:`
+/// keyword renames the file — which is how the target corpus works: 178 of its 179 files
+/// set one, and `2018-11-28-aes-encryption.org` publishes as `aes-encryption.html`. The
+/// slug names the *file*, never the directory, so the page stays where its source lives.
+pub fn output_path(source: &Utf8Path, keywords: &Keywords) -> Utf8PathBuf {
+    let slug = keywords
+        .entries
+        .iter()
+        .find(|(k, _)| k.eq_ignore_ascii_case("SLUG"))
+        .map(|(_, v)| sanitize_slug(v))
+        .filter(|s| !s.is_empty());
+
+    match slug {
+        Some(slug) => {
+            let dir = source.parent().unwrap_or_else(|| Utf8Path::new(""));
+            dir.join(format!("{slug}.html"))
+        }
+        None => source.with_extension("html"),
+    }
+}
+
+/// Reduce a slug to a safe single filename component.
+///
+/// A slug is author-controlled text that becomes a path we write to, so `../../etc/x`
+/// has to be impossible by construction rather than by convention: separators and dots
+/// are folded to `-`, which cannot traverse and cannot produce a hidden file.
+fn sanitize_slug(raw: &str) -> String {
+    let mut out = String::with_capacity(raw.len());
+    let mut prev_dash = false;
+    for c in raw.trim().chars() {
+        if c.is_ascii_alphanumeric() || c == '_' {
+            out.extend(c.to_lowercase());
+            prev_dash = false;
+        } else if !prev_dash {
+            out.push('-');
+            prev_dash = true;
+        }
+    }
+    out.trim_matches('-').to_string()
+}
 
 /// Flatten inline objects to their plain-text content (markup stripped). Used to
 /// derive heading anchors and `[[*Heading]]` link identities (spec §4.3).
@@ -49,16 +92,19 @@ pub fn slugify(text: &str) -> String {
     out.trim_matches('-').to_string()
 }
 
-/// The output URL to reach `to_rel` (a source `.org` path relative to the site root)
-/// from the page at `from_rel`, honoring an optional `anchor`. Same-file links reduce
-/// to a bare `#anchor` fragment; cross-file links become a relative `.html` path.
-pub fn output_url(from_rel: &Utf8Path, to_rel: &Utf8Path, anchor: Option<&str>) -> String {
-    let path = if from_rel == to_rel {
+/// The URL to reach the page output at `to_out` from the page output at `from_out`,
+/// honoring an optional `anchor`. Same-page links reduce to a bare `#anchor` fragment;
+/// cross-page links become a relative path.
+///
+/// Both arguments are *output* paths, not source paths, because `#+SLUG:` means the two
+/// no longer correspond: deriving the URL here would reintroduce the filename assumption
+/// that [`output_path`] exists to remove.
+pub fn output_url(from_out: &Utf8Path, to_out: &Utf8Path, anchor: Option<&str>) -> String {
+    let path = if from_out == to_out {
         String::new()
     } else {
-        let to_html = to_rel.with_extension("html");
-        let from_dir = from_rel.parent().unwrap_or_else(|| Utf8Path::new(""));
-        relative_path(from_dir, &to_html)
+        let from_dir = from_out.parent().unwrap_or_else(|| Utf8Path::new(""));
+        relative_path(from_dir, to_out)
     };
     match anchor {
         Some(a) if !a.is_empty() => {
diff --git a/tests/oracle.el b/tests/oracle.el
new file mode 100644
index 0000000..c694ed7
--- /dev/null
+++ b/tests/oracle.el
@@ -0,0 +1,42 @@
+;;; oracle.el --- ground-truth HTML export for the org-ssg differential tests  -*- lexical-binding: t -*-
+
+;; Exports the org file named by $ORG_ORACLE_INPUT to HTML on stdout, using org's own
+;; exporter — the same one weblorg wraps to publish the corpus this project targets.
+;; Run with:  ORG_ORACLE_INPUT=x.org emacs -Q --batch -l tests/oracle.el
+;;
+;; `-Q' is deliberate: no user init, so the oracle is the stock org exporter and not
+;; this machine's Emacs configuration. The path is passed by environment variable
+;; rather than as an argument because batch Emacs would otherwise try to visit it.
+
+(require 'org)
+(require 'ox-html)
+
+;; Presentation settings are normalized so the diff carries semantic divergences only.
+;; Everything that affects *content* is left at its default, because the point is to
+;; learn what stock org does — normalizing that away would be marking our own homework.
+(setq org-export-with-toc nil              ; we emit no table of contents
+      org-export-with-section-numbers nil  ; we do not number headings
+      org-html-toplevel-hlevel 1           ; org defaults to h2 for a level-1 heading,
+                                           ; because a template supplies the page <h1>.
+                                           ; Aligning here keeps a global +1 offset from
+                                           ; drowning every real finding in the diff.
+      org-html-htmlize-output-type nil     ; plain <pre>, not htmlize spans: we highlight
+                                           ; with syntect, so comparing code *text* is
+                                           ; the meaningful part
+      org-html-head-include-default-style nil
+      org-html-head-include-scripts nil
+      ;; Fixtures link to ids that live in org-ssg's own symbol table, not in an
+      ;; `org-id' database. Without this, org aborts the whole export on the first one.
+      org-export-with-broken-links t
+      make-backup-files nil)
+
+(let ((input (getenv "ORG_ORACLE_INPUT")))
+  (unless input
+    (error "ORG_ORACLE_INPUT is not set"))
+  (with-temp-buffer
+    (insert-file-contents input)
+    (org-mode)
+    ;; BODY-ONLY: emit the content, not a full document with <head> chrome.
+    (princ (org-export-as 'html nil nil t nil))))
+
+;;; oracle.el ends here
diff --git a/tests/oracle.rs b/tests/oracle.rs
new file mode 100644
index 0000000..3723b44
--- /dev/null
+++ b/tests/oracle.rs
@@ -0,0 +1,454 @@
+//! The `emacs --batch` ground-truth oracle (spec §5, Phase 0).
+//!
+//! Every other test in this suite checks org-ssg against org-ssg: a snapshot says our
+//! output has not *changed*, never that it is *right*. Those two questions are different,
+//! and only one of them matters to someone whose site is currently published by Emacs.
+//! This file answers the second by exporting the same fixture with org's own HTML
+//! exporter — the exporter weblorg wraps to publish the target corpus — and diffing the
+//! two.
+//!
+//! **What is compared.** Byte equality is not a useful goal: org wraps every section in
+//! `outline-container` divs keyed by generated ids, and no amount of agreement on
+//! semantics would survive that. Both sides are reduced to a *semantic skeleton* — the
+//! sequence of element opens, closes, and text runs, with `<div>`s and all attributes
+//! except `href`/`src` dropped, whitespace collapsed, and entities decoded. What remains
+//! is the question worth asking: does org think this is a `<blockquote><p>`, and do we?
+//!
+//! **What the result means.** These tests do not assert agreement — they *snapshot the
+//! disagreement*. A divergence report that is checked in and reviewed is worth more than
+//! a red test nobody can act on, and it makes any new divergence show up as a diff in
+//! code review. A few invariants that must never break are asserted outright.
+//!
+//! The suite skips cleanly when Emacs is absent, so it never blocks a machine or CI
+//! runner that has no Emacs.
+
+use std::process::Command;
+
+use camino::Utf8PathBuf;
+
+use org_ssg::parser::parse;
+use org_ssg::render::{render, Html, SyntectHighlighter};
+use org_ssg::resolve::ResolvedDoc;
+
+fn manifest_dir() -> Utf8PathBuf {
+    Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+}
+
+/// Is a usable Emacs on PATH? The oracle is a development instrument, not a build
+/// dependency, so its absence skips rather than fails.
+fn emacs_available() -> bool {
+    Command::new("emacs")
+        .arg("--version")
+        .output()
+        .map(|o| o.status.success())
+        .unwrap_or(false)
+}
+
+/// Export a fixture with org's own HTML exporter.
+fn org_export(fixture: &str) -> String {
+    let root = manifest_dir();
+    let output = Command::new("emacs")
+        .args(["-Q", "--batch", "-l"])
+        .arg(root.join("tests/oracle.el"))
+        .env("ORG_ORACLE_INPUT", root.join("fixtures").join(fixture))
+        .current_dir(&root)
+        .output()
+        .expect("run emacs");
+    assert!(
+        output.status.success(),
+        "emacs export of {fixture} failed:\n{}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    String::from_utf8(output.stdout).expect("emacs emits UTF-8")
+}
+
+/// Render a fixture with org-ssg.
+fn our_export(fixture: &str) -> String {
+    let path = manifest_dir().join("fixtures").join(fixture);
+    let source = std::fs::read_to_string(&path).expect("read fixture");
+    let document = parse(Utf8PathBuf::from(fixture).as_path(), &source).expect("parse");
+    let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter::new());
+    html
+}
+
+// ---------------------------------------------------------------------------
+// HTML → semantic skeleton
+// ---------------------------------------------------------------------------
+
+/// Elements dropped from the skeleton entirely, because once attributes are gone they
+/// carry no meaning the two exporters could agree or disagree *about*.
+///
+/// `div` is pure layout: org wraps every section in `outline-container`/`outline-text`
+/// wrappers and we emit none. `span` is the same story at the inline level, and matters
+/// far more than it looks: syntect emits one span per code token, so keeping them made a
+/// source block contribute ~60 skeleton lines of pure noise and dragged the agreement on
+/// `blocks.org` down to 36% — a number that said nothing about whether we render blocks
+/// correctly. Text still carries the signal: a `<span class="todo">` shows up as its
+/// text, `"TODO"`, which is the part worth comparing.
+const IGNORED: &[&str] = &["div", "span"];
+
+/// Attributes kept in the skeleton. Ids and classes are generated (`org6c28c1b`) or
+/// cosmetic (`org-ul`); `href` and `src` are the content.
+const KEPT_ATTRS: &[&str] = &["href", "src"];
+
+/// HTML void elements, which never emit a close event.
+const VOID: &[&str] = &[
+    "br", "hr", "img", "input", "meta", "link", "col", "area", "base", "source", "wbr",
+];
+
+/// Reduce an HTML fragment to its semantic skeleton: one line per element open, element
+/// close, or text run.
+fn skeleton(html: &str) -> Vec<String> {
+    let mut out = Vec::new();
+    let chars: Vec<char> = html.chars().collect();
+    let mut i = 0;
+    let mut text = String::new();
+
+    while i < chars.len() {
+        if chars[i] != '<' {
+            text.push(chars[i]);
+            i += 1;
+            continue;
+        }
+
+        // Comments and doctypes carry nothing.
+        if chars[i..].starts_with(&['<', '!']) {
+            i += match find_from(&chars, i, ">") {
+                Some(end) => end - i + 1,
+                None => break,
+            };
+            continue;
+        }
+        let Some(end) = find_from(&chars, i, ">") else {
+            break;
+        };
+        let raw: String = chars[i + 1..end].iter().collect();
+        i = end + 1;
+
+        let raw = raw.trim().trim_end_matches('/').trim().to_string();
+        // Text is flushed only when a tag is actually *emitted*. Text either side of an
+        // ignored tag therefore merges into one run, which is what makes a highlighted
+        // source block compare as the one string of code it is, rather than as a
+        // token-by-token sequence that has to line up exactly.
+        if let Some(name) = raw.strip_prefix('/') {
+            let name = name.trim().to_ascii_lowercase();
+            if !IGNORED.contains(&name.as_str()) && !VOID.contains(&name.as_str()) {
+                flush_text(&mut text, &mut out);
+                out.push(format!("</{name}>"));
+            }
+            continue;
+        }
+        let mut parts = raw.splitn(2, char::is_whitespace);
+        let name = parts.next().unwrap_or("").to_ascii_lowercase();
+        if name.is_empty() || IGNORED.contains(&name.as_str()) {
+            continue;
+        }
+        let attrs = kept_attributes(parts.next().unwrap_or(""));
+        flush_text(&mut text, &mut out);
+        out.push(format!("<{name}{attrs}>"));
+    }
+    flush_text(&mut text, &mut out);
+    out
+}
+
+fn flush_text(text: &mut String, out: &mut Vec<String>) {
+    let decoded = decode_entities(text);
+    let collapsed = decoded.split_whitespace().collect::<Vec<_>>().join(" ");
+    if !collapsed.is_empty() {
+        out.push(format!("{collapsed:?}"));
+    }
+    text.clear();
+}
+
+fn find_from(chars: &[char], from: usize, needle: &str) -> Option<usize> {
+    let n: Vec<char> = needle.chars().collect();
+    (from..chars.len()).find(|&k| chars[k..].starts_with(&n[..]))
+}
+
+/// Keep only the content-bearing attributes, in a stable order.
+fn kept_attributes(rest: &str) -> String {
+    let mut kept: Vec<(String, String)> = Vec::new();
+    for attr in KEPT_ATTRS {
+        if let Some(value) = attribute_value(rest, attr) {
+            kept.push(((*attr).to_string(), value));
+        }
+    }
+    kept.iter()
+        .map(|(k, v)| format!(" {k}=\"{}\"", decode_entities(v)))
+        .collect()
+}
+
+fn attribute_value(rest: &str, name: &str) -> Option<String> {
+    let mut search = rest;
+    while let Some(pos) = search.find(name) {
+        let before_ok = pos == 0
+            || search[..pos]
+                .chars()
+                .next_back()
+                .is_some_and(char::is_whitespace);
+        let after = &search[pos + name.len()..];
+        let after_trimmed = after.trim_start();
+        if before_ok && after_trimmed.starts_with('=') {
+            let value = after_trimmed[1..].trim_start();
+            let quote = value.chars().next()?;
+            if quote == '"' || quote == '\'' {
+                let end = value[1..].find(quote)? + 1;
+                return Some(value[1..end].to_string());
+            }
+            let end = value.find(char::is_whitespace).unwrap_or(value.len());
+            return Some(value[..end].to_string());
+        }
+        search = &search[pos + name.len()..];
+    }
+    None
+}
+
+/// Decode the entities either exporter is likely to emit, so an encoding difference is
+/// never reported as a semantic one.
+fn decode_entities(s: &str) -> String {
+    let mut out = String::with_capacity(s.len());
+    let mut rest = s;
+    while let Some(amp) = rest.find('&') {
+        out.push_str(&rest[..amp]);
+        let tail = &rest[amp..];
+        let Some(semi) = tail.find(';').filter(|s| *s <= 12) else {
+            out.push('&');
+            rest = &tail[1..];
+            continue;
+        };
+        let entity = &tail[1..semi];
+        let decoded = match entity {
+            "amp" => Some('&'),
+            "lt" => Some('<'),
+            "gt" => Some('>'),
+            "quot" => Some('"'),
+            "apos" => Some('\''),
+            "nbsp" => Some(' '),
+            _ => entity
+                .strip_prefix('#')
+                .and_then(|n| match n.strip_prefix(['x', 'X']) {
+                    Some(hex) => u32::from_str_radix(hex, 16).ok(),
+                    None => n.parse::<u32>().ok(),
+                })
+                .and_then(char::from_u32),
+        };
+        match decoded {
+            // A non-breaking space is a space for comparison purposes.
+            Some('\u{a0}') => out.push(' '),
+            Some(c) => out.push(c),
+            None => {
+                out.push('&');
+                rest = &tail[1..];
+                continue;
+            }
+        }
+        rest = &tail[semi + 1..];
+    }
+    out.push_str(rest);
+    out
+}
+
+// ---------------------------------------------------------------------------
+// Divergence report
+// ---------------------------------------------------------------------------
+
+/// A unified diff of the two skeletons, via a longest-common-subsequence walk. `-` is
+/// org-ssg, `+` is Emacs.
+fn divergence(ours: &[String], theirs: &[String]) -> String {
+    let (n, m) = (ours.len(), theirs.len());
+    // lcs[i][j] = length of the longest common subsequence of ours[i..] and theirs[j..].
+    let mut lcs = vec![vec![0usize; m + 1]; n + 1];
+    for i in (0..n).rev() {
+        for j in (0..m).rev() {
+            lcs[i][j] = if ours[i] == theirs[j] {
+                lcs[i + 1][j + 1] + 1
+            } else {
+                lcs[i + 1][j].max(lcs[i][j + 1])
+            };
+        }
+    }
+
+    let mut out = String::new();
+    let (mut i, mut j) = (0, 0);
+    let mut agreed = 0usize;
+    while i < n && j < m {
+        if ours[i] == theirs[j] {
+            out.push_str(&format!("  {}\n", ours[i]));
+            agreed += 1;
+            i += 1;
+            j += 1;
+        } else if lcs[i + 1][j] >= lcs[i][j + 1] {
+            out.push_str(&format!("- {}\n", ours[i]));
+            i += 1;
+        } else {
+            out.push_str(&format!("+ {}\n", theirs[j]));
+            j += 1;
+        }
+    }
+    for line in &ours[i..] {
+        out.push_str(&format!("- {line}\n"));
+    }
+    for line in &theirs[j..] {
+        out.push_str(&format!("+ {line}\n"));
+    }
+
+    let total = n.max(m);
+    let pct = if total == 0 {
+        100.0
+    } else {
+        100.0 * agreed as f64 / total as f64
+    };
+    format!("agreement: {agreed}/{total} skeleton lines ({pct:.1}%)\n(- org-ssg, + emacs)\n\n{out}")
+}
+
+/// Snapshot the divergence between org-ssg and Emacs for one fixture.
+fn compare(fixture: &str) -> Option<String> {
+    if !emacs_available() {
+        eprintln!("skipping oracle comparison for {fixture}: no emacs on PATH");
+        return None;
+    }
+    let ours = skeleton(&our_export(fixture));
+    let theirs = skeleton(&org_export(fixture));
+    Some(divergence(&ours, &theirs))
+}
+
+macro_rules! oracle_test {
+    ($name:ident, $fixture:literal) => {
+        #[test]
+        fn $name() {
+            if let Some(report) = compare($fixture) {
+                insta::assert_snapshot!(report);
+            }
+        }
+    };
+}
+
+oracle_test!(oracle_minimal, "minimal.org");
+oracle_test!(oracle_core, "core.org");
+oracle_test!(oracle_headings, "headings.org");
+oracle_test!(oracle_lists, "lists.org");
+oracle_test!(oracle_blocks, "blocks.org");
+oracle_test!(oracle_table, "table.org");
+oracle_test!(oracle_footnote, "footnote.org");
+oracle_test!(oracle_timestamps, "timestamps.org");
+oracle_test!(oracle_images, "images.org");
+oracle_test!(oracle_elements, "elements.org");
+
+// ---------------------------------------------------------------------------
+// Invariants that must hold against the oracle, not merely be snapshotted
+// ---------------------------------------------------------------------------
+
+/// How many headings a document has and at what depth is the shape of the document.
+/// Getting it wrong reorganizes someone's writing, so it is asserted rather than
+/// snapshotted. Heading *decoration* (priority cookies, tag markup) is a policy
+/// difference and is left to the snapshots.
+#[test]
+fn heading_structure_matches_emacs() {
+    if !emacs_available() {
+        eprintln!("skipping: no emacs on PATH");
+        return;
+    }
+    for fixture in ["minimal.org", "core.org", "headings.org", "lists.org"] {
+        let ours = heading_levels(&skeleton(&our_export(fixture)));
+        let theirs = heading_levels(&skeleton(&org_export(fixture)));
+        assert_eq!(
+            ours, theirs,
+            "heading structure diverges from Emacs in {fixture}"
+        );
+    }
+}
+
+/// The sequence of heading open tags, e.g. `["<h1>", "<h2>", "<h1>"]`.
+fn heading_levels(skeleton: &[String]) -> Vec<String> {
+    skeleton
+        .iter()
+        .filter(|l| l.starts_with("<h") && l[2..].starts_with(|c: char| c.is_ascii_digit()))
+        .cloned()
+        .collect()
+}
+
+/// A list is the construct where nesting is easiest to get subtly wrong, and where being
+/// wrong changes the meaning of the document rather than its looks.
+#[test]
+fn list_nesting_matches_emacs() {
+    if !emacs_available() {
+        eprintln!("skipping: no emacs on PATH");
+        return;
+    }
+    let ours = list_shape(&skeleton(&our_export("lists.org")));
+    let theirs = list_shape(&skeleton(&org_export("lists.org")));
+    assert_eq!(ours, theirs, "list nesting diverges from Emacs");
+}
+
+/// The sequence of list opens/closes, ignoring content — the shape of the nesting.
+fn list_shape(skeleton: &[String]) -> Vec<String> {
+    skeleton
+        .iter()
+        .filter(|l| {
+            matches!(
+                l.as_str(),
+                "<ul>" | "</ul>" | "<ol>" | "</ol>" | "<li>" | "</li>" | "<dl>" | "</dl>"
+                    | "<dt>" | "</dt>" | "<dd>" | "</dd>"
+            )
+        })
+        .cloned()
+        .collect()
+}
+
+/// Code must survive verbatim. Highlighting markup differs by construction (syntect
+/// spans vs htmlize), but if the *characters of the program* differ, we have corrupted
+/// the author's content.
+#[test]
+fn source_block_text_matches_emacs() {
+    if !emacs_available() {
+        eprintln!("skipping: no emacs on PATH");
+        return;
+    }
+    for fixture in ["blocks.org", "core.org", "elements.org"] {
+        let ours = code_text(&our_export(fixture));
+        let theirs = code_text(&org_export(fixture));
+        assert_eq!(ours, theirs, "source block text diverges from Emacs in {fixture}");
+    }
+}
+
+/// All text inside `<pre>` blocks, with tags stripped and whitespace collapsed.
+fn code_text(html: &str) -> Vec<String> {
+    let mut out = Vec::new();
+    let mut rest = html;
+    while let Some(start) = rest.find("<pre") {
+        let after = &rest[start..];
+        let Some(open_end) = after.find('>') else { break };
+        let Some(close) = after.find("</pre>") else { break };
+        let inner = &after[open_end + 1..close];
+        out.push(strip_tags(inner));
+        rest = &after[close + 6..];
+    }
+    out
+}
+
+/// All text in a fragment with tags removed and entities decoded, then whitespace
+/// collapsed once at the end.
+///
+/// [`skeleton`] cannot do this job: it trims each text run individually, which is
+/// invisible for prose (one run per paragraph) but destructive for highlighted code,
+/// where syntect splits a line into one run per token and the spaces *between* tokens
+/// live at the edges of those runs. Trimming each run turns `def greet` into `defgreet`.
+fn strip_tags(html: &str) -> String {
+    let mut text = String::new();
+    let mut rest = html;
+    while let Some(open) = rest.find('<') {
+        text.push_str(&rest[..open]);
+        match rest[open..].find('>') {
+            Some(close) => rest = &rest[open + close + 1..],
+            None => {
+                rest = "";
+                break;
+            }
+        }
+    }
+    text.push_str(rest);
+    decode_entities(&text)
+        .split_whitespace()
+        .collect::<Vec<_>>()
+        .join(" ")
+}
diff --git a/tests/site.rs b/tests/site.rs
index c8e018f..41568f2 100644
--- a/tests/site.rs
+++ b/tests/site.rs
@@ -122,3 +122,86 @@ fn table_render() {
 fn footnote_render() {
     insta::assert_snapshot!(render_fragment("footnote.org"));
 }
+
+// ---------------------------------------------------------------------------
+// `#+SLUG:` output paths (Phase 0 corpus-audit finding)
+// ---------------------------------------------------------------------------
+
+/// The audit found `#+SLUG:` in 178 of the target corpus's 179 files, and the live site
+/// derives every URL from it — `2018-11-28-aes-encryption.org` publishes as
+/// `aes-encryption.html`. Deriving output paths from source filenames would therefore
+/// have rewritten every URL on the site.
+#[test]
+fn slug_renames_the_output_page() {
+    let (pages, broken) = render_site(&fixtures().join("slugsite")).expect("build site");
+    assert!(broken.is_empty(), "fixture site has no broken links: {broken:?}");
+    let post = pages
+        .iter()
+        .find(|p| p.source == "2024-02-11-long-source-name.org")
+        .expect("post page");
+    assert_eq!(
+        post.output, "short-url.html",
+        "the slug names the output file, not the source stem"
+    );
+}
+
+/// A link's URL has to follow the target's slug. If resolution kept using source paths,
+/// every cross-page link would point at a file that was never written.
+#[test]
+fn links_resolve_through_the_slug() {
+    let (pages, _) = render_site(&fixtures().join("slugsite")).expect("build site");
+    let index = &page(&pages, "index.org").html;
+    assert!(
+        index.contains("href=\"short-url.html\""),
+        "a file: link must target the slugged page:\n{index}"
+    );
+    assert!(
+        index.contains("href=\"short-url.html#setup\""),
+        "a custom-id link must target the slugged page plus the anchor:\n{index}"
+    );
+    assert!(
+        !index.contains("long-source-name"),
+        "no URL may mention the source filename:\n{index}"
+    );
+}
+
+/// A slug is author-controlled text that becomes a path we write to, so traversal has to
+/// be impossible by construction rather than by convention.
+#[test]
+fn slugs_cannot_escape_the_output_directory() {
+    use org_ssg::model::Keywords;
+    let source = Utf8PathBuf::from("blog/post.org");
+    let slugged = |value: &str| {
+        let keywords = Keywords {
+            entries: vec![("SLUG".to_string(), value.to_string())],
+        };
+        org_ssg::util::output_path(&source, &keywords).to_string()
+    };
+    assert_eq!(slugged("../../etc/passwd"), "blog/etc-passwd.html");
+    assert_eq!(slugged("/absolute"), "blog/absolute.html");
+    assert_eq!(slugged(".hidden"), "blog/hidden.html");
+    assert_eq!(slugged("Mixed Case Slug"), "blog/mixed-case-slug.html");
+    // An empty or punctuation-only slug falls back to the source stem rather than
+    // producing `.html` with no name at all.
+    assert_eq!(slugged("///"), "blog/post.html");
+}
+
+/// Two pages claiming one URL silently drops a page. With slugs that is a typo away and
+/// invisible in the source filenames, so the build refuses rather than picking a winner.
+#[test]
+fn colliding_slugs_are_a_build_error() {
+    let dir = std::env::temp_dir().join(format!("org-ssg-slug-{}", std::process::id()));
+    let dir = Utf8PathBuf::from_path_buf(dir).expect("utf-8 temp dir");
+    let _ = std::fs::remove_dir_all(&dir);
+    std::fs::create_dir_all(&dir).unwrap();
+    std::fs::write(dir.join("a.org"), "#+TITLE: A\n#+SLUG: same\n").unwrap();
+    std::fs::write(dir.join("b.org"), "#+TITLE: B\n#+SLUG: same\n").unwrap();
+
+    let err = render_site(&dir).expect_err("colliding slugs must fail the build");
+    let message = format!("{err:#}");
+    assert!(
+        message.contains("collision") && message.contains("same.html"),
+        "the error must name the collision: {message}"
+    );
+    std::fs::remove_dir_all(&dir).unwrap();
+}
diff --git a/tests/snapshots/oracle__oracle_blocks.snap b/tests/snapshots/oracle__oracle_blocks.snap
new file mode 100644
index 0000000..55bf857
--- /dev/null
+++ b/tests/snapshots/oracle__oracle_blocks.snap
@@ -0,0 +1,68 @@
+---
+source: tests/oracle.rs
+expression: report
+---
+agreement: 51/59 skeleton lines (86.4%)
+(- org-ssg, + emacs)
+
+  <h1>
+  "Quote"
+  </h1>
+  <blockquote>
+  <p>
+  "A quoted paragraph with"
+- <em>
++ <i>
+  "markup"
+- </em>
++ </i>
+  "."
+  </p>
+  <p>
+  "And a second paragraph."
+  </p>
+  </blockquote>
+  <h1>
+  "Center"
+  </h1>
+  <p>
+  "Centred text."
+  </p>
+  <h1>
+  "Example"
+  </h1>
+  <pre>
+  "Verbatim *not bold* text. Indentation preserved."
+  </pre>
+  <h1>
+  "Export"
+  </h1>
+  <aside>
+  "Raw HTML passes through."
+  </aside>
+  <h1>
+  "Source"
+  </h1>
+  <pre>
+- <code>
+  "def greet(name): return f\"hello {name}\""
+- </code>
+  </pre>
+  <pre>
+- <code>
+  "plain block, no language"
+- </code>
+  </pre>
+  <h1>
+  "Nested"
+  </h1>
+  <blockquote>
+  <p>
+  "A quote containing a source block:"
+  </p>
+  <pre>
+- <code>
+  "echo hi"
+- </code>
+  </pre>
+  </blockquote>
diff --git a/tests/snapshots/oracle__oracle_core.snap b/tests/snapshots/oracle__oracle_core.snap
new file mode 100644
index 0000000..f1bbb2d
--- /dev/null
+++ b/tests/snapshots/oracle__oracle_core.snap
@@ -0,0 +1,70 @@
+---
+source: tests/oracle.rs
+expression: report
+---
+agreement: 45/54 skeleton lines (83.3%)
+(- org-ssg, + emacs)
+
+  <p>
+  "Intro paragraph with a bare URL"
+  <a href="https://example.com">
+  "https://example.com"
+  </a>
+  "and some"
+  <code>
+  "inline code"
+  </code>
+  "."
+  </p>
+  <h1>
+  "Ordered and checked"
+  </h1>
+  <ol>
+  <li>
+  "first item"
+  </li>
+  <li>
+  "second item with"
+- <em>
++ <i>
+  "emphasis"
+- </em>
++ </i>
+  </li>
+- </ol>
+- <ul>
+  <li>
+- <input>
++ <code>
++ "[ ]"
++ </code>
+  "todo item"
+  </li>
+  <li>
+- <input>
++ <code>
++ "[X]"
++ </code>
+  "done item"
+  </li>
+- </ul>
++ </ol>
+  <h1>
+  "Links and code"
+  </h1>
+  <p>
+  "An external"
+  <a href="https://example.org">
+  "site"
+  </a>
+  "and a bare"
+  <a href="https://bare.example">
+  "https://bare.example"
+  </a>
+  "."
+  </p>
+  <pre>
+- <code>
+  "fn main() { println!(\"hello\"); }"
+- </code>
+  </pre>
diff --git a/tests/snapshots/oracle__oracle_elements.snap b/tests/snapshots/oracle__oracle_elements.snap
new file mode 100644
index 0000000..f046fea
--- /dev/null
+++ b/tests/snapshots/oracle__oracle_elements.snap
@@ -0,0 +1,103 @@
+---
+source: tests/oracle.rs
+expression: report
+---
+agreement: 64/82 skeleton lines (78.0%)
+(- org-ssg, + emacs)
+
+  <h1>
+  "Code and tables"
+  </h1>
+  <pre>
+- <code>
+  "fn main() { println!(\"hello\"); }"
+- </code>
+  </pre>
+  <table>
++ <colgroup>
++ <col>
++ <col>
++ </colgroup>
+  <thead>
+  <tr>
+  <th>
+  "Name"
+  </th>
+  <th>
+  "Score"
+  </th>
+  </tr>
+  </thead>
+  <tbody>
+  <tr>
+  <td>
+  "alpha"
+  </td>
+  <td>
+  "10"
+  </td>
+  </tr>
+  <tr>
+  <td>
+  "beta"
+  </td>
+  <td>
+  "20"
+  </td>
+  </tr>
+  </tbody>
+  </table>
+  <h1>
+  "Links and footnotes"
+  </h1>
+  <p>
+  "An external link:"
+  <a href="https://example.com">
+  "Example"
+  </a>
+- "and an id link"
+- <a href="#abc-123">
+- "abc-123"
+- </a>
+- "."
++ "and an id link ."
+  </p>
+  <p>
+  "Text with a footnote reference."
+  <sup>
+- <a href="#fn-1">
++ <a href="#fn.1">
+  "1"
+  </a>
+  </sup>
+  </p>
+  <h1>
+  "Blocks"
+  </h1>
+  <blockquote>
+  <p>
+  "A quoted paragraph."
+  </p>
+  </blockquote>
+  <hr>
+- <section>
+- <hr>
+- <ol>
+- <li>
++ <h2>
++ "Footnotes:"
++ </h2>
++ <sup>
++ <a href="#fnr.1">
++ "1"
++ </a>
++ </sup>
+  <p>
+  "The footnote definition."
+  </p>
+- <a href="#fnr-1">
+- "↩"
+- </a>
+- </li>
+- </ol>
+- </section>
diff --git a/tests/snapshots/oracle__oracle_footnote.snap b/tests/snapshots/oracle__oracle_footnote.snap
new file mode 100644
index 0000000..3982336
--- /dev/null
+++ b/tests/snapshots/oracle__oracle_footnote.snap
@@ -0,0 +1,83 @@
+---
+source: tests/oracle.rs
+expression: report
+---
+agreement: 30/53 skeleton lines (56.6%)
+(- org-ssg, + emacs)
+
+  <p>
+  "Text with a reference."
+  <sup>
+- <a href="#fn-1">
++ <a href="#fn.1">
+  "1"
+  </a>
+  </sup>
+  "And a second one."
+  <sup>
+- <a href="#fn-2">
++ <a href="#fn.2">
+  "2"
+  </a>
+  </sup>
+  </p>
+  <p>
+  "An inline footnote."
+  <sup>
+- <a href="#fn-3">
++ <a href="#fn.3">
+  "3"
+  </a>
+  </sup>
+  </p>
+- <section>
+- <hr>
+- <ol>
+- <li>
++ <h2>
++ "Footnotes:"
++ </h2>
++ <sup>
++ <a href="#fnr.1">
++ "1"
++ </a>
++ </sup>
+  <p>
+  "The first definition."
+  </p>
+- <a href="#fnr-1">
+- "↩"
++ <sup>
++ <a href="#fnr.2">
++ "2"
+  </a>
+- </li>
+- <li>
++ </sup>
+  <p>
+  "The second definition, with"
+- <em>
++ <i>
+  "emphasis"
+- </em>
++ </i>
+  "."
+  </p>
+- <a href="#fnr-2">
+- "↩"
++ <sup>
++ <a href="#fnr.3">
++ "3"
+  </a>
+- </li>
+- <li>
++ </sup>
++ <p>
+  "defined right here"
+- <a href="#fnr-3">
+- "↩"
+- </a>
+- </li>
+- </ol>
+- </section>
++ </p>
diff --git a/tests/snapshots/oracle__oracle_headings.snap b/tests/snapshots/oracle__oracle_headings.snap
new file mode 100644
index 0000000..f1a9f22
--- /dev/null
+++ b/tests/snapshots/oracle__oracle_headings.snap
@@ -0,0 +1,39 @@
+---
+source: tests/oracle.rs
+expression: report
+---
+agreement: 28/30 skeleton lines (93.3%)
+(- org-ssg, + emacs)
+
+  <h1>
+- "TODO [#A] Write the parser work rust"
++ "TODO Write the parser work rust"
+  </h1>
+  <p>
+  "A heading carrying a keyword, a priority, tags and a property drawer."
+  </p>
+  <h2>
+  "DONE Nested and finished"
+  </h2>
+  <p>
+  "Sub-headings nest by star count."
+  </p>
+  <h2>
+- "[#C] Priority without a keyword"
++ "Priority without a keyword"
+  </h2>
+  <p>
+  "A priority cookie can stand alone."
+  </p>
+  <h1>
+  "TODOs are not a keyword"
+  </h1>
+  <p>
+  "The word boundary matters: this heading has no TODO keyword."
+  </p>
+  <h1>
+  "DONE"
+  </h1>
+  <p>
+  "A keyword with no title at all."
+  </p>
diff --git a/tests/snapshots/oracle__oracle_images.snap b/tests/snapshots/oracle__oracle_images.snap
new file mode 100644
index 0000000..0f92af3
--- /dev/null
+++ b/tests/snapshots/oracle__oracle_images.snap
@@ -0,0 +1,63 @@
+---
+source: tests/oracle.rs
+expression: report
+---
+agreement: 28/42 skeleton lines (66.7%)
+(- org-ssg, + emacs)
+
+  <h1>
+  "Bare image"
+  </h1>
+  <p>
+  <img src="diagram.png">
+  </p>
+  <h1>
+  "Captioned figure"
+  </h1>
+- <figure>
++ <p>
+  <img src="pipeline.svg">
+- <figcaption>
+- "The pipeline, end to end"
+- </figcaption>
+- </figure>
++ </p>
++ <p>
++ "Figure 1: The pipeline, end to end"
++ </p>
+  <h1>
+  "Caption with markup"
+  </h1>
+- <figure>
++ <p>
+  <img src="chart.png">
+- <figcaption>
+- "A"
+- <em>
++ </p>
++ <p>
++ "Figure 2: A"
++ <i>
+  "stylised"
+- </em>
++ </i>
+  "chart"
+- </figcaption>
+- </figure>
++ </p>
+  <h1>
+  "Quoted attribute values"
+  </h1>
+- <figure>
++ <p>
+  <img src="cat.jpg">
+- </figure>
++ </p>
+  <h1>
+  "Image with a description is a link"
+  </h1>
+  <p>
+  <a href="diagram.png">
+  "the diagram"
+  </a>
+  </p>
diff --git a/tests/snapshots/oracle__oracle_lists.snap b/tests/snapshots/oracle__oracle_lists.snap
new file mode 100644
index 0000000..7b54a34
--- /dev/null
+++ b/tests/snapshots/oracle__oracle_lists.snap
@@ -0,0 +1,123 @@
+---
+source: tests/oracle.rs
+expression: report
+---
+agreement: 100/111 skeleton lines (90.1%)
+(- org-ssg, + emacs)
+
+  <h1>
+  "Nesting"
+  </h1>
+  <ul>
+  <li>
+  "outer item"
+  <ul>
+  <li>
+  "inner item"
+  <ul>
+  <li>
+  "deepest item"
+  </li>
+  </ul>
+  </li>
+  <li>
+  "second inner"
+  </li>
+  </ul>
+  </li>
+  <li>
+  "second outer"
+  </li>
+  </ul>
+  <h1>
+  "Ordered"
+  </h1>
+  <ol>
+  <li>
+  "first"
+  </li>
+  <li>
+  "second"
+  <ol>
+  <li>
+  "second point one"
+  </li>
+  <li>
+  "second point two"
+  </li>
+  </ol>
+  </li>
+  <li>
+  "third"
+  </li>
+  </ol>
+  <h1>
+  "Checkboxes"
+  </h1>
+  <ul>
+  <li>
+- <input>
++ <code>
++ "[ ]"
++ </code>
+  "not done"
+  </li>
+  <li>
+- <input>
++ <code>
++ "[X]"
++ </code>
+  "done"
+  </li>
+  <li>
+- <input>
++ <code>
++ "[-]"
++ </code>
+  "partially done"
+  </li>
+  </ul>
+  <h1>
+  "Description"
+  </h1>
+  <dl>
+  <dt>
+  "term one"
+  </dt>
+  <dd>
+  "the first definition"
+  </dd>
+  <dt>
+  "term two"
+  </dt>
+  <dd>
+  "the second definition, which is soft-wrapped across two lines"
+  </dd>
+  <dt>
+- <em>
++ <i>
+  "marked up"
+- </em>
++ </i>
+  "term"
+  </dt>
+  <dd>
+  "definitions hold inline markup"
+  </dd>
+  </dl>
+  <h1>
+  "Multi-paragraph items"
+  </h1>
+  <ul>
+  <li>
+  <p>
+  "an item whose body has two paragraphs"
+  </p>
+  <p>
+  "the second paragraph, indented under the bullet"
+  </p>
+  </li>
+  <li>
+  "a plain sibling"
+  </li>
+  </ul>
diff --git a/tests/snapshots/oracle__oracle_minimal.snap b/tests/snapshots/oracle__oracle_minimal.snap
new file mode 100644
index 0000000..48e4731
--- /dev/null
+++ b/tests/snapshots/oracle__oracle_minimal.snap
@@ -0,0 +1,53 @@
+---
+source: tests/oracle.rs
+expression: report
+---
+agreement: 38/42 skeleton lines (90.5%)
+(- org-ssg, + emacs)
+
+  <p>
+  "A single paragraph of preamble text before any heading."
+  </p>
+  <h1>
+  "First Heading"
+  </h1>
+  <p>
+  "Some body text with"
+- <strong>
++ <b>
+  "bold"
+- </strong>
++ </b>
+  ","
+- <em>
++ <i>
+  "italic"
+- </em>
++ </i>
+  ", and"
+  <code>
+  "verbatim"
+  </code>
+  "."
+  </p>
+  <h2>
+  "A Subheading tag1 tag2"
+  </h2>
+  <ul>
+  <li>
+  "an unordered item"
+  </li>
+  <li>
+  "another with a checkbox [ ]"
+  </li>
+  </ul>
+  <h1>
+  "Second Heading"
+  </h1>
+  <p>
+  "See"
+  <a href="#first">
+  "the first heading"
+  </a>
+  "."
+  </p>
diff --git a/tests/snapshots/oracle__oracle_table.snap b/tests/snapshots/oracle__oracle_table.snap
new file mode 100644
index 0000000..f4b62da
--- /dev/null
+++ b/tests/snapshots/oracle__oracle_table.snap
@@ -0,0 +1,41 @@
+---
+source: tests/oracle.rs
+expression: report
+---
+agreement: 30/34 skeleton lines (88.2%)
+(- org-ssg, + emacs)
+
+  <table>
++ <colgroup>
++ <col>
++ <col>
++ </colgroup>
+  <thead>
+  <tr>
+  <th>
+  "Name"
+  </th>
+  <th>
+  "Score"
+  </th>
+  </tr>
+  </thead>
+  <tbody>
+  <tr>
+  <td>
+  "alpha"
+  </td>
+  <td>
+  "10"
+  </td>
+  </tr>
+  <tr>
+  <td>
+  "beta"
+  </td>
+  <td>
+  "20"
+  </td>
+  </tr>
+  </tbody>
+  </table>
diff --git a/tests/snapshots/oracle__oracle_timestamps.snap b/tests/snapshots/oracle__oracle_timestamps.snap
new file mode 100644
index 0000000..41393bc
--- /dev/null
+++ b/tests/snapshots/oracle__oracle_timestamps.snap
@@ -0,0 +1,74 @@
+---
+source: tests/oracle.rs
+expression: report
+---
+agreement: 25/62 skeleton lines (40.3%)
+(- org-ssg, + emacs)
+
+  <h1>
+  "Single"
+  </h1>
+  <p>
+- "An active date"
+- <time>
+- "2024-01-15"
+- </time>
+- "and an inactive one"
+- <time>
+- "2024-01-15"
+- </time>
+- "."
++ "An active date <2024-01-15 Mon> and an inactive one [2024-01-15 Mon]."
+  </p>
+  <p>
+- "With a time:"
+- <time>
+- "2024-01-15 10:30"
+- </time>
+- "."
++ "With a time: <2024-01-15 Mon 10:30>."
+  </p>
+  <h1>
+  "Ranges"
+  </h1>
+  <p>
+- "A same-day time range"
+- <time>
+- "2024-01-15 10:00"
+- </time>
+- "–"
+- <time>
+- "11:45"
+- </time>
+- "."
++ "A same-day time range <2024-01-15 Mon 10:00-11:45>."
+  </p>
+  <p>
+- "A multi-day range"
+- <time>
+- "2024-01-15"
+- </time>
+- "–"
+- <time>
+- "2024-01-20"
+- </time>
+- "."
++ "A multi-day range <2024-01-15 Mon>–<2024-01-20 Sat>."
+  </p>
+  <h1>
+  "Ignored decorations"
+  </h1>
+  <p>
+- "A repeater is dropped:"
+- <time>
+- "2024-01-15"
+- </time>
+- "."
++ "A repeater is dropped: <2024-01-15 Mon +1w>."
+  </p>
+  <h1>
+  "Not timestamps"
+  </h1>
+  <p>
+  "Comparisons like 3 < 4 and [not a stamp] stay literal text."
+  </p>