krz/orgo

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

Commit ec5b3807fe

ec5b3807fe43db7771aa3c13621470d7e17936e3

parent: 012e561704

Verified · cmc

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

v0.8: tag pages and tag indexes (grouped collections)

One collection emitting many pages — the shape the listing feature could not express.
Adding `group_by` to a collection splits it into groups and emits a page per group, plus
an optional index of the groups themselves.

    [[collections]]
    source = "blog"
    group_by = "tags"
    output = "tags/{tag}.html"
    index_output = "tags/index.html"

`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:
with no extra machinery. A group page gets its own posts as `pages` and itself as
`group` (name/slug/url/count); the index gets `groups`, sorted by name so it reads
alphabetically rather than in discovery order. A grouped collection contributes its
*index* to the nav — a nav listing every tag is the same mistake as a nav listing every
page.

The interesting decision was what a group page is allowed to see. Passing `groups` to
every tag page would support a tag cloud, but it also makes every tag page depend on
every group, so one new post re-rendered all of them — measured at 6 pages instead of 4
on a three-tag site, scaling with tag count. `groups` now goes to the index only, and a
tag page depends on its own posts. Adding a post tagged `rust` re-renders that post, its
section index, tags/rust.html, and the tag index whose counts changed. Four pages,
whatever the tag count.

Config is validated where a mistake would otherwise be silent: an `output` without
{tag} would have every group overwrite one file; two tags whose slugs collide
(`web_dev` and `web@dev` both become `web-dev`) would have one page overwrite the other;
`index_output` without `group_by` indexes nothing.

RenderContext replaces the positional arguments the template call had grown — nine and
counting — so adding template data no longer means touching every call site.

Milestone: with blog, garden and tag collections configured, org-ssg reproduces all 182
URLs of the weblorg-built incumbent, up from 179 two commits ago. An unchanged rebuild
of those 195 pages renders zero.

Layout: unified · split

Cargo.lock +1 −1
@@ -569,7 +569,7 @@ dependencies = [
569 569
570[[package]] 570[[package]]
571name = "org-ssg" 571name = "org-ssg"
572version = "0.7.0" 572version = "0.8.0"
573dependencies = [ 573dependencies = [
574 "anyhow", 574 "anyhow",
575 "blake3", 575 "blake3",
Cargo.toml +1 −1
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "org-ssg" 2name = "org-ssg"
3version = "0.7.0" 3version = "0.8.0"
4edition = "2021" 4edition = "2021"
5description = "Org-mode static site generator that renders the org element tree straight to HTML" 5description = "Org-mode static site generator that renders the org element tree straight to HTML"
6license = "MIT" 6license = "MIT"
README.md +45 −2
@@ -120,6 +120,47 @@ written in — `[2025-09-05 Fri 10:21:00]`, `<2024-05-01 Wed>` or bare `2024-05-
120also the sort key; pages without a parseable date sort last, so an undated draft never 120also the sort key; pages without a parseable date sort last, so an undated draft never
121leads a dated archive. 121leads a dated archive.
122 122
123#### Tag pages
124
125Add `group_by` and the collection emits one page *per group* instead of one page total,
126plus an optional index of the groups:
127
128```toml
129[[collections]]
130source = "blog"
131group_by = "tags" # "tags", or any #+KEYWORD: name to group by its value
132output = "tags/{tag}.html" # {tag} is replaced by each group's slug
133template = "tag.html"
134title = "Tagged: {tag}"
135index_output = "tags/index.html" # the tag index
136index_template = "tags.html"
137index_title = "Tags"
138nav = true # adds the *index*, not every tag
139```
140
141A group page receives its own posts as `pages` and itself as `group`
142(`.name`, `.slug`, `.url`, `.count`). The index receives `groups` — every group, sorted
143by name:
144
145```jinja
146<ul>{% for tag in groups %}
147 <li><a href="{{ root }}{{ tag.url }}">{{ tag.name }}</a> ({{ tag.count }})</li>
148{% endfor %}</ul>
149```
150
151`group_by = "tags"` is multi-valued: a post appears under every tag it carries. Any other
152value names a single-valued `#+KEYWORD:`, so `group_by = "category"` buckets by
153`#+CATEGORY:`.
154
155Two tags that would produce the same URL (`web_dev` and `web@dev` both slugify to
156`web-dev`) are a build error rather than one page silently overwriting the other.
157
158A tag page depends on its own posts and nothing else, so adding a post tagged `rust`
159re-renders that post, its section index, `tags/rust.html`, and the tag index whose counts
160changed — four pages, not one per tag. That precision is why `groups` is given to the
161index and not to every group page: a page that can see every group depends on every
162group.
163
123**A feed is a listing page with an XML template**, not a separate feature — templates are 164**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 165loaded by full filename and any extension, so `output = "feed.xml"` with
125`template = "feed.xml"` is all it takes. 166`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
196| **7** | **Hardening: rayon parallelism, error locations in parse diagnostics** | **done** | 237| **7** | **Hardening: rayon parallelism, error locations in parse diagnostics** | **done** |
197| **8** | **General use: config file, user templates, nav modes, `init` scaffold, safe discovery** | **done** | 238| **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** | 239| **9** | **Generated listing pages: `[[collections]]`, sorted indexes, feeds via XML templates** | **done** |
240| **10** | **Grouped collections: one page per tag plus a tag index — full parity with the incumbent** | **done** |
199 241
200### v0.2 in / out 242### v0.2 in / out
201 243
@@ -302,7 +344,8 @@ Emacs does.
302The audit runs against any corpus — point it at your own notes before trusting this tool 344The audit runs against any corpus — point it at your own notes before trusting this tool
303with them. The numbers below come from a 179-file site published today by weblorg, a 345with them. The numbers below come from a 179-file site published today by weblorg, a
304wrapper around org's own HTML exporter, which makes it both a realistic workload and a 346wrapper around org's own HTML exporter, which makes it both a realistic workload and a
305directly comparable incumbent. 347directly comparable incumbent. With collections configured, org-ssg now reproduces
348**all 182 of that site's URLs**.
306 349
307``` 350```
308cargo run -- audit <src-dir> # what does this corpus use, and is it in scope? 351cargo 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
458 501
459``` 502```
460cargo build 503cargo build
461cargo test # 99 tests 504cargo test # 107 tests
462cargo run -- init my-site # scaffold a new site 505cargo run -- init my-site # scaffold a new site
463cargo run -- build fixtures/minimal.org -o minimal.html # single file 506cargo run -- build fixtures/minimal.org -o minimal.html # single file
464cargo run -- build fixtures/site -o _site # whole site (incremental) 507cargo run -- build fixtures/site -o _site # whole site (incremental)
src/config.rs +71 −5
@@ -53,6 +53,21 @@ pub struct Collection {
53 pub template: String, 53 pub template: String,
54 /// Title for the generated page, available to the template as `page.title`. 54 /// Title for the generated page, available to the template as `page.title`.
55 pub title: String, 55 pub title: String,
56 /// Split the collection into groups and emit one page per group.
57 ///
58 /// `"tags"` groups by `#+FILETAGS:`, where a page belongs to every tag it carries.
59 /// Any other value names a `#+KEYWORD:` and groups by its value, so `"category"`
60 /// buckets pages by `#+CATEGORY:`. Empty means one page for the whole collection.
61 ///
62 /// When set, `output` and `title` may contain `{tag}`, replaced by the group — and
63 /// `output` must, or every group would write to the same file.
64 pub group_by: String,
65 /// Where to write a page listing the groups themselves — a tag index. Empty means
66 /// no such page. Only meaningful with `group_by`.
67 pub index_output: Utf8PathBuf,
68 /// Template for the group-index page. It receives `groups` rather than `pages`.
69 pub index_template: String,
70 pub index_title: String,
56 pub sort: SortKey, 71 pub sort: SortKey,
57 pub order: SortOrder, 72 pub order: SortOrder,
58 /// Add this listing page to the site navigation. This is how a section landing page 73 /// Add this listing page to the site navigation. This is how a section landing page
@@ -67,6 +82,10 @@ impl Default for Collection {
67 output: Utf8PathBuf::from("index.html"), 82 output: Utf8PathBuf::from("index.html"),
68 template: "list.html".to_string(), 83 template: "list.html".to_string(),
69 title: "Index".to_string(), 84 title: "Index".to_string(),
85 group_by: String::new(),
86 index_output: Utf8PathBuf::new(),
87 index_template: "tags.html".to_string(),
88 index_title: "Tags".to_string(),
70 sort: SortKey::default(), 89 sort: SortKey::default(),
71 order: SortOrder::default(), 90 order: SortOrder::default(),
72 nav: false, 91 nav: false,
@@ -74,6 +93,9 @@ impl Default for Collection {
74 } 93 }
75} 94}
76 95
96/// The `{tag}` placeholder in a grouped collection's `output` and `title`.
97pub const GROUP_PLACEHOLDER: &str = "{tag}";
98
77#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] 99#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "kebab-case")] 100#[serde(rename_all = "kebab-case")]
79pub enum SortKey { 101pub enum SortKey {
@@ -251,16 +273,47 @@ impl Config {
251 } 273 }
252 let mut seen: Vec<&Utf8PathBuf> = Vec::new(); 274 let mut seen: Vec<&Utf8PathBuf> = Vec::new();
253 for collection in &self.collections { 275 for collection in &self.collections {
254 if collection.output.as_str().is_empty() { 276 let grouped = !collection.group_by.is_empty();
255 anyhow::bail!("a collection has an empty `output`; it needs a file to write"); 277 if collection.output.as_str().is_empty() && collection.index_output.as_str().is_empty()
278 {
279 anyhow::bail!("a collection has no `output`; it needs a file to write");
280 }
281 if grouped
282 && !collection.output.as_str().is_empty()
283 && !collection.output.as_str().contains(GROUP_PLACEHOLDER)
284 {
285 anyhow::bail!(
286 "collection output {} groups by \"{}\" but has no {GROUP_PLACEHOLDER} in \
287 its path, so every group would overwrite the same file",
288 collection.output,
289 collection.group_by
290 );
256 } 291 }
257 if seen.contains(&&collection.output) { 292 if !grouped && collection.output.as_str().contains(GROUP_PLACEHOLDER) {
258 anyhow::bail!( 293 anyhow::bail!(
259 "two collections both write to {}; give them different `output` paths", 294 "collection output {} uses {GROUP_PLACEHOLDER} but sets no `group_by`",
260 collection.output 295 collection.output
261 ); 296 );
262 } 297 }
263 seen.push(&collection.output); 298 if !grouped && !collection.index_output.as_str().is_empty() {
299 anyhow::bail!(
300 "collection writes an `index_output` of {} but sets no `group_by`; \
301 there are no groups to index",
302 collection.index_output
303 );
304 }
305 for path in [&collection.output, &collection.index_output] {
306 if path.as_str().is_empty() || path.as_str().contains(GROUP_PLACEHOLDER) {
307 continue;
308 }
309 if seen.contains(&path) {
310 anyhow::bail!(
311 "two collections both write to {path}; give them different \
312 `output` paths"
313 );
314 }
315 seen.push(path);
316 }
264 } 317 }
265 if !self.site.base_url.is_empty() && self.site.base_url.ends_with('/') { 318 if !self.site.base_url.is_empty() && self.site.base_url.ends_with('/') {
266 anyhow::bail!( 319 anyhow::bail!(
@@ -323,4 +376,17 @@ title = "Blog"
323sort = "date" # date | title | path 376sort = "date" # date | title | path
324order = "desc" # desc | asc 377order = "desc" # desc | asc
325nav = true # put this listing page in the site nav 378nav = true # put this listing page in the site nav
379
380# One page per tag, plus an index of all tags. `{tag}` in `output`/`title` is replaced
381# by each tag; the index gets `groups` instead of `pages`.
382[[collections]]
383source = "blog"
384group_by = "tags" # "tags", or any #+KEYWORD: name to group by its value
385output = "tags/{tag}.html"
386template = "list.html"
387title = "Tagged: {tag}"
388index_output = "tags/index.html"
389index_template = "tags.html"
390index_title = "Tags"
391nav = true # adds the tag *index*, not every tag
326"#; 392"#;
src/main.rs +7 −4
@@ -11,7 +11,7 @@ use org_ssg::config::Config;
11use org_ssg::render::{self, render, Html, SyntectHighlighter}; 11use org_ssg::render::{self, render, Html, SyntectHighlighter};
12use org_ssg::resolve::ResolvedDoc; 12use org_ssg::resolve::ResolvedDoc;
13use org_ssg::site::{build_site, BuildOptions, SYNTAX_STYLESHEET}; 13use org_ssg::site::{build_site, BuildOptions, SYNTAX_STYLESHEET};
14use org_ssg::template::{PageContext, SiteContext, Templater}; 14use org_ssg::template::{PageContext, RenderContext, SiteContext, Templater};
15 15
16#[derive(Parser)] 16#[derive(Parser)]
17#[command(name = "org-ssg", version, about = "Org-mode static site generator")] 17#[command(name = "org-ssg", version, about = "Org-mode static site generator")]
@@ -132,7 +132,7 @@ fn main() -> Result<()> {
132/// in a directory that has content is safe and additive rather than destructive. 132/// in a directory that has content is safe and additive rather than destructive.
133fn init(dir: &Utf8Path) -> Result<()> { 133fn init(dir: &Utf8Path) -> Result<()> {
134 use org_ssg::config::{CONFIG_FILE, STARTER_CONFIG}; 134 use org_ssg::config::{CONFIG_FILE, STARTER_CONFIG};
135 use org_ssg::template::{starter_template, STARTER_LIST_TEMPLATE}; 135 use org_ssg::template::{starter_template, STARTER_LIST_TEMPLATE, STARTER_TAGS_TEMPLATE};
136 136
137 fs::create_dir_all(dir).with_context(|| format!("creating {dir}"))?; 137 fs::create_dir_all(dir).with_context(|| format!("creating {dir}"))?;
138 fs::create_dir_all(dir.join("templates")).with_context(|| format!("creating {dir}/templates"))?; 138 fs::create_dir_all(dir.join("templates")).with_context(|| format!("creating {dir}/templates"))?;
@@ -165,10 +165,11 @@ fn init(dir: &Utf8Path) -> Result<()> {
165 "in org-ssg.toml, newest first.\n", 165 "in org-ssg.toml, newest first.\n",
166 ); 166 );
167 167
168 let files: [(Utf8PathBuf, &str); 5] = [ 168 let files: [(Utf8PathBuf, &str); 6] = [
169 (dir.join(CONFIG_FILE), STARTER_CONFIG), 169 (dir.join(CONFIG_FILE), STARTER_CONFIG),
170 (dir.join("templates/base.html"), starter_template()), 170 (dir.join("templates/base.html"), starter_template()),
171 (dir.join("templates/list.html"), STARTER_LIST_TEMPLATE), 171 (dir.join("templates/list.html"), STARTER_LIST_TEMPLATE),
172 (dir.join("templates/tags.html"), STARTER_TAGS_TEMPLATE),
172 (dir.join("index.org"), index), 173 (dir.join("index.org"), index),
173 (dir.join("blog/first-post.org"), post), 174 (dir.join("blog/first-post.org"), post),
174 ]; 175 ];
@@ -295,8 +296,10 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
295 tags: Vec::new(), 296 tags: Vec::new(),
296 keywords: Default::default(), 297 keywords: Default::default(),
297 }; 298 };
299 let mut ctx = RenderContext::new(&site, &page_ctx, &[], SYNTAX_STYLESHEET, "");
300 ctx.body = &fragment;
298 let page = templater 301 let page = templater
299 .render_page(&site, &page_ctx, &fragment, &[], SYNTAX_STYLESHEET, "", None) 302 .render_page(&ctx)
300 .with_context(|| format!("templating {input}"))?; 303 .with_context(|| format!("templating {input}"))?;
301 fs::write(output, page).with_context(|| format!("writing output file {output}"))?; 304 fs::write(output, page).with_context(|| format!("writing output file {output}"))?;
302 305
src/site.rs +163 −27
@@ -9,7 +9,7 @@
9//! `--no-cache` forces a full rebuild; the cache is never a correctness dependency, so a 9//! `--no-cache` forces a full rebuild; the cache is never a correctness dependency, so a
10//! full rebuild and an incremental rebuild produce byte-identical output. 10//! full rebuild and an incremental rebuild produce byte-identical output.
11 11
12use std::collections::HashSet; 12use std::collections::{HashMap, HashSet};
13use std::fs; 13use std::fs;
14 14
15use anyhow::{Context, Result}; 15use anyhow::{Context, Result};
@@ -28,8 +28,8 @@ use crate::parser::parse;
28use crate::render::{self, render_with, Html, RenderOptions, SyntectHighlighter}; 28use crate::render::{self, render_with, Html, RenderOptions, SyntectHighlighter};
29use crate::resolve::resolve; 29use crate::resolve::resolve;
30use crate::config::{self, Config, NavMode, SortKey, SortOrder}; 30use crate::config::{self, Config, NavMode, SortKey, SortOrder};
31use crate::template::{NavItem, PageContext, SiteContext, Templater}; 31use crate::template::{GroupContext, NavItem, PageContext, RenderContext, SiteContext, Templater};
32use crate::util::{output_path, output_url, relative_root}; 32use crate::util::{output_path, output_url, relative_root, slugify};
33 33
34/// A fully built page: source and output paths (relative to their roots) and its 34/// A fully built page: source and output paths (relative to their roots) and its
35/// final templated HTML. 35/// final templated HTML.
@@ -105,13 +105,18 @@ struct PagePrep {
105 context: PageContext, 105 context: PageContext,
106} 106}
107 107
108/// A generated listing page, resolved against the pages it lists. 108/// A generated page, resolved against the pages it lists.
109struct Listing { 109struct Listing {
110 output: Utf8PathBuf, 110 output: Utf8PathBuf,
111 template: String, 111 template: String,
112 title: String, 112 title: String,
113 /// The pages it lists, already sorted. 113 /// The pages it lists, already sorted. Empty for a group index, which lists groups.
114 entries: Vec<PageContext>, 114 entries: Vec<PageContext>,
115 /// The group this page is for, when it belongs to a grouped collection.
116 group: Option<GroupContext>,
117 /// Every group of the owning collection. The content of a group index, and context
118 /// for a group page.
119 groups: Vec<GroupContext>,
115} 120}
116 121
117/// The `YYYY-MM-DD` inside an org date, if there is one. Org dates arrive as 122/// 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>> {
167 entries.reverse(); 172 entries.reverse();
168 } 173 }
169 174
170 listings.push(Listing { 175 if collection.group_by.is_empty() {
171 output: collection.output.clone(), 176 listings.push(Listing {
172 template: collection.template.clone(), 177 output: collection.output.clone(),
173 title: collection.title.clone(), 178 template: collection.template.clone(),
174 entries, 179 title: collection.title.clone(),
175 }); 180 entries,
181 group: None,
182 groups: Vec::new(),
183 });
184 continue;
185 }
186
187 // Grouped: one page per distinct term. `entries` is already sorted, and grouping
188 // preserves that order within each group.
189 let mut terms: Vec<String> = Vec::new();
190 let mut members: HashMap<String, Vec<PageContext>> = HashMap::new();
191 for entry in &entries {
192 for term in group_terms(entry, &collection.group_by) {
193 if !members.contains_key(&term) {
194 terms.push(term.clone());
195 }
196 members.entry(term).or_default().push(entry.clone());
197 }
198 }
199 // Terms are discovered in page order, which is arbitrary from a reader's point of
200 // view; sort so a tag index reads alphabetically and hashes deterministically.
201 terms.sort();
202
203 let mut groups: Vec<GroupContext> = Vec::new();
204 let mut slugs: HashMap<String, String> = HashMap::new();
205 for term in &terms {
206 let slug = slugify(term);
207 if slug.is_empty() {
208 anyhow::bail!(
209 "the {} value {term:?} has no URL-safe form; it cannot name a page",
210 collection.group_by
211 );
212 }
213 // `C++` and `C ++` both slugify to `c`, and one would silently overwrite the
214 // other's page.
215 if let Some(other) = slugs.insert(slug.clone(), term.clone()) {
216 anyhow::bail!(
217 "the {} values {other:?} and {term:?} both become {slug:?} in a URL; \
218 rename one so their pages do not collide",
219 collection.group_by
220 );
221 }
222 groups.push(GroupContext {
223 name: term.clone(),
224 slug: slug.clone(),
225 url: if collection.output.as_str().is_empty() {
226 String::new()
227 } else {
228 collection
229 .output
230 .as_str()
231 .replace(config::GROUP_PLACEHOLDER, &slug)
232 }
233 .to_string(),
234 count: members.get(term).map(Vec::len).unwrap_or(0),
235 });
236 }
237
238 if !collection.output.as_str().is_empty() {
239 for group in &groups {
240 listings.push(Listing {
241 output: Utf8PathBuf::from(&group.url),
242 template: collection.template.clone(),
243 title: collection
244 .title
245 .replace(config::GROUP_PLACEHOLDER, &group.name),
246 entries: members.get(&group.name).cloned().unwrap_or_default(),
247 group: Some(group.clone()),
248 // Deliberately not the whole group list. A page that can see every
249 // group depends on every group, so one new post would re-render every
250 // tag page — cost that scales with tag count, to support a tag cloud
251 // nobody has asked for. A tag page depends on its own posts, and the
252 // group index is where the group list belongs.
253 groups: Vec::new(),
254 });
255 }
256 }
257 if !collection.index_output.as_str().is_empty() {
258 listings.push(Listing {
259 output: collection.index_output.clone(),
260 template: collection.index_template.clone(),
261 title: collection.index_title.clone(),
262 entries: Vec::new(),
263 group: None,
264 groups: groups.clone(),
265 });
266 }
176 } 267 }
177 268
178 // A listing page writing over a real page would silently replace it. 269 // A generated page writing over a real page would silently replace it. Group pages
270 // make this easy to hit by accident, since their paths come from content.
179 for listing in &listings { 271 for listing in &listings {
180 if let Some(clash) = preps.iter().find(|p| p.output == listing.output) { 272 if let Some(clash) = preps.iter().find(|p| p.output == listing.output) {
181 anyhow::bail!( 273 anyhow::bail!(
@@ -185,9 +277,41 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
185 ); 277 );
186 } 278 }
187 } 279 }
280 let mut claimed: HashMap<&Utf8PathBuf, ()> = HashMap::new();
281 for listing in &listings {
282 if claimed.insert(&listing.output, ()).is_some() {
283 anyhow::bail!("two generated pages both write to {}", listing.output);
284 }
285 }
188 Ok(listings) 286 Ok(listings)
189} 287}
190 288
289/// The `(output, title)` a collection contributes to the nav. A grouped collection
290/// offers its index; an ungrouped one offers its single page.
291fn nav_target(collection: &config::Collection) -> (Utf8PathBuf, String) {
292 if !collection.group_by.is_empty() {
293 return (
294 collection.index_output.clone(),
295 collection.index_title.clone(),
296 );
297 }
298 (collection.output.clone(), collection.title.clone())
299}
300
301/// The group terms a page belongs to. `tags` is multi-valued — a page appears under
302/// every tag it carries — while any other key names a single-valued `#+KEYWORD:`.
303fn group_terms(page: &PageContext, group_by: &str) -> Vec<String> {
304 if group_by.eq_ignore_ascii_case("tags") {
305 return page.tags.clone();
306 }
307 page.keywords
308 .get(&group_by.to_lowercase())
309 .map(|v| v.trim())
310 .filter(|v| !v.is_empty())
311 .map(|v| vec![v.to_string()])
312 .unwrap_or_default()
313}
314
191/// Everything a listing template can see about its entries, hashed. This is the listing 315/// 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. 316/// page's whole dependency: if none of these change, its output cannot have changed.
193fn listing_entries_hash(listing: &Listing) -> Hash { 317fn listing_entries_hash(listing: &Listing) -> Hash {
@@ -203,6 +327,14 @@ fn listing_entries_hash(listing: &Listing) -> Hash {
203 ), 327 ),
204 ] 328 ]
205 }) 329 })
330 // A group index has no entries at all — its content *is* the group list, so the
331 // groups have to be in the hash or a tag index would never notice a new tag.
332 .chain(
333 listing
334 .groups
335 .iter()
336 .map(|g| (g.url.clone(), format!("{}\u{0}{}", g.name, g.count))),
337 )
206 .chain([(listing.title.clone(), listing.template.clone())]) 338 .chain([(listing.title.clone(), listing.template.clone())])
207 .collect(); 339 .collect();
208 // Entry *order* is meaningful in a listing, so this hashes the sorted-by-us sequence 340 // Entry *order* is meaningful in a listing, so this hashes the sorted-by-us sequence
@@ -354,9 +486,11 @@ fn prepare_pages(
354 .map(|(_, out, title)| (out.clone(), title.clone())) 486 .map(|(_, out, title)| (out.clone(), title.clone()))
355 .collect(); 487 .collect();
356 // A listing page is exactly what a section's nav entry should point at — `/blog/` 488 // 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. 489 // rather than any one post — so collections can opt into the nav directly. For a
358 for collection in config.collections.iter().filter(|c| c.nav) { 490 // grouped collection that means its *index*: a nav listing every tag is the same
359 entries.push((collection.output.clone(), collection.title.clone())); 491 // mistake as a nav listing every page.
492 for (output, title) in config.collections.iter().filter(|c| c.nav).map(nav_target) {
493 entries.push((output, title));
360 } 494 }
361 495
362 // RESOLVE reads the shared symbol table and writes only into its own page's output, 496 // RESOLVE reads the shared symbol table and writes only into its own page's output,
@@ -466,8 +600,11 @@ fn render_page(
466 // Relative to the *output* path, since `#+SLUG:` can move a page between depths. 600 // Relative to the *output* path, since `#+SLUG:` can move a page between depths.
467 let root = relative_root(&p.output); 601 let root = relative_root(&p.output);
468 let stylesheet = format!("{root}{SYNTAX_STYLESHEET}"); 602 let stylesheet = format!("{root}{SYNTAX_STYLESHEET}");
603 let mut ctx = RenderContext::new(site, &p.context, &p.nav, &stylesheet, &root);
604 ctx.body = &fragment;
605 ctx.pages = pages;
469 templater 606 templater
470 .render_page(site, &p.context, &fragment, &p.nav, &stylesheet, &root, pages) 607 .render_page(&ctx)
471 .with_context(|| format!("templating {}", p.source)) 608 .with_context(|| format!("templating {}", p.source))
472} 609}
473 610
@@ -529,7 +666,8 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
529 cfg.collections 666 cfg.collections
530 .iter() 667 .iter()
531 .filter(|c| c.nav) 668 .filter(|c| c.nav)
532 .map(|c| (c.output.to_string(), c.title.clone())), 669 .map(nav_target)
670 .map(|(out, title)| (out.to_string(), title)),
533 ) 671 )
534 .collect() 672 .collect()
535 }; 673 };
@@ -659,17 +797,15 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
659 fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?; 797 fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?;
660 } 798 }
661 let root = relative_root(&listing.output); 799 let root = relative_root(&listing.output);
800 let stylesheet = format!("{root}{SYNTAX_STYLESHEET}");
801 let nav = listing_nav(&preps, &listing.output);
802 let page_ctx = listing_context(listing);
803 let mut ctx = RenderContext::new(&site, &page_ctx, &nav, &stylesheet, &root);
804 ctx.pages = Some(&listing.entries);
805 ctx.group = listing.group.as_ref();
806 ctx.groups = &listing.groups;
662 let html = templater 807 let html = templater
663 .render_named( 808 .render(&listing.template, &ctx)
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(|| { 809 .with_context(|| {
674 format!( 810 format!(
675 "rendering collection {} with template {} (available: {})", 811 "rendering collection {} with template {} (available: {})",
src/template.rs +111 −39
@@ -184,56 +184,128 @@ impl Templater {
184 self.sources.iter().map(|(n, _)| n.as_str()).collect() 184 self.sources.iter().map(|(n, _)| n.as_str()).collect()
185 } 185 }
186 186
187 /// fragment + page metadata → full page, through the base layout. 187 /// Render through a named template. Generated pages use this to reach their own
188 /// 188 /// layout; the context is identical to a normal page's, so a listing template can
189 /// `stylesheet` and `root` are URLs relative to *this* page, so a template works the
190 /// same at any directory depth.
191 #[allow(clippy::too_many_arguments)]
192 pub fn render_page(
193 &self,
194 site: &SiteContext,
195 page: &PageContext,
196 body: &str,
197 nav: &[NavItem],
198 stylesheet: &str,
199 root: &str,
200 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. 189 /// `{% extends "base.html" %}` and inherit the site's chrome for free.
208 #[allow(clippy::too_many_arguments)] 190 pub fn render(&self, template: &str, ctx: &RenderContext) -> Result<String, TemplateError> {
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]>,
219 ) -> Result<String, TemplateError> {
220 let tmpl = self 191 let tmpl = self
221 .env 192 .env
222 .get_template(template) 193 .get_template(template)
223 .map_err(|e| TemplateError::Render(e.to_string()))?; 194 .map_err(|e| TemplateError::Render(e.to_string()))?;
224 tmpl.render(context! { 195 tmpl.render(context! {
225 site => site, 196 site => ctx.site,
226 page => page, 197 page => ctx.page,
227 body => body, 198 body => ctx.body,
228 nav => nav, 199 nav => ctx.nav,
229 stylesheet => stylesheet, 200 stylesheet => ctx.stylesheet,
230 root => root, 201 root => ctx.root,
231 pages => pages, 202 pages => ctx.pages,
203 group => ctx.group,
204 groups => ctx.groups,
232 }) 205 })
233 .map_err(|e| TemplateError::Render(render_error_detail(e))) 206 .map_err(|e| TemplateError::Render(render_error_detail(e)))
234 } 207 }
208
209 /// Render through the site's base layout.
210 pub fn render_page(&self, ctx: &RenderContext) -> Result<String, TemplateError> {
211 self.render(BASE_TEMPLATE_NAME, ctx)
212 }
213}
214
215/// One group of a grouped collection — a tag, or a `#+CATEGORY:` value.
216#[derive(Debug, Clone, Serialize)]
217pub struct GroupContext {
218 /// The term as written, e.g. `Rust Lang`.
219 pub name: String,
220 /// URL-safe form used in the output path, e.g. `rust-lang`.
221 pub slug: String,
222 /// Output path of this group's page, relative to the site root. Empty when the
223 /// collection emits no per-group pages.
224 pub url: String,
225 /// How many pages carry this term.
226 pub count: usize,
227}
228
229/// Everything a template can see. A struct rather than a dozen positional arguments,
230/// because the list grows every time templates learn something new.
231pub struct RenderContext<'a> {
232 pub site: &'a SiteContext,
233 pub page: &'a PageContext,
234 /// Rendered page HTML. Empty for generated pages, which build their body from
235 /// `pages`/`groups` instead.
236 pub body: &'a str,
237 pub nav: &'a [NavItem],
238 /// URL of the syntax stylesheet, relative to this page.
239 pub stylesheet: &'a str,
240 /// `../`-prefix back to the site root from this page.
241 pub root: &'a str,
242 /// The pages this listing shows, or every page when `expose_page_list` is on.
243 pub pages: Option<&'a [PageContext]>,
244 /// The group this page is for, on a grouped collection's per-group page.
245 pub group: Option<&'a GroupContext>,
246 /// Every group of a grouped collection — the group index's content. Empty on a
247 /// per-group page, which depends on its own entries and not on the other groups.
248 pub groups: &'a [GroupContext],
249}
250
251impl<'a> RenderContext<'a> {
252 /// A context with only the universally-present parts filled in.
253 pub fn new(
254 site: &'a SiteContext,
255 page: &'a PageContext,
256 nav: &'a [NavItem],
257 stylesheet: &'a str,
258 root: &'a str,
259 ) -> Self {
260 RenderContext {
261 site,
262 page,
263 body: "",
264 nav,
265 stylesheet,
266 root,
267 pages: None,
268 group: None,
269 groups: &[],
270 }
271 }
235} 272}
236 273
274/// The starter tag-index template written by `org-ssg init`: shows how `groups` is
275/// iterated, and how a group page is linked.
276pub const STARTER_TAGS_TEMPLATE: &str = r#"<!DOCTYPE html>
277<html lang="{{ site.language }}">
278<head>
279<meta charset="utf-8">
280<meta name="viewport" content="width=device-width, initial-scale=1">
281<title>{{ page.title }} &middot; {{ site.title }}</title>
282{%- if stylesheet %}
283<link rel="stylesheet" href="{{ stylesheet }}">
284{%- endif %}
285</head>
286<body>
287<header>
288<a class="site-title" href="{{ root }}index.html">{{ site.title }}</a>
289{%- if nav %}
290<nav>
291{%- for item in nav %}
292<a href="{{ item.url }}">{{ item.title }}</a>
293{%- endfor %}
294</nav>
295{%- endif %}
296</header>
297<main>
298<h1>{{ page.title }}</h1>
299<ul class="tag-list">
300{%- for tag in groups %}
301<li><a href="{{ root }}{{ tag.url }}">{{ tag.name }}</a> ({{ tag.count }})</li>
302{%- endfor %}
303</ul>
304</main>
305</body>
306</html>
307"#;
308
237/// The starter listing template written by `org-ssg init`: a blog index, showing how a 309/// The starter listing template written by `org-ssg init`: a blog index, showing how a
238/// collection's `pages` are iterated. 310/// collection's `pages` are iterated.
239pub const STARTER_LIST_TEMPLATE: &str = r#"<!DOCTYPE html> 311pub const STARTER_LIST_TEMPLATE: &str = r#"<!DOCTYPE html>
tests/config.rs +246
@@ -812,3 +812,249 @@ fn a_missing_collection_template_names_the_ones_that_exist() {
812 assert!(message.contains("nope.html"), "names the missing one: {message}"); 812 assert!(message.contains("nope.html"), "names the missing one: {message}");
813 assert!(message.contains("list.html"), "lists what is available: {message}"); 813 assert!(message.contains("list.html"), "lists what is available: {message}");
814} 814}
815
816// ---------------------------------------------------------------------------
817// Grouped collections: tag pages and the tag index
818// ---------------------------------------------------------------------------
819
820/// Posts carrying tags, a per-tag template, a tag-index template, and a grouped
821/// collection over them.
822fn write_tagged_blog(src: &Utf8PathBuf, extra: &str) {
823 std::fs::create_dir_all(src.join("blog")).unwrap();
824 std::fs::create_dir_all(src.join("templates")).unwrap();
825 std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nWelcome.\n").unwrap();
826 for (name, title, date, tags) in [
827 ("a", "Post A", "2024-01-01", ":rust:web:"),
828 ("b", "Post B", "2024-02-02", ":rust:"),
829 ("c", "Post C", "2024-03-03", ":emacs:"),
830 ("d", "Post D", "2024-04-04", ""),
831 ] {
832 let filetags = if tags.is_empty() {
833 String::new()
834 } else {
835 format!("#+FILETAGS: {tags}\n")
836 };
837 std::fs::write(
838 src.join(format!("blog/{name}.org")),
839 format!("#+TITLE: {title}\n#+DATE: {date}\n{filetags}\nBody.\n"),
840 )
841 .unwrap();
842 }
843 std::fs::write(
844 src.join("templates/tag.html"),
845 "<html><body><h1>{{ page.title }}</h1><p>slug={{ group.slug }} count={{ group.count }}</p>\
846 <ul>{% for p in pages %}<li>{{ p.title }}</li>{% endfor %}</ul></body></html>",
847 )
848 .unwrap();
849 std::fs::write(
850 src.join("templates/tags.html"),
851 "<html><body><h1>{{ page.title }}</h1><ul>\
852 {% for g in groups %}<li>{{ g.name }}={{ g.count }}@{{ root }}{{ g.url }}</li>\
853 {% endfor %}</ul></body></html>",
854 )
855 .unwrap();
856 std::fs::write(
857 src.join("org-ssg.toml"),
858 format!(
859 "[[collections]]\nsource = \"blog\"\ngroup_by = \"tags\"\n\
860 output = \"tags/{{tag}}.html\"\ntemplate = \"tag.html\"\ntitle = \"Tagged: {{tag}}\"\n\
861 index_output = \"tags/index.html\"\nindex_template = \"tags.html\"\n\
862 index_title = \"All tags\"\n{extra}"
863 ),
864 )
865 .unwrap();
866}
867
868/// One collection, many outputs — the shape the earlier listing feature could not express.
869#[test]
870fn a_grouped_collection_emits_one_page_per_tag() {
871 let root = tmpdir("tags");
872 let src = root.join("src");
873 std::fs::create_dir_all(&src).unwrap();
874 write_tagged_blog(&src, "");
875 let out = root.join("out");
876 build(&src, &out);
877
878 for (tag, expected) in [("rust", vec!["Post A", "Post B"]), ("emacs", vec!["Post C"])] {
879 let html = page(&out, &format!("tags/{tag}.html"));
880 for title in &expected {
881 assert!(html.contains(title), "{tag} lists {title}:\n{html}");
882 }
883 assert!(
884 html.contains(&format!("count={}", expected.len())),
885 "{tag} knows its own size:\n{html}"
886 );
887 }
888 assert!(
889 !out.join("tags/.html").exists(),
890 "an untagged post creates no empty group"
891 );
892 assert!(
893 !page(&out, "tags/rust.html").contains("Post C"),
894 "a tag page lists only its own posts"
895 );
896}
897
898/// The index lists the groups themselves, not the pages.
899#[test]
900fn the_tag_index_lists_every_tag_with_counts() {
901 let root = tmpdir("tagindex");
902 let src = root.join("src");
903 std::fs::create_dir_all(&src).unwrap();
904 write_tagged_blog(&src, "");
905 let out = root.join("out");
906 build(&src, &out);
907
908 let index = page(&out, "tags/index.html");
909 assert!(index.contains("All tags"), "uses index_title:\n{index}");
910 assert!(index.contains("rust=2@../tags/rust.html"), "counts and links:\n{index}");
911 assert!(index.contains("emacs=1@"), "every tag appears:\n{index}");
912 assert!(index.contains("web=1@"), "every tag appears:\n{index}");
913 // Alphabetical, so the index reads predictably rather than in discovery order.
914 let pos = |t: &str| index.find(t).unwrap();
915 assert!(pos("emacs") < pos("rust") && pos("rust") < pos("web"), "sorted:\n{index}");
916}
917
918/// A tag page depends on its own posts. Adding a post tagged `rust` must not re-render
919/// the `emacs` page — invalidation that scales with tag count would undo the point.
920#[test]
921fn adding_a_tagged_post_rebuilds_only_the_affected_pages() {
922 let root = tmpdir("tagsinc");
923 let src = root.join("src");
924 std::fs::create_dir_all(&src).unwrap();
925 write_tagged_blog(&src, "");
926 let out = root.join("out");
927 build(&src, &out);
928 assert!(build(&src, &out).rendered.is_empty(), "unchanged rebuild renders nothing");
929
930 std::fs::write(
931 src.join("blog/e.org"),
932 "#+TITLE: Post E\n#+DATE: 2024-05-05\n#+FILETAGS: :rust:\n\nBody.\n",
933 )
934 .unwrap();
935 let report = build(&src, &out);
936
937 let mut rendered = report.rendered.clone();
938 rendered.sort();
939 assert_eq!(
940 rendered,
941 vec![
942 Utf8PathBuf::from("blog/e.html"),
943 Utf8PathBuf::from("tags/index.html"),
944 Utf8PathBuf::from("tags/rust.html"),
945 ],
946 "the post, its tag page, and the index whose counts changed — nothing else"
947 );
948 assert!(page(&out, "tags/rust.html").contains("Post E"));
949}
950
951/// A new tag has to produce a new page and reach the index.
952#[test]
953fn a_new_tag_creates_its_page_and_joins_the_index() {
954 let root = tmpdir("newtag");
955 let src = root.join("src");
956 std::fs::create_dir_all(&src).unwrap();
957 write_tagged_blog(&src, "");
958 let out = root.join("out");
959 build(&src, &out);
960 assert!(!out.join("tags/zig.html").exists());
961
962 std::fs::write(
963 src.join("blog/f.org"),
964 "#+TITLE: Post F\n#+DATE: 2024-06-06\n#+FILETAGS: :zig:\n\nBody.\n",
965 )
966 .unwrap();
967 build(&src, &out);
968
969 assert!(out.join("tags/zig.html").exists(), "the new tag gets a page");
970 assert!(
971 page(&out, "tags/index.html").contains("zig=1@"),
972 "and the index knows about it"
973 );
974}
975
976/// Grouping by any `#+KEYWORD:`, not just tags — same mechanism, single-valued.
977#[test]
978fn a_collection_can_group_by_any_keyword() {
979 let root = tmpdir("groupkw");
980 let src = root.join("src");
981 std::fs::create_dir_all(&src).unwrap();
982 write_tagged_blog(&src, "");
983 std::fs::write(
984 src.join("blog/a.org"),
985 "#+TITLE: Post A\n#+DATE: 2024-01-01\n#+CATEGORY: Notes\n\nBody.\n",
986 )
987 .unwrap();
988 std::fs::write(
989 src.join("org-ssg.toml"),
990 "[[collections]]\nsource = \"blog\"\ngroup_by = \"category\"\n\
991 output = \"cat/{tag}.html\"\ntemplate = \"tag.html\"\ntitle = \"{tag}\"\n",
992 )
993 .unwrap();
994 let out = root.join("out");
995 build(&src, &out);
996
997 assert!(out.join("cat/notes.html").exists(), "grouped by #+CATEGORY:");
998 assert!(page(&out, "cat/notes.html").contains("Post A"));
999}
1000
1001/// A grouped collection puts its *index* in the nav. A nav listing every tag is the same
1002/// mistake as a nav listing every page.
1003#[test]
1004fn a_grouped_collection_contributes_its_index_to_the_nav() {
1005 let root = tmpdir("tagnav");
1006 let src = root.join("src");
1007 std::fs::create_dir_all(&src).unwrap();
1008 write_tagged_blog(&src, "nav = true\n");
1009 let out = root.join("out");
1010 build(&src, &out);
1011
1012 let nav = nav_of(&page(&out, "index.html"));
1013 assert!(nav.contains("tags/index.html"), "the index is in the nav:\n{nav}");
1014 assert!(!nav.contains("tags/rust.html"), "individual tags are not:\n{nav}");
1015}
1016
1017/// An output path with no `{tag}` would have every group overwrite one file — a config
1018/// that looks reasonable and silently produces one page instead of many.
1019#[test]
1020fn grouping_without_a_placeholder_is_rejected() {
1021 let root = tmpdir("noplaceholder");
1022 let src = root.join("src");
1023 std::fs::create_dir_all(&src).unwrap();
1024 write_tagged_blog(&src, "");
1025 std::fs::write(
1026 src.join("org-ssg.toml"),
1027 "[[collections]]\nsource = \"blog\"\ngroup_by = \"tags\"\n\
1028 output = \"tags/all.html\"\ntemplate = \"tag.html\"\n",
1029 )
1030 .unwrap();
1031
1032 let err = build_site(&src, &root.join("out"), &BuildOptions::default())
1033 .expect_err("grouping without {tag} must fail");
1034 assert!(format!("{err:#}").contains("{tag}"), "explains what is missing: {err:#}");
1035}
1036
1037/// Two tags that differ only in punctuation slugify to the same path, and one page would
1038/// silently overwrite the other.
1039#[test]
1040fn tags_that_collide_in_a_url_are_rejected() {
1041 let root = tmpdir("tagcollide");
1042 let src = root.join("src");
1043 std::fs::create_dir_all(&src).unwrap();
1044 write_tagged_blog(&src, "");
1045 std::fs::write(
1046 src.join("blog/a.org"),
1047 "#+TITLE: Post A\n#+DATE: 2024-01-01\n#+FILETAGS: :web_dev:\n\nBody.\n",
1048 )
1049 .unwrap();
1050 std::fs::write(
1051 src.join("blog/b.org"),
1052 "#+TITLE: Post B\n#+DATE: 2024-02-02\n#+FILETAGS: :web@dev:\n\nBody.\n",
1053 )
1054 .unwrap();
1055
1056 let err = build_site(&src, &root.join("out"), &BuildOptions::default())
1057 .expect_err("colliding tag slugs must fail");
1058 let message = format!("{err:#}");
1059 assert!(message.contains("web_dev") && message.contains("web@dev"), "{message}");
1060}