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

v0.10: base_url, absolute URLs, and a valid RSS feed

site.base_url existed in config and was handed to templates, but nothing consumed it: a
feed template had to concatenate URLs by hand, which is how feeds end up with double
slashes or relative links. Two filters make it a real capability.

`absolute` turns a site-root-relative path into an absolute URL, and passes through
anything already absolute so a template can apply it uniformly to internal paths and
external links. It applies to the site-root-relative values — page.url, pages[].url,
group.url — and deliberately not to nav[].url, paginator.*_url, stylesheet or root,
which are relative to the page carrying them and already correct there.

`rfc822` converts any org or ISO date to the format RSS pubDate requires, so
[2026-02-02 Mon 09:15:00] becomes "Mon, 02 Feb 2026 00:00:00 +0000". Org dates carry no
timezone, so midnight UTC is the honest reading.

With no base_url, `absolute` is an error rather than a fallback to the relative path. A
feed with relative links validates nowhere and looks fine everywhere, which is the worst
combination; the message names the setting, the file to set it in, an example value, and
the template and line that needed it.

`org-ssg init` now writes a working RSS template and a commented-out feed collection,
left commented because it cannot work until there is a base URL. The default layout
emits <link rel="canonical"> when a base URL is set.

Verified: the generated feed parses as well-formed XML, is RSS 2.0, every item link is
absolute, and every pubDate round-trips through Python's email.utils.parsedate_to_datetime.
base_url is part of the config hash, so changing it re-renders the site. All 182
incumbent URLs still reproduced.
 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(-)

diff --git a/Cargo.lock b/Cargo.lock
index d249110..4331cb6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -569,7 +569,7 @@ dependencies = [
 
 [[package]]
 name = "org-ssg"
-version = "0.9.0"
+version = "0.10.0"
 dependencies = [
  "anyhow",
  "blake3",
diff --git a/Cargo.toml b/Cargo.toml
index ece3df6..b017cf6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"
diff --git a/README.md b/README.md
index c2ea9d3..1221b1b 100644
--- a/README.md
+++ b/README.md
@@ -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)
diff --git a/src/config.rs b/src/config.rs
index e18d7a1..8b6e4ce 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -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]]
diff --git a/src/main.rs b/src/main.rs
index d522622..450efc4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -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: {}",
diff --git a/src/site.rs b/src/site.rs
index c819848..c1a7e65 100644
--- a/src/site.rs
+++ b/src/site.rs
@@ -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: {}",
diff --git a/src/template.rs b/src/template.rs
index fe8b5c4..889362b 100644
--- a/src/template.rs
+++ b/src/template.rs
@@ -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 }} &middot; {{ 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 `&#x2f;`, which is a defence for values
diff --git a/src/util.rs b/src/util.rs
index c1506ec..2d974bc 100644
--- a/src/util.rs
+++ b/src/util.rs
@@ -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
+}
diff --git a/tests/config.rs b/tests/config.rs
index d49f073..1614682 100644
--- a/tests/config.rs
+++ b/tests/config.rs
@@ -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"));
+}