krz/orgo

Lightning fast org-mode static site generator.

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

b6270e3bf7994c0a4894e4b516f63eee371cdeb4

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T18:51:02Z

Let pages choose their layout

base.html was the only layout an authored page could have, so a section that
needed its own chrome — a blog post's byline and reply footer — had no way to
ask for it. The workaround was a URL-prefix test inside the shared layout,
which puts routing in the template and gets worse with every section.

Two ways in, because there are two different facts to state:

- `[[pages]]` rules map a source path to a template. A section's layout is a
  property of the section, and asking an author to repeat `#+TEMPLATE:` in 200
  posts is asking them to maintain one fact 200 times. `match` is a directory
  (covering everything beneath it) or one file, matched by path component so
  `blog` does not capture `blogroll.org`. The most specific rule wins, by path
  depth, independent of declaration order.
- `#+TEMPLATE:` on the page itself, which beats any rule — the more local
  statement, written with that page in view.

A name that is not in the templates directory fails the build before anything
renders, naming the page, the template and what does exist. minijinja would
report the missing name eventually, but only once some page reached it.

Incremental correctness comes out unchanged: the keyword is content, the rules
are config, and both already feed the render key.
 README.md                       |   9 ++
 docs/guide/02-configuration.org |  34 +++++++
 docs/guide/04-templates.org     |  28 ++++++
 docs/guide/05-org-support.org   |   1 +
 src/config.rs                   |  77 ++++++++++++++
 src/main.rs                     |  13 ++-
 src/site.rs                     |  32 +++++-
 tests/config.rs                 | 215 ++++++++++++++++++++++++++++++++++++++++
 8 files changed, 404 insertions(+), 5 deletions(-)

diff --git a/README.md b/README.md
index fc36189..f477b14 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,10 @@ mode = "top-level"     # top-level | all | explicit | none
 dir = "templates"      # base.html replaces the built-in layout
 expose_page_list = false
 
+# [[pages]]            # which layout a section renders through; base.html by default
+# match = "blog"       # a source directory or one .org file; most specific rule wins
+# template = "post.html"
+
 [highlight]
 theme = "InspiredGitHub"
 
@@ -90,6 +94,11 @@ receive:
 your own metadata works without this crate knowing about it: `#+CUSTOM_THING: x` is
 `{{ page.keywords.custom_thing }}`.
 
+`base.html` is the default layout, not the only one. A `[[pages]]` rule gives a section
+its own — `match = "blog"`, `template = "post.html"` — and `#+TEMPLATE: wide.html` gives
+one page its own, which wins over any rule. A second layout usually starts with
+`{% extends "base.html" %}`.
+
 Editing a template re-renders the pages that use it — template sources are a hash input,
 so a design change never leaves a site half-updated.
 
diff --git a/docs/guide/02-configuration.org b/docs/guide/02-configuration.org
index 94430db..975d878 100644
--- a/docs/guide/02-configuration.org
+++ b/docs/guide/02-configuration.org
@@ -117,6 +117,40 @@ being listed is appended after everything you did list, so ="about.org"= alone w
 said. Either spelling works for an authored page too — its source path or its output
 path — though the source path is the one that survives a =#+SLUG:=.
 
+* [[pages]]
+
+Which layout a page renders through. Without any of these, every authored page uses
+=base.html=.
+
+#+BEGIN_SRC toml
+[[pages]]
+match = "blog"
+template = "post.html"
+#+END_SRC
+
+=match= is a *source* path relative to the source root — a directory, covering every page
+beneath it however deep, or one =.org= file. It is matched by path component, so =blog=
+covers =blog/2026/post.org= and does not touch =blogroll.org=.
+
+A section's layout is a property of the section, which is why this is a rule and not
+something you write in each file: a blog post carries the same byline and reply footer as
+every other one, and repeating that in 200 files means maintaining one fact 200 times.
+
+** Which rule wins
+
+Most specific, by path depth — =blog/notes= beats =blog=, whatever order they appear in.
+An empty =match= covers the whole site, which is how you rename the default layout.
+
+A page that differs from its section says so itself, and that wins over any rule:
+
+#+BEGIN_SRC org
+,#+TITLE: Colophon
+,#+TEMPLATE: wide.html
+#+END_SRC
+
+Naming a template that is not in the templates directory is an error that names the page,
+the template and what does exist — a layout typo should not be a hunt.
+
 * [templates]
 
 | Key | Default | Meaning |
diff --git a/docs/guide/04-templates.org b/docs/guide/04-templates.org
index ecc4286..27ab507 100644
--- a/docs/guide/04-templates.org
+++ b/docs/guide/04-templates.org
@@ -35,6 +35,34 @@ A template that does not compile is a *build error*, not a fallback to the defau
 someone editing a layout should see the mistake, not output that looks like their edit
 did nothing.
 
+* Pages can render through a different layout
+
+=base.html= is the default, not the only option. A =[[pages]]= rule gives a section its
+own layout, and =#+TEMPLATE:= gives one page its own:
+
+#+BEGIN_SRC toml
+[[pages]]
+match = "blog"
+template = "post.html"
+#+END_SRC
+
+#+BEGIN_SRC org
+,#+TEMPLATE: wide.html
+#+END_SRC
+
+The page's own keyword wins over any rule, and the most specific rule wins over a broader
+one. A second layout almost always wants the first one's chrome, so it extends it:
+
+#+BEGIN_SRC html
+{% extends "base.html" %}
+{% block content %}
+{{ body | safe }}
+<p><a href="mailto:you@example.com">Reply by email &rarr;</a></p>
+{% endblock %}
+#+END_SRC
+
+Full rules in [[file:02-configuration.org][Configuration]].
+
 * Names are full filenames
 
 Templates are registered under their full relative filename: =base.html=,
diff --git a/docs/guide/05-org-support.org b/docs/guide/05-org-support.org
index 1b4b0b1..d82048d 100644
--- a/docs/guide/05-org-support.org
+++ b/docs/guide/05-org-support.org
@@ -118,6 +118,7 @@ broken.
 | =#+FILETAGS:= | Tags, for grouping and =page.tags=. |
 | =#+SLUG:= | Sets the output filename. |
 | =#+DRAFT:= | Keeps the page out of the build. |
+| =#+TEMPLATE:= | The layout this page renders through. |
 | =#+OPTIONS:= | Per-file export switches. |
 | =#+CAPTION:=, =#+ATTR_HTML:= | Attach to the image below them. |
 
diff --git a/src/config.rs b/src/config.rs
index 015dc85..747cb6d 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -34,6 +34,67 @@ pub struct Config {
     /// Generated listing pages. Each produces one output file that has no source `.org`
     /// file behind it — a blog index, an archive, a feed.
     pub collections: Vec<Collection>,
+    /// Which layout authored pages render through, by source path. Pages matching no
+    /// rule use `base.html`.
+    pub pages: Vec<PageRule>,
+}
+
+/// One layout rule: the pages under `match` render through `template`.
+///
+/// Sections usually want one layout — every blog post carries the same byline and reply
+/// footer — and asking an author to repeat `#+TEMPLATE:` in each of 200 files is asking
+/// them to maintain the same fact 200 times. A rule states it once for the directory; a
+/// page that differs still says so itself with `#+TEMPLATE:`, which wins.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct PageRule {
+    /// A source path: a directory, matching every page beneath it, or one `.org` file.
+    /// Relative to the source root, like `nav.pages`.
+    #[serde(rename = "match")]
+    pub pattern: Utf8PathBuf,
+    /// Template file name, as it appears in the templates directory.
+    pub template: String,
+}
+
+impl PageRule {
+    /// Does this rule cover `source`? Matching is by path component, so a directory rule
+    /// covers everything beneath it however deep — `blog` matches `blog/2026/post.org`,
+    /// because a section's layout is a property of the section and not of how its files
+    /// happen to be filed — while `blo` matches nothing. An empty `match` covers the
+    /// whole site, which is how you change the default layout's name.
+    pub fn covers(&self, source: &Utf8Path) -> bool {
+        source.starts_with(&self.pattern)
+    }
+
+    /// How specific this rule is, for picking between two that both match. Longer paths
+    /// are more specific, so `blog/notes` beats `blog`.
+    fn specificity(&self) -> usize {
+        self.pattern.components().count()
+    }
+}
+
+/// Which template an authored page renders through: its own `#+TEMPLATE:` if it names
+/// one, else the most specific `[[pages]]` rule covering it, else `base.html`.
+///
+/// A page's own declaration wins because it is the more local statement — the one written
+/// with that page in view.
+pub fn page_template(config: &Config, source: &Utf8Path, keywords: &crate::model::Keywords) -> String {
+    let declared = keywords
+        .entries
+        .iter()
+        .find(|(k, _)| k.eq_ignore_ascii_case("TEMPLATE"))
+        .map(|(_, v)| v.trim())
+        .filter(|v| !v.is_empty());
+    if let Some(name) = declared {
+        return name.to_string();
+    }
+    config
+        .pages
+        .iter()
+        .filter(|rule| rule.covers(source))
+        .max_by_key(|rule| rule.specificity())
+        .map(|rule| rule.template.clone())
+        .unwrap_or_else(|| crate::template::BASE_TEMPLATE_NAME.to_string())
 }
 
 /// A generated page that lists other pages.
@@ -390,6 +451,15 @@ impl Config {
                 seen.push(path);
             }
         }
+        for rule in &self.pages {
+            if rule.template.trim().is_empty() {
+                anyhow::bail!(
+                    "the [[pages]] rule matching {:?} names no `template`; it has nothing \
+                     to select",
+                    rule.pattern.as_str()
+                );
+            }
+        }
         if !self.site.base_url.is_empty() && self.site.base_url.ends_with('/') {
             anyhow::bail!(
                 "site.base_url must not end with a slash (got {:?}) — URLs are joined \
@@ -431,6 +501,13 @@ dir = "templates"
 # archive. Costs incremental precision: with this on, adding a page re-renders the site.
 expose_page_list = false
 
+# Which layout a page renders through. Without a rule, every page uses base.html.
+# `match` is a source path — a directory (covering everything beneath it) or one .org
+# file — and the most specific rule wins. A page overrides any rule with `#+TEMPLATE:`.
+# [[pages]]
+# match = "blog"
+# template = "post.html"
+
 [highlight]
 # A syntect theme name: InspiredGitHub, Solarized (dark), base16-ocean.dark,
 # base16-eighties.dark, base16-mocha.dark, base16-ocean.light.
diff --git a/src/main.rs b/src/main.rs
index c356fcc..aeb4bff 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7,7 +7,7 @@ use camino::{Utf8Path, Utf8PathBuf};
 use clap::{Parser, Subcommand};
 
 use org_ssg::parser::parse;
-use org_ssg::config::Config;
+use org_ssg::config::{self, Config};
 use org_ssg::render::{self, render, Html, SyntectHighlighter};
 use org_ssg::resolve::ResolvedDoc;
 use org_ssg::site::{build_site, BuildOptions, SYNTAX_STYLESHEET};
@@ -323,9 +323,16 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
     };
     let mut ctx = RenderContext::new(&site, &page_ctx, &[], SYNTAX_STYLESHEET, "");
     ctx.body = &fragment;
+    // `#+TEMPLATE:` and `[[pages]]` apply here too, so `build one.org` and a whole-site
+    // build put the same page through the same layout.
+    let name = config::page_template(
+        &config,
+        Utf8Path::new(input.file_name().unwrap_or_default()),
+        &resolved.document.keywords,
+    );
     let page = templater
-        .render_page(&ctx)
-        .with_context(|| format!("templating {input}"))?;
+        .render(&name, &ctx)
+        .with_context(|| format!("templating {input} through {name}"))?;
     fs::write(output, page).with_context(|| format!("writing output file {output}"))?;
 
     let css = output.with_file_name(SYNTAX_STYLESHEET);
diff --git a/src/site.rs b/src/site.rs
index 4272a89..020c409 100644
--- a/src/site.rs
+++ b/src/site.rs
@@ -115,6 +115,9 @@ struct PagePrep {
     diagnostics: Vec<Diagnostic>,
     nav: Vec<NavItem>,
     context: PageContext,
+    /// The layout this page renders through: `#+TEMPLATE:`, a `[[pages]]` rule, or
+    /// `base.html` (see [`config::page_template`]).
+    template: String,
 }
 
 /// A generated page, resolved against the pages it lists.
@@ -677,6 +680,7 @@ fn prepare_pages(
 
             PagePrep {
                 context: page_context(doc, &output, config),
+                template: config::page_template(config, &doc.source_path, &doc.keywords),
                 source: doc.source_path.clone(),
                 output,
                 title: page_title(doc),
@@ -694,6 +698,28 @@ fn prepare_pages(
     Ok((pages, symbols))
 }
 
+/// Fail before rendering if any page names a template that does not exist.
+///
+/// minijinja would report the missing name on its own, but only once a page reaches it
+/// — and a typo in `#+TEMPLATE:` or a `[[pages]]` rule is worth naming together with the
+/// page that carries it and the templates that do exist.
+fn check_page_templates(templater: &Templater, preps: &[PagePrep]) -> Result<()> {
+    for p in preps {
+        if !templater.has(&p.template) {
+            let mut available = templater.names();
+            available.sort_unstable();
+            anyhow::bail!(
+                "{} renders through {}, which is not in the templates directory. \
+                 Available: {}",
+                p.source,
+                p.template,
+                available.join(", ")
+            );
+        }
+    }
+    Ok(())
+}
+
 /// Parse + index + resolve + render + template a whole site *in memory*, without
 /// touching the output directory. Shared by the tests (full render, every page).
 pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
@@ -702,6 +728,7 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
     let (preps, _symbols) = prepare_pages(src, &config, None)?;
     let highlighter = SyntectHighlighter::new();
     let templater = Templater::load(Some(&src.join(&config.templates.dir)), &config.site.base_url)?;
+    check_page_templates(&templater, &preps)?;
     let site = site_context(&config);
     let listing = page_listing(&config, &preps);
 
@@ -770,8 +797,8 @@ fn render_page(
     ctx.body = &fragment;
     ctx.pages = pages;
     templater
-        .render_page(&ctx)
-        .with_context(|| format!("templating {}", p.source))
+        .render(&p.template, &ctx)
+        .with_context(|| format!("templating {} through {}", p.source, p.template))
 }
 
 /// Site-root-relative name of the generated syntax stylesheet. Every page links to it.
@@ -796,6 +823,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
     let (preps, symbols) = prepare_pages(src, &cfg, Some(out))?;
 
     let templater = Templater::load(Some(&src.join(&cfg.templates.dir)), &cfg.site.base_url)?;
+    check_page_templates(&templater, &preps)?;
     let syntax_css = render::syntax_css(&cfg.highlight.theme).ok_or_else(|| {
         anyhow::anyhow!(
             "unknown highlight.theme {:?}. Available: {}",
diff --git a/tests/config.rs b/tests/config.rs
index 8e120be..b8ea988 100644
--- a/tests/config.rs
+++ b/tests/config.rs
@@ -1913,3 +1913,218 @@ fn export_options_parse_as_org_writes_them() {
         assert!(!option_enabled(&keywords(&format!("toc:{off}")), "toc", true), "{off}");
     }
 }
+
+// ---------------------------------------------------------------------------
+// Per-page template selection
+// ---------------------------------------------------------------------------
+
+/// A site with a `post.html` layout beside the default one, so a page can be shown to
+/// render through the layout it chose rather than the one every page gets.
+fn write_two_layouts(src: &Utf8PathBuf, config: &str) {
+    write_site(src);
+    std::fs::create_dir_all(src.join("templates")).unwrap();
+    std::fs::write(
+        src.join("templates/base.html"),
+        "<html><body><h1>{{ page.title }}</h1>{{ body | safe }}</body></html>",
+    )
+    .unwrap();
+    std::fs::write(
+        src.join("templates/post.html"),
+        "<html><body class=\"post\"><h1>{{ page.title }}</h1>{{ body | safe }}\
+         <p>Reply by email</p></body></html>",
+    )
+    .unwrap();
+    std::fs::write(src.join("org-ssg.toml"), config).unwrap();
+}
+
+/// A section's layout is a property of the section: one rule covers every page under it,
+/// however deep, without touching a single source file.
+#[test]
+fn a_pages_rule_gives_a_directory_its_own_layout() {
+    let root = tmpdir("tmplrule");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_two_layouts(
+        &src,
+        "[[pages]]\nmatch = \"blog\"\ntemplate = \"post.html\"\n",
+    );
+    std::fs::create_dir_all(src.join("blog/2026")).unwrap();
+    std::fs::write(
+        src.join("blog/2026/nested.org"),
+        "#+TITLE: Nested\n\nDeep.\n",
+    )
+    .unwrap();
+    let out = root.join("out");
+    build(&src, &out);
+
+    assert!(
+        page(&out, "blog/post.html").contains("Reply by email"),
+        "a post uses the section layout"
+    );
+    assert!(
+        page(&out, "blog/2026/nested.html").contains("Reply by email"),
+        "so does a post nested deeper"
+    );
+    assert!(
+        !page(&out, "about.html").contains("Reply by email"),
+        "a page outside the section does not"
+    );
+}
+
+/// Matching is by path component, not by string prefix: `blog` must not capture
+/// `blogroll.org`, which is a different page with a name that happens to start the same.
+#[test]
+fn a_pages_rule_matches_whole_path_components() {
+    let root = tmpdir("tmplprefix");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_two_layouts(
+        &src,
+        "[[pages]]\nmatch = \"blog\"\ntemplate = \"post.html\"\n",
+    );
+    std::fs::write(src.join("blogroll.org"), "#+TITLE: Blogroll\n\nLinks.\n").unwrap();
+    let out = root.join("out");
+    build(&src, &out);
+
+    assert!(
+        !page(&out, "blogroll.html").contains("Reply by email"),
+        "blogroll.org is not inside blog/"
+    );
+}
+
+/// The page's own declaration wins: it is the more local statement, written with that
+/// page in view.
+#[test]
+fn a_page_template_keyword_overrides_the_rule() {
+    let root = tmpdir("tmplkeyword");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_two_layouts(
+        &src,
+        "[[pages]]\nmatch = \"blog\"\ntemplate = \"base.html\"\n",
+    );
+    std::fs::write(
+        src.join("blog/post.org"),
+        "#+TITLE: A Post\n#+TEMPLATE: post.html\n\nBody.\n",
+    )
+    .unwrap();
+    let out = root.join("out");
+    build(&src, &out);
+
+    assert!(
+        page(&out, "blog/post.html").contains("Reply by email"),
+        "the keyword beats the rule"
+    );
+}
+
+/// Two rules can both cover a page; the more specific path is the one that meant it.
+#[test]
+fn the_most_specific_pages_rule_wins() {
+    let root = tmpdir("tmplspecific");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_two_layouts(
+        &src,
+        // Declared before the broader rule, so passing this test means specificity
+        // decided it and not declaration order.
+        "[[pages]]\nmatch = \"blog/notes\"\ntemplate = \"post.html\"\n\n\
+         [[pages]]\nmatch = \"blog\"\ntemplate = \"base.html\"\n",
+    );
+    std::fs::create_dir_all(src.join("blog/notes")).unwrap();
+    std::fs::write(src.join("blog/notes/n.org"), "#+TITLE: Note\n\nBody.\n").unwrap();
+    let out = root.join("out");
+    build(&src, &out);
+
+    assert!(
+        page(&out, "blog/notes/n.html").contains("Reply by email"),
+        "the deeper rule wins"
+    );
+    assert!(
+        !page(&out, "blog/post.html").contains("Reply by email"),
+        "the shallower rule still covers the rest"
+    );
+}
+
+/// A template name that does not exist is a typo. Naming the page, the template and what
+/// does exist is the difference between a fix and a hunt.
+#[test]
+fn a_missing_page_template_is_an_error_naming_it() {
+    let root = tmpdir("tmplmissing");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_two_layouts(&src, "");
+    std::fs::write(
+        src.join("about.org"),
+        "#+TITLE: About\n#+TEMPLATE: nope.html\n\nAbout.\n",
+    )
+    .unwrap();
+
+    let err = build_site(&src, &root.join("out"), &BuildOptions::default())
+        .expect_err("a missing template must fail the build");
+    let msg = format!("{err:#}");
+    assert!(msg.contains("about.org"), "names the page: {msg}");
+    assert!(msg.contains("nope.html"), "names the template: {msg}");
+    assert!(msg.contains("post.html"), "lists what exists: {msg}");
+}
+
+/// Changing a page's layout has to re-render that page and no other.
+#[test]
+fn changing_a_page_template_keyword_rerenders_only_that_page() {
+    let root = tmpdir("tmplinc");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_two_layouts(&src, "");
+    let out = root.join("out");
+    build(&src, &out);
+
+    std::fs::write(
+        src.join("about.org"),
+        "#+TITLE: About\n#+TEMPLATE: post.html\n\nAbout.\n",
+    )
+    .unwrap();
+    let second = build(&src, &out);
+
+    assert_eq!(
+        second.rendered,
+        vec![Utf8PathBuf::from("about.html")],
+        "only the page whose layout changed"
+    );
+    assert!(page(&out, "about.html").contains("Reply by email"));
+}
+
+/// A rule is config, so adding one re-renders the pages it covers.
+#[test]
+fn adding_a_pages_rule_rerenders_the_pages_it_covers() {
+    let root = tmpdir("tmplruleinc");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_two_layouts(&src, "");
+    let out = root.join("out");
+    build(&src, &out);
+
+    std::fs::write(
+        src.join("org-ssg.toml"),
+        "[[pages]]\nmatch = \"blog\"\ntemplate = \"post.html\"\n",
+    )
+    .unwrap();
+    let second = build(&src, &out);
+
+    assert!(
+        second.rendered.contains(&Utf8PathBuf::from("blog/post.html")),
+        "the covered page re-rendered: {:?}",
+        second.rendered
+    );
+    assert!(page(&out, "blog/post.html").contains("Reply by email"));
+}
+
+/// A rule that names no template is a rule that does nothing.
+#[test]
+fn a_pages_rule_without_a_template_is_rejected() {
+    let mut config = Config::default();
+    config.pages.push(org_ssg::config::PageRule {
+        pattern: Utf8PathBuf::from("blog"),
+        template: String::new(),
+    });
+    let err = config.validate().expect_err("empty template must fail");
+    assert!(format!("{err:#}").contains("blog"), "names it: {err:#}");
+}