krz/orgo

Lightning fast org-mode static site generator.

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

ecaccb90aa064219247a4cfc3261c0e6ff66e86a

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T05:54:49Z

v0.13: table of contents, section numbers, and #+OPTIONS:

Found by diffing our output's *content* against the incumbent rather than its URLs: the
live site emits a table of contents and numbered headings on ~175 pages, and 4 of the
179 source files turn the TOC off with #+OPTIONS: toc:nil — direct evidence it is a
used, opt-out feature on the rest. We emitted neither.

page.toc is the heading tree, {title, anchor, level, children}, nested rather than flat
because a table of contents is a tree and rebuilding one from a list of levels inside a
template is what Jinja is worst at. 155 of the corpus's 182 pages now render one, with
the same anchors the live site uses.

Anchors come from a new util::heading_anchor, which replaced two hand-copied versions of
the same rule in render.rs and index.rs. The TOC would have been a third, and any drift
between them is a link that silently goes nowhere.

Org's per-file export switches are honoured, so a document turns a feature off for
itself the way its author already knows:
- #+OPTIONS: toc:nil empties page.toc for that page; [html] toc = true is the site
  default, on because it is data and whether it appears is the template's business.
- #+OPTIONS: num:t numbers headings, with Emacs' own section-number-N classes so output
  stays diffable against the oracle. Numbering resets at each level, and a document that
  skips a level starts the missing ones at 1 rather than being treated as malformed.

Section numbers default to OFF, which differs from Emacs deliberately.
org-export-with-section-numbers is on there, so an org-published site inherits numbered
headings whether or not anyone chose them. Most sites do not want them, so the default
is the taste rather than the inheritance, and the switch gets Emacs' behaviour back.

Render options are now resolved per page rather than once per build, since #+OPTIONS: is
a per-document override. Its inputs are the document's own keywords, which are already in
its content hash, so the cache stays correct without a new hash class.

All 182 incumbent URLs still reproduced.
 Cargo.lock                                 |   2 +-
 Cargo.toml                                 |   2 +-
 README.md                                  |  37 +++++-
 src/config.rs                              |  19 ++-
 src/index.rs                               |   8 +-
 src/main.rs                                |   1 +
 src/render.rs                              |  41 +++++--
 src/site.rs                                |  32 +++--
 src/template.rs                            |  21 +++-
 src/util.rs                                |  77 +++++++++++-
 tests/config.rs                            | 186 +++++++++++++++++++++++++++++
 tests/snapshots/site__site_guide_html.snap |   7 ++
 12 files changed, 401 insertions(+), 32 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index cc5df9f..f82aab3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -657,7 +657,7 @@ dependencies = [
 
 [[package]]
 name = "org-ssg"
-version = "0.12.0"
+version = "0.13.0"
 dependencies = [
  "anyhow",
  "blake3",
diff --git a/Cargo.toml b/Cargo.toml
index 30f8023..e437d55 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "org-ssg"
-version = "0.12.0"
+version = "0.13.0"
 edition = "2021"
 description = "Org-mode static site generator that renders the org element tree straight to HTML"
 license = "MIT"
diff --git a/README.md b/README.md
index 5ec98a5..5b8ba44 100644
--- a/README.md
+++ b/README.md
@@ -72,7 +72,7 @@ receive:
 | Variable | What it is |
 |---|---|
 | `body` | the rendered page HTML — use `{{ body \| safe }}` |
-| `page` | `.title`, `.url`, `.source`, `.date`, `.date_iso`, `.tags`, `.excerpt`, `.word_count`, `.reading_time`, `.keywords` |
+| `page` | `.title`, `.url`, `.source`, `.date`, `.date_iso`, `.tags`, `.excerpt`, `.word_count`, `.reading_time`, `.toc`, `.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 |
@@ -231,6 +231,38 @@ The default layout also emits `<link rel="canonical">` when a base URL is set.
 Listing pages are cached on the entries they list, so adding a post re-renders that
 section's index and nothing else.
 
+### Table of contents and `#+OPTIONS:`
+
+`page.toc` is the page's headings as a **tree** — `{title, anchor, level, children}` —
+because a table of contents is one, and rebuilding a tree from a flat list of levels
+inside a template is what Jinja is worst at. Its anchors come from the same function the
+renderer uses to emit heading `id`s, so a TOC link cannot drift from the heading it
+points at.
+
+```jinja
+{% macro toc_list(entries) %}
+<ul>{% for e in entries %}
+  <li><a href="#{{ e.anchor }}">{{ e.title }}</a>
+  {%- if e.children %}{{ toc_list(e.children) }}{% endif %}</li>
+{% endfor %}</ul>
+{% endmacro %}
+{% if page.toc %}{{ toc_list(page.toc) }}{% endif %}
+```
+
+Org's own per-file export switches are honoured, so a document can turn a feature off for
+itself the way its author already knows:
+
+| Switch | Effect | Site default |
+|---|---|---|
+| `#+OPTIONS: toc:nil` | empties `page.toc` for this page | `[html] toc = true` |
+| `#+OPTIONS: num:t` | numbers headings `1.`, `1.1.`, … | `[html] section_numbers = false` |
+
+**Section numbers default to off, which differs from Emacs on purpose.**
+`org-export-with-section-numbers` is on there, so an org-published site inherits numbered
+headings whether or not anyone chose them. Most sites do not want them; `num:t` or
+`section_numbers = true` gets Emacs' behaviour back, with Emacs' own
+`section-number-N` classes so the output stays diffable against the oracle.
+
 ### Excerpts and drafts
 
 `page.excerpt` is a page's `#+DESCRIPTION:` when it sets one and its first paragraph
@@ -323,6 +355,7 @@ all-of-org. Phase 0 checked this line against a real 179-file corpus and found i
 | **12** | **`base_url`: `absolute`/`rfc822` filters, a valid RSS feed in the scaffold, canonical links** | **done** |
 | **13** | **`watch` on OS filesystem events, debounced, with the feedback loop closed** | **done** |
 | **14** | **Authoring: excerpts, word count, reading time, `truncate`, and draft pages** | **done** |
+| **15** | **Table of contents, section numbers, and org's `#+OPTIONS:` per-file switches** | **done** |
 
 ### v0.2 in / out
 
@@ -610,7 +643,7 @@ PARSE/RESOLVE/RENDER), `notify` (filesystem events for `watch`), `toml` (config)
 
 ```
 cargo build
-cargo test                                                # 135 tests
+cargo test                                                # 142 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)
diff --git a/src/config.rs b/src/config.rs
index ce41598..b606dc4 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -153,11 +153,28 @@ pub struct HtmlOutput {
     /// 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,
+    /// Make each page's table of contents available to templates as `page.toc`.
+    ///
+    /// On by default: it is *data*, and whether it appears is the template's business.
+    /// A document turns it off for itself with org's own `#+OPTIONS: toc:nil`, which is
+    /// how ~2% of the reference corpus does it.
+    pub toc: bool,
+    /// Number headings, `1.`, `1.1.`, and so on.
+    ///
+    /// Off by default, which differs from Emacs — `org-export-with-section-numbers` is
+    /// on there, and the reference site inherits numbered headings from it. Most sites
+    /// do not want them, so the default is the taste rather than the inheritance;
+    /// `#+OPTIONS: num:t` or `section_numbers = true` gets Emacs' behaviour back.
+    pub section_numbers: bool,
 }
 
 impl Default for HtmlOutput {
     fn default() -> Self {
-        HtmlOutput { heading_offset: 1 }
+        HtmlOutput {
+            heading_offset: 1,
+            toc: true,
+            section_numbers: false,
+        }
     }
 }
 
diff --git a/src/index.rs b/src/index.rs
index 26dac10..728699f 100644
--- a/src/index.rs
+++ b/src/index.rs
@@ -7,7 +7,7 @@ use camino::{Utf8Path, Utf8PathBuf};
 use serde::{Deserialize, Serialize};
 
 use crate::model::{Document, Section};
-use crate::util::{output_path, plain_text, slugify};
+use crate::util::{heading_anchor, output_path, plain_text};
 
 /// Identity of a link target. A target is owned by exactly one file (spec §4.3).
 ///
@@ -119,11 +119,7 @@ fn index_section(
     targets: &mut HashMap<TargetId, TargetLocation>,
 ) {
     if let Some(h) = &section.heading {
-        let anchor = h
-            .custom_id
-            .clone()
-            .or_else(|| h.id.clone())
-            .unwrap_or_else(|| slugify(&plain_text(&h.title)));
+        let anchor = heading_anchor(h);
         let mut record = |id: TargetId, anchor: Option<String>| {
             targets.insert(
                 id,
diff --git a/src/main.rs b/src/main.rs
index ff4fcf1..459b76d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -280,6 +280,7 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
         word_count: 0,
         reading_time: 0,
         keywords: Default::default(),
+        toc: org_ssg::util::table_of_contents(&resolved.document.root),
     };
     let mut ctx = RenderContext::new(&site, &page_ctx, &[], SYNTAX_STYLESHEET, "");
     ctx.body = &fragment;
diff --git a/src/render.rs b/src/render.rs
index b6d72c0..b36c681 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -26,7 +26,7 @@ use syntect::util::LinesWithEndings;
 use crate::model::{Checkbox, Element, Link, LinkTarget, ListKind, Object, Section, TableRow};
 use crate::parser::is_image_target;
 use crate::resolve::ResolvedDoc;
-use crate::util::{plain_text, slugify};
+use crate::util::{heading_anchor, plain_text, slugify};
 
 /// A rendered HTML fragment (content only — no page chrome; spec §2.4).
 #[derive(Debug, Clone)]
@@ -137,6 +137,8 @@ struct Renderer<'a> {
     inline_defs: HashMap<String, Vec<Object>>,
     /// Reference keys in order of first appearance — drives numbering and note order.
     order: Vec<String>,
+    /// Counter per heading depth, for section numbers.
+    counters: Vec<usize>,
 }
 
 /// Options affecting how the tree becomes HTML. Presentation choices that belong to the
@@ -147,12 +149,17 @@ pub struct RenderOptions {
     /// beneath a page title supplied by the layout. See
     /// [`HtmlOutput::heading_offset`](crate::config::HtmlOutput::heading_offset).
     pub heading_offset: u8,
+    /// Prefix headings with `1.`, `1.1.`, … See
+    /// [`HtmlOutput::section_numbers`](crate::config::HtmlOutput::section_numbers).
+    pub section_numbers: bool,
 }
 
 impl Default for RenderOptions {
     fn default() -> Self {
+        let html = crate::config::HtmlOutput::default();
         RenderOptions {
-            heading_offset: crate::config::HtmlOutput::default().heading_offset,
+            heading_offset: html.heading_offset,
+            section_numbers: html.section_numbers,
         }
     }
 }
@@ -170,6 +177,7 @@ pub fn render_with(doc: &ResolvedDoc, highlighter: &dyn Highlighter, opts: &Rend
         block_defs: HashMap::new(),
         inline_defs: HashMap::new(),
         order: Vec::new(),
+        counters: Vec::new(),
     };
     r.collect_defs(&doc.document.root);
     let mut out = String::new();
@@ -190,11 +198,7 @@ impl Renderer<'_> {
     fn render_section(&mut self, section: &Section, out: &mut String) {
         if let Some(h) = &section.heading {
             let level = h.level.saturating_add(self.opts.heading_offset).clamp(1, 6);
-            let anchor = h
-                .custom_id
-                .clone()
-                .or_else(|| h.id.clone())
-                .unwrap_or_else(|| slugify(&plain_text(&h.title)));
+            let anchor = heading_anchor(h);
             // A heading with no title text has no meaningful slug; emit no `id` at all
             // rather than a run of duplicate empty ones.
             if anchor.is_empty() {
@@ -202,6 +206,13 @@ impl Renderer<'_> {
             } else {
                 out.push_str(&format!("<h{} id=\"{}\">", level, escape_attr(&anchor)));
             }
+            if self.opts.section_numbers {
+                let number = self.next_section_number(h.level);
+                out.push_str(&format!(
+                    "<span class=\"section-number-{}\">{number}</span> ",
+                    level
+                ));
+            }
             // Keyword/priority markup mirrors Emacs' own HTML export classes, so output
             // stays diffable against an `emacs --batch` oracle.
             if let Some(todo) = &h.todo {
@@ -232,6 +243,22 @@ impl Renderer<'_> {
         }
     }
 
+    /// The next section number at `level`, e.g. `1.`, `1.1.`, `2.`.
+    ///
+    /// Deeper levels reset when a shallower one advances, and a document that skips a
+    /// level (a `***` under a `*`) simply starts the missing levels at 1 rather than
+    /// being treated as malformed.
+    fn next_section_number(&mut self, level: u8) -> String {
+        let depth = usize::from(level).max(1);
+        self.counters.truncate(depth);
+        while self.counters.len() < depth {
+            self.counters.push(0);
+        }
+        self.counters[depth - 1] += 1;
+        let parts: Vec<String> = self.counters.iter().map(usize::to_string).collect();
+        format!("{}.", parts.join("."))
+    }
+
     fn render_element(&mut self, element: &Element, out: &mut String) {
         match element {
             Element::Paragraph(objs) => {
diff --git a/src/site.rs b/src/site.rs
index 5b9fdef..1017f54 100644
--- a/src/site.rs
+++ b/src/site.rs
@@ -33,8 +33,8 @@ use crate::template::{
     Templater,
 };
 use crate::util::{
-    document_text, first_paragraph, is_draft, iso_date, output_path, output_url, relative_root,
-    slugify,
+    document_text, first_paragraph, is_draft, iso_date, option_enabled, output_path, output_url,
+    relative_root, slugify, table_of_contents,
 };
 
 /// Reading speed for [`PageContext::reading_time`]. 200 wpm is the conventional figure
@@ -481,6 +481,7 @@ fn listing_context(listing: &Listing) -> PageContext {
         word_count: 0,
         reading_time: 0,
         keywords: Default::default(),
+        toc: Vec::new(),
     }
 }
 
@@ -613,7 +614,7 @@ fn prepare_pages(
             .collect();
 
             PagePrep {
-                context: page_context(doc, &output),
+                context: page_context(doc, &output, config),
                 source: doc.source_path.clone(),
                 output,
                 title: page_title(doc),
@@ -641,7 +642,6 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
     let templater = Templater::load(Some(&src.join(&config.templates.dir)), &config.site.base_url)?;
     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();
@@ -649,7 +649,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, &site, listing.as_deref(), &render_opts, p)?;
+        let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &config, p)?;
         pages.push(BuiltPage {
             source: p.source.clone(),
             output: p.output.clone(),
@@ -660,9 +660,12 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
     Ok((pages, broken))
 }
 
-fn render_options(config: &Config) -> RenderOptions {
+/// Render options for one page: the site's settings, with the document's own
+/// `#+OPTIONS:` switches applied on top.
+fn render_options(config: &Config, keywords: &crate::model::Keywords) -> RenderOptions {
     RenderOptions {
         heading_offset: config.html.heading_offset,
+        section_numbers: option_enabled(keywords, "num", config.html.section_numbers),
     }
 }
 
@@ -691,10 +694,13 @@ fn render_page(
     highlighter: &SyntectHighlighter,
     site: &SiteContext,
     pages: Option<&[PageContext]>,
-    render_opts: &RenderOptions,
+    config: &Config,
     p: &PagePrep,
 ) -> Result<String> {
-    let Html(fragment) = render_with(&p.resolved, highlighter, render_opts);
+    // Options are resolved per page, because `#+OPTIONS:` is a per-document override of
+    // the site setting.
+    let opts = render_options(config, &p.resolved.document.keywords);
+    let Html(fragment) = render_with(&p.resolved, highlighter, &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}");
@@ -833,7 +839,6 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
     let highlighter = SyntectHighlighter::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
@@ -855,7 +860,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, &site, listing.as_deref(), &render_opts, p)?;
+            let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &cfg, p)?;
             fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?;
             Ok(true)
         })
@@ -1163,7 +1168,7 @@ fn is_top_level(output: &Utf8Path) -> bool {
 /// 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 {
+fn page_context(doc: &Document, output: &Utf8Path, config: &Config) -> PageContext {
     let words = document_text(&doc.root).split_whitespace().count();
     let keyword = |name: &str| {
         doc.keywords
@@ -1184,6 +1189,11 @@ fn page_context(doc: &Document, output: &Utf8Path) -> PageContext {
             .unwrap_or_default(),
         word_count: words,
         reading_time: words.div_ceil(WORDS_PER_MINUTE).max(usize::from(words > 0)),
+        toc: if option_enabled(&doc.keywords, "toc", config.html.toc) {
+            table_of_contents(&doc.root)
+        } else {
+            Vec::new()
+        },
         tags: keyword("FILETAGS")
             .unwrap_or_default()
             .split(':')
diff --git a/src/template.rs b/src/template.rs
index 15258ce..672abd3 100644
--- a/src/template.rs
+++ b/src/template.rs
@@ -63,12 +63,15 @@ pub struct PageContext {
     /// 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 page's headings as a tree. Empty when the page has none, when the site turns
+    /// `html.toc` off, or when the document opts out with `#+OPTIONS: toc:nil`.
+    pub toc: Vec<crate::util::TocEntry>,
 }
 
 /// 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>
+const BASE_TEMPLATE: &str = r##"<!DOCTYPE html>
 <html lang="{{ site.language }}">
 <head>
 <meta charset="utf-8">
@@ -100,10 +103,24 @@ const BASE_TEMPLATE: &str = r#"<!DOCTYPE html>
 {%- if page.date %}
 <p class="page-date">{{ page.date }}</p>
 {%- endif %}
+{%- if page.toc | length > 1 %}
+{%- macro toc_list(entries) %}
+<ul>
+{%- for entry in entries %}
+<li><a href="#{{ entry.anchor }}">{{ entry.title }}</a>
+{%- if entry.children %}{{ toc_list(entry.children) }}{% endif %}</li>
+{%- endfor %}
+</ul>
+{%- endmacro %}
+<nav class="toc" aria-label="Table of contents">
+<h2>Contents</h2>
+{{- toc_list(page.toc) }}
+</nav>
+{%- endif %}
 {{ body | safe }}</main>
 </body>
 </html>
-"#;
+"##;
 
 /// The name a template must have to serve as the page layout.
 pub const BASE_TEMPLATE_NAME: &str = "base.html";
diff --git a/src/util.rs b/src/util.rs
index 9ad8cf4..e779932 100644
--- a/src/util.rs
+++ b/src/util.rs
@@ -4,7 +4,7 @@
 
 use camino::{Utf8Path, Utf8PathBuf};
 
-use crate::model::{Element, Keywords, Object, Section, TableRow};
+use crate::model::{Element, Heading, Keywords, Object, Section, TableRow};
 
 /// The output path for a document, relative to the site root.
 ///
@@ -76,6 +76,81 @@ fn plain_text_into(objs: &[Object], out: &mut String) {
     }
 }
 
+/// The `id` a heading is emitted with, and therefore the fragment anything linking to it
+/// must use.
+///
+/// One function because there are three callers — the renderer emitting the `id`, INDEX
+/// recording link targets, and the table of contents linking into the page. Any drift
+/// between them is a link that silently goes nowhere.
+pub fn heading_anchor(heading: &Heading) -> String {
+    heading
+        .custom_id
+        .clone()
+        .or_else(|| heading.id.clone())
+        .unwrap_or_else(|| slugify(&plain_text(&heading.title)))
+}
+
+/// One entry in a page's table of contents.
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct TocEntry {
+    pub title: String,
+    /// Fragment identifier, without the `#`.
+    pub anchor: String,
+    /// Org heading level, 1-based, before any `heading_offset` is applied.
+    pub level: u8,
+    pub children: Vec<TocEntry>,
+}
+
+/// A page's table of contents, mirroring its heading tree.
+///
+/// Nested rather than flat: a table of contents *is* a tree, and reconstructing one from
+/// a flat list of levels inside a template is the kind of thing Jinja is bad at.
+pub fn table_of_contents(root: &Section) -> Vec<TocEntry> {
+    root.children.iter().map(toc_entry).collect()
+}
+
+fn toc_entry(section: &Section) -> TocEntry {
+    let heading = section.heading.as_ref();
+    TocEntry {
+        title: heading.map(|h| plain_text(&h.title)).unwrap_or_default(),
+        anchor: heading.map(heading_anchor).unwrap_or_default(),
+        level: heading.map(|h| h.level).unwrap_or(1),
+        children: section.children.iter().map(toc_entry).collect(),
+    }
+}
+
+/// Parse `#+OPTIONS:` into its `key:value` switches.
+///
+/// Org's per-file export switches are a space-separated list — `toc:nil num:t` — and
+/// this is the standard way an author turns a feature off for one document.
+pub fn export_options(keywords: &Keywords) -> std::collections::BTreeMap<String, String> {
+    let mut out = std::collections::BTreeMap::new();
+    for (_, value) in keywords
+        .entries
+        .iter()
+        .filter(|(k, _)| k.eq_ignore_ascii_case("OPTIONS"))
+    {
+        for token in value.split_whitespace() {
+            if let Some((key, val)) = token.split_once(':') {
+                if !key.is_empty() {
+                    out.insert(key.to_ascii_lowercase(), val.to_ascii_lowercase());
+                }
+            }
+        }
+    }
+    out
+}
+
+/// Whether an `#+OPTIONS:` switch is on, falling back to the site default when the
+/// document says nothing.
+pub fn option_enabled(keywords: &Keywords, key: &str, default: bool) -> bool {
+    match export_options(keywords).get(key).map(String::as_str) {
+        Some("nil" | "false" | "no" | "0" | "off") => false,
+        Some(_) => true,
+        None => default,
+    }
+}
+
 /// Is this document marked as a draft?
 ///
 /// `#+DRAFT:` counts as true by its mere presence — writing the keyword at all is the
diff --git a/tests/config.rs b/tests/config.rs
index 0644ae3..65f7b9a 100644
--- a/tests/config.rs
+++ b/tests/config.rs
@@ -1690,3 +1690,189 @@ fn draft_truthiness_is_forgiving_but_respects_an_explicit_negative() {
         "no keyword at all means published"
     );
 }
+
+// ---------------------------------------------------------------------------
+// Table of contents, section numbers, and #+OPTIONS:
+// ---------------------------------------------------------------------------
+
+/// A page with nested headings, one of which sets its own `:CUSTOM_ID:`.
+fn write_toc_site(src: &Utf8PathBuf, options: &str, config: &str) {
+    std::fs::create_dir_all(src.join("templates")).unwrap();
+    std::fs::write(
+        src.join("index.org"),
+        format!(
+            "#+TITLE: Contents\n{options}\n\nIntro.\n\n\
+             * First\nBody.\n** Nested\nBody.\n\
+             * Second\n:PROPERTIES:\n:CUSTOM_ID: chosen-id\n:END:\nBody.\n"
+        ),
+    )
+    .unwrap();
+    std::fs::write(
+        src.join("templates/base.html"),
+        "<html><body>{% macro walk(es) %}<ul>{% for e in es %}\
+         <li>{{ e.level }}:{{ e.title }}@{{ e.anchor }}{% if e.children %}{{ walk(e.children) }}\
+         {% endif %}</li>{% endfor %}</ul>{% endmacro %}\
+         <nav>{{ walk(page.toc) }}</nav>{{ body | safe }}</body></html>",
+    )
+    .unwrap();
+    std::fs::write(src.join("org-ssg.toml"), config).unwrap();
+}
+
+/// A table of contents is a tree, and reconstructing one from a flat list of levels
+/// inside a template is the kind of thing Jinja is bad at.
+#[test]
+fn the_table_of_contents_mirrors_the_heading_tree() {
+    let root = tmpdir("toc");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_toc_site(&src, "", "");
+    let out = root.join("out");
+    build(&src, &out);
+
+    let html = page(&out, "index.html");
+    assert!(html.contains("<li>1:First@first"), "top level:\n{html}");
+    assert!(
+        html.contains("<li>1:First@first<ul><li>2:Nested@nested</li></ul></li>"),
+        "a child nests inside its parent's item:\n{html}"
+    );
+}
+
+/// The TOC links into the page, so its anchors must be the ones the headings actually
+/// carry — including a heading that chose its own `:CUSTOM_ID:`.
+#[test]
+fn toc_anchors_match_the_ids_the_headings_are_emitted_with() {
+    let root = tmpdir("tocanchor");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_toc_site(&src, "", "");
+    std::fs::write(
+        src.join("templates/base.html"),
+        "<html><body>{% for e in page.toc %}<a href=\"#{{ e.anchor }}\">x</a>{% endfor %}\
+         {{ body | safe }}</body></html>",
+    )
+    .unwrap();
+    let out = root.join("out");
+    build(&src, &out);
+
+    let html = page(&out, "index.html");
+    let links: Vec<&str> = html.matches("href=\"#").map(|_| "").collect();
+    assert_eq!(links.len(), 2, "one link per top-level heading");
+    assert!(html.contains("href=\"#chosen-id\""), ":CUSTOM_ID: wins:\n{html}");
+    assert!(
+        html.contains("<h2 id=\"chosen-id\">"),
+        "and the heading carries that same id:\n{html}"
+    );
+}
+
+/// Org's own per-file switch. 4 of the reference corpus's 179 files use exactly this to
+/// turn the table of contents off for one document.
+#[test]
+fn options_toc_nil_turns_the_toc_off_for_one_document() {
+    let root = tmpdir("tocnil");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_toc_site(&src, "#+OPTIONS: toc:nil", "");
+    let out = root.join("out");
+    build(&src, &out);
+
+    let html = page(&out, "index.html");
+    assert!(html.contains("<nav><ul></ul></nav>"), "the toc is empty:\n{html}");
+    assert!(html.contains("First"), "the page itself still renders:\n{html}");
+}
+
+/// The site-wide switch, for someone who never wants one.
+#[test]
+fn the_toc_can_be_disabled_site_wide() {
+    let root = tmpdir("tocoff");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_toc_site(&src, "", "[html]\ntoc = false\n");
+    let out = root.join("out");
+    build(&src, &out);
+
+    assert!(page(&out, "index.html").contains("<nav><ul></ul></nav>"));
+}
+
+/// Numbering is off by default — which differs from Emacs deliberately — and
+/// `#+OPTIONS: num:t` gets Emacs' behaviour back for a document.
+#[test]
+fn section_numbers_are_off_by_default_and_enabled_per_document() {
+    let root = tmpdir("secnum");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_toc_site(&src, "", "");
+    let out = root.join("out");
+    build(&src, &out);
+    assert!(
+        !page(&out, "index.html").contains("section-number"),
+        "no numbers unless asked for"
+    );
+
+    write_toc_site(&src, "#+OPTIONS: num:t", "");
+    let out2 = root.join("out2");
+    build(&src, &out2);
+    let html = page(&out2, "index.html");
+    // Emacs' own class names, so output stays diffable against the oracle.
+    assert!(html.contains("<span class=\"section-number-2\">1.</span> First"), "{html}");
+    assert!(html.contains("<span class=\"section-number-3\">1.1.</span> Nested"), "{html}");
+    assert!(html.contains("<span class=\"section-number-2\">2.</span> Second"), "{html}");
+}
+
+/// Deeper levels have to reset when a shallower one advances, or the second chapter's
+/// first section is numbered 1.3.
+#[test]
+fn section_numbering_resets_at_each_level() {
+    let root = tmpdir("secreset");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    std::fs::write(
+        src.join("index.org"),
+        "#+TITLE: T\n#+OPTIONS: num:t\n\n\
+         * One\n** A\n** B\n* Two\n** C\n*** Deep\n* Three\n",
+    )
+    .unwrap();
+    std::fs::write(src.join("org-ssg.toml"), "").unwrap();
+    let out = root.join("out");
+    build(&src, &out);
+
+    let html = page(&out, "index.html");
+    for (number, title) in [
+        ("1.", "One"),
+        ("1.1.", "A"),
+        ("1.2.", "B"),
+        ("2.", "Two"),
+        ("2.1.", "C"),
+        ("2.1.1.", "Deep"),
+        ("3.", "Three"),
+    ] {
+        assert!(
+            html.contains(&format!("</span> {title}</h")),
+            "{title} should be numbered:\n{html}"
+        );
+        assert!(html.contains(&format!(">{number}</span> {title}")), "{title} = {number}:\n{html}");
+    }
+}
+
+/// `#+OPTIONS:` is a space-separated list of switches, and org spells "off" several ways.
+#[test]
+fn export_options_parse_as_org_writes_them() {
+    use org_ssg::model::Keywords;
+    use org_ssg::util::option_enabled;
+    let keywords = |v: &str| Keywords {
+        entries: vec![("OPTIONS".to_string(), v.to_string())],
+    };
+
+    assert!(!option_enabled(&keywords("toc:nil num:t"), "toc", true));
+    assert!(option_enabled(&keywords("toc:nil num:t"), "num", false));
+    assert!(
+        option_enabled(&keywords("toc:nil"), "num", true),
+        "a switch the document does not mention keeps the site default"
+    );
+    assert!(
+        !option_enabled(&Keywords::default(), "toc", false),
+        "no #+OPTIONS: at all keeps the site default"
+    );
+    for off in ["nil", "false", "no", "0", "off"] {
+        assert!(!option_enabled(&keywords(&format!("toc:{off}")), "toc", true), "{off}");
+    }
+}
diff --git a/tests/snapshots/site__site_guide_html.snap b/tests/snapshots/site__site_guide_html.snap
index 3f65165..d152eae 100644
--- a/tests/snapshots/site__site_guide_html.snap
+++ b/tests/snapshots/site__site_guide_html.snap
@@ -21,6 +21,13 @@ expression: "page(&pages, \"guide.org\").html"
 </header>
 <main>
 <h1>Guide</h1>
+<nav class="toc" aria-label="Table of contents">
+<h2>Contents</h2>
+<ul>
+<li><a href="#setup">Setup</a></li>
+<li><a href="#data">Data</a></li>
+</ul>
+</nav>
 <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>
 <h2 id="data">Data</h2>