Commit ec5b3807fe
Verified · cmc
Layout: unified · split
Cargo.lock +1 −1
| @@ -569,7 +569,7 @@ dependencies = [ | |||
| 569 | 569 | ||
| 570 | [[package]] | 570 | [[package]] |
| 571 | name = "org-ssg" | 571 | name = "org-ssg" |
| 572 | version = "0.7.0" | 572 | version = "0.8.0" |
| 573 | dependencies = [ | 573 | dependencies = [ |
| 574 | "anyhow", | 574 | "anyhow", |
| 575 | "blake3", | 575 | "blake3", |
Cargo.toml +1 −1
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "org-ssg" | 2 | name = "org-ssg" |
| 3 | version = "0.7.0" | 3 | version = "0.8.0" |
| 4 | edition = "2021" | 4 | edition = "2021" |
| 5 | description = "Org-mode static site generator that renders the org element tree straight to HTML" | 5 | description = "Org-mode static site generator that renders the org element tree straight to HTML" |
| 6 | license = "MIT" | 6 | license = "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- | |||
| 120 | also the sort key; pages without a parseable date sort last, so an undated draft never | 120 | also the sort key; pages without a parseable date sort last, so an undated draft never |
| 121 | leads a dated archive. | 121 | leads a dated archive. |
| 122 | 122 | ||
| 123 | #### Tag pages | ||
| 124 | |||
| 125 | Add `group_by` and the collection emits one page *per group* instead of one page total, | ||
| 126 | plus an optional index of the groups: | ||
| 127 | |||
| 128 | ```toml | ||
| 129 | [[collections]] | ||
| 130 | source = "blog" | ||
| 131 | group_by = "tags" # "tags", or any #+KEYWORD: name to group by its value | ||
| 132 | output = "tags/{tag}.html" # {tag} is replaced by each group's slug | ||
| 133 | template = "tag.html" | ||
| 134 | title = "Tagged: {tag}" | ||
| 135 | index_output = "tags/index.html" # the tag index | ||
| 136 | index_template = "tags.html" | ||
| 137 | index_title = "Tags" | ||
| 138 | nav = true # adds the *index*, not every tag | ||
| 139 | ``` | ||
| 140 | |||
| 141 | A group page receives its own posts as `pages` and itself as `group` | ||
| 142 | (`.name`, `.slug`, `.url`, `.count`). The index receives `groups` — every group, sorted | ||
| 143 | by 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 | ||
| 152 | value names a single-valued `#+KEYWORD:`, so `group_by = "category"` buckets by | ||
| 153 | `#+CATEGORY:`. | ||
| 154 | |||
| 155 | Two 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 | |||
| 158 | A tag page depends on its own posts and nothing else, so adding a post tagged `rust` | ||
| 159 | re-renders that post, its section index, `tags/rust.html`, and the tag index whose counts | ||
| 160 | changed — four pages, not one per tag. That precision is why `groups` is given to the | ||
| 161 | index and not to every group page: a page that can see every group depends on every | ||
| 162 | group. | ||
| 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 |
| 124 | loaded by full filename and any extension, so `output = "feed.xml"` with | 165 | loaded 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. | |||
| 302 | The audit runs against any corpus — point it at your own notes before trusting this tool | 344 | The audit runs against any corpus — point it at your own notes before trusting this tool |
| 303 | with them. The numbers below come from a 179-file site published today by weblorg, a | 345 | with them. The numbers below come from a 179-file site published today by weblorg, a |
| 304 | wrapper around org's own HTML exporter, which makes it both a realistic workload and a | 346 | wrapper around org's own HTML exporter, which makes it both a realistic workload and a |
| 305 | directly comparable incumbent. | 347 | directly comparable incumbent. With collections configured, org-ssg now reproduces |
| 348 | **all 182 of that site's URLs**. | ||
| 306 | 349 | ||
| 307 | ``` | 350 | ``` |
| 308 | cargo run -- audit <src-dir> # what does this corpus use, and is it in scope? | 351 | 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 | |||
| 458 | 501 | ||
| 459 | ``` | 502 | ``` |
| 460 | cargo build | 503 | cargo build |
| 461 | cargo test # 99 tests | 504 | cargo test # 107 tests |
| 462 | cargo run -- init my-site # scaffold a new site | 505 | cargo run -- init my-site # scaffold a new site |
| 463 | cargo run -- build fixtures/minimal.org -o minimal.html # single file | 506 | cargo run -- build fixtures/minimal.org -o minimal.html # single file |
| 464 | cargo run -- build fixtures/site -o _site # whole site (incremental) | 507 | cargo 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`. | ||
| 97 | pub 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")] |
| 79 | pub enum SortKey { | 101 | pub 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" | |||
| 323 | sort = "date" # date | title | path | 376 | sort = "date" # date | title | path |
| 324 | order = "desc" # desc | asc | 377 | order = "desc" # desc | asc |
| 325 | nav = true # put this listing page in the site nav | 378 | nav = 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]] | ||
| 383 | source = "blog" | ||
| 384 | group_by = "tags" # "tags", or any #+KEYWORD: name to group by its value | ||
| 385 | output = "tags/{tag}.html" | ||
| 386 | template = "list.html" | ||
| 387 | title = "Tagged: {tag}" | ||
| 388 | index_output = "tags/index.html" | ||
| 389 | index_template = "tags.html" | ||
| 390 | index_title = "Tags" | ||
| 391 | nav = true # adds the tag *index*, not every tag | ||
| 326 | "#; | 392 | "#; |
src/main.rs +7 −4
| @@ -11,7 +11,7 @@ use org_ssg::config::Config; | |||
| 11 | use org_ssg::render::{self, render, Html, SyntectHighlighter}; | 11 | use org_ssg::render::{self, render, Html, SyntectHighlighter}; |
| 12 | use org_ssg::resolve::ResolvedDoc; | 12 | use org_ssg::resolve::ResolvedDoc; |
| 13 | use org_ssg::site::{build_site, BuildOptions, SYNTAX_STYLESHEET}; | 13 | use org_ssg::site::{build_site, BuildOptions, SYNTAX_STYLESHEET}; |
| 14 | use org_ssg::template::{PageContext, SiteContext, Templater}; | 14 | use 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. |
| 133 | fn init(dir: &Utf8Path) -> Result<()> { | 133 | fn 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 | ||
| 12 | use std::collections::HashSet; | 12 | use std::collections::{HashMap, HashSet}; |
| 13 | use std::fs; | 13 | use std::fs; |
| 14 | 14 | ||
| 15 | use anyhow::{Context, Result}; | 15 | use anyhow::{Context, Result}; |
| @@ -28,8 +28,8 @@ use crate::parser::parse; | |||
| 28 | use crate::render::{self, render_with, Html, RenderOptions, SyntectHighlighter}; | 28 | use crate::render::{self, render_with, Html, RenderOptions, SyntectHighlighter}; |
| 29 | use crate::resolve::resolve; | 29 | use crate::resolve::resolve; |
| 30 | use crate::config::{self, Config, NavMode, SortKey, SortOrder}; | 30 | use crate::config::{self, Config, NavMode, SortKey, SortOrder}; |
| 31 | use crate::template::{NavItem, PageContext, SiteContext, Templater}; | 31 | use crate::template::{GroupContext, NavItem, PageContext, RenderContext, SiteContext, Templater}; |
| 32 | use crate::util::{output_path, output_url, relative_root}; | 32 | use 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. |
| 109 | struct Listing { | 109 | struct 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. | ||
| 291 | fn 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:`. | ||
| 303 | fn 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. |
| 193 | fn listing_entries_hash(listing: &Listing) -> Hash { | 317 | fn 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)] | ||
| 217 | pub 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. | ||
| 231 | pub 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 | |||
| 251 | impl<'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. | ||
| 276 | pub 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 }} · {{ 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. |
| 239 | pub const STARTER_LIST_TEMPLATE: &str = r#"<!DOCTYPE html> | 311 | pub 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. | ||
| 822 | fn 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] | ||
| 870 | fn 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] | ||
| 900 | fn 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] | ||
| 921 | fn 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] | ||
| 953 | fn 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] | ||
| 978 | fn 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] | ||
| 1004 | fn 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] | ||
| 1020 | fn 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] | ||
| 1040 | fn 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 | } | ||