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 = [
657 657
658[[package]] 658[[package]]
659name = "org-ssg" 659name = "org-ssg"
660version = "0.12.0" 660version = "0.13.0"
661dependencies = [ 661dependencies = [
662 "anyhow", 662 "anyhow",
663 "blake3", 663 "blake3",
Cargo.toml +1 −1
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "org-ssg" 2name = "org-ssg"
3version = "0.12.0" 3version = "0.13.0"
4edition = "2021" 4edition = "2021"
5description = "Org-mode static site generator that renders the org element tree straight to HTML" 5description = "Org-mode static site generator that renders the org element tree straight to HTML"
6license = "MIT" 6license = "MIT"
README.md +35 −2
@@ -72,7 +72,7 @@ receive:
72| Variable | What it is | 72| Variable | What it is |
73|---|---| 73|---|---|
74| `body` | the rendered page HTML — use `{{ body \| safe }}` | 74| `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` |
76| `site` | `.title`, `.base_url`, `.description`, `.language` | 76| `site` | `.title`, `.base_url`, `.description`, `.language` |
77| `nav` | list of `{title, url}`, relative to this page | 77| `nav` | list of `{title, url}`, relative to this page |
78| `root` | `../`-prefix back to the site root from this page | 78| `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.
231Listing pages are cached on the entries they list, so adding a post re-renders that 231Listing pages are cached on the entries they list, so adding a post re-renders that
232section's index and nothing else. 232section's index and nothing else.
233 233
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
234### Excerpts and drafts 266### Excerpts and drafts
235 267
236`page.excerpt` is a page's `#+DESCRIPTION:` when it sets one and its first paragraph 268`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
323| **12** | **`base_url`: `absolute`/`rfc822` filters, a valid RSS feed in the scaffold, canonical links** | **done** | 355| **12** | **`base_url`: `absolute`/`rfc822` filters, a valid RSS feed in the scaffold, canonical links** | **done** |
324| **13** | **`watch` on OS filesystem events, debounced, with the feedback loop closed** | **done** | 356| **13** | **`watch` on OS filesystem events, debounced, with the feedback loop closed** | **done** |
325| **14** | **Authoring: excerpts, word count, reading time, `truncate`, and draft pages** | **done** | 357| **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** |
326 359
327### v0.2 in / out 360### v0.2 in / out
328 361
@@ -610,7 +643,7 @@ PARSE/RESOLVE/RENDER), `notify` (filesystem events for `watch`), `toml` (config)
610 643
611``` 644```
612cargo build 645cargo build
613cargo test # 135 tests 646cargo test # 142 tests
614cargo run -- init my-site # scaffold a new site 647cargo run -- init my-site # scaffold a new site
615cargo run -- build fixtures/minimal.org -o minimal.html # single file 648cargo run -- build fixtures/minimal.org -o minimal.html # single file
616cargo run -- build fixtures/site -o _site # whole site (incremental) 649cargo run -- build fixtures/site -o _site # whole site (incremental)
src/config.rs +18 −1
@@ -153,11 +153,28 @@ pub struct HtmlOutput {
153 /// beneath it. Set to 0 if your template renders no title of its own, so the 153 /// beneath it. Set to 0 if your template renders no title of its own, so the
154 /// document does not start at `<h2>` with nothing above it. 154 /// document does not start at `<h2>` with nothing above it.
155 pub heading_offset: u8, 155 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,
156} 169}
157 170
158impl Default for HtmlOutput { 171impl Default for HtmlOutput {
159 fn default() -> Self { 172 fn default() -> Self {
160 HtmlOutput { heading_offset: 1 } 173 HtmlOutput {
174 heading_offset: 1,
175 toc: true,
176 section_numbers: false,
177 }
161 } 178 }
162} 179}
163 180
src/index.rs +2 −6
@@ -7,7 +7,7 @@ use camino::{Utf8Path, Utf8PathBuf};
7use serde::{Deserialize, Serialize}; 7use serde::{Deserialize, Serialize};
8 8
9use crate::model::{Document, Section}; 9use crate::model::{Document, Section};
10use crate::util::{output_path, plain_text, slugify}; 10use crate::util::{heading_anchor, output_path, plain_text};
11 11
12/// Identity of a link target. A target is owned by exactly one file (spec §4.3). 12/// Identity of a link target. A target is owned by exactly one file (spec §4.3).
13/// 13///
@@ -119,11 +119,7 @@ fn index_section(
119 targets: &mut HashMap<TargetId, TargetLocation>, 119 targets: &mut HashMap<TargetId, TargetLocation>,
120) { 120) {
121 if let Some(h) = &section.heading { 121 if let Some(h) = &section.heading {
122 let anchor = h 122 let anchor = heading_anchor(h);
123 .custom_id
124 .clone()
125 .or_else(|| h.id.clone())
126 .unwrap_or_else(|| slugify(&plain_text(&h.title)));
127 let mut record = |id: TargetId, anchor: Option<String>| { 123 let mut record = |id: TargetId, anchor: Option<String>| {
128 targets.insert( 124 targets.insert(
129 id, 125 id,
src/main.rs +1
@@ -280,6 +280,7 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
280 word_count: 0, 280 word_count: 0,
281 reading_time: 0, 281 reading_time: 0,
282 keywords: Default::default(), 282 keywords: Default::default(),
283 toc: org_ssg::util::table_of_contents(&resolved.document.root),
283 }; 284 };
284 let mut ctx = RenderContext::new(&site, &page_ctx, &[], SYNTAX_STYLESHEET, ""); 285 let mut ctx = RenderContext::new(&site, &page_ctx, &[], SYNTAX_STYLESHEET, "");
285 ctx.body = &fragment; 286 ctx.body = &fragment;
src/render.rs +34 −7
@@ -26,7 +26,7 @@ use syntect::util::LinesWithEndings;
26use crate::model::{Checkbox, Element, Link, LinkTarget, ListKind, Object, Section, TableRow}; 26use crate::model::{Checkbox, Element, Link, LinkTarget, ListKind, Object, Section, TableRow};
27use crate::parser::is_image_target; 27use crate::parser::is_image_target;
28use crate::resolve::ResolvedDoc; 28use crate::resolve::ResolvedDoc;
29use crate::util::{plain_text, slugify}; 29use crate::util::{heading_anchor, plain_text, slugify};
30 30
31/// A rendered HTML fragment (content only — no page chrome; spec §2.4). 31/// A rendered HTML fragment (content only — no page chrome; spec §2.4).
32#[derive(Debug, Clone)] 32#[derive(Debug, Clone)]
@@ -137,6 +137,8 @@ struct Renderer<'a> {
137 inline_defs: HashMap<String, Vec<Object>>, 137 inline_defs: HashMap<String, Vec<Object>>,
138 /// Reference keys in order of first appearance — drives numbering and note order. 138 /// Reference keys in order of first appearance — drives numbering and note order.
139 order: Vec<String>, 139 order: Vec<String>,
140 /// Counter per heading depth, for section numbers.
141 counters: Vec<usize>,
140} 142}
141 143
142/// Options affecting how the tree becomes HTML. Presentation choices that belong to the 144/// Options affecting how the tree becomes HTML. Presentation choices that belong to the
@@ -147,12 +149,17 @@ pub struct RenderOptions {
147 /// beneath a page title supplied by the layout. See 149 /// beneath a page title supplied by the layout. See
148 /// [`HtmlOutput::heading_offset`](crate::config::HtmlOutput::heading_offset). 150 /// [`HtmlOutput::heading_offset`](crate::config::HtmlOutput::heading_offset).
149 pub heading_offset: u8, 151 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,
150} 155}
151 156
152impl Default for RenderOptions { 157impl Default for RenderOptions {
153 fn default() -> Self { 158 fn default() -> Self {
159 let html = crate::config::HtmlOutput::default();
154 RenderOptions { 160 RenderOptions {
155 heading_offset: crate::config::HtmlOutput::default().heading_offset, 161 heading_offset: html.heading_offset,
162 section_numbers: html.section_numbers,
156 } 163 }
157 } 164 }
158} 165}
@@ -170,6 +177,7 @@ pub fn render_with(doc: &ResolvedDoc, highlighter: &dyn Highlighter, opts: &Rend
170 block_defs: HashMap::new(), 177 block_defs: HashMap::new(),
171 inline_defs: HashMap::new(), 178 inline_defs: HashMap::new(),
172 order: Vec::new(), 179 order: Vec::new(),
180 counters: Vec::new(),
173 }; 181 };
174 r.collect_defs(&doc.document.root); 182 r.collect_defs(&doc.document.root);
175 let mut out = String::new(); 183 let mut out = String::new();
@@ -190,11 +198,7 @@ impl Renderer<'_> {
190 fn render_section(&mut self, section: &Section, out: &mut String) { 198 fn render_section(&mut self, section: &Section, out: &mut String) {
191 if let Some(h) = &section.heading { 199 if let Some(h) = &section.heading {
192 let level = h.level.saturating_add(self.opts.heading_offset).clamp(1, 6); 200 let level = h.level.saturating_add(self.opts.heading_offset).clamp(1, 6);
193 let anchor = h 201 let anchor = heading_anchor(h);
194 .custom_id
195 .clone()
196 .or_else(|| h.id.clone())
197 .unwrap_or_else(|| slugify(&plain_text(&h.title)));
198 // A heading with no title text has no meaningful slug; emit no `id` at all 202 // A heading with no title text has no meaningful slug; emit no `id` at all
199 // rather than a run of duplicate empty ones. 203 // rather than a run of duplicate empty ones.
200 if anchor.is_empty() { 204 if anchor.is_empty() {
@@ -202,6 +206,13 @@ impl Renderer<'_> {
202 } else { 206 } else {
203 out.push_str(&format!("<h{} id=\"{}\">", level, escape_attr(&anchor))); 207 out.push_str(&format!("<h{} id=\"{}\">", level, escape_attr(&anchor)));
204 } 208 }
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 }
205 // Keyword/priority markup mirrors Emacs' own HTML export classes, so output 216 // Keyword/priority markup mirrors Emacs' own HTML export classes, so output
206 // stays diffable against an `emacs --batch` oracle. 217 // stays diffable against an `emacs --batch` oracle.
207 if let Some(todo) = &h.todo { 218 if let Some(todo) = &h.todo {
@@ -232,6 +243,22 @@ impl Renderer<'_> {
232 } 243 }
233 } 244 }
234 245
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
235 fn render_element(&mut self, element: &Element, out: &mut String) { 262 fn render_element(&mut self, element: &Element, out: &mut String) {
236 match element { 263 match element {
237 Element::Paragraph(objs) => { 264 Element::Paragraph(objs) => {
src/site.rs +21 −11
@@ -33,8 +33,8 @@ use crate::template::{
33 Templater, 33 Templater,
34}; 34};
35use crate::util::{ 35use crate::util::{
36 document_text, first_paragraph, is_draft, iso_date, output_path, output_url, relative_root, 36 document_text, first_paragraph, is_draft, iso_date, option_enabled, output_path, output_url,
37 slugify, 37 relative_root, slugify, table_of_contents,
38}; 38};
39 39
40/// Reading speed for [`PageContext::reading_time`]. 200 wpm is the conventional figure 40/// Reading speed for [`PageContext::reading_time`]. 200 wpm is the conventional figure
@@ -481,6 +481,7 @@ fn listing_context(listing: &Listing) -> PageContext {
481 word_count: 0, 481 word_count: 0,
482 reading_time: 0, 482 reading_time: 0,
483 keywords: Default::default(), 483 keywords: Default::default(),
484 toc: Vec::new(),
484 } 485 }
485} 486}
486 487
@@ -613,7 +614,7 @@ fn prepare_pages(
613 .collect(); 614 .collect();
614 615
615 PagePrep { 616 PagePrep {
616 context: page_context(doc, &output), 617 context: page_context(doc, &output, config),
617 source: doc.source_path.clone(), 618 source: doc.source_path.clone(),
618 output, 619 output,
619 title: page_title(doc), 620 title: page_title(doc),
@@ -641,7 +642,6 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
641 let templater = Templater::load(Some(&src.join(&config.templates.dir)), &config.site.base_url)?; 642 let templater = Templater::load(Some(&src.join(&config.templates.dir)), &config.site.base_url)?;
642 let site = site_context(&config); 643 let site = site_context(&config);
643 let listing = page_listing(&config, &preps); 644 let listing = page_listing(&config, &preps);
644 let render_opts = render_options(&config);
645 645
646 let mut pages = Vec::new(); 646 let mut pages = Vec::new();
647 let mut broken = Vec::new(); 647 let mut broken = Vec::new();
@@ -649,7 +649,7 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
649 for t in &p.broken { 649 for t in &p.broken {
650 broken.push((p.source.clone(), t.clone())); 650 broken.push((p.source.clone(), t.clone()));
651 } 651 }
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)?;
653 pages.push(BuiltPage { 653 pages.push(BuiltPage {
654 source: p.source.clone(), 654 source: p.source.clone(),
655 output: p.output.clone(), 655 output: p.output.clone(),
@@ -660,9 +660,12 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
660 Ok((pages, broken)) 660 Ok((pages, broken))
661} 661}
662 662
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 {
664 RenderOptions { 666 RenderOptions {
665 heading_offset: config.html.heading_offset, 667 heading_offset: config.html.heading_offset,
668 section_numbers: option_enabled(keywords, "num", config.html.section_numbers),
666 } 669 }
667} 670}
668 671
@@ -691,10 +694,13 @@ fn render_page(
691 highlighter: &SyntectHighlighter, 694 highlighter: &SyntectHighlighter,
692 site: &SiteContext, 695 site: &SiteContext,
693 pages: Option<&[PageContext]>, 696 pages: Option<&[PageContext]>,
694 render_opts: &RenderOptions, 697 config: &Config,
695 p: &PagePrep, 698 p: &PagePrep,
696) -> Result<String> { 699) -> 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);
698 // Relative to the *output* path, since `#+SLUG:` can move a page between depths. 704 // Relative to the *output* path, since `#+SLUG:` can move a page between depths.
699 let root = relative_root(&p.output); 705 let root = relative_root(&p.output);
700 let stylesheet = format!("{root}{SYNTAX_STYLESHEET}"); 706 let stylesheet = format!("{root}{SYNTAX_STYLESHEET}");
@@ -833,7 +839,6 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
833 let highlighter = SyntectHighlighter::new(); 839 let highlighter = SyntectHighlighter::new();
834 let site = site_context(&cfg); 840 let site = site_context(&cfg);
835 let listing = page_listing(&cfg, &preps); 841 let listing = page_listing(&cfg, &preps);
836 let render_opts = render_options(&cfg);
837 let mut report = SiteReport::default(); 842 let mut report = SiteReport::default();
838 843
839 // RENDER + TEMPLATE + EMIT, in parallel. This is where a build's time actually goes 844 // 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
855 if let Some(parent) = dest.parent() { 860 if let Some(parent) = dest.parent() {
856 fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?; 861 fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?;
857 } 862 }
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)?;
859 fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?; 864 fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?;
860 Ok(true) 865 Ok(true)
861 }) 866 })
@@ -1163,7 +1168,7 @@ fn is_top_level(output: &Utf8Path) -> bool {
1163/// Everything a template can know about one page. Every `#+KEYWORD:` is passed through 1168/// Everything a template can know about one page. Every `#+KEYWORD:` is passed through
1164/// under its lowercased name, so a template can use metadata this crate has never heard 1169/// under its lowercased name, so a template can use metadata this crate has never heard
1165/// of without the crate needing a release to support it. 1170/// 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 {
1167 let words = document_text(&doc.root).split_whitespace().count(); 1172 let words = document_text(&doc.root).split_whitespace().count();
1168 let keyword = |name: &str| { 1173 let keyword = |name: &str| {
1169 doc.keywords 1174 doc.keywords
@@ -1184,6 +1189,11 @@ fn page_context(doc: &Document, output: &Utf8Path) -> PageContext {
1184 .unwrap_or_default(), 1189 .unwrap_or_default(),
1185 word_count: words, 1190 word_count: words,
1186 reading_time: words.div_ceil(WORDS_PER_MINUTE).max(usize::from(words > 0)), 1191 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 },
1187 tags: keyword("FILETAGS") 1197 tags: keyword("FILETAGS")
1188 .unwrap_or_default() 1198 .unwrap_or_default()
1189 .split(':') 1199 .split(':')
src/template.rs +19 −2
@@ -63,12 +63,15 @@ pub struct PageContext {
63 /// Every `#+KEYWORD:` in the file, keyed by lowercased name, so a template can use 63 /// Every `#+KEYWORD:` in the file, keyed by lowercased name, so a template can use
64 /// project-specific metadata this crate has never heard of. 64 /// project-specific metadata this crate has never heard of.
65 pub keywords: BTreeMap<String, String>, 65 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>,
66} 69}
67 70
68/// The built-in layout, used when the templates directory has no `base.html`. 71/// The built-in layout, used when the templates directory has no `base.html`.
69/// Deliberately plain: it should be a working starting point and an obvious thing to 72/// Deliberately plain: it should be a working starting point and an obvious thing to
70/// replace, not a design anyone has to live with. 73/// replace, not a design anyone has to live with.
71const BASE_TEMPLATE: &str = r#"<!DOCTYPE html> 74const BASE_TEMPLATE: &str = r##"<!DOCTYPE html>
72<html lang="{{ site.language }}"> 75<html lang="{{ site.language }}">
73<head> 76<head>
74<meta charset="utf-8"> 77<meta charset="utf-8">
@@ -100,10 +103,24 @@ const BASE_TEMPLATE: &str = r#"<!DOCTYPE html>
100{%- if page.date %} 103{%- if page.date %}
101<p class="page-date">{{ page.date }}</p> 104<p class="page-date">{{ page.date }}</p>
102{%- endif %} 105{%- 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 %}
103{{ body | safe }}</main> 120{{ body | safe }}</main>
104</body> 121</body>
105</html> 122</html>
106"#; 123"##;
107 124
108/// The name a template must have to serve as the page layout. 125/// The name a template must have to serve as the page layout.
109pub const BASE_TEMPLATE_NAME: &str = "base.html"; 126pub const BASE_TEMPLATE_NAME: &str = "base.html";
src/util.rs +76 −1
@@ -4,7 +4,7 @@
4 4
5use camino::{Utf8Path, Utf8PathBuf}; 5use camino::{Utf8Path, Utf8PathBuf};
6 6
7use crate::model::{Element, Keywords, Object, Section, TableRow}; 7use crate::model::{Element, Heading, Keywords, Object, Section, TableRow};
8 8
9/// The output path for a document, relative to the site root. 9/// The output path for a document, relative to the site root.
10/// 10///
@@ -76,6 +76,81 @@ fn plain_text_into(objs: &[Object], out: &mut String) {
76 } 76 }
77} 77}
78 78
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
79/// Is this document marked as a draft? 154/// Is this document marked as a draft?
80/// 155///
81/// `#+DRAFT:` counts as true by its mere presence — writing the keyword at all is the 156/// `#+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() {
1690 "no keyword at all means published" 1690 "no keyword at all means published"
1691 ); 1691 );
1692} 1692}
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"
21</header> 21</header>
22<main> 22<main>
23<h1>Guide</h1> 23<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>
24<h2 id="setup">Setup</h2> 31<h2 id="setup">Setup</h2>
25<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> 32<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>
26<h2 id="data">Data</h2> 33<h2 id="data">Data</h2>