krz/orgo

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

Commit cbd8cc8c4b

cbd8cc8c4bc59284882a746ad016ca6a8c8d126c

parent: f2f12bfb9c

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-11 20:31 UTC

0.17: asset roots outside the source, and template hashing that is per template

Two things the migration to a real site made obvious.

**Static files that live elsewhere.** weblorg publishes `theme/static/` at `/`,
and until now the only way to keep those URLs was to copy the files next to the
writing — which is how a repository ends up with two `styles.css` and a note
asking you to keep them identical. `[build] assets = ["../theme/static"]` reads
them where they are. Each directory's contents land at the site root, paths may
point outside the source, and `watch`/`serve` watch them too, so editing a
stylesheet up there still reloads the page. Two files claiming one URL is a
build error naming both, rather than a coin flip decided by directory order.

**Template hashing, per template.** A page's render key hashed *every* template,
so editing `feed.xml` re-rendered a 196-page site. It now hashes the layout the
page actually uses plus what that layout extends, includes or imports —
followed statically, with a template whose include is computed at render time
falling back to depending on everything, because over-invalidating is slow and
under-invalidating publishes a stale page.

On cleberg.net, editing:

| feed.xml   | 196 → 1 rendered   |
| post.html  | 196 → 170 rendered |
| base.html  | 196 → 195 rendered |

base.html is extended by nearly everything, so it still re-renders nearly
everything — correct, and why the win shows on the other edits. The one page it
does not reach is the feed, which extends nothing.

Full and incremental builds remain byte-identical over the 196-page corpus.
Cache format 7: the manifest's global template hash is gone, since the
comparison it fed has been replaced by the per-page render key.

Layout: unified · split

Cargo.lock +1 −1
@@ -675,7 +675,7 @@ dependencies = [
675675
676676[[package]]
677677name = "org-ssg"
678version = "0.16.0"
678version = "0.17.0"
679679dependencies = [
680680 "anyhow",
681681 "blake3",
Cargo.toml +1 −1
@@ -1,6 +1,6 @@
11[package]
22name = "org-ssg"
3version = "0.16.0"
3version = "0.17.0"
44edition = "2021"
55description = "Org-mode static site generator that renders the org element tree straight to HTML"
66license = "MIT"
README.md +5 −1
@@ -69,6 +69,10 @@ expose_page_list = false
6969[highlight]
7070theme = "InspiredGitHub"
7171
72[build]
73drafts = false
74assets = [] # extra directories copied to the site root, e.g. ["../theme/static"]
75
7276[html]
7377heading_offset = 1 # a level-1 org heading becomes <h2>, beneath the layout's <h1>
7478```
@@ -382,7 +386,7 @@ all-of-org. Phase 0 checked this line against a real 179-file corpus and found i
382386| **18** | **Per-page layouts: `[[pages]]` rules and `#+TEMPLATE:`** | **done** |
383387| **19** | **Export parity: relative heading levels, special strings, sub/superscript, caption numbering, checkbox and counter markup, table marker columns, special blocks** | **done** |
384388| **20** | **Correctness debt: org's entity table, table captions, a reported `#+INCLUDE:`, and an oracle that separates deliberate divergence from defects** | **done** |
385| 21 | Extra asset roots; per-template hashing so one layout edit does not re-render the site | next |
389| **21** | **Extra asset roots; per-template hashing so one layout edit does not re-render the site** | **done** |
386390| 22 | Release engineering: CI, MSRV, published binaries, changelog, a written compatibility promise | 1.0 |
387391
388392### v0.2 in / out
docs/guide/02-configuration.org +23
@@ -33,6 +33,7 @@ syntaxes_dir = "syntaxes"
3333
3434[build]
3535drafts = false
36assets = []
3637
3738[html]
3839heading_offset = 1
@@ -190,10 +191,32 @@ should not stop a site from building.
190191| Key | Default | Meaning |
191192|-----+---------+---------|
192193| =drafts= | =false= | Include pages marked =#+DRAFT:=. |
194| =assets= | =[]= | Extra directories copied to the *site root*. |
193195
194196=--drafts= on the command line turns this on for one run. The flag can only turn drafts
195197on; it never turns off a config that asked for them.
196198
199** Static files that live elsewhere
200
201A site's static files do not always sit where its writing does. weblorg publishes
202=theme/static/= at =/=, and a repository migrating from it should not have to move
203=robots.txt= next to its blog posts to keep the URL:
204
205#+BEGIN_SRC toml
206[build]
207assets = ["../theme/static"]
208#+END_SRC
209
210Paths are relative to the source root and may point outside it. Each directory's
211*contents* land at the site root — =theme/static/img/logo.svg= publishes at =/img/logo.svg=,
212not =/static/img/logo.svg=.
213
214Two files claiming one URL is a build error naming both, rather than a coin flip decided
215by directory order. A path that is not a directory is an error too, since it is a typo.
216
217Under =watch= and =serve= these directories are watched as well, so editing a stylesheet
218outside the source tree still reloads the page.
219
197220* [html]
198221
199222| Key | Default | Meaning |
docs/guide/07-incremental.org +14 −1
@@ -36,11 +36,24 @@ Every page has a key composed from four hashes:
3636| content | The source file's bytes change. |
3737| resolved links | A link's target moves, is renamed, or disappears. |
3838| config | =org-ssg.toml= changes, or the shared chrome does. |
39| templates | Any template's source changes. |
39| templates | *This page's* layout changes, or something that layout extends or includes. |
4040
4141If a page's key matches the cached one and its output file still exists, the file on disk
4242is already correct and is left untouched.
4343
44** Template scope
45
46The template component covers the layout a page actually renders through, plus everything
47that layout pulls in — followed through ={% extends %}=, ={% include %}=, ={% import %}=
48and ={% from %}=. Editing =feed.xml= on a 196-page site re-renders one page; editing a
49=post.html= that only blog posts use re-renders the posts. =base.html= is extended by
50almost everything, so editing it still re-renders almost everything — which is correct,
51and is why the win shows up on the *other* edits.
52
53A template whose include is computed at render time — ={% include chooser %}= — cannot be
54followed, so it is treated as depending on every template. Over-invalidating costs time;
55under-invalidating publishes a stale page.
56
4457The cache lives in =<output>/.org-ssg-cache.json= and is tagged with a format version. A
4558version mismatch, a missing file or a corrupt file all fall back to a full rebuild — the
4659cache is an optimisation, never a correctness dependency. There is a test for each of
src/config.rs +12
@@ -201,6 +201,14 @@ pub struct Build {
201201 /// ready to be read. `--drafts` turns it on for a session, which is what you want
202202 /// under `watch` while writing one.
203203 pub drafts: bool,
204 /// Extra directories whose contents are copied to the *site root*, on top of the
205 /// non-`.org` files found in the source directory. Relative to the source root, and
206 /// allowed to point outside it.
207 ///
208 /// This exists because a site's static files do not always live where its writing
209 /// does: weblorg publishes `theme/static/` to `/`, and a repository migrating from it
210 /// should not have to move `robots.txt` next to its blog posts to keep the URL.
211 pub assets: Vec<Utf8PathBuf>,
204212}
205213
206214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -565,6 +573,10 @@ syntaxes_dir = "syntaxes"
565573# Include pages marked `#+DRAFT:`. Off by default — the point of marking a draft is that
566574# it is not ready to be read. `--drafts` turns it on for one run, handy under `watch`.
567575drafts = false
576# Extra directories copied to the site root, for static files that live outside the
577# source directory. `assets = ["../theme/static"]` publishes that directory's contents at
578# `/`, not at `/static/`.
579assets = []
568580
569581[html]
570582# How far to push heading levels down: a level-1 org heading becomes <h(1 + offset)>.
src/incremental.rs +1 −2
@@ -29,7 +29,7 @@ use crate::util::output_url;
2929/// Bump whenever the `Document` type, hashing scheme, or resolution rules change.
3030/// On mismatch: discard cache, full rebuild (spec §4.5). The blake3 crate's major
3131/// version is folded in as the "hash-algo version" so a hash upgrade also busts.
32pub const CACHE_FORMAT_VERSION: u32 = 6;
32pub const CACHE_FORMAT_VERSION: u32 = 7;
3333
3434/// blake3 hex identity for a content/config/template/render-key hash class (spec §4.1).
3535pub type Hash = ContentHash;
@@ -192,7 +192,6 @@ pub struct PageRecord {
192192pub struct Manifest {
193193 pub format_version: u32,
194194 pub config_hash: Option<Hash>,
195 pub template_hash: Option<Hash>,
196195 pub pages: HashMap<Utf8PathBuf, PageRecord>,
197196 pub graph: DepGraph,
198197}
src/site.rs +102 −14
@@ -833,7 +833,8 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
833833 // Create the output directory up front so it can be recognised and excluded when it
834834 // lives inside the source tree.
835835 fs::create_dir_all(out).with_context(|| format!("creating {out}"))?;
836 let (_org_rel, assets) = discover(src, &cfg, Some(out))?;
836 let (_org_rel, source_assets) = discover(src, &cfg, Some(out))?;
837 let assets = collect_assets(src, &cfg, Some(out), &source_assets)?;
837838 let (preps, symbols) = prepare_pages(src, &cfg, Some(out))?;
838839
839840 let templater = Templater::load(Some(&src.join(&cfg.templates.dir)), &cfg.site.base_url)?;
@@ -876,7 +877,9 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
876877 site_structure_hash_ordered(&entries)
877878 };
878879 let cfg_hash = combine(config_hash(&cfg), structure_hash);
879 let tmpl_hash = template_hash(templater.sources());
880 // Per template rather than per site: a page's render key covers the layout it uses
881 // and that layout's own includes, so editing `feed.xml` re-renders the feed.
882 let tmpl_hash_for = |name: &str| template_hash(&templater.sources_for(name));
880883
881884 // Compose each page's render key and record its dependency edges.
882885 let mut new_graph = DepGraph::default();
@@ -884,7 +887,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
884887 let listings = build_listings(&cfg, &preps)?;
885888 for p in &preps {
886889 let rlh = resolved_links_hash(&p.source, &p.used, &symbols);
887 let key = render_key(p.content_hash, rlh, cfg_hash, tmpl_hash);
890 let key = render_key(p.content_hash, rlh, cfg_hash, tmpl_hash_for(&p.template));
888891 new_graph.defines.insert(p.source.clone(), p.defines.clone());
889892 new_graph.uses.insert(p.source.clone(), p.used.clone());
890893 new_records.push((
@@ -911,7 +914,6 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
911914 &new_records,
912915 &new_graph,
913916 cfg_hash,
914 tmpl_hash,
915917 out,
916918 prior.as_ref(),
917919 );
@@ -984,7 +986,10 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
984986 // therefore re-renders that section's index and nothing else — the same precision the
985987 // rest of the build gets from content hashing.
986988 for listing in &listings {
987 let key = combine(listing_entries_hash(listing), combine(cfg_hash, tmpl_hash));
989 let key = combine(
990 listing_entries_hash(listing),
991 combine(cfg_hash, tmpl_hash_for(&listing.template)),
992 );
988993 let dest = out.join(&listing.output);
989994 let cached = prior
990995 .as_ref()
@@ -1040,21 +1045,20 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
10401045
10411046 // Assets are a dumb copy in v0.3 (spec §8 Q11): copy every run. Cheap, and keeps the
10421047 // full-vs-incremental byte equivalence trivially true for non-`.org` files.
1043 for rel in &assets {
1044 let from = src.join(rel);
1045 let dest = out.join(rel);
1048 for asset in &assets {
1049 let dest = out.join(&asset.rel);
10461050 if let Some(parent) = dest.parent() {
10471051 fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?;
10481052 }
1049 fs::copy(&from, &dest).with_context(|| format!("copying {from} -> {dest}"))?;
1050 report.assets.push(rel.clone());
1053 fs::copy(&asset.from, &dest)
1054 .with_context(|| format!("copying {} -> {dest}", asset.from))?;
1055 report.assets.push(asset.rel.clone());
10511056 }
10521057
10531058 // Persist the manifest for the next build.
10541059 let manifest = Manifest {
10551060 format_version: CACHE_FORMAT_VERSION,
10561061 config_hash: Some(cfg_hash),
1057 template_hash: Some(tmpl_hash),
10581062 pages: new_records
10591063 .into_iter()
10601064 .map(|(src_path, rec, _)| (src_path, rec))
@@ -1098,7 +1102,6 @@ fn compute_rebuild_set(
10981102 new_records: &[(Utf8PathBuf, PageRecord, Hash)],
10991103 new_graph: &DepGraph,
11001104 cfg_hash: Hash,
1101 tmpl_hash: Hash,
11021105 out: &Utf8Path,
11031106 prior: Option<&Manifest>,
11041107) -> HashSet<Utf8PathBuf> {
@@ -1108,8 +1111,10 @@ fn compute_rebuild_set(
11081111 return all; // No usable cache ⇒ full rebuild.
11091112 };
11101113
1111 // A global config/template change invalidates every page (spec §4.1).
1112 if prior.config_hash != Some(cfg_hash) || prior.template_hash != Some(tmpl_hash) {
1114 // A config change invalidates every page. Template changes do not come through here:
1115 // each page's render key carries the hash of the templates *it* uses, so the key
1116 // comparison below invalidates exactly the pages whose layout moved.
1117 if prior.config_hash != Some(cfg_hash) {
11131118 return all;
11141119 }
11151120
@@ -1196,6 +1201,89 @@ fn discover(
11961201 Ok((org, assets))
11971202}
11981203
1204/// One file to copy through to the output: where it is, and where it goes.
1205#[derive(Debug, Clone, PartialEq)]
1206pub struct Asset {
1207 /// Path to read from.
1208 pub from: Utf8PathBuf,
1209 /// Path to write, relative to the output root.
1210 pub rel: Utf8PathBuf,
1211}
1212
1213/// Every file to copy: the source directory's non-`.org` files, then each extra asset
1214/// root's contents, flattened onto the site root.
1215///
1216/// Two files claiming one output path is an error rather than a race — whichever won
1217/// would depend on directory order, and a site whose favicon changes when a file is
1218/// renamed elsewhere is worse than a build that stops.
1219fn collect_assets(
1220 src: &Utf8Path,
1221 config: &Config,
1222 out: Option<&Utf8Path>,
1223 from_source: &[Utf8PathBuf],
1224) -> Result<Vec<Asset>> {
1225 let mut assets: Vec<Asset> = from_source
1226 .iter()
1227 .map(|rel| Asset {
1228 from: src.join(rel),
1229 rel: rel.clone(),
1230 })
1231 .collect();
1232
1233 for root in &config.build.assets {
1234 let base = src.join(root);
1235 if !base.is_dir() {
1236 anyhow::bail!(
1237 "build.assets lists {root}, which is not a directory (looked in {base})"
1238 );
1239 }
1240 let base_canon = std::fs::canonicalize(&base)
1241 .ok()
1242 .and_then(|p| Utf8PathBuf::from_path_buf(p).ok())
1243 .unwrap_or_else(|| base.clone());
1244 // An asset root that contains the output directory would copy the site into
1245 // itself, one build at a time.
1246 let out_canon = out
1247 .and_then(|out| std::fs::canonicalize(out).ok())
1248 .and_then(|p| Utf8PathBuf::from_path_buf(p).ok());
1249 if out_canon.is_some_and(|o| o.starts_with(&base_canon)) {
1250 anyhow::bail!(
1251 "build.assets lists {root}, which contains the output directory {}",
1252 out.unwrap_or(Utf8Path::new("(none)"))
1253 );
1254 }
1255 for entry in WalkDir::new(&base).sort_by_file_name() {
1256 let entry = entry.with_context(|| format!("walking {base}"))?;
1257 if !entry.file_type().is_file() {
1258 continue;
1259 }
1260 let abs = Utf8PathBuf::from_path_buf(entry.into_path())
1261 .map_err(|p| anyhow::anyhow!("non-UTF-8 path: {}", p.display()))?;
1262 let rel = abs
1263 .strip_prefix(&base)
1264 .map(|p| p.to_owned())
1265 .unwrap_or_else(|_| abs.clone());
1266 if rel.components().any(|c| c.as_str().starts_with('.')) {
1267 continue;
1268 }
1269 assets.push(Asset { from: abs, rel });
1270 }
1271 }
1272
1273 let mut seen: HashMap<&Utf8Path, &Utf8Path> = HashMap::new();
1274 for asset in &assets {
1275 if let Some(first) = seen.insert(&asset.rel, &asset.from) {
1276 anyhow::bail!(
1277 "two files both publish to {}: {first} and {}",
1278 asset.rel,
1279 asset.from
1280 );
1281 }
1282 }
1283 assets.sort_by(|a, b| a.rel.cmp(&b.rel));
1284 Ok(assets)
1285}
1286
11991287/// Source-relative directories that DISCOVER must not descend into: the template
12001288/// directory (build input, not content) and the output directory when it lives inside
12011289/// the source.
src/template.rs +83 −1
@@ -11,7 +11,7 @@
1111//! invalidates the pages that use it, and that has to hold for user templates too, or a
1212//! design change would leave a site half-updated.
1313
14use std::collections::BTreeMap;
14use std::collections::{BTreeMap, BTreeSet};
1515
1616use anyhow::{Context, Result};
1717use camino::Utf8Path;
@@ -206,6 +206,39 @@ impl Templater {
206206 &self.sources
207207 }
208208
209 /// The sources a page rendered through `name` actually depends on: that template plus
210 /// everything it extends, includes or imports, transitively.
211 ///
212 /// This is what keeps a layout edit proportional. Hashing *all* templates into every
213 /// page means touching `feed.xml` re-renders a 200-page site, which is most of the
214 /// wait in a `serve` session spent on design.
215 ///
216 /// A template whose include is computed at render time — `{% include chooser %}` —
217 /// cannot be followed statically, so it depends on everything. Over-invalidating is
218 /// slow; under-invalidating publishes a stale page.
219 pub fn sources_for(&self, name: &str) -> Vec<(String, String)> {
220 let mut seen: BTreeSet<String> = BTreeSet::new();
221 let mut queue = vec![name.to_string()];
222 while let Some(current) = queue.pop() {
223 if !seen.insert(current.clone()) {
224 continue;
225 }
226 let Some((_, source)) = self.sources.iter().find(|(n, _)| *n == current) else {
227 continue;
228 };
229 let (deps, dynamic) = referenced_templates(source);
230 if dynamic {
231 return self.sources.clone();
232 }
233 queue.extend(deps);
234 }
235 self.sources
236 .iter()
237 .filter(|(n, _)| seen.contains(n))
238 .cloned()
239 .collect()
240 }
241
209242 /// Is a template with this name registered?
210243 pub fn has(&self, name: &str) -> bool {
211244 self.env.get_template(name).is_ok()
@@ -532,6 +565,55 @@ pub const STARTER_FEED_TEMPLATE: &str = r#"<?xml version="1.0" encoding="utf-8"?
532565</rss>
533566"#;
534567
568/// Template names a source refers to, and whether any reference is computed at render
569/// time rather than written as a literal.
570///
571/// A hand-rolled scan rather than a parse: minijinja does not expose the dependency
572/// graph, and the three tags that pull in another template all name it as the first
573/// string literal in the tag.
574fn referenced_templates(source: &str) -> (Vec<String>, bool) {
575 const TAGS: &[&str] = &["extends", "include", "import", "from"];
576 let mut names = Vec::new();
577 let mut dynamic = false;
578 let mut rest = source;
579 while let Some(start) = rest.find("{%") {
580 let after = &rest[start + 2..];
581 let Some(end) = after.find("%}") else { break };
582 let tag = &after[..end];
583 rest = &after[end + 2..];
584
585 let keyword = tag
586 .trim_start()
587 .trim_start_matches('-')
588 .split_whitespace()
589 .next()
590 .unwrap_or("");
591 if !TAGS.contains(&keyword) {
592 continue;
593 }
594 match string_literal(tag) {
595 Some(name) => names.push(name),
596 // `{% include some_variable %}` or `{% include ["a", "b"] %}` past the first
597 // entry: the set cannot be known here.
598 None => dynamic = true,
599 }
600 }
601 if source.contains("{% include [") || source.contains("{%- include [") {
602 dynamic = true;
603 }
604 (names, dynamic)
605}
606
607/// The first single- or double-quoted string in a tag body.
608fn string_literal(tag: &str) -> Option<String> {
609 let bytes = tag.as_bytes();
610 let quote = bytes.iter().position(|b| *b == b'"' || *b == b'\'')?;
611 let delim = bytes[quote];
612 let after = &tag[quote + 1..];
613 let end = after.find(delim as char)?;
614 Some(after[..end].to_string())
615}
616
535617/// HTML-escape template output, escaping the same characters Jinja2 does.
536618///
537619/// minijinja additionally escapes `/` as `&#x2f;`, which is a defence for values
src/watch.rs +40 −4
@@ -57,6 +57,12 @@ impl ChangeFilter {
5757 /// Build a filter for a source and output directory. Paths are canonicalized so
5858 /// `.`, `./src`, an absolute path and a symlinked one all compare equal.
5959 pub fn new(src: &Utf8Path, out: &Utf8Path) -> Self {
60 ChangeFilter::with_asset_roots(src, out, &[])
61 }
62
63 /// As [`ChangeFilter::new`], plus extra asset roots. Their paths are recognised too,
64 /// so editing a stylesheet that lives outside the source directory still rebuilds.
65 pub fn with_asset_roots(src: &Utf8Path, out: &Utf8Path, asset_roots: &[Utf8PathBuf]) -> Self {
6066 let canon = |p: &Utf8Path| -> Option<Utf8PathBuf> {
6167 std::fs::canonicalize(p)
6268 .ok()
@@ -77,6 +83,10 @@ impl ChangeFilter {
7783 };
7884
7985 let mut roots: Vec<Utf8PathBuf> = src_canon.into_iter().chain([src.to_owned()]).collect();
86 for root in asset_roots {
87 roots.extend(canon(root));
88 roots.push(root.clone());
89 }
8090 roots.dedup();
8191 // Longest first, so the most specific spelling wins.
8292 roots.sort_by_key(|r| std::cmp::Reverse(r.as_str().len()));
@@ -148,6 +158,27 @@ fn is_editor_scratch(name: &str) -> bool {
148158 || (name.starts_with('#') && name.ends_with('#'))
149159}
150160
161/// The extra asset directories a build will read, as paths that can be watched.
162///
163/// A config that fails to load is not this function's problem — the rebuild reports it
164/// properly — so an unreadable config simply yields no extra roots.
165fn asset_roots(src: &Utf8Path, opts: &BuildOptions) -> Vec<Utf8PathBuf> {
166 let config = match &opts.config_path {
167 Some(path) => crate::config::Config::load_file(path),
168 None => crate::config::Config::load(src),
169 };
170 config
171 .map(|c| {
172 c.build
173 .assets
174 .iter()
175 .map(|root| src.join(root))
176 .filter(|root| root.is_dir())
177 .collect()
178 })
179 .unwrap_or_default()
180}
181
151182/// Build once, then rebuild whenever the source changes. Runs until interrupted.
152183pub fn run(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<()> {
153184 run_with(src, out, opts, |_| {})
@@ -172,12 +203,17 @@ pub fn run_with(
172203 report.rendered.len()
173204 );
174205
175 let filter = ChangeFilter::new(src, out);
206 // Asset roots can live outside the source directory, and a stylesheet that does not
207 // rebuild when saved is worse than no watching at all.
208 let asset_roots = asset_roots(src, opts);
209 let filter = ChangeFilter::with_asset_roots(src, out, &asset_roots);
176210 let (tx, rx) = mpsc::channel();
177211 let mut watcher = make_watcher(tx)?;
178 watcher
179 .watch(src.as_std_path(), RecursiveMode::Recursive)
180 .with_context(|| format!("watching {src}"))?;
212 for root in std::iter::once(src.to_owned()).chain(asset_roots) {
213 watcher
214 .watch(root.as_std_path(), RecursiveMode::Recursive)
215 .with_context(|| format!("watching {root}"))?;
216 }
181217
182218 loop {
183219 // Block until something happens, then keep draining while events keep arriving
tests/config.rs +79
@@ -2210,3 +2210,82 @@ fn same_day_entries_sort_by_time_of_day() {
22102210 "newest first, by the clock:\n{html}"
22112211 );
22122212}
2213
2214// ---------------------------------------------------------------------------
2215// Extra asset roots
2216// ---------------------------------------------------------------------------
2217
2218/// A site's static files do not always live where its writing does. A repository
2219/// migrating from a generator that published `theme/static/` to `/` should not have to
2220/// move `robots.txt` next to its blog posts to keep the URL.
2221#[test]
2222fn an_asset_root_publishes_to_the_site_root() {
2223 let root = tmpdir("assetroot");
2224 let src = root.join("src");
2225 std::fs::create_dir_all(&src).unwrap();
2226 write_site(&src);
2227 std::fs::create_dir_all(root.join("theme/static/img")).unwrap();
2228 std::fs::write(root.join("theme/static/robots.txt"), "User-agent: *\n").unwrap();
2229 std::fs::write(root.join("theme/static/img/logo.svg"), "<svg/>").unwrap();
2230 std::fs::write(
2231 src.join("org-ssg.toml"),
2232 "[build]\nassets = [\"../theme/static\"]\n",
2233 )
2234 .unwrap();
2235 let out = root.join("out");
2236 let report = build(&src, &out);
2237
2238 assert!(out.join("robots.txt").exists(), "flattened onto the root");
2239 assert!(
2240 out.join("img/logo.svg").exists(),
2241 "and keeps its own structure below that"
2242 );
2243 assert!(
2244 report.assets.contains(&Utf8PathBuf::from("robots.txt")),
2245 "the report counts it: {:?}",
2246 report.assets
2247 );
2248}
2249
2250/// Two files claiming one URL is a coin flip decided by directory order. A build that
2251/// stops is better than a favicon that changes when something elsewhere is renamed.
2252#[test]
2253fn two_assets_claiming_one_url_is_an_error() {
2254 let root = tmpdir("assetclash");
2255 let src = root.join("src");
2256 std::fs::create_dir_all(&src).unwrap();
2257 write_site(&src);
2258 std::fs::write(src.join("style.css"), "body{}").unwrap();
2259 std::fs::create_dir_all(root.join("static")).unwrap();
2260 std::fs::write(root.join("static/style.css"), "body{color:red}").unwrap();
2261 std::fs::write(
2262 src.join("org-ssg.toml"),
2263 "[build]\nassets = [\"../static\"]\n",
2264 )
2265 .unwrap();
2266
2267 let err = build_site(&src, &root.join("out"), &BuildOptions::default())
2268 .expect_err("a collision must fail the build");
2269 assert!(
2270 format!("{err:#}").contains("style.css"),
2271 "names the path: {err:#}"
2272 );
2273}
2274
2275/// A typo in a path is a typo, not an empty directory to shrug at.
2276#[test]
2277fn a_missing_asset_root_is_an_error() {
2278 let root = tmpdir("assetmissing");
2279 let src = root.join("src");
2280 std::fs::create_dir_all(&src).unwrap();
2281 write_site(&src);
2282 std::fs::write(
2283 src.join("org-ssg.toml"),
2284 "[build]\nassets = [\"../nope\"]\n",
2285 )
2286 .unwrap();
2287
2288 let err = build_site(&src, &root.join("out"), &BuildOptions::default())
2289 .expect_err("a missing asset root must fail");
2290 assert!(format!("{err:#}").contains("nope"), "names it: {err:#}");
2291}
tests/incremental.rs +65
@@ -448,3 +448,68 @@ fn retitling_a_top_level_page_still_rebuilds_the_site() {
448448 let post = std::fs::read_to_string(out_dir.join("blog/first.html")).unwrap();
449449 assert!(post.contains("Colophon"), "nested pages show the updated nav title");
450450}
451
452/// Editing one layout must re-render the pages that use it, and only those. Hashing every
453/// template into every page means a change to the feed template rewrites the whole site,
454/// which is most of the wait in a `serve` session spent on design.
455#[test]
456fn editing_one_template_rebuilds_only_the_pages_that_use_it() {
457 let root = tmpdir("tmplscope");
458 let src = root.join("src");
459 std::fs::create_dir_all(src.join("blog")).unwrap();
460 std::fs::create_dir_all(src.join("templates")).unwrap();
461 std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nWelcome.\n").unwrap();
462 std::fs::write(src.join("about.org"), "#+TITLE: About\n\nAbout.\n").unwrap();
463 std::fs::write(
464 src.join("blog/post.org"),
465 "#+TITLE: Post\n#+DATE: 2026-01-01\n\nBody.\n",
466 )
467 .unwrap();
468 std::fs::write(
469 src.join("templates/base.html"),
470 "<html><body>{% block content %}{{ body | safe }}{% endblock %}</body></html>",
471 )
472 .unwrap();
473 std::fs::write(
474 src.join("templates/post.html"),
475 "{% extends \"base.html\" %}{% block content %}{{ body | safe }}<p>reply</p>{% endblock %}",
476 )
477 .unwrap();
478 std::fs::write(
479 src.join("org-ssg.toml"),
480 "[[pages]]\nmatch = \"blog\"\ntemplate = \"post.html\"\n",
481 )
482 .unwrap();
483 let out_dir = root.join("out");
484 build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
485
486 // post.html is used by one page.
487 std::fs::write(
488 src.join("templates/post.html"),
489 "{% extends \"base.html\" %}{% block content %}{{ body | safe }}<p>reply now</p>{% endblock %}",
490 )
491 .unwrap();
492 let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
493 assert_eq!(
494 r.rendered,
495 vec![Utf8PathBuf::from("blog/post.html")],
496 "only the page whose layout changed"
497 );
498 assert!(std::fs::read_to_string(out_dir.join("blog/post.html"))
499 .unwrap()
500 .contains("reply now"));
501
502 // base.html is extended by post.html, so editing it reaches both.
503 std::fs::write(
504 src.join("templates/base.html"),
505 "<html><body class=\"new\">{% block content %}{{ body | safe }}{% endblock %}</body></html>",
506 )
507 .unwrap();
508 let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
509 assert_eq!(
510 r.rendered.len(),
511 3,
512 "a layout everything inherits still re-renders everything: {:?}",
513 r.rendered
514 );
515}