krz/orgo

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

Commit 34e27a5061

34e27a50613a3f76cb1a9ad7cb096505f45f640a

parent: d6911e2a61

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-11 04:51 UTC

v0.6: make it a generator for any site, not one site

Everything that shaped the output was a constant in the source — the page layout, the
nav rule, the highlighting theme, heading levels. That produced exactly one kind of
site: a reasonable place to start and a dead end for anyone whose site is not that one.

Configuration (src/config.rs, org-ssg.toml):
- site title/base_url/description/language, nav mode, template dir, highlight theme,
  heading offset. Discovered beside the sources or passed with --config, and folded into
  the config hash so editing it invalidates the pages it affects.
- Absent config is a valid config: every field has a default, so a bare directory of
  .org files still builds a complete site. Configuration changes the output; it is never
  what makes it work.
- A missing config is silent, a malformed one is an error, and an unknown key is
  rejected — a misspelled setting that silently does nothing is how people lose an
  afternoon.

Templates:
- base.html in the templates directory replaces the built-in layout entirely; other
  files are available to include/extends. User template sources are part of the template
  hash, so editing a layout re-renders the pages that use it.
- Templates receive site, page, nav, root, stylesheet, and optionally pages.
  page.keywords carries every #+KEYWORD: under its lowercased name, so a user's own
  metadata works without this crate knowing about it.
- expose_page_list makes listing pages possible and is off by default: letting every
  template see every page means adding one page can change any page, so the structure
  hash has to widen to match. The cost is stated rather than hidden.

Nav modes: top-level (default), all, explicit (with configured ordering), none. An
explicit nav naming a page that does not exist is an error, as is a mode/pages
combination where one silently ignores the other.

heading_offset defaults to 1, matching Emacs' org-html-toplevel-hlevel: the layout
supplies the page <h1>, so a level-1 org heading renders as <h2>. This also removes the
override tests/oracle.el needed — both sides now agree on heading levels from their own
defaults rather than because the oracle was told to.

`org-ssg init` scaffolds a working site (config, an editable copy of the layout, a page)
and only writes files that do not exist, so it is safe to run in place.

Two bugs found by using the tool as a newcomer would:
- `org-ssg build . -o _site` copied its own output back into the source tree, nesting
  _site/_site/_site and growing the asset count on every run (2 -> 7 -> 12).
- Discovery published build inputs and dot-directories, so a source directory that was a
  git repo would publish .git — its entire history — next to the homepage.
Discovery now excludes the output directory when nested, the config file, the template
directory, and dot-entries.

Verified against the 179-file corpus: still 179/179 live URLs, zero diagnostics, and the
default output now matches the incumbent's heading structure (<h1> title, <h2> sections).

Layout: unified · split

Cargo.lock +56 −1
@@ -569,7 +569,7 @@ dependencies = [
569 569
570[[package]] 570[[package]]
571name = "org-ssg" 571name = "org-ssg"
572version = "0.5.0" 572version = "0.6.0"
573dependencies = [ 573dependencies = [
574 "anyhow", 574 "anyhow",
575 "blake3", 575 "blake3",
@@ -583,6 +583,7 @@ dependencies = [
583 "serde_json", 583 "serde_json",
584 "syntect", 584 "syntect",
585 "thiserror", 585 "thiserror",
586 "toml",
586 "walkdir", 587 "walkdir",
587] 588]
588 589
@@ -747,6 +748,15 @@ dependencies = [
747 "zmij", 748 "zmij",
748] 749]
749 750
751[[package]]
752name = "serde_spanned"
753version = "1.1.1"
754source = "registry+https://github.com/rust-lang/crates.io-index"
755checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
756dependencies = [
757 "serde_core",
758]
759
750[[package]] 760[[package]]
751name = "shlex" 761name = "shlex"
752version = "2.0.1" 762version = "2.0.1"
@@ -883,6 +893,45 @@ dependencies = [
883 "time-core", 893 "time-core",
884] 894]
885 895
896[[package]]
897name = "toml"
898version = "1.1.4+spec-1.1.0"
899source = "registry+https://github.com/rust-lang/crates.io-index"
900checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
901dependencies = [
902 "indexmap",
903 "serde_core",
904 "serde_spanned",
905 "toml_datetime",
906 "toml_parser",
907 "toml_writer",
908 "winnow",
909]
910
911[[package]]
912name = "toml_datetime"
913version = "1.1.1+spec-1.1.0"
914source = "registry+https://github.com/rust-lang/crates.io-index"
915checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
916dependencies = [
917 "serde_core",
918]
919
920[[package]]
921name = "toml_parser"
922version = "1.1.3+spec-1.1.0"
923source = "registry+https://github.com/rust-lang/crates.io-index"
924checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
925dependencies = [
926 "winnow",
927]
928
929[[package]]
930name = "toml_writer"
931version = "1.1.2+spec-1.1.0"
932source = "registry+https://github.com/rust-lang/crates.io-index"
933checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
934
886[[package]] 935[[package]]
887name = "unicode-ident" 936name = "unicode-ident"
888version = "1.0.24" 937version = "1.0.24"
@@ -1027,6 +1076,12 @@ dependencies = [
1027 "windows-link", 1076 "windows-link",
1028] 1077]
1029 1078
1079[[package]]
1080name = "winnow"
1081version = "1.0.4"
1082source = "registry+https://github.com/rust-lang/crates.io-index"
1083checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
1084
1030[[package]] 1085[[package]]
1031name = "yaml-rust" 1086name = "yaml-rust"
1032version = "0.4.5" 1087version = "0.4.5"
Cargo.toml +2 −1
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "org-ssg" 2name = "org-ssg"
3version = "0.5.0" 3version = "0.6.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"
@@ -31,6 +31,7 @@ clap = { version = "4", features = ["derive"] }
31anyhow = "1" 31anyhow = "1"
32thiserror = "2" 32thiserror = "2"
33rayon = "1.12.0" 33rayon = "1.12.0"
34toml = "1.1.4"
34 35
35[dev-dependencies] 36[dev-dependencies]
36insta = { version = "1", features = ["json"] } 37insta = { version = "1", features = ["json"] }
README.md +93 −8
@@ -13,6 +13,86 @@ hashing**, treated as a first-class architectural concern from day one. The disc
13it imposes on the data model — pure, hashable, dependency-tracked units — is the real 13it imposes on the data model — pure, hashable, dependency-tracked units — is the real
14deliverable, even while the corpus is small enough that a full rebuild is instant. 14deliverable, even while the corpus is small enough that a full rebuild is instant.
15 15
16## Quick start
17
18```bash
19cargo run -- init my-site # config + an editable copy of the layout + a page
20cargo run -- build my-site -o _site
21```
22
23Or skip the scaffolding entirely — point it at any directory of `.org` files:
24
25```bash
26cargo run -- build ~/notes -o _site
27```
28
29**Zero configuration is a supported path, not a demo.** With no `org-ssg.toml`, no
30templates and no org-ssg-specific markup in your files, you get a complete site: pages,
31navigation, syntax-highlighted code and the stylesheet to colour it. Configuration
32changes what you get; it is never what makes it work.
33
34Discovery skips what should not be published — dot-directories such as `.git`, the config
35file, the templates directory, and the output directory when it sits inside the source, so
36`org-ssg build . -o _site` does the obvious thing.
37
38## Configuration
39
40Everything is optional. `org-ssg init` writes a fully commented `org-ssg.toml`; every
41value below is the default.
42
43```toml
44[site]
45title = "org-ssg site"
46base_url = "" # absolute URL, no trailing slash; empty = relative URLs only
47description = ""
48language = "en"
49
50[nav]
51mode = "top-level" # top-level | all | explicit | none
52# pages = ["index.org", "about.org"] # for mode = "explicit"; order is preserved
53
54[templates]
55dir = "templates" # base.html replaces the built-in layout
56expose_page_list = false
57
58[highlight]
59theme = "InspiredGitHub"
60
61[html]
62heading_offset = 1 # a level-1 org heading becomes <h2>, beneath the layout's <h1>
63```
64
65### Templates
66
67Drop a `base.html` into the templates directory and it replaces the built-in layout
68entirely. Any other `.html` file there is available to `{% include %}` and
69`{% extends %}`. Templates are [minijinja](https://docs.rs/minijinja) (Jinja2 syntax) and
70receive:
71
72| Variable | What it is |
73|---|---|
74| `body` | the rendered page HTML — use `{{ body \| safe }}` |
75| `page` | `.title`, `.url`, `.source`, `.date`, `.tags`, `.keywords` |
76| `site` | `.title`, `.base_url`, `.description`, `.language` |
77| `nav` | list of `{title, url}`, relative to this page |
78| `root` | `../`-prefix back to the site root from this page |
79| `stylesheet` | URL of the generated `syntax.css` |
80| `pages` | every page's metadata — only when `expose_page_list = true` |
81
82`page.keywords` carries **every** `#+KEYWORD:` in the file under its lowercased name, so
83your own metadata works without this crate knowing about it: `#+CUSTOM_THING: x` is
84`{{ page.keywords.custom_thing }}`.
85
86Editing a template re-renders the pages that use it — template sources are a hash input,
87so a design change never leaves a site half-updated.
88
89### `#+SLUG:`
90
91A page's output filename comes from its `#+SLUG:` when it has one, so
92`2018-11-28-aes-encryption.org` can publish as `aes-encryption.html`. Without one the
93source filename is used. Slugs are sanitized to a single safe path component, and two
94pages claiming one URL is a build error rather than a silently dropped page.
95
16## Pipeline 96## Pipeline
17 97
18``` 98```
@@ -24,6 +104,7 @@ is the only inherently global stage — it is where the link dependency graph is
24 104
25| Stage | Module | Notes | 105| Stage | Module | Notes |
26|---|---|---| 106|---|---|---|
107| config | `src/config.rs` | `org-ssg.toml`: site metadata, nav mode, templates, theme. A hash input. |
27| PARSE | `src/parser.rs` | Hand-written recursive descent: line lexer → element builder → inline tokenizer. | 108| PARSE | `src/parser.rs` | Hand-written recursive descent: line lexer → element builder → inline tokenizer. |
28| audit | `src/audit.rs` | Phase 0 corpus audit: construct frequencies against the IN/OUT line. | 109| audit | `src/audit.rs` | Phase 0 corpus audit: construct frequencies against the IN/OUT line. |
29| model | `src/model.rs` | The org element tree — Elements (block) vs Objects (inline). | 110| model | `src/model.rs` | The org element tree — Elements (block) vs Objects (inline). |
@@ -72,6 +153,7 @@ all-of-org. Phase 0 checked this line against a real 179-file corpus and found i
72| 5 | Link resolution + symbol table (INDEX + RESOLVE, used-target list, broken-link reporting) | done | 153| 5 | Link resolution + symbol table (INDEX + RESOLVE, used-target list, broken-link reporting) | done |
73| 6 | Incremental build layer (hashing, dep graph, invalidation) done; `watch` is a simple poll loop | done | 154| 6 | Incremental build layer (hashing, dep graph, invalidation) done; `watch` is a simple poll loop | done |
74| **7** | **Hardening: rayon parallelism, error locations in parse diagnostics** | **done** | 155| **7** | **Hardening: rayon parallelism, error locations in parse diagnostics** | **done** |
156| **8** | **General use: config file, user templates, nav modes, `init` scaffold, safe discovery** | **done** |
75 157
76### v0.2 in / out 158### v0.2 in / out
77 159
@@ -173,9 +255,12 @@ and the `watch` fs-notify integration.
173The v1 scope was, by its own admission, *recommended* — a guess about which slice of org 255The v1 scope was, by its own admission, *recommended* — a guess about which slice of org
174matters. Phase 0 replaces both halves of that guess with a measurement: an audit that asks 256matters. Phase 0 replaces both halves of that guess with a measurement: an audit that asks
175what a real corpus actually uses, and an oracle that asks whether we render it the way 257what a real corpus actually uses, and an oracle that asks whether we render it the way
176Emacs does. The corpus is the 179 files behind [cleberg.net](https://cleberg.net), which is 258Emacs does.
177published today by weblorg — a wrapper around org's own HTML exporter. That makes it both 259
178the workload and the incumbent. 260The audit runs against any corpus — point it at your own notes before trusting this tool
261with them. The numbers below come from a 179-file site published today by weblorg, a
262wrapper around org's own HTML exporter, which makes it both a realistic workload and a
263directly comparable incumbent.
179 264
180``` 265```
181cargo run -- audit <src-dir> # what does this corpus use, and is it in scope? 266cargo run -- audit <src-dir> # what does this corpus use, and is it in scope?
@@ -309,10 +394,9 @@ blog post used to re-render the entire site; now it renders one page.** A top-le
309title still invalidates everything, correctly, since every page displays it. 394title still invalidates everything, correctly, since every page displays it.
310 395
311**Trade-off worth knowing:** on a site whose sections live in subdirectories, only genuinely 396**Trade-off worth knowing:** on a site whose sections live in subdirectories, only genuinely
312root-level pages appear. cleberg.net keeps its landing pages at `content/salary/index.org` 397root-level pages appear — a site keeping its landing pages at `salary/index.org` and friends
313and friends, so its nav comes out as a single `index.org` entry where the live site shows 398gets a one-entry nav. That is what `nav.mode = "explicit"` is for: list the pages you want,
314four. Treating a directory's `index.org` as top-level too is a one-line change to 399in the order you want them.
315`is_top_level` if that is the behaviour you want.
316 400
317**From v0.1 (core subset):** headings with nesting and anchors (every heading is now 401**From v0.1 (core subset):** headings with nesting and anchors (every heading is now
318anchored — `:CUSTOM_ID:`/`:ID:` else a slug of its text) and trailing tags; paragraphs; 402anchored — `:CUSTOM_ID:`/`:ID:` else a slug of its text) and trailing tags; paragraphs;
@@ -332,7 +416,8 @@ PARSE/RESOLVE/RENDER), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserro
332 416
333``` 417```
334cargo build 418cargo build
335cargo test 419cargo test # 86 tests
420cargo run -- init my-site # scaffold a new site
336cargo run -- build fixtures/minimal.org -o minimal.html # single file 421cargo run -- build fixtures/minimal.org -o minimal.html # single file
337cargo run -- build fixtures/site -o _site # whole site (incremental) 422cargo run -- build fixtures/site -o _site # whole site (incremental)
338cargo run -- audit fixtures/site # corpus audit (Phase 0) 423cargo run -- audit fixtures/site # corpus audit (Phase 0)
src/config.rs added +239
@@ -0,0 +1,239 @@
1//! User-facing build configuration (`org-ssg.toml`).
2//!
3//! Everything here was once a constant in the source: the page layout, the nav rule, the
4//! highlighting theme. That made the generator produce exactly one kind of site — a
5//! reasonable place to start from, and a dead end for anyone whose site is not that one.
6//!
7//! Two properties matter beyond the settings themselves:
8//!
9//! 1. **Absent config is a valid config.** Every field has a default, so a directory of
10//! `.org` files with no `org-ssg.toml` still builds. Configuration is how you change
11//! the output, never how you make it work at all.
12//! 2. **Config is a hash input** (spec §4.1). [`Config`] serializes deterministically and
13//! its hash is folded into every page's render key, so editing `org-ssg.toml` re-renders
14//! exactly the pages it affects — which for most settings is all of them.
15
16use anyhow::{Context, Result};
17use camino::{Utf8Path, Utf8PathBuf};
18use serde::{Deserialize, Serialize};
19
20/// The config file's name, looked for in the source directory.
21pub const CONFIG_FILE: &str = "org-ssg.toml";
22
23/// Resolved build configuration. Serialized into the config hash, so field order and
24/// defaults are part of the cache contract.
25#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
26#[serde(default, deny_unknown_fields)]
27pub struct Config {
28 pub site: Site,
29 pub nav: Nav,
30 pub templates: Templates,
31 pub highlight: Highlight,
32 pub html: HtmlOutput,
33}
34
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36#[serde(default, deny_unknown_fields)]
37pub struct HtmlOutput {
38 /// How far to push heading levels down: a level-1 org heading becomes
39 /// `<h{1 + heading_offset}>`.
40 ///
41 /// Defaults to 1, matching Emacs' own `org-html-toplevel-hlevel`, because the page
42 /// layout supplies the `<h1>` — the document's title — and section headings sit
43 /// beneath it. Set to 0 if your template renders no title of its own, so the
44 /// document does not start at `<h2>` with nothing above it.
45 pub heading_offset: u8,
46}
47
48impl Default for HtmlOutput {
49 fn default() -> Self {
50 HtmlOutput { heading_offset: 1 }
51 }
52}
53
54/// Site-wide metadata, exposed to templates as `site`.
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56#[serde(default, deny_unknown_fields)]
57pub struct Site {
58 /// Shown in the default layout's header and available as `site.title`.
59 pub title: String,
60 /// Absolute base URL (no trailing slash), for feeds and canonical links. Empty means
61 /// the site is built with relative URLs only, which is the portable default.
62 pub base_url: String,
63 /// Free-form description, available as `site.description`.
64 pub description: String,
65 /// `<html lang="…">` in the default layout.
66 pub language: String,
67}
68
69impl Default for Site {
70 fn default() -> Self {
71 Site {
72 title: "org-ssg site".to_string(),
73 base_url: String::new(),
74 description: String::new(),
75 language: "en".to_string(),
76 }
77 }
78}
79
80/// Which pages appear in the shared navigation.
81#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "kebab-case")]
83pub enum NavMode {
84 /// Pages at the site root. A nav is a map of the top level, not an index of the
85 /// whole site, and this keeps nav size independent of how many pages exist.
86 #[default]
87 TopLevel,
88 /// Every page. Fine for a small site; note that it makes total output quadratic in
89 /// page count, since each of `n` pages then carries `n` nav links.
90 All,
91 /// Only the pages listed in `nav.pages`, in that order.
92 Explicit,
93 /// No navigation at all.
94 None,
95}
96
97#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
98#[serde(default, deny_unknown_fields)]
99pub struct Nav {
100 pub mode: NavMode,
101 /// Source paths (relative to the source root, e.g. `about.org`) used when
102 /// `mode = "explicit"`. Order is preserved, so this doubles as nav ordering.
103 pub pages: Vec<Utf8PathBuf>,
104}
105
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107#[serde(default, deny_unknown_fields)]
108pub struct Templates {
109 /// Directory of `.html` templates, relative to the source root. Each file is
110 /// registered under its stem, so `base.html` overrides the built-in layout and
111 /// anything else is available to `{% include %}`/`{% extends %}`.
112 pub dir: Utf8PathBuf,
113 /// Give templates a `pages` list of every page's metadata, so a template can build
114 /// an index or archive.
115 ///
116 /// Off by default because it is not free: if any page can read every page's
117 /// metadata, then adding one page can change any page's output, so the whole site
118 /// must re-render on every add, rename or retitle. Turning this on trades that
119 /// incremental precision for the ability to write listing pages.
120 pub expose_page_list: bool,
121}
122
123impl Default for Templates {
124 fn default() -> Self {
125 Templates {
126 dir: Utf8PathBuf::from("templates"),
127 expose_page_list: false,
128 }
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133#[serde(default, deny_unknown_fields)]
134pub struct Highlight {
135 /// A syntect built-in theme name — `InspiredGitHub`, `Solarized (dark)`,
136 /// `base16-ocean.dark`, `base16-eighties.dark`, `base16-mocha.dark`,
137 /// `base16-ocean.light`. Highlighting emits CSS classes, and this theme is what the
138 /// generated `syntax.css` colours them with.
139 pub theme: String,
140}
141
142impl Default for Highlight {
143 fn default() -> Self {
144 Highlight {
145 theme: "InspiredGitHub".to_string(),
146 }
147 }
148}
149
150impl Config {
151 /// Load `org-ssg.toml` from `dir`, or return defaults if there is none.
152 ///
153 /// A *missing* config is normal and silent. A *malformed* one is an error: someone
154 /// who wrote a config meant it, and silently building the default site would hide
155 /// their typo behind plausible-looking output.
156 pub fn load(dir: &Utf8Path) -> Result<Config> {
157 Self::load_file(&dir.join(CONFIG_FILE))
158 }
159
160 /// Load a config from an explicit path. Missing is still fine; malformed is not.
161 pub fn load_file(path: &Utf8Path) -> Result<Config> {
162 let text = match std::fs::read_to_string(path) {
163 Ok(text) => text,
164 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Config::default()),
165 Err(e) => return Err(e).with_context(|| format!("reading {path}")),
166 };
167 toml::from_str(&text).with_context(|| format!("parsing {path}"))
168 }
169
170 /// Validate settings that only make sense in combination. Catching these up front
171 /// beats emitting a site with a silently empty nav.
172 pub fn validate(&self) -> Result<()> {
173 if self.nav.mode == NavMode::Explicit && self.nav.pages.is_empty() {
174 anyhow::bail!(
175 "nav.mode is \"explicit\" but nav.pages is empty: list the pages to \
176 include, or use mode = \"top-level\"/\"all\"/\"none\""
177 );
178 }
179 if self.nav.mode != NavMode::Explicit && !self.nav.pages.is_empty() {
180 anyhow::bail!(
181 "nav.pages is set but nav.mode is \"{}\", so it would be ignored; set \
182 mode = \"explicit\" to use it",
183 toml::to_string(&self.nav.mode)
184 .unwrap_or_default()
185 .trim()
186 .trim_matches('"')
187 );
188 }
189 if !self.site.base_url.is_empty() && self.site.base_url.ends_with('/') {
190 anyhow::bail!(
191 "site.base_url must not end with a slash (got {:?}) — URLs are joined \
192 with an explicit separator",
193 self.site.base_url
194 );
195 }
196 Ok(())
197 }
198}
199
200/// The starter config written by `org-ssg init`, and the documentation of record for
201/// what is configurable. Every value shown is the default, so deleting any line is safe.
202pub const STARTER_CONFIG: &str = r#"# org-ssg configuration. Every setting here is optional and shown at its default,
203# so you can delete any line you do not need — or the whole file.
204
205[site]
206title = "org-ssg site"
207# Absolute base URL, no trailing slash. Leave empty to build with relative URLs only.
208base_url = ""
209description = ""
210language = "en"
211
212[nav]
213# Which pages appear in the shared navigation:
214# "top-level" — pages at the site root (default; keeps nav size independent of site size)
215# "all" — every page (fine when small; output grows quadratically with page count)
216# "explicit" — only nav.pages, in the order listed
217# "none" — no navigation
218mode = "top-level"
219# pages = ["index.org", "about.org"]
220
221[templates]
222# Directory of .html templates, relative to this file. `base.html` replaces the built-in
223# layout; any other file can be pulled in with {% include %} or {% extends %}.
224dir = "templates"
225# Give templates a `pages` list of every page's metadata, so you can build an index or
226# archive. Costs incremental precision: with this on, adding a page re-renders the site.
227expose_page_list = false
228
229[highlight]
230# A syntect theme name: InspiredGitHub, Solarized (dark), base16-ocean.dark,
231# base16-eighties.dark, base16-mocha.dark, base16-ocean.light.
232theme = "InspiredGitHub"
233
234[html]
235# How far to push heading levels down: a level-1 org heading becomes <h(1 + offset)>.
236# The default of 1 matches Emacs, and assumes your layout renders the page title as the
237# <h1>. Set to 0 if your template renders no title of its own.
238heading_offset = 1
239"#;
src/incremental.rs +5 −19
@@ -34,24 +34,10 @@ pub const CACHE_FORMAT_VERSION: u32 = 4;
34/// blake3 hex identity for a content/config/template/render-key hash class (spec §4.1). 34/// blake3 hex identity for a content/config/template/render-key hash class (spec §4.1).
35pub type Hash = ContentHash; 35pub type Hash = ContentHash;
36 36
37/// Resolved global build config. Its hash is a component of every page's render key 37/// The resolved global build config is [`crate::config::Config`]; its hash is a
38/// (spec §4.1): a change here can invalidate the whole site. Kept minimal for v0.3 — 38/// component of every page's render key (spec §4.1), so editing `org-ssg.toml`
39/// there is no user-facing config yet — but structured so real knobs (base URL, TODO 39/// invalidates the pages it affects.
40/// keyword set, highlighter theme id, inline features) flow into the hash when added. 40pub use crate::config::Config as BuildConfig;
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct BuildConfig {
43 pub output_extension: String,
44 pub highlighter_theme: String,
45}
46
47impl Default for BuildConfig {
48 fn default() -> Self {
49 BuildConfig {
50 output_extension: "html".to_string(),
51 highlighter_theme: crate::render::SYNTAX_THEME.to_string(),
52 }
53 }
54}
55 41
56/// Compose bytes into a blake3 hash. The one place hashing happens for composite keys. 42/// Compose bytes into a blake3 hash. The one place hashing happens for composite keys.
57fn hash_bytes(bytes: &[u8]) -> Hash { 43fn hash_bytes(bytes: &[u8]) -> Hash {
@@ -93,7 +79,7 @@ pub fn site_structure_hash(entries: &[(String, String)]) -> Hash {
93/// blake3 over the template sources (spec §4.1, hash class 3). One combined hash over 79/// blake3 over the template sources (spec §4.1, hash class 3). One combined hash over
94/// all templates; when partials land, split this per-template so a single-partial edit 80/// all templates; when partials land, split this per-template so a single-partial edit
95/// invalidates only its users. 81/// invalidates only its users.
96pub fn template_hash(sources: &[(&str, &str)]) -> Hash { 82pub fn template_hash(sources: &[(String, String)]) -> Hash {
97 let mut hasher = blake3::Hasher::new(); 83 let mut hasher = blake3::Hasher::new();
98 for (name, src) in sources { 84 for (name, src) in sources {
99 hasher.update(name.as_bytes()); 85 hasher.update(name.as_bytes());
src/lib.rs +1
@@ -9,6 +9,7 @@
9//! deciding which pages actually need rewriting. 9//! deciding which pages actually need rewriting.
10 10
11pub mod audit; 11pub mod audit;
12pub mod config;
12pub mod incremental; 13pub mod incremental;
13pub mod index; 14pub mod index;
14pub mod model; 15pub mod model;
src/main.rs +101 −7
@@ -7,10 +7,11 @@ use camino::{Utf8Path, Utf8PathBuf};
7use clap::{Parser, Subcommand}; 7use clap::{Parser, Subcommand};
8 8
9use org_ssg::parser::parse; 9use org_ssg::parser::parse;
10use org_ssg::render::{render, syntax_css, Html, SyntectHighlighter}; 10use org_ssg::config::Config;
11use org_ssg::render::{self, render, Html, SyntectHighlighter};
11use org_ssg::resolve::ResolvedDoc; 12use org_ssg::resolve::ResolvedDoc;
12use org_ssg::site::{build_site, BuildOptions, SYNTAX_STYLESHEET}; 13use org_ssg::site::{build_site, BuildOptions, SYNTAX_STYLESHEET};
13use org_ssg::template::Templater; 14use org_ssg::template::{PageContext, SiteContext, Templater};
14 15
15#[derive(Parser)] 16#[derive(Parser)]
16#[command(name = "org-ssg", version, about = "Org-mode static site generator")] 17#[command(name = "org-ssg", version, about = "Org-mode static site generator")]
@@ -32,9 +33,12 @@ enum Command {
32 /// Bypass the incremental cache and re-render every page (spec §4.5). 33 /// Bypass the incremental cache and re-render every page (spec §4.5).
33 #[arg(long)] 34 #[arg(long)]
34 no_cache: bool, 35 no_cache: bool,
35 /// Treat broken internal links as errors (spec §4.3.4). 36 /// Treat broken links and parse diagnostics as errors (spec §4.3.4).
36 #[arg(long)] 37 #[arg(long)]
37 strict: bool, 38 strict: bool,
39 /// Config file to use, overriding `org-ssg.toml` in the source directory.
40 #[arg(long, value_name = "FILE")]
41 config: Option<Utf8PathBuf>,
38 }, 42 },
39 /// Watch a source directory and rebuild incrementally on change (simple poll loop). 43 /// Watch a source directory and rebuild incrementally on change (simple poll loop).
40 Watch { 44 Watch {
@@ -55,6 +59,13 @@ enum Command {
55 /// Source directory (or single `.org` file) to audit. 59 /// Source directory (or single `.org` file) to audit.
56 input: Utf8PathBuf, 60 input: Utf8PathBuf,
57 }, 61 },
62 /// Scaffold a new site: config, an editable copy of the default layout, and a page.
63 Init {
64 /// Directory to create the site in (created if missing; defaults to the
65 /// current directory).
66 #[arg(default_value = ".")]
67 directory: Utf8PathBuf,
68 },
58} 69}
59 70
60fn main() -> Result<()> { 71fn main() -> Result<()> {
@@ -65,11 +76,16 @@ fn main() -> Result<()> {
65 output, 76 output,
66 no_cache, 77 no_cache,
67 strict, 78 strict,
79 config,
68 } => { 80 } => {
69 if input.is_dir() { 81 if input.is_dir() {
70 let out = output 82 let out = output
71 .context("site build requires an output directory: build <src-dir> -o <out-dir>")?; 83 .context("site build requires an output directory: build <src-dir> -o <out-dir>")?;
72 let opts = BuildOptions { no_cache, strict }; 84 let opts = BuildOptions {
85 no_cache,
86 strict,
87 config_path: config.clone(),
88 };
73 let report = build_site(&input, &out, &opts)?; 89 let report = build_site(&input, &out, &opts)?;
74 println!( 90 println!(
75 "built {} page(s) ({} rendered, {} cached), copied {} asset(s) from {} -> {} ({} unresolved link(s), {} diagnostic(s))", 91 "built {} page(s) ({} rendered, {} cached), copied {} asset(s) from {} -> {} ({} unresolved link(s), {} diagnostic(s))",
@@ -98,6 +114,7 @@ fn main() -> Result<()> {
98 print!("{}", org_ssg::audit::report(&result)); 114 print!("{}", org_ssg::audit::report(&result));
99 Ok(()) 115 Ok(())
100 } 116 }
117 Command::Init { directory } => init(&directory),
101 Command::Clean { output } => { 118 Command::Clean { output } => {
102 if output.exists() { 119 if output.exists() {
103 fs::remove_dir_all(&output) 120 fs::remove_dir_all(&output)
@@ -111,6 +128,56 @@ fn main() -> Result<()> {
111 } 128 }
112} 129}
113 130
131/// Scaffold a working site. Writes only files that do not already exist, so running it
132/// in a directory that has content is safe and additive rather than destructive.
133fn init(dir: &Utf8Path) -> Result<()> {
134 use org_ssg::config::{CONFIG_FILE, STARTER_CONFIG};
135 use org_ssg::template::starter_template;
136
137 fs::create_dir_all(dir).with_context(|| format!("creating {dir}"))?;
138 fs::create_dir_all(dir.join("templates")).with_context(|| format!("creating {dir}/templates"))?;
139
140 let index = concat!(
141 "#+TITLE: Hello\n",
142 "#+DATE: today\n",
143 "\n",
144 "Welcome to your new site. Edit this file, then run the build again.\n",
145 "\n",
146 "* A heading\n",
147 "\n",
148 "Org markup works as you would expect: *bold*, /italic/, ~code~, and\n",
149 "[[https://orgmode.org][links]].\n",
150 "\n",
151 "#+BEGIN_SRC rust\n",
152 "fn main() {\n",
153 " println!(\"syntax highlighting is on by default\");\n",
154 "}\n",
155 "#+END_SRC\n",
156 );
157
158 let files: [(Utf8PathBuf, &str); 3] = [
159 (dir.join(CONFIG_FILE), STARTER_CONFIG),
160 (dir.join("templates/base.html"), starter_template()),
161 (dir.join("index.org"), index),
162 ];
163
164 let mut created = Vec::new();
165 for (path, contents) in &files {
166 if path.exists() {
167 println!("kept existing {path}");
168 continue;
169 }
170 fs::write(path, contents).with_context(|| format!("writing {path}"))?;
171 created.push(path.clone());
172 }
173
174 for path in &created {
175 println!("created {path}");
176 }
177 println!("\nNext: org-ssg build {dir} -o _site");
178 Ok(())
179}
180
114/// Minimal poll-based watch loop: rebuild incrementally whenever a source file changes. 181/// Minimal poll-based watch loop: rebuild incrementally whenever a source file changes.
115/// Not an OS file-watcher (deferred); it snapshots source mtimes every 500ms. 182/// Not an OS file-watcher (deferred); it snapshots source mtimes every 500ms.
116fn watch(input: &Utf8Path, output: &Utf8Path) -> Result<()> { 183fn watch(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
@@ -187,13 +254,40 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
187 let highlighter = SyntectHighlighter::new(); 254 let highlighter = SyntectHighlighter::new();
188 let Html(fragment) = render(&resolved, &highlighter); 255 let Html(fragment) = render(&resolved, &highlighter);
189 256
190 let templater = Templater::new(); 257 // A single-file build still honours a config beside the source, so `build one.org`
258 // and a whole-site build produce the same-looking page.
259 let dir = input.parent().unwrap_or_else(|| Utf8Path::new("."));
260 let config = Config::load(dir)?;
261 config.validate()?;
262 let templater = Templater::load(Some(&dir.join(&config.templates.dir)))?;
263 let css_text = render::syntax_css(&config.highlight.theme).ok_or_else(|| {
264 anyhow::anyhow!(
265 "unknown highlight.theme {:?}. Available: {}",
266 config.highlight.theme,
267 render::available_themes().join(", ")
268 )
269 })?;
270
271 let site = SiteContext {
272 title: config.site.title.clone(),
273 base_url: config.site.base_url.clone(),
274 description: config.site.description.clone(),
275 language: config.site.language.clone(),
276 };
277 let page_ctx = PageContext {
278 title: title.clone(),
279 url: output.file_name().unwrap_or("index.html").to_string(),
280 source: input.to_string(),
281 date: None,
282 tags: Vec::new(),
283 keywords: Default::default(),
284 };
191 let page = templater 285 let page = templater
192 .render_page(&title, &fragment, &[], SYNTAX_STYLESHEET) 286 .render_page(&site, &page_ctx, &fragment, &[], SYNTAX_STYLESHEET, "", None)
193 .with_context(|| format!("templating {input}"))?; 287 .with_context(|| format!("templating {input}"))?;
194 fs::write(output, page).with_context(|| format!("writing output file {output}"))?; 288 fs::write(output, page).with_context(|| format!("writing output file {output}"))?;
195 289
196 let css = output.with_file_name(SYNTAX_STYLESHEET); 290 let css = output.with_file_name(SYNTAX_STYLESHEET);
197 fs::write(&css, syntax_css()).with_context(|| format!("writing stylesheet {css}"))?; 291 fs::write(&css, css_text).with_context(|| format!("writing stylesheet {css}"))?;
198 Ok(()) 292 Ok(())
199} 293}
src/render.rs +45 −19
@@ -42,11 +42,6 @@ pub trait Highlighter {
42/// two must agree or the CSS will not match the markup. 42/// two must agree or the CSS will not match the markup.
43const CLASS_STYLE: ClassStyle = ClassStyle::Spaced; 43const CLASS_STYLE: ClassStyle = ClassStyle::Spaced;
44 44
45/// The syntect theme whose colours become [`syntax_css`]. Mirrored in
46/// [`BuildConfig::highlighter_theme`](crate::incremental::BuildConfig) so a theme change
47/// flows into the config hash and invalidates every page.
48pub const SYNTAX_THEME: &str = "InspiredGitHub";
49
50/// Syntect's default syntax definitions, loaded once per process (loading is far more 45/// Syntect's default syntax definitions, loaded once per process (loading is far more
51/// expensive than highlighting, and a site build highlights many blocks). 46/// expensive than highlighting, and a site build highlights many blocks).
52fn syntax_set() -> &'static SyntaxSet { 47fn syntax_set() -> &'static SyntaxSet {
@@ -54,18 +49,24 @@ fn syntax_set() -> &'static SyntaxSet {
54 SET.get_or_init(SyntaxSet::load_defaults_newlines) 49 SET.get_or_init(SyntaxSet::load_defaults_newlines)
55} 50}
56 51
57/// The stylesheet the emitted highlight classes refer to. Highlighting emits CSS 52fn theme_set() -> &'static ThemeSet {
58/// classes rather than inline styles (spec §3.2), so a build must also emit this. 53 static THEMES: OnceLock<ThemeSet> = OnceLock::new();
59pub fn syntax_css() -> &'static str { 54 THEMES.get_or_init(ThemeSet::load_defaults)
60 static CSS: OnceLock<String> = OnceLock::new(); 55}
61 CSS.get_or_init(|| { 56
62 let themes = ThemeSet::load_defaults(); 57/// The stylesheet the emitted highlight classes refer to, for a named syntect theme.
63 themes 58/// Highlighting emits CSS classes rather than inline styles (spec §3.2), so a build must
64 .themes 59/// also emit this. `None` means the theme name is not one syntect ships — the caller
65 .get(SYNTAX_THEME) 60/// reports that rather than quietly emitting an empty stylesheet, which would look like
66 .and_then(|theme| css_for_theme_with_class_style(theme, CLASS_STYLE).ok()) 61/// highlighting is broken.
67 .unwrap_or_default() 62pub fn syntax_css(theme: &str) -> Option<String> {
68 }) 63 let theme = theme_set().themes.get(theme)?;
64 css_for_theme_with_class_style(theme, CLASS_STYLE).ok()
65}
66
67/// Every theme name [`syntax_css`] accepts, for error messages and documentation.
68pub fn available_themes() -> Vec<&'static str> {
69 theme_set().themes.keys().map(String::as_str).collect()
69} 70}
70 71
71/// The v1 highlighter: syntect tokenizing to CSS-class spans (spec §3.2, §4.2). A block 72/// The v1 highlighter: syntect tokenizing to CSS-class spans (spec §3.2, §4.2). A block
@@ -129,6 +130,7 @@ fn language_class(lang: Option<&str>) -> String {
129/// Carries the highlighter plus the footnote collector across the tree walk (spec §2.4). 130/// Carries the highlighter plus the footnote collector across the tree walk (spec §2.4).
130struct Renderer<'a> { 131struct Renderer<'a> {
131 hl: &'a dyn Highlighter, 132 hl: &'a dyn Highlighter,
133 opts: RenderOptions,
132 /// Block footnote definitions, keyed by label (collected before the walk). 134 /// Block footnote definitions, keyed by label (collected before the walk).
133 block_defs: HashMap<String, Vec<Element>>, 135 block_defs: HashMap<String, Vec<Element>>,
134 /// Inline footnote definitions discovered at reference sites. 136 /// Inline footnote definitions discovered at reference sites.
@@ -137,10 +139,34 @@ struct Renderer<'a> {
137 order: Vec<String>, 139 order: Vec<String>,
138} 140}
139 141
140/// Render a resolved document to an HTML fragment. 142/// Options affecting how the tree becomes HTML. Presentation choices that belong to the
143/// site rather than to the document.
144#[derive(Debug, Clone, Copy)]
145pub struct RenderOptions {
146 /// Added to every heading's level, so a level-1 org heading can render as `<h2>`
147 /// beneath a page title supplied by the layout. See
148 /// [`HtmlOutput::heading_offset`](crate::config::HtmlOutput::heading_offset).
149 pub heading_offset: u8,
150}
151
152impl Default for RenderOptions {
153 fn default() -> Self {
154 RenderOptions {
155 heading_offset: crate::config::HtmlOutput::default().heading_offset,
156 }
157 }
158}
159
160/// Render a resolved document to an HTML fragment, with default options.
141pub fn render(doc: &ResolvedDoc, highlighter: &dyn Highlighter) -> Html { 161pub fn render(doc: &ResolvedDoc, highlighter: &dyn Highlighter) -> Html {
162 render_with(doc, highlighter, &RenderOptions::default())
163}
164
165/// Render a resolved document to an HTML fragment.
166pub fn render_with(doc: &ResolvedDoc, highlighter: &dyn Highlighter, opts: &RenderOptions) -> Html {
142 let mut r = Renderer { 167 let mut r = Renderer {
143 hl: highlighter, 168 hl: highlighter,
169 opts: *opts,
144 block_defs: HashMap::new(), 170 block_defs: HashMap::new(),
145 inline_defs: HashMap::new(), 171 inline_defs: HashMap::new(),
146 order: Vec::new(), 172 order: Vec::new(),
@@ -163,7 +189,7 @@ impl Renderer<'_> {
163 189
164 fn render_section(&mut self, section: &Section, out: &mut String) { 190 fn render_section(&mut self, section: &Section, out: &mut String) {
165 if let Some(h) = &section.heading { 191 if let Some(h) = &section.heading {
166 let level = h.level.clamp(1, 6); 192 let level = h.level.saturating_add(self.opts.heading_offset).clamp(1, 6);
167 let anchor = h 193 let anchor = h
168 .custom_id 194 .custom_id
169 .clone() 195 .clone()
src/site.rs +258 −48
@@ -19,14 +19,15 @@ use walkdir::WalkDir;
19 19
20use crate::incremental::{ 20use crate::incremental::{
21 self, combine, config_hash, render_key, resolved_links_hash, site_structure_hash, 21 self, combine, config_hash, render_key, resolved_links_hash, site_structure_hash,
22 template_hash, BuildConfig, DepGraph, Hash, Manifest, PageRecord, CACHE_FORMAT_VERSION, 22 template_hash, DepGraph, Hash, Manifest, PageRecord, CACHE_FORMAT_VERSION,
23}; 23};
24use crate::index::{document_targets, SymbolTable, TargetId}; 24use crate::index::{document_targets, SymbolTable, TargetId};
25use crate::model::{ContentHash, Diagnostic, Document}; 25use crate::model::{ContentHash, Diagnostic, Document};
26use crate::parser::parse; 26use crate::parser::parse;
27use crate::render::{render, syntax_css, Html, SyntectHighlighter}; 27use crate::render::{self, render_with, Html, RenderOptions, SyntectHighlighter};
28use crate::resolve::resolve; 28use crate::resolve::resolve;
29use crate::template::{template_sources, NavItem, Templater}; 29use crate::config::{self, Config, NavMode};
30use crate::template::{NavItem, PageContext, SiteContext, Templater};
30use crate::util::{output_path, output_url, relative_root}; 31use crate::util::{output_path, output_url, relative_root};
31 32
32/// A fully built page: source and output paths (relative to their roots) and its 33/// A fully built page: source and output paths (relative to their roots) and its
@@ -49,6 +50,8 @@ pub struct BuildOptions {
49 pub no_cache: bool, 50 pub no_cache: bool,
50 /// Treat broken internal links as a build error rather than a warning (spec §4.3.4). 51 /// Treat broken internal links as a build error rather than a warning (spec §4.3.4).
51 pub strict: bool, 52 pub strict: bool,
53 /// Explicit config file, overriding `org-ssg.toml` in the source directory.
54 pub config_path: Option<Utf8PathBuf>,
52} 55}
53 56
54/// Summary of a site build. 57/// Summary of a site build.
@@ -98,14 +101,39 @@ struct PagePrep {
98 broken: Vec<TargetId>, 101 broken: Vec<TargetId>,
99 diagnostics: Vec<Diagnostic>, 102 diagnostics: Vec<Diagnostic>,
100 nav: Vec<NavItem>, 103 nav: Vec<NavItem>,
104 context: PageContext,
105}
106
107/// Which pages the configured [`NavMode`] selects, in nav order.
108fn nav_selection<'a>(
109 config: &Config,
110 pages: &'a [(Utf8PathBuf, Utf8PathBuf, String)],
111) -> Vec<&'a (Utf8PathBuf, Utf8PathBuf, String)> {
112 match config.nav.mode {
113 NavMode::None => Vec::new(),
114 NavMode::All => pages.iter().collect(),
115 NavMode::TopLevel => pages.iter().filter(|(_, out, _)| is_top_level(out)).collect(),
116 // Configured order wins over discovery order — a hand-written nav is a designed
117 // sequence, not an alphabetical one.
118 NavMode::Explicit => config
119 .nav
120 .pages
121 .iter()
122 .filter_map(|want| pages.iter().find(|(source, _, _)| source == want))
123 .collect(),
124 }
101} 125}
102 126
103/// DISCOVER + PARSE + INDEX + RESOLVE the whole site, returning per-page prep and the 127/// DISCOVER + PARSE + INDEX + RESOLVE the whole site, returning per-page prep and the
104/// global symbol table. RENDER/TEMPLATE is deferred to the caller so the incremental 128/// global symbol table. RENDER/TEMPLATE is deferred to the caller so the incremental
105/// build can render only the pages it must. PARSE/INDEX/RESOLVE are cheap and pure, so 129/// build can render only the pages it must. PARSE/INDEX/RESOLVE are cheap and pure, so
106/// they run for every file each build; the incremental win is on RENDER + EMIT (spec §4.4). 130/// they run for every file each build; the incremental win is on RENDER + EMIT (spec §4.4).
107fn prepare_pages(src: &Utf8Path) -> Result<(Vec<PagePrep>, SymbolTable)> { 131fn prepare_pages(
108 let (org_rel, _assets) = discover(src)?; 132 src: &Utf8Path,
133 config: &Config,
134 out: Option<&Utf8Path>,
135) -> Result<(Vec<PagePrep>, SymbolTable)> {
136 let (org_rel, _assets) = discover(src, config, out)?;
109 137
110 // PARSE every file (relative paths keep snapshots and links machine-independent). 138 // PARSE every file (relative paths keep snapshots and links machine-independent).
111 // PARSE is a pure function of one file's bytes (spec §2.1), which is exactly the 139 // PARSE is a pure function of one file's bytes (spec §2.1), which is exactly the
@@ -127,32 +155,46 @@ fn prepare_pages(src: &Utf8Path) -> Result<(Vec<PagePrep>, SymbolTable)> {
127 symbols.index_document(doc); 155 symbols.index_document(doc);
128 } 156 }
129 157
130 // Nav is global chrome; titles come from #+TITLE (falling back to the file stem) and 158 // `(source, output, title)` for every page. Titles come from #+TITLE (falling back to
131 // URLs from each page's output path, which `#+SLUG:` can rename. 159 // the file stem) and URLs from each page's output path, which `#+SLUG:` can rename.
132 let all_pages: Vec<(Utf8PathBuf, String)> = docs 160 let all_pages: Vec<(Utf8PathBuf, Utf8PathBuf, String)> = docs
133 .iter()
134 .map(|d| (output_path(&d.source_path, &d.keywords), page_title(d)))
135 .collect();
136 let entries: Vec<(Utf8PathBuf, String)> = all_pages
137 .iter() 161 .iter()
138 .filter(|(out, _)| is_top_level(out)) 162 .map(|d| {
139 .cloned() 163 (
164 d.source_path.clone(),
165 output_path(&d.source_path, &d.keywords),
166 page_title(d),
167 )
168 })
140 .collect(); 169 .collect();
141 170
142 // Two sources emitting one page would silently drop a page — and with slugs, a 171 // Two sources emitting one page would silently drop a page — and with slugs, a
143 // collision is a typo away and invisible in the source filenames. 172 // collision is a typo away and invisible in the source filenames.
144 let mut claimed: std::collections::HashMap<&Utf8PathBuf, &Utf8PathBuf> = 173 let mut claimed: std::collections::HashMap<&Utf8PathBuf, &Utf8PathBuf> =
145 std::collections::HashMap::new(); 174 std::collections::HashMap::new();
146 for (doc, (out, _)) in docs.iter().zip(&all_pages) { 175 for (source, out, _) in &all_pages {
147 if let Some(other) = claimed.insert(out, &doc.source_path) { 176 if let Some(other) = claimed.insert(out, source) {
148 anyhow::bail!( 177 anyhow::bail!(
149 "output collision: {} and {} both build to {out} (check their #+SLUG:)", 178 "output collision: {other} and {source} both build to {out} \
150 other, 179 (check their #+SLUG:)"
151 doc.source_path
152 ); 180 );
153 } 181 }
154 } 182 }
155 183
184 // An explicit nav naming a page that does not exist is a typo, and a silently
185 // shorter nav is a poor way to learn about it.
186 if config.nav.mode == NavMode::Explicit {
187 for want in &config.nav.pages {
188 if !all_pages.iter().any(|(source, _, _)| source == want) {
189 anyhow::bail!("nav.pages lists {want}, which is not a page in {src}");
190 }
191 }
192 }
193 let entries: Vec<(Utf8PathBuf, String)> = nav_selection(config, &all_pages)
194 .into_iter()
195 .map(|(_, out, title)| (out.clone(), title.clone()))
196 .collect();
197
156 // RESOLVE reads the shared symbol table and writes only into its own page's output, 198 // RESOLVE reads the shared symbol table and writes only into its own page's output,
157 // so it parallelizes for free once INDEX has finished building the table. 199 // so it parallelizes for free once INDEX has finished building the table.
158 let pages: Vec<PagePrep> = docs 200 let pages: Vec<PagePrep> = docs
@@ -175,6 +217,7 @@ fn prepare_pages(src: &Utf8Path) -> Result<(Vec<PagePrep>, SymbolTable)> {
175 .collect(); 217 .collect();
176 218
177 PagePrep { 219 PagePrep {
220 context: page_context(doc, &output),
178 source: doc.source_path.clone(), 221 source: doc.source_path.clone(),
179 output, 222 output,
180 title: page_title(doc), 223 title: page_title(doc),
@@ -195,9 +238,14 @@ fn prepare_pages(src: &Utf8Path) -> Result<(Vec<PagePrep>, SymbolTable)> {
195/// Parse + index + resolve + render + template a whole site *in memory*, without 238/// Parse + index + resolve + render + template a whole site *in memory*, without
196/// touching the output directory. Shared by the tests (full render, every page). 239/// touching the output directory. Shared by the tests (full render, every page).
197pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> { 240pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
198 let (preps, _symbols) = prepare_pages(src)?; 241 let config = Config::load(src)?;
242 config.validate()?;
243 let (preps, _symbols) = prepare_pages(src, &config, None)?;
199 let highlighter = SyntectHighlighter::new(); 244 let highlighter = SyntectHighlighter::new();
200 let templater = Templater::new(); 245 let templater = Templater::load(Some(&src.join(&config.templates.dir)))?;
246 let site = site_context(&config);
247 let listing = page_listing(&config, &preps);
248 let render_opts = render_options(&config);
201 249
202 let mut pages = Vec::new(); 250 let mut pages = Vec::new();
203 let mut broken = Vec::new(); 251 let mut broken = Vec::new();
@@ -205,7 +253,7 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
205 for t in &p.broken { 253 for t in &p.broken {
206 broken.push((p.source.clone(), t.clone())); 254 broken.push((p.source.clone(), t.clone()));
207 } 255 }
208 let html = render_page(&templater, &highlighter, p)?; 256 let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &render_opts, p)?;
209 pages.push(BuiltPage { 257 pages.push(BuiltPage {
210 source: p.source.clone(), 258 source: p.source.clone(),
211 output: p.output.clone(), 259 output: p.output.clone(),
@@ -216,16 +264,46 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
216 Ok((pages, broken)) 264 Ok((pages, broken))
217} 265}
218 266
267fn render_options(config: &Config) -> RenderOptions {
268 RenderOptions {
269 heading_offset: config.html.heading_offset,
270 }
271}
272
273fn site_context(config: &Config) -> SiteContext {
274 SiteContext {
275 title: config.site.title.clone(),
276 base_url: config.site.base_url.clone(),
277 description: config.site.description.clone(),
278 language: config.site.language.clone(),
279 }
280}
281
282/// The `pages` list templates see, when configured to see one (see
283/// [`crate::config::Templates::expose_page_list`]).
284fn page_listing(config: &Config, preps: &[PagePrep]) -> Option<Vec<PageContext>> {
285 config
286 .templates
287 .expose_page_list
288 .then(|| preps.iter().map(|p| p.context.clone()).collect())
289}
290
219/// RENDER + TEMPLATE one prepared page into its final HTML string. 291/// RENDER + TEMPLATE one prepared page into its final HTML string.
292#[allow(clippy::too_many_arguments)]
220fn render_page( 293fn render_page(
221 templater: &Templater, 294 templater: &Templater,
222 highlighter: &SyntectHighlighter, 295 highlighter: &SyntectHighlighter,
296 site: &SiteContext,
297 pages: Option<&[PageContext]>,
298 render_opts: &RenderOptions,
223 p: &PagePrep, 299 p: &PagePrep,
224) -> Result<String> { 300) -> Result<String> {
225 let Html(fragment) = render(&p.resolved, highlighter); 301 let Html(fragment) = render_with(&p.resolved, highlighter, render_opts);
226 let stylesheet = format!("{}{}", relative_root(&p.source), SYNTAX_STYLESHEET); 302 // Relative to the *output* path, since `#+SLUG:` can move a page between depths.
303 let root = relative_root(&p.output);
304 let stylesheet = format!("{root}{SYNTAX_STYLESHEET}");
227 templater 305 templater
228 .render_page(&p.title, &fragment, &p.nav, &stylesheet) 306 .render_page(site, &p.context, &fragment, &p.nav, &stylesheet, &root, pages)
229 .with_context(|| format!("templating {}", p.source)) 307 .with_context(|| format!("templating {}", p.source))
230} 308}
231 309
@@ -236,28 +314,55 @@ pub const SYNTAX_STYLESHEET: &str = "syntax.css";
236/// `render_key` changed or that link into a changed file's targets; reuses the on-disk 314/// `render_key` changed or that link into a changed file's targets; reuses the on-disk
237/// output of everything else; persists an updated cache manifest. 315/// output of everything else; persists an updated cache manifest.
238pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<SiteReport> { 316pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<SiteReport> {
239 let (_org_rel, assets) = discover(src)?; 317 let cfg = match &opts.config_path {
240 let (preps, symbols) = prepare_pages(src)?; 318 Some(path) => Config::load_file(path)?,
319 None => Config::load(src)?,
320 };
321 cfg.validate()?;
322
323 // Create the output directory up front so it can be recognised and excluded when it
324 // lives inside the source tree.
325 fs::create_dir_all(out).with_context(|| format!("creating {out}"))?;
326 let (_org_rel, assets) = discover(src, &cfg, Some(out))?;
327 let (preps, symbols) = prepare_pages(src, &cfg, Some(out))?;
328
329 let templater = Templater::load(Some(&src.join(&cfg.templates.dir)))?;
330 let syntax_css = render::syntax_css(&cfg.highlight.theme).ok_or_else(|| {
331 anyhow::anyhow!(
332 "unknown highlight.theme {:?}. Available: {}",
333 cfg.highlight.theme,
334 render::available_themes().join(", ")
335 )
336 })?;
241 337
242 // The global hash classes (spec §4.1): a change in any invalidates the site. The 338 // The global hash classes (spec §4.1): a change in any invalidates the site. The
243 // config hash is combined with a site-structure hash because the nav bar — global 339 // config hash is combined with a site-structure hash covering the global chrome each
244 // chrome on every page — is built from every page's (path, title), so a title/path 340 // page carries, so a change to that chrome re-renders the pages showing it.
245 // change or a page add/remove must re-render every page (else stale nav on disk).
246 let cfg = BuildConfig::default();
247 // Only the pages that actually appear in the nav belong in the site-structure hash,
248 // because the nav is the only global chrome a page carries. Hashing *every* page
249 // here would mean adding one blog post re-rendered the entire site — correct, but
250 // needlessly: a nested page cannot change any other page's nav.
251 // 341 //
252 // Keyed on the *output* path, since a `#+SLUG:` change moves a page's URL — and so 342 // Which pages belong in that hash depends on what a template can *see*. Normally it
253 // its nav link — even though no source filename moved. 343 // is the nav only — a nested page cannot change another page's nav, so adding a blog
254 let nav_entries: Vec<(String, String)> = preps 344 // post should render one page, not the site. But `expose_page_list` hands every
345 // template every page's metadata, and then any page's output really can depend on
346 // any other page, so the hash has to widen to match. Keyed on output paths, since a
347 // `#+SLUG:` change moves a page's URL without moving its source.
348 let all_pages: Vec<(Utf8PathBuf, Utf8PathBuf, String)> = preps
255 .iter() 349 .iter()
256 .filter(|p| is_top_level(&p.output)) 350 .map(|p| (p.source.clone(), p.output.clone(), p.title.clone()))
257 .map(|p| (p.output.to_string(), p.title.clone()))
258 .collect(); 351 .collect();
259 let cfg_hash = combine(config_hash(&cfg), site_structure_hash(&nav_entries)); 352 let structure: Vec<(String, String)> = if cfg.templates.expose_page_list {
260 let tmpl_hash = template_hash(template_sources()); 353 all_pages
354 .iter()
355 .map(|(_, out, title)| (out.to_string(), title.clone()))
356 .collect()
357 } else {
358 // The same selection the nav itself is built from, so the two can never drift.
359 nav_selection(&cfg, &all_pages)
360 .into_iter()
361 .map(|(_, out, title)| (out.to_string(), title.clone()))
362 .collect()
363 };
364 let cfg_hash = combine(config_hash(&cfg), site_structure_hash(&structure));
365 let tmpl_hash = template_hash(templater.sources());
261 366
262 // Compose each page's render key and record its dependency edges. 367 // Compose each page's render key and record its dependency edges.
263 let mut new_graph = DepGraph::default(); 368 let mut new_graph = DepGraph::default();
@@ -310,7 +415,9 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
310 } 415 }
311 416
312 let highlighter = SyntectHighlighter::new(); 417 let highlighter = SyntectHighlighter::new();
313 let templater = Templater::new(); 418 let site = site_context(&cfg);
419 let listing = page_listing(&cfg, &preps);
420 let render_opts = render_options(&cfg);
314 let mut report = SiteReport::default(); 421 let mut report = SiteReport::default();
315 422
316 // RENDER + TEMPLATE + EMIT, in parallel. This is where a build's time actually goes 423 // RENDER + TEMPLATE + EMIT, in parallel. This is where a build's time actually goes
@@ -332,7 +439,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
332 if let Some(parent) = dest.parent() { 439 if let Some(parent) = dest.parent() {
333 fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?; 440 fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?;
334 } 441 }
335 let html = render_page(&templater, &highlighter, p)?; 442 let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &render_opts, p)?;
336 fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?; 443 fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?;
337 Ok(true) 444 Ok(true)
338 }) 445 })
@@ -355,8 +462,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
355 462
356 // The syntax stylesheet the highlighter's CSS classes refer to. Written every build 463 // The syntax stylesheet the highlighter's CSS classes refer to. Written every build
357 // (it is a few KB and depends only on the theme, which lives in the config hash). 464 // (it is a few KB and depends only on the theme, which lives in the config hash).
358 fs::create_dir_all(out).with_context(|| format!("creating {out}"))?; 465 fs::write(out.join(SYNTAX_STYLESHEET), &syntax_css)
359 fs::write(out.join(SYNTAX_STYLESHEET), syntax_css())
360 .with_context(|| format!("writing {SYNTAX_STYLESHEET} under {out}"))?; 466 .with_context(|| format!("writing {SYNTAX_STYLESHEET} under {out}"))?;
361 467
362 // Assets are a dumb copy in v0.3 (spec §8 Q11): copy every run. Cheap, and keeps the 468 // Assets are a dumb copy in v0.3 (spec §8 Q11): copy every run. Cheap, and keeps the
@@ -475,10 +581,24 @@ fn compute_rebuild_set(
475 581
476/// Walk `src`, returning `.org` source paths and non-`.org` asset paths, both relative 582/// Walk `src`, returning `.org` source paths and non-`.org` asset paths, both relative
477/// to `src` and sorted for deterministic output. The cache manifest is not an asset. 583/// to `src` and sorted for deterministic output. The cache manifest is not an asset.
478fn discover(src: &Utf8Path) -> Result<(Vec<Utf8PathBuf>, Vec<Utf8PathBuf>)> { 584fn discover(
585 src: &Utf8Path,
586 config: &Config,
587 out: Option<&Utf8Path>,
588) -> Result<(Vec<Utf8PathBuf>, Vec<Utf8PathBuf>)> {
589 let skip_dirs = excluded_dirs(src, config, out);
479 let mut org = Vec::new(); 590 let mut org = Vec::new();
480 let mut assets = Vec::new(); 591 let mut assets = Vec::new();
481 for entry in WalkDir::new(src).sort_by_file_name() { 592
593 let walker = WalkDir::new(src).sort_by_file_name().into_iter();
594 for entry in walker.filter_entry(|e| {
595 let Some(path) = Utf8Path::from_path(e.path()) else {
596 return false;
597 };
598 let rel = path.strip_prefix(src).unwrap_or(path);
599 // The source root itself always passes; `filter_entry` prunes whole subtrees.
600 rel.as_str().is_empty() || !is_excluded(rel, &skip_dirs)
601 }) {
482 let entry = entry.with_context(|| format!("walking {src}"))?; 602 let entry = entry.with_context(|| format!("walking {src}"))?;
483 if !entry.file_type().is_file() { 603 if !entry.file_type().is_file() {
484 continue; 604 continue;
@@ -489,6 +609,9 @@ fn discover(src: &Utf8Path) -> Result<(Vec<Utf8PathBuf>, Vec<Utf8PathBuf>)> {
489 .strip_prefix(src) 609 .strip_prefix(src)
490 .map(|p| p.to_owned()) 610 .map(|p| p.to_owned())
491 .unwrap_or_else(|_| abs.clone()); 611 .unwrap_or_else(|_| abs.clone());
612 if rel == config::CONFIG_FILE {
613 continue;
614 }
492 if rel.extension() == Some("org") { 615 if rel.extension() == Some("org") {
493 org.push(rel); 616 org.push(rel);
494 } else { 617 } else {
@@ -500,6 +623,62 @@ fn discover(src: &Utf8Path) -> Result<(Vec<Utf8PathBuf>, Vec<Utf8PathBuf>)> {
500 Ok((org, assets)) 623 Ok((org, assets))
501} 624}
502 625
626/// Source-relative directories that DISCOVER must not descend into: the template
627/// directory (build input, not content) and the output directory when it lives inside
628/// the source.
629///
630/// The output case is not a corner case — `org-ssg build . -o _site` is the obvious
631/// thing to type, and without this the build copies its own output back into itself,
632/// growing `_site/_site/_site/…` on every run.
633fn excluded_dirs(src: &Utf8Path, config: &Config, out: Option<&Utf8Path>) -> Vec<Utf8PathBuf> {
634 let mut dirs = vec![config.templates.dir.clone()];
635 if let Some(out) = out {
636 // Compare canonicalized paths so `.`, `./x` and an absolute path all agree.
637 // The output may not exist yet, in which case it cannot contain anything and
638 // the textual fallback is enough.
639 let canon = |p: &Utf8Path| -> Option<Utf8PathBuf> {
640 std::fs::canonicalize(p)
641 .ok()
642 .and_then(|p| Utf8PathBuf::from_path_buf(p).ok())
643 };
644 match (canon(src), canon(out)) {
645 (Some(src_abs), Some(out_abs)) => {
646 if let Ok(rel) = out_abs.strip_prefix(&src_abs) {
647 if !rel.as_str().is_empty() {
648 dirs.push(rel.to_owned());
649 }
650 }
651 }
652 _ => {
653 if let Ok(rel) = out.strip_prefix(src) {
654 if !rel.as_str().is_empty() {
655 dirs.push(rel.to_owned());
656 }
657 }
658 }
659 }
660 }
661 dirs
662}
663
664/// Is this source-relative path excluded from discovery?
665///
666/// Dot-entries are skipped wholesale. That is the conventional rule for site generators,
667/// and the reason is safety rather than tidiness: a source directory is very often a git
668/// repository, and publishing `.git` — or `.env` — is a way to leak a project's entire
669/// history alongside its homepage.
670fn is_excluded(rel: &Utf8Path, skip_dirs: &[Utf8PathBuf]) -> bool {
671 if rel
672 .components()
673 .any(|c| c.as_str().starts_with('.') && c.as_str() != "." && c.as_str() != "..")
674 {
675 return true;
676 }
677 skip_dirs
678 .iter()
679 .any(|dir| !dir.as_str().is_empty() && rel.starts_with(dir))
680}
681
503/// Does this output path sit at the site root? 682/// Does this output path sit at the site root?
504/// 683///
505/// The nav is the site's global chrome, and listing *every* page in it makes an `n`-page 684/// The nav is the site's global chrome, and listing *every* page in it makes an `n`-page
@@ -511,6 +690,37 @@ fn is_top_level(output: &Utf8Path) -> bool {
511 output.parent().is_none_or(|p| p.as_str().is_empty()) 690 output.parent().is_none_or(|p| p.as_str().is_empty())
512} 691}
513 692
693/// Everything a template can know about one page. Every `#+KEYWORD:` is passed through
694/// under its lowercased name, so a template can use metadata this crate has never heard
695/// of without the crate needing a release to support it.
696fn page_context(doc: &Document, output: &Utf8Path) -> PageContext {
697 let keyword = |name: &str| {
698 doc.keywords
699 .entries
700 .iter()
701 .find(|(k, _)| k.eq_ignore_ascii_case(name))
702 .map(|(_, v)| v.clone())
703 };
704 PageContext {
705 title: page_title(doc),
706 url: output.to_string(),
707 source: doc.source_path.to_string(),
708 date: keyword("DATE"),
709 tags: keyword("FILETAGS")
710 .unwrap_or_default()
711 .split(':')
712 .filter(|t| !t.trim().is_empty())
713 .map(|t| t.trim().to_string())
714 .collect(),
715 keywords: doc
716 .keywords
717 .entries
718 .iter()
719 .map(|(k, v)| (k.to_lowercase(), v.clone()))
720 .collect(),
721 }
722}
723
514fn page_title(doc: &Document) -> String { 724fn page_title(doc: &Document) -> String {
515 doc.keywords 725 doc.keywords
516 .entries 726 .entries
src/template.rs +158 −28
@@ -1,10 +1,20 @@
1//! TEMPLATE stage (spec §2.1, §2.4, §3.3): rendered fragment + page metadata → full HTML. 1//! TEMPLATE stage (spec §2.1, §2.4, §3.3): rendered fragment + page metadata → full HTML.
2//! 2//!
3//! minijinja (Jinja2 semantics, runtime templates: edit-and-rebuild, no recompile). 3//! minijinja (Jinja2 semantics, runtime templates: edit-and-rebuild, no recompile).
4//! Templates are a hashing input for incrementality (spec §4.1): a base-layout edit 4//!
5//! invalidates every page that transitively uses it. Keep the fragment/template 5//! Templates come from the configured directory when it exists, and fall back to a
6//! boundary sharp so content HTML can be snapshot-tested independently of chrome. 6//! built-in layout when it does not. That fallback is what lets a bare directory of
7//! `.org` files build into a real site with no setup, while `base.html` in the templates
8//! directory replaces the layout entirely for anyone who wants their own.
9//!
10//! Template sources are a hashing input for incrementality (spec §4.1): editing a layout
11//! invalidates the pages that use it, and that has to hold for user templates too, or a
12//! design change would leave a site half-updated.
7 13
14use std::collections::BTreeMap;
15
16use anyhow::{Context, Result};
17use camino::Utf8Path;
8use minijinja::{context, Environment}; 18use minijinja::{context, Environment};
9use serde::Serialize; 19use serde::Serialize;
10 20
@@ -15,36 +25,73 @@ pub struct NavItem {
15 pub url: String, 25 pub url: String,
16} 26}
17 27
18/// The base layout applied to every page: `<title>`, a nav bar, and the body. 28/// Site-wide values, exposed to templates as `site`.
19/// Minimal but real — a single `base` template, no partials yet. 29#[derive(Debug, Clone, Serialize)]
30pub struct SiteContext {
31 pub title: String,
32 pub base_url: String,
33 pub description: String,
34 pub language: String,
35}
36
37/// One page's metadata, exposed to templates as `page` — and, when
38/// `templates.expose_page_list` is on, as entries of `pages`.
39#[derive(Debug, Clone, Serialize)]
40pub struct PageContext {
41 pub title: String,
42 /// Output path relative to the site root, e.g. `blog/post.html`.
43 pub url: String,
44 /// Source path relative to the source root, e.g. `blog/post.org`.
45 pub source: String,
46 /// `#+DATE:` verbatim, if present — org date syntax is not normalized here because
47 /// templates are better placed to decide how a date should read.
48 pub date: Option<String>,
49 /// `#+FILETAGS:` split on `:`.
50 pub tags: Vec<String>,
51 /// Every `#+KEYWORD:` in the file, keyed by lowercased name, so a template can use
52 /// project-specific metadata this crate has never heard of.
53 pub keywords: BTreeMap<String, String>,
54}
55
56/// The built-in layout, used when the templates directory has no `base.html`.
57/// Deliberately plain: it should be a working starting point and an obvious thing to
58/// replace, not a design anyone has to live with.
20const BASE_TEMPLATE: &str = r#"<!DOCTYPE html> 59const BASE_TEMPLATE: &str = r#"<!DOCTYPE html>
21<html lang="en"> 60<html lang="{{ site.language }}">
22<head> 61<head>
23<meta charset="utf-8"> 62<meta charset="utf-8">
24<title>{{ title }}</title> 63<meta name="viewport" content="width=device-width, initial-scale=1">
64<title>{{ page.title }} &middot; {{ site.title }}</title>
65{%- if page.description %}
66<meta name="description" content="{{ page.description }}">
67{%- endif %}
25{%- if stylesheet %} 68{%- if stylesheet %}
26<link rel="stylesheet" href="{{ stylesheet }}"> 69<link rel="stylesheet" href="{{ stylesheet }}">
27{%- endif %} 70{%- endif %}
28</head> 71</head>
29<body> 72<body>
73<header>
74<a class="site-title" href="{{ root }}index.html">{{ site.title }}</a>
75{%- if nav %}
30<nav> 76<nav>
31{%- for item in nav %} 77{%- for item in nav %}
32<a href="{{ item.url }}">{{ item.title }}</a> 78<a href="{{ item.url }}">{{ item.title }}</a>
33{%- endfor %} 79{%- endfor %}
34</nav> 80</nav>
81{%- endif %}
82</header>
35<main> 83<main>
84<h1>{{ page.title }}</h1>
85{%- if page.date %}
86<p class="page-date">{{ page.date }}</p>
87{%- endif %}
36{{ body | safe }}</main> 88{{ body | safe }}</main>
37</body> 89</body>
38</html> 90</html>
39"#; 91"#;
40 92
41/// The source text of every template that participates in the page layout. Hashed by 93/// The name a template must have to serve as the page layout.
42/// the incremental layer (spec §4.1): a base-layout edit invalidates every page that 94pub const BASE_TEMPLATE_NAME: &str = "base";
43/// uses it. There is a single `base` template today; when partials arrive this returns
44/// the transitive closure so a single-partial edit invalidates only its users.
45pub fn template_sources() -> &'static [(&'static str, &'static str)] {
46 &[("base", BASE_TEMPLATE)]
47}
48 95
49#[derive(Debug, thiserror::Error)] 96#[derive(Debug, thiserror::Error)]
50pub enum TemplateError { 97pub enum TemplateError {
@@ -55,37 +102,120 @@ pub enum TemplateError {
55/// Wraps a rendered fragment in its page template. 102/// Wraps a rendered fragment in its page template.
56pub struct Templater { 103pub struct Templater {
57 env: Environment<'static>, 104 env: Environment<'static>,
105 /// `(name, source)` for every registered template, for the template hash. Sorted by
106 /// name so the hash does not depend on directory iteration order.
107 sources: Vec<(String, String)>,
58} 108}
59 109
60impl Templater { 110impl Templater {
61 pub fn new() -> Self { 111 /// Load templates from `dir`, falling back to the built-in layout.
112 ///
113 /// A missing directory is fine — that is the zero-config path. A directory that
114 /// exists but contains a template that does not compile is an error: it means
115 /// someone is actively editing their layout, and rendering the built-in default
116 /// instead would look like their edit silently did nothing.
117 pub fn load(dir: Option<&Utf8Path>) -> Result<Self> {
118 let mut sources: Vec<(String, String)> = Vec::new();
119
120 if let Some(dir) = dir.filter(|d| d.is_dir()) {
121 let mut entries: Vec<_> = std::fs::read_dir(dir)
122 .with_context(|| format!("reading template directory {dir}"))?
123 .collect::<std::io::Result<Vec<_>>>()
124 .with_context(|| format!("reading template directory {dir}"))?;
125 entries.sort_by_key(|e| e.file_name());
126
127 for entry in entries {
128 let path = Utf8Path::from_path(&entry.path())
129 .map(Utf8Path::to_owned)
130 .ok_or_else(|| anyhow::anyhow!("non-UTF-8 template path"))?;
131 if path.extension() != Some("html") || !path.is_file() {
132 continue;
133 }
134 let name = path
135 .file_stem()
136 .ok_or_else(|| anyhow::anyhow!("template with no name: {path}"))?
137 .to_string();
138 let source = std::fs::read_to_string(&path)
139 .with_context(|| format!("reading template {path}"))?;
140 sources.push((name, source));
141 }
142 }
143
144 if !sources.iter().any(|(n, _)| n == BASE_TEMPLATE_NAME) {
145 sources.push((BASE_TEMPLATE_NAME.to_string(), BASE_TEMPLATE.to_string()));
146 }
147 sources.sort_by(|a, b| a.0.cmp(&b.0));
148
62 let mut env = Environment::new(); 149 let mut env = Environment::new();
63 env.add_template("base", BASE_TEMPLATE) 150 for (name, source) in &sources {
64 .expect("base template compiles"); 151 // `Environment<'static>` needs owned sources; leaking is bounded by the
65 Templater { env } 152 // template count and lives as long as the build anyway.
153 let name: &'static str = Box::leak(name.clone().into_boxed_str());
154 let source: &'static str = Box::leak(source.clone().into_boxed_str());
155 env.add_template(name, source)
156 .with_context(|| format!("compiling template {name}"))?;
157 }
158
159 Ok(Templater { env, sources })
160 }
161
162 /// `(name, source)` for every registered template — the template hash's input
163 /// (spec §4.1), covering user templates so editing one invalidates its pages.
164 pub fn sources(&self) -> &[(String, String)] {
165 &self.sources
66 } 166 }
67 167
68 /// fragment + page metadata → full HTML page. `stylesheet` is the URL of the 168 /// fragment + page metadata → full HTML page.
69 /// syntax-highlighting stylesheet relative to *this* page (highlighting emits CSS 169 ///
70 /// classes, so the sheet has to come with it). 170 /// `stylesheet` and `root` are URLs relative to *this* page, so a template works the
171 /// same at any directory depth.
172 #[allow(clippy::too_many_arguments)]
71 pub fn render_page( 173 pub fn render_page(
72 &self, 174 &self,
73 title: &str, 175 site: &SiteContext,
176 page: &PageContext,
74 body: &str, 177 body: &str,
75 nav: &[NavItem], 178 nav: &[NavItem],
76 stylesheet: &str, 179 stylesheet: &str,
180 root: &str,
181 pages: Option<&[PageContext]>,
77 ) -> Result<String, TemplateError> { 182 ) -> Result<String, TemplateError> {
78 let tmpl = self 183 let tmpl = self
79 .env 184 .env
80 .get_template("base") 185 .get_template(BASE_TEMPLATE_NAME)
81 .map_err(|e| TemplateError::Render(e.to_string()))?; 186 .map_err(|e| TemplateError::Render(e.to_string()))?;
82 tmpl.render(context! { title => title, body => body, nav => nav, stylesheet => stylesheet }) 187 tmpl.render(context! {
83 .map_err(|e| TemplateError::Render(e.to_string())) 188 site => site,
189 page => page,
190 body => body,
191 nav => nav,
192 stylesheet => stylesheet,
193 root => root,
194 pages => pages,
195 })
196 .map_err(|e| TemplateError::Render(render_error_detail(e)))
84 } 197 }
85} 198}
86 199
87impl Default for Templater { 200/// minijinja's `Display` gives only the top-level message; the useful part (which
88 fn default() -> Self { 201/// template, which line) is in the source and cause chain.
89 Self::new() 202fn render_error_detail(error: minijinja::Error) -> String {
203 let mut out = error.to_string();
204 if let Some(name) = error.template_source().map(|_| error.name().unwrap_or("?")) {
205 if let Some(line) = error.line() {
206 out = format!("{out} (in template {name}, line {line})");
207 }
90 } 208 }
209 let mut source = std::error::Error::source(&error);
210 while let Some(cause) = source {
211 out.push_str(&format!(": {cause}"));
212 source = cause.source();
213 }
214 out
215}
216
217/// The starter layout written by `org-ssg init`: the built-in template, on disk, ready
218/// to edit.
219pub fn starter_template() -> &'static str {
220 BASE_TEMPLATE
91} 221}
tests/config.rs added +445
@@ -0,0 +1,445 @@
1//! Configuration, templating and discovery — the surface that decides whether this is a
2//! generator for one site or for anyone's.
3//!
4//! The theme running through these tests is that **the zero-config path has to work**.
5//! A directory of `.org` files with no `org-ssg.toml`, no templates and no knowledge of
6//! this tool must build into a real site; configuration is how you change the output,
7//! never how you make it work at all.
8
9use std::sync::atomic::{AtomicU32, Ordering};
10
11use camino::Utf8PathBuf;
12
13use org_ssg::config::{Config, NavMode};
14use org_ssg::site::{build_site, BuildOptions};
15
16fn tmpdir(tag: &str) -> Utf8PathBuf {
17 static N: AtomicU32 = AtomicU32::new(0);
18 let n = N.fetch_add(1, Ordering::Relaxed);
19 let base = Utf8PathBuf::from_path_buf(std::env::temp_dir())
20 .expect("utf-8 temp dir")
21 .join(format!("org-ssg-cfg-{}-{tag}-{n}", std::process::id()));
22 let _ = std::fs::remove_dir_all(&base);
23 std::fs::create_dir_all(&base).unwrap();
24 base
25}
26
27/// A site with a root page, a second root page, and one nested page.
28fn write_site(src: &Utf8PathBuf) {
29 std::fs::create_dir_all(src.join("blog")).unwrap();
30 std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nWelcome.\n").unwrap();
31 std::fs::write(src.join("about.org"), "#+TITLE: About\n\nAbout.\n").unwrap();
32 std::fs::write(
33 src.join("blog/post.org"),
34 "#+TITLE: A Post\n#+DATE: 2024-05-01\n#+FILETAGS: :rust:web:\n\nBody.\n",
35 )
36 .unwrap();
37}
38
39fn build(src: &Utf8PathBuf, out: &Utf8PathBuf) -> org_ssg::site::SiteReport {
40 build_site(src, out, &BuildOptions::default()).expect("build")
41}
42
43fn page(out: &Utf8PathBuf, rel: &str) -> String {
44 std::fs::read_to_string(out.join(rel)).unwrap_or_else(|e| panic!("reading {rel}: {e}"))
45}
46
47// ---------------------------------------------------------------------------
48// Zero config
49// ---------------------------------------------------------------------------
50
51/// The headline promise: point it at a directory of org files and get a site.
52#[test]
53fn a_bare_directory_of_org_files_builds_with_no_config() {
54 let root = tmpdir("bare");
55 let src = root.join("src");
56 std::fs::create_dir_all(&src).unwrap();
57 write_site(&src);
58 let out = root.join("out");
59
60 let report = build(&src, &out);
61 assert_eq!(report.pages.len(), 3);
62
63 let home = page(&out, "index.html");
64 assert!(home.contains("<!DOCTYPE html>"), "a full page, not a fragment");
65 assert!(home.contains("Welcome."), "the content is there");
66 assert!(
67 out.join("syntax.css").exists(),
68 "the stylesheet the highlighter needs is emitted too"
69 );
70}
71
72/// A missing config is normal. A *malformed* one is not: someone who wrote a config
73/// meant it, and quietly building the default site would hide their typo behind
74/// plausible-looking output.
75#[test]
76fn a_malformed_config_is_an_error_but_a_missing_one_is_not() {
77 let root = tmpdir("malformed");
78 let src = root.join("src");
79 std::fs::create_dir_all(&src).unwrap();
80 write_site(&src);
81
82 assert_eq!(Config::load(&src).unwrap(), Config::default());
83
84 std::fs::write(src.join("org-ssg.toml"), "[site\ntitle = broken").unwrap();
85 let err = Config::load(&src).expect_err("malformed config must fail");
86 assert!(format!("{err:#}").contains("org-ssg.toml"), "names the file: {err:#}");
87}
88
89/// A misspelled key is a silent no-op in most config formats, which is exactly how
90/// someone spends an afternoon wondering why a setting does nothing.
91#[test]
92fn an_unknown_config_key_is_rejected() {
93 let root = tmpdir("unknownkey");
94 let src = root.join("src");
95 std::fs::create_dir_all(&src).unwrap();
96 std::fs::write(src.join("org-ssg.toml"), "[site]\ntittle = \"typo\"\n").unwrap();
97
98 let err = Config::load(&src).expect_err("unknown key must fail");
99 assert!(
100 format!("{err:#}").contains("tittle"),
101 "the error names the offending key: {err:#}"
102 );
103}
104
105// ---------------------------------------------------------------------------
106// Nav modes
107// ---------------------------------------------------------------------------
108
109fn nav_of(html: &str) -> String {
110 html.split("<nav>")
111 .nth(1)
112 .and_then(|s| s.split("</nav>").next())
113 .unwrap_or("")
114 .to_string()
115}
116
117#[test]
118fn nav_modes_select_different_pages() {
119 for (mode, expect_post, expect_about) in [
120 ("top-level", false, true),
121 ("all", true, true),
122 ("none", false, false),
123 ] {
124 let root = tmpdir(&format!("nav-{mode}"));
125 let src = root.join("src");
126 std::fs::create_dir_all(&src).unwrap();
127 write_site(&src);
128 std::fs::write(
129 src.join("org-ssg.toml"),
130 format!("[nav]\nmode = \"{mode}\"\n"),
131 )
132 .unwrap();
133 let out = root.join("out");
134 build(&src, &out);
135
136 let nav = nav_of(&page(&out, "index.html"));
137 assert_eq!(
138 nav.contains("A Post"),
139 expect_post,
140 "mode {mode} nested page presence, nav was:\n{nav}"
141 );
142 assert_eq!(
143 nav.contains("About"),
144 expect_about,
145 "mode {mode} root page presence, nav was:\n{nav}"
146 );
147 }
148}
149
150/// An explicit nav is a designed sequence, so configured order beats discovery order.
151#[test]
152fn explicit_nav_uses_the_configured_order() {
153 let root = tmpdir("navexplicit");
154 let src = root.join("src");
155 std::fs::create_dir_all(&src).unwrap();
156 write_site(&src);
157 std::fs::write(
158 src.join("org-ssg.toml"),
159 "[nav]\nmode = \"explicit\"\npages = [\"blog/post.org\", \"index.org\"]\n",
160 )
161 .unwrap();
162 let out = root.join("out");
163 build(&src, &out);
164
165 let nav = nav_of(&page(&out, "index.html"));
166 let post = nav.find("A Post").expect("post in nav");
167 let home = nav.find("Home").expect("home in nav");
168 assert!(post < home, "configured order wins:\n{nav}");
169 assert!(!nav.contains("About"), "unlisted pages stay out:\n{nav}");
170}
171
172/// A nav entry naming a page that does not exist is a typo, and a silently shorter nav
173/// is a poor way to find out.
174#[test]
175fn explicit_nav_rejects_a_page_that_does_not_exist() {
176 let root = tmpdir("navmissing");
177 let src = root.join("src");
178 std::fs::create_dir_all(&src).unwrap();
179 write_site(&src);
180 std::fs::write(
181 src.join("org-ssg.toml"),
182 "[nav]\nmode = \"explicit\"\npages = [\"nope.org\"]\n",
183 )
184 .unwrap();
185
186 let err = build_site(&src, &root.join("out"), &BuildOptions::default())
187 .expect_err("missing nav page must fail");
188 assert!(format!("{err:#}").contains("nope.org"), "names it: {err:#}");
189}
190
191/// `mode` and `pages` disagreeing means one of them is being ignored.
192#[test]
193fn contradictory_nav_settings_are_rejected() {
194 let mut config = Config::default();
195 config.nav.pages = vec![Utf8PathBuf::from("index.org")];
196 assert!(config.validate().is_err(), "pages without explicit mode");
197
198 let mut config = Config::default();
199 config.nav.mode = NavMode::Explicit;
200 assert!(config.validate().is_err(), "explicit mode without pages");
201}
202
203// ---------------------------------------------------------------------------
204// Templates
205// ---------------------------------------------------------------------------
206
207/// The single biggest blocker to general use: without this every site built with this
208/// tool looks identical.
209#[test]
210fn a_user_template_replaces_the_built_in_layout() {
211 let root = tmpdir("template");
212 let src = root.join("src");
213 std::fs::create_dir_all(src.join("templates")).unwrap();
214 write_site(&src);
215 std::fs::write(
216 src.join("templates/base.html"),
217 "<html><body class=\"mine\"><h1>{{ page.title }}</h1>{{ body | safe }}</body></html>",
218 )
219 .unwrap();
220 let out = root.join("out");
221 build(&src, &out);
222
223 let home = page(&out, "index.html");
224 assert!(home.contains("class=\"mine\""), "the user layout is used:\n{home}");
225 assert!(!home.contains("<nav>"), "nothing of the default layout leaks in");
226 assert!(home.contains("Welcome."), "content still renders");
227}
228
229/// Templates are a hashing input (spec §4.1). If editing a layout did not invalidate,
230/// a design change would leave a site half-updated — the worst kind of caching bug,
231/// because it looks like it worked.
232#[test]
233fn editing_a_template_re_renders_every_page_that_uses_it() {
234 let root = tmpdir("templatehash");
235 let src = root.join("src");
236 std::fs::create_dir_all(src.join("templates")).unwrap();
237 write_site(&src);
238 let tpl = src.join("templates/base.html");
239 std::fs::write(&tpl, "<html><body>v1{{ body | safe }}</body></html>").unwrap();
240 let out = root.join("out");
241
242 build(&src, &out);
243 std::fs::write(&tpl, "<html><body>v2{{ body | safe }}</body></html>").unwrap();
244 let report = build(&src, &out);
245
246 assert_eq!(report.rendered.len(), 3, "a layout edit re-renders every page");
247 assert!(page(&out, "index.html").contains("v2"), "and the change lands");
248}
249
250/// A template that does not compile means someone is actively editing their layout.
251/// Falling back to the built-in would look like their edit silently did nothing.
252#[test]
253fn a_broken_template_fails_the_build() {
254 let root = tmpdir("badtemplate");
255 let src = root.join("src");
256 std::fs::create_dir_all(src.join("templates")).unwrap();
257 write_site(&src);
258 std::fs::write(src.join("templates/base.html"), "{% if %}unclosed").unwrap();
259
260 let err = build_site(&src, &root.join("out"), &BuildOptions::default())
261 .expect_err("a broken template must fail the build");
262 assert!(
263 format!("{err:#}").contains("base"),
264 "the error names the template: {err:#}"
265 );
266}
267
268/// Templates get page metadata, including arbitrary `#+KEYWORD:`s this crate has never
269/// heard of — otherwise every new bit of metadata would need a release.
270#[test]
271fn templates_receive_page_metadata_including_unknown_keywords() {
272 let root = tmpdir("meta");
273 let src = root.join("src");
274 std::fs::create_dir_all(src.join("templates")).unwrap();
275 write_site(&src);
276 std::fs::write(
277 src.join("blog/post.org"),
278 "#+TITLE: A Post\n#+DATE: 2024-05-01\n#+FILETAGS: :rust:web:\n#+CUSTOM_THING: hello\n\nBody.\n",
279 )
280 .unwrap();
281 std::fs::write(
282 src.join("templates/base.html"),
283 "<html><body>date={{ page.date }} tags={{ page.tags | join(\",\") }} \
284 custom={{ page.keywords.custom_thing }} url={{ page.url }} \
285 site={{ site.title }}{{ body | safe }}</body></html>",
286 )
287 .unwrap();
288 let out = root.join("out");
289 build(&src, &out);
290
291 let post = page(&out, "blog/post.html");
292 assert!(post.contains("date=2024-05-01"), "#+DATE: reaches the template:\n{post}");
293 assert!(post.contains("tags=rust,web"), "#+FILETAGS: is split:\n{post}");
294 assert!(post.contains("custom=hello"), "unknown keywords pass through:\n{post}");
295 assert!(post.contains("url=blog/post.html"), "the page URL is available:\n{post}");
296}
297
298/// Off by default, because it trades incremental precision for the ability to write
299/// listing pages — and that trade should be a choice.
300#[test]
301fn the_page_list_is_opt_in_and_widens_invalidation() {
302 let root = tmpdir("pagelist");
303 let src = root.join("src");
304 std::fs::create_dir_all(src.join("templates")).unwrap();
305 write_site(&src);
306 std::fs::write(
307 src.join("org-ssg.toml"),
308 "[templates]\nexpose_page_list = true\n",
309 )
310 .unwrap();
311 std::fs::write(
312 src.join("templates/base.html"),
313 "<html><body><ul>{% for p in pages %}<li>{{ p.title }}</li>{% endfor %}</ul>\
314 {{ body | safe }}</body></html>",
315 )
316 .unwrap();
317 let out = root.join("out");
318 build(&src, &out);
319
320 let home = page(&out, "index.html");
321 for title in ["Home", "About", "A Post"] {
322 assert!(home.contains(title), "an index can list {title}:\n{home}");
323 }
324
325 // With every page visible to every template, adding one must re-render them all —
326 // the opposite of the default, and the documented cost of turning this on.
327 std::fs::write(src.join("blog/second.org"), "#+TITLE: Second\n\nBody.\n").unwrap();
328 let report = build(&src, &out);
329 assert_eq!(
330 report.rendered.len(),
331 4,
332 "with the page list exposed, adding a page re-renders the site"
333 );
334}
335
336// ---------------------------------------------------------------------------
337// Output settings
338// ---------------------------------------------------------------------------
339
340/// The default layout renders the page title as `<h1>`, so section headings belong
341/// beneath it — which is also what Emacs does by default.
342#[test]
343fn heading_offset_shifts_content_headings_below_the_page_title() {
344 let root = tmpdir("hoffset");
345 let src = root.join("src");
346 std::fs::create_dir_all(&src).unwrap();
347 std::fs::write(src.join("index.org"), "#+TITLE: T\n\n* Section\n\nBody.\n").unwrap();
348 let out = root.join("out");
349 build(&src, &out);
350 assert!(
351 page(&out, "index.html").contains("<h2 id=\"section\">"),
352 "a level-1 org heading renders as <h2> by default"
353 );
354
355 std::fs::write(src.join("org-ssg.toml"), "[html]\nheading_offset = 0\n").unwrap();
356 let out2 = root.join("out2");
357 build(&src, &out2);
358 assert!(
359 page(&out2, "index.html").contains("<h1 id=\"section\">"),
360 "offset 0 leaves headings where the document put them"
361 );
362}
363
364/// An unknown theme silently produces an empty stylesheet, which looks exactly like
365/// highlighting being broken. Naming the valid options turns a mystery into a typo.
366#[test]
367fn an_unknown_highlight_theme_is_rejected_with_the_available_ones() {
368 let root = tmpdir("theme");
369 let src = root.join("src");
370 std::fs::create_dir_all(&src).unwrap();
371 write_site(&src);
372 std::fs::write(src.join("org-ssg.toml"), "[highlight]\ntheme = \"nope\"\n").unwrap();
373
374 let err = build_site(&src, &root.join("out"), &BuildOptions::default())
375 .expect_err("unknown theme must fail");
376 let message = format!("{err:#}");
377 assert!(message.contains("nope"), "names the bad theme: {message}");
378 assert!(
379 message.contains("InspiredGitHub"),
380 "lists what is available: {message}"
381 );
382}
383
384// ---------------------------------------------------------------------------
385// Discovery
386// ---------------------------------------------------------------------------
387
388/// `org-ssg build . -o _site` is the obvious thing to type. Without excluding the output
389/// directory, the build copies its own output back into itself, growing `_site/_site/…`
390/// on every run.
391#[test]
392fn an_output_directory_inside_the_source_is_not_swallowed() {
393 let root = tmpdir("nested");
394 let src = root.join("src");
395 std::fs::create_dir_all(&src).unwrap();
396 write_site(&src);
397 let out = src.join("_site");
398
399 for _ in 0..3 {
400 build(&src, &out);
401 }
402 assert!(!out.join("_site").exists(), "output must not nest inside itself");
403
404 let report = build(&src, &out);
405 assert_eq!(report.pages.len(), 3, "still exactly the source pages");
406 assert!(
407 report.assets.is_empty(),
408 "no output file is mistaken for an asset: {:?}",
409 report.assets
410 );
411}
412
413/// A source directory is very often a git repository. Publishing `.git` alongside the
414/// homepage leaks a project's entire history.
415#[test]
416fn dot_directories_and_build_inputs_are_never_published() {
417 let root = tmpdir("dotfiles");
418 let src = root.join("src");
419 std::fs::create_dir_all(src.join(".git")).unwrap();
420 std::fs::create_dir_all(src.join("templates")).unwrap();
421 write_site(&src);
422 std::fs::write(src.join(".git/config"), "[remote]\nurl = private\n").unwrap();
423 std::fs::write(src.join(".env"), "SECRET=hunter2\n").unwrap();
424 std::fs::write(src.join("org-ssg.toml"), "[site]\ntitle = \"T\"\n").unwrap();
425 std::fs::write(src.join("templates/base.html"), "<html>{{ body | safe }}</html>").unwrap();
426 std::fs::write(src.join("style.css"), "body{}\n").unwrap();
427 let out = root.join("out");
428
429 let report = build(&src, &out);
430 assert!(!out.join(".git").exists(), ".git must never be published");
431 assert!(!out.join(".env").exists(), "dotfiles must never be published");
432 assert!(
433 !out.join("org-ssg.toml").exists(),
434 "the config is a build input, not content"
435 );
436 assert!(
437 !out.join("templates").exists(),
438 "templates are build inputs, not content"
439 );
440 assert_eq!(
441 report.assets,
442 vec![Utf8PathBuf::from("style.css")],
443 "genuine assets still copy through"
444 );
445}
tests/constructs.rs +3 −1
@@ -160,7 +160,9 @@ fn highlighting_emits_classes_not_inline_styles() {
160 "highlighting must not emit inline styles:\n{html}" 160 "highlighting must not emit inline styles:\n{html}"
161 ); 161 );
162 assert!( 162 assert!(
163 org_ssg::render::syntax_css().contains(".storage"), 163 org_ssg::render::syntax_css("InspiredGitHub")
164 .expect("a built-in theme")
165 .contains(".storage"),
164 "the generated stylesheet must define the emitted classes" 166 "the generated stylesheet must define the emitted classes"
165 ); 167 );
166} 168}
tests/incremental.rs +2 −2
@@ -85,7 +85,7 @@ fn full_and_incremental_are_byte_identical_and_second_build_renders_nothing() {
85 &full, 85 &full,
86 &BuildOptions { 86 &BuildOptions {
87 no_cache: true, 87 no_cache: true,
88 strict: false, 88 ..Default::default()
89 }, 89 },
90 ) 90 )
91 .unwrap(); 91 .unwrap();
@@ -329,7 +329,7 @@ fn parallel_builds_are_deterministic_in_output_and_report_order() {
329 out, 329 out,
330 &BuildOptions { 330 &BuildOptions {
331 no_cache: true, 331 no_cache: true,
332 strict: false, 332 ..Default::default()
333 }, 333 },
334 ) 334 )
335 .unwrap() 335 .unwrap()
tests/oracle.el +3 −4
@@ -16,10 +16,9 @@
16;; learn what stock org does — normalizing that away would be marking our own homework. 16;; learn what stock org does — normalizing that away would be marking our own homework.
17(setq org-export-with-toc nil ; we emit no table of contents 17(setq org-export-with-toc nil ; we emit no table of contents
18 org-export-with-section-numbers nil ; we do not number headings 18 org-export-with-section-numbers nil ; we do not number headings
19 org-html-toplevel-hlevel 1 ; org defaults to h2 for a level-1 heading, 19 ;; org-html-toplevel-hlevel is left at its default of 2. org-ssg's own default
20 ; because a template supplies the page <h1>. 20 ;; heading_offset is 1, which produces the same <h2>, so both sides now agree
21 ; Aligning here keeps a global +1 offset from 21 ;; without the oracle being told to.
22 ; drowning every real finding in the diff.
23 org-html-htmlize-output-type nil ; plain <pre>, not htmlize spans: we highlight 22 org-html-htmlize-output-type nil ; plain <pre>, not htmlize spans: we highlight
24 ; with syntect, so comparing code *text* is 23 ; with syntect, so comparing code *text* is
25 ; the meaningful part 24 ; the meaningful part
tests/snapshots/constructs__blocks_html.snap +6 −6
@@ -2,25 +2,25 @@
2source: tests/constructs.rs 2source: tests/constructs.rs
3expression: "render_fixture(\"blocks.org\")" 3expression: "render_fixture(\"blocks.org\")"
4--- 4---
5<h1 id="quote">Quote</h1> 5<h2 id="quote">Quote</h2>
6<blockquote> 6<blockquote>
7<p>A quoted paragraph with <em>markup</em>.</p> 7<p>A quoted paragraph with <em>markup</em>.</p>
8<p>And a second paragraph.</p> 8<p>And a second paragraph.</p>
9</blockquote> 9</blockquote>
10<h1 id="center">Center</h1> 10<h2 id="center">Center</h2>
11<div class="center"> 11<div class="center">
12<p>Centred text.</p> 12<p>Centred text.</p>
13</div> 13</div>
14<h1 id="example">Example</h1> 14<h2 id="example">Example</h2>
15<pre>Verbatim *not bold* text. 15<pre>Verbatim *not bold* text.
16 Indentation preserved.</pre> 16 Indentation preserved.</pre>
17<h1 id="export">Export</h1> 17<h2 id="export">Export</h2>
18<aside class="raw">Raw HTML passes through.</aside> 18<aside class="raw">Raw HTML passes through.</aside>
19<h1 id="source">Source</h1> 19<h2 id="source">Source</h2>
20<pre><code class="language-python highlight"><span class="source python"><span class="meta function python"><span class="storage type function python">def</span> <span class="entity name function python"><span class="meta generic-name python">greet</span></span></span><span class="meta function parameters python"><span class="punctuation section parameters begin python">(</span></span><span class="meta function parameters python"><span class="variable parameter python">name</span><span class="punctuation section parameters end python">)</span></span><span class="meta function python"><span class="punctuation section function begin python">:</span></span> 20<pre><code class="language-python highlight"><span class="source python"><span class="meta function python"><span class="storage type function python">def</span> <span class="entity name function python"><span class="meta generic-name python">greet</span></span></span><span class="meta function parameters python"><span class="punctuation section parameters begin python">(</span></span><span class="meta function parameters python"><span class="variable parameter python">name</span><span class="punctuation section parameters end python">)</span></span><span class="meta function python"><span class="punctuation section function begin python">:</span></span>
21 <span class="keyword control flow return python">return</span> <span class="storage type string python">f</span><span class="meta string interpolated python"><span class="string quoted double python"><span class="punctuation definition string begin python">&quot;</span></span></span><span class="meta string interpolated python"><span class="string quoted double python">hello </span><span class="meta interpolation python"><span class="punctuation section interpolation begin python">{</span><span class="source python embedded"><span class="meta qualified-name python"><span class="meta generic-name python">name</span></span></span></span><span class="meta interpolation python"><span class="punctuation section interpolation end python">}</span></span><span class="string quoted double python"><span class="punctuation definition string end python">&quot;</span></span></span></span></code></pre> 21 <span class="keyword control flow return python">return</span> <span class="storage type string python">f</span><span class="meta string interpolated python"><span class="string quoted double python"><span class="punctuation definition string begin python">&quot;</span></span></span><span class="meta string interpolated python"><span class="string quoted double python">hello </span><span class="meta interpolation python"><span class="punctuation section interpolation begin python">{</span><span class="source python embedded"><span class="meta qualified-name python"><span class="meta generic-name python">name</span></span></span></span><span class="meta interpolation python"><span class="punctuation section interpolation end python">}</span></span><span class="string quoted double python"><span class="punctuation definition string end python">&quot;</span></span></span></span></code></pre>
22<pre><code class="language-none">plain block, no language</code></pre> 22<pre><code class="language-none">plain block, no language</code></pre>
23<h1 id="nested">Nested</h1> 23<h2 id="nested">Nested</h2>
24<blockquote> 24<blockquote>
25<p>A quote containing a source block:</p> 25<p>A quote containing a source block:</p>
26<pre><code class="language-sh highlight"><span class="source shell bash"><span class="meta function-call shell"><span class="support function echo shell">echo</span></span><span class="meta function-call arguments shell"> hi</span></span></code></pre> 26<pre><code class="language-sh highlight"><span class="source shell bash"><span class="meta function-call shell"><span class="support function echo shell">echo</span></span><span class="meta function-call arguments shell"> hi</span></span></code></pre>
tests/snapshots/constructs__headings_html.snap +5 −5
@@ -2,13 +2,13 @@
2source: tests/constructs.rs 2source: tests/constructs.rs
3expression: "render_fixture(\"headings.org\")" 3expression: "render_fixture(\"headings.org\")"
4--- 4---
5<h1 id="write-parser"><span class="todo TODO">TODO</span> <span class="priority">[#A]</span> Write the parser <span class="tag">work</span> <span class="tag">rust</span></h1> 5<h2 id="write-parser"><span class="todo TODO">TODO</span> <span class="priority">[#A]</span> Write the parser <span class="tag">work</span> <span class="tag">rust</span></h2>
6<p>A heading carrying a keyword, a priority, tags and a property drawer.</p> 6<p>A heading carrying a keyword, a priority, tags and a property drawer.</p>
7<h2 id="nested-and-finished"><span class="done DONE">DONE</span> Nested and finished</h2> 7<h3 id="nested-and-finished"><span class="done DONE">DONE</span> Nested and finished</h3>
8<p>Sub-headings nest by star count.</p> 8<p>Sub-headings nest by star count.</p>
9<h2 id="priority-without-a-keyword"><span class="priority">[#C]</span> Priority without a keyword</h2> 9<h3 id="priority-without-a-keyword"><span class="priority">[#C]</span> Priority without a keyword</h3>
10<p>A priority cookie can stand alone.</p> 10<p>A priority cookie can stand alone.</p>
11<h1 id="todos-are-not-a-keyword">TODOs are not a keyword</h1> 11<h2 id="todos-are-not-a-keyword">TODOs are not a keyword</h2>
12<p>The word boundary matters: this heading has no TODO keyword.</p> 12<p>The word boundary matters: this heading has no TODO keyword.</p>
13<h1><span class="done DONE">DONE</span> </h1> 13<h2><span class="done DONE">DONE</span> </h2>
14<p>A keyword with no title at all.</p> 14<p>A keyword with no title at all.</p>
tests/snapshots/constructs__images_html.snap +5 −5
@@ -2,13 +2,13 @@
2source: tests/constructs.rs 2source: tests/constructs.rs
3expression: "render_fixture(\"images.org\")" 3expression: "render_fixture(\"images.org\")"
4--- 4---
5<h1 id="bare-image">Bare image</h1> 5<h2 id="bare-image">Bare image</h2>
6<p><img src="diagram.png" alt=""></p> 6<p><img src="diagram.png" alt=""></p>
7<h1 id="captioned-figure">Captioned figure</h1> 7<h2 id="captioned-figure">Captioned figure</h2>
8<figure><img src="pipeline.svg" alt="The pipeline, end to end" width="640" class="diagram"><figcaption>The pipeline, end to end</figcaption></figure> 8<figure><img src="pipeline.svg" alt="The pipeline, end to end" width="640" class="diagram"><figcaption>The pipeline, end to end</figcaption></figure>
9<h1 id="caption-with-markup">Caption with markup</h1> 9<h2 id="caption-with-markup">Caption with markup</h2>
10<figure><img src="chart.png" alt="A stylised chart"><figcaption>A <em>stylised</em> chart</figcaption></figure> 10<figure><img src="chart.png" alt="A stylised chart"><figcaption>A <em>stylised</em> chart</figcaption></figure>
11<h1 id="quoted-attribute-values">Quoted attribute values</h1> 11<h2 id="quoted-attribute-values">Quoted attribute values</h2>
12<figure><img src="cat.jpg" alt="a cat, sitting" loading="lazy"></figure> 12<figure><img src="cat.jpg" alt="a cat, sitting" loading="lazy"></figure>
13<h1 id="image-with-a-description-is-a-link">Image with a description is a link</h1> 13<h2 id="image-with-a-description-is-a-link">Image with a description is a link</h2>
14<p><a href="diagram.png">the diagram</a></p> 14<p><a href="diagram.png">the diagram</a></p>
tests/snapshots/constructs__lists_html.snap +5 −5
@@ -2,7 +2,7 @@
2source: tests/constructs.rs 2source: tests/constructs.rs
3expression: "render_fixture(\"lists.org\")" 3expression: "render_fixture(\"lists.org\")"
4--- 4---
5<h1 id="nesting">Nesting</h1> 5<h2 id="nesting">Nesting</h2>
6<ul> 6<ul>
7<li>outer item<ul> 7<li>outer item<ul>
8<li>inner item<ul> 8<li>inner item<ul>
@@ -14,7 +14,7 @@ expression: "render_fixture(\"lists.org\")"
14</li> 14</li>
15<li>second outer</li> 15<li>second outer</li>
16</ul> 16</ul>
17<h1 id="ordered">Ordered</h1> 17<h2 id="ordered">Ordered</h2>
18<ol> 18<ol>
19<li>first</li> 19<li>first</li>
20<li>second<ol> 20<li>second<ol>
@@ -24,13 +24,13 @@ expression: "render_fixture(\"lists.org\")"
24</li> 24</li>
25<li>third</li> 25<li>third</li>
26</ol> 26</ol>
27<h1 id="checkboxes">Checkboxes</h1> 27<h2 id="checkboxes">Checkboxes</h2>
28<ul> 28<ul>
29<li><input type="checkbox" disabled> not done</li> 29<li><input type="checkbox" disabled> not done</li>
30<li><input type="checkbox" disabled checked> done</li> 30<li><input type="checkbox" disabled checked> done</li>
31<li><input type="checkbox" disabled> partially done</li> 31<li><input type="checkbox" disabled> partially done</li>
32</ul> 32</ul>
33<h1 id="description">Description</h1> 33<h2 id="description">Description</h2>
34<dl> 34<dl>
35<dt>term one</dt> 35<dt>term one</dt>
36<dd>the first definition</dd> 36<dd>the first definition</dd>
@@ -39,7 +39,7 @@ expression: "render_fixture(\"lists.org\")"
39<dt><em>marked up</em> term</dt> 39<dt><em>marked up</em> term</dt>
40<dd>definitions hold inline markup</dd> 40<dd>definitions hold inline markup</dd>
41</dl> 41</dl>
42<h1 id="multi-paragraph-items">Multi-paragraph items</h1> 42<h2 id="multi-paragraph-items">Multi-paragraph items</h2>
43<ul> 43<ul>
44<li><p>an item whose body has two paragraphs</p> 44<li><p>an item whose body has two paragraphs</p>
45<p>the second paragraph, indented under the bullet</p> 45<p>the second paragraph, indented under the bullet</p>
tests/snapshots/constructs__out_of_scope_html.snap +7 −7
@@ -3,9 +3,9 @@ source: tests/constructs.rs
3expression: "render_fixture(\"outofscope.org\")" 3expression: "render_fixture(\"outofscope.org\")"
4--- 4---
5<p>Every construct here is on the README's explicit OUT list. The contract is not that we handle them — it is that they degrade predictably and never crash the build.</p> 5<p>Every construct here is on the README's explicit OUT list. The contract is not that we handle them — it is that they degrade predictably and never crash the build.</p>
6<h1 id="babel">Babel</h1> 6<h2 id="babel">Babel</h2>
7<pre><code class="language-sh highlight"><span class="source shell bash"><span class="meta function-call shell"><span class="support function echo shell">echo</span></span><span class="meta function-call arguments shell"> <span class="string quoted double shell"><span class="punctuation definition string begin shell">&quot;</span>the block renders; :results is never executed<span class="punctuation definition string end shell">&quot;</span></span></span></span></code></pre> 7<pre><code class="language-sh highlight"><span class="source shell bash"><span class="meta function-call shell"><span class="support function echo shell">echo</span></span><span class="meta function-call arguments shell"> <span class="string quoted double shell"><span class="punctuation definition string begin shell">&quot;</span>the block renders; :results is never executed<span class="punctuation definition string end shell">&quot;</span></span></span></span></code></pre>
8<h1 id="table-formulas">Table formulas</h1> 8<h2 id="table-formulas">Table formulas</h2>
9<table> 9<table>
10<thead> 10<thead>
11<tr><th>item</th><th>cost</th></tr> 11<tr><th>item</th><th>cost</th></tr>
@@ -15,14 +15,14 @@ expression: "render_fixture(\"outofscope.org\")"
15<tr><td>b</td><td>2</td></tr> 15<tr><td>b</td><td>2</td></tr>
16</tbody> 16</tbody>
17</table> 17</table>
18<h1 id="latex">LaTeX</h1> 18<h2 id="latex">LaTeX</h2>
19<p>Inline math $x^2 + y^2$ and a display block:</p> 19<p>Inline math $x^2 + y^2$ and a display block:</p>
20<p>\begin{equation} E = mc^2 \end{equation}</p> 20<p>\begin{equation} E = mc^2 \end{equation}</p>
21<h1 id="macros-and-radio-targets">Macros and radio targets</h1> 21<h2 id="macros-and-radio-targets">Macros and radio targets</h2>
22<p>A macro call {{{author}}} and a &lt;&lt;&lt;radio target&gt;&gt;&gt; stay literal.</p> 22<p>A macro call {{{author}}} and a &lt;&lt;&lt;radio target&gt;&gt;&gt; stay literal.</p>
23<h1 id="drawers">Drawers</h1> 23<h2 id="drawers">Drawers</h2>
24<h1 id="verse">Verse</h1> 24<h2 id="verse">Verse</h2>
25<pre>An unmodelled block type 25<pre>An unmodelled block type
26keeps its content verbatim.</pre> 26keeps its content verbatim.</pre>
27<h1 id="entities">Entities</h1> 27<h2 id="entities">Entities</h2>
28<p>The full entity set is out of scope, so \alpha stays literal.</p> 28<p>The full entity set is out of scope, so \alpha stays literal.</p>
tests/snapshots/constructs__timestamps_html.snap +4 −4
@@ -2,13 +2,13 @@
2source: tests/constructs.rs 2source: tests/constructs.rs
3expression: "render_fixture(\"timestamps.org\")" 3expression: "render_fixture(\"timestamps.org\")"
4--- 4---
5<h1 id="single">Single</h1> 5<h2 id="single">Single</h2>
6<p>An active date <time class="timestamp" datetime="2024-01-15">2024-01-15</time> and an inactive one <time class="timestamp inactive" datetime="2024-01-15">2024-01-15</time>.</p> 6<p>An active date <time class="timestamp" datetime="2024-01-15">2024-01-15</time> and an inactive one <time class="timestamp inactive" datetime="2024-01-15">2024-01-15</time>.</p>
7<p>With a time: <time class="timestamp" datetime="2024-01-15T10:30">2024-01-15 10:30</time>.</p> 7<p>With a time: <time class="timestamp" datetime="2024-01-15T10:30">2024-01-15 10:30</time>.</p>
8<h1 id="ranges">Ranges</h1> 8<h2 id="ranges">Ranges</h2>
9<p>A same-day time range <time class="timestamp" datetime="2024-01-15T10:00">2024-01-15 10:00</time>&#8211;<time class="timestamp" datetime="2024-01-15T11:45">11:45</time>.</p> 9<p>A same-day time range <time class="timestamp" datetime="2024-01-15T10:00">2024-01-15 10:00</time>&#8211;<time class="timestamp" datetime="2024-01-15T11:45">11:45</time>.</p>
10<p>A multi-day range <time class="timestamp" datetime="2024-01-15">2024-01-15</time>&#8211;<time class="timestamp" datetime="2024-01-20">2024-01-20</time>.</p> 10<p>A multi-day range <time class="timestamp" datetime="2024-01-15">2024-01-15</time>&#8211;<time class="timestamp" datetime="2024-01-20">2024-01-20</time>.</p>
11<h1 id="ignored-decorations">Ignored decorations</h1> 11<h2 id="ignored-decorations">Ignored decorations</h2>
12<p>A repeater is dropped: <time class="timestamp" datetime="2024-01-15">2024-01-15</time>.</p> 12<p>A repeater is dropped: <time class="timestamp" datetime="2024-01-15">2024-01-15</time>.</p>
13<h1 id="not-timestamps">Not timestamps</h1> 13<h2 id="not-timestamps">Not timestamps</h2>
14<p>Comparisons like 3 &lt; 4 and [not a stamp] stay literal text.</p> 14<p>Comparisons like 3 &lt; 4 and [not a stamp] stay literal text.</p>
tests/snapshots/oracle__oracle_blocks.snap +12 −12
@@ -5,9 +5,9 @@ expression: report
5agreement: 51/59 skeleton lines (86.4%) 5agreement: 51/59 skeleton lines (86.4%)
6(- org-ssg, + emacs) 6(- org-ssg, + emacs)
7 7
8 <h1> 8 <h2>
9 "Quote" 9 "Quote"
10 </h1> 10 </h2>
11 <blockquote> 11 <blockquote>
12 <p> 12 <p>
13 "A quoted paragraph with" 13 "A quoted paragraph with"
@@ -22,27 +22,27 @@ agreement: 51/59 skeleton lines (86.4%)
22 "And a second paragraph." 22 "And a second paragraph."
23 </p> 23 </p>
24 </blockquote> 24 </blockquote>
25 <h1> 25 <h2>
26 "Center" 26 "Center"
27 </h1> 27 </h2>
28 <p> 28 <p>
29 "Centred text." 29 "Centred text."
30 </p> 30 </p>
31 <h1> 31 <h2>
32 "Example" 32 "Example"
33 </h1> 33 </h2>
34 <pre> 34 <pre>
35 "Verbatim *not bold* text. Indentation preserved." 35 "Verbatim *not bold* text. Indentation preserved."
36 </pre> 36 </pre>
37 <h1> 37 <h2>
38 "Export" 38 "Export"
39 </h1> 39 </h2>
40 <aside> 40 <aside>
41 "Raw HTML passes through." 41 "Raw HTML passes through."
42 </aside> 42 </aside>
43 <h1> 43 <h2>
44 "Source" 44 "Source"
45 </h1> 45 </h2>
46 <pre> 46 <pre>
47- <code> 47- <code>
48 "def greet(name): return f\"hello {name}\"" 48 "def greet(name): return f\"hello {name}\""
@@ -53,9 +53,9 @@ agreement: 51/59 skeleton lines (86.4%)
53 "plain block, no language" 53 "plain block, no language"
54- </code> 54- </code>
55 </pre> 55 </pre>
56 <h1> 56 <h2>
57 "Nested" 57 "Nested"
58 </h1> 58 </h2>
59 <blockquote> 59 <blockquote>
60 <p> 60 <p>
61 "A quote containing a source block:" 61 "A quote containing a source block:"
tests/snapshots/oracle__oracle_core.snap +4 −4
@@ -16,9 +16,9 @@ agreement: 45/54 skeleton lines (83.3%)
16 </code> 16 </code>
17 "." 17 "."
18 </p> 18 </p>
19 <h1> 19 <h2>
20 "Ordered and checked" 20 "Ordered and checked"
21 </h1> 21 </h2>
22 <ol> 22 <ol>
23 <li> 23 <li>
24 "first item" 24 "first item"
@@ -49,9 +49,9 @@ agreement: 45/54 skeleton lines (83.3%)
49 </li> 49 </li>
50- </ul> 50- </ul>
51+ </ol> 51+ </ol>
52 <h1> 52 <h2>
53 "Links and code" 53 "Links and code"
54 </h1> 54 </h2>
55 <p> 55 <p>
56 "An external" 56 "An external"
57 <a href="https://example.org"> 57 <a href="https://example.org">
tests/snapshots/oracle__oracle_elements.snap +6 −6
@@ -5,9 +5,9 @@ expression: report
5agreement: 64/82 skeleton lines (78.0%) 5agreement: 64/82 skeleton lines (78.0%)
6(- org-ssg, + emacs) 6(- org-ssg, + emacs)
7 7
8 <h1> 8 <h2>
9 "Code and tables" 9 "Code and tables"
10 </h1> 10 </h2>
11 <pre> 11 <pre>
12- <code> 12- <code>
13 "fn main() { println!(\"hello\"); }" 13 "fn main() { println!(\"hello\"); }"
@@ -47,9 +47,9 @@ agreement: 64/82 skeleton lines (78.0%)
47 </tr> 47 </tr>
48 </tbody> 48 </tbody>
49 </table> 49 </table>
50 <h1> 50 <h2>
51 "Links and footnotes" 51 "Links and footnotes"
52 </h1> 52 </h2>
53 <p> 53 <p>
54 "An external link:" 54 "An external link:"
55 <a href="https://example.com"> 55 <a href="https://example.com">
@@ -71,9 +71,9 @@ agreement: 64/82 skeleton lines (78.0%)
71 </a> 71 </a>
72 </sup> 72 </sup>
73 </p> 73 </p>
74 <h1> 74 <h2>
75 "Blocks" 75 "Blocks"
76 </h1> 76 </h2>
77 <blockquote> 77 <blockquote>
78 <p> 78 <p>
79 "A quoted paragraph." 79 "A quoted paragraph."
tests/snapshots/oracle__oracle_headings.snap +10 −10
@@ -5,35 +5,35 @@ expression: report
5agreement: 28/30 skeleton lines (93.3%) 5agreement: 28/30 skeleton lines (93.3%)
6(- org-ssg, + emacs) 6(- org-ssg, + emacs)
7 7
8 <h1> 8 <h2>
9- "TODO [#A] Write the parser work rust" 9- "TODO [#A] Write the parser work rust"
10+ "TODO Write the parser work rust" 10+ "TODO Write the parser work rust"
11 </h1> 11 </h2>
12 <p> 12 <p>
13 "A heading carrying a keyword, a priority, tags and a property drawer." 13 "A heading carrying a keyword, a priority, tags and a property drawer."
14 </p> 14 </p>
15 <h2> 15 <h3>
16 "DONE Nested and finished" 16 "DONE Nested and finished"
17 </h2> 17 </h3>
18 <p> 18 <p>
19 "Sub-headings nest by star count." 19 "Sub-headings nest by star count."
20 </p> 20 </p>
21 <h2> 21 <h3>
22- "[#C] Priority without a keyword" 22- "[#C] Priority without a keyword"
23+ "Priority without a keyword" 23+ "Priority without a keyword"
24 </h2> 24 </h3>
25 <p> 25 <p>
26 "A priority cookie can stand alone." 26 "A priority cookie can stand alone."
27 </p> 27 </p>
28 <h1> 28 <h2>
29 "TODOs are not a keyword" 29 "TODOs are not a keyword"
30 </h1> 30 </h2>
31 <p> 31 <p>
32 "The word boundary matters: this heading has no TODO keyword." 32 "The word boundary matters: this heading has no TODO keyword."
33 </p> 33 </p>
34 <h1> 34 <h2>
35 "DONE" 35 "DONE"
36 </h1> 36 </h2>
37 <p> 37 <p>
38 "A keyword with no title at all." 38 "A keyword with no title at all."
39 </p> 39 </p>
tests/snapshots/oracle__oracle_images.snap +10 −10
@@ -5,15 +5,15 @@ expression: report
5agreement: 28/42 skeleton lines (66.7%) 5agreement: 28/42 skeleton lines (66.7%)
6(- org-ssg, + emacs) 6(- org-ssg, + emacs)
7 7
8 <h1> 8 <h2>
9 "Bare image" 9 "Bare image"
10 </h1> 10 </h2>
11 <p> 11 <p>
12 <img src="diagram.png"> 12 <img src="diagram.png">
13 </p> 13 </p>
14 <h1> 14 <h2>
15 "Captioned figure" 15 "Captioned figure"
16 </h1> 16 </h2>
17- <figure> 17- <figure>
18+ <p> 18+ <p>
19 <img src="pipeline.svg"> 19 <img src="pipeline.svg">
@@ -25,9 +25,9 @@ agreement: 28/42 skeleton lines (66.7%)
25+ <p> 25+ <p>
26+ "Figure 1: The pipeline, end to end" 26+ "Figure 1: The pipeline, end to end"
27+ </p> 27+ </p>
28 <h1> 28 <h2>
29 "Caption with markup" 29 "Caption with markup"
30 </h1> 30 </h2>
31- <figure> 31- <figure>
32+ <p> 32+ <p>
33 <img src="chart.png"> 33 <img src="chart.png">
@@ -45,17 +45,17 @@ agreement: 28/42 skeleton lines (66.7%)
45- </figcaption> 45- </figcaption>
46- </figure> 46- </figure>
47+ </p> 47+ </p>
48 <h1> 48 <h2>
49 "Quoted attribute values" 49 "Quoted attribute values"
50 </h1> 50 </h2>
51- <figure> 51- <figure>
52+ <p> 52+ <p>
53 <img src="cat.jpg"> 53 <img src="cat.jpg">
54- </figure> 54- </figure>
55+ </p> 55+ </p>
56 <h1> 56 <h2>
57 "Image with a description is a link" 57 "Image with a description is a link"
58 </h1> 58 </h2>
59 <p> 59 <p>
60 <a href="diagram.png"> 60 <a href="diagram.png">
61 "the diagram" 61 "the diagram"
tests/snapshots/oracle__oracle_lists.snap +10 −10
@@ -5,9 +5,9 @@ expression: report
5agreement: 100/111 skeleton lines (90.1%) 5agreement: 100/111 skeleton lines (90.1%)
6(- org-ssg, + emacs) 6(- org-ssg, + emacs)
7 7
8 <h1> 8 <h2>
9 "Nesting" 9 "Nesting"
10 </h1> 10 </h2>
11 <ul> 11 <ul>
12 <li> 12 <li>
13 "outer item" 13 "outer item"
@@ -29,9 +29,9 @@ agreement: 100/111 skeleton lines (90.1%)
29 "second outer" 29 "second outer"
30 </li> 30 </li>
31 </ul> 31 </ul>
32 <h1> 32 <h2>
33 "Ordered" 33 "Ordered"
34 </h1> 34 </h2>
35 <ol> 35 <ol>
36 <li> 36 <li>
37 "first" 37 "first"
@@ -51,9 +51,9 @@ agreement: 100/111 skeleton lines (90.1%)
51 "third" 51 "third"
52 </li> 52 </li>
53 </ol> 53 </ol>
54 <h1> 54 <h2>
55 "Checkboxes" 55 "Checkboxes"
56 </h1> 56 </h2>
57 <ul> 57 <ul>
58 <li> 58 <li>
59- <input> 59- <input>
@@ -77,9 +77,9 @@ agreement: 100/111 skeleton lines (90.1%)
77 "partially done" 77 "partially done"
78 </li> 78 </li>
79 </ul> 79 </ul>
80 <h1> 80 <h2>
81 "Description" 81 "Description"
82 </h1> 82 </h2>
83 <dl> 83 <dl>
84 <dt> 84 <dt>
85 "term one" 85 "term one"
@@ -105,9 +105,9 @@ agreement: 100/111 skeleton lines (90.1%)
105 "definitions hold inline markup" 105 "definitions hold inline markup"
106 </dd> 106 </dd>
107 </dl> 107 </dl>
108 <h1> 108 <h2>
109 "Multi-paragraph items" 109 "Multi-paragraph items"
110 </h1> 110 </h2>
111 <ul> 111 <ul>
112 <li> 112 <li>
113 <p> 113 <p>
tests/snapshots/oracle__oracle_minimal.snap +6 −6
@@ -8,9 +8,9 @@ agreement: 38/42 skeleton lines (90.5%)
8 <p> 8 <p>
9 "A single paragraph of preamble text before any heading." 9 "A single paragraph of preamble text before any heading."
10 </p> 10 </p>
11 <h1> 11 <h2>
12 "First Heading" 12 "First Heading"
13 </h1> 13 </h2>
14 <p> 14 <p>
15 "Some body text with" 15 "Some body text with"
16- <strong> 16- <strong>
@@ -30,9 +30,9 @@ agreement: 38/42 skeleton lines (90.5%)
30 </code> 30 </code>
31 "." 31 "."
32 </p> 32 </p>
33 <h2> 33 <h3>
34 "A Subheading tag1 tag2" 34 "A Subheading tag1 tag2"
35 </h2> 35 </h3>
36 <ul> 36 <ul>
37 <li> 37 <li>
38 "an unordered item" 38 "an unordered item"
@@ -41,9 +41,9 @@ agreement: 38/42 skeleton lines (90.5%)
41 "another with a checkbox [ ]" 41 "another with a checkbox [ ]"
42 </li> 42 </li>
43 </ul> 43 </ul>
44 <h1> 44 <h2>
45 "Second Heading" 45 "Second Heading"
46 </h1> 46 </h2>
47 <p> 47 <p>
48 "See" 48 "See"
49 <a href="#first"> 49 <a href="#first">
tests/snapshots/oracle__oracle_timestamps.snap +8 −8
@@ -5,9 +5,9 @@ expression: report
5agreement: 25/62 skeleton lines (40.3%) 5agreement: 25/62 skeleton lines (40.3%)
6(- org-ssg, + emacs) 6(- org-ssg, + emacs)
7 7
8 <h1> 8 <h2>
9 "Single" 9 "Single"
10 </h1> 10 </h2>
11 <p> 11 <p>
12- "An active date" 12- "An active date"
13- <time> 13- <time>
@@ -28,9 +28,9 @@ agreement: 25/62 skeleton lines (40.3%)
28- "." 28- "."
29+ "With a time: <2024-01-15 Mon 10:30>." 29+ "With a time: <2024-01-15 Mon 10:30>."
30 </p> 30 </p>
31 <h1> 31 <h2>
32 "Ranges" 32 "Ranges"
33 </h1> 33 </h2>
34 <p> 34 <p>
35- "A same-day time range" 35- "A same-day time range"
36- <time> 36- <time>
@@ -55,9 +55,9 @@ agreement: 25/62 skeleton lines (40.3%)
55- "." 55- "."
56+ "A multi-day range <2024-01-15 Mon>–<2024-01-20 Sat>." 56+ "A multi-day range <2024-01-15 Mon>–<2024-01-20 Sat>."
57 </p> 57 </p>
58 <h1> 58 <h2>
59 "Ignored decorations" 59 "Ignored decorations"
60 </h1> 60 </h2>
61 <p> 61 <p>
62- "A repeater is dropped:" 62- "A repeater is dropped:"
63- <time> 63- <time>
@@ -66,9 +66,9 @@ agreement: 25/62 skeleton lines (40.3%)
66- "." 66- "."
67+ "A repeater is dropped: <2024-01-15 Mon +1w>." 67+ "A repeater is dropped: <2024-01-15 Mon +1w>."
68 </p> 68 </p>
69 <h1> 69 <h2>
70 "Not timestamps" 70 "Not timestamps"
71 </h1> 71 </h2>
72 <p> 72 <p>
73 "Comparisons like 3 < 4 and [not a stamp] stay literal text." 73 "Comparisons like 3 < 4 and [not a stamp] stay literal text."
74 </p> 74 </p>
tests/snapshots/pipeline__core_html.snap +2 −2
@@ -3,7 +3,7 @@ source: tests/pipeline.rs
3expression: "render_fixture(\"core.org\")" 3expression: "render_fixture(\"core.org\")"
4--- 4---
5<p>Intro paragraph with a bare URL <a href="https://example.com">https://example.com</a> and some <code>inline code</code>.</p> 5<p>Intro paragraph with a bare URL <a href="https://example.com">https://example.com</a> and some <code>inline code</code>.</p>
6<h1 id="ordered-and-checked">Ordered and checked</h1> 6<h2 id="ordered-and-checked">Ordered and checked</h2>
7<ol> 7<ol>
8<li>first item</li> 8<li>first item</li>
9<li>second item with <em>emphasis</em></li> 9<li>second item with <em>emphasis</em></li>
@@ -12,7 +12,7 @@ expression: "render_fixture(\"core.org\")"
12<li><input type="checkbox" disabled> todo item</li> 12<li><input type="checkbox" disabled> todo item</li>
13<li><input type="checkbox" disabled checked> done item</li> 13<li><input type="checkbox" disabled checked> done item</li>
14</ul> 14</ul>
15<h1 id="links-and-code">Links and code</h1> 15<h2 id="links-and-code">Links and code</h2>
16<p>An external <a href="https://example.org">site</a> and a bare <a href="https://bare.example">https://bare.example</a>.</p> 16<p>An external <a href="https://example.org">site</a> and a bare <a href="https://bare.example">https://bare.example</a>.</p>
17<pre><code class="language-rust highlight"><span class="source rust"><span class="meta function rust"><span class="meta function rust"><span class="storage type function rust">fn</span> </span><span class="entity name function rust">main</span></span><span class="meta function rust"><span class="meta function parameters rust"><span class="punctuation section parameters begin rust">(</span></span><span class="meta function rust"><span class="meta function parameters rust"><span class="punctuation section parameters end rust">)</span></span></span></span><span class="meta function rust"> </span><span class="meta function rust"><span class="meta block rust"><span class="punctuation section block begin rust">{</span> 17<pre><code class="language-rust highlight"><span class="source rust"><span class="meta function rust"><span class="meta function rust"><span class="storage type function rust">fn</span> </span><span class="entity name function rust">main</span></span><span class="meta function rust"><span class="meta function parameters rust"><span class="punctuation section parameters begin rust">(</span></span><span class="meta function rust"><span class="meta function parameters rust"><span class="punctuation section parameters end rust">)</span></span></span></span><span class="meta function rust"> </span><span class="meta function rust"><span class="meta block rust"><span class="punctuation section block begin rust">{</span>
18 <span class="support macro rust">println!</span><span class="meta group rust"><span class="punctuation section group begin rust">(</span></span><span class="meta group rust"><span class="string quoted double rust"><span class="punctuation definition string begin rust">&quot;</span>hello<span class="punctuation definition string end rust">&quot;</span></span></span><span class="meta group rust"><span class="punctuation section group end rust">)</span></span><span class="punctuation terminator rust">;</span> 18 <span class="support macro rust">println!</span><span class="meta group rust"><span class="punctuation section group begin rust">(</span></span><span class="meta group rust"><span class="string quoted double rust"><span class="punctuation definition string begin rust">&quot;</span>hello<span class="punctuation definition string end rust">&quot;</span></span></span><span class="meta group rust"><span class="punctuation section group end rust">)</span></span><span class="punctuation terminator rust">;</span>
tests/snapshots/pipeline__minimal_html.snap +3 −3
@@ -3,12 +3,12 @@ source: tests/pipeline.rs
3expression: "render_fixture(\"minimal.org\")" 3expression: "render_fixture(\"minimal.org\")"
4--- 4---
5<p>A single paragraph of preamble text before any heading.</p> 5<p>A single paragraph of preamble text before any heading.</p>
6<h1 id="first">First Heading</h1> 6<h2 id="first">First Heading</h2>
7<p>Some body text with <strong>bold</strong>, <em>italic</em>, and <code class="verbatim">verbatim</code>.</p> 7<p>Some body text with <strong>bold</strong>, <em>italic</em>, and <code class="verbatim">verbatim</code>.</p>
8<h2 id="a-subheading">A Subheading <span class="tag">tag1</span> <span class="tag">tag2</span></h2> 8<h3 id="a-subheading">A Subheading <span class="tag">tag1</span> <span class="tag">tag2</span></h3>
9<ul> 9<ul>
10<li>an unordered item</li> 10<li>an unordered item</li>
11<li>another with a checkbox [ ]</li> 11<li>another with a checkbox [ ]</li>
12</ul> 12</ul>
13<h1 id="second-heading">Second Heading</h1> 13<h2 id="second-heading">Second Heading</h2>
14<p>See <a href="#first">the first heading</a>.</p> 14<p>See <a href="#first">the first heading</a>.</p>
tests/snapshots/site__site_guide_html.snap +8 −3
@@ -6,19 +6,24 @@ expression: "page(&pages, \"guide.org\").html"
6<html lang="en"> 6<html lang="en">
7<head> 7<head>
8<meta charset="utf-8"> 8<meta charset="utf-8">
9<title>Guide</title> 9<meta name="viewport" content="width=device-width, initial-scale=1">
10<title>Guide &middot; org-ssg site</title>
10<link rel="stylesheet" href="syntax.css"> 11<link rel="stylesheet" href="syntax.css">
11</head> 12</head>
12<body> 13<body>
14<header>
15<a class="site-title" href="index.html">org-ssg site</a>
13<nav> 16<nav>
14<a href="about.html">About</a> 17<a href="about.html">About</a>
15<a href="#">Guide</a> 18<a href="#">Guide</a>
16<a href="index.html">Home</a> 19<a href="index.html">Home</a>
17</nav> 20</nav>
21</header>
18<main> 22<main>
19<h1 id="setup">Setup</h1> 23<h1>Guide</h1>
24<h2 id="setup">Setup</h2>
20<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> 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>
21<h1 id="data">Data</h1> 26<h2 id="data">Data</h2>
22<table> 27<table>
23<thead> 28<thead>
24<tr><th>Name</th><th>Score</th></tr> 29<tr><th>Name</th><th>Score</th></tr>
tests/snapshots/site__site_index_html.snap +7 −2
@@ -6,19 +6,24 @@ expression: "page(&pages, \"index.org\").html"
6<html lang="en"> 6<html lang="en">
7<head> 7<head>
8<meta charset="utf-8"> 8<meta charset="utf-8">
9<title>Home</title> 9<meta name="viewport" content="width=device-width, initial-scale=1">
10<title>Home &middot; org-ssg site</title>
10<link rel="stylesheet" href="syntax.css"> 11<link rel="stylesheet" href="syntax.css">
11</head> 12</head>
12<body> 13<body>
14<header>
15<a class="site-title" href="index.html">org-ssg site</a>
13<nav> 16<nav>
14<a href="about.html">About</a> 17<a href="about.html">About</a>
15<a href="guide.html">Guide</a> 18<a href="guide.html">Guide</a>
16<a href="#">Home</a> 19<a href="#">Home</a>
17</nav> 20</nav>
21</header>
18<main> 22<main>
23<h1>Home</h1>
19<p>Welcome. See the <a href="guide.html">guide</a> and jump straight to its <a href="guide.html#setup">setup section</a> across files.</p> 24<p>Welcome. See the <a href="guide.html">guide</a> and jump straight to its <a href="guide.html#setup">setup section</a> across files.</p>
20<p>Also see <a href="#overview">Overview</a> further down this page.</p> 25<p>Also see <a href="#overview">Overview</a> further down this page.</p>
21<h1 id="overview">Overview</h1> 26<h2 id="overview">Overview</h2>
22<p>The overview lives on the home page.</p> 27<p>The overview lives on the home page.</p>
23</main> 28</main>
24</body> 29</body>