krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
8911dc10dbedad77249387da12a318f0f5d0143f
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T05:32:03Z
Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 30 +++++++++- src/config.rs | 11 +++- src/main.rs | 9 ++- src/site.rs | 26 +-------- src/template.rs | 93 +++++++++++++++++++++++++++++- src/util.rs | 20 +++++++ tests/config.rs | 172 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 332 insertions(+), 33 deletions(-) @@ -569,7 +569,7 @@ dependencies = [ [[package]] name = "org-ssg" -version = "0.9.0" +version = "0.10.0" dependencies = [ "anyhow", "blake3", @@ -1,6 +1,6 @@ [package] name = "org-ssg" -version = "0.9.0" +version = "0.10.0" edition = "2021" description = "Org-mode static site generator that renders the org element tree straight to HTML" license = "MIT" @@ -43,7 +43,7 @@ value below is the default. ```toml [site] title = "org-ssg site" -base_url = "" # absolute URL, no trailing slash; empty = relative URLs only +base_url = "" # absolute URL, no trailing slash; needed for feeds/canonical links description = "" language = "en" @@ -200,9 +200,32 @@ changed — four pages, not one per tag. That precision is why `groups` is given index and not to every group page: a page that can see every group depends on every group. +#### Feeds and absolute URLs + **A feed is a listing page with an XML template**, not a separate feature — templates are loaded by full filename and any extension, so `output = "feed.xml"` with -`template = "feed.xml"` is all it takes. +`template = "feed.xml"` is all it takes. `org-ssg init` writes a working RSS template. + +A feed is read away from the site that served it, so relative links in one are simply +broken. Set `site.base_url` and use the `absolute` filter: + +```jinja +<link>{{ post.url | absolute }}</link> +<pubDate>{{ post.date_iso | rfc822 }}</pubDate> +``` + +| Filter | Does | +|---|---| +| `absolute` | site-root-relative path → absolute URL; already-absolute URLs pass through | +| `rfc822` | any org or ISO date → the format RSS `pubDate` requires | + +Apply `absolute` to the site-root-relative values — `page.url`, `pages[].url`, +`group.url` — and not to `nav[].url`, `paginator.*_url`, `stylesheet` or `root`, which +are relative to the page carrying them and already correct there. + +With no `base_url`, `absolute` is an **error** naming the setting, rather than quietly +emitting a relative URL that would make the feed invalid everywhere while looking fine. +The default layout also emits `<link rel="canonical">` when a base URL is set. Listing pages are cached on the entries they list, so adding a post re-renders that 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 | **9** | **Generated listing pages: `[[collections]]`, sorted indexes, feeds via XML templates** | **done** | | **10** | **Grouped collections: one page per tag plus a tag index — full parity with the incumbent** | **done** | | **11** | **Pagination: numbered pages with a `paginator` context, composing with grouping** | **done** | +| **12** | **`base_url`: `absolute`/`rfc822` filters, a valid RSS feed in the scaffold, canonical links** | **done** | ### v0.2 in / out @@ -541,7 +565,7 @@ PARSE/RESOLVE/RENDER), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserro ``` cargo build -cargo test # 115 tests +cargo test # 122 tests cargo run -- init my-site # scaffold a new site cargo run -- build fixtures/minimal.org -o minimal.html # single file cargo run -- build fixtures/site -o _site # whole site (incremental) @@ -372,7 +372,8 @@ pub const STARTER_CONFIG: &str = r#"# org-ssg configuration. Every setting here [site] title = "org-ssg site" -# Absolute base URL, no trailing slash. Leave empty to build with relative URLs only. +# Absolute base URL, no trailing slash. Needed for feeds and canonical links, which +# cannot be relative — set it and uncomment the [[collections]] feed block below. base_url = "" description = "" language = "en" @@ -418,6 +419,14 @@ nav = true # put this listing page in the site nav # paginate = 10 # entries per page; page 1 stays at `output` # paginate_output = "blog/page/{n}.html" # where pages 2..N go; needs {n} +# An RSS feed is a listing page with an XML template. It needs site.base_url above, +# because a feed is read away from the site that served it and relative links break. +# [[collections]] +# source = "blog" +# output = "feed.xml" +# template = "feed.xml" +# title = "Feed" + # One page per tag, plus an index of all tags. `{tag}` in `output`/`title` is replaced # by each tag; the index gets `groups` instead of `pages`. [[collections]] @@ -132,7 +132,9 @@ fn main() -> Result<()> { /// in a directory that has content is safe and additive rather than destructive. fn init(dir: &Utf8Path) -> Result<()> { use org_ssg::config::{CONFIG_FILE, STARTER_CONFIG}; - use org_ssg::template::{starter_template, STARTER_LIST_TEMPLATE, STARTER_TAGS_TEMPLATE}; + use org_ssg::template::{ + starter_template, STARTER_FEED_TEMPLATE, STARTER_LIST_TEMPLATE, STARTER_TAGS_TEMPLATE, + }; fs::create_dir_all(dir).with_context(|| format!("creating {dir}"))?; fs::create_dir_all(dir.join("templates")).with_context(|| format!("creating {dir}/templates"))?; @@ -165,11 +167,12 @@ fn init(dir: &Utf8Path) -> Result<()> { "in org-ssg.toml, newest first.\n", ); - let files: [(Utf8PathBuf, &str); 6] = [ + let files: [(Utf8PathBuf, &str); 7] = [ (dir.join(CONFIG_FILE), STARTER_CONFIG), (dir.join("templates/base.html"), starter_template()), (dir.join("templates/list.html"), STARTER_LIST_TEMPLATE), (dir.join("templates/tags.html"), STARTER_TAGS_TEMPLATE), + (dir.join("templates/feed.xml"), STARTER_FEED_TEMPLATE), (dir.join("index.org"), index), (dir.join("blog/first-post.org"), post), ]; @@ -272,7 +275,7 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> { let dir = input.parent().unwrap_or_else(|| Utf8Path::new(".")); let config = Config::load(dir)?; config.validate()?; - let templater = Templater::load(Some(&dir.join(&config.templates.dir)))?; + let templater = Templater::load(Some(&dir.join(&config.templates.dir)), &config.site.base_url)?; let css_text = render::syntax_css(&config.highlight.theme).ok_or_else(|| { anyhow::anyhow!( "unknown highlight.theme {:?}. Available: {}", @@ -32,7 +32,7 @@ use crate::template::{ GroupContext, NavItem, PageContext, Paginator, PaginatorPage, RenderContext, SiteContext, Templater, }; -use crate::util::{output_path, output_url, relative_root, slugify}; +use crate::util::{iso_date, output_path, output_url, relative_root, slugify}; /// A fully built page: source and output paths (relative to their roots) and its /// final templated HTML. @@ -207,26 +207,6 @@ fn push_paginated( } } -/// The `YYYY-MM-DD` inside an org date, if there is one. Org dates arrive as -/// `[2025-09-05 Fri 10:21:00]`, `<2024-05-01 Wed>` or bare `2024-05-01`, and a listing -/// needs one key it can sort on. -pub fn iso_date(raw: &str) -> Option<String> { - let bytes = raw.as_bytes(); - for i in 0..bytes.len().saturating_sub(9) { - let window = &bytes[i..i + 10]; - let digits = |r: std::ops::Range<usize>| window[r].iter().all(u8::is_ascii_digit); - if digits(0..4) && window[4] == b'-' && digits(5..7) && window[7] == b'-' && digits(8..10) { - // Must not be part of a longer number, or `123-45-6789` would parse. - let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit(); - let after_ok = i + 10 >= bytes.len() || !bytes[i + 10].is_ascii_digit(); - if before_ok && after_ok { - return Some(raw[i..i + 10].to_string()); - } - } - } - None -} - /// Build the listing pages a config asks for, each with its entries sorted. fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> { let mut listings = Vec::new(); @@ -637,7 +617,7 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> { config.validate()?; let (preps, _symbols) = prepare_pages(src, &config, None)?; let highlighter = SyntectHighlighter::new(); - let templater = Templater::load(Some(&src.join(&config.templates.dir)))?; + let templater = Templater::load(Some(&src.join(&config.templates.dir)), &config.site.base_url)?; let site = site_context(&config); let listing = page_listing(&config, &preps); let render_opts = render_options(&config); @@ -724,7 +704,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result let (_org_rel, assets) = discover(src, &cfg, Some(out))?; let (preps, symbols) = prepare_pages(src, &cfg, Some(out))?; - let templater = Templater::load(Some(&src.join(&cfg.templates.dir)))?; + let templater = Templater::load(Some(&src.join(&cfg.templates.dir)), &cfg.site.base_url)?; let syntax_css = render::syntax_css(&cfg.highlight.theme).ok_or_else(|| { anyhow::anyhow!( "unknown highlight.theme {:?}. Available: {}", @@ -66,6 +66,9 @@ const BASE_TEMPLATE: &str = r#"<!DOCTYPE html> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{{ page.title }} · {{ site.title }}</title> +{%- if site.base_url %} +<link rel="canonical" href="{{ page.url | absolute }}"> +{%- endif %} {%- if page.description %} <meta name="description" content="{{ page.description }}"> {%- endif %} @@ -118,7 +121,7 @@ impl Templater { /// exists but contains a template that does not compile is an error: it means /// someone is actively editing their layout, and rendering the built-in default /// instead would look like their edit silently did nothing. - pub fn load(dir: Option<&Utf8Path>) -> Result<Self> { + pub fn load(dir: Option<&Utf8Path>, base_url: &str) -> Result<Self> { let mut sources: Vec<(String, String)> = Vec::new(); if let Some(dir) = dir.filter(|d| d.is_dir()) { @@ -156,6 +159,7 @@ impl Templater { let mut env = Environment::new(); env.set_formatter(html_formatter); + add_filters(&mut env, base_url); for (name, source) in &sources { // `Environment<'static>` needs owned sources; leaking is bounded by the // template count and lives as long as the build anyway. @@ -390,6 +394,93 @@ pub const STARTER_LIST_TEMPLATE: &str = r#"<!DOCTYPE html> </html> "#; +/// Filters a template can use beyond minijinja's built-ins. +/// +/// Both exist for the same reason: a syndication feed has requirements an HTML page does +/// not, and satisfying them by hand in a template is the kind of thing that produces a +/// feed which *looks* right and fails validation. +fn add_filters(env: &mut Environment<'static>, base_url: &str) { + let base = base_url.trim_end_matches('/').to_string(); + + // `absolute`: a site-root-relative path → an absolute URL. + // + // Feeds are read away from the site that served them, so relative links in one are + // simply broken. Applies to the site-root-relative paths — `page.url`, `pages[].url`, + // `group.url` — and not to `nav[].url`, `paginator.*_url`, `stylesheet` or `root`, + // which are relative to the page carrying them and already correct in a page. + env.add_filter( + "absolute", + move |path: &str| -> Result<String, minijinja::Error> { + if base.is_empty() { + // Returning the relative path would produce a feed that validates + // nowhere and looks fine everywhere. Say what is missing instead. + return Err(minijinja::Error::new( + minijinja::ErrorKind::InvalidOperation, + "the `absolute` filter needs site.base_url, which is empty; \ + set it in org-ssg.toml (e.g. base_url = \"https://example.com\")", + )); + } + if path.starts_with("http://") || path.starts_with("https://") { + return Ok(path.to_string()); + } + Ok(format!("{base}/{}", path.trim_start_matches('/'))) + }, + ); + + // `rfc822`: an org or ISO date → the format RSS `pubDate` requires. + env.add_filter("rfc822", |raw: &str| -> Result<String, minijinja::Error> { + let iso = crate::util::iso_date(raw).ok_or_else(|| { + minijinja::Error::new( + minijinja::ErrorKind::InvalidOperation, + format!("cannot read a date out of {raw:?} for an RSS pubDate"), + ) + })?; + let date = chrono::NaiveDate::parse_from_str(&iso, "%Y-%m-%d").map_err(|e| { + minijinja::Error::new( + minijinja::ErrorKind::InvalidOperation, + format!("{iso} is not a valid date: {e}"), + ) + })?; + // Org dates carry no timezone, so midnight UTC is the honest reading of one. + Ok(date + .and_hms_opt(0, 0, 0) + .expect("midnight is a valid time") + .format("%a, %d %b %Y %H:%M:%S +0000") + .to_string()) + }); +} + +/// The starter RSS feed written by `org-ssg init`. A listing page with an XML template: +/// no feed-specific machinery, just `absolute` and `rfc822` doing what syndication needs. +/// +/// Emitted commented-out guidance rather than a broken feed when `site.base_url` is +/// unset — see the `init` scaffold, which leaves the feed collection commented out until +/// there is a base URL to make absolute links from. +pub const STARTER_FEED_TEMPLATE: &str = r#"<?xml version="1.0" encoding="utf-8"?> +<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> +<channel> +<title>{{ site.title }}</title> +<link>{{ "index.html" | absolute }}</link> +<description>{{ site.description }}</description> +<language>{{ site.language }}</language> +<atom:link href="{{ page.url | absolute }}" rel="self" type="application/rss+xml"/> +{%- for post in pages %} +<item> +<title>{{ post.title }}</title> +<link>{{ post.url | absolute }}</link> +<guid isPermaLink="true">{{ post.url | absolute }}</guid> +{%- if post.date_iso %} +<pubDate>{{ post.date_iso | rfc822 }}</pubDate> +{%- endif %} +{%- for tag in post.tags %} +<category>{{ tag }}</category> +{%- endfor %} +</item> +{%- endfor %} +</channel> +</rss> +"#; + /// HTML-escape template output, escaping the same characters Jinja2 does. /// /// minijinja additionally escapes `/` as `/`, which is a defence for values @@ -164,3 +164,23 @@ pub fn normalize_link_path(from_rel: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf } Utf8PathBuf::from(stack.join("/")) } + +/// The `YYYY-MM-DD` inside an org date, if there is one. Org dates arrive as +/// `[2025-09-05 Fri 10:21:00]`, `<2024-05-01 Wed>` or bare `2024-05-01`, and a listing +/// needs one key it can sort on. +pub fn iso_date(raw: &str) -> Option<String> { + let bytes = raw.as_bytes(); + for i in 0..bytes.len().saturating_sub(9) { + let window = &bytes[i..i + 10]; + let digits = |r: std::ops::Range<usize>| window[r].iter().all(u8::is_ascii_digit); + if digits(0..4) && window[4] == b'-' && digits(5..7) && window[7] == b'-' && digits(8..10) { + // Must not be part of a longer number, or `123-45-6789` would parse. + let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit(); + let after_ok = i + 10 >= bytes.len() || !bytes[i + 10].is_ascii_digit(); + if before_ok && after_ok { + return Some(raw[i..i + 10].to_string()); + } + } + } + None +} @@ -1297,3 +1297,175 @@ fn page_count_changes_add_and_remove_page_files() { let first = page(&out, "blog/index.html"); assert!(first.contains("page 1/1 of 2"), "the paginator reflects the new size:\n{first}"); } + +// --------------------------------------------------------------------------- +// base_url and absolute URLs +// --------------------------------------------------------------------------- + +/// A site with a feed collection, optionally with a base URL configured. +fn write_feed_site(src: &Utf8PathBuf, base_url: &str) { + std::fs::create_dir_all(src.join("blog")).unwrap(); + std::fs::create_dir_all(src.join("templates")).unwrap(); + std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nWelcome.\n").unwrap(); + std::fs::write( + src.join("blog/post.org"), + "#+TITLE: A Post\n#+DATE: [2026-02-02 Mon 09:15:00]\n#+FILETAGS: :rust:\n\nBody.\n", + ) + .unwrap(); + std::fs::write( + src.join("templates/feed.xml"), + "<?xml version=\"1.0\"?><rss version=\"2.0\"><channel>\ + <link>{{ \"index.html\" | absolute }}</link>\ + {% for p in pages %}<item><link>{{ p.url | absolute }}</link>\ + <pubDate>{{ p.date_iso | rfc822 }}</pubDate></item>{% endfor %}\ + </channel></rss>", + ) + .unwrap(); + std::fs::write( + src.join("org-ssg.toml"), + format!( + "[site]\nbase_url = \"{base_url}\"\n\n\ + [[collections]]\nsource = \"blog\"\noutput = \"feed.xml\"\n\ + template = \"feed.xml\"\ntitle = \"Feed\"\n" + ), + ) + .unwrap(); +} + +/// A feed is read away from the site that served it, so its links have to be absolute. +#[test] +fn a_feed_gets_absolute_urls_from_base_url() { + let root = tmpdir("feedabs"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_feed_site(&src, "https://example.com"); + let out = root.join("out"); + build(&src, &out); + + let feed = page(&out, "feed.xml"); + assert!( + feed.contains("<link>https://example.com/blog/post.html</link>"), + "entry links are absolute:\n{feed}" + ); + assert!( + feed.contains("<link>https://example.com/index.html</link>"), + "a literal path can be made absolute too:\n{feed}" + ); + assert!(!feed.contains("<link>blog/"), "no relative link survives:\n{feed}"); +} + +/// RSS `pubDate` has a required format, and org dates are not in it. +#[test] +fn dates_convert_to_rfc822_for_rss() { + let root = tmpdir("feedrfc"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_feed_site(&src, "https://example.com"); + let out = root.join("out"); + build(&src, &out); + + assert!( + page(&out, "feed.xml").contains("<pubDate>Mon, 02 Feb 2026 00:00:00 +0000</pubDate>"), + "an org timestamp becomes an RSS date:\n{}", + page(&out, "feed.xml") + ); +} + +/// Falling back to a relative URL would produce a feed that validates nowhere and looks +/// fine everywhere. The error has to name the setting and the fix. +#[test] +fn absolute_without_a_base_url_is_an_error_that_says_what_to_set() { + let root = tmpdir("feednobase"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_feed_site(&src, ""); + + let err = build_site(&src, &root.join("out"), &BuildOptions::default()) + .expect_err("absolute with no base_url must fail"); + let message = format!("{err:#}"); + assert!(message.contains("base_url"), "names the setting: {message}"); + assert!(message.contains("org-ssg.toml"), "names where to set it: {message}"); + assert!(message.contains("feed.xml"), "names the template: {message}"); +} + +/// A base URL with a trailing slash would produce `https://example.com//blog/x.html`. +#[test] +fn a_trailing_slash_on_base_url_is_rejected() { + let mut config = Config::default(); + config.site.base_url = "https://example.com/".to_string(); + let err = config.validate().expect_err("trailing slash must fail"); + assert!(format!("{err:#}").contains("slash"), "{err:#}"); +} + +/// An already-absolute URL passes through, so a template can apply the filter uniformly +/// to a mix of internal paths and external links. +#[test] +fn absolute_leaves_existing_absolute_urls_alone() { + let root = tmpdir("feedpass"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_feed_site(&src, "https://example.com"); + std::fs::write( + src.join("templates/feed.xml"), + "<x>{{ \"https://other.example/a.html\" | absolute }}</x>", + ) + .unwrap(); + let out = root.join("out"); + build(&src, &out); + + assert_eq!(page(&out, "feed.xml"), "<x>https://other.example/a.html</x>"); +} + +/// Canonical links need an absolute URL, so the default layout emits one only when there +/// is a base URL to build it from. +#[test] +fn the_default_layout_emits_a_canonical_link_only_with_a_base_url() { + for (base, expect) in [("https://example.com", true), ("", false)] { + let root = tmpdir("canonical"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_site(&src); + std::fs::write( + src.join("org-ssg.toml"), + format!("[site]\nbase_url = \"{base}\"\n"), + ) + .unwrap(); + let out = root.join("out"); + build(&src, &out); + + let html = page(&out, "blog/post.html"); + assert_eq!( + html.contains("<link rel=\"canonical\" href=\"https://example.com/blog/post.html\">"), + expect, + "base_url {base:?} canonical presence:\n{html}" + ); + } +} + +/// `base_url` changes every absolute URL on the site, so it has to invalidate the cache +/// like any other config change. +#[test] +fn changing_base_url_re_renders_the_site() { + let root = tmpdir("basehash"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_site(&src); + std::fs::write( + src.join("org-ssg.toml"), + "[site]\nbase_url = \"https://example.com\"\n", + ) + .unwrap(); + let out = root.join("out"); + build(&src, &out); + assert!(build(&src, &out).rendered.is_empty(), "unchanged rebuild renders nothing"); + + std::fs::write( + src.join("org-ssg.toml"), + "[site]\nbase_url = \"https://moved.example\"\n", + ) + .unwrap(); + let report = build(&src, &out); + + assert_eq!(report.rendered.len(), 3, "every page carries the base URL"); + assert!(page(&out, "index.html").contains("https://moved.example/index.html")); +}