krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

cbd8cc8c4bc59284882a746ad016ca6a8c8d126c

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T20:31:29Z

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.
 Cargo.lock                      |   2 +-
 Cargo.toml                      |   2 +-
 README.md                       |   6 ++-
 docs/guide/02-configuration.org |  23 ++++++++
 docs/guide/07-incremental.org   |  15 +++++-
 src/config.rs                   |  12 +++++
 src/incremental.rs              |   3 +-
 src/site.rs                     | 116 +++++++++++++++++++++++++++++++++++-----
 src/template.rs                 |  84 ++++++++++++++++++++++++++++-
 src/watch.rs                    |  44 +++++++++++++--
 tests/config.rs                 |  79 +++++++++++++++++++++++++++
 tests/incremental.rs            |  65 ++++++++++++++++++++++
 12 files changed, 426 insertions(+), 25 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 5a55506..3a6f42a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -675,7 +675,7 @@ dependencies = [
 
 [[package]]
 name = "org-ssg"
-version = "0.16.0"
+version = "0.17.0"
 dependencies = [
  "anyhow",
  "blake3",
diff --git a/Cargo.toml b/Cargo.toml
index 4b40678..19e7926 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "org-ssg"
-version = "0.16.0"
+version = "0.17.0"
 edition = "2021"
 description = "Org-mode static site generator that renders the org element tree straight to HTML"
 license = "MIT"
diff --git a/README.md b/README.md
index 574eb9e..19ac383 100644
--- a/README.md
+++ b/README.md
@@ -69,6 +69,10 @@ expose_page_list = false
 [highlight]
 theme = "InspiredGitHub"
 
+[build]
+drafts = false
+assets = []            # extra directories copied to the site root, e.g. ["../theme/static"]
+
 [html]
 heading_offset = 1     # a level-1 org heading becomes <h2>, beneath the layout's <h1>
 ```
@@ -382,7 +386,7 @@ all-of-org. Phase 0 checked this line against a real 179-file corpus and found i
 | **18** | **Per-page layouts: `[[pages]]` rules and `#+TEMPLATE:`** | **done** |
 | **19** | **Export parity: relative heading levels, special strings, sub/superscript, caption numbering, checkbox and counter markup, table marker columns, special blocks** | **done** |
 | **20** | **Correctness debt: org's entity table, table captions, a reported `#+INCLUDE:`, and an oracle that separates deliberate divergence from defects** | **done** |
-| 21 | Extra asset roots; per-template hashing so one layout edit does not re-render the site | next |
+| **21** | **Extra asset roots; per-template hashing so one layout edit does not re-render the site** | **done** |
 | 22 | Release engineering: CI, MSRV, published binaries, changelog, a written compatibility promise | 1.0 |
 
 ### v0.2 in / out
diff --git a/docs/guide/02-configuration.org b/docs/guide/02-configuration.org
index 975d878..4a254de 100644
--- a/docs/guide/02-configuration.org
+++ b/docs/guide/02-configuration.org
@@ -33,6 +33,7 @@ syntaxes_dir = "syntaxes"
 
 [build]
 drafts = false
+assets = []
 
 [html]
 heading_offset = 1
@@ -190,10 +191,32 @@ should not stop a site from building.
 | Key | Default | Meaning |
 |-----+---------+---------|
 | =drafts= | =false= | Include pages marked =#+DRAFT:=. |
+| =assets= | =[]= | Extra directories copied to the *site root*. |
 
 =--drafts= on the command line turns this on for one run. The flag can only turn drafts
 on; it never turns off a config that asked for them.
 
+** Static files that live elsewhere
+
+A site's static files do not always sit where its writing does. weblorg publishes
+=theme/static/= at =/=, and a repository migrating from it should not have to move
+=robots.txt= next to its blog posts to keep the URL:
+
+#+BEGIN_SRC toml
+[build]
+assets = ["../theme/static"]
+#+END_SRC
+
+Paths are relative to the source root and may point outside it. Each directory's
+*contents* land at the site root — =theme/static/img/logo.svg= publishes at =/img/logo.svg=,
+not =/static/img/logo.svg=.
+
+Two files claiming one URL is a build error naming both, rather than a coin flip decided
+by directory order. A path that is not a directory is an error too, since it is a typo.
+
+Under =watch= and =serve= these directories are watched as well, so editing a stylesheet
+outside the source tree still reloads the page.
+
 * [html]
 
 | Key | Default | Meaning |
diff --git a/docs/guide/07-incremental.org b/docs/guide/07-incremental.org
index 3b66b3f..9e3e7f3 100644
--- a/docs/guide/07-incremental.org
+++ b/docs/guide/07-incremental.org
@@ -36,11 +36,24 @@ Every page has a key composed from four hashes:
 | content | The source file's bytes change. |
 | resolved links | A link's target moves, is renamed, or disappears. |
 | config | =org-ssg.toml= changes, or the shared chrome does. |
-| templates | Any template's source changes. |
+| templates | *This page's* layout changes, or something that layout extends or includes. |
 
 If a page's key matches the cached one and its output file still exists, the file on disk
 is already correct and is left untouched.
 
+** Template scope
+
+The template component covers the layout a page actually renders through, plus everything
+that layout pulls in — followed through ={% extends %}=, ={% include %}=, ={% import %}=
+and ={% from %}=. Editing =feed.xml= on a 196-page site re-renders one page; editing a
+=post.html= that only blog posts use re-renders the posts. =base.html= is extended by
+almost everything, so editing it still re-renders almost everything — which is correct,
+and is why the win shows up on the *other* edits.
+
+A template whose include is computed at render time — ={% include chooser %}= — cannot be
+followed, so it is treated as depending on every template. Over-invalidating costs time;
+under-invalidating publishes a stale page.
+
 The cache lives in =<output>/.org-ssg-cache.json= and is tagged with a format version. A
 version mismatch, a missing file or a corrupt file all fall back to a full rebuild — the
 cache is an optimisation, never a correctness dependency. There is a test for each of
diff --git a/src/config.rs b/src/config.rs
index 8122243..c68c93f 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -201,6 +201,14 @@ pub struct Build {
     /// ready to be read. `--drafts` turns it on for a session, which is what you want
     /// under `watch` while writing one.
     pub drafts: bool,
+    /// Extra directories whose contents are copied to the *site root*, on top of the
+    /// non-`.org` files found in the source directory. Relative to the source root, and
+    /// allowed to point outside it.
+    ///
+    /// This exists because a site's static files do not always live where its writing
+    /// does: weblorg publishes `theme/static/` to `/`, and a repository migrating from it
+    /// should not have to move `robots.txt` next to its blog posts to keep the URL.
+    pub assets: Vec<Utf8PathBuf>,
 }
 
 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -565,6 +573,10 @@ syntaxes_dir = "syntaxes"
 # Include pages marked `#+DRAFT:`. Off by default — the point of marking a draft is that
 # it is not ready to be read. `--drafts` turns it on for one run, handy under `watch`.
 drafts = false
+# Extra directories copied to the site root, for static files that live outside the
+# source directory. `assets = ["../theme/static"]` publishes that directory's contents at
+# `/`, not at `/static/`.
+assets = []
 
 [html]
 # How far to push heading levels down: a level-1 org heading becomes <h(1 + offset)>.
diff --git a/src/incremental.rs b/src/incremental.rs
index 2a1f79f..1ae9d0d 100644
--- a/src/incremental.rs
+++ b/src/incremental.rs
@@ -29,7 +29,7 @@ use crate::util::output_url;
 /// Bump whenever the `Document` type, hashing scheme, or resolution rules change.
 /// On mismatch: discard cache, full rebuild (spec §4.5). The blake3 crate's major
 /// version is folded in as the "hash-algo version" so a hash upgrade also busts.
-pub const CACHE_FORMAT_VERSION: u32 = 6;
+pub const CACHE_FORMAT_VERSION: u32 = 7;
 
 /// blake3 hex identity for a content/config/template/render-key hash class (spec §4.1).
 pub type Hash = ContentHash;
@@ -192,7 +192,6 @@ pub struct PageRecord {
 pub struct Manifest {
     pub format_version: u32,
     pub config_hash: Option<Hash>,
-    pub template_hash: Option<Hash>,
     pub pages: HashMap<Utf8PathBuf, PageRecord>,
     pub graph: DepGraph,
 }
diff --git a/src/site.rs b/src/site.rs
index 1d11397..cee1dd9 100644
--- a/src/site.rs
+++ b/src/site.rs
@@ -833,7 +833,8 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
     // Create the output directory up front so it can be recognised and excluded when it
     // lives inside the source tree.
     fs::create_dir_all(out).with_context(|| format!("creating {out}"))?;
-    let (_org_rel, assets) = discover(src, &cfg, Some(out))?;
+    let (_org_rel, source_assets) = discover(src, &cfg, Some(out))?;
+    let assets = collect_assets(src, &cfg, Some(out), &source_assets)?;
     let (preps, symbols) = prepare_pages(src, &cfg, Some(out))?;
 
     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
         site_structure_hash_ordered(&entries)
     };
     let cfg_hash = combine(config_hash(&cfg), structure_hash);
-    let tmpl_hash = template_hash(templater.sources());
+    // Per template rather than per site: a page's render key covers the layout it uses
+    // and that layout's own includes, so editing `feed.xml` re-renders the feed.
+    let tmpl_hash_for = |name: &str| template_hash(&templater.sources_for(name));
 
     // Compose each page's render key and record its dependency edges.
     let mut new_graph = DepGraph::default();
@@ -884,7 +887,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
     let listings = build_listings(&cfg, &preps)?;
     for p in &preps {
         let rlh = resolved_links_hash(&p.source, &p.used, &symbols);
-        let key = render_key(p.content_hash, rlh, cfg_hash, tmpl_hash);
+        let key = render_key(p.content_hash, rlh, cfg_hash, tmpl_hash_for(&p.template));
         new_graph.defines.insert(p.source.clone(), p.defines.clone());
         new_graph.uses.insert(p.source.clone(), p.used.clone());
         new_records.push((
@@ -911,7 +914,6 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
         &new_records,
         &new_graph,
         cfg_hash,
-        tmpl_hash,
         out,
         prior.as_ref(),
     );
@@ -984,7 +986,10 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
     // therefore re-renders that section's index and nothing else — the same precision the
     // rest of the build gets from content hashing.
     for listing in &listings {
-        let key = combine(listing_entries_hash(listing), combine(cfg_hash, tmpl_hash));
+        let key = combine(
+            listing_entries_hash(listing),
+            combine(cfg_hash, tmpl_hash_for(&listing.template)),
+        );
         let dest = out.join(&listing.output);
         let cached = prior
             .as_ref()
@@ -1040,21 +1045,20 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
 
     // Assets are a dumb copy in v0.3 (spec §8 Q11): copy every run. Cheap, and keeps the
     // full-vs-incremental byte equivalence trivially true for non-`.org` files.
-    for rel in &assets {
-        let from = src.join(rel);
-        let dest = out.join(rel);
+    for asset in &assets {
+        let dest = out.join(&asset.rel);
         if let Some(parent) = dest.parent() {
             fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?;
         }
-        fs::copy(&from, &dest).with_context(|| format!("copying {from} -> {dest}"))?;
-        report.assets.push(rel.clone());
+        fs::copy(&asset.from, &dest)
+            .with_context(|| format!("copying {} -> {dest}", asset.from))?;
+        report.assets.push(asset.rel.clone());
     }
 
     // Persist the manifest for the next build.
     let manifest = Manifest {
         format_version: CACHE_FORMAT_VERSION,
         config_hash: Some(cfg_hash),
-        template_hash: Some(tmpl_hash),
         pages: new_records
             .into_iter()
             .map(|(src_path, rec, _)| (src_path, rec))
@@ -1098,7 +1102,6 @@ fn compute_rebuild_set(
     new_records: &[(Utf8PathBuf, PageRecord, Hash)],
     new_graph: &DepGraph,
     cfg_hash: Hash,
-    tmpl_hash: Hash,
     out: &Utf8Path,
     prior: Option<&Manifest>,
 ) -> HashSet<Utf8PathBuf> {
@@ -1108,8 +1111,10 @@ fn compute_rebuild_set(
         return all; // No usable cache ⇒ full rebuild.
     };
 
-    // A global config/template change invalidates every page (spec §4.1).
-    if prior.config_hash != Some(cfg_hash) || prior.template_hash != Some(tmpl_hash) {
+    // A config change invalidates every page. Template changes do not come through here:
+    // each page's render key carries the hash of the templates *it* uses, so the key
+    // comparison below invalidates exactly the pages whose layout moved.
+    if prior.config_hash != Some(cfg_hash) {
         return all;
     }
 
@@ -1196,6 +1201,89 @@ fn discover(
     Ok((org, assets))
 }
 
+/// One file to copy through to the output: where it is, and where it goes.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Asset {
+    /// Path to read from.
+    pub from: Utf8PathBuf,
+    /// Path to write, relative to the output root.
+    pub rel: Utf8PathBuf,
+}
+
+/// Every file to copy: the source directory's non-`.org` files, then each extra asset
+/// root's contents, flattened onto the site root.
+///
+/// Two files claiming one output path is an error rather than a race — whichever won
+/// would depend on directory order, and a site whose favicon changes when a file is
+/// renamed elsewhere is worse than a build that stops.
+fn collect_assets(
+    src: &Utf8Path,
+    config: &Config,
+    out: Option<&Utf8Path>,
+    from_source: &[Utf8PathBuf],
+) -> Result<Vec<Asset>> {
+    let mut assets: Vec<Asset> = from_source
+        .iter()
+        .map(|rel| Asset {
+            from: src.join(rel),
+            rel: rel.clone(),
+        })
+        .collect();
+
+    for root in &config.build.assets {
+        let base = src.join(root);
+        if !base.is_dir() {
+            anyhow::bail!(
+                "build.assets lists {root}, which is not a directory (looked in {base})"
+            );
+        }
+        let base_canon = std::fs::canonicalize(&base)
+            .ok()
+            .and_then(|p| Utf8PathBuf::from_path_buf(p).ok())
+            .unwrap_or_else(|| base.clone());
+        // An asset root that contains the output directory would copy the site into
+        // itself, one build at a time.
+        let out_canon = out
+            .and_then(|out| std::fs::canonicalize(out).ok())
+            .and_then(|p| Utf8PathBuf::from_path_buf(p).ok());
+        if out_canon.is_some_and(|o| o.starts_with(&base_canon)) {
+            anyhow::bail!(
+                "build.assets lists {root}, which contains the output directory {}",
+                out.unwrap_or(Utf8Path::new("(none)"))
+            );
+        }
+        for entry in WalkDir::new(&base).sort_by_file_name() {
+            let entry = entry.with_context(|| format!("walking {base}"))?;
+            if !entry.file_type().is_file() {
+                continue;
+            }
+            let abs = Utf8PathBuf::from_path_buf(entry.into_path())
+                .map_err(|p| anyhow::anyhow!("non-UTF-8 path: {}", p.display()))?;
+            let rel = abs
+                .strip_prefix(&base)
+                .map(|p| p.to_owned())
+                .unwrap_or_else(|_| abs.clone());
+            if rel.components().any(|c| c.as_str().starts_with('.')) {
+                continue;
+            }
+            assets.push(Asset { from: abs, rel });
+        }
+    }
+
+    let mut seen: HashMap<&Utf8Path, &Utf8Path> = HashMap::new();
+    for asset in &assets {
+        if let Some(first) = seen.insert(&asset.rel, &asset.from) {
+            anyhow::bail!(
+                "two files both publish to {}: {first} and {}",
+                asset.rel,
+                asset.from
+            );
+        }
+    }
+    assets.sort_by(|a, b| a.rel.cmp(&b.rel));
+    Ok(assets)
+}
+
 /// Source-relative directories that DISCOVER must not descend into: the template
 /// directory (build input, not content) and the output directory when it lives inside
 /// the source.
diff --git a/src/template.rs b/src/template.rs
index e46a454..3ed8046 100644
--- a/src/template.rs
+++ b/src/template.rs
@@ -11,7 +11,7 @@
 //! invalidates the pages that use it, and that has to hold for user templates too, or a
 //! design change would leave a site half-updated.
 
-use std::collections::BTreeMap;
+use std::collections::{BTreeMap, BTreeSet};
 
 use anyhow::{Context, Result};
 use camino::Utf8Path;
@@ -206,6 +206,39 @@ impl Templater {
         &self.sources
     }
 
+    /// The sources a page rendered through `name` actually depends on: that template plus
+    /// everything it extends, includes or imports, transitively.
+    ///
+    /// This is what keeps a layout edit proportional. Hashing *all* templates into every
+    /// page means touching `feed.xml` re-renders a 200-page site, which is most of the
+    /// wait in a `serve` session spent on design.
+    ///
+    /// A template whose include is computed at render time — `{% include chooser %}` —
+    /// cannot be followed statically, so it depends on everything. Over-invalidating is
+    /// slow; under-invalidating publishes a stale page.
+    pub fn sources_for(&self, name: &str) -> Vec<(String, String)> {
+        let mut seen: BTreeSet<String> = BTreeSet::new();
+        let mut queue = vec![name.to_string()];
+        while let Some(current) = queue.pop() {
+            if !seen.insert(current.clone()) {
+                continue;
+            }
+            let Some((_, source)) = self.sources.iter().find(|(n, _)| *n == current) else {
+                continue;
+            };
+            let (deps, dynamic) = referenced_templates(source);
+            if dynamic {
+                return self.sources.clone();
+            }
+            queue.extend(deps);
+        }
+        self.sources
+            .iter()
+            .filter(|(n, _)| seen.contains(n))
+            .cloned()
+            .collect()
+    }
+
     /// Is a template with this name registered?
     pub fn has(&self, name: &str) -> bool {
         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"?
 </rss>
 "#;
 
+/// Template names a source refers to, and whether any reference is computed at render
+/// time rather than written as a literal.
+///
+/// A hand-rolled scan rather than a parse: minijinja does not expose the dependency
+/// graph, and the three tags that pull in another template all name it as the first
+/// string literal in the tag.
+fn referenced_templates(source: &str) -> (Vec<String>, bool) {
+    const TAGS: &[&str] = &["extends", "include", "import", "from"];
+    let mut names = Vec::new();
+    let mut dynamic = false;
+    let mut rest = source;
+    while let Some(start) = rest.find("{%") {
+        let after = &rest[start + 2..];
+        let Some(end) = after.find("%}") else { break };
+        let tag = &after[..end];
+        rest = &after[end + 2..];
+
+        let keyword = tag
+            .trim_start()
+            .trim_start_matches('-')
+            .split_whitespace()
+            .next()
+            .unwrap_or("");
+        if !TAGS.contains(&keyword) {
+            continue;
+        }
+        match string_literal(tag) {
+            Some(name) => names.push(name),
+            // `{% include some_variable %}` or `{% include ["a", "b"] %}` past the first
+            // entry: the set cannot be known here.
+            None => dynamic = true,
+        }
+    }
+    if source.contains("{% include [") || source.contains("{%- include [") {
+        dynamic = true;
+    }
+    (names, dynamic)
+}
+
+/// The first single- or double-quoted string in a tag body.
+fn string_literal(tag: &str) -> Option<String> {
+    let bytes = tag.as_bytes();
+    let quote = bytes.iter().position(|b| *b == b'"' || *b == b'\'')?;
+    let delim = bytes[quote];
+    let after = &tag[quote + 1..];
+    let end = after.find(delim as char)?;
+    Some(after[..end].to_string())
+}
+
 /// HTML-escape template output, escaping the same characters Jinja2 does.
 ///
 /// minijinja additionally escapes `/` as `&#x2f;`, which is a defence for values
diff --git a/src/watch.rs b/src/watch.rs
index e881fa2..63dd56f 100644
--- a/src/watch.rs
+++ b/src/watch.rs
@@ -57,6 +57,12 @@ impl ChangeFilter {
     /// Build a filter for a source and output directory. Paths are canonicalized so
     /// `.`, `./src`, an absolute path and a symlinked one all compare equal.
     pub fn new(src: &Utf8Path, out: &Utf8Path) -> Self {
+        ChangeFilter::with_asset_roots(src, out, &[])
+    }
+
+    /// As [`ChangeFilter::new`], plus extra asset roots. Their paths are recognised too,
+    /// so editing a stylesheet that lives outside the source directory still rebuilds.
+    pub fn with_asset_roots(src: &Utf8Path, out: &Utf8Path, asset_roots: &[Utf8PathBuf]) -> Self {
         let canon = |p: &Utf8Path| -> Option<Utf8PathBuf> {
             std::fs::canonicalize(p)
                 .ok()
@@ -77,6 +83,10 @@ impl ChangeFilter {
         };
 
         let mut roots: Vec<Utf8PathBuf> = src_canon.into_iter().chain([src.to_owned()]).collect();
+        for root in asset_roots {
+            roots.extend(canon(root));
+            roots.push(root.clone());
+        }
         roots.dedup();
         // Longest first, so the most specific spelling wins.
         roots.sort_by_key(|r| std::cmp::Reverse(r.as_str().len()));
@@ -148,6 +158,27 @@ fn is_editor_scratch(name: &str) -> bool {
         || (name.starts_with('#') && name.ends_with('#'))
 }
 
+/// The extra asset directories a build will read, as paths that can be watched.
+///
+/// A config that fails to load is not this function's problem — the rebuild reports it
+/// properly — so an unreadable config simply yields no extra roots.
+fn asset_roots(src: &Utf8Path, opts: &BuildOptions) -> Vec<Utf8PathBuf> {
+    let config = match &opts.config_path {
+        Some(path) => crate::config::Config::load_file(path),
+        None => crate::config::Config::load(src),
+    };
+    config
+        .map(|c| {
+            c.build
+                .assets
+                .iter()
+                .map(|root| src.join(root))
+                .filter(|root| root.is_dir())
+                .collect()
+        })
+        .unwrap_or_default()
+}
+
 /// Build once, then rebuild whenever the source changes. Runs until interrupted.
 pub fn run(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<()> {
     run_with(src, out, opts, |_| {})
@@ -172,12 +203,17 @@ pub fn run_with(
         report.rendered.len()
     );
 
-    let filter = ChangeFilter::new(src, out);
+    // Asset roots can live outside the source directory, and a stylesheet that does not
+    // rebuild when saved is worse than no watching at all.
+    let asset_roots = asset_roots(src, opts);
+    let filter = ChangeFilter::with_asset_roots(src, out, &asset_roots);
     let (tx, rx) = mpsc::channel();
     let mut watcher = make_watcher(tx)?;
-    watcher
-        .watch(src.as_std_path(), RecursiveMode::Recursive)
-        .with_context(|| format!("watching {src}"))?;
+    for root in std::iter::once(src.to_owned()).chain(asset_roots) {
+        watcher
+            .watch(root.as_std_path(), RecursiveMode::Recursive)
+            .with_context(|| format!("watching {root}"))?;
+    }
 
     loop {
         // Block until something happens, then keep draining while events keep arriving
diff --git a/tests/config.rs b/tests/config.rs
index 4794f04..b3ee741 100644
--- a/tests/config.rs
+++ b/tests/config.rs
@@ -2210,3 +2210,82 @@ fn same_day_entries_sort_by_time_of_day() {
         "newest first, by the clock:\n{html}"
     );
 }
+
+// ---------------------------------------------------------------------------
+// Extra asset roots
+// ---------------------------------------------------------------------------
+
+/// A site's static files do not always live where its writing does. A repository
+/// migrating from a generator that published `theme/static/` to `/` should not have to
+/// move `robots.txt` next to its blog posts to keep the URL.
+#[test]
+fn an_asset_root_publishes_to_the_site_root() {
+    let root = tmpdir("assetroot");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_site(&src);
+    std::fs::create_dir_all(root.join("theme/static/img")).unwrap();
+    std::fs::write(root.join("theme/static/robots.txt"), "User-agent: *\n").unwrap();
+    std::fs::write(root.join("theme/static/img/logo.svg"), "<svg/>").unwrap();
+    std::fs::write(
+        src.join("org-ssg.toml"),
+        "[build]\nassets = [\"../theme/static\"]\n",
+    )
+    .unwrap();
+    let out = root.join("out");
+    let report = build(&src, &out);
+
+    assert!(out.join("robots.txt").exists(), "flattened onto the root");
+    assert!(
+        out.join("img/logo.svg").exists(),
+        "and keeps its own structure below that"
+    );
+    assert!(
+        report.assets.contains(&Utf8PathBuf::from("robots.txt")),
+        "the report counts it: {:?}",
+        report.assets
+    );
+}
+
+/// Two files claiming one URL is a coin flip decided by directory order. A build that
+/// stops is better than a favicon that changes when something elsewhere is renamed.
+#[test]
+fn two_assets_claiming_one_url_is_an_error() {
+    let root = tmpdir("assetclash");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_site(&src);
+    std::fs::write(src.join("style.css"), "body{}").unwrap();
+    std::fs::create_dir_all(root.join("static")).unwrap();
+    std::fs::write(root.join("static/style.css"), "body{color:red}").unwrap();
+    std::fs::write(
+        src.join("org-ssg.toml"),
+        "[build]\nassets = [\"../static\"]\n",
+    )
+    .unwrap();
+
+    let err = build_site(&src, &root.join("out"), &BuildOptions::default())
+        .expect_err("a collision must fail the build");
+    assert!(
+        format!("{err:#}").contains("style.css"),
+        "names the path: {err:#}"
+    );
+}
+
+/// A typo in a path is a typo, not an empty directory to shrug at.
+#[test]
+fn a_missing_asset_root_is_an_error() {
+    let root = tmpdir("assetmissing");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_site(&src);
+    std::fs::write(
+        src.join("org-ssg.toml"),
+        "[build]\nassets = [\"../nope\"]\n",
+    )
+    .unwrap();
+
+    let err = build_site(&src, &root.join("out"), &BuildOptions::default())
+        .expect_err("a missing asset root must fail");
+    assert!(format!("{err:#}").contains("nope"), "names it: {err:#}");
+}
diff --git a/tests/incremental.rs b/tests/incremental.rs
index 0f4573e..40a7cfb 100644
--- a/tests/incremental.rs
+++ b/tests/incremental.rs
@@ -448,3 +448,68 @@ fn retitling_a_top_level_page_still_rebuilds_the_site() {
     let post = std::fs::read_to_string(out_dir.join("blog/first.html")).unwrap();
     assert!(post.contains("Colophon"), "nested pages show the updated nav title");
 }
+
+/// Editing one layout must re-render the pages that use it, and only those. Hashing every
+/// template into every page means a change to the feed template rewrites the whole site,
+/// which is most of the wait in a `serve` session spent on design.
+#[test]
+fn editing_one_template_rebuilds_only_the_pages_that_use_it() {
+    let root = tmpdir("tmplscope");
+    let src = root.join("src");
+    std::fs::create_dir_all(src.join("blog")).unwrap();
+    std::fs::create_dir_all(src.join("templates")).unwrap();
+    std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nWelcome.\n").unwrap();
+    std::fs::write(src.join("about.org"), "#+TITLE: About\n\nAbout.\n").unwrap();
+    std::fs::write(
+        src.join("blog/post.org"),
+        "#+TITLE: Post\n#+DATE: 2026-01-01\n\nBody.\n",
+    )
+    .unwrap();
+    std::fs::write(
+        src.join("templates/base.html"),
+        "<html><body>{% block content %}{{ body | safe }}{% endblock %}</body></html>",
+    )
+    .unwrap();
+    std::fs::write(
+        src.join("templates/post.html"),
+        "{% extends \"base.html\" %}{% block content %}{{ body | safe }}<p>reply</p>{% endblock %}",
+    )
+    .unwrap();
+    std::fs::write(
+        src.join("org-ssg.toml"),
+        "[[pages]]\nmatch = \"blog\"\ntemplate = \"post.html\"\n",
+    )
+    .unwrap();
+    let out_dir = root.join("out");
+    build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
+
+    // post.html is used by one page.
+    std::fs::write(
+        src.join("templates/post.html"),
+        "{% extends \"base.html\" %}{% block content %}{{ body | safe }}<p>reply now</p>{% endblock %}",
+    )
+    .unwrap();
+    let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
+    assert_eq!(
+        r.rendered,
+        vec![Utf8PathBuf::from("blog/post.html")],
+        "only the page whose layout changed"
+    );
+    assert!(std::fs::read_to_string(out_dir.join("blog/post.html"))
+        .unwrap()
+        .contains("reply now"));
+
+    // base.html is extended by post.html, so editing it reaches both.
+    std::fs::write(
+        src.join("templates/base.html"),
+        "<html><body class=\"new\">{% block content %}{{ body | safe }}{% endblock %}</body></html>",
+    )
+    .unwrap();
+    let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
+    assert_eq!(
+        r.rendered.len(),
+        3,
+        "a layout everything inherits still re-renders everything: {:?}",
+        r.rendered
+    );
+}