krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
012e5617046046f5b71f563201f809a222c56ad1
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T05:14:49Z
Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 44 ++++++- src/config.rs | 87 +++++++++++++ src/incremental.rs | 9 +- src/main.rs | 17 ++- src/site.rs | 248 ++++++++++++++++++++++++++++++++++- src/template.rs | 139 +++++++++++++++++--- tests/config.rs | 369 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 888 insertions(+), 29 deletions(-) @@ -569,7 +569,7 @@ dependencies = [ [[package]] name = "org-ssg" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "blake3", @@ -1,6 +1,6 @@ [package] name = "org-ssg" -version = "0.6.0" +version = "0.7.0" edition = "2021" description = "Org-mode static site generator that renders the org element tree straight to HTML" license = "MIT" @@ -86,6 +86,47 @@ your own metadata works without this crate knowing about it: `#+CUSTOM_THING: x` 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. +### Generated listing pages + +A blog index, an archive, a feed — output files with no source `.org` behind them. +Repeat the block for each one: + +```toml +[[collections]] +source = "blog" # directory to list; empty means every page +output = "blog/index.html" # where to write it +template = "list.html" +title = "Blog" +sort = "date" # date | title | path +order = "desc" # desc | asc +nav = true # put this listing page in the nav +``` + +The template gets the collection's entries as `pages`, already sorted, plus the usual +`site`/`nav`/`root`. It can `{% extends "base.html" %}` to inherit the site chrome: + +```jinja +{% extends "base.html" %} +{% block main %} +<ul>{% for p in pages %} + <li><time datetime="{{ p.date_iso }}">{{ p.date_iso }}</time> + <a href="{{ root }}{{ p.url }}">{{ p.title }}</a></li> +{% endfor %}</ul> +{% endblock %} +``` + +`p.date_iso` is the `YYYY-MM-DD` extracted from `#+DATE:`, whatever org syntax it was +written in — `[2025-09-05 Fri 10:21:00]`, `<2024-05-01 Wed>` or bare `2024-05-01`. It is +also the sort key; pages without a parseable date sort last, so an undated draft never +leads a dated archive. + +**A feed is a listing page with an XML template**, not a separate feature — templates are +loaded by full filename and any extension, so `output = "feed.xml"` with +`template = "feed.xml"` is all it takes. + +Listing pages are cached on the entries they list, so adding a post re-renders that +section's index and nothing else. + ### `#+SLUG:` A page's output filename comes from its `#+SLUG:` when it has one, so @@ -154,6 +195,7 @@ all-of-org. Phase 0 checked this line against a real 179-file corpus and found i | 6 | Incremental build layer (hashing, dep graph, invalidation) done; `watch` is a simple poll loop | done | | **7** | **Hardening: rayon parallelism, error locations in parse diagnostics** | **done** | | **8** | **General use: config file, user templates, nav modes, `init` scaffold, safe discovery** | **done** | +| **9** | **Generated listing pages: `[[collections]]`, sorted indexes, feeds via XML templates** | **done** | ### v0.2 in / out @@ -416,7 +458,7 @@ PARSE/RESOLVE/RENDER), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserro ``` cargo build -cargo test # 86 tests +cargo test # 99 tests cargo run -- init my-site # scaffold a new site cargo run -- build fixtures/minimal.org -o minimal.html # single file cargo run -- build fixtures/site -o _site # whole site (incremental) @@ -30,6 +30,69 @@ pub struct Config { pub templates: Templates, pub highlight: Highlight, pub html: HtmlOutput, + /// 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>, +} + +/// A generated page that lists other pages. +/// +/// This is the one output that is not a translation of some input: a blog index exists +/// because a set of posts exists, not because someone wrote `index.org`. Keeping it +/// declarative — a directory in, a file out, through a template — means a feed is the +/// same mechanism with an XML template rather than a second feature. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Collection { + /// Directory of source pages to list, relative to the source root. Empty means every + /// page in the site. + pub source: Utf8PathBuf, + /// Where to write the generated page, relative to the output root. + pub output: Utf8PathBuf, + /// Template file name, as it appears in the templates directory. + pub template: String, + /// Title for the generated page, available to the template as `page.title`. + pub title: String, + pub sort: SortKey, + pub order: SortOrder, + /// Add this listing page to the site navigation. This is how a section landing page + /// — `/blog/`, `/notes/` — gets into a nav built from top-level pages. + pub nav: bool, +} + +impl Default for Collection { + fn default() -> Self { + Collection { + source: Utf8PathBuf::new(), + output: Utf8PathBuf::from("index.html"), + template: "list.html".to_string(), + title: "Index".to_string(), + sort: SortKey::default(), + order: SortOrder::default(), + nav: false, + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SortKey { + /// By `#+DATE:`, newest first by default. Pages with no parseable date sort last, + /// keeping undated drafts out of the way of a dated archive. + #[default] + Date, + Title, + /// Output path — stable and predictable when dates are absent or unreliable. + Path, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SortOrder { + /// Newest or last first — the useful default for a blog. + #[default] + Desc, + Asc, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -186,6 +249,19 @@ impl Config { .trim_matches('"') ); } + let mut seen: Vec<&Utf8PathBuf> = Vec::new(); + for collection in &self.collections { + if collection.output.as_str().is_empty() { + anyhow::bail!("a collection has an empty `output`; it needs a file to write"); + } + if seen.contains(&&collection.output) { + anyhow::bail!( + "two collections both write to {}; give them different `output` paths", + collection.output + ); + } + seen.push(&collection.output); + } 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 \ @@ -236,4 +312,15 @@ theme = "InspiredGitHub" # The default of 1 matches Emacs, and assumes your layout renders the page title as the # <h1>. Set to 0 if your template renders no title of its own. heading_offset = 1 + +# Generated listing pages: output files with no source .org behind them. Repeat the +# [[collections]] block for each one. A feed is the same thing with an XML template. +[[collections]] +source = "blog" # directory to list; empty means every page +output = "blog/index.html" # where to write it +template = "list.html" # template file name +title = "Blog" +sort = "date" # date | title | path +order = "desc" # desc | asc +nav = true # put this listing page in the site nav "#; @@ -66,8 +66,15 @@ pub fn combine(a: Hash, b: Hash) -> Hash { pub fn site_structure_hash(entries: &[(String, String)]) -> Hash { let mut sorted = entries.to_vec(); sorted.sort(); + site_structure_hash_ordered(&sorted) +} + +/// As [`site_structure_hash`], but hashing the sequence *as given*. Used where order is +/// itself part of the output — a listing page's entries are sorted deliberately, so +/// re-ordering them is a real change even when the set is identical. +pub fn site_structure_hash_ordered(entries: &[(String, String)]) -> Hash { let mut hasher = blake3::Hasher::new(); - for (path, title) in &sorted { + for (path, title) in entries { hasher.update(path.as_bytes()); hasher.update(&[0]); hasher.update(title.as_bytes()); @@ -132,10 +132,11 @@ fn main() -> Result<()> { /// in a directory that has content is safe and additive rather than destructive. fn init(dir: &Utf8Path) -> Result<()> { use org_ssg::config::{CONFIG_FILE, STARTER_CONFIG}; - use org_ssg::template::starter_template; + use org_ssg::template::{starter_template, STARTER_LIST_TEMPLATE}; fs::create_dir_all(dir).with_context(|| format!("creating {dir}"))?; fs::create_dir_all(dir.join("templates")).with_context(|| format!("creating {dir}/templates"))?; + fs::create_dir_all(dir.join("blog")).with_context(|| format!("creating {dir}/blog"))?; let index = concat!( "#+TITLE: Hello\n", @@ -155,10 +156,21 @@ fn init(dir: &Utf8Path) -> Result<()> { "#+END_SRC\n", ); - let files: [(Utf8PathBuf, &str); 3] = [ + let post = concat!( + "#+TITLE: A first post\n", + "#+DATE: <2026-01-15 Thu>\n", + "#+FILETAGS: :example:\n", + "\n", + "Posts in this directory are collected into /blog/ by the [[collections]] block\n", + "in org-ssg.toml, newest first.\n", + ); + + let files: [(Utf8PathBuf, &str); 5] = [ (dir.join(CONFIG_FILE), STARTER_CONFIG), (dir.join("templates/base.html"), starter_template()), + (dir.join("templates/list.html"), STARTER_LIST_TEMPLATE), (dir.join("index.org"), index), + (dir.join("blog/first-post.org"), post), ]; let mut created = Vec::new(); @@ -279,6 +291,7 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> { url: output.file_name().unwrap_or("index.html").to_string(), source: input.to_string(), date: None, + date_iso: None, tags: Vec::new(), keywords: Default::default(), }; @@ -19,14 +19,15 @@ use walkdir::WalkDir; use crate::incremental::{ self, combine, config_hash, render_key, resolved_links_hash, site_structure_hash, - template_hash, DepGraph, Hash, Manifest, PageRecord, CACHE_FORMAT_VERSION, + site_structure_hash_ordered, template_hash, DepGraph, Hash, Manifest, PageRecord, + CACHE_FORMAT_VERSION, }; use crate::index::{document_targets, SymbolTable, TargetId}; use crate::model::{ContentHash, Diagnostic, Document}; use crate::parser::parse; use crate::render::{self, render_with, Html, RenderOptions, SyntectHighlighter}; use crate::resolve::resolve; -use crate::config::{self, Config, NavMode}; +use crate::config::{self, Config, NavMode, SortKey, SortOrder}; use crate::template::{NavItem, PageContext, SiteContext, Templater}; use crate::util::{output_path, output_url, relative_root}; @@ -104,6 +105,164 @@ struct PagePrep { context: PageContext, } +/// A generated listing page, resolved against the pages it lists. +struct Listing { + output: Utf8PathBuf, + template: String, + title: String, + /// The pages it lists, already sorted. + entries: Vec<PageContext>, +} + +/// The `YYYY-MM-DD` inside an org date, if there is one. Org dates arrive as +/// `[2025-09-05 Fri 10:21:00]`, `<2024-05-01 Wed>` or bare `2024-05-01`, and a listing +/// needs one key it can sort on. +pub fn iso_date(raw: &str) -> Option<String> { + let bytes = raw.as_bytes(); + for i in 0..bytes.len().saturating_sub(9) { + let window = &bytes[i..i + 10]; + let digits = |r: std::ops::Range<usize>| window[r].iter().all(u8::is_ascii_digit); + if digits(0..4) && window[4] == b'-' && digits(5..7) && window[7] == b'-' && digits(8..10) { + // Must not be part of a longer number, or `123-45-6789` would parse. + let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit(); + let after_ok = i + 10 >= bytes.len() || !bytes[i + 10].is_ascii_digit(); + if before_ok && after_ok { + return Some(raw[i..i + 10].to_string()); + } + } + } + None +} + +/// Build the listing pages a config asks for, each with its entries sorted. +fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> { + let mut listings = Vec::new(); + for collection in &config.collections { + let mut entries: Vec<PageContext> = preps + .iter() + .filter(|p| { + collection.source.as_str().is_empty() || p.source.starts_with(&collection.source) + }) + .map(|p| p.context.clone()) + .collect(); + + // Sort ascending first, then reverse for `desc`, so the two orders are exact + // mirrors of one another rather than two separately-written comparisons. + match collection.sort { + SortKey::Title => entries.sort_by(|a, b| a.title.cmp(&b.title)), + SortKey::Path => entries.sort_by(|a, b| a.url.cmp(&b.url)), + // Undated pages sort last in the final order regardless of direction: a + // draft with no date should not lead an archive. + SortKey::Date => entries.sort_by(|a, b| { + let key = |p: &PageContext| p.date_iso.clone(); + match (key(a), key(b)) { + (Some(x), Some(y)) => x.cmp(&y).then_with(|| a.url.cmp(&b.url)), + (Some(_), None) => std::cmp::Ordering::Greater, + (None, Some(_)) => std::cmp::Ordering::Less, + (None, None) => a.url.cmp(&b.url), + } + }), + } + if collection.order == SortOrder::Desc { + entries.reverse(); + } + + listings.push(Listing { + output: collection.output.clone(), + template: collection.template.clone(), + title: collection.title.clone(), + entries, + }); + } + + // A listing page writing over a real page would silently replace it. + for listing in &listings { + if let Some(clash) = preps.iter().find(|p| p.output == listing.output) { + anyhow::bail!( + "collection output {} collides with the page built from {}", + listing.output, + clash.source + ); + } + } + Ok(listings) +} + +/// Everything a listing template can see about its entries, hashed. This is the listing +/// page's whole dependency: if none of these change, its output cannot have changed. +fn listing_entries_hash(listing: &Listing) -> Hash { + let fields: Vec<(String, String)> = listing + .entries + .iter() + .flat_map(|e| { + [ + (e.url.clone(), e.title.clone()), + ( + e.date.clone().unwrap_or_default(), + e.tags.join(",") + "\u{0}" + &e.keywords.len().to_string(), + ), + ] + }) + .chain([(listing.title.clone(), listing.template.clone())]) + .collect(); + // Entry *order* is meaningful in a listing, so this hashes the sorted-by-us sequence + // rather than a set: a re-ordering is a real change to the page. + site_structure_hash_ordered(&fields) +} + +/// The nav a listing page shows: whatever the site's nav is, relativized to this +/// listing's own location. +fn listing_nav(preps: &[PagePrep], output: &Utf8Path) -> Vec<NavItem> { + let Some(first) = preps.first() else { + return Vec::new(); + }; + first + .nav + .iter() + .map(|item| { + // Nav URLs on `preps[0]` are relative to that page; re-resolve them against + // the site root, then against this listing's depth. + let absolute = resolve_relative(&first.output, &item.url); + NavItem { + title: item.title.clone(), + url: output_url(output, &absolute, None), + } + }) + .collect() +} + +/// Turn a URL relative to `from` back into a site-root-relative path. +fn resolve_relative(from: &Utf8Path, url: &str) -> Utf8PathBuf { + if url == "#" { + return from.to_owned(); + } + let base = from.parent().unwrap_or_else(|| Utf8Path::new("")); + let mut stack: Vec<&str> = base.components().map(|c| c.as_str()).collect(); + for part in url.split('/') { + match part { + "." | "" => {} + ".." => { + stack.pop(); + } + other => stack.push(other), + } + } + Utf8PathBuf::from(stack.join("/")) +} + +/// The `PageContext` a listing page presents for *itself*. +fn listing_context(listing: &Listing) -> PageContext { + PageContext { + title: listing.title.clone(), + url: listing.output.to_string(), + source: String::new(), + date: None, + date_iso: None, + tags: Vec::new(), + keywords: Default::default(), + } +} + /// Which pages the configured [`NavMode`] selects, in nav order. fn nav_selection<'a>( config: &Config, @@ -190,10 +349,15 @@ fn prepare_pages( } } } - let entries: Vec<(Utf8PathBuf, String)> = nav_selection(config, &all_pages) + let mut entries: Vec<(Utf8PathBuf, String)> = nav_selection(config, &all_pages) .into_iter() .map(|(_, out, title)| (out.clone(), title.clone())) .collect(); + // A listing page is exactly what a section's nav entry should point at — `/blog/` + // rather than any one post — so collections can opt into the nav directly. + for collection in config.collections.iter().filter(|c| c.nav) { + entries.push((collection.output.clone(), collection.title.clone())); + } // RESOLVE reads the shared symbol table and writes only into its own page's output, // so it parallelizes for free once INDEX has finished building the table. @@ -355,10 +519,18 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result .map(|(_, out, title)| (out.to_string(), title.clone())) .collect() } else { - // The same selection the nav itself is built from, so the two can never drift. + // The same selection the nav itself is built from, so the two can never drift — + // including the listing pages that opted into the nav, whose titles appear on + // every page just as a source page's would. nav_selection(&cfg, &all_pages) .into_iter() .map(|(_, out, title)| (out.to_string(), title.clone())) + .chain( + cfg.collections + .iter() + .filter(|c| c.nav) + .map(|c| (c.output.to_string(), c.title.clone())), + ) .collect() }; let cfg_hash = combine(config_hash(&cfg), site_structure_hash(&structure)); @@ -367,6 +539,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result // Compose each page's render key and record its dependency edges. let mut new_graph = DepGraph::default(); let mut new_records: Vec<(Utf8PathBuf, PageRecord, Hash)> = Vec::new(); + 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); @@ -405,9 +578,14 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result // removed files). Their targets are already in the merged graph, so their linkers were // invalidated above. if let Some(prior) = &prior { - let current: HashSet<&Utf8PathBuf> = preps.iter().map(|p| &p.source).collect(); - for (src_path, rec) in &prior.pages { - if !current.contains(src_path) { + // Keyed by source path for real pages and by output path for generated listings, + // which is also how each records itself in the manifest. Listings have to be in + // this set or the cleanup would delete the file it just decided to keep — and a + // removed collection genuinely should have its output deleted. + let mut current: HashSet<&Utf8PathBuf> = preps.iter().map(|p| &p.source).collect(); + current.extend(listings.iter().map(|l| &l.output)); + for (key, rec) in &prior.pages { + if !current.contains(key) { let dest = out.join(&rec.output_path); let _ = fs::remove_file(&dest); } @@ -460,6 +638,61 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result } } + // Generated listing pages (spec §2.1 EMIT). A listing has no source file, so it is + // cached on the one thing it actually depends on: the entries it lists. Adding a post + // 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 dest = out.join(&listing.output); + let cached = prior + .as_ref() + .and_then(|m| m.pages.get(&listing.output)) + .map(|rec| rec.render_key == key) + .unwrap_or(false); + + report.pages.push(listing.output.clone()); + if cached && dest.exists() { + report.skipped.push(listing.output.clone()); + } else { + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?; + } + let root = relative_root(&listing.output); + let html = templater + .render_named( + &listing.template, + &site, + &listing_context(listing), + "", + &listing_nav(&preps, &listing.output), + &format!("{root}{SYNTAX_STYLESHEET}"), + &root, + Some(&listing.entries), + ) + .with_context(|| { + format!( + "rendering collection {} with template {} (available: {})", + listing.output, + listing.template, + templater.names().join(", ") + ) + })?; + fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?; + report.rendered.push(listing.output.clone()); + } + + new_records.push(( + listing.output.clone(), + PageRecord { + content_hash: key, + render_key: key, + output_path: listing.output.clone(), + }, + key, + )); + } + // The syntax stylesheet the highlighter's CSS classes refer to. Written every build // (it is a few KB and depends only on the theme, which lives in the config hash). fs::write(out.join(SYNTAX_STYLESHEET), &syntax_css) @@ -705,6 +938,7 @@ fn page_context(doc: &Document, output: &Utf8Path) -> PageContext { title: page_title(doc), url: output.to_string(), source: doc.source_path.to_string(), + date_iso: keyword("DATE").as_deref().and_then(iso_date), date: keyword("DATE"), tags: keyword("FILETAGS") .unwrap_or_default() @@ -46,6 +46,10 @@ pub struct PageContext { /// `#+DATE:` verbatim, if present — org date syntax is not normalized here because /// templates are better placed to decide how a date should read. pub date: Option<String>, + /// The `YYYY-MM-DD` found inside `date`, if there is one. Org dates arrive in many + /// shapes (`[2025-09-05 Fri 10:21:00]`, `<2024-05-01>`, `2024-05-01`), and a listing + /// wants one it can sort and print. `None` when the date is free text like "someday". + pub date_iso: Option<String>, /// `#+FILETAGS:` split on `:`. pub tags: Vec<String>, /// Every `#+KEYWORD:` in the file, keyed by lowercased name, so a template can use @@ -91,7 +95,7 @@ const BASE_TEMPLATE: &str = r#"<!DOCTYPE html> "#; /// The name a template must have to serve as the page layout. -pub const BASE_TEMPLATE_NAME: &str = "base"; +pub const BASE_TEMPLATE_NAME: &str = "base.html"; #[derive(Debug, thiserror::Error)] pub enum TemplateError { @@ -118,23 +122,27 @@ impl Templater { let mut sources: Vec<(String, String)> = Vec::new(); if let Some(dir) = dir.filter(|d| d.is_dir()) { - let mut entries: Vec<_> = std::fs::read_dir(dir) - .with_context(|| format!("reading template directory {dir}"))? - .collect::<std::io::Result<Vec<_>>>() - .with_context(|| format!("reading template directory {dir}"))?; - entries.sort_by_key(|e| e.file_name()); - - for entry in entries { - let path = Utf8Path::from_path(&entry.path()) + // Registered by full relative filename — `base.html`, `partials/head.html` — + // because that is what `{% extends "base.html" %}` names, and a stem-based + // scheme silently breaks the include syntax every Jinja user already knows. + // Any extension is loaded, so a feed can be a listing page with an XML + // template rather than a separate mechanism. + for entry in walkdir::WalkDir::new(dir).sort_by_file_name() { + let entry = entry.with_context(|| format!("reading templates from {dir}"))?; + if !entry.file_type().is_file() { + continue; + } + let path = Utf8Path::from_path(entry.path()) .map(Utf8Path::to_owned) .ok_or_else(|| anyhow::anyhow!("non-UTF-8 template path"))?; - if path.extension() != Some("html") || !path.is_file() { + let name = path + .strip_prefix(dir) + .unwrap_or(&path) + .as_str() + .replace('\\', "/"); + if name.starts_with('.') || name.contains("/.") { continue; } - let name = path - .file_stem() - .ok_or_else(|| anyhow::anyhow!("template with no name: {path}"))? - .to_string(); let source = std::fs::read_to_string(&path) .with_context(|| format!("reading template {path}"))?; sources.push((name, source)); @@ -147,6 +155,7 @@ impl Templater { sources.sort_by(|a, b| a.0.cmp(&b.0)); let mut env = Environment::new(); + env.set_formatter(html_formatter); for (name, source) in &sources { // `Environment<'static>` needs owned sources; leaking is bounded by the // template count and lives as long as the build anyway. @@ -165,7 +174,17 @@ impl Templater { &self.sources } - /// fragment + page metadata → full HTML page. + /// Is a template with this name registered? + pub fn has(&self, name: &str) -> bool { + self.env.get_template(name).is_ok() + } + + /// Every registered template name, for error messages. + pub fn names(&self) -> Vec<&str> { + self.sources.iter().map(|(n, _)| n.as_str()).collect() + } + + /// fragment + page metadata → full page, through the base layout. /// /// `stylesheet` and `root` are URLs relative to *this* page, so a template works the /// same at any directory depth. @@ -179,10 +198,28 @@ impl Templater { stylesheet: &str, root: &str, pages: Option<&[PageContext]>, + ) -> Result<String, TemplateError> { + self.render_named(BASE_TEMPLATE_NAME, site, page, body, nav, stylesheet, root, pages) + } + + /// Render through a named template. Generated listing pages use this to reach their + /// own layout; the context is identical to a normal page's, so a listing template can + /// `{% extends "base.html" %}` and inherit the site's chrome for free. + #[allow(clippy::too_many_arguments)] + pub fn render_named( + &self, + template: &str, + site: &SiteContext, + page: &PageContext, + body: &str, + nav: &[NavItem], + stylesheet: &str, + root: &str, + pages: Option<&[PageContext]>, ) -> Result<String, TemplateError> { let tmpl = self .env - .get_template(BASE_TEMPLATE_NAME) + .get_template(template) .map_err(|e| TemplateError::Render(e.to_string()))?; tmpl.render(context! { site => site, @@ -197,6 +234,76 @@ impl Templater { } } +/// The starter listing template written by `org-ssg init`: a blog index, showing how a +/// collection's `pages` are iterated. +pub const STARTER_LIST_TEMPLATE: &str = r#"<!DOCTYPE html> +<html lang="{{ site.language }}"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>{{ page.title }} · {{ site.title }}</title> +{%- if stylesheet %} +<link rel="stylesheet" href="{{ stylesheet }}"> +{%- endif %} +</head> +<body> +<header> +<a class="site-title" href="{{ root }}index.html">{{ site.title }}</a> +{%- if nav %} +<nav> +{%- for item in nav %} +<a href="{{ item.url }}">{{ item.title }}</a> +{%- endfor %} +</nav> +{%- endif %} +</header> +<main> +<h1>{{ page.title }}</h1> +<ul class="post-list"> +{%- for post in pages %} +<li> +{%- if post.date_iso %}<time datetime="{{ post.date_iso }}">{{ post.date_iso }}</time> {% endif %} +<a href="{{ root }}{{ post.url }}">{{ post.title }}</a> +</li> +{%- endfor %} +</ul> +</main> +</body> +</html> +"#; + +/// HTML-escape template output, escaping the same characters Jinja2 does. +/// +/// minijinja additionally escapes `/` as `/`, which is a defence for values +/// interpolated into JavaScript. It is correct but, since `<` is escaped anyway, it buys +/// nothing in an HTML document — and it makes every generated URL read +/// `../index.html`. Templates emit a lot of URLs, so that is most of the output. +/// +/// Auto-escaping itself stays on: page titles come from `#+TITLE:` and are user content. +fn html_formatter( + out: &mut minijinja::Output, + state: &minijinja::State, + value: &minijinja::Value, +) -> Result<(), minijinja::Error> { + if state.auto_escape() == minijinja::AutoEscape::Html && !value.is_safe() { + if let Some(text) = value.as_str() { + let mut escaped = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + _ => escaped.push(c), + } + } + return out.write_str(&escaped).map_err(minijinja::Error::from); + } + } + minijinja::escape_formatter(out, state, value) +} + /// minijinja's `Display` gives only the top-level message; the useful part (which /// template, which line) is in the source and cause chain. fn render_error_detail(error: minijinja::Error) -> String { @@ -443,3 +443,372 @@ fn dot_directories_and_build_inputs_are_never_published() { "genuine assets still copy through" ); } + +// --------------------------------------------------------------------------- +// Generated listing pages +// --------------------------------------------------------------------------- + +/// A site with dated posts, a listing template, and a collection configured over them. +fn write_blog(src: &Utf8PathBuf, extra_config: &str) { + 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(); + for (name, title, date) in [ + ("old", "Older Post", "<2024-01-02 Tue>"), + ("new", "Newer Post", "[2025-06-30 Mon 09:15:00]"), + ("mid", "Middle Post", "2024-08-05"), + ] { + std::fs::write( + src.join(format!("blog/{name}.org")), + format!("#+TITLE: {title}\n#+DATE: {date}\n\nBody.\n"), + ) + .unwrap(); + } + std::fs::write( + src.join("templates/list.html"), + "<html><body><h1>{{ page.title }}</h1><ul>\ + {% for p in pages %}<li>{{ p.date_iso }}|{{ p.title }}|{{ root }}{{ p.url }}</li>\ + {% endfor %}</ul></body></html>", + ) + .unwrap(); + std::fs::write( + src.join("org-ssg.toml"), + format!( + "[[collections]]\nsource = \"blog\"\noutput = \"blog/index.html\"\n\ + template = \"list.html\"\ntitle = \"Blog\"\n{extra_config}" + ), + ) + .unwrap(); +} + +/// The whole point: an output file with no source `.org` behind it. +#[test] +fn a_collection_generates_a_listing_page_sorted_newest_first() { + let root = tmpdir("listing"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_blog(&src, ""); + let out = root.join("out"); + let report = build(&src, &out); + + assert!( + report.pages.contains(&Utf8PathBuf::from("blog/index.html")), + "the listing page is part of the build: {:?}", + report.pages + ); + + let listing = page(&out, "blog/index.html"); + let order: Vec<&str> = ["Newer Post", "Middle Post", "Older Post"] + .into_iter() + .filter(|t| listing.contains(t)) + .collect(); + assert_eq!( + order, + vec!["Newer Post", "Middle Post", "Older Post"], + "all three posts appear:\n{listing}" + ); + let pos = |t: &str| listing.find(t).unwrap(); + assert!( + pos("Newer Post") < pos("Middle Post") && pos("Middle Post") < pos("Older Post"), + "newest first by default:\n{listing}" + ); + assert!(!listing.contains("Home"), "only the collection's pages are listed"); +} + +/// Org dates arrive as `[2025-06-30 Mon 09:15:00]`, `<2024-01-02 Tue>` or bare +/// `2024-08-05`. A listing needs one key it can sort and print. +#[test] +fn dates_are_normalized_from_every_org_shape() { + let root = tmpdir("dates"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_blog(&src, ""); + let out = root.join("out"); + build(&src, &out); + + let listing = page(&out, "blog/index.html"); + for iso in ["2025-06-30", "2024-08-05", "2024-01-02"] { + assert!(listing.contains(iso), "{iso} normalized out of its org syntax:\n{listing}"); + } +} + +#[test] +fn sort_and_order_are_configurable() { + let root = tmpdir("sortorder"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_blog(&src, "sort = \"title\"\norder = \"asc\"\n"); + let out = root.join("out"); + build(&src, &out); + + let listing = page(&out, "blog/index.html"); + let pos = |t: &str| listing.find(t).unwrap(); + assert!( + pos("Middle Post") < pos("Newer Post") && pos("Newer Post") < pos("Older Post"), + "ascending by title:\n{listing}" + ); +} + +/// A dateless draft leading a dated archive is almost never what anyone wants. +#[test] +fn undated_pages_sort_last_whichever_direction() { + let root = tmpdir("undated"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_blog(&src, ""); + std::fs::write(src.join("blog/draft.org"), "#+TITLE: No Date Here\n\nBody.\n").unwrap(); + let out = root.join("out"); + build(&src, &out); + + let listing = page(&out, "blog/index.html"); + let undated = listing.find("No Date Here").unwrap(); + for dated in ["Newer Post", "Middle Post", "Older Post"] { + assert!( + listing.find(dated).unwrap() < undated, + "{dated} must precede the undated draft:\n{listing}" + ); + } +} + +/// A listing page is exactly what a section's nav entry should point at — `/blog/` +/// rather than any one post. +#[test] +fn a_collection_can_join_the_nav() { + let root = tmpdir("listnav"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_blog(&src, "nav = true\n"); + let out = root.join("out"); + build(&src, &out); + + let home_nav = nav_of(&page(&out, "index.html")); + assert!( + home_nav.contains("blog/index.html"), + "the listing page is in the nav:\n{home_nav}" + ); + // And the URL has to be right from a nested page too. + let post = page(&out, "blog/new.html"); + assert!( + nav_of(&post).contains("href=\"index.html\"") || nav_of(&post).contains("blog/index.html"), + "the nav link resolves from a nested page:\n{}", + nav_of(&post) + ); +} + +/// A listing page depends on every page it lists — and on nothing else. Adding a post +/// must re-render the index without re-rendering the rest of the site. +#[test] +fn adding_a_post_rebuilds_only_the_listing_and_the_post() { + let root = tmpdir("listinc"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_blog(&src, ""); + let out = root.join("out"); + + build(&src, &out); + let second = build(&src, &out); + assert!( + second.rendered.is_empty(), + "an unchanged rebuild renders nothing, including the listing: {:?}", + second.rendered + ); + + std::fs::write( + src.join("blog/fresh.org"), + "#+TITLE: Fresh Post\n#+DATE: 2026-01-01\n\nBody.\n", + ) + .unwrap(); + let report = build(&src, &out); + + let mut rendered = report.rendered.clone(); + rendered.sort(); + assert_eq!( + rendered, + vec![ + Utf8PathBuf::from("blog/fresh.html"), + Utf8PathBuf::from("blog/index.html") + ], + "exactly the new post and the listing it belongs to" + ); + assert!( + page(&out, "blog/index.html").contains("Fresh Post"), + "and the listing actually picked it up" + ); +} + +/// Editing a post's body changes no listing metadata, so the index must not churn. +#[test] +fn editing_a_post_body_does_not_rebuild_the_listing() { + let root = tmpdir("listbody"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_blog(&src, ""); + let out = root.join("out"); + build(&src, &out); + + std::fs::write( + src.join("blog/mid.org"), + "#+TITLE: Middle Post\n#+DATE: 2024-08-05\n\nEdited body.\n", + ) + .unwrap(); + let report = build(&src, &out); + + assert_eq!( + report.rendered, + vec![Utf8PathBuf::from("blog/mid.html")], + "only the post itself; the listing shows unchanged metadata" + ); +} + +/// Retitling a post *does* change the listing, since the title is what it displays. +#[test] +fn retitling_a_post_rebuilds_the_listing() { + let root = tmpdir("listtitle"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_blog(&src, ""); + let out = root.join("out"); + build(&src, &out); + + std::fs::write( + src.join("blog/mid.org"), + "#+TITLE: Renamed Post\n#+DATE: 2024-08-05\n\nBody.\n", + ) + .unwrap(); + let report = build(&src, &out); + + assert!( + report.rendered.contains(&Utf8PathBuf::from("blog/index.html")), + "the listing must follow a title change: {:?}", + report.rendered + ); + assert!(page(&out, "blog/index.html").contains("Renamed Post")); +} + +/// A feed is a listing page with an XML template, not a separate feature — which is why +/// templates are loaded by full filename and any extension. +#[test] +fn a_feed_is_just_a_listing_page_with_an_xml_template() { + let root = tmpdir("feed"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_blog(&src, ""); + std::fs::write( + src.join("templates/feed.xml"), + "<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>{{ site.title }}</title>\ + {% for p in pages %}<item><title>{{ p.title }}</title>\ + <pubDate>{{ p.date_iso }}</pubDate></item>{% endfor %}</channel></rss>", + ) + .unwrap(); + let mut config = std::fs::read_to_string(src.join("org-ssg.toml")).unwrap(); + config.push_str( + "\n[[collections]]\nsource = \"blog\"\noutput = \"feed.xml\"\n\ + template = \"feed.xml\"\ntitle = \"Feed\"\n", + ); + std::fs::write(src.join("org-ssg.toml"), config).unwrap(); + let out = root.join("out"); + build(&src, &out); + + let feed = page(&out, "feed.xml"); + assert!(feed.starts_with("<?xml"), "an XML document, not HTML:\n{feed}"); + assert!(feed.contains("<pubDate>2025-06-30</pubDate>"), "entries carry dates:\n{feed}"); +} + +/// A listing template can inherit the site layout instead of duplicating it. +#[test] +fn a_listing_template_can_extend_the_base_layout() { + let root = tmpdir("listextends"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_blog(&src, ""); + std::fs::write( + src.join("templates/base.html"), + "<html><body class=\"shared\">{% block main %}{{ body | safe }}{% endblock %}</body></html>", + ) + .unwrap(); + std::fs::write( + src.join("templates/list.html"), + "{% extends \"base.html\" %}{% block main %}<ul>\ + {% for p in pages %}<li>{{ p.title }}</li>{% endfor %}</ul>{% endblock %}", + ) + .unwrap(); + let out = root.join("out"); + build(&src, &out); + + let listing = page(&out, "blog/index.html"); + assert!(listing.contains("class=\"shared\""), "inherits the layout:\n{listing}"); + assert!(listing.contains("Newer Post"), "and adds its own content:\n{listing}"); +} + +/// URLs are most of a template's output. Escaping `/` as `/` is valid but makes +/// every link unreadable; escaping user content is not optional. +#[test] +fn urls_stay_readable_while_user_content_is_still_escaped() { + let root = tmpdir("escaping"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_blog(&src, ""); + std::fs::write( + src.join("blog/evil.org"), + "#+TITLE: <script>alert(1)</script>\n#+DATE: 2026-02-02\n\nBody.\n", + ) + .unwrap(); + let out = root.join("out"); + build(&src, &out); + + let listing = page(&out, "blog/index.html"); + assert!(listing.contains("../blog/new.html"), "URLs read as URLs:\n{listing}"); + assert!(!listing.contains("/"), "no escaped slashes:\n{listing}"); + assert!( + listing.contains("<script>"), + "a title is user content and stays escaped:\n{listing}" + ); + assert!(!listing.contains("<script>"), "never unescaped:\n{listing}"); +} + +/// Two generated pages writing the same file, or a listing writing over a real page, +/// silently loses one of them. +#[test] +fn colliding_collection_outputs_are_rejected() { + let root = tmpdir("listcollide"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_blog(&src, ""); + + let mut config = std::fs::read_to_string(src.join("org-ssg.toml")).unwrap(); + config.push_str("\n[[collections]]\nsource = \"\"\noutput = \"blog/index.html\"\n"); + std::fs::write(src.join("org-ssg.toml"), &config).unwrap(); + let err = build_site(&src, &root.join("out"), &BuildOptions::default()) + .expect_err("two collections writing one file must fail"); + assert!(format!("{err:#}").contains("blog/index.html"), "{err:#}"); + + // And a listing that would overwrite a real page. + std::fs::write( + src.join("org-ssg.toml"), + "[[collections]]\nsource = \"blog\"\noutput = \"index.html\"\ntemplate = \"list.html\"\n", + ) + .unwrap(); + let err = build_site(&src, &root.join("out2"), &BuildOptions::default()) + .expect_err("a listing over a real page must fail"); + assert!(format!("{err:#}").contains("index.org"), "names the page it would replace: {err:#}"); +} + +/// A missing template is a typo; listing what exists turns it into a one-second fix. +#[test] +fn a_missing_collection_template_names_the_ones_that_exist() { + let root = tmpdir("listnotpl"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_blog(&src, ""); + std::fs::write( + src.join("org-ssg.toml"), + "[[collections]]\nsource = \"blog\"\noutput = \"blog/index.html\"\ntemplate = \"nope.html\"\n", + ) + .unwrap(); + + let err = build_site(&src, &root.join("out"), &BuildOptions::default()) + .expect_err("missing template must fail"); + let message = format!("{err:#}"); + assert!(message.contains("nope.html"), "names the missing one: {message}"); + assert!(message.contains("list.html"), "lists what is available: {message}"); +}