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