krz/orgo

Lightning fast org-mode static site generator. fast go org-mode static-site-generator

Commit 8911dc10db

8911dc10dbedad77249387da12a318f0f5d0143f

parent: f7cc7db743

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-11 05:32 UTC

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.

Layout: unified · split

Cargo.lock +1 −1
@@ -569,7 +569,7 @@ dependencies = [
569569
570570[[package]]
571571name = "org-ssg"
572version = "0.9.0"
572version = "0.10.0"
573573dependencies = [
574574 "anyhow",
575575 "blake3",
Cargo.toml +1 −1
@@ -1,6 +1,6 @@
11[package]
22name = "org-ssg"
3version = "0.9.0"
3version = "0.10.0"
44edition = "2021"
55description = "Org-mode static site generator that renders the org element tree straight to HTML"
66license = "MIT"
README.md +27 −3
@@ -43,7 +43,7 @@ value below is the default.
4343```toml
4444[site]
4545title = "org-ssg site"
46base_url = "" # absolute URL, no trailing slash; empty = relative URLs only
46base_url = "" # absolute URL, no trailing slash; needed for feeds/canonical links
4747description = ""
4848language = "en"
4949
@@ -200,9 +200,32 @@ changed — four pages, not one per tag. That precision is why `groups` is given
200200index and not to every group page: a page that can see every group depends on every
201201group.
202202
203#### Feeds and absolute URLs
204
203205**A feed is a listing page with an XML template**, not a separate feature — templates are
204206loaded 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
209A feed is read away from the site that served it, so relative links in one are simply
210broken. 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
222Apply `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
224are relative to the page carrying them and already correct there.
225
226With no `base_url`, `absolute` is an **error** naming the setting, rather than quietly
227emitting a relative URL that would make the feed invalid everywhere while looking fine.
228The default layout also emits `<link rel="canonical">` when a base URL is set.
206229
207230Listing pages are cached on the entries they list, so adding a post re-renders that
208231section'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
278301| **9** | **Generated listing pages: `[[collections]]`, sorted indexes, feeds via XML templates** | **done** |
279302| **10** | **Grouped collections: one page per tag plus a tag index — full parity with the incumbent** | **done** |
280303| **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** |
281305
282306### v0.2 in / out
283307
@@ -541,7 +565,7 @@ PARSE/RESOLVE/RENDER), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserro
541565
542566```
543567cargo build
544cargo test # 115 tests
568cargo test # 122 tests
545569cargo run -- init my-site # scaffold a new site
546570cargo run -- build fixtures/minimal.org -o minimal.html # single file
547571cargo 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
372372
373373[site]
374374title = "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.
376377base_url = ""
377378description = ""
378379language = "en"
@@ -418,6 +419,14 @@ nav = true # put this listing page in the site nav
418419# paginate = 10 # entries per page; page 1 stays at `output`
419420# paginate_output = "blog/page/{n}.html" # where pages 2..N go; needs {n}
420421
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
421430# One page per tag, plus an index of all tags. `{tag}` in `output`/`title` is replaced
422431# by each tag; the index gets `groups` instead of `pages`.
423432[[collections]]
src/main.rs +6 −3
@@ -132,7 +132,9 @@ fn main() -> Result<()> {
132132/// in a directory that has content is safe and additive rather than destructive.
133133fn init(dir: &Utf8Path) -> Result<()> {
134134 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 };
136138
137139 fs::create_dir_all(dir).with_context(|| format!("creating {dir}"))?;
138140 fs::create_dir_all(dir.join("templates")).with_context(|| format!("creating {dir}/templates"))?;
@@ -165,11 +167,12 @@ fn init(dir: &Utf8Path) -> Result<()> {
165167 "in org-ssg.toml, newest first.\n",
166168 );
167169
168 let files: [(Utf8PathBuf, &str); 6] = [
170 let files: [(Utf8PathBuf, &str); 7] = [
169171 (dir.join(CONFIG_FILE), STARTER_CONFIG),
170172 (dir.join("templates/base.html"), starter_template()),
171173 (dir.join("templates/list.html"), STARTER_LIST_TEMPLATE),
172174 (dir.join("templates/tags.html"), STARTER_TAGS_TEMPLATE),
175 (dir.join("templates/feed.xml"), STARTER_FEED_TEMPLATE),
173176 (dir.join("index.org"), index),
174177 (dir.join("blog/first-post.org"), post),
175178 ];
@@ -272,7 +275,7 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
272275 let dir = input.parent().unwrap_or_else(|| Utf8Path::new("."));
273276 let config = Config::load(dir)?;
274277 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)?;
276279 let css_text = render::syntax_css(&config.highlight.theme).ok_or_else(|| {
277280 anyhow::anyhow!(
278281 "unknown highlight.theme {:?}. Available: {}",
src/site.rs +3 −23
@@ -32,7 +32,7 @@ use crate::template::{
3232 GroupContext, NavItem, PageContext, Paginator, PaginatorPage, RenderContext, SiteContext,
3333 Templater,
3434};
35use crate::util::{output_path, output_url, relative_root, slugify};
35use crate::util::{iso_date, output_path, output_url, relative_root, slugify};
3636
3737/// A fully built page: source and output paths (relative to their roots) and its
3838/// final templated HTML.
@@ -207,26 +207,6 @@ fn push_paginated(
207207 }
208208}
209209
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.
213pub 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
230210/// Build the listing pages a config asks for, each with its entries sorted.
231211fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
232212 let mut listings = Vec::new();
@@ -637,7 +617,7 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
637617 config.validate()?;
638618 let (preps, _symbols) = prepare_pages(src, &config, None)?;
639619 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)?;
641621 let site = site_context(&config);
642622 let listing = page_listing(&config, &preps);
643623 let render_opts = render_options(&config);
@@ -724,7 +704,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
724704 let (_org_rel, assets) = discover(src, &cfg, Some(out))?;
725705 let (preps, symbols) = prepare_pages(src, &cfg, Some(out))?;
726706
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)?;
728708 let syntax_css = render::syntax_css(&cfg.highlight.theme).ok_or_else(|| {
729709 anyhow::anyhow!(
730710 "unknown highlight.theme {:?}. Available: {}",
src/template.rs +92 −1
@@ -66,6 +66,9 @@ const BASE_TEMPLATE: &str = r#"<!DOCTYPE html>
6666<meta charset="utf-8">
6767<meta name="viewport" content="width=device-width, initial-scale=1">
6868<title>{{ page.title }} &middot; {{ site.title }}</title>
69{%- if site.base_url %}
70<link rel="canonical" href="{{ page.url | absolute }}">
71{%- endif %}
6972{%- if page.description %}
7073<meta name="description" content="{{ page.description }}">
7174{%- endif %}
@@ -118,7 +121,7 @@ impl Templater {
118121 /// exists but contains a template that does not compile is an error: it means
119122 /// someone is actively editing their layout, and rendering the built-in default
120123 /// 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> {
122125 let mut sources: Vec<(String, String)> = Vec::new();
123126
124127 if let Some(dir) = dir.filter(|d| d.is_dir()) {
@@ -156,6 +159,7 @@ impl Templater {
156159
157160 let mut env = Environment::new();
158161 env.set_formatter(html_formatter);
162 add_filters(&mut env, base_url);
159163 for (name, source) in &sources {
160164 // `Environment<'static>` needs owned sources; leaking is bounded by the
161165 // template count and lives as long as the build anyway.
@@ -390,6 +394,93 @@ pub const STARTER_LIST_TEMPLATE: &str = r#"<!DOCTYPE html>
390394</html>
391395"#;
392396
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.
402fn 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.
459pub 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
393484/// HTML-escape template output, escaping the same characters Jinja2 does.
394485///
395486/// minijinja additionally escapes `/` as `&#x2f;`, 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
164164 }
165165 Utf8PathBuf::from(stack.join("/"))
166166}
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.
171pub 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() {
12971297 let first = page(&out, "blog/index.html");
12981298 assert!(first.contains("page 1/1 of 2"), "the paginator reflects the new size:\n{first}");
12991299}
1300
1301// ---------------------------------------------------------------------------
1302// base_url and absolute URLs
1303// ---------------------------------------------------------------------------
1304
1305/// A site with a feed collection, optionally with a base URL configured.
1306fn 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]
1337fn 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]
1359fn 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]
1377fn 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]
1393fn 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]
1403fn 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]
1422fn 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]
1448fn 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}