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
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(-) @@ -657,7 +657,7 @@ dependencies = [ [[package]] name = "org-ssg" -version = "0.12.0" +version = "0.13.0" dependencies = [ "anyhow", "blake3", @@ -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" @@ -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) @@ -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, + } } } @@ -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) = §ion.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, @@ -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; @@ -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) = §ion.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) => { @@ -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(':') @@ -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"; @@ -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 @@ -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}"); + } +} @@ -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>