krz/orgo

Lightning fast org-mode static site generator.

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

34e27a50613a3f76cb1a9ad7cb096505f45f640a

verified · cmc

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

v0.6: make it a generator for any site, not one site

Everything that shaped the output was a constant in the source — the page layout, the
nav rule, the highlighting theme, heading levels. That produced exactly one kind of
site: a reasonable place to start and a dead end for anyone whose site is not that one.

Configuration (src/config.rs, org-ssg.toml):
- site title/base_url/description/language, nav mode, template dir, highlight theme,
  heading offset. Discovered beside the sources or passed with --config, and folded into
  the config hash so editing it invalidates the pages it affects.
- Absent config is a valid config: every field has a default, so a bare directory of
  .org files still builds a complete site. Configuration changes the output; it is never
  what makes it work.
- A missing config is silent, a malformed one is an error, and an unknown key is
  rejected — a misspelled setting that silently does nothing is how people lose an
  afternoon.

Templates:
- base.html in the templates directory replaces the built-in layout entirely; other
  files are available to include/extends. User template sources are part of the template
  hash, so editing a layout re-renders the pages that use it.
- Templates receive site, page, nav, root, stylesheet, and optionally pages.
  page.keywords carries every #+KEYWORD: under its lowercased name, so a user's own
  metadata works without this crate knowing about it.
- expose_page_list makes listing pages possible and is off by default: letting every
  template see every page means adding one page can change any page, so the structure
  hash has to widen to match. The cost is stated rather than hidden.

Nav modes: top-level (default), all, explicit (with configured ordering), none. An
explicit nav naming a page that does not exist is an error, as is a mode/pages
combination where one silently ignores the other.

heading_offset defaults to 1, matching Emacs' org-html-toplevel-hlevel: the layout
supplies the page <h1>, so a level-1 org heading renders as <h2>. This also removes the
override tests/oracle.el needed — both sides now agree on heading levels from their own
defaults rather than because the oracle was told to.

`org-ssg init` scaffolds a working site (config, an editable copy of the layout, a page)
and only writes files that do not exist, so it is safe to run in place.

Two bugs found by using the tool as a newcomer would:
- `org-ssg build . -o _site` copied its own output back into the source tree, nesting
  _site/_site/_site and growing the asset count on every run (2 -> 7 -> 12).
- Discovery published build inputs and dot-directories, so a source directory that was a
  git repo would publish .git — its entire history — next to the homepage.
Discovery now excludes the output directory when nested, the config file, the template
directory, and dot-entries.

Verified against the 179-file corpus: still 179/179 live URLs, zero diagnostics, and the
default output now matches the incumbent's heading structure (<h1> title, <h2> sections).
 Cargo.lock                                         |  57 ++-
 Cargo.toml                                         |   3 +-
 README.md                                          | 101 ++++-
 src/config.rs                                      | 239 +++++++++++
 src/incremental.rs                                 |  24 +-
 src/lib.rs                                         |   1 +
 src/main.rs                                        | 108 ++++-
 src/render.rs                                      |  64 ++-
 src/site.rs                                        | 306 +++++++++++---
 src/template.rs                                    | 186 +++++++--
 tests/config.rs                                    | 445 +++++++++++++++++++++
 tests/constructs.rs                                |   4 +-
 tests/incremental.rs                               |   4 +-
 tests/oracle.el                                    |   7 +-
 tests/snapshots/constructs__blocks_html.snap       |  12 +-
 tests/snapshots/constructs__headings_html.snap     |  10 +-
 tests/snapshots/constructs__images_html.snap       |  10 +-
 tests/snapshots/constructs__lists_html.snap        |  10 +-
 tests/snapshots/constructs__out_of_scope_html.snap |  14 +-
 tests/snapshots/constructs__timestamps_html.snap   |   8 +-
 tests/snapshots/oracle__oracle_blocks.snap         |  24 +-
 tests/snapshots/oracle__oracle_core.snap           |   8 +-
 tests/snapshots/oracle__oracle_elements.snap       |  12 +-
 tests/snapshots/oracle__oracle_headings.snap       |  20 +-
 tests/snapshots/oracle__oracle_images.snap         |  20 +-
 tests/snapshots/oracle__oracle_lists.snap          |  20 +-
 tests/snapshots/oracle__oracle_minimal.snap        |  12 +-
 tests/snapshots/oracle__oracle_timestamps.snap     |  16 +-
 tests/snapshots/pipeline__core_html.snap           |   4 +-
 tests/snapshots/pipeline__minimal_html.snap        |   6 +-
 tests/snapshots/site__site_guide_html.snap         |  11 +-
 tests/snapshots/site__site_index_html.snap         |   9 +-
 32 files changed, 1529 insertions(+), 246 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 1fae172..ea41342 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -569,7 +569,7 @@ dependencies = [
 
 [[package]]
 name = "org-ssg"
-version = "0.5.0"
+version = "0.6.0"
 dependencies = [
  "anyhow",
  "blake3",
@@ -583,6 +583,7 @@ dependencies = [
  "serde_json",
  "syntect",
  "thiserror",
+ "toml",
  "walkdir",
 ]
 
@@ -747,6 +748,15 @@ dependencies = [
  "zmij",
 ]
 
+[[package]]
+name = "serde_spanned"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
+dependencies = [
+ "serde_core",
+]
+
 [[package]]
 name = "shlex"
 version = "2.0.1"
@@ -883,6 +893,45 @@ dependencies = [
  "time-core",
 ]
 
+[[package]]
+name = "toml"
+version = "1.1.4+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
+dependencies = [
+ "indexmap",
+ "serde_core",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_parser",
+ "toml_writer",
+ "winnow",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
+
 [[package]]
 name = "unicode-ident"
 version = "1.0.24"
@@ -1027,6 +1076,12 @@ dependencies = [
  "windows-link",
 ]
 
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+
 [[package]]
 name = "yaml-rust"
 version = "0.4.5"
diff --git a/Cargo.toml b/Cargo.toml
index da9ff17..64060b0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "org-ssg"
-version = "0.5.0"
+version = "0.6.0"
 edition = "2021"
 description = "Org-mode static site generator that renders the org element tree straight to HTML"
 license = "MIT"
@@ -31,6 +31,7 @@ clap = { version = "4", features = ["derive"] }
 anyhow = "1"
 thiserror = "2"
 rayon = "1.12.0"
+toml = "1.1.4"
 
 [dev-dependencies]
 insta = { version = "1", features = ["json"] }
diff --git a/README.md b/README.md
index 4da14ff..46d3ccf 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,86 @@ hashing**, treated as a first-class architectural concern from day one. The disc
 it imposes on the data model — pure, hashable, dependency-tracked units — is the real
 deliverable, even while the corpus is small enough that a full rebuild is instant.
 
+## Quick start
+
+```bash
+cargo run -- init my-site      # config + an editable copy of the layout + a page
+cargo run -- build my-site -o _site
+```
+
+Or skip the scaffolding entirely — point it at any directory of `.org` files:
+
+```bash
+cargo run -- build ~/notes -o _site
+```
+
+**Zero configuration is a supported path, not a demo.** With no `org-ssg.toml`, no
+templates and no org-ssg-specific markup in your files, you get a complete site: pages,
+navigation, syntax-highlighted code and the stylesheet to colour it. Configuration
+changes what you get; it is never what makes it work.
+
+Discovery skips what should not be published — dot-directories such as `.git`, the config
+file, the templates directory, and the output directory when it sits inside the source, so
+`org-ssg build . -o _site` does the obvious thing.
+
+## Configuration
+
+Everything is optional. `org-ssg init` writes a fully commented `org-ssg.toml`; every
+value below is the default.
+
+```toml
+[site]
+title = "org-ssg site"
+base_url = ""          # absolute URL, no trailing slash; empty = relative URLs only
+description = ""
+language = "en"
+
+[nav]
+mode = "top-level"     # top-level | all | explicit | none
+# pages = ["index.org", "about.org"]   # for mode = "explicit"; order is preserved
+
+[templates]
+dir = "templates"      # base.html replaces the built-in layout
+expose_page_list = false
+
+[highlight]
+theme = "InspiredGitHub"
+
+[html]
+heading_offset = 1     # a level-1 org heading becomes <h2>, beneath the layout's <h1>
+```
+
+### Templates
+
+Drop a `base.html` into the templates directory and it replaces the built-in layout
+entirely. Any other `.html` file there is available to `{% include %}` and
+`{% extends %}`. Templates are [minijinja](https://docs.rs/minijinja) (Jinja2 syntax) and
+receive:
+
+| Variable | What it is |
+|---|---|
+| `body` | the rendered page HTML — use `{{ body \| safe }}` |
+| `page` | `.title`, `.url`, `.source`, `.date`, `.tags`, `.keywords` |
+| `site` | `.title`, `.base_url`, `.description`, `.language` |
+| `nav` | list of `{title, url}`, relative to this page |
+| `root` | `../`-prefix back to the site root from this page |
+| `stylesheet` | URL of the generated `syntax.css` |
+| `pages` | every page's metadata — only when `expose_page_list = true` |
+
+`page.keywords` carries **every** `#+KEYWORD:` in the file under its lowercased name, so
+your own metadata works without this crate knowing about it: `#+CUSTOM_THING: x` is
+`{{ page.keywords.custom_thing }}`.
+
+Editing a template re-renders the pages that use it — template sources are a hash input,
+so a design change never leaves a site half-updated.
+
+### `#+SLUG:`
+
+A page's output filename comes from its `#+SLUG:` when it has one, so
+`2018-11-28-aes-encryption.org` can publish as `aes-encryption.html`. Without one the
+source filename is used. Slugs are sanitized to a single safe path component, and two
+pages claiming one URL is a build error rather than a silently dropped page.
+
 ## Pipeline
 
 ```
@@ -24,6 +104,7 @@ is the only inherently global stage — it is where the link dependency graph is
 
 | Stage | Module | Notes |
 |---|---|---|
+| config | `src/config.rs` | `org-ssg.toml`: site metadata, nav mode, templates, theme. A hash input. |
 | 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). |
@@ -72,6 +153,7 @@ all-of-org. Phase 0 checked this line against a real 179-file corpus and found i
 | 5 | Link resolution + symbol table (INDEX + RESOLVE, used-target list, broken-link reporting) | done |
 | 6 | Incremental build layer (hashing, dep graph, invalidation) done; `watch` is a simple poll loop | done |
 | **7** | **Hardening: rayon parallelism, error locations in parse diagnostics** | **done** |
+| **8** | **General use: config file, user templates, nav modes, `init` scaffold, safe discovery** | **done** |
 
 ### v0.2 in / out
 
@@ -173,9 +255,12 @@ and the `watch` fs-notify integration.
 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.
+Emacs does.
+
+The audit runs against any corpus — point it at your own notes before trusting this tool
+with them. The numbers below come from a 179-file site published today by weblorg, a
+wrapper around org's own HTML exporter, which makes it both a realistic workload and a
+directly comparable incumbent.
 
 ```
 cargo run -- audit <src-dir>   # what does this corpus use, and is it in scope?
@@ -309,10 +394,9 @@ blog post used to re-render the entire site; now it renders one page.** A top-le
 title still invalidates everything, correctly, since every page displays it.
 
 **Trade-off worth knowing:** on a site whose sections live in subdirectories, only genuinely
-root-level pages appear. cleberg.net keeps its landing pages at `content/salary/index.org`
-and friends, so its nav comes out as a single `index.org` entry where the live site shows
-four. Treating a directory's `index.org` as top-level too is a one-line change to
-`is_top_level` if that is the behaviour you want.
+root-level pages appear — a site keeping its landing pages at `salary/index.org` and friends
+gets a one-entry nav. That is what `nav.mode = "explicit"` is for: list the pages you want,
+in the order you want them.
 
 **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;
@@ -332,7 +416,8 @@ PARSE/RESOLVE/RENDER), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserro
 
 ```
 cargo build
-cargo test
+cargo test                                                # 86 tests
+cargo run -- init my-site                                 # scaffold a new site
 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)
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..dfd8943
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,239 @@
+//! User-facing build configuration (`org-ssg.toml`).
+//!
+//! Everything here was once a constant in the source: the page layout, the nav rule, the
+//! highlighting theme. That made the generator produce exactly one kind of site — a
+//! reasonable place to start from, and a dead end for anyone whose site is not that one.
+//!
+//! Two properties matter beyond the settings themselves:
+//!
+//! 1. **Absent config is a valid config.** Every field has a default, so a directory of
+//!    `.org` files with no `org-ssg.toml` still builds. Configuration is how you change
+//!    the output, never how you make it work at all.
+//! 2. **Config is a hash input** (spec §4.1). [`Config`] serializes deterministically and
+//!    its hash is folded into every page's render key, so editing `org-ssg.toml` re-renders
+//!    exactly the pages it affects — which for most settings is all of them.
+
+use anyhow::{Context, Result};
+use camino::{Utf8Path, Utf8PathBuf};
+use serde::{Deserialize, Serialize};
+
+/// The config file's name, looked for in the source directory.
+pub const CONFIG_FILE: &str = "org-ssg.toml";
+
+/// Resolved build configuration. Serialized into the config hash, so field order and
+/// defaults are part of the cache contract.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct Config {
+    pub site: Site,
+    pub nav: Nav,
+    pub templates: Templates,
+    pub highlight: Highlight,
+    pub html: HtmlOutput,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct HtmlOutput {
+    /// How far to push heading levels down: a level-1 org heading becomes
+    /// `<h{1 + heading_offset}>`.
+    ///
+    /// Defaults to 1, matching Emacs' own `org-html-toplevel-hlevel`, because the page
+    /// layout supplies the `<h1>` — the document's title — and section headings sit
+    /// beneath it. Set to 0 if your template renders no title of its own, so the
+    /// document does not start at `<h2>` with nothing above it.
+    pub heading_offset: u8,
+}
+
+impl Default for HtmlOutput {
+    fn default() -> Self {
+        HtmlOutput { heading_offset: 1 }
+    }
+}
+
+/// Site-wide metadata, exposed to templates as `site`.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct Site {
+    /// Shown in the default layout's header and available as `site.title`.
+    pub title: String,
+    /// Absolute base URL (no trailing slash), for feeds and canonical links. Empty means
+    /// the site is built with relative URLs only, which is the portable default.
+    pub base_url: String,
+    /// Free-form description, available as `site.description`.
+    pub description: String,
+    /// `<html lang="…">` in the default layout.
+    pub language: String,
+}
+
+impl Default for Site {
+    fn default() -> Self {
+        Site {
+            title: "org-ssg site".to_string(),
+            base_url: String::new(),
+            description: String::new(),
+            language: "en".to_string(),
+        }
+    }
+}
+
+/// Which pages appear in the shared navigation.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum NavMode {
+    /// Pages at the site root. A nav is a map of the top level, not an index of the
+    /// whole site, and this keeps nav size independent of how many pages exist.
+    #[default]
+    TopLevel,
+    /// Every page. Fine for a small site; note that it makes total output quadratic in
+    /// page count, since each of `n` pages then carries `n` nav links.
+    All,
+    /// Only the pages listed in `nav.pages`, in that order.
+    Explicit,
+    /// No navigation at all.
+    None,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct Nav {
+    pub mode: NavMode,
+    /// Source paths (relative to the source root, e.g. `about.org`) used when
+    /// `mode = "explicit"`. Order is preserved, so this doubles as nav ordering.
+    pub pages: Vec<Utf8PathBuf>,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct Templates {
+    /// Directory of `.html` templates, relative to the source root. Each file is
+    /// registered under its stem, so `base.html` overrides the built-in layout and
+    /// anything else is available to `{% include %}`/`{% extends %}`.
+    pub dir: Utf8PathBuf,
+    /// Give templates a `pages` list of every page's metadata, so a template can build
+    /// an index or archive.
+    ///
+    /// Off by default because it is not free: if any page can read every page's
+    /// metadata, then adding one page can change any page's output, so the whole site
+    /// must re-render on every add, rename or retitle. Turning this on trades that
+    /// incremental precision for the ability to write listing pages.
+    pub expose_page_list: bool,
+}
+
+impl Default for Templates {
+    fn default() -> Self {
+        Templates {
+            dir: Utf8PathBuf::from("templates"),
+            expose_page_list: false,
+        }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct Highlight {
+    /// A syntect built-in theme name — `InspiredGitHub`, `Solarized (dark)`,
+    /// `base16-ocean.dark`, `base16-eighties.dark`, `base16-mocha.dark`,
+    /// `base16-ocean.light`. Highlighting emits CSS classes, and this theme is what the
+    /// generated `syntax.css` colours them with.
+    pub theme: String,
+}
+
+impl Default for Highlight {
+    fn default() -> Self {
+        Highlight {
+            theme: "InspiredGitHub".to_string(),
+        }
+    }
+}
+
+impl Config {
+    /// Load `org-ssg.toml` from `dir`, or return defaults if there is none.
+    ///
+    /// A *missing* config is normal and silent. A *malformed* one is an error: someone
+    /// who wrote a config meant it, and silently building the default site would hide
+    /// their typo behind plausible-looking output.
+    pub fn load(dir: &Utf8Path) -> Result<Config> {
+        Self::load_file(&dir.join(CONFIG_FILE))
+    }
+
+    /// Load a config from an explicit path. Missing is still fine; malformed is not.
+    pub fn load_file(path: &Utf8Path) -> Result<Config> {
+        let text = match std::fs::read_to_string(path) {
+            Ok(text) => text,
+            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Config::default()),
+            Err(e) => return Err(e).with_context(|| format!("reading {path}")),
+        };
+        toml::from_str(&text).with_context(|| format!("parsing {path}"))
+    }
+
+    /// Validate settings that only make sense in combination. Catching these up front
+    /// beats emitting a site with a silently empty nav.
+    pub fn validate(&self) -> Result<()> {
+        if self.nav.mode == NavMode::Explicit && self.nav.pages.is_empty() {
+            anyhow::bail!(
+                "nav.mode is \"explicit\" but nav.pages is empty: list the pages to \
+                 include, or use mode = \"top-level\"/\"all\"/\"none\""
+            );
+        }
+        if self.nav.mode != NavMode::Explicit && !self.nav.pages.is_empty() {
+            anyhow::bail!(
+                "nav.pages is set but nav.mode is \"{}\", so it would be ignored; set \
+                 mode = \"explicit\" to use it",
+                toml::to_string(&self.nav.mode)
+                    .unwrap_or_default()
+                    .trim()
+                    .trim_matches('"')
+            );
+        }
+        if !self.site.base_url.is_empty() && self.site.base_url.ends_with('/') {
+            anyhow::bail!(
+                "site.base_url must not end with a slash (got {:?}) — URLs are joined \
+                 with an explicit separator",
+                self.site.base_url
+            );
+        }
+        Ok(())
+    }
+}
+
+/// The starter config written by `org-ssg init`, and the documentation of record for
+/// what is configurable. Every value shown is the default, so deleting any line is safe.
+pub const STARTER_CONFIG: &str = r#"# org-ssg configuration. Every setting here is optional and shown at its default,
+# so you can delete any line you do not need — or the whole file.
+
+[site]
+title = "org-ssg site"
+# Absolute base URL, no trailing slash. Leave empty to build with relative URLs only.
+base_url = ""
+description = ""
+language = "en"
+
+[nav]
+# Which pages appear in the shared navigation:
+#   "top-level" — pages at the site root (default; keeps nav size independent of site size)
+#   "all"       — every page (fine when small; output grows quadratically with page count)
+#   "explicit"  — only nav.pages, in the order listed
+#   "none"      — no navigation
+mode = "top-level"
+# pages = ["index.org", "about.org"]
+
+[templates]
+# Directory of .html templates, relative to this file. `base.html` replaces the built-in
+# layout; any other file can be pulled in with {% include %} or {% extends %}.
+dir = "templates"
+# Give templates a `pages` list of every page's metadata, so you can build an index or
+# archive. Costs incremental precision: with this on, adding a page re-renders the site.
+expose_page_list = false
+
+[highlight]
+# A syntect theme name: InspiredGitHub, Solarized (dark), base16-ocean.dark,
+# base16-eighties.dark, base16-mocha.dark, base16-ocean.light.
+theme = "InspiredGitHub"
+
+[html]
+# How far to push heading levels down: a level-1 org heading becomes <h(1 + offset)>.
+# The default of 1 matches Emacs, and assumes your layout renders the page title as the
+# <h1>. Set to 0 if your template renders no title of its own.
+heading_offset = 1
+"#;
diff --git a/src/incremental.rs b/src/incremental.rs
index ff421da..8159572 100644
--- a/src/incremental.rs
+++ b/src/incremental.rs
@@ -34,24 +34,10 @@ pub const CACHE_FORMAT_VERSION: u32 = 4;
 /// blake3 hex identity for a content/config/template/render-key hash class (spec §4.1).
 pub type Hash = ContentHash;
 
-/// Resolved global build config. Its hash is a component of every page's render key
-/// (spec §4.1): a change here can invalidate the whole site. Kept minimal for v0.3 —
-/// there is no user-facing config yet — but structured so real knobs (base URL, TODO
-/// keyword set, highlighter theme id, inline features) flow into the hash when added.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct BuildConfig {
-    pub output_extension: String,
-    pub highlighter_theme: String,
-}
-
-impl Default for BuildConfig {
-    fn default() -> Self {
-        BuildConfig {
-            output_extension: "html".to_string(),
-            highlighter_theme: crate::render::SYNTAX_THEME.to_string(),
-        }
-    }
-}
+/// The resolved global build config is [`crate::config::Config`]; its hash is a
+/// component of every page's render key (spec §4.1), so editing `org-ssg.toml`
+/// invalidates the pages it affects.
+pub use crate::config::Config as BuildConfig;
 
 /// Compose bytes into a blake3 hash. The one place hashing happens for composite keys.
 fn hash_bytes(bytes: &[u8]) -> Hash {
@@ -93,7 +79,7 @@ pub fn site_structure_hash(entries: &[(String, String)]) -> Hash {
 /// blake3 over the template sources (spec §4.1, hash class 3). One combined hash over
 /// all templates; when partials land, split this per-template so a single-partial edit
 /// invalidates only its users.
-pub fn template_hash(sources: &[(&str, &str)]) -> Hash {
+pub fn template_hash(sources: &[(String, String)]) -> Hash {
     let mut hasher = blake3::Hasher::new();
     for (name, src) in sources {
         hasher.update(name.as_bytes());
diff --git a/src/lib.rs b/src/lib.rs
index 4d11b08..c5cc7a6 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -9,6 +9,7 @@
 //! deciding which pages actually need rewriting.
 
 pub mod audit;
+pub mod config;
 pub mod incremental;
 pub mod index;
 pub mod model;
diff --git a/src/main.rs b/src/main.rs
index bd25b3c..aa35de0 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7,10 +7,11 @@ use camino::{Utf8Path, Utf8PathBuf};
 use clap::{Parser, Subcommand};
 
 use org_ssg::parser::parse;
-use org_ssg::render::{render, syntax_css, Html, SyntectHighlighter};
+use org_ssg::config::Config;
+use org_ssg::render::{self, render, Html, SyntectHighlighter};
 use org_ssg::resolve::ResolvedDoc;
 use org_ssg::site::{build_site, BuildOptions, SYNTAX_STYLESHEET};
-use org_ssg::template::Templater;
+use org_ssg::template::{PageContext, SiteContext, Templater};
 
 #[derive(Parser)]
 #[command(name = "org-ssg", version, about = "Org-mode static site generator")]
@@ -32,9 +33,12 @@ enum Command {
         /// Bypass the incremental cache and re-render every page (spec §4.5).
         #[arg(long)]
         no_cache: bool,
-        /// Treat broken internal links as errors (spec §4.3.4).
+        /// Treat broken links and parse diagnostics as errors (spec §4.3.4).
         #[arg(long)]
         strict: bool,
+        /// Config file to use, overriding `org-ssg.toml` in the source directory.
+        #[arg(long, value_name = "FILE")]
+        config: Option<Utf8PathBuf>,
     },
     /// Watch a source directory and rebuild incrementally on change (simple poll loop).
     Watch {
@@ -55,6 +59,13 @@ enum Command {
         /// Source directory (or single `.org` file) to audit.
         input: Utf8PathBuf,
     },
+    /// Scaffold a new site: config, an editable copy of the default layout, and a page.
+    Init {
+        /// Directory to create the site in (created if missing; defaults to the
+        /// current directory).
+        #[arg(default_value = ".")]
+        directory: Utf8PathBuf,
+    },
 }
 
 fn main() -> Result<()> {
@@ -65,11 +76,16 @@ fn main() -> Result<()> {
             output,
             no_cache,
             strict,
+            config,
         } => {
             if input.is_dir() {
                 let out = output
                     .context("site build requires an output directory: build <src-dir> -o <out-dir>")?;
-                let opts = BuildOptions { no_cache, strict };
+                let opts = BuildOptions {
+                    no_cache,
+                    strict,
+                    config_path: config.clone(),
+                };
                 let report = build_site(&input, &out, &opts)?;
                 println!(
                     "built {} page(s) ({} rendered, {} cached), copied {} asset(s) from {} -> {} ({} unresolved link(s), {} diagnostic(s))",
@@ -98,6 +114,7 @@ fn main() -> Result<()> {
             print!("{}", org_ssg::audit::report(&result));
             Ok(())
         }
+        Command::Init { directory } => init(&directory),
         Command::Clean { output } => {
             if output.exists() {
                 fs::remove_dir_all(&output)
@@ -111,6 +128,56 @@ fn main() -> Result<()> {
     }
 }
 
+/// Scaffold a working site. Writes only files that do not already exist, so running it
+/// in a directory that has content is safe and additive rather than destructive.
+fn init(dir: &Utf8Path) -> Result<()> {
+    use org_ssg::config::{CONFIG_FILE, STARTER_CONFIG};
+    use org_ssg::template::starter_template;
+
+    fs::create_dir_all(dir).with_context(|| format!("creating {dir}"))?;
+    fs::create_dir_all(dir.join("templates")).with_context(|| format!("creating {dir}/templates"))?;
+
+    let index = concat!(
+        "#+TITLE: Hello\n",
+        "#+DATE: today\n",
+        "\n",
+        "Welcome to your new site. Edit this file, then run the build again.\n",
+        "\n",
+        "* A heading\n",
+        "\n",
+        "Org markup works as you would expect: *bold*, /italic/, ~code~, and\n",
+        "[[https://orgmode.org][links]].\n",
+        "\n",
+        "#+BEGIN_SRC rust\n",
+        "fn main() {\n",
+        "    println!(\"syntax highlighting is on by default\");\n",
+        "}\n",
+        "#+END_SRC\n",
+    );
+
+    let files: [(Utf8PathBuf, &str); 3] = [
+        (dir.join(CONFIG_FILE), STARTER_CONFIG),
+        (dir.join("templates/base.html"), starter_template()),
+        (dir.join("index.org"), index),
+    ];
+
+    let mut created = Vec::new();
+    for (path, contents) in &files {
+        if path.exists() {
+            println!("kept existing {path}");
+            continue;
+        }
+        fs::write(path, contents).with_context(|| format!("writing {path}"))?;
+        created.push(path.clone());
+    }
+
+    for path in &created {
+        println!("created {path}");
+    }
+    println!("\nNext: org-ssg build {dir} -o _site");
+    Ok(())
+}
+
 /// Minimal poll-based watch loop: rebuild incrementally whenever a source file changes.
 /// Not an OS file-watcher (deferred); it snapshots source mtimes every 500ms.
 fn watch(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
@@ -187,13 +254,40 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
     let highlighter = SyntectHighlighter::new();
     let Html(fragment) = render(&resolved, &highlighter);
 
-    let templater = Templater::new();
+    // A single-file build still honours a config beside the source, so `build one.org`
+    // and a whole-site build produce the same-looking page.
+    let dir = input.parent().unwrap_or_else(|| Utf8Path::new("."));
+    let config = Config::load(dir)?;
+    config.validate()?;
+    let templater = Templater::load(Some(&dir.join(&config.templates.dir)))?;
+    let css_text = render::syntax_css(&config.highlight.theme).ok_or_else(|| {
+        anyhow::anyhow!(
+            "unknown highlight.theme {:?}. Available: {}",
+            config.highlight.theme,
+            render::available_themes().join(", ")
+        )
+    })?;
+
+    let site = SiteContext {
+        title: config.site.title.clone(),
+        base_url: config.site.base_url.clone(),
+        description: config.site.description.clone(),
+        language: config.site.language.clone(),
+    };
+    let page_ctx = PageContext {
+        title: title.clone(),
+        url: output.file_name().unwrap_or("index.html").to_string(),
+        source: input.to_string(),
+        date: None,
+        tags: Vec::new(),
+        keywords: Default::default(),
+    };
     let page = templater
-        .render_page(&title, &fragment, &[], SYNTAX_STYLESHEET)
+        .render_page(&site, &page_ctx, &fragment, &[], SYNTAX_STYLESHEET, "", None)
         .with_context(|| format!("templating {input}"))?;
     fs::write(output, page).with_context(|| format!("writing output file {output}"))?;
 
     let css = output.with_file_name(SYNTAX_STYLESHEET);
-    fs::write(&css, syntax_css()).with_context(|| format!("writing stylesheet {css}"))?;
+    fs::write(&css, css_text).with_context(|| format!("writing stylesheet {css}"))?;
     Ok(())
 }
diff --git a/src/render.rs b/src/render.rs
index d2a28de..b6d72c0 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -42,11 +42,6 @@ pub trait Highlighter {
 /// two must agree or the CSS will not match the markup.
 const CLASS_STYLE: ClassStyle = ClassStyle::Spaced;
 
-/// The syntect theme whose colours become [`syntax_css`]. Mirrored in
-/// [`BuildConfig::highlighter_theme`](crate::incremental::BuildConfig) so a theme change
-/// flows into the config hash and invalidates every page.
-pub const SYNTAX_THEME: &str = "InspiredGitHub";
-
 /// Syntect's default syntax definitions, loaded once per process (loading is far more
 /// expensive than highlighting, and a site build highlights many blocks).
 fn syntax_set() -> &'static SyntaxSet {
@@ -54,18 +49,24 @@ fn syntax_set() -> &'static SyntaxSet {
     SET.get_or_init(SyntaxSet::load_defaults_newlines)
 }
 
-/// The stylesheet the emitted highlight classes refer to. Highlighting emits CSS
-/// classes rather than inline styles (spec §3.2), so a build must also emit this.
-pub fn syntax_css() -> &'static str {
-    static CSS: OnceLock<String> = OnceLock::new();
-    CSS.get_or_init(|| {
-        let themes = ThemeSet::load_defaults();
-        themes
-            .themes
-            .get(SYNTAX_THEME)
-            .and_then(|theme| css_for_theme_with_class_style(theme, CLASS_STYLE).ok())
-            .unwrap_or_default()
-    })
+fn theme_set() -> &'static ThemeSet {
+    static THEMES: OnceLock<ThemeSet> = OnceLock::new();
+    THEMES.get_or_init(ThemeSet::load_defaults)
+}
+
+/// The stylesheet the emitted highlight classes refer to, for a named syntect theme.
+/// Highlighting emits CSS classes rather than inline styles (spec §3.2), so a build must
+/// also emit this. `None` means the theme name is not one syntect ships — the caller
+/// reports that rather than quietly emitting an empty stylesheet, which would look like
+/// highlighting is broken.
+pub fn syntax_css(theme: &str) -> Option<String> {
+    let theme = theme_set().themes.get(theme)?;
+    css_for_theme_with_class_style(theme, CLASS_STYLE).ok()
+}
+
+/// Every theme name [`syntax_css`] accepts, for error messages and documentation.
+pub fn available_themes() -> Vec<&'static str> {
+    theme_set().themes.keys().map(String::as_str).collect()
 }
 
 /// The v1 highlighter: syntect tokenizing to CSS-class spans (spec §3.2, §4.2). A block
@@ -129,6 +130,7 @@ fn language_class(lang: Option<&str>) -> String {
 /// Carries the highlighter plus the footnote collector across the tree walk (spec §2.4).
 struct Renderer<'a> {
     hl: &'a dyn Highlighter,
+    opts: RenderOptions,
     /// Block footnote definitions, keyed by label (collected before the walk).
     block_defs: HashMap<String, Vec<Element>>,
     /// Inline footnote definitions discovered at reference sites.
@@ -137,10 +139,34 @@ struct Renderer<'a> {
     order: Vec<String>,
 }
 
-/// Render a resolved document to an HTML fragment.
+/// Options affecting how the tree becomes HTML. Presentation choices that belong to the
+/// site rather than to the document.
+#[derive(Debug, Clone, Copy)]
+pub struct RenderOptions {
+    /// Added to every heading's level, so a level-1 org heading can render as `<h2>`
+    /// beneath a page title supplied by the layout. See
+    /// [`HtmlOutput::heading_offset`](crate::config::HtmlOutput::heading_offset).
+    pub heading_offset: u8,
+}
+
+impl Default for RenderOptions {
+    fn default() -> Self {
+        RenderOptions {
+            heading_offset: crate::config::HtmlOutput::default().heading_offset,
+        }
+    }
+}
+
+/// Render a resolved document to an HTML fragment, with default options.
 pub fn render(doc: &ResolvedDoc, highlighter: &dyn Highlighter) -> Html {
+    render_with(doc, highlighter, &RenderOptions::default())
+}
+
+/// Render a resolved document to an HTML fragment.
+pub fn render_with(doc: &ResolvedDoc, highlighter: &dyn Highlighter, opts: &RenderOptions) -> Html {
     let mut r = Renderer {
         hl: highlighter,
+        opts: *opts,
         block_defs: HashMap::new(),
         inline_defs: HashMap::new(),
         order: Vec::new(),
@@ -163,7 +189,7 @@ impl Renderer<'_> {
 
     fn render_section(&mut self, section: &Section, out: &mut String) {
         if let Some(h) = &section.heading {
-            let level = h.level.clamp(1, 6);
+            let level = h.level.saturating_add(self.opts.heading_offset).clamp(1, 6);
             let anchor = h
                 .custom_id
                 .clone()
diff --git a/src/site.rs b/src/site.rs
index 48abd69..196e0b2 100644
--- a/src/site.rs
+++ b/src/site.rs
@@ -19,14 +19,15 @@ use walkdir::WalkDir;
 
 use crate::incremental::{
     self, combine, config_hash, render_key, resolved_links_hash, site_structure_hash,
-    template_hash, BuildConfig, DepGraph, Hash, Manifest, PageRecord, CACHE_FORMAT_VERSION,
+    template_hash, DepGraph, Hash, Manifest, PageRecord, CACHE_FORMAT_VERSION,
 };
 use crate::index::{document_targets, SymbolTable, TargetId};
 use crate::model::{ContentHash, Diagnostic, Document};
 use crate::parser::parse;
-use crate::render::{render, syntax_css, Html, SyntectHighlighter};
+use crate::render::{self, render_with, Html, RenderOptions, SyntectHighlighter};
 use crate::resolve::resolve;
-use crate::template::{template_sources, NavItem, Templater};
+use crate::config::{self, Config, NavMode};
+use crate::template::{NavItem, PageContext, SiteContext, Templater};
 use crate::util::{output_path, output_url, relative_root};
 
 /// A fully built page: source and output paths (relative to their roots) and its
@@ -49,6 +50,8 @@ pub struct BuildOptions {
     pub no_cache: bool,
     /// Treat broken internal links as a build error rather than a warning (spec §4.3.4).
     pub strict: bool,
+    /// Explicit config file, overriding `org-ssg.toml` in the source directory.
+    pub config_path: Option<Utf8PathBuf>,
 }
 
 /// Summary of a site build.
@@ -98,14 +101,39 @@ struct PagePrep {
     broken: Vec<TargetId>,
     diagnostics: Vec<Diagnostic>,
     nav: Vec<NavItem>,
+    context: PageContext,
+}
+
+/// Which pages the configured [`NavMode`] selects, in nav order.
+fn nav_selection<'a>(
+    config: &Config,
+    pages: &'a [(Utf8PathBuf, Utf8PathBuf, String)],
+) -> Vec<&'a (Utf8PathBuf, Utf8PathBuf, String)> {
+    match config.nav.mode {
+        NavMode::None => Vec::new(),
+        NavMode::All => pages.iter().collect(),
+        NavMode::TopLevel => pages.iter().filter(|(_, out, _)| is_top_level(out)).collect(),
+        // Configured order wins over discovery order — a hand-written nav is a designed
+        // sequence, not an alphabetical one.
+        NavMode::Explicit => config
+            .nav
+            .pages
+            .iter()
+            .filter_map(|want| pages.iter().find(|(source, _, _)| source == want))
+            .collect(),
+    }
 }
 
 /// DISCOVER + PARSE + INDEX + RESOLVE the whole site, returning per-page prep and the
 /// global symbol table. RENDER/TEMPLATE is deferred to the caller so the incremental
 /// build can render only the pages it must. PARSE/INDEX/RESOLVE are cheap and pure, so
 /// they run for every file each build; the incremental win is on RENDER + EMIT (spec §4.4).
-fn prepare_pages(src: &Utf8Path) -> Result<(Vec<PagePrep>, SymbolTable)> {
-    let (org_rel, _assets) = discover(src)?;
+fn prepare_pages(
+    src: &Utf8Path,
+    config: &Config,
+    out: Option<&Utf8Path>,
+) -> Result<(Vec<PagePrep>, SymbolTable)> {
+    let (org_rel, _assets) = discover(src, config, out)?;
 
     // PARSE every file (relative paths keep snapshots and links machine-independent).
     // PARSE is a pure function of one file's bytes (spec §2.1), which is exactly the
@@ -127,32 +155,46 @@ fn prepare_pages(src: &Utf8Path) -> Result<(Vec<PagePrep>, SymbolTable)> {
         symbols.index_document(doc);
     }
 
-    // Nav is global chrome; titles come from #+TITLE (falling back to the file stem) and
-    // URLs from each page's output path, which `#+SLUG:` can rename.
-    let all_pages: Vec<(Utf8PathBuf, String)> = docs
-        .iter()
-        .map(|d| (output_path(&d.source_path, &d.keywords), page_title(d)))
-        .collect();
-    let entries: Vec<(Utf8PathBuf, String)> = all_pages
+    // `(source, output, title)` for every page. Titles come from #+TITLE (falling back to
+    // the file stem) and URLs from each page's output path, which `#+SLUG:` can rename.
+    let all_pages: Vec<(Utf8PathBuf, Utf8PathBuf, String)> = docs
         .iter()
-        .filter(|(out, _)| is_top_level(out))
-        .cloned()
+        .map(|d| {
+            (
+                d.source_path.clone(),
+                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(&all_pages) {
-        if let Some(other) = claimed.insert(out, &doc.source_path) {
+    for (source, out, _) in &all_pages {
+        if let Some(other) = claimed.insert(out, source) {
             anyhow::bail!(
-                "output collision: {} and {} both build to {out} (check their #+SLUG:)",
-                other,
-                doc.source_path
+                "output collision: {other} and {source} both build to {out} \
+                 (check their #+SLUG:)"
             );
         }
     }
 
+    // An explicit nav naming a page that does not exist is a typo, and a silently
+    // shorter nav is a poor way to learn about it.
+    if config.nav.mode == NavMode::Explicit {
+        for want in &config.nav.pages {
+            if !all_pages.iter().any(|(source, _, _)| source == want) {
+                anyhow::bail!("nav.pages lists {want}, which is not a page in {src}");
+            }
+        }
+    }
+    let entries: Vec<(Utf8PathBuf, String)> = nav_selection(config, &all_pages)
+        .into_iter()
+        .map(|(_, out, title)| (out.clone(), title.clone()))
+        .collect();
+
     // RESOLVE reads the shared symbol table and writes only into its own page's output,
     // so it parallelizes for free once INDEX has finished building the table.
     let pages: Vec<PagePrep> = docs
@@ -175,6 +217,7 @@ fn prepare_pages(src: &Utf8Path) -> Result<(Vec<PagePrep>, SymbolTable)> {
             .collect();
 
             PagePrep {
+                context: page_context(doc, &output),
                 source: doc.source_path.clone(),
                 output,
                 title: page_title(doc),
@@ -195,9 +238,14 @@ fn prepare_pages(src: &Utf8Path) -> Result<(Vec<PagePrep>, SymbolTable)> {
 /// Parse + index + resolve + render + template a whole site *in memory*, without
 /// touching the output directory. Shared by the tests (full render, every page).
 pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
-    let (preps, _symbols) = prepare_pages(src)?;
+    let config = Config::load(src)?;
+    config.validate()?;
+    let (preps, _symbols) = prepare_pages(src, &config, None)?;
     let highlighter = SyntectHighlighter::new();
-    let templater = Templater::new();
+    let templater = Templater::load(Some(&src.join(&config.templates.dir)))?;
+    let site = site_context(&config);
+    let listing = page_listing(&config, &preps);
+    let render_opts = render_options(&config);
 
     let mut pages = Vec::new();
     let mut broken = Vec::new();
@@ -205,7 +253,7 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
         for t in &p.broken {
             broken.push((p.source.clone(), t.clone()));
         }
-        let html = render_page(&templater, &highlighter, p)?;
+        let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &render_opts, p)?;
         pages.push(BuiltPage {
             source: p.source.clone(),
             output: p.output.clone(),
@@ -216,16 +264,46 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
     Ok((pages, broken))
 }
 
+fn render_options(config: &Config) -> RenderOptions {
+    RenderOptions {
+        heading_offset: config.html.heading_offset,
+    }
+}
+
+fn site_context(config: &Config) -> SiteContext {
+    SiteContext {
+        title: config.site.title.clone(),
+        base_url: config.site.base_url.clone(),
+        description: config.site.description.clone(),
+        language: config.site.language.clone(),
+    }
+}
+
+/// The `pages` list templates see, when configured to see one (see
+/// [`crate::config::Templates::expose_page_list`]).
+fn page_listing(config: &Config, preps: &[PagePrep]) -> Option<Vec<PageContext>> {
+    config
+        .templates
+        .expose_page_list
+        .then(|| preps.iter().map(|p| p.context.clone()).collect())
+}
+
 /// RENDER + TEMPLATE one prepared page into its final HTML string.
+#[allow(clippy::too_many_arguments)]
 fn render_page(
     templater: &Templater,
     highlighter: &SyntectHighlighter,
+    site: &SiteContext,
+    pages: Option<&[PageContext]>,
+    render_opts: &RenderOptions,
     p: &PagePrep,
 ) -> Result<String> {
-    let Html(fragment) = render(&p.resolved, highlighter);
-    let stylesheet = format!("{}{}", relative_root(&p.source), SYNTAX_STYLESHEET);
+    let Html(fragment) = render_with(&p.resolved, highlighter, render_opts);
+    // Relative to the *output* path, since `#+SLUG:` can move a page between depths.
+    let root = relative_root(&p.output);
+    let stylesheet = format!("{root}{SYNTAX_STYLESHEET}");
     templater
-        .render_page(&p.title, &fragment, &p.nav, &stylesheet)
+        .render_page(site, &p.context, &fragment, &p.nav, &stylesheet, &root, pages)
         .with_context(|| format!("templating {}", p.source))
 }
 
@@ -236,28 +314,55 @@ pub const SYNTAX_STYLESHEET: &str = "syntax.css";
 /// `render_key` changed or that link into a changed file's targets; reuses the on-disk
 /// output of everything else; persists an updated cache manifest.
 pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<SiteReport> {
-    let (_org_rel, assets) = discover(src)?;
-    let (preps, symbols) = prepare_pages(src)?;
+    let cfg = match &opts.config_path {
+        Some(path) => Config::load_file(path)?,
+        None => Config::load(src)?,
+    };
+    cfg.validate()?;
+
+    // Create the output directory up front so it can be recognised and excluded when it
+    // lives inside the source tree.
+    fs::create_dir_all(out).with_context(|| format!("creating {out}"))?;
+    let (_org_rel, assets) = discover(src, &cfg, Some(out))?;
+    let (preps, symbols) = prepare_pages(src, &cfg, Some(out))?;
+
+    let templater = Templater::load(Some(&src.join(&cfg.templates.dir)))?;
+    let syntax_css = render::syntax_css(&cfg.highlight.theme).ok_or_else(|| {
+        anyhow::anyhow!(
+            "unknown highlight.theme {:?}. Available: {}",
+            cfg.highlight.theme,
+            render::available_themes().join(", ")
+        )
+    })?;
 
     // The global hash classes (spec §4.1): a change in any invalidates the site. The
-    // config hash is combined with a site-structure hash because the nav bar — global
-    // 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();
-    // Only the pages that actually appear in the nav belong in the site-structure hash,
-    // because the nav is the only global chrome a page carries. Hashing *every* page
-    // here would mean adding one blog post re-rendered the entire site — correct, but
-    // needlessly: a nested page cannot change any other page's nav.
+    // config hash is combined with a site-structure hash covering the global chrome each
+    // page carries, so a change to that chrome re-renders the pages showing it.
     //
-    // Keyed on the *output* path, since a `#+SLUG:` change moves a page's URL — and so
-    // its nav link — even though no source filename moved.
-    let nav_entries: Vec<(String, String)> = preps
+    // Which pages belong in that hash depends on what a template can *see*. Normally it
+    // is the nav only — a nested page cannot change another page's nav, so adding a blog
+    // post should render one page, not the site. But `expose_page_list` hands every
+    // template every page's metadata, and then any page's output really can depend on
+    // any other page, so the hash has to widen to match. Keyed on output paths, since a
+    // `#+SLUG:` change moves a page's URL without moving its source.
+    let all_pages: Vec<(Utf8PathBuf, Utf8PathBuf, String)> = preps
         .iter()
-        .filter(|p| is_top_level(&p.output))
-        .map(|p| (p.output.to_string(), p.title.clone()))
+        .map(|p| (p.source.clone(), p.output.clone(), p.title.clone()))
         .collect();
-    let cfg_hash = combine(config_hash(&cfg), site_structure_hash(&nav_entries));
-    let tmpl_hash = template_hash(template_sources());
+    let structure: Vec<(String, String)> = if cfg.templates.expose_page_list {
+        all_pages
+            .iter()
+            .map(|(_, out, title)| (out.to_string(), title.clone()))
+            .collect()
+    } else {
+        // The same selection the nav itself is built from, so the two can never drift.
+        nav_selection(&cfg, &all_pages)
+            .into_iter()
+            .map(|(_, out, title)| (out.to_string(), title.clone()))
+            .collect()
+    };
+    let cfg_hash = combine(config_hash(&cfg), site_structure_hash(&structure));
+    let tmpl_hash = template_hash(templater.sources());
 
     // Compose each page's render key and record its dependency edges.
     let mut new_graph = DepGraph::default();
@@ -310,7 +415,9 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
     }
 
     let highlighter = SyntectHighlighter::new();
-    let templater = Templater::new();
+    let site = site_context(&cfg);
+    let listing = page_listing(&cfg, &preps);
+    let render_opts = render_options(&cfg);
     let mut report = SiteReport::default();
 
     // RENDER + TEMPLATE + EMIT, in parallel. This is where a build's time actually goes
@@ -332,7 +439,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
             if let Some(parent) = dest.parent() {
                 fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?;
             }
-            let html = render_page(&templater, &highlighter, p)?;
+            let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &render_opts, p)?;
             fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?;
             Ok(true)
         })
@@ -355,8 +462,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
 
     // The syntax stylesheet the highlighter's CSS classes refer to. Written every build
     // (it is a few KB and depends only on the theme, which lives in the config hash).
-    fs::create_dir_all(out).with_context(|| format!("creating {out}"))?;
-    fs::write(out.join(SYNTAX_STYLESHEET), syntax_css())
+    fs::write(out.join(SYNTAX_STYLESHEET), &syntax_css)
         .with_context(|| format!("writing {SYNTAX_STYLESHEET} under {out}"))?;
 
     // Assets are a dumb copy in v0.3 (spec §8 Q11): copy every run. Cheap, and keeps the
@@ -475,10 +581,24 @@ fn compute_rebuild_set(
 
 /// Walk `src`, returning `.org` source paths and non-`.org` asset paths, both relative
 /// to `src` and sorted for deterministic output. The cache manifest is not an asset.
-fn discover(src: &Utf8Path) -> Result<(Vec<Utf8PathBuf>, Vec<Utf8PathBuf>)> {
+fn discover(
+    src: &Utf8Path,
+    config: &Config,
+    out: Option<&Utf8Path>,
+) -> Result<(Vec<Utf8PathBuf>, Vec<Utf8PathBuf>)> {
+    let skip_dirs = excluded_dirs(src, config, out);
     let mut org = Vec::new();
     let mut assets = Vec::new();
-    for entry in WalkDir::new(src).sort_by_file_name() {
+
+    let walker = WalkDir::new(src).sort_by_file_name().into_iter();
+    for entry in walker.filter_entry(|e| {
+        let Some(path) = Utf8Path::from_path(e.path()) else {
+            return false;
+        };
+        let rel = path.strip_prefix(src).unwrap_or(path);
+        // The source root itself always passes; `filter_entry` prunes whole subtrees.
+        rel.as_str().is_empty() || !is_excluded(rel, &skip_dirs)
+    }) {
         let entry = entry.with_context(|| format!("walking {src}"))?;
         if !entry.file_type().is_file() {
             continue;
@@ -489,6 +609,9 @@ fn discover(src: &Utf8Path) -> Result<(Vec<Utf8PathBuf>, Vec<Utf8PathBuf>)> {
             .strip_prefix(src)
             .map(|p| p.to_owned())
             .unwrap_or_else(|_| abs.clone());
+        if rel == config::CONFIG_FILE {
+            continue;
+        }
         if rel.extension() == Some("org") {
             org.push(rel);
         } else {
@@ -500,6 +623,62 @@ fn discover(src: &Utf8Path) -> Result<(Vec<Utf8PathBuf>, Vec<Utf8PathBuf>)> {
     Ok((org, assets))
 }
 
+/// Source-relative directories that DISCOVER must not descend into: the template
+/// directory (build input, not content) and the output directory when it lives inside
+/// the source.
+///
+/// The output case is not a corner case — `org-ssg build . -o _site` is the obvious
+/// thing to type, and without this the build copies its own output back into itself,
+/// growing `_site/_site/_site/…` on every run.
+fn excluded_dirs(src: &Utf8Path, config: &Config, out: Option<&Utf8Path>) -> Vec<Utf8PathBuf> {
+    let mut dirs = vec![config.templates.dir.clone()];
+    if let Some(out) = out {
+        // Compare canonicalized paths so `.`, `./x` and an absolute path all agree.
+        // The output may not exist yet, in which case it cannot contain anything and
+        // the textual fallback is enough.
+        let canon = |p: &Utf8Path| -> Option<Utf8PathBuf> {
+            std::fs::canonicalize(p)
+                .ok()
+                .and_then(|p| Utf8PathBuf::from_path_buf(p).ok())
+        };
+        match (canon(src), canon(out)) {
+            (Some(src_abs), Some(out_abs)) => {
+                if let Ok(rel) = out_abs.strip_prefix(&src_abs) {
+                    if !rel.as_str().is_empty() {
+                        dirs.push(rel.to_owned());
+                    }
+                }
+            }
+            _ => {
+                if let Ok(rel) = out.strip_prefix(src) {
+                    if !rel.as_str().is_empty() {
+                        dirs.push(rel.to_owned());
+                    }
+                }
+            }
+        }
+    }
+    dirs
+}
+
+/// Is this source-relative path excluded from discovery?
+///
+/// Dot-entries are skipped wholesale. That is the conventional rule for site generators,
+/// and the reason is safety rather than tidiness: a source directory is very often a git
+/// repository, and publishing `.git` — or `.env` — is a way to leak a project's entire
+/// history alongside its homepage.
+fn is_excluded(rel: &Utf8Path, skip_dirs: &[Utf8PathBuf]) -> bool {
+    if rel
+        .components()
+        .any(|c| c.as_str().starts_with('.') && c.as_str() != "." && c.as_str() != "..")
+    {
+        return true;
+    }
+    skip_dirs
+        .iter()
+        .any(|dir| !dir.as_str().is_empty() && rel.starts_with(dir))
+}
+
 /// Does this output path sit at the site root?
 ///
 /// The nav is the site's global chrome, and listing *every* page in it makes an `n`-page
@@ -511,6 +690,37 @@ fn is_top_level(output: &Utf8Path) -> bool {
     output.parent().is_none_or(|p| p.as_str().is_empty())
 }
 
+/// Everything a template can know about one page. Every `#+KEYWORD:` is passed through
+/// under its lowercased name, so a template can use metadata this crate has never heard
+/// of without the crate needing a release to support it.
+fn page_context(doc: &Document, output: &Utf8Path) -> PageContext {
+    let keyword = |name: &str| {
+        doc.keywords
+            .entries
+            .iter()
+            .find(|(k, _)| k.eq_ignore_ascii_case(name))
+            .map(|(_, v)| v.clone())
+    };
+    PageContext {
+        title: page_title(doc),
+        url: output.to_string(),
+        source: doc.source_path.to_string(),
+        date: keyword("DATE"),
+        tags: keyword("FILETAGS")
+            .unwrap_or_default()
+            .split(':')
+            .filter(|t| !t.trim().is_empty())
+            .map(|t| t.trim().to_string())
+            .collect(),
+        keywords: doc
+            .keywords
+            .entries
+            .iter()
+            .map(|(k, v)| (k.to_lowercase(), v.clone()))
+            .collect(),
+    }
+}
+
 fn page_title(doc: &Document) -> String {
     doc.keywords
         .entries
diff --git a/src/template.rs b/src/template.rs
index 91c6b4f..e87ac1d 100644
--- a/src/template.rs
+++ b/src/template.rs
@@ -1,10 +1,20 @@
 //! TEMPLATE stage (spec §2.1, §2.4, §3.3): rendered fragment + page metadata → full HTML.
 //!
 //! minijinja (Jinja2 semantics, runtime templates: edit-and-rebuild, no recompile).
-//! Templates are a hashing input for incrementality (spec §4.1): a base-layout edit
-//! invalidates every page that transitively uses it. Keep the fragment/template
-//! boundary sharp so content HTML can be snapshot-tested independently of chrome.
+//!
+//! Templates come from the configured directory when it exists, and fall back to a
+//! built-in layout when it does not. That fallback is what lets a bare directory of
+//! `.org` files build into a real site with no setup, while `base.html` in the templates
+//! directory replaces the layout entirely for anyone who wants their own.
+//!
+//! Template sources are a hashing input for incrementality (spec §4.1): editing a layout
+//! invalidates the pages that use it, and that has to hold for user templates too, or a
+//! design change would leave a site half-updated.
 
+use std::collections::BTreeMap;
+
+use anyhow::{Context, Result};
+use camino::Utf8Path;
 use minijinja::{context, Environment};
 use serde::Serialize;
 
@@ -15,36 +25,73 @@ pub struct NavItem {
     pub url: String,
 }
 
-/// The base layout applied to every page: `<title>`, a nav bar, and the body.
-/// Minimal but real — a single `base` template, no partials yet.
+/// Site-wide values, exposed to templates as `site`.
+#[derive(Debug, Clone, Serialize)]
+pub struct SiteContext {
+    pub title: String,
+    pub base_url: String,
+    pub description: String,
+    pub language: String,
+}
+
+/// One page's metadata, exposed to templates as `page` — and, when
+/// `templates.expose_page_list` is on, as entries of `pages`.
+#[derive(Debug, Clone, Serialize)]
+pub struct PageContext {
+    pub title: String,
+    /// Output path relative to the site root, e.g. `blog/post.html`.
+    pub url: String,
+    /// Source path relative to the source root, e.g. `blog/post.org`.
+    pub source: String,
+    /// `#+DATE:` verbatim, if present — org date syntax is not normalized here because
+    /// templates are better placed to decide how a date should read.
+    pub date: Option<String>,
+    /// `#+FILETAGS:` split on `:`.
+    pub tags: Vec<String>,
+    /// Every `#+KEYWORD:` in the file, keyed by lowercased name, so a template can use
+    /// project-specific metadata this crate has never heard of.
+    pub keywords: BTreeMap<String, String>,
+}
+
+/// The built-in layout, used when the templates directory has no `base.html`.
+/// Deliberately plain: it should be a working starting point and an obvious thing to
+/// replace, not a design anyone has to live with.
 const BASE_TEMPLATE: &str = r#"<!DOCTYPE html>
-<html lang="en">
+<html lang="{{ site.language }}">
 <head>
 <meta charset="utf-8">
-<title>{{ title }}</title>
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{ page.title }} &middot; {{ site.title }}</title>
+{%- if page.description %}
+<meta name="description" content="{{ page.description }}">
+{%- endif %}
 {%- if stylesheet %}
 <link rel="stylesheet" href="{{ stylesheet }}">
 {%- endif %}
 </head>
 <body>
+<header>
+<a class="site-title" href="{{ root }}index.html">{{ site.title }}</a>
+{%- if nav %}
 <nav>
 {%- for item in nav %}
 <a href="{{ item.url }}">{{ item.title }}</a>
 {%- endfor %}
 </nav>
+{%- endif %}
+</header>
 <main>
+<h1>{{ page.title }}</h1>
+{%- if page.date %}
+<p class="page-date">{{ page.date }}</p>
+{%- endif %}
 {{ body | safe }}</main>
 </body>
 </html>
 "#;
 
-/// The source text of every template that participates in the page layout. Hashed by
-/// the incremental layer (spec §4.1): a base-layout edit invalidates every page that
-/// uses it. There is a single `base` template today; when partials arrive this returns
-/// the transitive closure so a single-partial edit invalidates only its users.
-pub fn template_sources() -> &'static [(&'static str, &'static str)] {
-    &[("base", BASE_TEMPLATE)]
-}
+/// The name a template must have to serve as the page layout.
+pub const BASE_TEMPLATE_NAME: &str = "base";
 
 #[derive(Debug, thiserror::Error)]
 pub enum TemplateError {
@@ -55,37 +102,120 @@ pub enum TemplateError {
 /// Wraps a rendered fragment in its page template.
 pub struct Templater {
     env: Environment<'static>,
+    /// `(name, source)` for every registered template, for the template hash. Sorted by
+    /// name so the hash does not depend on directory iteration order.
+    sources: Vec<(String, String)>,
 }
 
 impl Templater {
-    pub fn new() -> Self {
+    /// Load templates from `dir`, falling back to the built-in layout.
+    ///
+    /// A missing directory is fine — that is the zero-config path. A directory that
+    /// exists but contains a template that does not compile is an error: it means
+    /// someone is actively editing their layout, and rendering the built-in default
+    /// instead would look like their edit silently did nothing.
+    pub fn load(dir: Option<&Utf8Path>) -> Result<Self> {
+        let mut sources: Vec<(String, String)> = Vec::new();
+
+        if let Some(dir) = dir.filter(|d| d.is_dir()) {
+            let mut entries: Vec<_> = std::fs::read_dir(dir)
+                .with_context(|| format!("reading template directory {dir}"))?
+                .collect::<std::io::Result<Vec<_>>>()
+                .with_context(|| format!("reading template directory {dir}"))?;
+            entries.sort_by_key(|e| e.file_name());
+
+            for entry in entries {
+                let path = Utf8Path::from_path(&entry.path())
+                    .map(Utf8Path::to_owned)
+                    .ok_or_else(|| anyhow::anyhow!("non-UTF-8 template path"))?;
+                if path.extension() != Some("html") || !path.is_file() {
+                    continue;
+                }
+                let name = path
+                    .file_stem()
+                    .ok_or_else(|| anyhow::anyhow!("template with no name: {path}"))?
+                    .to_string();
+                let source = std::fs::read_to_string(&path)
+                    .with_context(|| format!("reading template {path}"))?;
+                sources.push((name, source));
+            }
+        }
+
+        if !sources.iter().any(|(n, _)| n == BASE_TEMPLATE_NAME) {
+            sources.push((BASE_TEMPLATE_NAME.to_string(), BASE_TEMPLATE.to_string()));
+        }
+        sources.sort_by(|a, b| a.0.cmp(&b.0));
+
         let mut env = Environment::new();
-        env.add_template("base", BASE_TEMPLATE)
-            .expect("base template compiles");
-        Templater { env }
+        for (name, source) in &sources {
+            // `Environment<'static>` needs owned sources; leaking is bounded by the
+            // template count and lives as long as the build anyway.
+            let name: &'static str = Box::leak(name.clone().into_boxed_str());
+            let source: &'static str = Box::leak(source.clone().into_boxed_str());
+            env.add_template(name, source)
+                .with_context(|| format!("compiling template {name}"))?;
+        }
+
+        Ok(Templater { env, sources })
+    }
+
+    /// `(name, source)` for every registered template — the template hash's input
+    /// (spec §4.1), covering user templates so editing one invalidates its pages.
+    pub fn sources(&self) -> &[(String, String)] {
+        &self.sources
     }
 
-    /// fragment + page metadata → full HTML page. `stylesheet` is the URL of the
-    /// syntax-highlighting stylesheet relative to *this* page (highlighting emits CSS
-    /// classes, so the sheet has to come with it).
+    /// fragment + page metadata → full HTML page.
+    ///
+    /// `stylesheet` and `root` are URLs relative to *this* page, so a template works the
+    /// same at any directory depth.
+    #[allow(clippy::too_many_arguments)]
     pub fn render_page(
         &self,
-        title: &str,
+        site: &SiteContext,
+        page: &PageContext,
         body: &str,
         nav: &[NavItem],
         stylesheet: &str,
+        root: &str,
+        pages: Option<&[PageContext]>,
     ) -> Result<String, TemplateError> {
         let tmpl = self
             .env
-            .get_template("base")
+            .get_template(BASE_TEMPLATE_NAME)
             .map_err(|e| TemplateError::Render(e.to_string()))?;
-        tmpl.render(context! { title => title, body => body, nav => nav, stylesheet => stylesheet })
-            .map_err(|e| TemplateError::Render(e.to_string()))
+        tmpl.render(context! {
+            site => site,
+            page => page,
+            body => body,
+            nav => nav,
+            stylesheet => stylesheet,
+            root => root,
+            pages => pages,
+        })
+        .map_err(|e| TemplateError::Render(render_error_detail(e)))
     }
 }
 
-impl Default for Templater {
-    fn default() -> Self {
-        Self::new()
+/// minijinja's `Display` gives only the top-level message; the useful part (which
+/// template, which line) is in the source and cause chain.
+fn render_error_detail(error: minijinja::Error) -> String {
+    let mut out = error.to_string();
+    if let Some(name) = error.template_source().map(|_| error.name().unwrap_or("?")) {
+        if let Some(line) = error.line() {
+            out = format!("{out} (in template {name}, line {line})");
+        }
     }
+    let mut source = std::error::Error::source(&error);
+    while let Some(cause) = source {
+        out.push_str(&format!(": {cause}"));
+        source = cause.source();
+    }
+    out
+}
+
+/// The starter layout written by `org-ssg init`: the built-in template, on disk, ready
+/// to edit.
+pub fn starter_template() -> &'static str {
+    BASE_TEMPLATE
 }
diff --git a/tests/config.rs b/tests/config.rs
new file mode 100644
index 0000000..703942d
--- /dev/null
+++ b/tests/config.rs
@@ -0,0 +1,445 @@
+//! Configuration, templating and discovery — the surface that decides whether this is a
+//! generator for one site or for anyone's.
+//!
+//! The theme running through these tests is that **the zero-config path has to work**.
+//! A directory of `.org` files with no `org-ssg.toml`, no templates and no knowledge of
+//! this tool must build into a real site; configuration is how you change the output,
+//! never how you make it work at all.
+
+use std::sync::atomic::{AtomicU32, Ordering};
+
+use camino::Utf8PathBuf;
+
+use org_ssg::config::{Config, NavMode};
+use org_ssg::site::{build_site, BuildOptions};
+
+fn tmpdir(tag: &str) -> Utf8PathBuf {
+    static N: AtomicU32 = AtomicU32::new(0);
+    let n = N.fetch_add(1, Ordering::Relaxed);
+    let base = Utf8PathBuf::from_path_buf(std::env::temp_dir())
+        .expect("utf-8 temp dir")
+        .join(format!("org-ssg-cfg-{}-{tag}-{n}", std::process::id()));
+    let _ = std::fs::remove_dir_all(&base);
+    std::fs::create_dir_all(&base).unwrap();
+    base
+}
+
+/// A site with a root page, a second root page, and one nested page.
+fn write_site(src: &Utf8PathBuf) {
+    std::fs::create_dir_all(src.join("blog")).unwrap();
+    std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nWelcome.\n").unwrap();
+    std::fs::write(src.join("about.org"), "#+TITLE: About\n\nAbout.\n").unwrap();
+    std::fs::write(
+        src.join("blog/post.org"),
+        "#+TITLE: A Post\n#+DATE: 2024-05-01\n#+FILETAGS: :rust:web:\n\nBody.\n",
+    )
+    .unwrap();
+}
+
+fn build(src: &Utf8PathBuf, out: &Utf8PathBuf) -> org_ssg::site::SiteReport {
+    build_site(src, out, &BuildOptions::default()).expect("build")
+}
+
+fn page(out: &Utf8PathBuf, rel: &str) -> String {
+    std::fs::read_to_string(out.join(rel)).unwrap_or_else(|e| panic!("reading {rel}: {e}"))
+}
+
+// ---------------------------------------------------------------------------
+// Zero config
+// ---------------------------------------------------------------------------
+
+/// The headline promise: point it at a directory of org files and get a site.
+#[test]
+fn a_bare_directory_of_org_files_builds_with_no_config() {
+    let root = tmpdir("bare");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_site(&src);
+    let out = root.join("out");
+
+    let report = build(&src, &out);
+    assert_eq!(report.pages.len(), 3);
+
+    let home = page(&out, "index.html");
+    assert!(home.contains("<!DOCTYPE html>"), "a full page, not a fragment");
+    assert!(home.contains("Welcome."), "the content is there");
+    assert!(
+        out.join("syntax.css").exists(),
+        "the stylesheet the highlighter needs is emitted too"
+    );
+}
+
+/// A missing config is normal. A *malformed* one is not: someone who wrote a config
+/// meant it, and quietly building the default site would hide their typo behind
+/// plausible-looking output.
+#[test]
+fn a_malformed_config_is_an_error_but_a_missing_one_is_not() {
+    let root = tmpdir("malformed");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_site(&src);
+
+    assert_eq!(Config::load(&src).unwrap(), Config::default());
+
+    std::fs::write(src.join("org-ssg.toml"), "[site\ntitle = broken").unwrap();
+    let err = Config::load(&src).expect_err("malformed config must fail");
+    assert!(format!("{err:#}").contains("org-ssg.toml"), "names the file: {err:#}");
+}
+
+/// A misspelled key is a silent no-op in most config formats, which is exactly how
+/// someone spends an afternoon wondering why a setting does nothing.
+#[test]
+fn an_unknown_config_key_is_rejected() {
+    let root = tmpdir("unknownkey");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    std::fs::write(src.join("org-ssg.toml"), "[site]\ntittle = \"typo\"\n").unwrap();
+
+    let err = Config::load(&src).expect_err("unknown key must fail");
+    assert!(
+        format!("{err:#}").contains("tittle"),
+        "the error names the offending key: {err:#}"
+    );
+}
+
+// ---------------------------------------------------------------------------
+// Nav modes
+// ---------------------------------------------------------------------------
+
+fn nav_of(html: &str) -> String {
+    html.split("<nav>")
+        .nth(1)
+        .and_then(|s| s.split("</nav>").next())
+        .unwrap_or("")
+        .to_string()
+}
+
+#[test]
+fn nav_modes_select_different_pages() {
+    for (mode, expect_post, expect_about) in [
+        ("top-level", false, true),
+        ("all", true, true),
+        ("none", false, false),
+    ] {
+        let root = tmpdir(&format!("nav-{mode}"));
+        let src = root.join("src");
+        std::fs::create_dir_all(&src).unwrap();
+        write_site(&src);
+        std::fs::write(
+            src.join("org-ssg.toml"),
+            format!("[nav]\nmode = \"{mode}\"\n"),
+        )
+        .unwrap();
+        let out = root.join("out");
+        build(&src, &out);
+
+        let nav = nav_of(&page(&out, "index.html"));
+        assert_eq!(
+            nav.contains("A Post"),
+            expect_post,
+            "mode {mode} nested page presence, nav was:\n{nav}"
+        );
+        assert_eq!(
+            nav.contains("About"),
+            expect_about,
+            "mode {mode} root page presence, nav was:\n{nav}"
+        );
+    }
+}
+
+/// An explicit nav is a designed sequence, so configured order beats discovery order.
+#[test]
+fn explicit_nav_uses_the_configured_order() {
+    let root = tmpdir("navexplicit");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_site(&src);
+    std::fs::write(
+        src.join("org-ssg.toml"),
+        "[nav]\nmode = \"explicit\"\npages = [\"blog/post.org\", \"index.org\"]\n",
+    )
+    .unwrap();
+    let out = root.join("out");
+    build(&src, &out);
+
+    let nav = nav_of(&page(&out, "index.html"));
+    let post = nav.find("A Post").expect("post in nav");
+    let home = nav.find("Home").expect("home in nav");
+    assert!(post < home, "configured order wins:\n{nav}");
+    assert!(!nav.contains("About"), "unlisted pages stay out:\n{nav}");
+}
+
+/// A nav entry naming a page that does not exist is a typo, and a silently shorter nav
+/// is a poor way to find out.
+#[test]
+fn explicit_nav_rejects_a_page_that_does_not_exist() {
+    let root = tmpdir("navmissing");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_site(&src);
+    std::fs::write(
+        src.join("org-ssg.toml"),
+        "[nav]\nmode = \"explicit\"\npages = [\"nope.org\"]\n",
+    )
+    .unwrap();
+
+    let err = build_site(&src, &root.join("out"), &BuildOptions::default())
+        .expect_err("missing nav page must fail");
+    assert!(format!("{err:#}").contains("nope.org"), "names it: {err:#}");
+}
+
+/// `mode` and `pages` disagreeing means one of them is being ignored.
+#[test]
+fn contradictory_nav_settings_are_rejected() {
+    let mut config = Config::default();
+    config.nav.pages = vec![Utf8PathBuf::from("index.org")];
+    assert!(config.validate().is_err(), "pages without explicit mode");
+
+    let mut config = Config::default();
+    config.nav.mode = NavMode::Explicit;
+    assert!(config.validate().is_err(), "explicit mode without pages");
+}
+
+// ---------------------------------------------------------------------------
+// Templates
+// ---------------------------------------------------------------------------
+
+/// The single biggest blocker to general use: without this every site built with this
+/// tool looks identical.
+#[test]
+fn a_user_template_replaces_the_built_in_layout() {
+    let root = tmpdir("template");
+    let src = root.join("src");
+    std::fs::create_dir_all(src.join("templates")).unwrap();
+    write_site(&src);
+    std::fs::write(
+        src.join("templates/base.html"),
+        "<html><body class=\"mine\"><h1>{{ page.title }}</h1>{{ body | safe }}</body></html>",
+    )
+    .unwrap();
+    let out = root.join("out");
+    build(&src, &out);
+
+    let home = page(&out, "index.html");
+    assert!(home.contains("class=\"mine\""), "the user layout is used:\n{home}");
+    assert!(!home.contains("<nav>"), "nothing of the default layout leaks in");
+    assert!(home.contains("Welcome."), "content still renders");
+}
+
+/// Templates are a hashing input (spec §4.1). If editing a layout did not invalidate,
+/// a design change would leave a site half-updated — the worst kind of caching bug,
+/// because it looks like it worked.
+#[test]
+fn editing_a_template_re_renders_every_page_that_uses_it() {
+    let root = tmpdir("templatehash");
+    let src = root.join("src");
+    std::fs::create_dir_all(src.join("templates")).unwrap();
+    write_site(&src);
+    let tpl = src.join("templates/base.html");
+    std::fs::write(&tpl, "<html><body>v1{{ body | safe }}</body></html>").unwrap();
+    let out = root.join("out");
+
+    build(&src, &out);
+    std::fs::write(&tpl, "<html><body>v2{{ body | safe }}</body></html>").unwrap();
+    let report = build(&src, &out);
+
+    assert_eq!(report.rendered.len(), 3, "a layout edit re-renders every page");
+    assert!(page(&out, "index.html").contains("v2"), "and the change lands");
+}
+
+/// A template that does not compile means someone is actively editing their layout.
+/// Falling back to the built-in would look like their edit silently did nothing.
+#[test]
+fn a_broken_template_fails_the_build() {
+    let root = tmpdir("badtemplate");
+    let src = root.join("src");
+    std::fs::create_dir_all(src.join("templates")).unwrap();
+    write_site(&src);
+    std::fs::write(src.join("templates/base.html"), "{% if %}unclosed").unwrap();
+
+    let err = build_site(&src, &root.join("out"), &BuildOptions::default())
+        .expect_err("a broken template must fail the build");
+    assert!(
+        format!("{err:#}").contains("base"),
+        "the error names the template: {err:#}"
+    );
+}
+
+/// Templates get page metadata, including arbitrary `#+KEYWORD:`s this crate has never
+/// heard of — otherwise every new bit of metadata would need a release.
+#[test]
+fn templates_receive_page_metadata_including_unknown_keywords() {
+    let root = tmpdir("meta");
+    let src = root.join("src");
+    std::fs::create_dir_all(src.join("templates")).unwrap();
+    write_site(&src);
+    std::fs::write(
+        src.join("blog/post.org"),
+        "#+TITLE: A Post\n#+DATE: 2024-05-01\n#+FILETAGS: :rust:web:\n#+CUSTOM_THING: hello\n\nBody.\n",
+    )
+    .unwrap();
+    std::fs::write(
+        src.join("templates/base.html"),
+        "<html><body>date={{ page.date }} tags={{ page.tags | join(\",\") }} \
+         custom={{ page.keywords.custom_thing }} url={{ page.url }} \
+         site={{ site.title }}{{ body | safe }}</body></html>",
+    )
+    .unwrap();
+    let out = root.join("out");
+    build(&src, &out);
+
+    let post = page(&out, "blog/post.html");
+    assert!(post.contains("date=2024-05-01"), "#+DATE: reaches the template:\n{post}");
+    assert!(post.contains("tags=rust,web"), "#+FILETAGS: is split:\n{post}");
+    assert!(post.contains("custom=hello"), "unknown keywords pass through:\n{post}");
+    assert!(post.contains("url=blog/post.html"), "the page URL is available:\n{post}");
+}
+
+/// Off by default, because it trades incremental precision for the ability to write
+/// listing pages — and that trade should be a choice.
+#[test]
+fn the_page_list_is_opt_in_and_widens_invalidation() {
+    let root = tmpdir("pagelist");
+    let src = root.join("src");
+    std::fs::create_dir_all(src.join("templates")).unwrap();
+    write_site(&src);
+    std::fs::write(
+        src.join("org-ssg.toml"),
+        "[templates]\nexpose_page_list = true\n",
+    )
+    .unwrap();
+    std::fs::write(
+        src.join("templates/base.html"),
+        "<html><body><ul>{% for p in pages %}<li>{{ p.title }}</li>{% endfor %}</ul>\
+         {{ body | safe }}</body></html>",
+    )
+    .unwrap();
+    let out = root.join("out");
+    build(&src, &out);
+
+    let home = page(&out, "index.html");
+    for title in ["Home", "About", "A Post"] {
+        assert!(home.contains(title), "an index can list {title}:\n{home}");
+    }
+
+    // With every page visible to every template, adding one must re-render them all —
+    // the opposite of the default, and the documented cost of turning this on.
+    std::fs::write(src.join("blog/second.org"), "#+TITLE: Second\n\nBody.\n").unwrap();
+    let report = build(&src, &out);
+    assert_eq!(
+        report.rendered.len(),
+        4,
+        "with the page list exposed, adding a page re-renders the site"
+    );
+}
+
+// ---------------------------------------------------------------------------
+// Output settings
+// ---------------------------------------------------------------------------
+
+/// The default layout renders the page title as `<h1>`, so section headings belong
+/// beneath it — which is also what Emacs does by default.
+#[test]
+fn heading_offset_shifts_content_headings_below_the_page_title() {
+    let root = tmpdir("hoffset");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    std::fs::write(src.join("index.org"), "#+TITLE: T\n\n* Section\n\nBody.\n").unwrap();
+    let out = root.join("out");
+    build(&src, &out);
+    assert!(
+        page(&out, "index.html").contains("<h2 id=\"section\">"),
+        "a level-1 org heading renders as <h2> by default"
+    );
+
+    std::fs::write(src.join("org-ssg.toml"), "[html]\nheading_offset = 0\n").unwrap();
+    let out2 = root.join("out2");
+    build(&src, &out2);
+    assert!(
+        page(&out2, "index.html").contains("<h1 id=\"section\">"),
+        "offset 0 leaves headings where the document put them"
+    );
+}
+
+/// An unknown theme silently produces an empty stylesheet, which looks exactly like
+/// highlighting being broken. Naming the valid options turns a mystery into a typo.
+#[test]
+fn an_unknown_highlight_theme_is_rejected_with_the_available_ones() {
+    let root = tmpdir("theme");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_site(&src);
+    std::fs::write(src.join("org-ssg.toml"), "[highlight]\ntheme = \"nope\"\n").unwrap();
+
+    let err = build_site(&src, &root.join("out"), &BuildOptions::default())
+        .expect_err("unknown theme must fail");
+    let message = format!("{err:#}");
+    assert!(message.contains("nope"), "names the bad theme: {message}");
+    assert!(
+        message.contains("InspiredGitHub"),
+        "lists what is available: {message}"
+    );
+}
+
+// ---------------------------------------------------------------------------
+// Discovery
+// ---------------------------------------------------------------------------
+
+/// `org-ssg build . -o _site` is the obvious thing to type. Without excluding the output
+/// directory, the build copies its own output back into itself, growing `_site/_site/…`
+/// on every run.
+#[test]
+fn an_output_directory_inside_the_source_is_not_swallowed() {
+    let root = tmpdir("nested");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_site(&src);
+    let out = src.join("_site");
+
+    for _ in 0..3 {
+        build(&src, &out);
+    }
+    assert!(!out.join("_site").exists(), "output must not nest inside itself");
+
+    let report = build(&src, &out);
+    assert_eq!(report.pages.len(), 3, "still exactly the source pages");
+    assert!(
+        report.assets.is_empty(),
+        "no output file is mistaken for an asset: {:?}",
+        report.assets
+    );
+}
+
+/// A source directory is very often a git repository. Publishing `.git` alongside the
+/// homepage leaks a project's entire history.
+#[test]
+fn dot_directories_and_build_inputs_are_never_published() {
+    let root = tmpdir("dotfiles");
+    let src = root.join("src");
+    std::fs::create_dir_all(src.join(".git")).unwrap();
+    std::fs::create_dir_all(src.join("templates")).unwrap();
+    write_site(&src);
+    std::fs::write(src.join(".git/config"), "[remote]\nurl = private\n").unwrap();
+    std::fs::write(src.join(".env"), "SECRET=hunter2\n").unwrap();
+    std::fs::write(src.join("org-ssg.toml"), "[site]\ntitle = \"T\"\n").unwrap();
+    std::fs::write(src.join("templates/base.html"), "<html>{{ body | safe }}</html>").unwrap();
+    std::fs::write(src.join("style.css"), "body{}\n").unwrap();
+    let out = root.join("out");
+
+    let report = build(&src, &out);
+    assert!(!out.join(".git").exists(), ".git must never be published");
+    assert!(!out.join(".env").exists(), "dotfiles must never be published");
+    assert!(
+        !out.join("org-ssg.toml").exists(),
+        "the config is a build input, not content"
+    );
+    assert!(
+        !out.join("templates").exists(),
+        "templates are build inputs, not content"
+    );
+    assert_eq!(
+        report.assets,
+        vec![Utf8PathBuf::from("style.css")],
+        "genuine assets still copy through"
+    );
+}
diff --git a/tests/constructs.rs b/tests/constructs.rs
index c99b231..722ffad 100644
--- a/tests/constructs.rs
+++ b/tests/constructs.rs
@@ -160,7 +160,9 @@ fn highlighting_emits_classes_not_inline_styles() {
         "highlighting must not emit inline styles:\n{html}"
     );
     assert!(
-        org_ssg::render::syntax_css().contains(".storage"),
+        org_ssg::render::syntax_css("InspiredGitHub")
+            .expect("a built-in theme")
+            .contains(".storage"),
         "the generated stylesheet must define the emitted classes"
     );
 }
diff --git a/tests/incremental.rs b/tests/incremental.rs
index d512609..0f4573e 100644
--- a/tests/incremental.rs
+++ b/tests/incremental.rs
@@ -85,7 +85,7 @@ fn full_and_incremental_are_byte_identical_and_second_build_renders_nothing() {
         &full,
         &BuildOptions {
             no_cache: true,
-            strict: false,
+            ..Default::default()
         },
     )
     .unwrap();
@@ -329,7 +329,7 @@ fn parallel_builds_are_deterministic_in_output_and_report_order() {
             out,
             &BuildOptions {
                 no_cache: true,
-                strict: false,
+                ..Default::default()
             },
         )
         .unwrap()
diff --git a/tests/oracle.el b/tests/oracle.el
index c694ed7..2a3b0ce 100644
--- a/tests/oracle.el
+++ b/tests/oracle.el
@@ -16,10 +16,9 @@
 ;; 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-toplevel-hlevel is left at its default of 2. org-ssg's own default
+      ;; heading_offset is 1, which produces the same <h2>, so both sides now agree
+      ;; without the oracle being told to.
       org-html-htmlize-output-type nil     ; plain <pre>, not htmlize spans: we highlight
                                            ; with syntect, so comparing code *text* is
                                            ; the meaningful part
diff --git a/tests/snapshots/constructs__blocks_html.snap b/tests/snapshots/constructs__blocks_html.snap
index c0df83e..27e5768 100644
--- a/tests/snapshots/constructs__blocks_html.snap
+++ b/tests/snapshots/constructs__blocks_html.snap
@@ -2,25 +2,25 @@
 source: tests/constructs.rs
 expression: "render_fixture(\"blocks.org\")"
 ---
-<h1 id="quote">Quote</h1>
+<h2 id="quote">Quote</h2>
 <blockquote>
 <p>A quoted paragraph with <em>markup</em>.</p>
 <p>And a second paragraph.</p>
 </blockquote>
-<h1 id="center">Center</h1>
+<h2 id="center">Center</h2>
 <div class="center">
 <p>Centred text.</p>
 </div>
-<h1 id="example">Example</h1>
+<h2 id="example">Example</h2>
 <pre>Verbatim *not bold* text.
   Indentation preserved.</pre>
-<h1 id="export">Export</h1>
+<h2 id="export">Export</h2>
 <aside class="raw">Raw HTML passes through.</aside>
-<h1 id="source">Source</h1>
+<h2 id="source">Source</h2>
 <pre><code class="language-python highlight"><span class="source python"><span class="meta function python"><span class="storage type function python">def</span> <span class="entity name function python"><span class="meta generic-name python">greet</span></span></span><span class="meta function parameters python"><span class="punctuation section parameters begin python">(</span></span><span class="meta function parameters python"><span class="variable parameter python">name</span><span class="punctuation section parameters end python">)</span></span><span class="meta function python"><span class="punctuation section function begin python">:</span></span>
     <span class="keyword control flow return python">return</span> <span class="storage type string python">f</span><span class="meta string interpolated python"><span class="string quoted double python"><span class="punctuation definition string begin python">&quot;</span></span></span><span class="meta string interpolated python"><span class="string quoted double python">hello </span><span class="meta interpolation python"><span class="punctuation section interpolation begin python">{</span><span class="source python embedded"><span class="meta qualified-name python"><span class="meta generic-name python">name</span></span></span></span><span class="meta interpolation python"><span class="punctuation section interpolation end python">}</span></span><span class="string quoted double python"><span class="punctuation definition string end python">&quot;</span></span></span></span></code></pre>
 <pre><code class="language-none">plain block, no language</code></pre>
-<h1 id="nested">Nested</h1>
+<h2 id="nested">Nested</h2>
 <blockquote>
 <p>A quote containing a source block:</p>
 <pre><code class="language-sh highlight"><span class="source shell bash"><span class="meta function-call shell"><span class="support function echo shell">echo</span></span><span class="meta function-call arguments shell"> hi</span></span></code></pre>
diff --git a/tests/snapshots/constructs__headings_html.snap b/tests/snapshots/constructs__headings_html.snap
index 8ef8dd8..ec72a10 100644
--- a/tests/snapshots/constructs__headings_html.snap
+++ b/tests/snapshots/constructs__headings_html.snap
@@ -2,13 +2,13 @@
 source: tests/constructs.rs
 expression: "render_fixture(\"headings.org\")"
 ---
-<h1 id="write-parser"><span class="todo TODO">TODO</span> <span class="priority">[#A]</span> Write the parser <span class="tag">work</span> <span class="tag">rust</span></h1>
+<h2 id="write-parser"><span class="todo TODO">TODO</span> <span class="priority">[#A]</span> Write the parser <span class="tag">work</span> <span class="tag">rust</span></h2>
 <p>A heading carrying a keyword, a priority, tags and a property drawer.</p>
-<h2 id="nested-and-finished"><span class="done DONE">DONE</span> Nested and finished</h2>
+<h3 id="nested-and-finished"><span class="done DONE">DONE</span> Nested and finished</h3>
 <p>Sub-headings nest by star count.</p>
-<h2 id="priority-without-a-keyword"><span class="priority">[#C]</span> Priority without a keyword</h2>
+<h3 id="priority-without-a-keyword"><span class="priority">[#C]</span> Priority without a keyword</h3>
 <p>A priority cookie can stand alone.</p>
-<h1 id="todos-are-not-a-keyword">TODOs are not a keyword</h1>
+<h2 id="todos-are-not-a-keyword">TODOs are not a keyword</h2>
 <p>The word boundary matters: this heading has no TODO keyword.</p>
-<h1><span class="done DONE">DONE</span> </h1>
+<h2><span class="done DONE">DONE</span> </h2>
 <p>A keyword with no title at all.</p>
diff --git a/tests/snapshots/constructs__images_html.snap b/tests/snapshots/constructs__images_html.snap
index 1028d7f..777b3ef 100644
--- a/tests/snapshots/constructs__images_html.snap
+++ b/tests/snapshots/constructs__images_html.snap
@@ -2,13 +2,13 @@
 source: tests/constructs.rs
 expression: "render_fixture(\"images.org\")"
 ---
-<h1 id="bare-image">Bare image</h1>
+<h2 id="bare-image">Bare image</h2>
 <p><img src="diagram.png" alt=""></p>
-<h1 id="captioned-figure">Captioned figure</h1>
+<h2 id="captioned-figure">Captioned figure</h2>
 <figure><img src="pipeline.svg" alt="The pipeline, end to end" width="640" class="diagram"><figcaption>The pipeline, end to end</figcaption></figure>
-<h1 id="caption-with-markup">Caption with markup</h1>
+<h2 id="caption-with-markup">Caption with markup</h2>
 <figure><img src="chart.png" alt="A stylised chart"><figcaption>A <em>stylised</em> chart</figcaption></figure>
-<h1 id="quoted-attribute-values">Quoted attribute values</h1>
+<h2 id="quoted-attribute-values">Quoted attribute values</h2>
 <figure><img src="cat.jpg" alt="a cat, sitting" loading="lazy"></figure>
-<h1 id="image-with-a-description-is-a-link">Image with a description is a link</h1>
+<h2 id="image-with-a-description-is-a-link">Image with a description is a link</h2>
 <p><a href="diagram.png">the diagram</a></p>
diff --git a/tests/snapshots/constructs__lists_html.snap b/tests/snapshots/constructs__lists_html.snap
index c8ad91c..34af61c 100644
--- a/tests/snapshots/constructs__lists_html.snap
+++ b/tests/snapshots/constructs__lists_html.snap
@@ -2,7 +2,7 @@
 source: tests/constructs.rs
 expression: "render_fixture(\"lists.org\")"
 ---
-<h1 id="nesting">Nesting</h1>
+<h2 id="nesting">Nesting</h2>
 <ul>
 <li>outer item<ul>
 <li>inner item<ul>
@@ -14,7 +14,7 @@ expression: "render_fixture(\"lists.org\")"
 </li>
 <li>second outer</li>
 </ul>
-<h1 id="ordered">Ordered</h1>
+<h2 id="ordered">Ordered</h2>
 <ol>
 <li>first</li>
 <li>second<ol>
@@ -24,13 +24,13 @@ expression: "render_fixture(\"lists.org\")"
 </li>
 <li>third</li>
 </ol>
-<h1 id="checkboxes">Checkboxes</h1>
+<h2 id="checkboxes">Checkboxes</h2>
 <ul>
 <li><input type="checkbox" disabled> not done</li>
 <li><input type="checkbox" disabled checked> done</li>
 <li><input type="checkbox" disabled> partially done</li>
 </ul>
-<h1 id="description">Description</h1>
+<h2 id="description">Description</h2>
 <dl>
 <dt>term one</dt>
 <dd>the first definition</dd>
@@ -39,7 +39,7 @@ expression: "render_fixture(\"lists.org\")"
 <dt><em>marked up</em> term</dt>
 <dd>definitions hold inline markup</dd>
 </dl>
-<h1 id="multi-paragraph-items">Multi-paragraph items</h1>
+<h2 id="multi-paragraph-items">Multi-paragraph items</h2>
 <ul>
 <li><p>an item whose body has two paragraphs</p>
 <p>the second paragraph, indented under the bullet</p>
diff --git a/tests/snapshots/constructs__out_of_scope_html.snap b/tests/snapshots/constructs__out_of_scope_html.snap
index d7e50ba..3a9f4ed 100644
--- a/tests/snapshots/constructs__out_of_scope_html.snap
+++ b/tests/snapshots/constructs__out_of_scope_html.snap
@@ -3,9 +3,9 @@ source: tests/constructs.rs
 expression: "render_fixture(\"outofscope.org\")"
 ---
 <p>Every construct here is on the README's explicit OUT list. The contract is not that we handle them — it is that they degrade predictably and never crash the build.</p>
-<h1 id="babel">Babel</h1>
+<h2 id="babel">Babel</h2>
 <pre><code class="language-sh highlight"><span class="source shell bash"><span class="meta function-call shell"><span class="support function echo shell">echo</span></span><span class="meta function-call arguments shell"> <span class="string quoted double shell"><span class="punctuation definition string begin shell">&quot;</span>the block renders; :results is never executed<span class="punctuation definition string end shell">&quot;</span></span></span></span></code></pre>
-<h1 id="table-formulas">Table formulas</h1>
+<h2 id="table-formulas">Table formulas</h2>
 <table>
 <thead>
 <tr><th>item</th><th>cost</th></tr>
@@ -15,14 +15,14 @@ expression: "render_fixture(\"outofscope.org\")"
 <tr><td>b</td><td>2</td></tr>
 </tbody>
 </table>
-<h1 id="latex">LaTeX</h1>
+<h2 id="latex">LaTeX</h2>
 <p>Inline math $x^2 + y^2$ and a display block:</p>
 <p>\begin{equation} E = mc^2 \end{equation}</p>
-<h1 id="macros-and-radio-targets">Macros and radio targets</h1>
+<h2 id="macros-and-radio-targets">Macros and radio targets</h2>
 <p>A macro call {{{author}}} and a &lt;&lt;&lt;radio target&gt;&gt;&gt; stay literal.</p>
-<h1 id="drawers">Drawers</h1>
-<h1 id="verse">Verse</h1>
+<h2 id="drawers">Drawers</h2>
+<h2 id="verse">Verse</h2>
 <pre>An unmodelled block type
 keeps its content verbatim.</pre>
-<h1 id="entities">Entities</h1>
+<h2 id="entities">Entities</h2>
 <p>The full entity set is out of scope, so \alpha stays literal.</p>
diff --git a/tests/snapshots/constructs__timestamps_html.snap b/tests/snapshots/constructs__timestamps_html.snap
index 2b5b1bd..8659e6a 100644
--- a/tests/snapshots/constructs__timestamps_html.snap
+++ b/tests/snapshots/constructs__timestamps_html.snap
@@ -2,13 +2,13 @@
 source: tests/constructs.rs
 expression: "render_fixture(\"timestamps.org\")"
 ---
-<h1 id="single">Single</h1>
+<h2 id="single">Single</h2>
 <p>An active date <time class="timestamp" datetime="2024-01-15">2024-01-15</time> and an inactive one <time class="timestamp inactive" datetime="2024-01-15">2024-01-15</time>.</p>
 <p>With a time: <time class="timestamp" datetime="2024-01-15T10:30">2024-01-15 10:30</time>.</p>
-<h1 id="ranges">Ranges</h1>
+<h2 id="ranges">Ranges</h2>
 <p>A same-day time range <time class="timestamp" datetime="2024-01-15T10:00">2024-01-15 10:00</time>&#8211;<time class="timestamp" datetime="2024-01-15T11:45">11:45</time>.</p>
 <p>A multi-day range <time class="timestamp" datetime="2024-01-15">2024-01-15</time>&#8211;<time class="timestamp" datetime="2024-01-20">2024-01-20</time>.</p>
-<h1 id="ignored-decorations">Ignored decorations</h1>
+<h2 id="ignored-decorations">Ignored decorations</h2>
 <p>A repeater is dropped: <time class="timestamp" datetime="2024-01-15">2024-01-15</time>.</p>
-<h1 id="not-timestamps">Not timestamps</h1>
+<h2 id="not-timestamps">Not timestamps</h2>
 <p>Comparisons like 3 &lt; 4 and [not a stamp] stay literal text.</p>
diff --git a/tests/snapshots/oracle__oracle_blocks.snap b/tests/snapshots/oracle__oracle_blocks.snap
index 55bf857..5209d8b 100644
--- a/tests/snapshots/oracle__oracle_blocks.snap
+++ b/tests/snapshots/oracle__oracle_blocks.snap
@@ -5,9 +5,9 @@ expression: report
 agreement: 51/59 skeleton lines (86.4%)
 (- org-ssg, + emacs)
 
-  <h1>
+  <h2>
   "Quote"
-  </h1>
+  </h2>
   <blockquote>
   <p>
   "A quoted paragraph with"
@@ -22,27 +22,27 @@ agreement: 51/59 skeleton lines (86.4%)
   "And a second paragraph."
   </p>
   </blockquote>
-  <h1>
+  <h2>
   "Center"
-  </h1>
+  </h2>
   <p>
   "Centred text."
   </p>
-  <h1>
+  <h2>
   "Example"
-  </h1>
+  </h2>
   <pre>
   "Verbatim *not bold* text. Indentation preserved."
   </pre>
-  <h1>
+  <h2>
   "Export"
-  </h1>
+  </h2>
   <aside>
   "Raw HTML passes through."
   </aside>
-  <h1>
+  <h2>
   "Source"
-  </h1>
+  </h2>
   <pre>
 - <code>
   "def greet(name): return f\"hello {name}\""
@@ -53,9 +53,9 @@ agreement: 51/59 skeleton lines (86.4%)
   "plain block, no language"
 - </code>
   </pre>
-  <h1>
+  <h2>
   "Nested"
-  </h1>
+  </h2>
   <blockquote>
   <p>
   "A quote containing a source block:"
diff --git a/tests/snapshots/oracle__oracle_core.snap b/tests/snapshots/oracle__oracle_core.snap
index f1bbb2d..028e7b9 100644
--- a/tests/snapshots/oracle__oracle_core.snap
+++ b/tests/snapshots/oracle__oracle_core.snap
@@ -16,9 +16,9 @@ agreement: 45/54 skeleton lines (83.3%)
   </code>
   "."
   </p>
-  <h1>
+  <h2>
   "Ordered and checked"
-  </h1>
+  </h2>
   <ol>
   <li>
   "first item"
@@ -49,9 +49,9 @@ agreement: 45/54 skeleton lines (83.3%)
   </li>
 - </ul>
 + </ol>
-  <h1>
+  <h2>
   "Links and code"
-  </h1>
+  </h2>
   <p>
   "An external"
   <a href="https://example.org">
diff --git a/tests/snapshots/oracle__oracle_elements.snap b/tests/snapshots/oracle__oracle_elements.snap
index f046fea..63a0ee1 100644
--- a/tests/snapshots/oracle__oracle_elements.snap
+++ b/tests/snapshots/oracle__oracle_elements.snap
@@ -5,9 +5,9 @@ expression: report
 agreement: 64/82 skeleton lines (78.0%)
 (- org-ssg, + emacs)
 
-  <h1>
+  <h2>
   "Code and tables"
-  </h1>
+  </h2>
   <pre>
 - <code>
   "fn main() { println!(\"hello\"); }"
@@ -47,9 +47,9 @@ agreement: 64/82 skeleton lines (78.0%)
   </tr>
   </tbody>
   </table>
-  <h1>
+  <h2>
   "Links and footnotes"
-  </h1>
+  </h2>
   <p>
   "An external link:"
   <a href="https://example.com">
@@ -71,9 +71,9 @@ agreement: 64/82 skeleton lines (78.0%)
   </a>
   </sup>
   </p>
-  <h1>
+  <h2>
   "Blocks"
-  </h1>
+  </h2>
   <blockquote>
   <p>
   "A quoted paragraph."
diff --git a/tests/snapshots/oracle__oracle_headings.snap b/tests/snapshots/oracle__oracle_headings.snap
index f1a9f22..e9f2c7b 100644
--- a/tests/snapshots/oracle__oracle_headings.snap
+++ b/tests/snapshots/oracle__oracle_headings.snap
@@ -5,35 +5,35 @@ expression: report
 agreement: 28/30 skeleton lines (93.3%)
 (- org-ssg, + emacs)
 
-  <h1>
+  <h2>
 - "TODO [#A] Write the parser work rust"
 + "TODO Write the parser work rust"
-  </h1>
+  </h2>
   <p>
   "A heading carrying a keyword, a priority, tags and a property drawer."
   </p>
-  <h2>
+  <h3>
   "DONE Nested and finished"
-  </h2>
+  </h3>
   <p>
   "Sub-headings nest by star count."
   </p>
-  <h2>
+  <h3>
 - "[#C] Priority without a keyword"
 + "Priority without a keyword"
-  </h2>
+  </h3>
   <p>
   "A priority cookie can stand alone."
   </p>
-  <h1>
+  <h2>
   "TODOs are not a keyword"
-  </h1>
+  </h2>
   <p>
   "The word boundary matters: this heading has no TODO keyword."
   </p>
-  <h1>
+  <h2>
   "DONE"
-  </h1>
+  </h2>
   <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
index 0f92af3..9cbe7b4 100644
--- a/tests/snapshots/oracle__oracle_images.snap
+++ b/tests/snapshots/oracle__oracle_images.snap
@@ -5,15 +5,15 @@ expression: report
 agreement: 28/42 skeleton lines (66.7%)
 (- org-ssg, + emacs)
 
-  <h1>
+  <h2>
   "Bare image"
-  </h1>
+  </h2>
   <p>
   <img src="diagram.png">
   </p>
-  <h1>
+  <h2>
   "Captioned figure"
-  </h1>
+  </h2>
 - <figure>
 + <p>
   <img src="pipeline.svg">
@@ -25,9 +25,9 @@ agreement: 28/42 skeleton lines (66.7%)
 + <p>
 + "Figure 1: The pipeline, end to end"
 + </p>
-  <h1>
+  <h2>
   "Caption with markup"
-  </h1>
+  </h2>
 - <figure>
 + <p>
   <img src="chart.png">
@@ -45,17 +45,17 @@ agreement: 28/42 skeleton lines (66.7%)
 - </figcaption>
 - </figure>
 + </p>
-  <h1>
+  <h2>
   "Quoted attribute values"
-  </h1>
+  </h2>
 - <figure>
 + <p>
   <img src="cat.jpg">
 - </figure>
 + </p>
-  <h1>
+  <h2>
   "Image with a description is a link"
-  </h1>
+  </h2>
   <p>
   <a href="diagram.png">
   "the diagram"
diff --git a/tests/snapshots/oracle__oracle_lists.snap b/tests/snapshots/oracle__oracle_lists.snap
index 7b54a34..b88b324 100644
--- a/tests/snapshots/oracle__oracle_lists.snap
+++ b/tests/snapshots/oracle__oracle_lists.snap
@@ -5,9 +5,9 @@ expression: report
 agreement: 100/111 skeleton lines (90.1%)
 (- org-ssg, + emacs)
 
-  <h1>
+  <h2>
   "Nesting"
-  </h1>
+  </h2>
   <ul>
   <li>
   "outer item"
@@ -29,9 +29,9 @@ agreement: 100/111 skeleton lines (90.1%)
   "second outer"
   </li>
   </ul>
-  <h1>
+  <h2>
   "Ordered"
-  </h1>
+  </h2>
   <ol>
   <li>
   "first"
@@ -51,9 +51,9 @@ agreement: 100/111 skeleton lines (90.1%)
   "third"
   </li>
   </ol>
-  <h1>
+  <h2>
   "Checkboxes"
-  </h1>
+  </h2>
   <ul>
   <li>
 - <input>
@@ -77,9 +77,9 @@ agreement: 100/111 skeleton lines (90.1%)
   "partially done"
   </li>
   </ul>
-  <h1>
+  <h2>
   "Description"
-  </h1>
+  </h2>
   <dl>
   <dt>
   "term one"
@@ -105,9 +105,9 @@ agreement: 100/111 skeleton lines (90.1%)
   "definitions hold inline markup"
   </dd>
   </dl>
-  <h1>
+  <h2>
   "Multi-paragraph items"
-  </h1>
+  </h2>
   <ul>
   <li>
   <p>
diff --git a/tests/snapshots/oracle__oracle_minimal.snap b/tests/snapshots/oracle__oracle_minimal.snap
index 48e4731..8e8f701 100644
--- a/tests/snapshots/oracle__oracle_minimal.snap
+++ b/tests/snapshots/oracle__oracle_minimal.snap
@@ -8,9 +8,9 @@ agreement: 38/42 skeleton lines (90.5%)
   <p>
   "A single paragraph of preamble text before any heading."
   </p>
-  <h1>
+  <h2>
   "First Heading"
-  </h1>
+  </h2>
   <p>
   "Some body text with"
 - <strong>
@@ -30,9 +30,9 @@ agreement: 38/42 skeleton lines (90.5%)
   </code>
   "."
   </p>
-  <h2>
+  <h3>
   "A Subheading tag1 tag2"
-  </h2>
+  </h3>
   <ul>
   <li>
   "an unordered item"
@@ -41,9 +41,9 @@ agreement: 38/42 skeleton lines (90.5%)
   "another with a checkbox [ ]"
   </li>
   </ul>
-  <h1>
+  <h2>
   "Second Heading"
-  </h1>
+  </h2>
   <p>
   "See"
   <a href="#first">
diff --git a/tests/snapshots/oracle__oracle_timestamps.snap b/tests/snapshots/oracle__oracle_timestamps.snap
index 41393bc..503b745 100644
--- a/tests/snapshots/oracle__oracle_timestamps.snap
+++ b/tests/snapshots/oracle__oracle_timestamps.snap
@@ -5,9 +5,9 @@ expression: report
 agreement: 25/62 skeleton lines (40.3%)
 (- org-ssg, + emacs)
 
-  <h1>
+  <h2>
   "Single"
-  </h1>
+  </h2>
   <p>
 - "An active date"
 - <time>
@@ -28,9 +28,9 @@ agreement: 25/62 skeleton lines (40.3%)
 - "."
 + "With a time: <2024-01-15 Mon 10:30>."
   </p>
-  <h1>
+  <h2>
   "Ranges"
-  </h1>
+  </h2>
   <p>
 - "A same-day time range"
 - <time>
@@ -55,9 +55,9 @@ agreement: 25/62 skeleton lines (40.3%)
 - "."
 + "A multi-day range <2024-01-15 Mon>–<2024-01-20 Sat>."
   </p>
-  <h1>
+  <h2>
   "Ignored decorations"
-  </h1>
+  </h2>
   <p>
 - "A repeater is dropped:"
 - <time>
@@ -66,9 +66,9 @@ agreement: 25/62 skeleton lines (40.3%)
 - "."
 + "A repeater is dropped: <2024-01-15 Mon +1w>."
   </p>
-  <h1>
+  <h2>
   "Not timestamps"
-  </h1>
+  </h2>
   <p>
   "Comparisons like 3 < 4 and [not a stamp] stay literal text."
   </p>
diff --git a/tests/snapshots/pipeline__core_html.snap b/tests/snapshots/pipeline__core_html.snap
index d5eac26..5e291e4 100644
--- a/tests/snapshots/pipeline__core_html.snap
+++ b/tests/snapshots/pipeline__core_html.snap
@@ -3,7 +3,7 @@ source: tests/pipeline.rs
 expression: "render_fixture(\"core.org\")"
 ---
 <p>Intro paragraph with a bare URL <a href="https://example.com">https://example.com</a> and some <code>inline code</code>.</p>
-<h1 id="ordered-and-checked">Ordered and checked</h1>
+<h2 id="ordered-and-checked">Ordered and checked</h2>
 <ol>
 <li>first item</li>
 <li>second item with <em>emphasis</em></li>
@@ -12,7 +12,7 @@ expression: "render_fixture(\"core.org\")"
 <li><input type="checkbox" disabled> todo item</li>
 <li><input type="checkbox" disabled checked> done item</li>
 </ul>
-<h1 id="links-and-code">Links and code</h1>
+<h2 id="links-and-code">Links and code</h2>
 <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 class="language-rust highlight"><span class="source rust"><span class="meta function rust"><span class="meta function rust"><span class="storage type function rust">fn</span> </span><span class="entity name function rust">main</span></span><span class="meta function rust"><span class="meta function parameters rust"><span class="punctuation section parameters begin rust">(</span></span><span class="meta function rust"><span class="meta function parameters rust"><span class="punctuation section parameters end rust">)</span></span></span></span><span class="meta function rust"> </span><span class="meta function rust"><span class="meta block rust"><span class="punctuation section block begin rust">{</span>
     <span class="support macro rust">println!</span><span class="meta group rust"><span class="punctuation section group begin rust">(</span></span><span class="meta group rust"><span class="string quoted double rust"><span class="punctuation definition string begin rust">&quot;</span>hello<span class="punctuation definition string end rust">&quot;</span></span></span><span class="meta group rust"><span class="punctuation section group end rust">)</span></span><span class="punctuation terminator rust">;</span>
diff --git a/tests/snapshots/pipeline__minimal_html.snap b/tests/snapshots/pipeline__minimal_html.snap
index 8a81d72..f0b9e27 100644
--- a/tests/snapshots/pipeline__minimal_html.snap
+++ b/tests/snapshots/pipeline__minimal_html.snap
@@ -3,12 +3,12 @@ source: tests/pipeline.rs
 expression: "render_fixture(\"minimal.org\")"
 ---
 <p>A single paragraph of preamble text before any heading.</p>
-<h1 id="first">First Heading</h1>
+<h2 id="first">First Heading</h2>
 <p>Some body text with <strong>bold</strong>, <em>italic</em>, and <code class="verbatim">verbatim</code>.</p>
-<h2 id="a-subheading">A Subheading <span class="tag">tag1</span> <span class="tag">tag2</span></h2>
+<h3 id="a-subheading">A Subheading <span class="tag">tag1</span> <span class="tag">tag2</span></h3>
 <ul>
 <li>an unordered item</li>
 <li>another with a checkbox [ ]</li>
 </ul>
-<h1 id="second-heading">Second Heading</h1>
+<h2 id="second-heading">Second Heading</h2>
 <p>See <a href="#first">the first heading</a>.</p>
diff --git a/tests/snapshots/site__site_guide_html.snap b/tests/snapshots/site__site_guide_html.snap
index 626c16f..3f65165 100644
--- a/tests/snapshots/site__site_guide_html.snap
+++ b/tests/snapshots/site__site_guide_html.snap
@@ -6,19 +6,24 @@ expression: "page(&pages, \"guide.org\").html"
 <html lang="en">
 <head>
 <meta charset="utf-8">
-<title>Guide</title>
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Guide &middot; org-ssg site</title>
 <link rel="stylesheet" href="syntax.css">
 </head>
 <body>
+<header>
+<a class="site-title" href="index.html">org-ssg site</a>
 <nav>
 <a href="about.html">About</a>
 <a href="#">Guide</a>
 <a href="index.html">Home</a>
 </nav>
+</header>
 <main>
-<h1 id="setup">Setup</h1>
+<h1>Guide</h1>
+<h2 id="setup">Setup</h2>
 <p>Install the steps in order.<sup class="footnote-ref"><a id="fnr-1" href="#fn-1">1</a></sup> Then return <a href="index.html">home</a>.</p>
-<h1 id="data">Data</h1>
+<h2 id="data">Data</h2>
 <table>
 <thead>
 <tr><th>Name</th><th>Score</th></tr>
diff --git a/tests/snapshots/site__site_index_html.snap b/tests/snapshots/site__site_index_html.snap
index b398dfa..fd2459e 100644
--- a/tests/snapshots/site__site_index_html.snap
+++ b/tests/snapshots/site__site_index_html.snap
@@ -6,19 +6,24 @@ expression: "page(&pages, \"index.org\").html"
 <html lang="en">
 <head>
 <meta charset="utf-8">
-<title>Home</title>
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Home &middot; org-ssg site</title>
 <link rel="stylesheet" href="syntax.css">
 </head>
 <body>
+<header>
+<a class="site-title" href="index.html">org-ssg site</a>
 <nav>
 <a href="about.html">About</a>
 <a href="guide.html">Guide</a>
 <a href="#">Home</a>
 </nav>
+</header>
 <main>
+<h1>Home</h1>
 <p>Welcome. See the <a href="guide.html">guide</a> and jump straight to its <a href="guide.html#setup">setup section</a> across files.</p>
 <p>Also see <a href="#overview">Overview</a> further down this page.</p>
-<h1 id="overview">Overview</h1>
+<h2 id="overview">Overview</h2>
 <p>The overview lives on the home page.</p>
 </main>
 </body>