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
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(-) @@ -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. @@ -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 | @@ -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 →</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=, @@ -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. | @@ -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. @@ -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); @@ -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: {}", @@ -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:#}"); +}