Commit ecaccb90aa
Verified · cmc
Layout: unified · split
Cargo.lock +1 −1
| @@ -657,7 +657,7 @@ dependencies = [ | ||
| 657 | 657 | |
| 658 | 658 | [[package]] |
| 659 | 659 | name = "org-ssg" |
| 660 | version = "0.12.0" | |
| 660 | version = "0.13.0" | |
| 661 | 661 | dependencies = [ |
| 662 | 662 | "anyhow", |
| 663 | 663 | "blake3", |
Cargo.toml +1 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | [package] |
| 2 | 2 | name = "org-ssg" |
| 3 | version = "0.12.0" | |
| 3 | version = "0.13.0" | |
| 4 | 4 | edition = "2021" |
| 5 | 5 | description = "Org-mode static site generator that renders the org element tree straight to HTML" |
| 6 | 6 | license = "MIT" |
README.md +35 −2
| @@ -72,7 +72,7 @@ receive: | ||
| 72 | 72 | | Variable | What it is | |
| 73 | 73 | |---|---| |
| 74 | 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 | 76 | | `site` | `.title`, `.base_url`, `.description`, `.language` | |
| 77 | 77 | | `nav` | list of `{title, url}`, relative to this page | |
| 78 | 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. | ||
| 231 | 231 | Listing pages are cached on the entries they list, so adding a post re-renders that |
| 232 | 232 | section'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}` — | |
| 237 | because a table of contents is one, and rebuilding a tree from a flat list of levels | |
| 238 | inside a template is what Jinja is worst at. Its anchors come from the same function the | |
| 239 | renderer uses to emit heading `id`s, so a TOC link cannot drift from the heading it | |
| 240 | points 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 | ||
| 252 | Org's own per-file export switches are honoured, so a document can turn a feature off for | |
| 253 | itself 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 | |
| 262 | headings 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 | 266 | ### Excerpts and drafts |
| 235 | 267 | |
| 236 | 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 | 355 | | **12** | **`base_url`: `absolute`/`rfc822` filters, a valid RSS feed in the scaffold, canonical links** | **done** | |
| 324 | 356 | | **13** | **`watch` on OS filesystem events, debounced, with the feedback loop closed** | **done** | |
| 325 | 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 | 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 | ``` |
| 612 | 645 | cargo build |
| 613 | cargo test # 135 tests | |
| 646 | cargo test # 142 tests | |
| 614 | 647 | cargo run -- init my-site # scaffold a new site |
| 615 | 648 | cargo run -- build fixtures/minimal.org -o minimal.html # single file |
| 616 | 649 | cargo run -- build fixtures/site -o _site # whole site (incremental) |
src/config.rs +18 −1
| @@ -153,11 +153,28 @@ pub struct HtmlOutput { | ||
| 153 | 153 | /// beneath it. Set to 0 if your template renders no title of its own, so the |
| 154 | 154 | /// document does not start at `<h2>` with nothing above it. |
| 155 | 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 | |
| 158 | 171 | impl Default for HtmlOutput { |
| 159 | 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}; | ||
| 7 | 7 | use serde::{Deserialize, Serialize}; |
| 8 | 8 | |
| 9 | 9 | use crate::model::{Document, Section}; |
| 10 | use crate::util::{output_path, plain_text, slugify}; | |
| 10 | use crate::util::{heading_anchor, output_path, plain_text}; | |
| 11 | 11 | |
| 12 | 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 | 119 | targets: &mut HashMap<TargetId, TargetLocation>, |
| 120 | 120 | ) { |
| 121 | 121 | if let Some(h) = §ion.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); | |
| 127 | 123 | let mut record = |id: TargetId, anchor: Option<String>| { |
| 128 | 124 | targets.insert( |
| 129 | 125 | id, |
src/main.rs +1
| @@ -280,6 +280,7 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> { | ||
| 280 | 280 | word_count: 0, |
| 281 | 281 | reading_time: 0, |
| 282 | 282 | keywords: Default::default(), |
| 283 | toc: org_ssg::util::table_of_contents(&resolved.document.root), | |
| 283 | 284 | }; |
| 284 | 285 | let mut ctx = RenderContext::new(&site, &page_ctx, &[], SYNTAX_STYLESHEET, ""); |
| 285 | 286 | ctx.body = &fragment; |
src/render.rs +34 −7
| @@ -26,7 +26,7 @@ use syntect::util::LinesWithEndings; | ||
| 26 | 26 | use crate::model::{Checkbox, Element, Link, LinkTarget, ListKind, Object, Section, TableRow}; |
| 27 | 27 | use crate::parser::is_image_target; |
| 28 | 28 | use crate::resolve::ResolvedDoc; |
| 29 | use crate::util::{plain_text, slugify}; | |
| 29 | use crate::util::{heading_anchor, plain_text, slugify}; | |
| 30 | 30 | |
| 31 | 31 | /// A rendered HTML fragment (content only — no page chrome; spec §2.4). |
| 32 | 32 | #[derive(Debug, Clone)] |
| @@ -137,6 +137,8 @@ struct Renderer<'a> { | ||
| 137 | 137 | inline_defs: HashMap<String, Vec<Object>>, |
| 138 | 138 | /// Reference keys in order of first appearance — drives numbering and note order. |
| 139 | 139 | order: Vec<String>, |
| 140 | /// Counter per heading depth, for section numbers. | |
| 141 | counters: Vec<usize>, | |
| 140 | 142 | } |
| 141 | 143 | |
| 142 | 144 | /// Options affecting how the tree becomes HTML. Presentation choices that belong to the |
| @@ -147,12 +149,17 @@ pub struct RenderOptions { | ||
| 147 | 149 | /// beneath a page title supplied by the layout. See |
| 148 | 150 | /// [`HtmlOutput::heading_offset`](crate::config::HtmlOutput::heading_offset). |
| 149 | 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 | |
| 152 | 157 | impl Default for RenderOptions { |
| 153 | 158 | fn default() -> Self { |
| 159 | let html = crate::config::HtmlOutput::default(); | |
| 154 | 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 | 177 | block_defs: HashMap::new(), |
| 171 | 178 | inline_defs: HashMap::new(), |
| 172 | 179 | order: Vec::new(), |
| 180 | counters: Vec::new(), | |
| 173 | 181 | }; |
| 174 | 182 | r.collect_defs(&doc.document.root); |
| 175 | 183 | let mut out = String::new(); |
| @@ -190,11 +198,7 @@ impl Renderer<'_> { | ||
| 190 | 198 | fn render_section(&mut self, section: &Section, out: &mut String) { |
| 191 | 199 | if let Some(h) = §ion.heading { |
| 192 | 200 | 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); | |
| 198 | 202 | // A heading with no title text has no meaningful slug; emit no `id` at all |
| 199 | 203 | // rather than a run of duplicate empty ones. |
| 200 | 204 | if anchor.is_empty() { |
| @@ -202,6 +206,13 @@ impl Renderer<'_> { | ||
| 202 | 206 | } else { |
| 203 | 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 | 216 | // Keyword/priority markup mirrors Emacs' own HTML export classes, so output |
| 206 | 217 | // stays diffable against an `emacs --batch` oracle. |
| 207 | 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 | 262 | fn render_element(&mut self, element: &Element, out: &mut String) { |
| 236 | 263 | match element { |
| 237 | 264 | Element::Paragraph(objs) => { |
src/site.rs +21 −11
| @@ -33,8 +33,8 @@ use crate::template::{ | ||
| 33 | 33 | Templater, |
| 34 | 34 | }; |
| 35 | 35 | use 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, | |
| 38 | 38 | }; |
| 39 | 39 | |
| 40 | 40 | /// Reading speed for [`PageContext::reading_time`]. 200 wpm is the conventional figure |
| @@ -481,6 +481,7 @@ fn listing_context(listing: &Listing) -> PageContext { | ||
| 481 | 481 | word_count: 0, |
| 482 | 482 | reading_time: 0, |
| 483 | 483 | keywords: Default::default(), |
| 484 | toc: Vec::new(), | |
| 484 | 485 | } |
| 485 | 486 | } |
| 486 | 487 | |
| @@ -613,7 +614,7 @@ fn prepare_pages( | ||
| 613 | 614 | .collect(); |
| 614 | 615 | |
| 615 | 616 | PagePrep { |
| 616 | context: page_context(doc, &output), | |
| 617 | context: page_context(doc, &output, config), | |
| 617 | 618 | source: doc.source_path.clone(), |
| 618 | 619 | output, |
| 619 | 620 | title: page_title(doc), |
| @@ -641,7 +642,6 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> { | ||
| 641 | 642 | let templater = Templater::load(Some(&src.join(&config.templates.dir)), &config.site.base_url)?; |
| 642 | 643 | let site = site_context(&config); |
| 643 | 644 | let listing = page_listing(&config, &preps); |
| 644 | let render_opts = render_options(&config); | |
| 645 | 645 | |
| 646 | 646 | let mut pages = Vec::new(); |
| 647 | 647 | let mut broken = Vec::new(); |
| @@ -649,7 +649,7 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> { | ||
| 649 | 649 | for t in &p.broken { |
| 650 | 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 | 653 | pages.push(BuiltPage { |
| 654 | 654 | source: p.source.clone(), |
| 655 | 655 | output: p.output.clone(), |
| @@ -660,9 +660,12 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> { | ||
| 660 | 660 | Ok((pages, broken)) |
| 661 | 661 | } |
| 662 | 662 | |
| 663 | fn 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. | |
| 665 | fn render_options(config: &Config, keywords: &crate::model::Keywords) -> RenderOptions { | |
| 664 | 666 | RenderOptions { |
| 665 | 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 | 694 | highlighter: &SyntectHighlighter, |
| 692 | 695 | site: &SiteContext, |
| 693 | 696 | pages: Option<&[PageContext]>, |
| 694 | render_opts: &RenderOptions, | |
| 697 | config: &Config, | |
| 695 | 698 | p: &PagePrep, |
| 696 | 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 | 704 | // Relative to the *output* path, since `#+SLUG:` can move a page between depths. |
| 699 | 705 | let root = relative_root(&p.output); |
| 700 | 706 | let stylesheet = format!("{root}{SYNTAX_STYLESHEET}"); |
| @@ -833,7 +839,6 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result | ||
| 833 | 839 | let highlighter = SyntectHighlighter::new(); |
| 834 | 840 | let site = site_context(&cfg); |
| 835 | 841 | let listing = page_listing(&cfg, &preps); |
| 836 | let render_opts = render_options(&cfg); | |
| 837 | 842 | let mut report = SiteReport::default(); |
| 838 | 843 | |
| 839 | 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 | 860 | if let Some(parent) = dest.parent() { |
| 856 | 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 | 864 | fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?; |
| 860 | 865 | Ok(true) |
| 861 | 866 | }) |
| @@ -1163,7 +1168,7 @@ fn is_top_level(output: &Utf8Path) -> bool { | ||
| 1163 | 1168 | /// Everything a template can know about one page. Every `#+KEYWORD:` is passed through |
| 1164 | 1169 | /// under its lowercased name, so a template can use metadata this crate has never heard |
| 1165 | 1170 | /// of without the crate needing a release to support it. |
| 1166 | fn page_context(doc: &Document, output: &Utf8Path) -> PageContext { | |
| 1171 | fn page_context(doc: &Document, output: &Utf8Path, config: &Config) -> PageContext { | |
| 1167 | 1172 | let words = document_text(&doc.root).split_whitespace().count(); |
| 1168 | 1173 | let keyword = |name: &str| { |
| 1169 | 1174 | doc.keywords |
| @@ -1184,6 +1189,11 @@ fn page_context(doc: &Document, output: &Utf8Path) -> PageContext { | ||
| 1184 | 1189 | .unwrap_or_default(), |
| 1185 | 1190 | word_count: words, |
| 1186 | 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 | 1197 | tags: keyword("FILETAGS") |
| 1188 | 1198 | .unwrap_or_default() |
| 1189 | 1199 | .split(':') |
src/template.rs +19 −2
| @@ -63,12 +63,15 @@ pub struct PageContext { | ||
| 63 | 63 | /// Every `#+KEYWORD:` in the file, keyed by lowercased name, so a template can use |
| 64 | 64 | /// project-specific metadata this crate has never heard of. |
| 65 | 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 | 71 | /// The built-in layout, used when the templates directory has no `base.html`. |
| 69 | 72 | /// Deliberately plain: it should be a working starting point and an obvious thing to |
| 70 | 73 | /// replace, not a design anyone has to live with. |
| 71 | const BASE_TEMPLATE: &str = r#"<!DOCTYPE html> | |
| 74 | const BASE_TEMPLATE: &str = r##"<!DOCTYPE html> | |
| 72 | 75 | <html lang="{{ site.language }}"> |
| 73 | 76 | <head> |
| 74 | 77 | <meta charset="utf-8"> |
| @@ -100,10 +103,24 @@ const BASE_TEMPLATE: &str = r#"<!DOCTYPE html> | ||
| 100 | 103 | {%- if page.date %} |
| 101 | 104 | <p class="page-date">{{ page.date }}</p> |
| 102 | 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 | 120 | {{ body | safe }}</main> |
| 104 | 121 | </body> |
| 105 | 122 | </html> |
| 106 | "#; | |
| 123 | "##; | |
| 107 | 124 | |
| 108 | 125 | /// The name a template must have to serve as the page layout. |
| 109 | 126 | pub const BASE_TEMPLATE_NAME: &str = "base.html"; |
src/util.rs +76 −1
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | |
| 5 | 5 | use camino::{Utf8Path, Utf8PathBuf}; |
| 6 | 6 | |
| 7 | use crate::model::{Element, Keywords, Object, Section, TableRow}; | |
| 7 | use crate::model::{Element, Heading, Keywords, Object, Section, TableRow}; | |
| 8 | 8 | |
| 9 | 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. | |
| 85 | pub 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)] | |
| 95 | pub 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. | |
| 108 | pub fn table_of_contents(root: &Section) -> Vec<TocEntry> { | |
| 109 | root.children.iter().map(toc_entry).collect() | |
| 110 | } | |
| 111 | ||
| 112 | fn 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. | |
| 126 | pub 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. | |
| 146 | pub 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 | 154 | /// Is this document marked as a draft? |
| 80 | 155 | /// |
| 81 | 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 | 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:`. | |
| 1699 | fn 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] | |
| 1724 | fn 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] | |
| 1743 | fn 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] | |
| 1770 | fn 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] | |
| 1785 | fn 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] | |
| 1799 | fn 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] | |
| 1824 | fn 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] | |
| 1858 | fn 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 | 21 | </header> |
| 22 | 22 | <main> |
| 23 | 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 | 31 | <h2 id="setup">Setup</h2> |
| 25 | 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 | 33 | <h2 id="data">Data</h2> |