krz/orgo

Lightning fast org-mode static site generator. fast go org-mode static-site-generator

Commit ecaccb90aa

ecaccb90aa064219247a4cfc3261c0e6ff66e86a

parent: 20cb94bb4e

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-11 05:54 UTC

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.

Layout: unified · split

Cargo.lock +1 −1
@@ -657,7 +657,7 @@ dependencies = [
657657
658658[[package]]
659659name = "org-ssg"
660version = "0.12.0"
660version = "0.13.0"
661661dependencies = [
662662 "anyhow",
663663 "blake3",
Cargo.toml +1 −1
@@ -1,6 +1,6 @@
11[package]
22name = "org-ssg"
3version = "0.12.0"
3version = "0.13.0"
44edition = "2021"
55description = "Org-mode static site generator that renders the org element tree straight to HTML"
66license = "MIT"
README.md +35 −2
@@ -72,7 +72,7 @@ receive:
7272| Variable | What it is |
7373|---|---|
7474| `body` | the rendered page HTML — use `{{ body \| safe }}` |
75| `page` | `.title`, `.url`, `.source`, `.date`, `.date_iso`, `.tags`, `.excerpt`, `.word_count`, `.reading_time`, `.keywords` |
75| `page` | `.title`, `.url`, `.source`, `.date`, `.date_iso`, `.tags`, `.excerpt`, `.word_count`, `.reading_time`, `.toc`, `.keywords` |
7676| `site` | `.title`, `.base_url`, `.description`, `.language` |
7777| `nav` | list of `{title, url}`, relative to this page |
7878| `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.
231231Listing pages are cached on the entries they list, so adding a post re-renders that
232232section's index and nothing else.
233233
234### Table of contents and `#+OPTIONS:`
235
236`page.toc` is the page's headings as a **tree** — `{title, anchor, level, children}` —
237because a table of contents is one, and rebuilding a tree from a flat list of levels
238inside a template is what Jinja is worst at. Its anchors come from the same function the
239renderer uses to emit heading `id`s, so a TOC link cannot drift from the heading it
240points at.
241
242```jinja
243{% macro toc_list(entries) %}
244<ul>{% for e in entries %}
245 <li><a href="#{{ e.anchor }}">{{ e.title }}</a>
246 {%- if e.children %}{{ toc_list(e.children) }}{% endif %}</li>
247{% endfor %}</ul>
248{% endmacro %}
249{% if page.toc %}{{ toc_list(page.toc) }}{% endif %}
250```
251
252Org's own per-file export switches are honoured, so a document can turn a feature off for
253itself the way its author already knows:
254
255| Switch | Effect | Site default |
256|---|---|---|
257| `#+OPTIONS: toc:nil` | empties `page.toc` for this page | `[html] toc = true` |
258| `#+OPTIONS: num:t` | numbers headings `1.`, `1.1.`, … | `[html] section_numbers = false` |
259
260**Section numbers default to off, which differs from Emacs on purpose.**
261`org-export-with-section-numbers` is on there, so an org-published site inherits numbered
262headings whether or not anyone chose them. Most sites do not want them; `num:t` or
263`section_numbers = true` gets Emacs' behaviour back, with Emacs' own
264`section-number-N` classes so the output stays diffable against the oracle.
265
234266### Excerpts and drafts
235267
236268`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
323355| **12** | **`base_url`: `absolute`/`rfc822` filters, a valid RSS feed in the scaffold, canonical links** | **done** |
324356| **13** | **`watch` on OS filesystem events, debounced, with the feedback loop closed** | **done** |
325357| **14** | **Authoring: excerpts, word count, reading time, `truncate`, and draft pages** | **done** |
358| **15** | **Table of contents, section numbers, and org's `#+OPTIONS:` per-file switches** | **done** |
326359
327360### v0.2 in / out
328361
@@ -610,7 +643,7 @@ PARSE/RESOLVE/RENDER), `notify` (filesystem events for `watch`), `toml` (config)
610643
611644```
612645cargo build
613cargo test # 135 tests
646cargo test # 142 tests
614647cargo run -- init my-site # scaffold a new site
615648cargo run -- build fixtures/minimal.org -o minimal.html # single file
616649cargo run -- build fixtures/site -o _site # whole site (incremental)
src/config.rs +18 −1
@@ -153,11 +153,28 @@ pub struct HtmlOutput {
153153 /// beneath it. Set to 0 if your template renders no title of its own, so the
154154 /// document does not start at `<h2>` with nothing above it.
155155 pub heading_offset: u8,
156 /// Make each page's table of contents available to templates as `page.toc`.
157 ///
158 /// On by default: it is *data*, and whether it appears is the template's business.
159 /// A document turns it off for itself with org's own `#+OPTIONS: toc:nil`, which is
160 /// how ~2% of the reference corpus does it.
161 pub toc: bool,
162 /// Number headings, `1.`, `1.1.`, and so on.
163 ///
164 /// Off by default, which differs from Emacs — `org-export-with-section-numbers` is
165 /// on there, and the reference site inherits numbered headings from it. Most sites
166 /// do not want them, so the default is the taste rather than the inheritance;
167 /// `#+OPTIONS: num:t` or `section_numbers = true` gets Emacs' behaviour back.
168 pub section_numbers: bool,
156169}
157170
158171impl Default for HtmlOutput {
159172 fn default() -> Self {
160 HtmlOutput { heading_offset: 1 }
173 HtmlOutput {
174 heading_offset: 1,
175 toc: true,
176 section_numbers: false,
177 }
161178 }
162179}
163180
src/index.rs +2 −6
@@ -7,7 +7,7 @@ use camino::{Utf8Path, Utf8PathBuf};
77use serde::{Deserialize, Serialize};
88
99use crate::model::{Document, Section};
10use crate::util::{output_path, plain_text, slugify};
10use crate::util::{heading_anchor, output_path, plain_text};
1111
1212/// Identity of a link target. A target is owned by exactly one file (spec §4.3).
1313///
@@ -119,11 +119,7 @@ fn index_section(
119119 targets: &mut HashMap<TargetId, TargetLocation>,
120120) {
121121 if let Some(h) = &section.heading {
122 let anchor = h
123 .custom_id
124 .clone()
125 .or_else(|| h.id.clone())
126 .unwrap_or_else(|| slugify(&plain_text(&h.title)));
122 let anchor = heading_anchor(h);
127123 let mut record = |id: TargetId, anchor: Option<String>| {
128124 targets.insert(
129125 id,
src/main.rs +1
@@ -280,6 +280,7 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
280280 word_count: 0,
281281 reading_time: 0,
282282 keywords: Default::default(),
283 toc: org_ssg::util::table_of_contents(&resolved.document.root),
283284 };
284285 let mut ctx = RenderContext::new(&site, &page_ctx, &[], SYNTAX_STYLESHEET, "");
285286 ctx.body = &fragment;
src/render.rs +34 −7
@@ -26,7 +26,7 @@ use syntect::util::LinesWithEndings;
2626use crate::model::{Checkbox, Element, Link, LinkTarget, ListKind, Object, Section, TableRow};
2727use crate::parser::is_image_target;
2828use crate::resolve::ResolvedDoc;
29use crate::util::{plain_text, slugify};
29use crate::util::{heading_anchor, plain_text, slugify};
3030
3131/// A rendered HTML fragment (content only — no page chrome; spec §2.4).
3232#[derive(Debug, Clone)]
@@ -137,6 +137,8 @@ struct Renderer<'a> {
137137 inline_defs: HashMap<String, Vec<Object>>,
138138 /// Reference keys in order of first appearance — drives numbering and note order.
139139 order: Vec<String>,
140 /// Counter per heading depth, for section numbers.
141 counters: Vec<usize>,
140142}
141143
142144/// Options affecting how the tree becomes HTML. Presentation choices that belong to the
@@ -147,12 +149,17 @@ pub struct RenderOptions {
147149 /// beneath a page title supplied by the layout. See
148150 /// [`HtmlOutput::heading_offset`](crate::config::HtmlOutput::heading_offset).
149151 pub heading_offset: u8,
152 /// Prefix headings with `1.`, `1.1.`, … See
153 /// [`HtmlOutput::section_numbers`](crate::config::HtmlOutput::section_numbers).
154 pub section_numbers: bool,
150155}
151156
152157impl Default for RenderOptions {
153158 fn default() -> Self {
159 let html = crate::config::HtmlOutput::default();
154160 RenderOptions {
155 heading_offset: crate::config::HtmlOutput::default().heading_offset,
161 heading_offset: html.heading_offset,
162 section_numbers: html.section_numbers,
156163 }
157164 }
158165}
@@ -170,6 +177,7 @@ pub fn render_with(doc: &ResolvedDoc, highlighter: &dyn Highlighter, opts: &Rend
170177 block_defs: HashMap::new(),
171178 inline_defs: HashMap::new(),
172179 order: Vec::new(),
180 counters: Vec::new(),
173181 };
174182 r.collect_defs(&doc.document.root);
175183 let mut out = String::new();
@@ -190,11 +198,7 @@ impl Renderer<'_> {
190198 fn render_section(&mut self, section: &Section, out: &mut String) {
191199 if let Some(h) = &section.heading {
192200 let level = h.level.saturating_add(self.opts.heading_offset).clamp(1, 6);
193 let anchor = h
194 .custom_id
195 .clone()
196 .or_else(|| h.id.clone())
197 .unwrap_or_else(|| slugify(&plain_text(&h.title)));
201 let anchor = heading_anchor(h);
198202 // A heading with no title text has no meaningful slug; emit no `id` at all
199203 // rather than a run of duplicate empty ones.
200204 if anchor.is_empty() {
@@ -202,6 +206,13 @@ impl Renderer<'_> {
202206 } else {
203207 out.push_str(&format!("<h{} id=\"{}\">", level, escape_attr(&anchor)));
204208 }
209 if self.opts.section_numbers {
210 let number = self.next_section_number(h.level);
211 out.push_str(&format!(
212 "<span class=\"section-number-{}\">{number}</span> ",
213 level
214 ));
215 }
205216 // Keyword/priority markup mirrors Emacs' own HTML export classes, so output
206217 // stays diffable against an `emacs --batch` oracle.
207218 if let Some(todo) = &h.todo {
@@ -232,6 +243,22 @@ impl Renderer<'_> {
232243 }
233244 }
234245
246 /// The next section number at `level`, e.g. `1.`, `1.1.`, `2.`.
247 ///
248 /// Deeper levels reset when a shallower one advances, and a document that skips a
249 /// level (a `***` under a `*`) simply starts the missing levels at 1 rather than
250 /// being treated as malformed.
251 fn next_section_number(&mut self, level: u8) -> String {
252 let depth = usize::from(level).max(1);
253 self.counters.truncate(depth);
254 while self.counters.len() < depth {
255 self.counters.push(0);
256 }
257 self.counters[depth - 1] += 1;
258 let parts: Vec<String> = self.counters.iter().map(usize::to_string).collect();
259 format!("{}.", parts.join("."))
260 }
261
235262 fn render_element(&mut self, element: &Element, out: &mut String) {
236263 match element {
237264 Element::Paragraph(objs) => {
src/site.rs +21 −11
@@ -33,8 +33,8 @@ use crate::template::{
3333 Templater,
3434};
3535use crate::util::{
36 document_text, first_paragraph, is_draft, iso_date, output_path, output_url, relative_root,
37 slugify,
36 document_text, first_paragraph, is_draft, iso_date, option_enabled, output_path, output_url,
37 relative_root, slugify, table_of_contents,
3838};
3939
4040/// Reading speed for [`PageContext::reading_time`]. 200 wpm is the conventional figure
@@ -481,6 +481,7 @@ fn listing_context(listing: &Listing) -> PageContext {
481481 word_count: 0,
482482 reading_time: 0,
483483 keywords: Default::default(),
484 toc: Vec::new(),
484485 }
485486}
486487
@@ -613,7 +614,7 @@ fn prepare_pages(
613614 .collect();
614615
615616 PagePrep {
616 context: page_context(doc, &output),
617 context: page_context(doc, &output, config),
617618 source: doc.source_path.clone(),
618619 output,
619620 title: page_title(doc),
@@ -641,7 +642,6 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
641642 let templater = Templater::load(Some(&src.join(&config.templates.dir)), &config.site.base_url)?;
642643 let site = site_context(&config);
643644 let listing = page_listing(&config, &preps);
644 let render_opts = render_options(&config);
645645
646646 let mut pages = Vec::new();
647647 let mut broken = Vec::new();
@@ -649,7 +649,7 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
649649 for t in &p.broken {
650650 broken.push((p.source.clone(), t.clone()));
651651 }
652 let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &render_opts, p)?;
652 let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &config, p)?;
653653 pages.push(BuiltPage {
654654 source: p.source.clone(),
655655 output: p.output.clone(),
@@ -660,9 +660,12 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
660660 Ok((pages, broken))
661661}
662662
663fn render_options(config: &Config) -> RenderOptions {
663/// Render options for one page: the site's settings, with the document's own
664/// `#+OPTIONS:` switches applied on top.
665fn render_options(config: &Config, keywords: &crate::model::Keywords) -> RenderOptions {
664666 RenderOptions {
665667 heading_offset: config.html.heading_offset,
668 section_numbers: option_enabled(keywords, "num", config.html.section_numbers),
666669 }
667670}
668671
@@ -691,10 +694,13 @@ fn render_page(
691694 highlighter: &SyntectHighlighter,
692695 site: &SiteContext,
693696 pages: Option<&[PageContext]>,
694 render_opts: &RenderOptions,
697 config: &Config,
695698 p: &PagePrep,
696699) -> Result<String> {
697 let Html(fragment) = render_with(&p.resolved, highlighter, render_opts);
700 // Options are resolved per page, because `#+OPTIONS:` is a per-document override of
701 // the site setting.
702 let opts = render_options(config, &p.resolved.document.keywords);
703 let Html(fragment) = render_with(&p.resolved, highlighter, &opts);
698704 // Relative to the *output* path, since `#+SLUG:` can move a page between depths.
699705 let root = relative_root(&p.output);
700706 let stylesheet = format!("{root}{SYNTAX_STYLESHEET}");
@@ -833,7 +839,6 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
833839 let highlighter = SyntectHighlighter::new();
834840 let site = site_context(&cfg);
835841 let listing = page_listing(&cfg, &preps);
836 let render_opts = render_options(&cfg);
837842 let mut report = SiteReport::default();
838843
839844 // 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
855860 if let Some(parent) = dest.parent() {
856861 fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?;
857862 }
858 let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &render_opts, p)?;
863 let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &cfg, p)?;
859864 fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?;
860865 Ok(true)
861866 })
@@ -1163,7 +1168,7 @@ fn is_top_level(output: &Utf8Path) -> bool {
11631168/// Everything a template can know about one page. Every `#+KEYWORD:` is passed through
11641169/// under its lowercased name, so a template can use metadata this crate has never heard
11651170/// of without the crate needing a release to support it.
1166fn page_context(doc: &Document, output: &Utf8Path) -> PageContext {
1171fn page_context(doc: &Document, output: &Utf8Path, config: &Config) -> PageContext {
11671172 let words = document_text(&doc.root).split_whitespace().count();
11681173 let keyword = |name: &str| {
11691174 doc.keywords
@@ -1184,6 +1189,11 @@ fn page_context(doc: &Document, output: &Utf8Path) -> PageContext {
11841189 .unwrap_or_default(),
11851190 word_count: words,
11861191 reading_time: words.div_ceil(WORDS_PER_MINUTE).max(usize::from(words > 0)),
1192 toc: if option_enabled(&doc.keywords, "toc", config.html.toc) {
1193 table_of_contents(&doc.root)
1194 } else {
1195 Vec::new()
1196 },
11871197 tags: keyword("FILETAGS")
11881198 .unwrap_or_default()
11891199 .split(':')
src/template.rs +19 −2
@@ -63,12 +63,15 @@ pub struct PageContext {
6363 /// Every `#+KEYWORD:` in the file, keyed by lowercased name, so a template can use
6464 /// project-specific metadata this crate has never heard of.
6565 pub keywords: BTreeMap<String, String>,
66 /// The page's headings as a tree. Empty when the page has none, when the site turns
67 /// `html.toc` off, or when the document opts out with `#+OPTIONS: toc:nil`.
68 pub toc: Vec<crate::util::TocEntry>,
6669}
6770
6871/// The built-in layout, used when the templates directory has no `base.html`.
6972/// Deliberately plain: it should be a working starting point and an obvious thing to
7073/// replace, not a design anyone has to live with.
71const BASE_TEMPLATE: &str = r#"<!DOCTYPE html>
74const BASE_TEMPLATE: &str = r##"<!DOCTYPE html>
7275<html lang="{{ site.language }}">
7376<head>
7477<meta charset="utf-8">
@@ -100,10 +103,24 @@ const BASE_TEMPLATE: &str = r#"<!DOCTYPE html>
100103{%- if page.date %}
101104<p class="page-date">{{ page.date }}</p>
102105{%- endif %}
106{%- if page.toc | length > 1 %}
107{%- macro toc_list(entries) %}
108<ul>
109{%- for entry in entries %}
110<li><a href="#{{ entry.anchor }}">{{ entry.title }}</a>
111{%- if entry.children %}{{ toc_list(entry.children) }}{% endif %}</li>
112{%- endfor %}
113</ul>
114{%- endmacro %}
115<nav class="toc" aria-label="Table of contents">
116<h2>Contents</h2>
117{{- toc_list(page.toc) }}
118</nav>
119{%- endif %}
103120{{ body | safe }}</main>
104121</body>
105122</html>
106"#;
123"##;
107124
108125/// The name a template must have to serve as the page layout.
109126pub const BASE_TEMPLATE_NAME: &str = "base.html";
src/util.rs +76 −1
@@ -4,7 +4,7 @@
44
55use camino::{Utf8Path, Utf8PathBuf};
66
7use crate::model::{Element, Keywords, Object, Section, TableRow};
7use crate::model::{Element, Heading, Keywords, Object, Section, TableRow};
88
99/// The output path for a document, relative to the site root.
1010///
@@ -76,6 +76,81 @@ fn plain_text_into(objs: &[Object], out: &mut String) {
7676 }
7777}
7878
79/// The `id` a heading is emitted with, and therefore the fragment anything linking to it
80/// must use.
81///
82/// One function because there are three callers — the renderer emitting the `id`, INDEX
83/// recording link targets, and the table of contents linking into the page. Any drift
84/// between them is a link that silently goes nowhere.
85pub fn heading_anchor(heading: &Heading) -> String {
86 heading
87 .custom_id
88 .clone()
89 .or_else(|| heading.id.clone())
90 .unwrap_or_else(|| slugify(&plain_text(&heading.title)))
91}
92
93/// One entry in a page's table of contents.
94#[derive(Debug, Clone, serde::Serialize)]
95pub struct TocEntry {
96 pub title: String,
97 /// Fragment identifier, without the `#`.
98 pub anchor: String,
99 /// Org heading level, 1-based, before any `heading_offset` is applied.
100 pub level: u8,
101 pub children: Vec<TocEntry>,
102}
103
104/// A page's table of contents, mirroring its heading tree.
105///
106/// Nested rather than flat: a table of contents *is* a tree, and reconstructing one from
107/// a flat list of levels inside a template is the kind of thing Jinja is bad at.
108pub fn table_of_contents(root: &Section) -> Vec<TocEntry> {
109 root.children.iter().map(toc_entry).collect()
110}
111
112fn toc_entry(section: &Section) -> TocEntry {
113 let heading = section.heading.as_ref();
114 TocEntry {
115 title: heading.map(|h| plain_text(&h.title)).unwrap_or_default(),
116 anchor: heading.map(heading_anchor).unwrap_or_default(),
117 level: heading.map(|h| h.level).unwrap_or(1),
118 children: section.children.iter().map(toc_entry).collect(),
119 }
120}
121
122/// Parse `#+OPTIONS:` into its `key:value` switches.
123///
124/// Org's per-file export switches are a space-separated list — `toc:nil num:t` — and
125/// this is the standard way an author turns a feature off for one document.
126pub fn export_options(keywords: &Keywords) -> std::collections::BTreeMap<String, String> {
127 let mut out = std::collections::BTreeMap::new();
128 for (_, value) in keywords
129 .entries
130 .iter()
131 .filter(|(k, _)| k.eq_ignore_ascii_case("OPTIONS"))
132 {
133 for token in value.split_whitespace() {
134 if let Some((key, val)) = token.split_once(':') {
135 if !key.is_empty() {
136 out.insert(key.to_ascii_lowercase(), val.to_ascii_lowercase());
137 }
138 }
139 }
140 }
141 out
142}
143
144/// Whether an `#+OPTIONS:` switch is on, falling back to the site default when the
145/// document says nothing.
146pub fn option_enabled(keywords: &Keywords, key: &str, default: bool) -> bool {
147 match export_options(keywords).get(key).map(String::as_str) {
148 Some("nil" | "false" | "no" | "0" | "off") => false,
149 Some(_) => true,
150 None => default,
151 }
152}
153
79154/// Is this document marked as a draft?
80155///
81156/// `#+DRAFT:` counts as true by its mere presence — writing the keyword at all is the
tests/config.rs +186
@@ -1690,3 +1690,189 @@ fn draft_truthiness_is_forgiving_but_respects_an_explicit_negative() {
16901690 "no keyword at all means published"
16911691 );
16921692}
1693
1694// ---------------------------------------------------------------------------
1695// Table of contents, section numbers, and #+OPTIONS:
1696// ---------------------------------------------------------------------------
1697
1698/// A page with nested headings, one of which sets its own `:CUSTOM_ID:`.
1699fn write_toc_site(src: &Utf8PathBuf, options: &str, config: &str) {
1700 std::fs::create_dir_all(src.join("templates")).unwrap();
1701 std::fs::write(
1702 src.join("index.org"),
1703 format!(
1704 "#+TITLE: Contents\n{options}\n\nIntro.\n\n\
1705 * First\nBody.\n** Nested\nBody.\n\
1706 * Second\n:PROPERTIES:\n:CUSTOM_ID: chosen-id\n:END:\nBody.\n"
1707 ),
1708 )
1709 .unwrap();
1710 std::fs::write(
1711 src.join("templates/base.html"),
1712 "<html><body>{% macro walk(es) %}<ul>{% for e in es %}\
1713 <li>{{ e.level }}:{{ e.title }}@{{ e.anchor }}{% if e.children %}{{ walk(e.children) }}\
1714 {% endif %}</li>{% endfor %}</ul>{% endmacro %}\
1715 <nav>{{ walk(page.toc) }}</nav>{{ body | safe }}</body></html>",
1716 )
1717 .unwrap();
1718 std::fs::write(src.join("org-ssg.toml"), config).unwrap();
1719}
1720
1721/// A table of contents is a tree, and reconstructing one from a flat list of levels
1722/// inside a template is the kind of thing Jinja is bad at.
1723#[test]
1724fn the_table_of_contents_mirrors_the_heading_tree() {
1725 let root = tmpdir("toc");
1726 let src = root.join("src");
1727 std::fs::create_dir_all(&src).unwrap();
1728 write_toc_site(&src, "", "");
1729 let out = root.join("out");
1730 build(&src, &out);
1731
1732 let html = page(&out, "index.html");
1733 assert!(html.contains("<li>1:First@first"), "top level:\n{html}");
1734 assert!(
1735 html.contains("<li>1:First@first<ul><li>2:Nested@nested</li></ul></li>"),
1736 "a child nests inside its parent's item:\n{html}"
1737 );
1738}
1739
1740/// The TOC links into the page, so its anchors must be the ones the headings actually
1741/// carry — including a heading that chose its own `:CUSTOM_ID:`.
1742#[test]
1743fn toc_anchors_match_the_ids_the_headings_are_emitted_with() {
1744 let root = tmpdir("tocanchor");
1745 let src = root.join("src");
1746 std::fs::create_dir_all(&src).unwrap();
1747 write_toc_site(&src, "", "");
1748 std::fs::write(
1749 src.join("templates/base.html"),
1750 "<html><body>{% for e in page.toc %}<a href=\"#{{ e.anchor }}\">x</a>{% endfor %}\
1751 {{ body | safe }}</body></html>",
1752 )
1753 .unwrap();
1754 let out = root.join("out");
1755 build(&src, &out);
1756
1757 let html = page(&out, "index.html");
1758 let links: Vec<&str> = html.matches("href=\"#").map(|_| "").collect();
1759 assert_eq!(links.len(), 2, "one link per top-level heading");
1760 assert!(html.contains("href=\"#chosen-id\""), ":CUSTOM_ID: wins:\n{html}");
1761 assert!(
1762 html.contains("<h2 id=\"chosen-id\">"),
1763 "and the heading carries that same id:\n{html}"
1764 );
1765}
1766
1767/// Org's own per-file switch. 4 of the reference corpus's 179 files use exactly this to
1768/// turn the table of contents off for one document.
1769#[test]
1770fn options_toc_nil_turns_the_toc_off_for_one_document() {
1771 let root = tmpdir("tocnil");
1772 let src = root.join("src");
1773 std::fs::create_dir_all(&src).unwrap();
1774 write_toc_site(&src, "#+OPTIONS: toc:nil", "");
1775 let out = root.join("out");
1776 build(&src, &out);
1777
1778 let html = page(&out, "index.html");
1779 assert!(html.contains("<nav><ul></ul></nav>"), "the toc is empty:\n{html}");
1780 assert!(html.contains("First"), "the page itself still renders:\n{html}");
1781}
1782
1783/// The site-wide switch, for someone who never wants one.
1784#[test]
1785fn the_toc_can_be_disabled_site_wide() {
1786 let root = tmpdir("tocoff");
1787 let src = root.join("src");
1788 std::fs::create_dir_all(&src).unwrap();
1789 write_toc_site(&src, "", "[html]\ntoc = false\n");
1790 let out = root.join("out");
1791 build(&src, &out);
1792
1793 assert!(page(&out, "index.html").contains("<nav><ul></ul></nav>"));
1794}
1795
1796/// Numbering is off by default — which differs from Emacs deliberately — and
1797/// `#+OPTIONS: num:t` gets Emacs' behaviour back for a document.
1798#[test]
1799fn section_numbers_are_off_by_default_and_enabled_per_document() {
1800 let root = tmpdir("secnum");
1801 let src = root.join("src");
1802 std::fs::create_dir_all(&src).unwrap();
1803 write_toc_site(&src, "", "");
1804 let out = root.join("out");
1805 build(&src, &out);
1806 assert!(
1807 !page(&out, "index.html").contains("section-number"),
1808 "no numbers unless asked for"
1809 );
1810
1811 write_toc_site(&src, "#+OPTIONS: num:t", "");
1812 let out2 = root.join("out2");
1813 build(&src, &out2);
1814 let html = page(&out2, "index.html");
1815 // Emacs' own class names, so output stays diffable against the oracle.
1816 assert!(html.contains("<span class=\"section-number-2\">1.</span> First"), "{html}");
1817 assert!(html.contains("<span class=\"section-number-3\">1.1.</span> Nested"), "{html}");
1818 assert!(html.contains("<span class=\"section-number-2\">2.</span> Second"), "{html}");
1819}
1820
1821/// Deeper levels have to reset when a shallower one advances, or the second chapter's
1822/// first section is numbered 1.3.
1823#[test]
1824fn section_numbering_resets_at_each_level() {
1825 let root = tmpdir("secreset");
1826 let src = root.join("src");
1827 std::fs::create_dir_all(&src).unwrap();
1828 std::fs::write(
1829 src.join("index.org"),
1830 "#+TITLE: T\n#+OPTIONS: num:t\n\n\
1831 * One\n** A\n** B\n* Two\n** C\n*** Deep\n* Three\n",
1832 )
1833 .unwrap();
1834 std::fs::write(src.join("org-ssg.toml"), "").unwrap();
1835 let out = root.join("out");
1836 build(&src, &out);
1837
1838 let html = page(&out, "index.html");
1839 for (number, title) in [
1840 ("1.", "One"),
1841 ("1.1.", "A"),
1842 ("1.2.", "B"),
1843 ("2.", "Two"),
1844 ("2.1.", "C"),
1845 ("2.1.1.", "Deep"),
1846 ("3.", "Three"),
1847 ] {
1848 assert!(
1849 html.contains(&format!("</span> {title}</h")),
1850 "{title} should be numbered:\n{html}"
1851 );
1852 assert!(html.contains(&format!(">{number}</span> {title}")), "{title} = {number}:\n{html}");
1853 }
1854}
1855
1856/// `#+OPTIONS:` is a space-separated list of switches, and org spells "off" several ways.
1857#[test]
1858fn export_options_parse_as_org_writes_them() {
1859 use org_ssg::model::Keywords;
1860 use org_ssg::util::option_enabled;
1861 let keywords = |v: &str| Keywords {
1862 entries: vec![("OPTIONS".to_string(), v.to_string())],
1863 };
1864
1865 assert!(!option_enabled(&keywords("toc:nil num:t"), "toc", true));
1866 assert!(option_enabled(&keywords("toc:nil num:t"), "num", false));
1867 assert!(
1868 option_enabled(&keywords("toc:nil"), "num", true),
1869 "a switch the document does not mention keeps the site default"
1870 );
1871 assert!(
1872 !option_enabled(&Keywords::default(), "toc", false),
1873 "no #+OPTIONS: at all keeps the site default"
1874 );
1875 for off in ["nil", "false", "no", "0", "off"] {
1876 assert!(!option_enabled(&keywords(&format!("toc:{off}")), "toc", true), "{off}");
1877 }
1878}
tests/snapshots/site__site_guide_html.snap +7
@@ -21,6 +21,13 @@ expression: "page(&pages, \"guide.org\").html"
2121</header>
2222<main>
2323<h1>Guide</h1>
24<nav class="toc" aria-label="Table of contents">
25<h2>Contents</h2>
26<ul>
27<li><a href="#setup">Setup</a></li>
28<li><a href="#data">Data</a></li>
29</ul>
30</nav>
2431<h2 id="setup">Setup</h2>
2532<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>
2633<h2 id="data">Data</h2>