krz/orgo

Lightning fast org-mode static site generator. fast go org-mode static-site-generator

Commit 012e561704

012e5617046046f5b71f563201f809a222c56ad1

parent: 34e27a5061

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-11 05:14 UTC

v0.7: generated listing pages

The last output org-ssg could not produce: a file with no source .org behind it. A blog
index exists because a set of posts exists, not because someone wrote index.org.

[[collections]] declares one — a source directory in, an output file out, through a
template — with sort (date/title/path), order, a title, and whether it joins the nav.
Keeping it declarative means a feed is the same mechanism with an XML template rather
than a second feature; templates are now loaded by full filename and any extension, so
output = "feed.xml" with template = "feed.xml" is all an RSS feed takes.

Sorting is on page.date_iso, 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>, bare 2024-05-01). Pages
with no parseable date sort last in either direction, so an undated draft never leads a
dated archive.

Incremental: a listing page is cached on the entries it lists, so adding a post
re-renders that section's index and nothing else, and editing a post's *body* — which
changes no listing metadata — does not touch the index at all. Both directions are
tested. Fixing this surfaced a bug where the stale-output cleanup deleted listing files
every build, because it keyed on source paths and a listing has none.

Collections can join the nav, which resolves the trade-off left open when nav was
limited to top-level pages: a section landing page is exactly what /blog/ should point
at, and it is generated rather than authored.

Two things changed underneath:
- Templates register by full relative filename (base.html, partials/head.html) rather
  than by stem. Stem-based naming silently broke {% extends "base.html" %}, which is
  what every Jinja user writes.
- That rename turned on minijinja's autoescaping, which is a real safety win — page
  titles are user content — but it escapes / as &#x2f;, and templates emit mostly URLs.
  A custom formatter escapes exactly what Jinja2 escapes, so URLs read as URLs while
  <script> in a title still does not.

Validated against the 179-file corpus with blog and garden collections configured: 181
of the incumbent's 182 URLs now reproduced, up from 179. The remaining one is a tag
index, which needs one collection to emit many pages — a different feature.

Layout: unified · split

Cargo.lock +1 −1
@@ -569,7 +569,7 @@ dependencies = [
569569
570570[[package]]
571571name = "org-ssg"
572version = "0.6.0"
572version = "0.7.0"
573573dependencies = [
574574 "anyhow",
575575 "blake3",
Cargo.toml +1 −1
@@ -1,6 +1,6 @@
11[package]
22name = "org-ssg"
3version = "0.6.0"
3version = "0.7.0"
44edition = "2021"
55description = "Org-mode static site generator that renders the org element tree straight to HTML"
66license = "MIT"
README.md +43 −1
@@ -86,6 +86,47 @@ your own metadata works without this crate knowing about it: `#+CUSTOM_THING: x`
8686Editing a template re-renders the pages that use it — template sources are a hash input,
8787so a design change never leaves a site half-updated.
8888
89### Generated listing pages
90
91A blog index, an archive, a feed — output files with no source `.org` behind them.
92Repeat the block for each one:
93
94```toml
95[[collections]]
96source = "blog" # directory to list; empty means every page
97output = "blog/index.html" # where to write it
98template = "list.html"
99title = "Blog"
100sort = "date" # date | title | path
101order = "desc" # desc | asc
102nav = true # put this listing page in the nav
103```
104
105The template gets the collection's entries as `pages`, already sorted, plus the usual
106`site`/`nav`/`root`. It can `{% extends "base.html" %}` to inherit the site chrome:
107
108```jinja
109{% extends "base.html" %}
110{% block main %}
111<ul>{% for p in pages %}
112 <li><time datetime="{{ p.date_iso }}">{{ p.date_iso }}</time>
113 <a href="{{ root }}{{ p.url }}">{{ p.title }}</a></li>
114{% endfor %}</ul>
115{% endblock %}
116```
117
118`p.date_iso` is the `YYYY-MM-DD` extracted from `#+DATE:`, whatever org syntax it was
119written in — `[2025-09-05 Fri 10:21:00]`, `<2024-05-01 Wed>` or bare `2024-05-01`. It is
120also the sort key; pages without a parseable date sort last, so an undated draft never
121leads a dated archive.
122
123**A feed is a listing page with an XML template**, not a separate feature — templates are
124loaded by full filename and any extension, so `output = "feed.xml"` with
125`template = "feed.xml"` is all it takes.
126
127Listing pages are cached on the entries they list, so adding a post re-renders that
128section's index and nothing else.
129
89130### `#+SLUG:`
90131
91132A 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
154195| 6 | Incremental build layer (hashing, dep graph, invalidation) done; `watch` is a simple poll loop | done |
155196| **7** | **Hardening: rayon parallelism, error locations in parse diagnostics** | **done** |
156197| **8** | **General use: config file, user templates, nav modes, `init` scaffold, safe discovery** | **done** |
198| **9** | **Generated listing pages: `[[collections]]`, sorted indexes, feeds via XML templates** | **done** |
157199
158200### v0.2 in / out
159201
@@ -416,7 +458,7 @@ PARSE/RESOLVE/RENDER), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserro
416458
417459```
418460cargo build
419cargo test # 86 tests
461cargo test # 99 tests
420462cargo run -- init my-site # scaffold a new site
421463cargo run -- build fixtures/minimal.org -o minimal.html # single file
422464cargo run -- build fixtures/site -o _site # whole site (incremental)
src/config.rs +87
@@ -30,6 +30,69 @@ pub struct Config {
3030 pub templates: Templates,
3131 pub highlight: Highlight,
3232 pub html: HtmlOutput,
33 /// Generated listing pages. Each produces one output file that has no source `.org`
34 /// file behind it — a blog index, an archive, a feed.
35 pub collections: Vec<Collection>,
36}
37
38/// A generated page that lists other pages.
39///
40/// This is the one output that is not a translation of some input: a blog index exists
41/// because a set of posts exists, not because someone wrote `index.org`. Keeping it
42/// declarative — a directory in, a file out, through a template — means a feed is the
43/// same mechanism with an XML template rather than a second feature.
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45#[serde(default, deny_unknown_fields)]
46pub struct Collection {
47 /// Directory of source pages to list, relative to the source root. Empty means every
48 /// page in the site.
49 pub source: Utf8PathBuf,
50 /// Where to write the generated page, relative to the output root.
51 pub output: Utf8PathBuf,
52 /// Template file name, as it appears in the templates directory.
53 pub template: String,
54 /// Title for the generated page, available to the template as `page.title`.
55 pub title: String,
56 pub sort: SortKey,
57 pub order: SortOrder,
58 /// Add this listing page to the site navigation. This is how a section landing page
59 /// — `/blog/`, `/notes/` — gets into a nav built from top-level pages.
60 pub nav: bool,
61}
62
63impl Default for Collection {
64 fn default() -> Self {
65 Collection {
66 source: Utf8PathBuf::new(),
67 output: Utf8PathBuf::from("index.html"),
68 template: "list.html".to_string(),
69 title: "Index".to_string(),
70 sort: SortKey::default(),
71 order: SortOrder::default(),
72 nav: false,
73 }
74 }
75}
76
77#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "kebab-case")]
79pub enum SortKey {
80 /// By `#+DATE:`, newest first by default. Pages with no parseable date sort last,
81 /// keeping undated drafts out of the way of a dated archive.
82 #[default]
83 Date,
84 Title,
85 /// Output path — stable and predictable when dates are absent or unreliable.
86 Path,
87}
88
89#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "kebab-case")]
91pub enum SortOrder {
92 /// Newest or last first — the useful default for a blog.
93 #[default]
94 Desc,
95 Asc,
3396}
3497
3598#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -186,6 +249,19 @@ impl Config {
186249 .trim_matches('"')
187250 );
188251 }
252 let mut seen: Vec<&Utf8PathBuf> = Vec::new();
253 for collection in &self.collections {
254 if collection.output.as_str().is_empty() {
255 anyhow::bail!("a collection has an empty `output`; it needs a file to write");
256 }
257 if seen.contains(&&collection.output) {
258 anyhow::bail!(
259 "two collections both write to {}; give them different `output` paths",
260 collection.output
261 );
262 }
263 seen.push(&collection.output);
264 }
189265 if !self.site.base_url.is_empty() && self.site.base_url.ends_with('/') {
190266 anyhow::bail!(
191267 "site.base_url must not end with a slash (got {:?}) — URLs are joined \
@@ -236,4 +312,15 @@ theme = "InspiredGitHub"
236312# The default of 1 matches Emacs, and assumes your layout renders the page title as the
237313# <h1>. Set to 0 if your template renders no title of its own.
238314heading_offset = 1
315
316# Generated listing pages: output files with no source .org behind them. Repeat the
317# [[collections]] block for each one. A feed is the same thing with an XML template.
318[[collections]]
319source = "blog" # directory to list; empty means every page
320output = "blog/index.html" # where to write it
321template = "list.html" # template file name
322title = "Blog"
323sort = "date" # date | title | path
324order = "desc" # desc | asc
325nav = true # put this listing page in the site nav
239326"#;
src/incremental.rs +8 −1
@@ -66,8 +66,15 @@ pub fn combine(a: Hash, b: Hash) -> Hash {
6666pub fn site_structure_hash(entries: &[(String, String)]) -> Hash {
6767 let mut sorted = entries.to_vec();
6868 sorted.sort();
69 site_structure_hash_ordered(&sorted)
70}
71
72/// As [`site_structure_hash`], but hashing the sequence *as given*. Used where order is
73/// itself part of the output — a listing page's entries are sorted deliberately, so
74/// re-ordering them is a real change even when the set is identical.
75pub fn site_structure_hash_ordered(entries: &[(String, String)]) -> Hash {
6976 let mut hasher = blake3::Hasher::new();
70 for (path, title) in &sorted {
77 for (path, title) in entries {
7178 hasher.update(path.as_bytes());
7279 hasher.update(&[0]);
7380 hasher.update(title.as_bytes());
src/main.rs +15 −2
@@ -132,10 +132,11 @@ fn main() -> Result<()> {
132132/// in a directory that has content is safe and additive rather than destructive.
133133fn init(dir: &Utf8Path) -> Result<()> {
134134 use org_ssg::config::{CONFIG_FILE, STARTER_CONFIG};
135 use org_ssg::template::starter_template;
135 use org_ssg::template::{starter_template, STARTER_LIST_TEMPLATE};
136136
137137 fs::create_dir_all(dir).with_context(|| format!("creating {dir}"))?;
138138 fs::create_dir_all(dir.join("templates")).with_context(|| format!("creating {dir}/templates"))?;
139 fs::create_dir_all(dir.join("blog")).with_context(|| format!("creating {dir}/blog"))?;
139140
140141 let index = concat!(
141142 "#+TITLE: Hello\n",
@@ -155,10 +156,21 @@ fn init(dir: &Utf8Path) -> Result<()> {
155156 "#+END_SRC\n",
156157 );
157158
158 let files: [(Utf8PathBuf, &str); 3] = [
159 let post = concat!(
160 "#+TITLE: A first post\n",
161 "#+DATE: <2026-01-15 Thu>\n",
162 "#+FILETAGS: :example:\n",
163 "\n",
164 "Posts in this directory are collected into /blog/ by the [[collections]] block\n",
165 "in org-ssg.toml, newest first.\n",
166 );
167
168 let files: [(Utf8PathBuf, &str); 5] = [
159169 (dir.join(CONFIG_FILE), STARTER_CONFIG),
160170 (dir.join("templates/base.html"), starter_template()),
171 (dir.join("templates/list.html"), STARTER_LIST_TEMPLATE),
161172 (dir.join("index.org"), index),
173 (dir.join("blog/first-post.org"), post),
162174 ];
163175
164176 let mut created = Vec::new();
@@ -279,6 +291,7 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
279291 url: output.file_name().unwrap_or("index.html").to_string(),
280292 source: input.to_string(),
281293 date: None,
294 date_iso: None,
282295 tags: Vec::new(),
283296 keywords: Default::default(),
284297 };
src/site.rs +241 −7
@@ -19,14 +19,15 @@ use walkdir::WalkDir;
1919
2020use crate::incremental::{
2121 self, combine, config_hash, render_key, resolved_links_hash, site_structure_hash,
22 template_hash, DepGraph, Hash, Manifest, PageRecord, CACHE_FORMAT_VERSION,
22 site_structure_hash_ordered, template_hash, DepGraph, Hash, Manifest, PageRecord,
23 CACHE_FORMAT_VERSION,
2324};
2425use crate::index::{document_targets, SymbolTable, TargetId};
2526use crate::model::{ContentHash, Diagnostic, Document};
2627use crate::parser::parse;
2728use crate::render::{self, render_with, Html, RenderOptions, SyntectHighlighter};
2829use crate::resolve::resolve;
29use crate::config::{self, Config, NavMode};
30use crate::config::{self, Config, NavMode, SortKey, SortOrder};
3031use crate::template::{NavItem, PageContext, SiteContext, Templater};
3132use crate::util::{output_path, output_url, relative_root};
3233
@@ -104,6 +105,164 @@ struct PagePrep {
104105 context: PageContext,
105106}
106107
108/// A generated listing page, resolved against the pages it lists.
109struct Listing {
110 output: Utf8PathBuf,
111 template: String,
112 title: String,
113 /// The pages it lists, already sorted.
114 entries: Vec<PageContext>,
115}
116
117/// The `YYYY-MM-DD` inside an org date, if there is one. Org dates arrive as
118/// `[2025-09-05 Fri 10:21:00]`, `<2024-05-01 Wed>` or bare `2024-05-01`, and a listing
119/// needs one key it can sort on.
120pub fn iso_date(raw: &str) -> Option<String> {
121 let bytes = raw.as_bytes();
122 for i in 0..bytes.len().saturating_sub(9) {
123 let window = &bytes[i..i + 10];
124 let digits = |r: std::ops::Range<usize>| window[r].iter().all(u8::is_ascii_digit);
125 if digits(0..4) && window[4] == b'-' && digits(5..7) && window[7] == b'-' && digits(8..10) {
126 // Must not be part of a longer number, or `123-45-6789` would parse.
127 let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
128 let after_ok = i + 10 >= bytes.len() || !bytes[i + 10].is_ascii_digit();
129 if before_ok && after_ok {
130 return Some(raw[i..i + 10].to_string());
131 }
132 }
133 }
134 None
135}
136
137/// Build the listing pages a config asks for, each with its entries sorted.
138fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
139 let mut listings = Vec::new();
140 for collection in &config.collections {
141 let mut entries: Vec<PageContext> = preps
142 .iter()
143 .filter(|p| {
144 collection.source.as_str().is_empty() || p.source.starts_with(&collection.source)
145 })
146 .map(|p| p.context.clone())
147 .collect();
148
149 // Sort ascending first, then reverse for `desc`, so the two orders are exact
150 // mirrors of one another rather than two separately-written comparisons.
151 match collection.sort {
152 SortKey::Title => entries.sort_by(|a, b| a.title.cmp(&b.title)),
153 SortKey::Path => entries.sort_by(|a, b| a.url.cmp(&b.url)),
154 // Undated pages sort last in the final order regardless of direction: a
155 // draft with no date should not lead an archive.
156 SortKey::Date => entries.sort_by(|a, b| {
157 let key = |p: &PageContext| p.date_iso.clone();
158 match (key(a), key(b)) {
159 (Some(x), Some(y)) => x.cmp(&y).then_with(|| a.url.cmp(&b.url)),
160 (Some(_), None) => std::cmp::Ordering::Greater,
161 (None, Some(_)) => std::cmp::Ordering::Less,
162 (None, None) => a.url.cmp(&b.url),
163 }
164 }),
165 }
166 if collection.order == SortOrder::Desc {
167 entries.reverse();
168 }
169
170 listings.push(Listing {
171 output: collection.output.clone(),
172 template: collection.template.clone(),
173 title: collection.title.clone(),
174 entries,
175 });
176 }
177
178 // A listing page writing over a real page would silently replace it.
179 for listing in &listings {
180 if let Some(clash) = preps.iter().find(|p| p.output == listing.output) {
181 anyhow::bail!(
182 "collection output {} collides with the page built from {}",
183 listing.output,
184 clash.source
185 );
186 }
187 }
188 Ok(listings)
189}
190
191/// Everything a listing template can see about its entries, hashed. This is the listing
192/// page's whole dependency: if none of these change, its output cannot have changed.
193fn listing_entries_hash(listing: &Listing) -> Hash {
194 let fields: Vec<(String, String)> = listing
195 .entries
196 .iter()
197 .flat_map(|e| {
198 [
199 (e.url.clone(), e.title.clone()),
200 (
201 e.date.clone().unwrap_or_default(),
202 e.tags.join(",") + "\u{0}" + &e.keywords.len().to_string(),
203 ),
204 ]
205 })
206 .chain([(listing.title.clone(), listing.template.clone())])
207 .collect();
208 // Entry *order* is meaningful in a listing, so this hashes the sorted-by-us sequence
209 // rather than a set: a re-ordering is a real change to the page.
210 site_structure_hash_ordered(&fields)
211}
212
213/// The nav a listing page shows: whatever the site's nav is, relativized to this
214/// listing's own location.
215fn listing_nav(preps: &[PagePrep], output: &Utf8Path) -> Vec<NavItem> {
216 let Some(first) = preps.first() else {
217 return Vec::new();
218 };
219 first
220 .nav
221 .iter()
222 .map(|item| {
223 // Nav URLs on `preps[0]` are relative to that page; re-resolve them against
224 // the site root, then against this listing's depth.
225 let absolute = resolve_relative(&first.output, &item.url);
226 NavItem {
227 title: item.title.clone(),
228 url: output_url(output, &absolute, None),
229 }
230 })
231 .collect()
232}
233
234/// Turn a URL relative to `from` back into a site-root-relative path.
235fn resolve_relative(from: &Utf8Path, url: &str) -> Utf8PathBuf {
236 if url == "#" {
237 return from.to_owned();
238 }
239 let base = from.parent().unwrap_or_else(|| Utf8Path::new(""));
240 let mut stack: Vec<&str> = base.components().map(|c| c.as_str()).collect();
241 for part in url.split('/') {
242 match part {
243 "." | "" => {}
244 ".." => {
245 stack.pop();
246 }
247 other => stack.push(other),
248 }
249 }
250 Utf8PathBuf::from(stack.join("/"))
251}
252
253/// The `PageContext` a listing page presents for *itself*.
254fn listing_context(listing: &Listing) -> PageContext {
255 PageContext {
256 title: listing.title.clone(),
257 url: listing.output.to_string(),
258 source: String::new(),
259 date: None,
260 date_iso: None,
261 tags: Vec::new(),
262 keywords: Default::default(),
263 }
264}
265
107266/// Which pages the configured [`NavMode`] selects, in nav order.
108267fn nav_selection<'a>(
109268 config: &Config,
@@ -190,10 +349,15 @@ fn prepare_pages(
190349 }
191350 }
192351 }
193 let entries: Vec<(Utf8PathBuf, String)> = nav_selection(config, &all_pages)
352 let mut entries: Vec<(Utf8PathBuf, String)> = nav_selection(config, &all_pages)
194353 .into_iter()
195354 .map(|(_, out, title)| (out.clone(), title.clone()))
196355 .collect();
356 // A listing page is exactly what a section's nav entry should point at — `/blog/`
357 // rather than any one post — so collections can opt into the nav directly.
358 for collection in config.collections.iter().filter(|c| c.nav) {
359 entries.push((collection.output.clone(), collection.title.clone()));
360 }
197361
198362 // RESOLVE reads the shared symbol table and writes only into its own page's output,
199363 // 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
355519 .map(|(_, out, title)| (out.to_string(), title.clone()))
356520 .collect()
357521 } else {
358 // The same selection the nav itself is built from, so the two can never drift.
522 // The same selection the nav itself is built from, so the two can never drift —
523 // including the listing pages that opted into the nav, whose titles appear on
524 // every page just as a source page's would.
359525 nav_selection(&cfg, &all_pages)
360526 .into_iter()
361527 .map(|(_, out, title)| (out.to_string(), title.clone()))
528 .chain(
529 cfg.collections
530 .iter()
531 .filter(|c| c.nav)
532 .map(|c| (c.output.to_string(), c.title.clone())),
533 )
362534 .collect()
363535 };
364536 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
367539 // Compose each page's render key and record its dependency edges.
368540 let mut new_graph = DepGraph::default();
369541 let mut new_records: Vec<(Utf8PathBuf, PageRecord, Hash)> = Vec::new();
542 let listings = build_listings(&cfg, &preps)?;
370543 for p in &preps {
371544 let rlh = resolved_links_hash(&p.source, &p.used, &symbols);
372545 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
405578 // removed files). Their targets are already in the merged graph, so their linkers were
406579 // invalidated above.
407580 if let Some(prior) = &prior {
408 let current: HashSet<&Utf8PathBuf> = preps.iter().map(|p| &p.source).collect();
409 for (src_path, rec) in &prior.pages {
410 if !current.contains(src_path) {
581 // Keyed by source path for real pages and by output path for generated listings,
582 // which is also how each records itself in the manifest. Listings have to be in
583 // this set or the cleanup would delete the file it just decided to keep — and a
584 // removed collection genuinely should have its output deleted.
585 let mut current: HashSet<&Utf8PathBuf> = preps.iter().map(|p| &p.source).collect();
586 current.extend(listings.iter().map(|l| &l.output));
587 for (key, rec) in &prior.pages {
588 if !current.contains(key) {
411589 let dest = out.join(&rec.output_path);
412590 let _ = fs::remove_file(&dest);
413591 }
@@ -460,6 +638,61 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
460638 }
461639 }
462640
641 // Generated listing pages (spec §2.1 EMIT). A listing has no source file, so it is
642 // cached on the one thing it actually depends on: the entries it lists. Adding a post
643 // therefore re-renders that section's index and nothing else — the same precision the
644 // rest of the build gets from content hashing.
645 for listing in &listings {
646 let key = combine(listing_entries_hash(listing), combine(cfg_hash, tmpl_hash));
647 let dest = out.join(&listing.output);
648 let cached = prior
649 .as_ref()
650 .and_then(|m| m.pages.get(&listing.output))
651 .map(|rec| rec.render_key == key)
652 .unwrap_or(false);
653
654 report.pages.push(listing.output.clone());
655 if cached && dest.exists() {
656 report.skipped.push(listing.output.clone());
657 } else {
658 if let Some(parent) = dest.parent() {
659 fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?;
660 }
661 let root = relative_root(&listing.output);
662 let html = templater
663 .render_named(
664 &listing.template,
665 &site,
666 &listing_context(listing),
667 "",
668 &listing_nav(&preps, &listing.output),
669 &format!("{root}{SYNTAX_STYLESHEET}"),
670 &root,
671 Some(&listing.entries),
672 )
673 .with_context(|| {
674 format!(
675 "rendering collection {} with template {} (available: {})",
676 listing.output,
677 listing.template,
678 templater.names().join(", ")
679 )
680 })?;
681 fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?;
682 report.rendered.push(listing.output.clone());
683 }
684
685 new_records.push((
686 listing.output.clone(),
687 PageRecord {
688 content_hash: key,
689 render_key: key,
690 output_path: listing.output.clone(),
691 },
692 key,
693 ));
694 }
695
463696 // The syntax stylesheet the highlighter's CSS classes refer to. Written every build
464697 // (it is a few KB and depends only on the theme, which lives in the config hash).
465698 fs::write(out.join(SYNTAX_STYLESHEET), &syntax_css)
@@ -705,6 +938,7 @@ fn page_context(doc: &Document, output: &Utf8Path) -> PageContext {
705938 title: page_title(doc),
706939 url: output.to_string(),
707940 source: doc.source_path.to_string(),
941 date_iso: keyword("DATE").as_deref().and_then(iso_date),
708942 date: keyword("DATE"),
709943 tags: keyword("FILETAGS")
710944 .unwrap_or_default()
src/template.rs +123 −16
@@ -46,6 +46,10 @@ pub struct PageContext {
4646 /// `#+DATE:` verbatim, if present — org date syntax is not normalized here because
4747 /// templates are better placed to decide how a date should read.
4848 pub date: Option<String>,
49 /// The `YYYY-MM-DD` found inside `date`, if there is one. Org dates arrive in many
50 /// shapes (`[2025-09-05 Fri 10:21:00]`, `<2024-05-01>`, `2024-05-01`), and a listing
51 /// wants one it can sort and print. `None` when the date is free text like "someday".
52 pub date_iso: Option<String>,
4953 /// `#+FILETAGS:` split on `:`.
5054 pub tags: Vec<String>,
5155 /// 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>
9195"#;
9296
9397/// The name a template must have to serve as the page layout.
94pub const BASE_TEMPLATE_NAME: &str = "base";
98pub const BASE_TEMPLATE_NAME: &str = "base.html";
9599
96100#[derive(Debug, thiserror::Error)]
97101pub enum TemplateError {
@@ -118,23 +122,27 @@ impl Templater {
118122 let mut sources: Vec<(String, String)> = Vec::new();
119123
120124 if let Some(dir) = dir.filter(|d| d.is_dir()) {
121 let mut entries: Vec<_> = std::fs::read_dir(dir)
122 .with_context(|| format!("reading template directory {dir}"))?
123 .collect::<std::io::Result<Vec<_>>>()
124 .with_context(|| format!("reading template directory {dir}"))?;
125 entries.sort_by_key(|e| e.file_name());
126
127 for entry in entries {
128 let path = Utf8Path::from_path(&entry.path())
125 // Registered by full relative filename — `base.html`, `partials/head.html` —
126 // because that is what `{% extends "base.html" %}` names, and a stem-based
127 // scheme silently breaks the include syntax every Jinja user already knows.
128 // Any extension is loaded, so a feed can be a listing page with an XML
129 // template rather than a separate mechanism.
130 for entry in walkdir::WalkDir::new(dir).sort_by_file_name() {
131 let entry = entry.with_context(|| format!("reading templates from {dir}"))?;
132 if !entry.file_type().is_file() {
133 continue;
134 }
135 let path = Utf8Path::from_path(entry.path())
129136 .map(Utf8Path::to_owned)
130137 .ok_or_else(|| anyhow::anyhow!("non-UTF-8 template path"))?;
131 if path.extension() != Some("html") || !path.is_file() {
138 let name = path
139 .strip_prefix(dir)
140 .unwrap_or(&path)
141 .as_str()
142 .replace('\\', "/");
143 if name.starts_with('.') || name.contains("/.") {
132144 continue;
133145 }
134 let name = path
135 .file_stem()
136 .ok_or_else(|| anyhow::anyhow!("template with no name: {path}"))?
137 .to_string();
138146 let source = std::fs::read_to_string(&path)
139147 .with_context(|| format!("reading template {path}"))?;
140148 sources.push((name, source));
@@ -147,6 +155,7 @@ impl Templater {
147155 sources.sort_by(|a, b| a.0.cmp(&b.0));
148156
149157 let mut env = Environment::new();
158 env.set_formatter(html_formatter);
150159 for (name, source) in &sources {
151160 // `Environment<'static>` needs owned sources; leaking is bounded by the
152161 // template count and lives as long as the build anyway.
@@ -165,7 +174,17 @@ impl Templater {
165174 &self.sources
166175 }
167176
168 /// fragment + page metadata → full HTML page.
177 /// Is a template with this name registered?
178 pub fn has(&self, name: &str) -> bool {
179 self.env.get_template(name).is_ok()
180 }
181
182 /// Every registered template name, for error messages.
183 pub fn names(&self) -> Vec<&str> {
184 self.sources.iter().map(|(n, _)| n.as_str()).collect()
185 }
186
187 /// fragment + page metadata → full page, through the base layout.
169188 ///
170189 /// `stylesheet` and `root` are URLs relative to *this* page, so a template works the
171190 /// same at any directory depth.
@@ -179,10 +198,28 @@ impl Templater {
179198 stylesheet: &str,
180199 root: &str,
181200 pages: Option<&[PageContext]>,
201 ) -> Result<String, TemplateError> {
202 self.render_named(BASE_TEMPLATE_NAME, site, page, body, nav, stylesheet, root, pages)
203 }
204
205 /// Render through a named template. Generated listing pages use this to reach their
206 /// own layout; the context is identical to a normal page's, so a listing template can
207 /// `{% extends "base.html" %}` and inherit the site's chrome for free.
208 #[allow(clippy::too_many_arguments)]
209 pub fn render_named(
210 &self,
211 template: &str,
212 site: &SiteContext,
213 page: &PageContext,
214 body: &str,
215 nav: &[NavItem],
216 stylesheet: &str,
217 root: &str,
218 pages: Option<&[PageContext]>,
182219 ) -> Result<String, TemplateError> {
183220 let tmpl = self
184221 .env
185 .get_template(BASE_TEMPLATE_NAME)
222 .get_template(template)
186223 .map_err(|e| TemplateError::Render(e.to_string()))?;
187224 tmpl.render(context! {
188225 site => site,
@@ -197,6 +234,76 @@ impl Templater {
197234 }
198235}
199236
237/// The starter listing template written by `org-ssg init`: a blog index, showing how a
238/// collection's `pages` are iterated.
239pub const STARTER_LIST_TEMPLATE: &str = r#"<!DOCTYPE html>
240<html lang="{{ site.language }}">
241<head>
242<meta charset="utf-8">
243<meta name="viewport" content="width=device-width, initial-scale=1">
244<title>{{ page.title }} &middot; {{ site.title }}</title>
245{%- if stylesheet %}
246<link rel="stylesheet" href="{{ stylesheet }}">
247{%- endif %}
248</head>
249<body>
250<header>
251<a class="site-title" href="{{ root }}index.html">{{ site.title }}</a>
252{%- if nav %}
253<nav>
254{%- for item in nav %}
255<a href="{{ item.url }}">{{ item.title }}</a>
256{%- endfor %}
257</nav>
258{%- endif %}
259</header>
260<main>
261<h1>{{ page.title }}</h1>
262<ul class="post-list">
263{%- for post in pages %}
264<li>
265{%- if post.date_iso %}<time datetime="{{ post.date_iso }}">{{ post.date_iso }}</time> {% endif %}
266<a href="{{ root }}{{ post.url }}">{{ post.title }}</a>
267</li>
268{%- endfor %}
269</ul>
270</main>
271</body>
272</html>
273"#;
274
275/// HTML-escape template output, escaping the same characters Jinja2 does.
276///
277/// minijinja additionally escapes `/` as `&#x2f;`, which is a defence for values
278/// interpolated into JavaScript. It is correct but, since `<` is escaped anyway, it buys
279/// nothing in an HTML document — and it makes every generated URL read
280/// `..&#x2f;index.html`. Templates emit a lot of URLs, so that is most of the output.
281///
282/// Auto-escaping itself stays on: page titles come from `#+TITLE:` and are user content.
283fn html_formatter(
284 out: &mut minijinja::Output,
285 state: &minijinja::State,
286 value: &minijinja::Value,
287) -> Result<(), minijinja::Error> {
288 if state.auto_escape() == minijinja::AutoEscape::Html && !value.is_safe() {
289 if let Some(text) = value.as_str() {
290 let mut escaped = String::with_capacity(text.len());
291 for c in text.chars() {
292 match c {
293 '&' => escaped.push_str("&amp;"),
294 '<' => escaped.push_str("&lt;"),
295 '>' => escaped.push_str("&gt;"),
296 '"' => escaped.push_str("&quot;"),
297 '\'' => escaped.push_str("&#x27;"),
298 _ => escaped.push(c),
299 }
300 }
301 return out.write_str(&escaped).map_err(minijinja::Error::from);
302 }
303 }
304 minijinja::escape_formatter(out, state, value)
305}
306
200307/// minijinja's `Display` gives only the top-level message; the useful part (which
201308/// template, which line) is in the source and cause chain.
202309fn render_error_detail(error: minijinja::Error) -> String {
tests/config.rs +369
@@ -443,3 +443,372 @@ fn dot_directories_and_build_inputs_are_never_published() {
443443 "genuine assets still copy through"
444444 );
445445}
446
447// ---------------------------------------------------------------------------
448// Generated listing pages
449// ---------------------------------------------------------------------------
450
451/// A site with dated posts, a listing template, and a collection configured over them.
452fn write_blog(src: &Utf8PathBuf, extra_config: &str) {
453 std::fs::create_dir_all(src.join("blog")).unwrap();
454 std::fs::create_dir_all(src.join("templates")).unwrap();
455 std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nWelcome.\n").unwrap();
456 for (name, title, date) in [
457 ("old", "Older Post", "<2024-01-02 Tue>"),
458 ("new", "Newer Post", "[2025-06-30 Mon 09:15:00]"),
459 ("mid", "Middle Post", "2024-08-05"),
460 ] {
461 std::fs::write(
462 src.join(format!("blog/{name}.org")),
463 format!("#+TITLE: {title}\n#+DATE: {date}\n\nBody.\n"),
464 )
465 .unwrap();
466 }
467 std::fs::write(
468 src.join("templates/list.html"),
469 "<html><body><h1>{{ page.title }}</h1><ul>\
470 {% for p in pages %}<li>{{ p.date_iso }}|{{ p.title }}|{{ root }}{{ p.url }}</li>\
471 {% endfor %}</ul></body></html>",
472 )
473 .unwrap();
474 std::fs::write(
475 src.join("org-ssg.toml"),
476 format!(
477 "[[collections]]\nsource = \"blog\"\noutput = \"blog/index.html\"\n\
478 template = \"list.html\"\ntitle = \"Blog\"\n{extra_config}"
479 ),
480 )
481 .unwrap();
482}
483
484/// The whole point: an output file with no source `.org` behind it.
485#[test]
486fn a_collection_generates_a_listing_page_sorted_newest_first() {
487 let root = tmpdir("listing");
488 let src = root.join("src");
489 std::fs::create_dir_all(&src).unwrap();
490 write_blog(&src, "");
491 let out = root.join("out");
492 let report = build(&src, &out);
493
494 assert!(
495 report.pages.contains(&Utf8PathBuf::from("blog/index.html")),
496 "the listing page is part of the build: {:?}",
497 report.pages
498 );
499
500 let listing = page(&out, "blog/index.html");
501 let order: Vec<&str> = ["Newer Post", "Middle Post", "Older Post"]
502 .into_iter()
503 .filter(|t| listing.contains(t))
504 .collect();
505 assert_eq!(
506 order,
507 vec!["Newer Post", "Middle Post", "Older Post"],
508 "all three posts appear:\n{listing}"
509 );
510 let pos = |t: &str| listing.find(t).unwrap();
511 assert!(
512 pos("Newer Post") < pos("Middle Post") && pos("Middle Post") < pos("Older Post"),
513 "newest first by default:\n{listing}"
514 );
515 assert!(!listing.contains("Home"), "only the collection's pages are listed");
516}
517
518/// Org dates arrive as `[2025-06-30 Mon 09:15:00]`, `<2024-01-02 Tue>` or bare
519/// `2024-08-05`. A listing needs one key it can sort and print.
520#[test]
521fn dates_are_normalized_from_every_org_shape() {
522 let root = tmpdir("dates");
523 let src = root.join("src");
524 std::fs::create_dir_all(&src).unwrap();
525 write_blog(&src, "");
526 let out = root.join("out");
527 build(&src, &out);
528
529 let listing = page(&out, "blog/index.html");
530 for iso in ["2025-06-30", "2024-08-05", "2024-01-02"] {
531 assert!(listing.contains(iso), "{iso} normalized out of its org syntax:\n{listing}");
532 }
533}
534
535#[test]
536fn sort_and_order_are_configurable() {
537 let root = tmpdir("sortorder");
538 let src = root.join("src");
539 std::fs::create_dir_all(&src).unwrap();
540 write_blog(&src, "sort = \"title\"\norder = \"asc\"\n");
541 let out = root.join("out");
542 build(&src, &out);
543
544 let listing = page(&out, "blog/index.html");
545 let pos = |t: &str| listing.find(t).unwrap();
546 assert!(
547 pos("Middle Post") < pos("Newer Post") && pos("Newer Post") < pos("Older Post"),
548 "ascending by title:\n{listing}"
549 );
550}
551
552/// A dateless draft leading a dated archive is almost never what anyone wants.
553#[test]
554fn undated_pages_sort_last_whichever_direction() {
555 let root = tmpdir("undated");
556 let src = root.join("src");
557 std::fs::create_dir_all(&src).unwrap();
558 write_blog(&src, "");
559 std::fs::write(src.join("blog/draft.org"), "#+TITLE: No Date Here\n\nBody.\n").unwrap();
560 let out = root.join("out");
561 build(&src, &out);
562
563 let listing = page(&out, "blog/index.html");
564 let undated = listing.find("No Date Here").unwrap();
565 for dated in ["Newer Post", "Middle Post", "Older Post"] {
566 assert!(
567 listing.find(dated).unwrap() < undated,
568 "{dated} must precede the undated draft:\n{listing}"
569 );
570 }
571}
572
573/// A listing page is exactly what a section's nav entry should point at — `/blog/`
574/// rather than any one post.
575#[test]
576fn a_collection_can_join_the_nav() {
577 let root = tmpdir("listnav");
578 let src = root.join("src");
579 std::fs::create_dir_all(&src).unwrap();
580 write_blog(&src, "nav = true\n");
581 let out = root.join("out");
582 build(&src, &out);
583
584 let home_nav = nav_of(&page(&out, "index.html"));
585 assert!(
586 home_nav.contains("blog/index.html"),
587 "the listing page is in the nav:\n{home_nav}"
588 );
589 // And the URL has to be right from a nested page too.
590 let post = page(&out, "blog/new.html");
591 assert!(
592 nav_of(&post).contains("href=\"index.html\"") || nav_of(&post).contains("blog/index.html"),
593 "the nav link resolves from a nested page:\n{}",
594 nav_of(&post)
595 );
596}
597
598/// A listing page depends on every page it lists — and on nothing else. Adding a post
599/// must re-render the index without re-rendering the rest of the site.
600#[test]
601fn adding_a_post_rebuilds_only_the_listing_and_the_post() {
602 let root = tmpdir("listinc");
603 let src = root.join("src");
604 std::fs::create_dir_all(&src).unwrap();
605 write_blog(&src, "");
606 let out = root.join("out");
607
608 build(&src, &out);
609 let second = build(&src, &out);
610 assert!(
611 second.rendered.is_empty(),
612 "an unchanged rebuild renders nothing, including the listing: {:?}",
613 second.rendered
614 );
615
616 std::fs::write(
617 src.join("blog/fresh.org"),
618 "#+TITLE: Fresh Post\n#+DATE: 2026-01-01\n\nBody.\n",
619 )
620 .unwrap();
621 let report = build(&src, &out);
622
623 let mut rendered = report.rendered.clone();
624 rendered.sort();
625 assert_eq!(
626 rendered,
627 vec![
628 Utf8PathBuf::from("blog/fresh.html"),
629 Utf8PathBuf::from("blog/index.html")
630 ],
631 "exactly the new post and the listing it belongs to"
632 );
633 assert!(
634 page(&out, "blog/index.html").contains("Fresh Post"),
635 "and the listing actually picked it up"
636 );
637}
638
639/// Editing a post's body changes no listing metadata, so the index must not churn.
640#[test]
641fn editing_a_post_body_does_not_rebuild_the_listing() {
642 let root = tmpdir("listbody");
643 let src = root.join("src");
644 std::fs::create_dir_all(&src).unwrap();
645 write_blog(&src, "");
646 let out = root.join("out");
647 build(&src, &out);
648
649 std::fs::write(
650 src.join("blog/mid.org"),
651 "#+TITLE: Middle Post\n#+DATE: 2024-08-05\n\nEdited body.\n",
652 )
653 .unwrap();
654 let report = build(&src, &out);
655
656 assert_eq!(
657 report.rendered,
658 vec![Utf8PathBuf::from("blog/mid.html")],
659 "only the post itself; the listing shows unchanged metadata"
660 );
661}
662
663/// Retitling a post *does* change the listing, since the title is what it displays.
664#[test]
665fn retitling_a_post_rebuilds_the_listing() {
666 let root = tmpdir("listtitle");
667 let src = root.join("src");
668 std::fs::create_dir_all(&src).unwrap();
669 write_blog(&src, "");
670 let out = root.join("out");
671 build(&src, &out);
672
673 std::fs::write(
674 src.join("blog/mid.org"),
675 "#+TITLE: Renamed Post\n#+DATE: 2024-08-05\n\nBody.\n",
676 )
677 .unwrap();
678 let report = build(&src, &out);
679
680 assert!(
681 report.rendered.contains(&Utf8PathBuf::from("blog/index.html")),
682 "the listing must follow a title change: {:?}",
683 report.rendered
684 );
685 assert!(page(&out, "blog/index.html").contains("Renamed Post"));
686}
687
688/// A feed is a listing page with an XML template, not a separate feature — which is why
689/// templates are loaded by full filename and any extension.
690#[test]
691fn a_feed_is_just_a_listing_page_with_an_xml_template() {
692 let root = tmpdir("feed");
693 let src = root.join("src");
694 std::fs::create_dir_all(&src).unwrap();
695 write_blog(&src, "");
696 std::fs::write(
697 src.join("templates/feed.xml"),
698 "<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>{{ site.title }}</title>\
699 {% for p in pages %}<item><title>{{ p.title }}</title>\
700 <pubDate>{{ p.date_iso }}</pubDate></item>{% endfor %}</channel></rss>",
701 )
702 .unwrap();
703 let mut config = std::fs::read_to_string(src.join("org-ssg.toml")).unwrap();
704 config.push_str(
705 "\n[[collections]]\nsource = \"blog\"\noutput = \"feed.xml\"\n\
706 template = \"feed.xml\"\ntitle = \"Feed\"\n",
707 );
708 std::fs::write(src.join("org-ssg.toml"), config).unwrap();
709 let out = root.join("out");
710 build(&src, &out);
711
712 let feed = page(&out, "feed.xml");
713 assert!(feed.starts_with("<?xml"), "an XML document, not HTML:\n{feed}");
714 assert!(feed.contains("<pubDate>2025-06-30</pubDate>"), "entries carry dates:\n{feed}");
715}
716
717/// A listing template can inherit the site layout instead of duplicating it.
718#[test]
719fn a_listing_template_can_extend_the_base_layout() {
720 let root = tmpdir("listextends");
721 let src = root.join("src");
722 std::fs::create_dir_all(&src).unwrap();
723 write_blog(&src, "");
724 std::fs::write(
725 src.join("templates/base.html"),
726 "<html><body class=\"shared\">{% block main %}{{ body | safe }}{% endblock %}</body></html>",
727 )
728 .unwrap();
729 std::fs::write(
730 src.join("templates/list.html"),
731 "{% extends \"base.html\" %}{% block main %}<ul>\
732 {% for p in pages %}<li>{{ p.title }}</li>{% endfor %}</ul>{% endblock %}",
733 )
734 .unwrap();
735 let out = root.join("out");
736 build(&src, &out);
737
738 let listing = page(&out, "blog/index.html");
739 assert!(listing.contains("class=\"shared\""), "inherits the layout:\n{listing}");
740 assert!(listing.contains("Newer Post"), "and adds its own content:\n{listing}");
741}
742
743/// URLs are most of a template's output. Escaping `/` as `&#x2f;` is valid but makes
744/// every link unreadable; escaping user content is not optional.
745#[test]
746fn urls_stay_readable_while_user_content_is_still_escaped() {
747 let root = tmpdir("escaping");
748 let src = root.join("src");
749 std::fs::create_dir_all(&src).unwrap();
750 write_blog(&src, "");
751 std::fs::write(
752 src.join("blog/evil.org"),
753 "#+TITLE: <script>alert(1)</script>\n#+DATE: 2026-02-02\n\nBody.\n",
754 )
755 .unwrap();
756 let out = root.join("out");
757 build(&src, &out);
758
759 let listing = page(&out, "blog/index.html");
760 assert!(listing.contains("../blog/new.html"), "URLs read as URLs:\n{listing}");
761 assert!(!listing.contains("&#x2f;"), "no escaped slashes:\n{listing}");
762 assert!(
763 listing.contains("&lt;script&gt;"),
764 "a title is user content and stays escaped:\n{listing}"
765 );
766 assert!(!listing.contains("<script>"), "never unescaped:\n{listing}");
767}
768
769/// Two generated pages writing the same file, or a listing writing over a real page,
770/// silently loses one of them.
771#[test]
772fn colliding_collection_outputs_are_rejected() {
773 let root = tmpdir("listcollide");
774 let src = root.join("src");
775 std::fs::create_dir_all(&src).unwrap();
776 write_blog(&src, "");
777
778 let mut config = std::fs::read_to_string(src.join("org-ssg.toml")).unwrap();
779 config.push_str("\n[[collections]]\nsource = \"\"\noutput = \"blog/index.html\"\n");
780 std::fs::write(src.join("org-ssg.toml"), &config).unwrap();
781 let err = build_site(&src, &root.join("out"), &BuildOptions::default())
782 .expect_err("two collections writing one file must fail");
783 assert!(format!("{err:#}").contains("blog/index.html"), "{err:#}");
784
785 // And a listing that would overwrite a real page.
786 std::fs::write(
787 src.join("org-ssg.toml"),
788 "[[collections]]\nsource = \"blog\"\noutput = \"index.html\"\ntemplate = \"list.html\"\n",
789 )
790 .unwrap();
791 let err = build_site(&src, &root.join("out2"), &BuildOptions::default())
792 .expect_err("a listing over a real page must fail");
793 assert!(format!("{err:#}").contains("index.org"), "names the page it would replace: {err:#}");
794}
795
796/// A missing template is a typo; listing what exists turns it into a one-second fix.
797#[test]
798fn a_missing_collection_template_names_the_ones_that_exist() {
799 let root = tmpdir("listnotpl");
800 let src = root.join("src");
801 std::fs::create_dir_all(&src).unwrap();
802 write_blog(&src, "");
803 std::fs::write(
804 src.join("org-ssg.toml"),
805 "[[collections]]\nsource = \"blog\"\noutput = \"blog/index.html\"\ntemplate = \"nope.html\"\n",
806 )
807 .unwrap();
808
809 let err = build_site(&src, &root.join("out"), &BuildOptions::default())
810 .expect_err("missing template must fail");
811 let message = format!("{err:#}");
812 assert!(message.contains("nope.html"), "names the missing one: {message}");
813 assert!(message.contains("list.html"), "lists what is available: {message}");
814}