krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
ec5b3807fe43db7771aa3c13621470d7e17936e3
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T05:21:36Z
Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 47 ++++++++++- src/config.rs | 76 +++++++++++++++-- src/main.rs | 11 ++- src/site.rs | 190 ++++++++++++++++++++++++++++++++++++------- src/template.rs | 150 +++++++++++++++++++++++++--------- tests/config.rs | 246 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 645 insertions(+), 79 deletions(-) @@ -569,7 +569,7 @@ dependencies = [ [[package]] name = "org-ssg" -version = "0.7.0" +version = "0.8.0" dependencies = [ "anyhow", "blake3", @@ -1,6 +1,6 @@ [package] name = "org-ssg" -version = "0.7.0" +version = "0.8.0" edition = "2021" description = "Org-mode static site generator that renders the org element tree straight to HTML" license = "MIT" @@ -120,6 +120,47 @@ written in — `[2025-09-05 Fri 10:21:00]`, `<2024-05-01 Wed>` or bare `2024-05- also the sort key; pages without a parseable date sort last, so an undated draft never leads a dated archive. +#### Tag pages + +Add `group_by` and the collection emits one page *per group* instead of one page total, +plus an optional index of the groups: + +```toml +[[collections]] +source = "blog" +group_by = "tags" # "tags", or any #+KEYWORD: name to group by its value +output = "tags/{tag}.html" # {tag} is replaced by each group's slug +template = "tag.html" +title = "Tagged: {tag}" +index_output = "tags/index.html" # the tag index +index_template = "tags.html" +index_title = "Tags" +nav = true # adds the *index*, not every tag +``` + +A group page receives its own posts as `pages` and itself as `group` +(`.name`, `.slug`, `.url`, `.count`). The index receives `groups` — every group, sorted +by name: + +```jinja +<ul>{% for tag in groups %} + <li><a href="{{ root }}{{ tag.url }}">{{ tag.name }}</a> ({{ tag.count }})</li> +{% endfor %}</ul> +``` + +`group_by = "tags"` is multi-valued: a post appears under every tag it carries. Any other +value names a single-valued `#+KEYWORD:`, so `group_by = "category"` buckets by +`#+CATEGORY:`. + +Two tags that would produce the same URL (`web_dev` and `web@dev` both slugify to +`web-dev`) are a build error rather than one page silently overwriting the other. + +A tag page depends on its own posts and nothing else, so adding a post tagged `rust` +re-renders that post, its section index, `tags/rust.html`, and the tag index whose counts +changed — four pages, not one per tag. That precision is why `groups` is given to the +index and not to every group page: a page that can see every group depends on every +group. + **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. @@ -196,6 +237,7 @@ all-of-org. Phase 0 checked this line against a real 179-file corpus and found i | **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** | +| **10** | **Grouped collections: one page per tag plus a tag index — full parity with the incumbent** | **done** | ### v0.2 in / out @@ -302,7 +344,8 @@ Emacs does. The audit runs against any corpus — point it at your own notes before trusting this tool with them. The numbers below come from a 179-file site published today by weblorg, a wrapper around org's own HTML exporter, which makes it both a realistic workload and a -directly comparable incumbent. +directly comparable incumbent. With collections configured, org-ssg now reproduces +**all 182 of that site's URLs**. ``` cargo run -- audit <src-dir> # what does this corpus use, and is it in scope? @@ -458,7 +501,7 @@ PARSE/RESOLVE/RENDER), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserro ``` cargo build -cargo test # 99 tests +cargo test # 107 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) @@ -53,6 +53,21 @@ pub struct Collection { pub template: String, /// Title for the generated page, available to the template as `page.title`. pub title: String, + /// Split the collection into groups and emit one page per group. + /// + /// `"tags"` groups by `#+FILETAGS:`, where a page belongs to every tag it carries. + /// Any other value names a `#+KEYWORD:` and groups by its value, so `"category"` + /// buckets pages by `#+CATEGORY:`. Empty means one page for the whole collection. + /// + /// When set, `output` and `title` may contain `{tag}`, replaced by the group — and + /// `output` must, or every group would write to the same file. + pub group_by: String, + /// Where to write a page listing the groups themselves — a tag index. Empty means + /// no such page. Only meaningful with `group_by`. + pub index_output: Utf8PathBuf, + /// Template for the group-index page. It receives `groups` rather than `pages`. + pub index_template: String, + pub index_title: String, pub sort: SortKey, pub order: SortOrder, /// Add this listing page to the site navigation. This is how a section landing page @@ -67,6 +82,10 @@ impl Default for Collection { output: Utf8PathBuf::from("index.html"), template: "list.html".to_string(), title: "Index".to_string(), + group_by: String::new(), + index_output: Utf8PathBuf::new(), + index_template: "tags.html".to_string(), + index_title: "Tags".to_string(), sort: SortKey::default(), order: SortOrder::default(), nav: false, @@ -74,6 +93,9 @@ impl Default for Collection { } } +/// The `{tag}` placeholder in a grouped collection's `output` and `title`. +pub const GROUP_PLACEHOLDER: &str = "{tag}"; + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum SortKey { @@ -251,16 +273,47 @@ impl Config { } 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"); + let grouped = !collection.group_by.is_empty(); + if collection.output.as_str().is_empty() && collection.index_output.as_str().is_empty() + { + anyhow::bail!("a collection has no `output`; it needs a file to write"); + } + if grouped + && !collection.output.as_str().is_empty() + && !collection.output.as_str().contains(GROUP_PLACEHOLDER) + { + anyhow::bail!( + "collection output {} groups by \"{}\" but has no {GROUP_PLACEHOLDER} in \ + its path, so every group would overwrite the same file", + collection.output, + collection.group_by + ); } - if seen.contains(&&collection.output) { + if !grouped && collection.output.as_str().contains(GROUP_PLACEHOLDER) { anyhow::bail!( - "two collections both write to {}; give them different `output` paths", + "collection output {} uses {GROUP_PLACEHOLDER} but sets no `group_by`", collection.output ); } - seen.push(&collection.output); + if !grouped && !collection.index_output.as_str().is_empty() { + anyhow::bail!( + "collection writes an `index_output` of {} but sets no `group_by`; \ + there are no groups to index", + collection.index_output + ); + } + for path in [&collection.output, &collection.index_output] { + if path.as_str().is_empty() || path.as_str().contains(GROUP_PLACEHOLDER) { + continue; + } + if seen.contains(&path) { + anyhow::bail!( + "two collections both write to {path}; give them different \ + `output` paths" + ); + } + seen.push(path); + } } if !self.site.base_url.is_empty() && self.site.base_url.ends_with('/') { anyhow::bail!( @@ -323,4 +376,17 @@ title = "Blog" sort = "date" # date | title | path order = "desc" # desc | asc nav = true # put this listing page in the site nav + +# One page per tag, plus an index of all tags. `{tag}` in `output`/`title` is replaced +# by each tag; the index gets `groups` instead of `pages`. +[[collections]] +source = "blog" +group_by = "tags" # "tags", or any #+KEYWORD: name to group by its value +output = "tags/{tag}.html" +template = "list.html" +title = "Tagged: {tag}" +index_output = "tags/index.html" +index_template = "tags.html" +index_title = "Tags" +nav = true # adds the tag *index*, not every tag "#; @@ -11,7 +11,7 @@ use org_ssg::config::Config; use org_ssg::render::{self, render, Html, SyntectHighlighter}; use org_ssg::resolve::ResolvedDoc; use org_ssg::site::{build_site, BuildOptions, SYNTAX_STYLESHEET}; -use org_ssg::template::{PageContext, SiteContext, Templater}; +use org_ssg::template::{PageContext, RenderContext, SiteContext, Templater}; #[derive(Parser)] #[command(name = "org-ssg", version, about = "Org-mode static site generator")] @@ -132,7 +132,7 @@ 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, STARTER_LIST_TEMPLATE}; + use org_ssg::template::{starter_template, STARTER_LIST_TEMPLATE, STARTER_TAGS_TEMPLATE}; fs::create_dir_all(dir).with_context(|| format!("creating {dir}"))?; fs::create_dir_all(dir.join("templates")).with_context(|| format!("creating {dir}/templates"))?; @@ -165,10 +165,11 @@ fn init(dir: &Utf8Path) -> Result<()> { "in org-ssg.toml, newest first.\n", ); - let files: [(Utf8PathBuf, &str); 5] = [ + let files: [(Utf8PathBuf, &str); 6] = [ (dir.join(CONFIG_FILE), STARTER_CONFIG), (dir.join("templates/base.html"), starter_template()), (dir.join("templates/list.html"), STARTER_LIST_TEMPLATE), + (dir.join("templates/tags.html"), STARTER_TAGS_TEMPLATE), (dir.join("index.org"), index), (dir.join("blog/first-post.org"), post), ]; @@ -295,8 +296,10 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> { tags: Vec::new(), keywords: Default::default(), }; + let mut ctx = RenderContext::new(&site, &page_ctx, &[], SYNTAX_STYLESHEET, ""); + ctx.body = &fragment; let page = templater - .render_page(&site, &page_ctx, &fragment, &[], SYNTAX_STYLESHEET, "", None) + .render_page(&ctx) .with_context(|| format!("templating {input}"))?; fs::write(output, page).with_context(|| format!("writing output file {output}"))?; @@ -9,7 +9,7 @@ //! `--no-cache` forces a full rebuild; the cache is never a correctness dependency, so a //! full rebuild and an incremental rebuild produce byte-identical output. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::fs; use anyhow::{Context, Result}; @@ -28,8 +28,8 @@ use crate::parser::parse; use crate::render::{self, render_with, Html, RenderOptions, SyntectHighlighter}; use crate::resolve::resolve; use crate::config::{self, Config, NavMode, SortKey, SortOrder}; -use crate::template::{NavItem, PageContext, SiteContext, Templater}; -use crate::util::{output_path, output_url, relative_root}; +use crate::template::{GroupContext, NavItem, PageContext, RenderContext, SiteContext, Templater}; +use crate::util::{output_path, output_url, relative_root, slugify}; /// A fully built page: source and output paths (relative to their roots) and its /// final templated HTML. @@ -105,13 +105,18 @@ struct PagePrep { context: PageContext, } -/// A generated listing page, resolved against the pages it lists. +/// A generated page, resolved against the pages it lists. struct Listing { output: Utf8PathBuf, template: String, title: String, - /// The pages it lists, already sorted. + /// The pages it lists, already sorted. Empty for a group index, which lists groups. entries: Vec<PageContext>, + /// The group this page is for, when it belongs to a grouped collection. + group: Option<GroupContext>, + /// Every group of the owning collection. The content of a group index, and context + /// for a group page. + groups: Vec<GroupContext>, } /// The `YYYY-MM-DD` inside an org date, if there is one. Org dates arrive as @@ -167,15 +172,102 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> { entries.reverse(); } - listings.push(Listing { - output: collection.output.clone(), - template: collection.template.clone(), - title: collection.title.clone(), - entries, - }); + if collection.group_by.is_empty() { + listings.push(Listing { + output: collection.output.clone(), + template: collection.template.clone(), + title: collection.title.clone(), + entries, + group: None, + groups: Vec::new(), + }); + continue; + } + + // Grouped: one page per distinct term. `entries` is already sorted, and grouping + // preserves that order within each group. + let mut terms: Vec<String> = Vec::new(); + let mut members: HashMap<String, Vec<PageContext>> = HashMap::new(); + for entry in &entries { + for term in group_terms(entry, &collection.group_by) { + if !members.contains_key(&term) { + terms.push(term.clone()); + } + members.entry(term).or_default().push(entry.clone()); + } + } + // Terms are discovered in page order, which is arbitrary from a reader's point of + // view; sort so a tag index reads alphabetically and hashes deterministically. + terms.sort(); + + let mut groups: Vec<GroupContext> = Vec::new(); + let mut slugs: HashMap<String, String> = HashMap::new(); + for term in &terms { + let slug = slugify(term); + if slug.is_empty() { + anyhow::bail!( + "the {} value {term:?} has no URL-safe form; it cannot name a page", + collection.group_by + ); + } + // `C++` and `C ++` both slugify to `c`, and one would silently overwrite the + // other's page. + if let Some(other) = slugs.insert(slug.clone(), term.clone()) { + anyhow::bail!( + "the {} values {other:?} and {term:?} both become {slug:?} in a URL; \ + rename one so their pages do not collide", + collection.group_by + ); + } + groups.push(GroupContext { + name: term.clone(), + slug: slug.clone(), + url: if collection.output.as_str().is_empty() { + String::new() + } else { + collection + .output + .as_str() + .replace(config::GROUP_PLACEHOLDER, &slug) + } + .to_string(), + count: members.get(term).map(Vec::len).unwrap_or(0), + }); + } + + if !collection.output.as_str().is_empty() { + for group in &groups { + listings.push(Listing { + output: Utf8PathBuf::from(&group.url), + template: collection.template.clone(), + title: collection + .title + .replace(config::GROUP_PLACEHOLDER, &group.name), + entries: members.get(&group.name).cloned().unwrap_or_default(), + group: Some(group.clone()), + // Deliberately not the whole group list. A page that can see every + // group depends on every group, so one new post would re-render every + // tag page — cost that scales with tag count, to support a tag cloud + // nobody has asked for. A tag page depends on its own posts, and the + // group index is where the group list belongs. + groups: Vec::new(), + }); + } + } + if !collection.index_output.as_str().is_empty() { + listings.push(Listing { + output: collection.index_output.clone(), + template: collection.index_template.clone(), + title: collection.index_title.clone(), + entries: Vec::new(), + group: None, + groups: groups.clone(), + }); + } } - // A listing page writing over a real page would silently replace it. + // A generated page writing over a real page would silently replace it. Group pages + // make this easy to hit by accident, since their paths come from content. for listing in &listings { if let Some(clash) = preps.iter().find(|p| p.output == listing.output) { anyhow::bail!( @@ -185,9 +277,41 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> { ); } } + let mut claimed: HashMap<&Utf8PathBuf, ()> = HashMap::new(); + for listing in &listings { + if claimed.insert(&listing.output, ()).is_some() { + anyhow::bail!("two generated pages both write to {}", listing.output); + } + } Ok(listings) } +/// The `(output, title)` a collection contributes to the nav. A grouped collection +/// offers its index; an ungrouped one offers its single page. +fn nav_target(collection: &config::Collection) -> (Utf8PathBuf, String) { + if !collection.group_by.is_empty() { + return ( + collection.index_output.clone(), + collection.index_title.clone(), + ); + } + (collection.output.clone(), collection.title.clone()) +} + +/// The group terms a page belongs to. `tags` is multi-valued — a page appears under +/// every tag it carries — while any other key names a single-valued `#+KEYWORD:`. +fn group_terms(page: &PageContext, group_by: &str) -> Vec<String> { + if group_by.eq_ignore_ascii_case("tags") { + return page.tags.clone(); + } + page.keywords + .get(&group_by.to_lowercase()) + .map(|v| v.trim()) + .filter(|v| !v.is_empty()) + .map(|v| vec![v.to_string()]) + .unwrap_or_default() +} + /// 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 { @@ -203,6 +327,14 @@ fn listing_entries_hash(listing: &Listing) -> Hash { ), ] }) + // A group index has no entries at all — its content *is* the group list, so the + // groups have to be in the hash or a tag index would never notice a new tag. + .chain( + listing + .groups + .iter() + .map(|g| (g.url.clone(), format!("{}\u{0}{}", g.name, g.count))), + ) .chain([(listing.title.clone(), listing.template.clone())]) .collect(); // Entry *order* is meaningful in a listing, so this hashes the sorted-by-us sequence @@ -354,9 +486,11 @@ fn prepare_pages( .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())); + // rather than any one post — so collections can opt into the nav directly. For a + // grouped collection that means its *index*: a nav listing every tag is the same + // mistake as a nav listing every page. + for (output, title) in config.collections.iter().filter(|c| c.nav).map(nav_target) { + entries.push((output, title)); } // RESOLVE reads the shared symbol table and writes only into its own page's output, @@ -466,8 +600,11 @@ fn render_page( // Relative to the *output* path, since `#+SLUG:` can move a page between depths. let root = relative_root(&p.output); let stylesheet = format!("{root}{SYNTAX_STYLESHEET}"); + let mut ctx = RenderContext::new(site, &p.context, &p.nav, &stylesheet, &root); + ctx.body = &fragment; + ctx.pages = pages; templater - .render_page(site, &p.context, &fragment, &p.nav, &stylesheet, &root, pages) + .render_page(&ctx) .with_context(|| format!("templating {}", p.source)) } @@ -529,7 +666,8 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result cfg.collections .iter() .filter(|c| c.nav) - .map(|c| (c.output.to_string(), c.title.clone())), + .map(nav_target) + .map(|(out, title)| (out.to_string(), title)), ) .collect() }; @@ -659,17 +797,15 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?; } let root = relative_root(&listing.output); + let stylesheet = format!("{root}{SYNTAX_STYLESHEET}"); + let nav = listing_nav(&preps, &listing.output); + let page_ctx = listing_context(listing); + let mut ctx = RenderContext::new(&site, &page_ctx, &nav, &stylesheet, &root); + ctx.pages = Some(&listing.entries); + ctx.group = listing.group.as_ref(); + ctx.groups = &listing.groups; let html = templater - .render_named( - &listing.template, - &site, - &listing_context(listing), - "", - &listing_nav(&preps, &listing.output), - &format!("{root}{SYNTAX_STYLESHEET}"), - &root, - Some(&listing.entries), - ) + .render(&listing.template, &ctx) .with_context(|| { format!( "rendering collection {} with template {} (available: {})", @@ -184,56 +184,128 @@ impl Templater { 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. - #[allow(clippy::too_many_arguments)] - pub fn render_page( - &self, - site: &SiteContext, - page: &PageContext, - body: &str, - nav: &[NavItem], - 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 + /// Render through a named template. Generated 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> { + pub fn render(&self, template: &str, ctx: &RenderContext) -> Result<String, TemplateError> { let tmpl = self .env .get_template(template) .map_err(|e| TemplateError::Render(e.to_string()))?; tmpl.render(context! { - site => site, - page => page, - body => body, - nav => nav, - stylesheet => stylesheet, - root => root, - pages => pages, + site => ctx.site, + page => ctx.page, + body => ctx.body, + nav => ctx.nav, + stylesheet => ctx.stylesheet, + root => ctx.root, + pages => ctx.pages, + group => ctx.group, + groups => ctx.groups, }) .map_err(|e| TemplateError::Render(render_error_detail(e))) } + + /// Render through the site's base layout. + pub fn render_page(&self, ctx: &RenderContext) -> Result<String, TemplateError> { + self.render(BASE_TEMPLATE_NAME, ctx) + } +} + +/// One group of a grouped collection — a tag, or a `#+CATEGORY:` value. +#[derive(Debug, Clone, Serialize)] +pub struct GroupContext { + /// The term as written, e.g. `Rust Lang`. + pub name: String, + /// URL-safe form used in the output path, e.g. `rust-lang`. + pub slug: String, + /// Output path of this group's page, relative to the site root. Empty when the + /// collection emits no per-group pages. + pub url: String, + /// How many pages carry this term. + pub count: usize, +} + +/// Everything a template can see. A struct rather than a dozen positional arguments, +/// because the list grows every time templates learn something new. +pub struct RenderContext<'a> { + pub site: &'a SiteContext, + pub page: &'a PageContext, + /// Rendered page HTML. Empty for generated pages, which build their body from + /// `pages`/`groups` instead. + pub body: &'a str, + pub nav: &'a [NavItem], + /// URL of the syntax stylesheet, relative to this page. + pub stylesheet: &'a str, + /// `../`-prefix back to the site root from this page. + pub root: &'a str, + /// The pages this listing shows, or every page when `expose_page_list` is on. + pub pages: Option<&'a [PageContext]>, + /// The group this page is for, on a grouped collection's per-group page. + pub group: Option<&'a GroupContext>, + /// Every group of a grouped collection — the group index's content. Empty on a + /// per-group page, which depends on its own entries and not on the other groups. + pub groups: &'a [GroupContext], +} + +impl<'a> RenderContext<'a> { + /// A context with only the universally-present parts filled in. + pub fn new( + site: &'a SiteContext, + page: &'a PageContext, + nav: &'a [NavItem], + stylesheet: &'a str, + root: &'a str, + ) -> Self { + RenderContext { + site, + page, + body: "", + nav, + stylesheet, + root, + pages: None, + group: None, + groups: &[], + } + } } +/// The starter tag-index template written by `org-ssg init`: shows how `groups` is +/// iterated, and how a group page is linked. +pub const STARTER_TAGS_TEMPLATE: &str = r#"<!DOCTYPE html> +<html lang="{{ site.language }}"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>{{ page.title }} · {{ site.title }}</title> +{%- if stylesheet %} +<link rel="stylesheet" href="{{ stylesheet }}"> +{%- endif %} +</head> +<body> +<header> +<a class="site-title" href="{{ root }}index.html">{{ site.title }}</a> +{%- if nav %} +<nav> +{%- for item in nav %} +<a href="{{ item.url }}">{{ item.title }}</a> +{%- endfor %} +</nav> +{%- endif %} +</header> +<main> +<h1>{{ page.title }}</h1> +<ul class="tag-list"> +{%- for tag in groups %} +<li><a href="{{ root }}{{ tag.url }}">{{ tag.name }}</a> ({{ tag.count }})</li> +{%- endfor %} +</ul> +</main> +</body> +</html> +"#; + /// 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> @@ -812,3 +812,249 @@ fn a_missing_collection_template_names_the_ones_that_exist() { assert!(message.contains("nope.html"), "names the missing one: {message}"); assert!(message.contains("list.html"), "lists what is available: {message}"); } + +// --------------------------------------------------------------------------- +// Grouped collections: tag pages and the tag index +// --------------------------------------------------------------------------- + +/// Posts carrying tags, a per-tag template, a tag-index template, and a grouped +/// collection over them. +fn write_tagged_blog(src: &Utf8PathBuf, extra: &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, tags) in [ + ("a", "Post A", "2024-01-01", ":rust:web:"), + ("b", "Post B", "2024-02-02", ":rust:"), + ("c", "Post C", "2024-03-03", ":emacs:"), + ("d", "Post D", "2024-04-04", ""), + ] { + let filetags = if tags.is_empty() { + String::new() + } else { + format!("#+FILETAGS: {tags}\n") + }; + std::fs::write( + src.join(format!("blog/{name}.org")), + format!("#+TITLE: {title}\n#+DATE: {date}\n{filetags}\nBody.\n"), + ) + .unwrap(); + } + std::fs::write( + src.join("templates/tag.html"), + "<html><body><h1>{{ page.title }}</h1><p>slug={{ group.slug }} count={{ group.count }}</p>\ + <ul>{% for p in pages %}<li>{{ p.title }}</li>{% endfor %}</ul></body></html>", + ) + .unwrap(); + std::fs::write( + src.join("templates/tags.html"), + "<html><body><h1>{{ page.title }}</h1><ul>\ + {% for g in groups %}<li>{{ g.name }}={{ g.count }}@{{ root }}{{ g.url }}</li>\ + {% endfor %}</ul></body></html>", + ) + .unwrap(); + std::fs::write( + src.join("org-ssg.toml"), + format!( + "[[collections]]\nsource = \"blog\"\ngroup_by = \"tags\"\n\ + output = \"tags/{{tag}}.html\"\ntemplate = \"tag.html\"\ntitle = \"Tagged: {{tag}}\"\n\ + index_output = \"tags/index.html\"\nindex_template = \"tags.html\"\n\ + index_title = \"All tags\"\n{extra}" + ), + ) + .unwrap(); +} + +/// One collection, many outputs — the shape the earlier listing feature could not express. +#[test] +fn a_grouped_collection_emits_one_page_per_tag() { + let root = tmpdir("tags"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_tagged_blog(&src, ""); + let out = root.join("out"); + build(&src, &out); + + for (tag, expected) in [("rust", vec!["Post A", "Post B"]), ("emacs", vec!["Post C"])] { + let html = page(&out, &format!("tags/{tag}.html")); + for title in &expected { + assert!(html.contains(title), "{tag} lists {title}:\n{html}"); + } + assert!( + html.contains(&format!("count={}", expected.len())), + "{tag} knows its own size:\n{html}" + ); + } + assert!( + !out.join("tags/.html").exists(), + "an untagged post creates no empty group" + ); + assert!( + !page(&out, "tags/rust.html").contains("Post C"), + "a tag page lists only its own posts" + ); +} + +/// The index lists the groups themselves, not the pages. +#[test] +fn the_tag_index_lists_every_tag_with_counts() { + let root = tmpdir("tagindex"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_tagged_blog(&src, ""); + let out = root.join("out"); + build(&src, &out); + + let index = page(&out, "tags/index.html"); + assert!(index.contains("All tags"), "uses index_title:\n{index}"); + assert!(index.contains("rust=2@../tags/rust.html"), "counts and links:\n{index}"); + assert!(index.contains("emacs=1@"), "every tag appears:\n{index}"); + assert!(index.contains("web=1@"), "every tag appears:\n{index}"); + // Alphabetical, so the index reads predictably rather than in discovery order. + let pos = |t: &str| index.find(t).unwrap(); + assert!(pos("emacs") < pos("rust") && pos("rust") < pos("web"), "sorted:\n{index}"); +} + +/// A tag page depends on its own posts. Adding a post tagged `rust` must not re-render +/// the `emacs` page — invalidation that scales with tag count would undo the point. +#[test] +fn adding_a_tagged_post_rebuilds_only_the_affected_pages() { + let root = tmpdir("tagsinc"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_tagged_blog(&src, ""); + let out = root.join("out"); + build(&src, &out); + assert!(build(&src, &out).rendered.is_empty(), "unchanged rebuild renders nothing"); + + std::fs::write( + src.join("blog/e.org"), + "#+TITLE: Post E\n#+DATE: 2024-05-05\n#+FILETAGS: :rust:\n\nBody.\n", + ) + .unwrap(); + let report = build(&src, &out); + + let mut rendered = report.rendered.clone(); + rendered.sort(); + assert_eq!( + rendered, + vec![ + Utf8PathBuf::from("blog/e.html"), + Utf8PathBuf::from("tags/index.html"), + Utf8PathBuf::from("tags/rust.html"), + ], + "the post, its tag page, and the index whose counts changed — nothing else" + ); + assert!(page(&out, "tags/rust.html").contains("Post E")); +} + +/// A new tag has to produce a new page and reach the index. +#[test] +fn a_new_tag_creates_its_page_and_joins_the_index() { + let root = tmpdir("newtag"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_tagged_blog(&src, ""); + let out = root.join("out"); + build(&src, &out); + assert!(!out.join("tags/zig.html").exists()); + + std::fs::write( + src.join("blog/f.org"), + "#+TITLE: Post F\n#+DATE: 2024-06-06\n#+FILETAGS: :zig:\n\nBody.\n", + ) + .unwrap(); + build(&src, &out); + + assert!(out.join("tags/zig.html").exists(), "the new tag gets a page"); + assert!( + page(&out, "tags/index.html").contains("zig=1@"), + "and the index knows about it" + ); +} + +/// Grouping by any `#+KEYWORD:`, not just tags — same mechanism, single-valued. +#[test] +fn a_collection_can_group_by_any_keyword() { + let root = tmpdir("groupkw"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_tagged_blog(&src, ""); + std::fs::write( + src.join("blog/a.org"), + "#+TITLE: Post A\n#+DATE: 2024-01-01\n#+CATEGORY: Notes\n\nBody.\n", + ) + .unwrap(); + std::fs::write( + src.join("org-ssg.toml"), + "[[collections]]\nsource = \"blog\"\ngroup_by = \"category\"\n\ + output = \"cat/{tag}.html\"\ntemplate = \"tag.html\"\ntitle = \"{tag}\"\n", + ) + .unwrap(); + let out = root.join("out"); + build(&src, &out); + + assert!(out.join("cat/notes.html").exists(), "grouped by #+CATEGORY:"); + assert!(page(&out, "cat/notes.html").contains("Post A")); +} + +/// A grouped collection puts its *index* in the nav. A nav listing every tag is the same +/// mistake as a nav listing every page. +#[test] +fn a_grouped_collection_contributes_its_index_to_the_nav() { + let root = tmpdir("tagnav"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_tagged_blog(&src, "nav = true\n"); + let out = root.join("out"); + build(&src, &out); + + let nav = nav_of(&page(&out, "index.html")); + assert!(nav.contains("tags/index.html"), "the index is in the nav:\n{nav}"); + assert!(!nav.contains("tags/rust.html"), "individual tags are not:\n{nav}"); +} + +/// An output path with no `{tag}` would have every group overwrite one file — a config +/// that looks reasonable and silently produces one page instead of many. +#[test] +fn grouping_without_a_placeholder_is_rejected() { + let root = tmpdir("noplaceholder"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_tagged_blog(&src, ""); + std::fs::write( + src.join("org-ssg.toml"), + "[[collections]]\nsource = \"blog\"\ngroup_by = \"tags\"\n\ + output = \"tags/all.html\"\ntemplate = \"tag.html\"\n", + ) + .unwrap(); + + let err = build_site(&src, &root.join("out"), &BuildOptions::default()) + .expect_err("grouping without {tag} must fail"); + assert!(format!("{err:#}").contains("{tag}"), "explains what is missing: {err:#}"); +} + +/// Two tags that differ only in punctuation slugify to the same path, and one page would +/// silently overwrite the other. +#[test] +fn tags_that_collide_in_a_url_are_rejected() { + let root = tmpdir("tagcollide"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_tagged_blog(&src, ""); + std::fs::write( + src.join("blog/a.org"), + "#+TITLE: Post A\n#+DATE: 2024-01-01\n#+FILETAGS: :web_dev:\n\nBody.\n", + ) + .unwrap(); + std::fs::write( + src.join("blog/b.org"), + "#+TITLE: Post B\n#+DATE: 2024-02-02\n#+FILETAGS: :web@dev:\n\nBody.\n", + ) + .unwrap(); + + let err = build_site(&src, &root.join("out"), &BuildOptions::default()) + .expect_err("colliding tag slugs must fail"); + let message = format!("{err:#}"); + assert!(message.contains("web_dev") && message.contains("web@dev"), "{message}"); +}