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

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.
 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(-)

diff --git a/Cargo.lock b/Cargo.lock
index ea41342..89b31bc 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -569,7 +569,7 @@ dependencies = [
 
 [[package]]
 name = "org-ssg"
-version = "0.6.0"
+version = "0.7.0"
 dependencies = [
  "anyhow",
  "blake3",
diff --git a/Cargo.toml b/Cargo.toml
index 64060b0..1969193 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"
diff --git a/README.md b/README.md
index 46d3ccf..95b1124 100644
--- a/README.md
+++ b/README.md
@@ -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)
diff --git a/src/config.rs b/src/config.rs
index dfd8943..b97e9ba 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -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
 "#;
diff --git a/src/incremental.rs b/src/incremental.rs
index 8159572..c4e6341 100644
--- a/src/incremental.rs
+++ b/src/incremental.rs
@@ -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());
diff --git a/src/main.rs b/src/main.rs
index aa35de0..dfd119d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -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(),
     };
diff --git a/src/site.rs b/src/site.rs
index 196e0b2..bf47934 100644
--- a/src/site.rs
+++ b/src/site.rs
@@ -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()
diff --git a/src/template.rs b/src/template.rs
index e87ac1d..18f2fb8 100644
--- a/src/template.rs
+++ b/src/template.rs
@@ -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 }} &middot; {{ 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 `&#x2f;`, 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
+/// `..&#x2f;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("&amp;"),
+                    '<' => escaped.push_str("&lt;"),
+                    '>' => escaped.push_str("&gt;"),
+                    '"' => escaped.push_str("&quot;"),
+                    '\'' => escaped.push_str("&#x27;"),
+                    _ => 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 {
diff --git a/tests/config.rs b/tests/config.rs
index 703942d..fb189e3 100644
--- a/tests/config.rs
+++ b/tests/config.rs
@@ -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 `&#x2f;` 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("&#x2f;"), "no escaped slashes:\n{listing}");
+    assert!(
+        listing.contains("&lt;script&gt;"),
+        "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}");
+}