Commit 8911dc10db
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.9.0" | 572 | version = "0.10.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.9.0" | 3 | version = "0.10.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 +27 −3
| @@ -43,7 +43,7 @@ value below is the default. | |||
| 43 | ```toml | 43 | ```toml |
| 44 | [site] | 44 | [site] |
| 45 | title = "org-ssg site" | 45 | title = "org-ssg site" |
| 46 | base_url = "" # absolute URL, no trailing slash; empty = relative URLs only | 46 | base_url = "" # absolute URL, no trailing slash; needed for feeds/canonical links |
| 47 | description = "" | 47 | description = "" |
| 48 | language = "en" | 48 | language = "en" |
| 49 | 49 | ||
| @@ -200,9 +200,32 @@ changed — four pages, not one per tag. That precision is why `groups` is given | |||
| 200 | index and not to every group page: a page that can see every group depends on every | 200 | index and not to every group page: a page that can see every group depends on every |
| 201 | group. | 201 | group. |
| 202 | 202 | ||
| 203 | #### Feeds and absolute URLs | ||
| 204 | |||
| 203 | **A feed is a listing page with an XML template**, not a separate feature — templates are | 205 | **A feed is a listing page with an XML template**, not a separate feature — templates are |
| 204 | loaded by full filename and any extension, so `output = "feed.xml"` with | 206 | loaded by full filename and any extension, so `output = "feed.xml"` with |
| 205 | `template = "feed.xml"` is all it takes. | 207 | `template = "feed.xml"` is all it takes. `org-ssg init` writes a working RSS template. |
| 208 | |||
| 209 | A feed is read away from the site that served it, so relative links in one are simply | ||
| 210 | broken. Set `site.base_url` and use the `absolute` filter: | ||
| 211 | |||
| 212 | ```jinja | ||
| 213 | <link>{{ post.url | absolute }}</link> | ||
| 214 | <pubDate>{{ post.date_iso | rfc822 }}</pubDate> | ||
| 215 | ``` | ||
| 216 | |||
| 217 | | Filter | Does | | ||
| 218 | |---|---| | ||
| 219 | | `absolute` | site-root-relative path → absolute URL; already-absolute URLs pass through | | ||
| 220 | | `rfc822` | any org or ISO date → the format RSS `pubDate` requires | | ||
| 221 | |||
| 222 | Apply `absolute` to the site-root-relative values — `page.url`, `pages[].url`, | ||
| 223 | `group.url` — and not to `nav[].url`, `paginator.*_url`, `stylesheet` or `root`, which | ||
| 224 | are relative to the page carrying them and already correct there. | ||
| 225 | |||
| 226 | With no `base_url`, `absolute` is an **error** naming the setting, rather than quietly | ||
| 227 | emitting a relative URL that would make the feed invalid everywhere while looking fine. | ||
| 228 | The default layout also emits `<link rel="canonical">` when a base URL is set. | ||
| 206 | 229 | ||
| 207 | Listing pages are cached on the entries they list, so adding a post re-renders that | 230 | Listing pages are cached on the entries they list, so adding a post re-renders that |
| 208 | section's index and nothing else. | 231 | section's index and nothing else. |
| @@ -278,6 +301,7 @@ all-of-org. Phase 0 checked this line against a real 179-file corpus and found i | |||
| 278 | | **9** | **Generated listing pages: `[[collections]]`, sorted indexes, feeds via XML templates** | **done** | | 301 | | **9** | **Generated listing pages: `[[collections]]`, sorted indexes, feeds via XML templates** | **done** | |
| 279 | | **10** | **Grouped collections: one page per tag plus a tag index — full parity with the incumbent** | **done** | | 302 | | **10** | **Grouped collections: one page per tag plus a tag index — full parity with the incumbent** | **done** | |
| 280 | | **11** | **Pagination: numbered pages with a `paginator` context, composing with grouping** | **done** | | 303 | | **11** | **Pagination: numbered pages with a `paginator` context, composing with grouping** | **done** | |
| 304 | | **12** | **`base_url`: `absolute`/`rfc822` filters, a valid RSS feed in the scaffold, canonical links** | **done** | | ||
| 281 | 305 | ||
| 282 | ### v0.2 in / out | 306 | ### v0.2 in / out |
| 283 | 307 | ||
| @@ -541,7 +565,7 @@ PARSE/RESOLVE/RENDER), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserro | |||
| 541 | 565 | ||
| 542 | ``` | 566 | ``` |
| 543 | cargo build | 567 | cargo build |
| 544 | cargo test # 115 tests | 568 | cargo test # 122 tests |
| 545 | cargo run -- init my-site # scaffold a new site | 569 | cargo run -- init my-site # scaffold a new site |
| 546 | cargo run -- build fixtures/minimal.org -o minimal.html # single file | 570 | cargo run -- build fixtures/minimal.org -o minimal.html # single file |
| 547 | cargo run -- build fixtures/site -o _site # whole site (incremental) | 571 | cargo run -- build fixtures/site -o _site # whole site (incremental) |
src/config.rs +10 −1
| @@ -372,7 +372,8 @@ pub const STARTER_CONFIG: &str = r#"# org-ssg configuration. Every setting here | |||
| 372 | 372 | ||
| 373 | [site] | 373 | [site] |
| 374 | title = "org-ssg site" | 374 | title = "org-ssg site" |
| 375 | # Absolute base URL, no trailing slash. Leave empty to build with relative URLs only. | 375 | # Absolute base URL, no trailing slash. Needed for feeds and canonical links, which |
| 376 | # cannot be relative — set it and uncomment the [[collections]] feed block below. | ||
| 376 | base_url = "" | 377 | base_url = "" |
| 377 | description = "" | 378 | description = "" |
| 378 | language = "en" | 379 | language = "en" |
| @@ -418,6 +419,14 @@ nav = true # put this listing page in the site nav | |||
| 418 | # paginate = 10 # entries per page; page 1 stays at `output` | 419 | # paginate = 10 # entries per page; page 1 stays at `output` |
| 419 | # paginate_output = "blog/page/{n}.html" # where pages 2..N go; needs {n} | 420 | # paginate_output = "blog/page/{n}.html" # where pages 2..N go; needs {n} |
| 420 | 421 | ||
| 422 | # An RSS feed is a listing page with an XML template. It needs site.base_url above, | ||
| 423 | # because a feed is read away from the site that served it and relative links break. | ||
| 424 | # [[collections]] | ||
| 425 | # source = "blog" | ||
| 426 | # output = "feed.xml" | ||
| 427 | # template = "feed.xml" | ||
| 428 | # title = "Feed" | ||
| 429 | |||
| 421 | # One page per tag, plus an index of all tags. `{tag}` in `output`/`title` is replaced | 430 | # One page per tag, plus an index of all tags. `{tag}` in `output`/`title` is replaced |
| 422 | # by each tag; the index gets `groups` instead of `pages`. | 431 | # by each tag; the index gets `groups` instead of `pages`. |
| 423 | [[collections]] | 432 | [[collections]] |
src/main.rs +6 −3
| @@ -132,7 +132,9 @@ 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, STARTER_TAGS_TEMPLATE}; | 135 | use org_ssg::template::{ |
| 136 | starter_template, STARTER_FEED_TEMPLATE, STARTER_LIST_TEMPLATE, STARTER_TAGS_TEMPLATE, | ||
| 137 | }; | ||
| 136 | 138 | ||
| 137 | fs::create_dir_all(dir).with_context(|| format!("creating {dir}"))?; | 139 | fs::create_dir_all(dir).with_context(|| format!("creating {dir}"))?; |
| 138 | fs::create_dir_all(dir.join("templates")).with_context(|| format!("creating {dir}/templates"))?; | 140 | fs::create_dir_all(dir.join("templates")).with_context(|| format!("creating {dir}/templates"))?; |
| @@ -165,11 +167,12 @@ fn init(dir: &Utf8Path) -> Result<()> { | |||
| 165 | "in org-ssg.toml, newest first.\n", | 167 | "in org-ssg.toml, newest first.\n", |
| 166 | ); | 168 | ); |
| 167 | 169 | ||
| 168 | let files: [(Utf8PathBuf, &str); 6] = [ | 170 | let files: [(Utf8PathBuf, &str); 7] = [ |
| 169 | (dir.join(CONFIG_FILE), STARTER_CONFIG), | 171 | (dir.join(CONFIG_FILE), STARTER_CONFIG), |
| 170 | (dir.join("templates/base.html"), starter_template()), | 172 | (dir.join("templates/base.html"), starter_template()), |
| 171 | (dir.join("templates/list.html"), STARTER_LIST_TEMPLATE), | 173 | (dir.join("templates/list.html"), STARTER_LIST_TEMPLATE), |
| 172 | (dir.join("templates/tags.html"), STARTER_TAGS_TEMPLATE), | 174 | (dir.join("templates/tags.html"), STARTER_TAGS_TEMPLATE), |
| 175 | (dir.join("templates/feed.xml"), STARTER_FEED_TEMPLATE), | ||
| 173 | (dir.join("index.org"), index), | 176 | (dir.join("index.org"), index), |
| 174 | (dir.join("blog/first-post.org"), post), | 177 | (dir.join("blog/first-post.org"), post), |
| 175 | ]; | 178 | ]; |
| @@ -272,7 +275,7 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> { | |||
| 272 | let dir = input.parent().unwrap_or_else(|| Utf8Path::new(".")); | 275 | let dir = input.parent().unwrap_or_else(|| Utf8Path::new(".")); |
| 273 | let config = Config::load(dir)?; | 276 | let config = Config::load(dir)?; |
| 274 | config.validate()?; | 277 | config.validate()?; |
| 275 | let templater = Templater::load(Some(&dir.join(&config.templates.dir)))?; | 278 | let templater = Templater::load(Some(&dir.join(&config.templates.dir)), &config.site.base_url)?; |
| 276 | let css_text = render::syntax_css(&config.highlight.theme).ok_or_else(|| { | 279 | let css_text = render::syntax_css(&config.highlight.theme).ok_or_else(|| { |
| 277 | anyhow::anyhow!( | 280 | anyhow::anyhow!( |
| 278 | "unknown highlight.theme {:?}. Available: {}", | 281 | "unknown highlight.theme {:?}. Available: {}", |
src/site.rs +3 −23
| @@ -32,7 +32,7 @@ use crate::template::{ | |||
| 32 | GroupContext, NavItem, PageContext, Paginator, PaginatorPage, RenderContext, SiteContext, | 32 | GroupContext, NavItem, PageContext, Paginator, PaginatorPage, RenderContext, SiteContext, |
| 33 | Templater, | 33 | Templater, |
| 34 | }; | 34 | }; |
| 35 | use crate::util::{output_path, output_url, relative_root, slugify}; | 35 | use crate::util::{iso_date, output_path, output_url, relative_root, slugify}; |
| 36 | 36 | ||
| 37 | /// A fully built page: source and output paths (relative to their roots) and its | 37 | /// A fully built page: source and output paths (relative to their roots) and its |
| 38 | /// final templated HTML. | 38 | /// final templated HTML. |
| @@ -207,26 +207,6 @@ fn push_paginated( | |||
| 207 | } | 207 | } |
| 208 | } | 208 | } |
| 209 | 209 | ||
| 210 | /// The `YYYY-MM-DD` inside an org date, if there is one. Org dates arrive as | ||
| 211 | /// `[2025-09-05 Fri 10:21:00]`, `<2024-05-01 Wed>` or bare `2024-05-01`, and a listing | ||
| 212 | /// needs one key it can sort on. | ||
| 213 | pub fn iso_date(raw: &str) -> Option<String> { | ||
| 214 | let bytes = raw.as_bytes(); | ||
| 215 | for i in 0..bytes.len().saturating_sub(9) { | ||
| 216 | let window = &bytes[i..i + 10]; | ||
| 217 | let digits = |r: std::ops::Range<usize>| window[r].iter().all(u8::is_ascii_digit); | ||
| 218 | if digits(0..4) && window[4] == b'-' && digits(5..7) && window[7] == b'-' && digits(8..10) { | ||
| 219 | // Must not be part of a longer number, or `123-45-6789` would parse. | ||
| 220 | let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit(); | ||
| 221 | let after_ok = i + 10 >= bytes.len() || !bytes[i + 10].is_ascii_digit(); | ||
| 222 | if before_ok && after_ok { | ||
| 223 | return Some(raw[i..i + 10].to_string()); | ||
| 224 | } | ||
| 225 | } | ||
| 226 | } | ||
| 227 | None | ||
| 228 | } | ||
| 229 | |||
| 230 | /// Build the listing pages a config asks for, each with its entries sorted. | 210 | /// Build the listing pages a config asks for, each with its entries sorted. |
| 231 | fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> { | 211 | fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> { |
| 232 | let mut listings = Vec::new(); | 212 | let mut listings = Vec::new(); |
| @@ -637,7 +617,7 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> { | |||
| 637 | config.validate()?; | 617 | config.validate()?; |
| 638 | let (preps, _symbols) = prepare_pages(src, &config, None)?; | 618 | let (preps, _symbols) = prepare_pages(src, &config, None)?; |
| 639 | let highlighter = SyntectHighlighter::new(); | 619 | let highlighter = SyntectHighlighter::new(); |
| 640 | let templater = Templater::load(Some(&src.join(&config.templates.dir)))?; | 620 | let templater = Templater::load(Some(&src.join(&config.templates.dir)), &config.site.base_url)?; |
| 641 | let site = site_context(&config); | 621 | let site = site_context(&config); |
| 642 | let listing = page_listing(&config, &preps); | 622 | let listing = page_listing(&config, &preps); |
| 643 | let render_opts = render_options(&config); | 623 | let render_opts = render_options(&config); |
| @@ -724,7 +704,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result | |||
| 724 | let (_org_rel, assets) = discover(src, &cfg, Some(out))?; | 704 | let (_org_rel, assets) = discover(src, &cfg, Some(out))?; |
| 725 | let (preps, symbols) = prepare_pages(src, &cfg, Some(out))?; | 705 | let (preps, symbols) = prepare_pages(src, &cfg, Some(out))?; |
| 726 | 706 | ||
| 727 | let templater = Templater::load(Some(&src.join(&cfg.templates.dir)))?; | 707 | let templater = Templater::load(Some(&src.join(&cfg.templates.dir)), &cfg.site.base_url)?; |
| 728 | let syntax_css = render::syntax_css(&cfg.highlight.theme).ok_or_else(|| { | 708 | let syntax_css = render::syntax_css(&cfg.highlight.theme).ok_or_else(|| { |
| 729 | anyhow::anyhow!( | 709 | anyhow::anyhow!( |
| 730 | "unknown highlight.theme {:?}. Available: {}", | 710 | "unknown highlight.theme {:?}. Available: {}", |
src/template.rs +92 −1
| @@ -66,6 +66,9 @@ const BASE_TEMPLATE: &str = r#"<!DOCTYPE html> | |||
| 66 | <meta charset="utf-8"> | 66 | <meta charset="utf-8"> |
| 67 | <meta name="viewport" content="width=device-width, initial-scale=1"> | 67 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 68 | <title>{{ page.title }} · {{ site.title }}</title> | 68 | <title>{{ page.title }} · {{ site.title }}</title> |
| 69 | {%- if site.base_url %} | ||
| 70 | <link rel="canonical" href="{{ page.url | absolute }}"> | ||
| 71 | {%- endif %} | ||
| 69 | {%- if page.description %} | 72 | {%- if page.description %} |
| 70 | <meta name="description" content="{{ page.description }}"> | 73 | <meta name="description" content="{{ page.description }}"> |
| 71 | {%- endif %} | 74 | {%- endif %} |
| @@ -118,7 +121,7 @@ impl Templater { | |||
| 118 | /// exists but contains a template that does not compile is an error: it means | 121 | /// exists but contains a template that does not compile is an error: it means |
| 119 | /// someone is actively editing their layout, and rendering the built-in default | 122 | /// someone is actively editing their layout, and rendering the built-in default |
| 120 | /// instead would look like their edit silently did nothing. | 123 | /// instead would look like their edit silently did nothing. |
| 121 | pub fn load(dir: Option<&Utf8Path>) -> Result<Self> { | 124 | pub fn load(dir: Option<&Utf8Path>, base_url: &str) -> Result<Self> { |
| 122 | let mut sources: Vec<(String, String)> = Vec::new(); | 125 | let mut sources: Vec<(String, String)> = Vec::new(); |
| 123 | 126 | ||
| 124 | if let Some(dir) = dir.filter(|d| d.is_dir()) { | 127 | if let Some(dir) = dir.filter(|d| d.is_dir()) { |
| @@ -156,6 +159,7 @@ impl Templater { | |||
| 156 | 159 | ||
| 157 | let mut env = Environment::new(); | 160 | let mut env = Environment::new(); |
| 158 | env.set_formatter(html_formatter); | 161 | env.set_formatter(html_formatter); |
| 162 | add_filters(&mut env, base_url); | ||
| 159 | for (name, source) in &sources { | 163 | for (name, source) in &sources { |
| 160 | // `Environment<'static>` needs owned sources; leaking is bounded by the | 164 | // `Environment<'static>` needs owned sources; leaking is bounded by the |
| 161 | // template count and lives as long as the build anyway. | 165 | // template count and lives as long as the build anyway. |
| @@ -390,6 +394,93 @@ pub const STARTER_LIST_TEMPLATE: &str = r#"<!DOCTYPE html> | |||
| 390 | </html> | 394 | </html> |
| 391 | "#; | 395 | "#; |
| 392 | 396 | ||
| 397 | /// Filters a template can use beyond minijinja's built-ins. | ||
| 398 | /// | ||
| 399 | /// Both exist for the same reason: a syndication feed has requirements an HTML page does | ||
| 400 | /// not, and satisfying them by hand in a template is the kind of thing that produces a | ||
| 401 | /// feed which *looks* right and fails validation. | ||
| 402 | fn add_filters(env: &mut Environment<'static>, base_url: &str) { | ||
| 403 | let base = base_url.trim_end_matches('/').to_string(); | ||
| 404 | |||
| 405 | // `absolute`: a site-root-relative path → an absolute URL. | ||
| 406 | // | ||
| 407 | // Feeds are read away from the site that served them, so relative links in one are | ||
| 408 | // simply broken. Applies to the site-root-relative paths — `page.url`, `pages[].url`, | ||
| 409 | // `group.url` — and not to `nav[].url`, `paginator.*_url`, `stylesheet` or `root`, | ||
| 410 | // which are relative to the page carrying them and already correct in a page. | ||
| 411 | env.add_filter( | ||
| 412 | "absolute", | ||
| 413 | move |path: &str| -> Result<String, minijinja::Error> { | ||
| 414 | if base.is_empty() { | ||
| 415 | // Returning the relative path would produce a feed that validates | ||
| 416 | // nowhere and looks fine everywhere. Say what is missing instead. | ||
| 417 | return Err(minijinja::Error::new( | ||
| 418 | minijinja::ErrorKind::InvalidOperation, | ||
| 419 | "the `absolute` filter needs site.base_url, which is empty; \ | ||
| 420 | set it in org-ssg.toml (e.g. base_url = \"https://example.com\")", | ||
| 421 | )); | ||
| 422 | } | ||
| 423 | if path.starts_with("http://") || path.starts_with("https://") { | ||
| 424 | return Ok(path.to_string()); | ||
| 425 | } | ||
| 426 | Ok(format!("{base}/{}", path.trim_start_matches('/'))) | ||
| 427 | }, | ||
| 428 | ); | ||
| 429 | |||
| 430 | // `rfc822`: an org or ISO date → the format RSS `pubDate` requires. | ||
| 431 | env.add_filter("rfc822", |raw: &str| -> Result<String, minijinja::Error> { | ||
| 432 | let iso = crate::util::iso_date(raw).ok_or_else(|| { | ||
| 433 | minijinja::Error::new( | ||
| 434 | minijinja::ErrorKind::InvalidOperation, | ||
| 435 | format!("cannot read a date out of {raw:?} for an RSS pubDate"), | ||
| 436 | ) | ||
| 437 | })?; | ||
| 438 | let date = chrono::NaiveDate::parse_from_str(&iso, "%Y-%m-%d").map_err(|e| { | ||
| 439 | minijinja::Error::new( | ||
| 440 | minijinja::ErrorKind::InvalidOperation, | ||
| 441 | format!("{iso} is not a valid date: {e}"), | ||
| 442 | ) | ||
| 443 | })?; | ||
| 444 | // Org dates carry no timezone, so midnight UTC is the honest reading of one. | ||
| 445 | Ok(date | ||
| 446 | .and_hms_opt(0, 0, 0) | ||
| 447 | .expect("midnight is a valid time") | ||
| 448 | .format("%a, %d %b %Y %H:%M:%S +0000") | ||
| 449 | .to_string()) | ||
| 450 | }); | ||
| 451 | } | ||
| 452 | |||
| 453 | /// The starter RSS feed written by `org-ssg init`. A listing page with an XML template: | ||
| 454 | /// no feed-specific machinery, just `absolute` and `rfc822` doing what syndication needs. | ||
| 455 | /// | ||
| 456 | /// Emitted commented-out guidance rather than a broken feed when `site.base_url` is | ||
| 457 | /// unset — see the `init` scaffold, which leaves the feed collection commented out until | ||
| 458 | /// there is a base URL to make absolute links from. | ||
| 459 | pub const STARTER_FEED_TEMPLATE: &str = r#"<?xml version="1.0" encoding="utf-8"?> | ||
| 460 | <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> | ||
| 461 | <channel> | ||
| 462 | <title>{{ site.title }}</title> | ||
| 463 | <link>{{ "index.html" | absolute }}</link> | ||
| 464 | <description>{{ site.description }}</description> | ||
| 465 | <language>{{ site.language }}</language> | ||
| 466 | <atom:link href="{{ page.url | absolute }}" rel="self" type="application/rss+xml"/> | ||
| 467 | {%- for post in pages %} | ||
| 468 | <item> | ||
| 469 | <title>{{ post.title }}</title> | ||
| 470 | <link>{{ post.url | absolute }}</link> | ||
| 471 | <guid isPermaLink="true">{{ post.url | absolute }}</guid> | ||
| 472 | {%- if post.date_iso %} | ||
| 473 | <pubDate>{{ post.date_iso | rfc822 }}</pubDate> | ||
| 474 | {%- endif %} | ||
| 475 | {%- for tag in post.tags %} | ||
| 476 | <category>{{ tag }}</category> | ||
| 477 | {%- endfor %} | ||
| 478 | </item> | ||
| 479 | {%- endfor %} | ||
| 480 | </channel> | ||
| 481 | </rss> | ||
| 482 | "#; | ||
| 483 | |||
| 393 | /// HTML-escape template output, escaping the same characters Jinja2 does. | 484 | /// HTML-escape template output, escaping the same characters Jinja2 does. |
| 394 | /// | 485 | /// |
| 395 | /// minijinja additionally escapes `/` as `/`, which is a defence for values | 486 | /// minijinja additionally escapes `/` as `/`, which is a defence for values |
src/util.rs +20
| @@ -164,3 +164,23 @@ pub fn normalize_link_path(from_rel: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf | |||
| 164 | } | 164 | } |
| 165 | Utf8PathBuf::from(stack.join("/")) | 165 | Utf8PathBuf::from(stack.join("/")) |
| 166 | } | 166 | } |
| 167 | |||
| 168 | /// The `YYYY-MM-DD` inside an org date, if there is one. Org dates arrive as | ||
| 169 | /// `[2025-09-05 Fri 10:21:00]`, `<2024-05-01 Wed>` or bare `2024-05-01`, and a listing | ||
| 170 | /// needs one key it can sort on. | ||
| 171 | pub fn iso_date(raw: &str) -> Option<String> { | ||
| 172 | let bytes = raw.as_bytes(); | ||
| 173 | for i in 0..bytes.len().saturating_sub(9) { | ||
| 174 | let window = &bytes[i..i + 10]; | ||
| 175 | let digits = |r: std::ops::Range<usize>| window[r].iter().all(u8::is_ascii_digit); | ||
| 176 | if digits(0..4) && window[4] == b'-' && digits(5..7) && window[7] == b'-' && digits(8..10) { | ||
| 177 | // Must not be part of a longer number, or `123-45-6789` would parse. | ||
| 178 | let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit(); | ||
| 179 | let after_ok = i + 10 >= bytes.len() || !bytes[i + 10].is_ascii_digit(); | ||
| 180 | if before_ok && after_ok { | ||
| 181 | return Some(raw[i..i + 10].to_string()); | ||
| 182 | } | ||
| 183 | } | ||
| 184 | } | ||
| 185 | None | ||
| 186 | } | ||
tests/config.rs +172
| @@ -1297,3 +1297,175 @@ fn page_count_changes_add_and_remove_page_files() { | |||
| 1297 | let first = page(&out, "blog/index.html"); | 1297 | let first = page(&out, "blog/index.html"); |
| 1298 | assert!(first.contains("page 1/1 of 2"), "the paginator reflects the new size:\n{first}"); | 1298 | assert!(first.contains("page 1/1 of 2"), "the paginator reflects the new size:\n{first}"); |
| 1299 | } | 1299 | } |
| 1300 | |||
| 1301 | // --------------------------------------------------------------------------- | ||
| 1302 | // base_url and absolute URLs | ||
| 1303 | // --------------------------------------------------------------------------- | ||
| 1304 | |||
| 1305 | /// A site with a feed collection, optionally with a base URL configured. | ||
| 1306 | fn write_feed_site(src: &Utf8PathBuf, base_url: &str) { | ||
| 1307 | std::fs::create_dir_all(src.join("blog")).unwrap(); | ||
| 1308 | std::fs::create_dir_all(src.join("templates")).unwrap(); | ||
| 1309 | std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nWelcome.\n").unwrap(); | ||
| 1310 | std::fs::write( | ||
| 1311 | src.join("blog/post.org"), | ||
| 1312 | "#+TITLE: A Post\n#+DATE: [2026-02-02 Mon 09:15:00]\n#+FILETAGS: :rust:\n\nBody.\n", | ||
| 1313 | ) | ||
| 1314 | .unwrap(); | ||
| 1315 | std::fs::write( | ||
| 1316 | src.join("templates/feed.xml"), | ||
| 1317 | "<?xml version=\"1.0\"?><rss version=\"2.0\"><channel>\ | ||
| 1318 | <link>{{ \"index.html\" | absolute }}</link>\ | ||
| 1319 | {% for p in pages %}<item><link>{{ p.url | absolute }}</link>\ | ||
| 1320 | <pubDate>{{ p.date_iso | rfc822 }}</pubDate></item>{% endfor %}\ | ||
| 1321 | </channel></rss>", | ||
| 1322 | ) | ||
| 1323 | .unwrap(); | ||
| 1324 | std::fs::write( | ||
| 1325 | src.join("org-ssg.toml"), | ||
| 1326 | format!( | ||
| 1327 | "[site]\nbase_url = \"{base_url}\"\n\n\ | ||
| 1328 | [[collections]]\nsource = \"blog\"\noutput = \"feed.xml\"\n\ | ||
| 1329 | template = \"feed.xml\"\ntitle = \"Feed\"\n" | ||
| 1330 | ), | ||
| 1331 | ) | ||
| 1332 | .unwrap(); | ||
| 1333 | } | ||
| 1334 | |||
| 1335 | /// A feed is read away from the site that served it, so its links have to be absolute. | ||
| 1336 | #[test] | ||
| 1337 | fn a_feed_gets_absolute_urls_from_base_url() { | ||
| 1338 | let root = tmpdir("feedabs"); | ||
| 1339 | let src = root.join("src"); | ||
| 1340 | std::fs::create_dir_all(&src).unwrap(); | ||
| 1341 | write_feed_site(&src, "https://example.com"); | ||
| 1342 | let out = root.join("out"); | ||
| 1343 | build(&src, &out); | ||
| 1344 | |||
| 1345 | let feed = page(&out, "feed.xml"); | ||
| 1346 | assert!( | ||
| 1347 | feed.contains("<link>https://example.com/blog/post.html</link>"), | ||
| 1348 | "entry links are absolute:\n{feed}" | ||
| 1349 | ); | ||
| 1350 | assert!( | ||
| 1351 | feed.contains("<link>https://example.com/index.html</link>"), | ||
| 1352 | "a literal path can be made absolute too:\n{feed}" | ||
| 1353 | ); | ||
| 1354 | assert!(!feed.contains("<link>blog/"), "no relative link survives:\n{feed}"); | ||
| 1355 | } | ||
| 1356 | |||
| 1357 | /// RSS `pubDate` has a required format, and org dates are not in it. | ||
| 1358 | #[test] | ||
| 1359 | fn dates_convert_to_rfc822_for_rss() { | ||
| 1360 | let root = tmpdir("feedrfc"); | ||
| 1361 | let src = root.join("src"); | ||
| 1362 | std::fs::create_dir_all(&src).unwrap(); | ||
| 1363 | write_feed_site(&src, "https://example.com"); | ||
| 1364 | let out = root.join("out"); | ||
| 1365 | build(&src, &out); | ||
| 1366 | |||
| 1367 | assert!( | ||
| 1368 | page(&out, "feed.xml").contains("<pubDate>Mon, 02 Feb 2026 00:00:00 +0000</pubDate>"), | ||
| 1369 | "an org timestamp becomes an RSS date:\n{}", | ||
| 1370 | page(&out, "feed.xml") | ||
| 1371 | ); | ||
| 1372 | } | ||
| 1373 | |||
| 1374 | /// Falling back to a relative URL would produce a feed that validates nowhere and looks | ||
| 1375 | /// fine everywhere. The error has to name the setting and the fix. | ||
| 1376 | #[test] | ||
| 1377 | fn absolute_without_a_base_url_is_an_error_that_says_what_to_set() { | ||
| 1378 | let root = tmpdir("feednobase"); | ||
| 1379 | let src = root.join("src"); | ||
| 1380 | std::fs::create_dir_all(&src).unwrap(); | ||
| 1381 | write_feed_site(&src, ""); | ||
| 1382 | |||
| 1383 | let err = build_site(&src, &root.join("out"), &BuildOptions::default()) | ||
| 1384 | .expect_err("absolute with no base_url must fail"); | ||
| 1385 | let message = format!("{err:#}"); | ||
| 1386 | assert!(message.contains("base_url"), "names the setting: {message}"); | ||
| 1387 | assert!(message.contains("org-ssg.toml"), "names where to set it: {message}"); | ||
| 1388 | assert!(message.contains("feed.xml"), "names the template: {message}"); | ||
| 1389 | } | ||
| 1390 | |||
| 1391 | /// A base URL with a trailing slash would produce `https://example.com//blog/x.html`. | ||
| 1392 | #[test] | ||
| 1393 | fn a_trailing_slash_on_base_url_is_rejected() { | ||
| 1394 | let mut config = Config::default(); | ||
| 1395 | config.site.base_url = "https://example.com/".to_string(); | ||
| 1396 | let err = config.validate().expect_err("trailing slash must fail"); | ||
| 1397 | assert!(format!("{err:#}").contains("slash"), "{err:#}"); | ||
| 1398 | } | ||
| 1399 | |||
| 1400 | /// An already-absolute URL passes through, so a template can apply the filter uniformly | ||
| 1401 | /// to a mix of internal paths and external links. | ||
| 1402 | #[test] | ||
| 1403 | fn absolute_leaves_existing_absolute_urls_alone() { | ||
| 1404 | let root = tmpdir("feedpass"); | ||
| 1405 | let src = root.join("src"); | ||
| 1406 | std::fs::create_dir_all(&src).unwrap(); | ||
| 1407 | write_feed_site(&src, "https://example.com"); | ||
| 1408 | std::fs::write( | ||
| 1409 | src.join("templates/feed.xml"), | ||
| 1410 | "<x>{{ \"https://other.example/a.html\" | absolute }}</x>", | ||
| 1411 | ) | ||
| 1412 | .unwrap(); | ||
| 1413 | let out = root.join("out"); | ||
| 1414 | build(&src, &out); | ||
| 1415 | |||
| 1416 | assert_eq!(page(&out, "feed.xml"), "<x>https://other.example/a.html</x>"); | ||
| 1417 | } | ||
| 1418 | |||
| 1419 | /// Canonical links need an absolute URL, so the default layout emits one only when there | ||
| 1420 | /// is a base URL to build it from. | ||
| 1421 | #[test] | ||
| 1422 | fn the_default_layout_emits_a_canonical_link_only_with_a_base_url() { | ||
| 1423 | for (base, expect) in [("https://example.com", true), ("", false)] { | ||
| 1424 | let root = tmpdir("canonical"); | ||
| 1425 | let src = root.join("src"); | ||
| 1426 | std::fs::create_dir_all(&src).unwrap(); | ||
| 1427 | write_site(&src); | ||
| 1428 | std::fs::write( | ||
| 1429 | src.join("org-ssg.toml"), | ||
| 1430 | format!("[site]\nbase_url = \"{base}\"\n"), | ||
| 1431 | ) | ||
| 1432 | .unwrap(); | ||
| 1433 | let out = root.join("out"); | ||
| 1434 | build(&src, &out); | ||
| 1435 | |||
| 1436 | let html = page(&out, "blog/post.html"); | ||
| 1437 | assert_eq!( | ||
| 1438 | html.contains("<link rel=\"canonical\" href=\"https://example.com/blog/post.html\">"), | ||
| 1439 | expect, | ||
| 1440 | "base_url {base:?} canonical presence:\n{html}" | ||
| 1441 | ); | ||
| 1442 | } | ||
| 1443 | } | ||
| 1444 | |||
| 1445 | /// `base_url` changes every absolute URL on the site, so it has to invalidate the cache | ||
| 1446 | /// like any other config change. | ||
| 1447 | #[test] | ||
| 1448 | fn changing_base_url_re_renders_the_site() { | ||
| 1449 | let root = tmpdir("basehash"); | ||
| 1450 | let src = root.join("src"); | ||
| 1451 | std::fs::create_dir_all(&src).unwrap(); | ||
| 1452 | write_site(&src); | ||
| 1453 | std::fs::write( | ||
| 1454 | src.join("org-ssg.toml"), | ||
| 1455 | "[site]\nbase_url = \"https://example.com\"\n", | ||
| 1456 | ) | ||
| 1457 | .unwrap(); | ||
| 1458 | let out = root.join("out"); | ||
| 1459 | build(&src, &out); | ||
| 1460 | assert!(build(&src, &out).rendered.is_empty(), "unchanged rebuild renders nothing"); | ||
| 1461 | |||
| 1462 | std::fs::write( | ||
| 1463 | src.join("org-ssg.toml"), | ||
| 1464 | "[site]\nbase_url = \"https://moved.example\"\n", | ||
| 1465 | ) | ||
| 1466 | .unwrap(); | ||
| 1467 | let report = build(&src, &out); | ||
| 1468 | |||
| 1469 | assert_eq!(report.rendered.len(), 3, "every page carries the base URL"); | ||
| 1470 | assert!(page(&out, "index.html").contains("https://moved.example/index.html")); | ||
| 1471 | } | ||