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
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(-) @@ -675,7 +675,7 @@ dependencies = [ [[package]] name = "org-ssg" -version = "0.16.0" +version = "0.17.0" dependencies = [ "anyhow", "blake3", @@ -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" @@ -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 @@ -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 | @@ -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 @@ -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)>. @@ -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, } @@ -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. @@ -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 `/`, which is a defence for values @@ -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 @@ -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:#}"); +} @@ -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 + ); +}