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 = [
569569
570570[[package]]
571571name = "org-ssg"
572version = "0.5.0"
572version = "0.6.0"
573573dependencies = [
574574 "anyhow",
575575 "blake3",
@@ -583,6 +583,7 @@ dependencies = [
583583 "serde_json",
584584 "syntect",
585585 "thiserror",
586 "toml",
586587 "walkdir",
587588]
588589
@@ -747,6 +748,15 @@ dependencies = [
747748 "zmij",
748749]
749750
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
750760[[package]]
751761name = "shlex"
752762version = "2.0.1"
@@ -883,6 +893,45 @@ dependencies = [
883893 "time-core",
884894]
885895
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
886935[[package]]
887936name = "unicode-ident"
888937version = "1.0.24"
@@ -1027,6 +1076,12 @@ dependencies = [
10271076 "windows-link",
10281077]
10291078
1079[[package]]
1080name = "winnow"
1081version = "1.0.4"
1082source = "registry+https://github.com/rust-lang/crates.io-index"
1083checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
1084
10301085[[package]]
10311086name = "yaml-rust"
10321087version = "0.4.5"
Cargo.toml +2 −1
@@ -1,6 +1,6 @@
11[package]
22name = "org-ssg"
3version = "0.5.0"
3version = "0.6.0"
44edition = "2021"
55description = "Org-mode static site generator that renders the org element tree straight to HTML"
66license = "MIT"
@@ -31,6 +31,7 @@ clap = { version = "4", features = ["derive"] }
3131anyhow = "1"
3232thiserror = "2"
3333rayon = "1.12.0"
34toml = "1.1.4"
3435
3536[dev-dependencies]
3637insta = { 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
1313it imposes on the data model — pure, hashable, dependency-tracked units — is the real
1414deliverable, even while the corpus is small enough that a full rebuild is instant.
1515
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
1696## Pipeline
1797
1898```
@@ -24,6 +104,7 @@ is the only inherently global stage — it is where the link dependency graph is
24104
25105| Stage | Module | Notes |
26106|---|---|---|
107| config | `src/config.rs` | `org-ssg.toml`: site metadata, nav mode, templates, theme. A hash input. |
27108| PARSE | `src/parser.rs` | Hand-written recursive descent: line lexer → element builder → inline tokenizer. |
28109| audit | `src/audit.rs` | Phase 0 corpus audit: construct frequencies against the IN/OUT line. |
29110| 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
72153| 5 | Link resolution + symbol table (INDEX + RESOLVE, used-target list, broken-link reporting) | done |
73154| 6 | Incremental build layer (hashing, dep graph, invalidation) done; `watch` is a simple poll loop | done |
74155| **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** |
75157
76158### v0.2 in / out
77159
@@ -173,9 +255,12 @@ and the `watch` fs-notify integration.
173255The v1 scope was, by its own admission, *recommended* — a guess about which slice of org
174256matters. Phase 0 replaces both halves of that guess with a measurement: an audit that asks
175257what 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
177published today by weblorg — a wrapper around org's own HTML exporter. That makes it both
178the workload and the incumbent.
258Emacs does.
259
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.
179264
180265```
181266cargo 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
309394title still invalidates everything, correctly, since every page displays it.
310395
311396**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`
313and friends, so its nav comes out as a single `index.org` entry where the live site shows
314four. Treating a directory's `index.org` as top-level too is a one-line change to
315`is_top_level` if that is the behaviour you want.
397root-level pages appear — a site keeping its landing pages at `salary/index.org` and friends
398gets a one-entry nav. That is what `nav.mode = "explicit"` is for: list the pages you want,
399in the order you want them.
316400
317401**From v0.1 (core subset):** headings with nesting and anchors (every heading is now
318402anchored — `: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
332416
333417```
334418cargo build
335cargo test
419cargo test # 86 tests
420cargo run -- init my-site # scaffold a new site
336421cargo run -- build fixtures/minimal.org -o minimal.html # single file
337422cargo run -- build fixtures/site -o _site # whole site (incremental)
338423cargo 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;
3434/// blake3 hex identity for a content/config/template/render-key hash class (spec §4.1).
3535pub type Hash = ContentHash;
3636
37/// Resolved global build config. Its hash is a component of every page's render key
38/// (spec §4.1): a change here can invalidate the whole site. Kept minimal for v0.3 —
39/// there is no user-facing config yet — but structured so real knobs (base URL, TODO
40/// keyword set, highlighter theme id, inline features) flow into the hash when added.
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}
37/// The resolved global build config is [`crate::config::Config`]; its hash is a
38/// component of every page's render key (spec §4.1), so editing `org-ssg.toml`
39/// invalidates the pages it affects.
40pub use crate::config::Config as BuildConfig;
5541
5642/// Compose bytes into a blake3 hash. The one place hashing happens for composite keys.
5743fn hash_bytes(bytes: &[u8]) -> Hash {
@@ -93,7 +79,7 @@ pub fn site_structure_hash(entries: &[(String, String)]) -> Hash {
9379/// blake3 over the template sources (spec §4.1, hash class 3). One combined hash over
9480/// all templates; when partials land, split this per-template so a single-partial edit
9581/// invalidates only its users.
96pub fn template_hash(sources: &[(&str, &str)]) -> Hash {
82pub fn template_hash(sources: &[(String, String)]) -> Hash {
9783 let mut hasher = blake3::Hasher::new();
9884 for (name, src) in sources {
9985 hasher.update(name.as_bytes());
src/lib.rs +1
@@ -9,6 +9,7 @@
99//! deciding which pages actually need rewriting.
1010
1111pub mod audit;
12pub mod config;
1213pub mod incremental;
1314pub mod index;
1415pub mod model;
src/main.rs +101 −7
@@ -7,10 +7,11 @@ use camino::{Utf8Path, Utf8PathBuf};
77use clap::{Parser, Subcommand};
88
99use 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};
1112use org_ssg::resolve::ResolvedDoc;
1213use org_ssg::site::{build_site, BuildOptions, SYNTAX_STYLESHEET};
13use org_ssg::template::Templater;
14use org_ssg::template::{PageContext, SiteContext, Templater};
1415
1516#[derive(Parser)]
1617#[command(name = "org-ssg", version, about = "Org-mode static site generator")]
@@ -32,9 +33,12 @@ enum Command {
3233 /// Bypass the incremental cache and re-render every page (spec §4.5).
3334 #[arg(long)]
3435 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).
3637 #[arg(long)]
3738 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>,
3842 },
3943 /// Watch a source directory and rebuild incrementally on change (simple poll loop).
4044 Watch {
@@ -55,6 +59,13 @@ enum Command {
5559 /// Source directory (or single `.org` file) to audit.
5660 input: Utf8PathBuf,
5761 },
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 },
5869}
5970
6071fn main() -> Result<()> {
@@ -65,11 +76,16 @@ fn main() -> Result<()> {
6576 output,
6677 no_cache,
6778 strict,
79 config,
6880 } => {
6981 if input.is_dir() {
7082 let out = output
7183 .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 };
7389 let report = build_site(&input, &out, &opts)?;
7490 println!(
7591 "built {} page(s) ({} rendered, {} cached), copied {} asset(s) from {} -> {} ({} unresolved link(s), {} diagnostic(s))",
@@ -98,6 +114,7 @@ fn main() -> Result<()> {
98114 print!("{}", org_ssg::audit::report(&result));
99115 Ok(())
100116 }
117 Command::Init { directory } => init(&directory),
101118 Command::Clean { output } => {
102119 if output.exists() {
103120 fs::remove_dir_all(&output)
@@ -111,6 +128,56 @@ fn main() -> Result<()> {
111128 }
112129}
113130
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
114181/// Minimal poll-based watch loop: rebuild incrementally whenever a source file changes.
115182/// Not an OS file-watcher (deferred); it snapshots source mtimes every 500ms.
116183fn watch(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
@@ -187,13 +254,40 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
187254 let highlighter = SyntectHighlighter::new();
188255 let Html(fragment) = render(&resolved, &highlighter);
189256
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 };
191285 let page = templater
192 .render_page(&title, &fragment, &[], SYNTAX_STYLESHEET)
286 .render_page(&site, &page_ctx, &fragment, &[], SYNTAX_STYLESHEET, "", None)
193287 .with_context(|| format!("templating {input}"))?;
194288 fs::write(output, page).with_context(|| format!("writing output file {output}"))?;
195289
196290 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}"))?;
198292 Ok(())
199293}
src/render.rs +45 −19
@@ -42,11 +42,6 @@ pub trait Highlighter {
4242/// two must agree or the CSS will not match the markup.
4343const CLASS_STYLE: ClassStyle = ClassStyle::Spaced;
4444
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
5045/// Syntect's default syntax definitions, loaded once per process (loading is far more
5146/// expensive than highlighting, and a site build highlights many blocks).
5247fn syntax_set() -> &'static SyntaxSet {
@@ -54,18 +49,24 @@ fn syntax_set() -> &'static SyntaxSet {
5449 SET.get_or_init(SyntaxSet::load_defaults_newlines)
5550}
5651
57/// The stylesheet the emitted highlight classes refer to. Highlighting emits CSS
58/// classes rather than inline styles (spec §3.2), so a build must also emit this.
59pub fn syntax_css() -> &'static str {
60 static CSS: OnceLock<String> = OnceLock::new();
61 CSS.get_or_init(|| {
62 let themes = ThemeSet::load_defaults();
63 themes
64 .themes
65 .get(SYNTAX_THEME)
66 .and_then(|theme| css_for_theme_with_class_style(theme, CLASS_STYLE).ok())
67 .unwrap_or_default()
68 })
52fn theme_set() -> &'static ThemeSet {
53 static THEMES: OnceLock<ThemeSet> = OnceLock::new();
54 THEMES.get_or_init(ThemeSet::load_defaults)
55}
56
57/// The stylesheet the emitted highlight classes refer to, for a named syntect theme.
58/// Highlighting emits CSS classes rather than inline styles (spec §3.2), so a build must
59/// also emit this. `None` means the theme name is not one syntect ships — the caller
60/// reports that rather than quietly emitting an empty stylesheet, which would look like
61/// highlighting is broken.
62pub fn syntax_css(theme: &str) -> Option<String> {
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()
6970}
7071
7172/// 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 {
129130/// Carries the highlighter plus the footnote collector across the tree walk (spec §2.4).
130131struct Renderer<'a> {
131132 hl: &'a dyn Highlighter,
133 opts: RenderOptions,
132134 /// Block footnote definitions, keyed by label (collected before the walk).
133135 block_defs: HashMap<String, Vec<Element>>,
134136 /// Inline footnote definitions discovered at reference sites.
@@ -137,10 +139,34 @@ struct Renderer<'a> {
137139 order: Vec<String>,
138140}
139141
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.
141161pub 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 {
142167 let mut r = Renderer {
143168 hl: highlighter,
169 opts: *opts,
144170 block_defs: HashMap::new(),
145171 inline_defs: HashMap::new(),
146172 order: Vec::new(),
@@ -163,7 +189,7 @@ impl Renderer<'_> {
163189
164190 fn render_section(&mut self, section: &Section, out: &mut String) {
165191 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);
167193 let anchor = h
168194 .custom_id
169195 .clone()
src/site.rs +258 −48
@@ -19,14 +19,15 @@ use walkdir::WalkDir;
1919
2020use crate::incremental::{
2121 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,
2323};
2424use crate::index::{document_targets, SymbolTable, TargetId};
2525use crate::model::{ContentHash, Diagnostic, Document};
2626use crate::parser::parse;
27use crate::render::{render, syntax_css, Html, SyntectHighlighter};
27use crate::render::{self, render_with, Html, RenderOptions, SyntectHighlighter};
2828use crate::resolve::resolve;
29use crate::template::{template_sources, NavItem, Templater};
29use crate::config::{self, Config, NavMode};
30use crate::template::{NavItem, PageContext, SiteContext, Templater};
3031use crate::util::{output_path, output_url, relative_root};
3132
3233/// A fully built page: source and output paths (relative to their roots) and its
@@ -49,6 +50,8 @@ pub struct BuildOptions {
4950 pub no_cache: bool,
5051 /// Treat broken internal links as a build error rather than a warning (spec §4.3.4).
5152 pub strict: bool,
53 /// Explicit config file, overriding `org-ssg.toml` in the source directory.
54 pub config_path: Option<Utf8PathBuf>,
5255}
5356
5457/// Summary of a site build.
@@ -98,14 +101,39 @@ struct PagePrep {
98101 broken: Vec<TargetId>,
99102 diagnostics: Vec<Diagnostic>,
100103 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 }
101125}
102126
103127/// DISCOVER + PARSE + INDEX + RESOLVE the whole site, returning per-page prep and the
104128/// global symbol table. RENDER/TEMPLATE is deferred to the caller so the incremental
105129/// build can render only the pages it must. PARSE/INDEX/RESOLVE are cheap and pure, so
106130/// 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)> {
108 let (org_rel, _assets) = discover(src)?;
131fn prepare_pages(
132 src: &Utf8Path,
133 config: &Config,
134 out: Option<&Utf8Path>,
135) -> Result<(Vec<PagePrep>, SymbolTable)> {
136 let (org_rel, _assets) = discover(src, config, out)?;
109137
110138 // PARSE every file (relative paths keep snapshots and links machine-independent).
111139 // 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)> {
127155 symbols.index_document(doc);
128156 }
129157
130 // Nav is global chrome; titles come from #+TITLE (falling back to the file stem) and
131 // URLs from each page's output path, which `#+SLUG:` can rename.
132 let all_pages: Vec<(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
158 // `(source, output, title)` for every page. Titles come from #+TITLE (falling back to
159 // the file stem) and URLs from each page's output path, which `#+SLUG:` can rename.
160 let all_pages: Vec<(Utf8PathBuf, Utf8PathBuf, String)> = docs
137161 .iter()
138 .filter(|(out, _)| is_top_level(out))
139 .cloned()
162 .map(|d| {
163 (
164 d.source_path.clone(),
165 output_path(&d.source_path, &d.keywords),
166 page_title(d),
167 )
168 })
140169 .collect();
141170
142171 // Two sources emitting one page would silently drop a page — and with slugs, a
143172 // collision is a typo away and invisible in the source filenames.
144173 let mut claimed: std::collections::HashMap<&Utf8PathBuf, &Utf8PathBuf> =
145174 std::collections::HashMap::new();
146 for (doc, (out, _)) in docs.iter().zip(&all_pages) {
147 if let Some(other) = claimed.insert(out, &doc.source_path) {
175 for (source, out, _) in &all_pages {
176 if let Some(other) = claimed.insert(out, source) {
148177 anyhow::bail!(
149 "output collision: {} and {} both build to {out} (check their #+SLUG:)",
150 other,
151 doc.source_path
178 "output collision: {other} and {source} both build to {out} \
179 (check their #+SLUG:)"
152180 );
153181 }
154182 }
155183
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
156198 // RESOLVE reads the shared symbol table and writes only into its own page's output,
157199 // so it parallelizes for free once INDEX has finished building the table.
158200 let pages: Vec<PagePrep> = docs
@@ -175,6 +217,7 @@ fn prepare_pages(src: &Utf8Path) -> Result<(Vec<PagePrep>, SymbolTable)> {
175217 .collect();
176218
177219 PagePrep {
220 context: page_context(doc, &output),
178221 source: doc.source_path.clone(),
179222 output,
180223 title: page_title(doc),
@@ -195,9 +238,14 @@ fn prepare_pages(src: &Utf8Path) -> Result<(Vec<PagePrep>, SymbolTable)> {
195238/// Parse + index + resolve + render + template a whole site *in memory*, without
196239/// touching the output directory. Shared by the tests (full render, every page).
197240pub 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)?;
199244 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);
201249
202250 let mut pages = Vec::new();
203251 let mut broken = Vec::new();
@@ -205,7 +253,7 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
205253 for t in &p.broken {
206254 broken.push((p.source.clone(), t.clone()));
207255 }
208 let html = render_page(&templater, &highlighter, p)?;
256 let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &render_opts, p)?;
209257 pages.push(BuiltPage {
210258 source: p.source.clone(),
211259 output: p.output.clone(),
@@ -216,16 +264,46 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
216264 Ok((pages, broken))
217265}
218266
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
219291/// RENDER + TEMPLATE one prepared page into its final HTML string.
292#[allow(clippy::too_many_arguments)]
220293fn render_page(
221294 templater: &Templater,
222295 highlighter: &SyntectHighlighter,
296 site: &SiteContext,
297 pages: Option<&[PageContext]>,
298 render_opts: &RenderOptions,
223299 p: &PagePrep,
224300) -> Result<String> {
225 let Html(fragment) = render(&p.resolved, highlighter);
226 let stylesheet = format!("{}{}", relative_root(&p.source), SYNTAX_STYLESHEET);
301 let Html(fragment) = render_with(&p.resolved, highlighter, render_opts);
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}");
227305 templater
228 .render_page(&p.title, &fragment, &p.nav, &stylesheet)
306 .render_page(site, &p.context, &fragment, &p.nav, &stylesheet, &root, pages)
229307 .with_context(|| format!("templating {}", p.source))
230308}
231309
@@ -236,28 +314,55 @@ pub const SYNTAX_STYLESHEET: &str = "syntax.css";
236314/// `render_key` changed or that link into a changed file's targets; reuses the on-disk
237315/// output of everything else; persists an updated cache manifest.
238316pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<SiteReport> {
239 let (_org_rel, assets) = discover(src)?;
240 let (preps, symbols) = prepare_pages(src)?;
317 let cfg = match &opts.config_path {
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 })?;
241337
242338 // 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
244 // chrome on every page — is built from every page's (path, title), so a title/path
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.
339 // config hash is combined with a site-structure hash covering the global chrome each
340 // page carries, so a change to that chrome re-renders the pages showing it.
251341 //
252 // Keyed on the *output* path, since a `#+SLUG:` change moves a page's URL — and so
253 // its nav link — even though no source filename moved.
254 let nav_entries: Vec<(String, String)> = preps
342 // Which pages belong in that hash depends on what a template can *see*. Normally it
343 // is the nav only — a nested page cannot change another page's nav, so adding a blog
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
255349 .iter()
256 .filter(|p| is_top_level(&p.output))
257 .map(|p| (p.output.to_string(), p.title.clone()))
350 .map(|p| (p.source.clone(), p.output.clone(), p.title.clone()))
258351 .collect();
259 let cfg_hash = combine(config_hash(&cfg), site_structure_hash(&nav_entries));
260 let tmpl_hash = template_hash(template_sources());
352 let structure: Vec<(String, String)> = if cfg.templates.expose_page_list {
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());
261366
262367 // Compose each page's render key and record its dependency edges.
263368 let mut new_graph = DepGraph::default();
@@ -310,7 +415,9 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
310415 }
311416
312417 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);
314421 let mut report = SiteReport::default();
315422
316423 // 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
332439 if let Some(parent) = dest.parent() {
333440 fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?;
334441 }
335 let html = render_page(&templater, &highlighter, p)?;
442 let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &render_opts, p)?;
336443 fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?;
337444 Ok(true)
338445 })
@@ -355,8 +462,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
355462
356463 // The syntax stylesheet the highlighter's CSS classes refer to. Written every build
357464 // (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}"))?;
359 fs::write(out.join(SYNTAX_STYLESHEET), syntax_css())
465 fs::write(out.join(SYNTAX_STYLESHEET), &syntax_css)
360466 .with_context(|| format!("writing {SYNTAX_STYLESHEET} under {out}"))?;
361467
362468 // 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(
475581
476582/// Walk `src`, returning `.org` source paths and non-`.org` asset paths, both relative
477583/// 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);
479590 let mut org = Vec::new();
480591 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 }) {
482602 let entry = entry.with_context(|| format!("walking {src}"))?;
483603 if !entry.file_type().is_file() {
484604 continue;
@@ -489,6 +609,9 @@ fn discover(src: &Utf8Path) -> Result<(Vec<Utf8PathBuf>, Vec<Utf8PathBuf>)> {
489609 .strip_prefix(src)
490610 .map(|p| p.to_owned())
491611 .unwrap_or_else(|_| abs.clone());
612 if rel == config::CONFIG_FILE {
613 continue;
614 }
492615 if rel.extension() == Some("org") {
493616 org.push(rel);
494617 } else {
@@ -500,6 +623,62 @@ fn discover(src: &Utf8Path) -> Result<(Vec<Utf8PathBuf>, Vec<Utf8PathBuf>)> {
500623 Ok((org, assets))
501624}
502625
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
503682/// Does this output path sit at the site root?
504683///
505684/// 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 {
511690 output.parent().is_none_or(|p| p.as_str().is_empty())
512691}
513692
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
514724fn page_title(doc: &Document) -> String {
515725 doc.keywords
516726 .entries
src/template.rs +158 −28
@@ -1,10 +1,20 @@
11//! TEMPLATE stage (spec §2.1, §2.4, §3.3): rendered fragment + page metadata → full HTML.
22//!
33//! 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
5//! invalidates every page that transitively uses it. Keep the fragment/template
6//! boundary sharp so content HTML can be snapshot-tested independently of chrome.
4//!
5//! Templates come from the configured directory when it exists, and fall back to a
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.
713
14use std::collections::BTreeMap;
15
16use anyhow::{Context, Result};
17use camino::Utf8Path;
818use minijinja::{context, Environment};
919use serde::Serialize;
1020
@@ -15,36 +25,73 @@ pub struct NavItem {
1525 pub url: String,
1626}
1727
18/// The base layout applied to every page: `<title>`, a nav bar, and the body.
19/// Minimal but real — a single `base` template, no partials yet.
28/// Site-wide values, exposed to templates as `site`.
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.
2059const BASE_TEMPLATE: &str = r#"<!DOCTYPE html>
21<html lang="en">
60<html lang="{{ site.language }}">
2261<head>
2362<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 %}
2568{%- if stylesheet %}
2669<link rel="stylesheet" href="{{ stylesheet }}">
2770{%- endif %}
2871</head>
2972<body>
73<header>
74<a class="site-title" href="{{ root }}index.html">{{ site.title }}</a>
75{%- if nav %}
3076<nav>
3177{%- for item in nav %}
3278<a href="{{ item.url }}">{{ item.title }}</a>
3379{%- endfor %}
3480</nav>
81{%- endif %}
82</header>
3583<main>
84<h1>{{ page.title }}</h1>
85{%- if page.date %}
86<p class="page-date">{{ page.date }}</p>
87{%- endif %}
3688{{ body | safe }}</main>
3789</body>
3890</html>
3991"#;
4092
41/// The source text of every template that participates in the page layout. Hashed by
42/// the incremental layer (spec §4.1): a base-layout edit invalidates every page that
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}
93/// The name a template must have to serve as the page layout.
94pub const BASE_TEMPLATE_NAME: &str = "base";
4895
4996#[derive(Debug, thiserror::Error)]
5097pub enum TemplateError {
@@ -55,37 +102,120 @@ pub enum TemplateError {
55102/// Wraps a rendered fragment in its page template.
56103pub struct Templater {
57104 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)>,
58108}
59109
60110impl 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
62149 let mut env = Environment::new();
63 env.add_template("base", BASE_TEMPLATE)
64 .expect("base template compiles");
65 Templater { env }
150 for (name, source) in &sources {
151 // `Environment<'static>` needs owned sources; leaking is bounded by the
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
66166 }
67167
68 /// fragment + page metadata → full HTML page. `stylesheet` is the URL of the
69 /// syntax-highlighting stylesheet relative to *this* page (highlighting emits CSS
70 /// classes, so the sheet has to come with it).
168 /// fragment + page metadata → full HTML page.
169 ///
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)]
71173 pub fn render_page(
72174 &self,
73 title: &str,
175 site: &SiteContext,
176 page: &PageContext,
74177 body: &str,
75178 nav: &[NavItem],
76179 stylesheet: &str,
180 root: &str,
181 pages: Option<&[PageContext]>,
77182 ) -> Result<String, TemplateError> {
78183 let tmpl = self
79184 .env
80 .get_template("base")
185 .get_template(BASE_TEMPLATE_NAME)
81186 .map_err(|e| TemplateError::Render(e.to_string()))?;
82 tmpl.render(context! { title => title, body => body, nav => nav, stylesheet => stylesheet })
83 .map_err(|e| TemplateError::Render(e.to_string()))
187 tmpl.render(context! {
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)))
84197 }
85198}
86199
87impl Default for Templater {
88 fn default() -> Self {
89 Self::new()
200/// minijinja's `Display` gives only the top-level message; the useful part (which
201/// template, which line) is in the source and cause chain.
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 }
90208 }
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
91221}
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() {
160160 "highlighting must not emit inline styles:\n{html}"
161161 );
162162 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"),
164166 "the generated stylesheet must define the emitted classes"
165167 );
166168}
tests/incremental.rs +2 −2
@@ -85,7 +85,7 @@ fn full_and_incremental_are_byte_identical_and_second_build_renders_nothing() {
8585 &full,
8686 &BuildOptions {
8787 no_cache: true,
88 strict: false,
88 ..Default::default()
8989 },
9090 )
9191 .unwrap();
@@ -329,7 +329,7 @@ fn parallel_builds_are_deterministic_in_output_and_report_order() {
329329 out,
330330 &BuildOptions {
331331 no_cache: true,
332 strict: false,
332 ..Default::default()
333333 },
334334 )
335335 .unwrap()
tests/oracle.el +3 −4
@@ -16,10 +16,9 @@
1616;; learn what stock org does — normalizing that away would be marking our own homework.
1717(setq org-export-with-toc nil ; we emit no table of contents
1818 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,
20 ; because a template supplies the page <h1>.
21 ; Aligning here keeps a global +1 offset from
22 ; drowning every real finding in the diff.
19 ;; org-html-toplevel-hlevel is left at its default of 2. org-ssg's own default
20 ;; heading_offset is 1, which produces the same <h2>, so both sides now agree
21 ;; without the oracle being told to.
2322 org-html-htmlize-output-type nil ; plain <pre>, not htmlize spans: we highlight
2423 ; with syntect, so comparing code *text* is
2524 ; the meaningful part
tests/snapshots/constructs__blocks_html.snap +6 −6
@@ -2,25 +2,25 @@
22source: tests/constructs.rs
33expression: "render_fixture(\"blocks.org\")"
44---
5<h1 id="quote">Quote</h1>
5<h2 id="quote">Quote</h2>
66<blockquote>
77<p>A quoted paragraph with <em>markup</em>.</p>
88<p>And a second paragraph.</p>
99</blockquote>
10<h1 id="center">Center</h1>
10<h2 id="center">Center</h2>
1111<div class="center">
1212<p>Centred text.</p>
1313</div>
14<h1 id="example">Example</h1>
14<h2 id="example">Example</h2>
1515<pre>Verbatim *not bold* text.
1616 Indentation preserved.</pre>
17<h1 id="export">Export</h1>
17<h2 id="export">Export</h2>
1818<aside class="raw">Raw HTML passes through.</aside>
19<h1 id="source">Source</h1>
19<h2 id="source">Source</h2>
2020<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>
2121 <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>
2222<pre><code class="language-none">plain block, no language</code></pre>
23<h1 id="nested">Nested</h1>
23<h2 id="nested">Nested</h2>
2424<blockquote>
2525<p>A quote containing a source block:</p>
2626<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 @@
22source: tests/constructs.rs
33expression: "render_fixture(\"headings.org\")"
44---
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>
66<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>
88<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>
1010<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>
1212<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>
1414<p>A keyword with no title at all.</p>
tests/snapshots/constructs__images_html.snap +5 −5
@@ -2,13 +2,13 @@
22source: tests/constructs.rs
33expression: "render_fixture(\"images.org\")"
44---
5<h1 id="bare-image">Bare image</h1>
5<h2 id="bare-image">Bare image</h2>
66<p><img src="diagram.png" alt=""></p>
7<h1 id="captioned-figure">Captioned figure</h1>
7<h2 id="captioned-figure">Captioned figure</h2>
88<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>
1010<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>
1212<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>
1414<p><a href="diagram.png">the diagram</a></p>
tests/snapshots/constructs__lists_html.snap +5 −5
@@ -2,7 +2,7 @@
22source: tests/constructs.rs
33expression: "render_fixture(\"lists.org\")"
44---
5<h1 id="nesting">Nesting</h1>
5<h2 id="nesting">Nesting</h2>
66<ul>
77<li>outer item<ul>
88<li>inner item<ul>
@@ -14,7 +14,7 @@ expression: "render_fixture(\"lists.org\")"
1414</li>
1515<li>second outer</li>
1616</ul>
17<h1 id="ordered">Ordered</h1>
17<h2 id="ordered">Ordered</h2>
1818<ol>
1919<li>first</li>
2020<li>second<ol>
@@ -24,13 +24,13 @@ expression: "render_fixture(\"lists.org\")"
2424</li>
2525<li>third</li>
2626</ol>
27<h1 id="checkboxes">Checkboxes</h1>
27<h2 id="checkboxes">Checkboxes</h2>
2828<ul>
2929<li><input type="checkbox" disabled> not done</li>
3030<li><input type="checkbox" disabled checked> done</li>
3131<li><input type="checkbox" disabled> partially done</li>
3232</ul>
33<h1 id="description">Description</h1>
33<h2 id="description">Description</h2>
3434<dl>
3535<dt>term one</dt>
3636<dd>the first definition</dd>
@@ -39,7 +39,7 @@ expression: "render_fixture(\"lists.org\")"
3939<dt><em>marked up</em> term</dt>
4040<dd>definitions hold inline markup</dd>
4141</dl>
42<h1 id="multi-paragraph-items">Multi-paragraph items</h1>
42<h2 id="multi-paragraph-items">Multi-paragraph items</h2>
4343<ul>
4444<li><p>an item whose body has two paragraphs</p>
4545<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
33expression: "render_fixture(\"outofscope.org\")"
44---
55<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>
77<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>
99<table>
1010<thead>
1111<tr><th>item</th><th>cost</th></tr>
@@ -15,14 +15,14 @@ expression: "render_fixture(\"outofscope.org\")"
1515<tr><td>b</td><td>2</td></tr>
1616</tbody>
1717</table>
18<h1 id="latex">LaTeX</h1>
18<h2 id="latex">LaTeX</h2>
1919<p>Inline math $x^2 + y^2$ and a display block:</p>
2020<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>
2222<p>A macro call {{{author}}} and a &lt;&lt;&lt;radio target&gt;&gt;&gt; stay literal.</p>
23<h1 id="drawers">Drawers</h1>
24<h1 id="verse">Verse</h1>
23<h2 id="drawers">Drawers</h2>
24<h2 id="verse">Verse</h2>
2525<pre>An unmodelled block type
2626keeps its content verbatim.</pre>
27<h1 id="entities">Entities</h1>
27<h2 id="entities">Entities</h2>
2828<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 @@
22source: tests/constructs.rs
33expression: "render_fixture(\"timestamps.org\")"
44---
5<h1 id="single">Single</h1>
5<h2 id="single">Single</h2>
66<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>
77<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>
99<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>
1010<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>
1212<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>
1414<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
55agreement: 51/59 skeleton lines (86.4%)
66(- org-ssg, + emacs)
77
8 <h1>
8 <h2>
99 "Quote"
10 </h1>
10 </h2>
1111 <blockquote>
1212 <p>
1313 "A quoted paragraph with"
@@ -22,27 +22,27 @@ agreement: 51/59 skeleton lines (86.4%)
2222 "And a second paragraph."
2323 </p>
2424 </blockquote>
25 <h1>
25 <h2>
2626 "Center"
27 </h1>
27 </h2>
2828 <p>
2929 "Centred text."
3030 </p>
31 <h1>
31 <h2>
3232 "Example"
33 </h1>
33 </h2>
3434 <pre>
3535 "Verbatim *not bold* text. Indentation preserved."
3636 </pre>
37 <h1>
37 <h2>
3838 "Export"
39 </h1>
39 </h2>
4040 <aside>
4141 "Raw HTML passes through."
4242 </aside>
43 <h1>
43 <h2>
4444 "Source"
45 </h1>
45 </h2>
4646 <pre>
4747- <code>
4848 "def greet(name): return f\"hello {name}\""
@@ -53,9 +53,9 @@ agreement: 51/59 skeleton lines (86.4%)
5353 "plain block, no language"
5454- </code>
5555 </pre>
56 <h1>
56 <h2>
5757 "Nested"
58 </h1>
58 </h2>
5959 <blockquote>
6060 <p>
6161 "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%)
1616 </code>
1717 "."
1818 </p>
19 <h1>
19 <h2>
2020 "Ordered and checked"
21 </h1>
21 </h2>
2222 <ol>
2323 <li>
2424 "first item"
@@ -49,9 +49,9 @@ agreement: 45/54 skeleton lines (83.3%)
4949 </li>
5050- </ul>
5151+ </ol>
52 <h1>
52 <h2>
5353 "Links and code"
54 </h1>
54 </h2>
5555 <p>
5656 "An external"
5757 <a href="https://example.org">
tests/snapshots/oracle__oracle_elements.snap +6 −6
@@ -5,9 +5,9 @@ expression: report
55agreement: 64/82 skeleton lines (78.0%)
66(- org-ssg, + emacs)
77
8 <h1>
8 <h2>
99 "Code and tables"
10 </h1>
10 </h2>
1111 <pre>
1212- <code>
1313 "fn main() { println!(\"hello\"); }"
@@ -47,9 +47,9 @@ agreement: 64/82 skeleton lines (78.0%)
4747 </tr>
4848 </tbody>
4949 </table>
50 <h1>
50 <h2>
5151 "Links and footnotes"
52 </h1>
52 </h2>
5353 <p>
5454 "An external link:"
5555 <a href="https://example.com">
@@ -71,9 +71,9 @@ agreement: 64/82 skeleton lines (78.0%)
7171 </a>
7272 </sup>
7373 </p>
74 <h1>
74 <h2>
7575 "Blocks"
76 </h1>
76 </h2>
7777 <blockquote>
7878 <p>
7979 "A quoted paragraph."
tests/snapshots/oracle__oracle_headings.snap +10 −10
@@ -5,35 +5,35 @@ expression: report
55agreement: 28/30 skeleton lines (93.3%)
66(- org-ssg, + emacs)
77
8 <h1>
8 <h2>
99- "TODO [#A] Write the parser work rust"
1010+ "TODO Write the parser work rust"
11 </h1>
11 </h2>
1212 <p>
1313 "A heading carrying a keyword, a priority, tags and a property drawer."
1414 </p>
15 <h2>
15 <h3>
1616 "DONE Nested and finished"
17 </h2>
17 </h3>
1818 <p>
1919 "Sub-headings nest by star count."
2020 </p>
21 <h2>
21 <h3>
2222- "[#C] Priority without a keyword"
2323+ "Priority without a keyword"
24 </h2>
24 </h3>
2525 <p>
2626 "A priority cookie can stand alone."
2727 </p>
28 <h1>
28 <h2>
2929 "TODOs are not a keyword"
30 </h1>
30 </h2>
3131 <p>
3232 "The word boundary matters: this heading has no TODO keyword."
3333 </p>
34 <h1>
34 <h2>
3535 "DONE"
36 </h1>
36 </h2>
3737 <p>
3838 "A keyword with no title at all."
3939 </p>
tests/snapshots/oracle__oracle_images.snap +10 −10
@@ -5,15 +5,15 @@ expression: report
55agreement: 28/42 skeleton lines (66.7%)
66(- org-ssg, + emacs)
77
8 <h1>
8 <h2>
99 "Bare image"
10 </h1>
10 </h2>
1111 <p>
1212 <img src="diagram.png">
1313 </p>
14 <h1>
14 <h2>
1515 "Captioned figure"
16 </h1>
16 </h2>
1717- <figure>
1818+ <p>
1919 <img src="pipeline.svg">
@@ -25,9 +25,9 @@ agreement: 28/42 skeleton lines (66.7%)
2525+ <p>
2626+ "Figure 1: The pipeline, end to end"
2727+ </p>
28 <h1>
28 <h2>
2929 "Caption with markup"
30 </h1>
30 </h2>
3131- <figure>
3232+ <p>
3333 <img src="chart.png">
@@ -45,17 +45,17 @@ agreement: 28/42 skeleton lines (66.7%)
4545- </figcaption>
4646- </figure>
4747+ </p>
48 <h1>
48 <h2>
4949 "Quoted attribute values"
50 </h1>
50 </h2>
5151- <figure>
5252+ <p>
5353 <img src="cat.jpg">
5454- </figure>
5555+ </p>
56 <h1>
56 <h2>
5757 "Image with a description is a link"
58 </h1>
58 </h2>
5959 <p>
6060 <a href="diagram.png">
6161 "the diagram"
tests/snapshots/oracle__oracle_lists.snap +10 −10
@@ -5,9 +5,9 @@ expression: report
55agreement: 100/111 skeleton lines (90.1%)
66(- org-ssg, + emacs)
77
8 <h1>
8 <h2>
99 "Nesting"
10 </h1>
10 </h2>
1111 <ul>
1212 <li>
1313 "outer item"
@@ -29,9 +29,9 @@ agreement: 100/111 skeleton lines (90.1%)
2929 "second outer"
3030 </li>
3131 </ul>
32 <h1>
32 <h2>
3333 "Ordered"
34 </h1>
34 </h2>
3535 <ol>
3636 <li>
3737 "first"
@@ -51,9 +51,9 @@ agreement: 100/111 skeleton lines (90.1%)
5151 "third"
5252 </li>
5353 </ol>
54 <h1>
54 <h2>
5555 "Checkboxes"
56 </h1>
56 </h2>
5757 <ul>
5858 <li>
5959- <input>
@@ -77,9 +77,9 @@ agreement: 100/111 skeleton lines (90.1%)
7777 "partially done"
7878 </li>
7979 </ul>
80 <h1>
80 <h2>
8181 "Description"
82 </h1>
82 </h2>
8383 <dl>
8484 <dt>
8585 "term one"
@@ -105,9 +105,9 @@ agreement: 100/111 skeleton lines (90.1%)
105105 "definitions hold inline markup"
106106 </dd>
107107 </dl>
108 <h1>
108 <h2>
109109 "Multi-paragraph items"
110 </h1>
110 </h2>
111111 <ul>
112112 <li>
113113 <p>
tests/snapshots/oracle__oracle_minimal.snap +6 −6
@@ -8,9 +8,9 @@ agreement: 38/42 skeleton lines (90.5%)
88 <p>
99 "A single paragraph of preamble text before any heading."
1010 </p>
11 <h1>
11 <h2>
1212 "First Heading"
13 </h1>
13 </h2>
1414 <p>
1515 "Some body text with"
1616- <strong>
@@ -30,9 +30,9 @@ agreement: 38/42 skeleton lines (90.5%)
3030 </code>
3131 "."
3232 </p>
33 <h2>
33 <h3>
3434 "A Subheading tag1 tag2"
35 </h2>
35 </h3>
3636 <ul>
3737 <li>
3838 "an unordered item"
@@ -41,9 +41,9 @@ agreement: 38/42 skeleton lines (90.5%)
4141 "another with a checkbox [ ]"
4242 </li>
4343 </ul>
44 <h1>
44 <h2>
4545 "Second Heading"
46 </h1>
46 </h2>
4747 <p>
4848 "See"
4949 <a href="#first">
tests/snapshots/oracle__oracle_timestamps.snap +8 −8
@@ -5,9 +5,9 @@ expression: report
55agreement: 25/62 skeleton lines (40.3%)
66(- org-ssg, + emacs)
77
8 <h1>
8 <h2>
99 "Single"
10 </h1>
10 </h2>
1111 <p>
1212- "An active date"
1313- <time>
@@ -28,9 +28,9 @@ agreement: 25/62 skeleton lines (40.3%)
2828- "."
2929+ "With a time: <2024-01-15 Mon 10:30>."
3030 </p>
31 <h1>
31 <h2>
3232 "Ranges"
33 </h1>
33 </h2>
3434 <p>
3535- "A same-day time range"
3636- <time>
@@ -55,9 +55,9 @@ agreement: 25/62 skeleton lines (40.3%)
5555- "."
5656+ "A multi-day range <2024-01-15 Mon>–<2024-01-20 Sat>."
5757 </p>
58 <h1>
58 <h2>
5959 "Ignored decorations"
60 </h1>
60 </h2>
6161 <p>
6262- "A repeater is dropped:"
6363- <time>
@@ -66,9 +66,9 @@ agreement: 25/62 skeleton lines (40.3%)
6666- "."
6767+ "A repeater is dropped: <2024-01-15 Mon +1w>."
6868 </p>
69 <h1>
69 <h2>
7070 "Not timestamps"
71 </h1>
71 </h2>
7272 <p>
7373 "Comparisons like 3 < 4 and [not a stamp] stay literal text."
7474 </p>
tests/snapshots/pipeline__core_html.snap +2 −2
@@ -3,7 +3,7 @@ source: tests/pipeline.rs
33expression: "render_fixture(\"core.org\")"
44---
55<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>
77<ol>
88<li>first item</li>
99<li>second item with <em>emphasis</em></li>
@@ -12,7 +12,7 @@ expression: "render_fixture(\"core.org\")"
1212<li><input type="checkbox" disabled> todo item</li>
1313<li><input type="checkbox" disabled checked> done item</li>
1414</ul>
15<h1 id="links-and-code">Links and code</h1>
15<h2 id="links-and-code">Links and code</h2>
1616<p>An external <a href="https://example.org">site</a> and a bare <a href="https://bare.example">https://bare.example</a>.</p>
1717<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>
1818 <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
33expression: "render_fixture(\"minimal.org\")"
44---
55<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>
77<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>
99<ul>
1010<li>an unordered item</li>
1111<li>another with a checkbox [ ]</li>
1212</ul>
13<h1 id="second-heading">Second Heading</h1>
13<h2 id="second-heading">Second Heading</h2>
1414<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"
66<html lang="en">
77<head>
88<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>
1011<link rel="stylesheet" href="syntax.css">
1112</head>
1213<body>
14<header>
15<a class="site-title" href="index.html">org-ssg site</a>
1316<nav>
1417<a href="about.html">About</a>
1518<a href="#">Guide</a>
1619<a href="index.html">Home</a>
1720</nav>
21</header>
1822<main>
19<h1 id="setup">Setup</h1>
23<h1>Guide</h1>
24<h2 id="setup">Setup</h2>
2025<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>
2227<table>
2328<thead>
2429<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"
66<html lang="en">
77<head>
88<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>
1011<link rel="stylesheet" href="syntax.css">
1112</head>
1213<body>
14<header>
15<a class="site-title" href="index.html">org-ssg site</a>
1316<nav>
1417<a href="about.html">About</a>
1518<a href="guide.html">Guide</a>
1619<a href="#">Home</a>
1720</nav>
21</header>
1822<main>
23<h1>Home</h1>
1924<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>
2025<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>
2227<p>The overview lives on the home page.</p>
2328</main>
2429</body>