krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
v0.20.2: tests/config.rs · raw
1//! Configuration, templating and discovery — the surface that decides whether this is a
2//! generator for one site or for anyone's.
3//!
4//! The theme running through these tests is that **the zero-config path has to work**.
5//! A directory of `.org` files with no `orgo.toml`, no templates and no knowledge of
6//! this tool must build into a real site; configuration is how you change the output,
7//! never how you make it work at all.
8
9use std::sync::atomic::{AtomicU32, Ordering};
10
11use camino::Utf8PathBuf;
12
13use orgo::config::{Config, NavMode};
14use orgo::site::{build_site, BuildOptions};
15
16fn tmpdir(tag: &str) -> Utf8PathBuf {
17 static N: AtomicU32 = AtomicU32::new(0);
18 let n = N.fetch_add(1, Ordering::Relaxed);
19 let base = Utf8PathBuf::from_path_buf(std::env::temp_dir())
20 .expect("utf-8 temp dir")
21 .join(format!("orgo-cfg-{}-{tag}-{n}", std::process::id()));
22 let _ = std::fs::remove_dir_all(&base);
23 std::fs::create_dir_all(&base).unwrap();
24 base
25}
26
27/// A site with a root page, a second root page, and one nested page.
28fn write_site(src: &Utf8PathBuf) {
29 std::fs::create_dir_all(src.join("blog")).unwrap();
30 std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nWelcome.\n").unwrap();
31 std::fs::write(src.join("about.org"), "#+TITLE: About\n\nAbout.\n").unwrap();
32 std::fs::write(
33 src.join("blog/post.org"),
34 "#+TITLE: A Post\n#+DATE: 2024-05-01\n#+FILETAGS: :rust:web:\n\nBody.\n",
35 )
36 .unwrap();
37}
38
39fn build(src: &Utf8PathBuf, out: &Utf8PathBuf) -> orgo::site::SiteReport {
40 build_site(src, out, &BuildOptions::default()).expect("build")
41}
42
43fn page(out: &Utf8PathBuf, rel: &str) -> String {
44 std::fs::read_to_string(out.join(rel)).unwrap_or_else(|e| panic!("reading {rel}: {e}"))
45}
46
47// ---------------------------------------------------------------------------
48// Zero config
49// ---------------------------------------------------------------------------
50
51/// The headline promise: point it at a directory of org files and get a site.
52#[test]
53fn a_bare_directory_of_org_files_builds_with_no_config() {
54 let root = tmpdir("bare");
55 let src = root.join("src");
56 std::fs::create_dir_all(&src).unwrap();
57 write_site(&src);
58 let out = root.join("out");
59
60 let report = build(&src, &out);
61 assert_eq!(report.pages.len(), 3);
62
63 let home = page(&out, "index.html");
64 assert!(home.contains("<!DOCTYPE html>"), "a full page, not a fragment");
65 assert!(home.contains("Welcome."), "the content is there");
66 assert!(
67 out.join("syntax.css").exists(),
68 "the stylesheet the highlighter needs is emitted too"
69 );
70}
71
72/// A missing config is normal. A *malformed* one is not: someone who wrote a config
73/// meant it, and quietly building the default site would hide their typo behind
74/// plausible-looking output.
75#[test]
76fn a_malformed_config_is_an_error_but_a_missing_one_is_not() {
77 let root = tmpdir("malformed");
78 let src = root.join("src");
79 std::fs::create_dir_all(&src).unwrap();
80 write_site(&src);
81
82 assert_eq!(Config::load(&src).unwrap(), Config::default());
83
84 std::fs::write(src.join("orgo.toml"), "[site\ntitle = broken").unwrap();
85 let err = Config::load(&src).expect_err("malformed config must fail");
86 assert!(format!("{err:#}").contains("orgo.toml"), "names the file: {err:#}");
87}
88
89/// A misspelled key is a silent no-op in most config formats, which is exactly how
90/// someone spends an afternoon wondering why a setting does nothing.
91#[test]
92fn an_unknown_config_key_is_rejected() {
93 let root = tmpdir("unknownkey");
94 let src = root.join("src");
95 std::fs::create_dir_all(&src).unwrap();
96 std::fs::write(src.join("orgo.toml"), "[site]\ntittle = \"typo\"\n").unwrap();
97
98 let err = Config::load(&src).expect_err("unknown key must fail");
99 assert!(
100 format!("{err:#}").contains("tittle"),
101 "the error names the offending key: {err:#}"
102 );
103}
104
105// ---------------------------------------------------------------------------
106// Nav modes
107// ---------------------------------------------------------------------------
108
109fn nav_of(html: &str) -> String {
110 html.split("<nav>")
111 .nth(1)
112 .and_then(|s| s.split("</nav>").next())
113 .unwrap_or("")
114 .to_string()
115}
116
117#[test]
118fn nav_modes_select_different_pages() {
119 for (mode, expect_post, expect_about) in [
120 ("top-level", false, true),
121 ("all", true, true),
122 ("none", false, false),
123 ] {
124 let root = tmpdir(&format!("nav-{mode}"));
125 let src = root.join("src");
126 std::fs::create_dir_all(&src).unwrap();
127 write_site(&src);
128 std::fs::write(
129 src.join("orgo.toml"),
130 format!("[nav]\nmode = \"{mode}\"\n"),
131 )
132 .unwrap();
133 let out = root.join("out");
134 build(&src, &out);
135
136 let nav = nav_of(&page(&out, "index.html"));
137 assert_eq!(
138 nav.contains("A Post"),
139 expect_post,
140 "mode {mode} nested page presence, nav was:\n{nav}"
141 );
142 assert_eq!(
143 nav.contains("About"),
144 expect_about,
145 "mode {mode} root page presence, nav was:\n{nav}"
146 );
147 }
148}
149
150/// An explicit nav is a designed sequence, so configured order beats discovery order.
151#[test]
152fn explicit_nav_uses_the_configured_order() {
153 let root = tmpdir("navexplicit");
154 let src = root.join("src");
155 std::fs::create_dir_all(&src).unwrap();
156 write_site(&src);
157 std::fs::write(
158 src.join("orgo.toml"),
159 "[nav]\nmode = \"explicit\"\npages = [\"blog/post.org\", \"index.org\"]\n",
160 )
161 .unwrap();
162 let out = root.join("out");
163 build(&src, &out);
164
165 let nav = nav_of(&page(&out, "index.html"));
166 let post = nav.find("A Post").expect("post in nav");
167 let home = nav.find("Home").expect("home in nav");
168 assert!(post < home, "configured order wins:\n{nav}");
169 assert!(!nav.contains("About"), "unlisted pages stay out:\n{nav}");
170}
171
172/// A nav entry naming a page that does not exist is a typo, and a silently shorter nav
173/// is a poor way to find out.
174#[test]
175fn explicit_nav_rejects_a_page_that_does_not_exist() {
176 let root = tmpdir("navmissing");
177 let src = root.join("src");
178 std::fs::create_dir_all(&src).unwrap();
179 write_site(&src);
180 std::fs::write(
181 src.join("orgo.toml"),
182 "[nav]\nmode = \"explicit\"\npages = [\"nope.org\"]\n",
183 )
184 .unwrap();
185
186 let err = build_site(&src, &root.join("out"), &BuildOptions::default())
187 .expect_err("missing nav page must fail");
188 assert!(format!("{err:#}").contains("nope.org"), "names it: {err:#}");
189}
190
191/// `mode` and `pages` disagreeing means one of them is being ignored.
192#[test]
193fn contradictory_nav_settings_are_rejected() {
194 let mut config = Config::default();
195 config.nav.pages = vec![Utf8PathBuf::from("index.org")];
196 assert!(config.validate().is_err(), "pages without explicit mode");
197
198 let mut config = Config::default();
199 config.nav.mode = NavMode::Explicit;
200 assert!(config.validate().is_err(), "explicit mode without pages");
201}
202
203// ---------------------------------------------------------------------------
204// Templates
205// ---------------------------------------------------------------------------
206
207/// The single biggest blocker to general use: without this every site built with this
208/// tool looks identical.
209#[test]
210fn a_user_template_replaces_the_built_in_layout() {
211 let root = tmpdir("template");
212 let src = root.join("src");
213 std::fs::create_dir_all(src.join("templates")).unwrap();
214 write_site(&src);
215 std::fs::write(
216 src.join("templates/base.html"),
217 "<html><body class=\"mine\"><h1>{{ page.title }}</h1>{{ body | safe }}</body></html>",
218 )
219 .unwrap();
220 let out = root.join("out");
221 build(&src, &out);
222
223 let home = page(&out, "index.html");
224 assert!(home.contains("class=\"mine\""), "the user layout is used:\n{home}");
225 assert!(!home.contains("<nav>"), "nothing of the default layout leaks in");
226 assert!(home.contains("Welcome."), "content still renders");
227}
228
229/// Templates are a hashing input (spec §4.1). If editing a layout did not invalidate,
230/// a design change would leave a site half-updated — the worst kind of caching bug,
231/// because it looks like it worked.
232#[test]
233fn editing_a_template_re_renders_every_page_that_uses_it() {
234 let root = tmpdir("templatehash");
235 let src = root.join("src");
236 std::fs::create_dir_all(src.join("templates")).unwrap();
237 write_site(&src);
238 let tpl = src.join("templates/base.html");
239 std::fs::write(&tpl, "<html><body>v1{{ body | safe }}</body></html>").unwrap();
240 let out = root.join("out");
241
242 build(&src, &out);
243 std::fs::write(&tpl, "<html><body>v2{{ body | safe }}</body></html>").unwrap();
244 let report = build(&src, &out);
245
246 assert_eq!(report.rendered.len(), 3, "a layout edit re-renders every page");
247 assert!(page(&out, "index.html").contains("v2"), "and the change lands");
248}
249
250/// A template that does not compile means someone is actively editing their layout.
251/// Falling back to the built-in would look like their edit silently did nothing.
252#[test]
253fn a_broken_template_fails_the_build() {
254 let root = tmpdir("badtemplate");
255 let src = root.join("src");
256 std::fs::create_dir_all(src.join("templates")).unwrap();
257 write_site(&src);
258 std::fs::write(src.join("templates/base.html"), "{% if %}unclosed").unwrap();
259
260 let err = build_site(&src, &root.join("out"), &BuildOptions::default())
261 .expect_err("a broken template must fail the build");
262 assert!(
263 format!("{err:#}").contains("base"),
264 "the error names the template: {err:#}"
265 );
266}
267
268/// Templates get page metadata, including arbitrary `#+KEYWORD:`s this crate has never
269/// heard of — otherwise every new bit of metadata would need a release.
270#[test]
271fn templates_receive_page_metadata_including_unknown_keywords() {
272 let root = tmpdir("meta");
273 let src = root.join("src");
274 std::fs::create_dir_all(src.join("templates")).unwrap();
275 write_site(&src);
276 std::fs::write(
277 src.join("blog/post.org"),
278 "#+TITLE: A Post\n#+DATE: 2024-05-01\n#+FILETAGS: :rust:web:\n#+CUSTOM_THING: hello\n\nBody.\n",
279 )
280 .unwrap();
281 std::fs::write(
282 src.join("templates/base.html"),
283 "<html><body>date={{ page.date }} tags={{ page.tags | join(\",\") }} \
284 custom={{ page.keywords.custom_thing }} url={{ page.url }} \
285 site={{ site.title }}{{ body | safe }}</body></html>",
286 )
287 .unwrap();
288 let out = root.join("out");
289 build(&src, &out);
290
291 let post = page(&out, "blog/post.html");
292 assert!(post.contains("date=2024-05-01"), "#+DATE: reaches the template:\n{post}");
293 assert!(post.contains("tags=rust,web"), "#+FILETAGS: is split:\n{post}");
294 assert!(post.contains("custom=hello"), "unknown keywords pass through:\n{post}");
295 assert!(post.contains("url=blog/post.html"), "the page URL is available:\n{post}");
296}
297
298/// Off by default, because it trades incremental precision for the ability to write
299/// listing pages — and that trade should be a choice.
300#[test]
301fn the_page_list_is_opt_in_and_widens_invalidation() {
302 let root = tmpdir("pagelist");
303 let src = root.join("src");
304 std::fs::create_dir_all(src.join("templates")).unwrap();
305 write_site(&src);
306 std::fs::write(
307 src.join("orgo.toml"),
308 "[templates]\nexpose_page_list = true\n",
309 )
310 .unwrap();
311 std::fs::write(
312 src.join("templates/base.html"),
313 "<html><body><ul>{% for p in pages %}<li>{{ p.title }}</li>{% endfor %}</ul>\
314 {{ body | safe }}</body></html>",
315 )
316 .unwrap();
317 let out = root.join("out");
318 build(&src, &out);
319
320 let home = page(&out, "index.html");
321 for title in ["Home", "About", "A Post"] {
322 assert!(home.contains(title), "an index can list {title}:\n{home}");
323 }
324
325 // With every page visible to every template, adding one must re-render them all —
326 // the opposite of the default, and the documented cost of turning this on.
327 std::fs::write(src.join("blog/second.org"), "#+TITLE: Second\n\nBody.\n").unwrap();
328 let report = build(&src, &out);
329 assert_eq!(
330 report.rendered.len(),
331 4,
332 "with the page list exposed, adding a page re-renders the site"
333 );
334}
335
336// ---------------------------------------------------------------------------
337// Output settings
338// ---------------------------------------------------------------------------
339
340/// The default layout renders the page title as `<h1>`, so section headings belong
341/// beneath it — which is also what Emacs does by default.
342#[test]
343fn heading_offset_shifts_content_headings_below_the_page_title() {
344 let root = tmpdir("hoffset");
345 let src = root.join("src");
346 std::fs::create_dir_all(&src).unwrap();
347 std::fs::write(src.join("index.org"), "#+TITLE: T\n\n* Section\n\nBody.\n").unwrap();
348 let out = root.join("out");
349 build(&src, &out);
350 assert!(
351 page(&out, "index.html").contains("<h2 id=\"section\">"),
352 "a level-1 org heading renders as <h2> by default"
353 );
354
355 std::fs::write(src.join("orgo.toml"), "[html]\nheading_offset = 0\n").unwrap();
356 let out2 = root.join("out2");
357 build(&src, &out2);
358 assert!(
359 page(&out2, "index.html").contains("<h1 id=\"section\">"),
360 "offset 0 leaves headings where the document put them"
361 );
362}
363
364/// An unknown theme silently produces an empty stylesheet, which looks exactly like
365/// highlighting being broken. Naming the valid options turns a mystery into a typo.
366#[test]
367fn an_unknown_highlight_theme_is_rejected_with_the_available_ones() {
368 let root = tmpdir("theme");
369 let src = root.join("src");
370 std::fs::create_dir_all(&src).unwrap();
371 write_site(&src);
372 std::fs::write(src.join("orgo.toml"), "[highlight]\ntheme = \"nope\"\n").unwrap();
373
374 let err = build_site(&src, &root.join("out"), &BuildOptions::default())
375 .expect_err("unknown theme must fail");
376 let message = format!("{err:#}");
377 assert!(message.contains("nope"), "names the bad theme: {message}");
378 assert!(
379 message.contains("InspiredGitHub"),
380 "lists what is available: {message}"
381 );
382}
383
384// ---------------------------------------------------------------------------
385// Discovery
386// ---------------------------------------------------------------------------
387
388/// `orgo build . -o _site` is the obvious thing to type. Without excluding the output
389/// directory, the build copies its own output back into itself, growing `_site/_site/…`
390/// on every run.
391#[test]
392fn an_output_directory_inside_the_source_is_not_swallowed() {
393 let root = tmpdir("nested");
394 let src = root.join("src");
395 std::fs::create_dir_all(&src).unwrap();
396 write_site(&src);
397 let out = src.join("_site");
398
399 for _ in 0..3 {
400 build(&src, &out);
401 }
402 assert!(!out.join("_site").exists(), "output must not nest inside itself");
403
404 let report = build(&src, &out);
405 assert_eq!(report.pages.len(), 3, "still exactly the source pages");
406 assert!(
407 report.assets.is_empty(),
408 "no output file is mistaken for an asset: {:?}",
409 report.assets
410 );
411}
412
413/// A source directory is very often a git repository. Publishing `.git` alongside the
414/// homepage leaks a project's entire history.
415#[test]
416fn dot_directories_and_build_inputs_are_never_published() {
417 let root = tmpdir("dotfiles");
418 let src = root.join("src");
419 std::fs::create_dir_all(src.join(".git")).unwrap();
420 std::fs::create_dir_all(src.join("templates")).unwrap();
421 write_site(&src);
422 std::fs::write(src.join(".git/config"), "[remote]\nurl = private\n").unwrap();
423 std::fs::write(src.join(".env"), "SECRET=hunter2\n").unwrap();
424 std::fs::write(src.join("orgo.toml"), "[site]\ntitle = \"T\"\n").unwrap();
425 std::fs::write(src.join("templates/base.html"), "<html>{{ body | safe }}</html>").unwrap();
426 std::fs::write(src.join("style.css"), "body{}\n").unwrap();
427 let out = root.join("out");
428
429 let report = build(&src, &out);
430 assert!(!out.join(".git").exists(), ".git must never be published");
431 assert!(!out.join(".env").exists(), "dotfiles must never be published");
432 assert!(
433 !out.join("orgo.toml").exists(),
434 "the config is a build input, not content"
435 );
436 assert!(
437 !out.join("templates").exists(),
438 "templates are build inputs, not content"
439 );
440 assert_eq!(
441 report.assets,
442 vec![Utf8PathBuf::from("style.css")],
443 "genuine assets still copy through"
444 );
445}
446
447// ---------------------------------------------------------------------------
448// Generated listing pages
449// ---------------------------------------------------------------------------
450
451/// A site with dated posts, a listing template, and a collection configured over them.
452fn write_blog(src: &Utf8PathBuf, extra_config: &str) {
453 std::fs::create_dir_all(src.join("blog")).unwrap();
454 std::fs::create_dir_all(src.join("templates")).unwrap();
455 std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nWelcome.\n").unwrap();
456 for (name, title, date) in [
457 ("old", "Older Post", "<2024-01-02 Tue>"),
458 ("new", "Newer Post", "[2025-06-30 Mon 09:15:00]"),
459 ("mid", "Middle Post", "2024-08-05"),
460 ] {
461 std::fs::write(
462 src.join(format!("blog/{name}.org")),
463 format!("#+TITLE: {title}\n#+DATE: {date}\n\nBody.\n"),
464 )
465 .unwrap();
466 }
467 std::fs::write(
468 src.join("templates/list.html"),
469 "<html><body><h1>{{ page.title }}</h1><ul>\
470 {% for p in pages %}<li>{{ p.date_iso }}|{{ p.title }}|{{ root }}{{ p.url }}</li>\
471 {% endfor %}</ul></body></html>",
472 )
473 .unwrap();
474 std::fs::write(
475 src.join("orgo.toml"),
476 format!(
477 "[[collections]]\nsource = \"blog\"\noutput = \"blog/index.html\"\n\
478 template = \"list.html\"\ntitle = \"Blog\"\n{extra_config}"
479 ),
480 )
481 .unwrap();
482}
483
484/// The whole point: an output file with no source `.org` behind it.
485#[test]
486fn a_collection_generates_a_listing_page_sorted_newest_first() {
487 let root = tmpdir("listing");
488 let src = root.join("src");
489 std::fs::create_dir_all(&src).unwrap();
490 write_blog(&src, "");
491 let out = root.join("out");
492 let report = build(&src, &out);
493
494 assert!(
495 report.pages.contains(&Utf8PathBuf::from("blog/index.html")),
496 "the listing page is part of the build: {:?}",
497 report.pages
498 );
499
500 let listing = page(&out, "blog/index.html");
501 let order: Vec<&str> = ["Newer Post", "Middle Post", "Older Post"]
502 .into_iter()
503 .filter(|t| listing.contains(t))
504 .collect();
505 assert_eq!(
506 order,
507 vec!["Newer Post", "Middle Post", "Older Post"],
508 "all three posts appear:\n{listing}"
509 );
510 let pos = |t: &str| listing.find(t).unwrap();
511 assert!(
512 pos("Newer Post") < pos("Middle Post") && pos("Middle Post") < pos("Older Post"),
513 "newest first by default:\n{listing}"
514 );
515 assert!(!listing.contains("Home"), "only the collection's pages are listed");
516}
517
518/// Org dates arrive as `[2025-06-30 Mon 09:15:00]`, `<2024-01-02 Tue>` or bare
519/// `2024-08-05`. A listing needs one key it can sort and print.
520#[test]
521fn dates_are_normalized_from_every_org_shape() {
522 let root = tmpdir("dates");
523 let src = root.join("src");
524 std::fs::create_dir_all(&src).unwrap();
525 write_blog(&src, "");
526 let out = root.join("out");
527 build(&src, &out);
528
529 let listing = page(&out, "blog/index.html");
530 for iso in ["2025-06-30", "2024-08-05", "2024-01-02"] {
531 assert!(listing.contains(iso), "{iso} normalized out of its org syntax:\n{listing}");
532 }
533}
534
535#[test]
536fn sort_and_order_are_configurable() {
537 let root = tmpdir("sortorder");
538 let src = root.join("src");
539 std::fs::create_dir_all(&src).unwrap();
540 write_blog(&src, "sort = \"title\"\norder = \"asc\"\n");
541 let out = root.join("out");
542 build(&src, &out);
543
544 let listing = page(&out, "blog/index.html");
545 let pos = |t: &str| listing.find(t).unwrap();
546 assert!(
547 pos("Middle Post") < pos("Newer Post") && pos("Newer Post") < pos("Older Post"),
548 "ascending by title:\n{listing}"
549 );
550}
551
552/// A dateless draft leading a dated archive is almost never what anyone wants.
553#[test]
554fn undated_pages_sort_last_whichever_direction() {
555 let root = tmpdir("undated");
556 let src = root.join("src");
557 std::fs::create_dir_all(&src).unwrap();
558 write_blog(&src, "");
559 std::fs::write(src.join("blog/draft.org"), "#+TITLE: No Date Here\n\nBody.\n").unwrap();
560 let out = root.join("out");
561 build(&src, &out);
562
563 let listing = page(&out, "blog/index.html");
564 let undated = listing.find("No Date Here").unwrap();
565 for dated in ["Newer Post", "Middle Post", "Older Post"] {
566 assert!(
567 listing.find(dated).unwrap() < undated,
568 "{dated} must precede the undated draft:\n{listing}"
569 );
570 }
571}
572
573/// A listing page is exactly what a section's nav entry should point at — `/blog/`
574/// rather than any one post.
575#[test]
576fn a_collection_can_join_the_nav() {
577 let root = tmpdir("listnav");
578 let src = root.join("src");
579 std::fs::create_dir_all(&src).unwrap();
580 write_blog(&src, "nav = true\n");
581 let out = root.join("out");
582 build(&src, &out);
583
584 let home_nav = nav_of(&page(&out, "index.html"));
585 assert!(
586 home_nav.contains("blog/index.html"),
587 "the listing page is in the nav:\n{home_nav}"
588 );
589 // And the URL has to be right from a nested page too.
590 let post = page(&out, "blog/new.html");
591 assert!(
592 nav_of(&post).contains("href=\"index.html\"") || nav_of(&post).contains("blog/index.html"),
593 "the nav link resolves from a nested page:\n{}",
594 nav_of(&post)
595 );
596}
597
598/// `mode = "none"` means none. A collection asking for a nav that was turned off does
599/// not get to be the only thing in it.
600#[test]
601fn nav_mode_none_drops_a_collection_that_asked_for_the_nav() {
602 let root = tmpdir("navnonelist");
603 let src = root.join("src");
604 std::fs::create_dir_all(&src).unwrap();
605 write_blog(&src, "nav = true\n\n[nav]\nmode = \"none\"\n");
606 let out = root.join("out");
607 build(&src, &out);
608
609 let nav = nav_of(&page(&out, "index.html"));
610 assert!(!nav.contains("Blog"), "nav is empty:\n{nav}");
611}
612
613/// A generated page can be positioned like any other: an explicit nav names it by its
614/// output path, and it lands exactly there rather than being appended after the pages
615/// that have source files.
616#[test]
617fn an_explicit_nav_can_order_a_generated_page_before_an_authored_one() {
618 let root = tmpdir("navgenorder");
619 let src = root.join("src");
620 std::fs::create_dir_all(&src).unwrap();
621 write_blog(
622 &src,
623 "nav = true\n\n[nav]\nmode = \"explicit\"\n\
624 pages = [\"blog/index.html\", \"index.org\"]\n",
625 );
626 let out = root.join("out");
627 build(&src, &out);
628
629 let nav = nav_of(&page(&out, "index.html"));
630 let blog = nav.find("Blog").expect("the listing page is in the nav");
631 let home = nav.find("Home").expect("the authored page is in the nav");
632 assert!(blog < home, "configured order wins:\n{nav}");
633}
634
635/// A listing page depends on every page it lists — and on nothing else. Adding a post
636/// must re-render the index without re-rendering the rest of the site.
637#[test]
638fn adding_a_post_rebuilds_only_the_listing_and_the_post() {
639 let root = tmpdir("listinc");
640 let src = root.join("src");
641 std::fs::create_dir_all(&src).unwrap();
642 write_blog(&src, "");
643 let out = root.join("out");
644
645 build(&src, &out);
646 let second = build(&src, &out);
647 assert!(
648 second.rendered.is_empty(),
649 "an unchanged rebuild renders nothing, including the listing: {:?}",
650 second.rendered
651 );
652
653 std::fs::write(
654 src.join("blog/fresh.org"),
655 "#+TITLE: Fresh Post\n#+DATE: 2026-01-01\n\nBody.\n",
656 )
657 .unwrap();
658 let report = build(&src, &out);
659
660 let mut rendered = report.rendered.clone();
661 rendered.sort();
662 assert_eq!(
663 rendered,
664 vec![
665 Utf8PathBuf::from("blog/fresh.html"),
666 Utf8PathBuf::from("blog/index.html")
667 ],
668 "exactly the new post and the listing it belongs to"
669 );
670 assert!(
671 page(&out, "blog/index.html").contains("Fresh Post"),
672 "and the listing actually picked it up"
673 );
674}
675
676/// Editing a post's body reaches its listing, because a listing shows things derived
677/// from the body: the excerpt is its first paragraph, and the reading time is its length.
678///
679/// This test used to assert the opposite, and the site was wrong for it — rewriting a
680/// post's opening paragraph left the old excerpt on the index until something unrelated
681/// invalidated it. A listing depends on everything its template can read.
682#[test]
683fn editing_a_post_body_rebuilds_the_listing_that_shows_its_excerpt() {
684 let root = tmpdir("listbody");
685 let src = root.join("src");
686 std::fs::create_dir_all(&src).unwrap();
687 write_blog(&src, "");
688 std::fs::write(
689 src.join("templates/list.html"),
690 "<html><body>{% for p in pages %}<li>{{ p.excerpt }}</li>{% endfor %}</body></html>",
691 )
692 .unwrap();
693 let out = root.join("out");
694 build(&src, &out);
695
696 std::fs::write(
697 src.join("blog/mid.org"),
698 "#+TITLE: Middle Post\n#+DATE: 2024-08-05\n\nA completely different opening.\n",
699 )
700 .unwrap();
701 let report = build(&src, &out);
702
703 assert!(
704 report.rendered.contains(&Utf8PathBuf::from("blog/index.html")),
705 "the listing rebuilt: {:?}",
706 report.rendered
707 );
708 assert!(
709 page(&out, "blog/index.html").contains("A completely different opening."),
710 "and shows the new excerpt:\n{}",
711 page(&out, "blog/index.html")
712 );
713 assert_eq!(
714 report.rendered.len(),
715 2,
716 "the post and its listing, and nothing else: {:?}",
717 report.rendered
718 );
719}
720
721/// Retitling a post *does* change the listing, since the title is what it displays.
722#[test]
723fn retitling_a_post_rebuilds_the_listing() {
724 let root = tmpdir("listtitle");
725 let src = root.join("src");
726 std::fs::create_dir_all(&src).unwrap();
727 write_blog(&src, "");
728 let out = root.join("out");
729 build(&src, &out);
730
731 std::fs::write(
732 src.join("blog/mid.org"),
733 "#+TITLE: Renamed Post\n#+DATE: 2024-08-05\n\nBody.\n",
734 )
735 .unwrap();
736 let report = build(&src, &out);
737
738 assert!(
739 report.rendered.contains(&Utf8PathBuf::from("blog/index.html")),
740 "the listing must follow a title change: {:?}",
741 report.rendered
742 );
743 assert!(page(&out, "blog/index.html").contains("Renamed Post"));
744}
745
746/// A feed is a listing page with an XML template, not a separate feature — which is why
747/// templates are loaded by full filename and any extension.
748#[test]
749fn a_feed_is_just_a_listing_page_with_an_xml_template() {
750 let root = tmpdir("feed");
751 let src = root.join("src");
752 std::fs::create_dir_all(&src).unwrap();
753 write_blog(&src, "");
754 std::fs::write(
755 src.join("templates/feed.xml"),
756 "<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>{{ site.title }}</title>\
757 {% for p in pages %}<item><title>{{ p.title }}</title>\
758 <pubDate>{{ p.date_iso }}</pubDate></item>{% endfor %}</channel></rss>",
759 )
760 .unwrap();
761 let mut config = std::fs::read_to_string(src.join("orgo.toml")).unwrap();
762 config.push_str(
763 "\n[[collections]]\nsource = \"blog\"\noutput = \"feed.xml\"\n\
764 template = \"feed.xml\"\ntitle = \"Feed\"\n",
765 );
766 std::fs::write(src.join("orgo.toml"), config).unwrap();
767 let out = root.join("out");
768 build(&src, &out);
769
770 let feed = page(&out, "feed.xml");
771 assert!(feed.starts_with("<?xml"), "an XML document, not HTML:\n{feed}");
772 assert!(feed.contains("<pubDate>2025-06-30</pubDate>"), "entries carry dates:\n{feed}");
773}
774
775/// A listing template can inherit the site layout instead of duplicating it.
776#[test]
777fn a_listing_template_can_extend_the_base_layout() {
778 let root = tmpdir("listextends");
779 let src = root.join("src");
780 std::fs::create_dir_all(&src).unwrap();
781 write_blog(&src, "");
782 std::fs::write(
783 src.join("templates/base.html"),
784 "<html><body class=\"shared\">{% block main %}{{ body | safe }}{% endblock %}</body></html>",
785 )
786 .unwrap();
787 std::fs::write(
788 src.join("templates/list.html"),
789 "{% extends \"base.html\" %}{% block main %}<ul>\
790 {% for p in pages %}<li>{{ p.title }}</li>{% endfor %}</ul>{% endblock %}",
791 )
792 .unwrap();
793 let out = root.join("out");
794 build(&src, &out);
795
796 let listing = page(&out, "blog/index.html");
797 assert!(listing.contains("class=\"shared\""), "inherits the layout:\n{listing}");
798 assert!(listing.contains("Newer Post"), "and adds its own content:\n{listing}");
799}
800
801/// URLs are most of a template's output. Escaping `/` as `/` is valid but makes
802/// every link unreadable; escaping user content is not optional.
803#[test]
804fn urls_stay_readable_while_user_content_is_still_escaped() {
805 let root = tmpdir("escaping");
806 let src = root.join("src");
807 std::fs::create_dir_all(&src).unwrap();
808 write_blog(&src, "");
809 std::fs::write(
810 src.join("blog/evil.org"),
811 "#+TITLE: <script>alert(1)</script>\n#+DATE: 2026-02-02\n\nBody.\n",
812 )
813 .unwrap();
814 let out = root.join("out");
815 build(&src, &out);
816
817 let listing = page(&out, "blog/index.html");
818 assert!(listing.contains("../blog/new.html"), "URLs read as URLs:\n{listing}");
819 assert!(!listing.contains("/"), "no escaped slashes:\n{listing}");
820 assert!(
821 listing.contains("<script>"),
822 "a title is user content and stays escaped:\n{listing}"
823 );
824 assert!(!listing.contains("<script>"), "never unescaped:\n{listing}");
825}
826
827/// Two generated pages writing the same file, or a listing writing over a real page,
828/// silently loses one of them.
829#[test]
830fn colliding_collection_outputs_are_rejected() {
831 let root = tmpdir("listcollide");
832 let src = root.join("src");
833 std::fs::create_dir_all(&src).unwrap();
834 write_blog(&src, "");
835
836 let mut config = std::fs::read_to_string(src.join("orgo.toml")).unwrap();
837 config.push_str("\n[[collections]]\nsource = \"\"\noutput = \"blog/index.html\"\n");
838 std::fs::write(src.join("orgo.toml"), &config).unwrap();
839 let err = build_site(&src, &root.join("out"), &BuildOptions::default())
840 .expect_err("two collections writing one file must fail");
841 assert!(format!("{err:#}").contains("blog/index.html"), "{err:#}");
842
843 // And a listing that would overwrite a real page.
844 std::fs::write(
845 src.join("orgo.toml"),
846 "[[collections]]\nsource = \"blog\"\noutput = \"index.html\"\ntemplate = \"list.html\"\n",
847 )
848 .unwrap();
849 let err = build_site(&src, &root.join("out2"), &BuildOptions::default())
850 .expect_err("a listing over a real page must fail");
851 assert!(format!("{err:#}").contains("index.org"), "names the page it would replace: {err:#}");
852}
853
854/// A missing template is a typo; listing what exists turns it into a one-second fix.
855#[test]
856fn a_missing_collection_template_names_the_ones_that_exist() {
857 let root = tmpdir("listnotpl");
858 let src = root.join("src");
859 std::fs::create_dir_all(&src).unwrap();
860 write_blog(&src, "");
861 std::fs::write(
862 src.join("orgo.toml"),
863 "[[collections]]\nsource = \"blog\"\noutput = \"blog/index.html\"\ntemplate = \"nope.html\"\n",
864 )
865 .unwrap();
866
867 let err = build_site(&src, &root.join("out"), &BuildOptions::default())
868 .expect_err("missing template must fail");
869 let message = format!("{err:#}");
870 assert!(message.contains("nope.html"), "names the missing one: {message}");
871 assert!(message.contains("list.html"), "lists what is available: {message}");
872}
873
874// ---------------------------------------------------------------------------
875// Grouped collections: tag pages and the tag index
876// ---------------------------------------------------------------------------
877
878/// Posts carrying tags, a per-tag template, a tag-index template, and a grouped
879/// collection over them.
880fn write_tagged_blog(src: &Utf8PathBuf, extra: &str) {
881 std::fs::create_dir_all(src.join("blog")).unwrap();
882 std::fs::create_dir_all(src.join("templates")).unwrap();
883 std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nWelcome.\n").unwrap();
884 for (name, title, date, tags) in [
885 ("a", "Post A", "2024-01-01", ":rust:web:"),
886 ("b", "Post B", "2024-02-02", ":rust:"),
887 ("c", "Post C", "2024-03-03", ":emacs:"),
888 ("d", "Post D", "2024-04-04", ""),
889 ] {
890 let filetags = if tags.is_empty() {
891 String::new()
892 } else {
893 format!("#+FILETAGS: {tags}\n")
894 };
895 std::fs::write(
896 src.join(format!("blog/{name}.org")),
897 format!("#+TITLE: {title}\n#+DATE: {date}\n{filetags}\nBody.\n"),
898 )
899 .unwrap();
900 }
901 std::fs::write(
902 src.join("templates/tag.html"),
903 "<html><body><h1>{{ page.title }}</h1><p>slug={{ group.slug }} count={{ group.count }}</p>\
904 <ul>{% for p in pages %}<li>{{ p.title }}</li>{% endfor %}</ul></body></html>",
905 )
906 .unwrap();
907 std::fs::write(
908 src.join("templates/tags.html"),
909 "<html><body><h1>{{ page.title }}</h1><ul>\
910 {% for g in groups %}<li>{{ g.name }}={{ g.count }}@{{ root }}{{ g.url }}</li>\
911 {% endfor %}</ul></body></html>",
912 )
913 .unwrap();
914 std::fs::write(
915 src.join("orgo.toml"),
916 format!(
917 "[[collections]]\nsource = \"blog\"\ngroup_by = \"tags\"\n\
918 output = \"tags/{{tag}}.html\"\ntemplate = \"tag.html\"\ntitle = \"Tagged: {{tag}}\"\n\
919 index_output = \"tags/index.html\"\nindex_template = \"tags.html\"\n\
920 index_title = \"All tags\"\n{extra}"
921 ),
922 )
923 .unwrap();
924}
925
926/// One collection, many outputs — the shape the earlier listing feature could not express.
927#[test]
928fn a_grouped_collection_emits_one_page_per_tag() {
929 let root = tmpdir("tags");
930 let src = root.join("src");
931 std::fs::create_dir_all(&src).unwrap();
932 write_tagged_blog(&src, "");
933 let out = root.join("out");
934 build(&src, &out);
935
936 for (tag, expected) in [("rust", vec!["Post A", "Post B"]), ("emacs", vec!["Post C"])] {
937 let html = page(&out, &format!("tags/{tag}.html"));
938 for title in &expected {
939 assert!(html.contains(title), "{tag} lists {title}:\n{html}");
940 }
941 assert!(
942 html.contains(&format!("count={}", expected.len())),
943 "{tag} knows its own size:\n{html}"
944 );
945 }
946 assert!(
947 !out.join("tags/.html").exists(),
948 "an untagged post creates no empty group"
949 );
950 assert!(
951 !page(&out, "tags/rust.html").contains("Post C"),
952 "a tag page lists only its own posts"
953 );
954}
955
956/// The index lists the groups themselves, not the pages.
957#[test]
958fn the_tag_index_lists_every_tag_with_counts() {
959 let root = tmpdir("tagindex");
960 let src = root.join("src");
961 std::fs::create_dir_all(&src).unwrap();
962 write_tagged_blog(&src, "");
963 let out = root.join("out");
964 build(&src, &out);
965
966 let index = page(&out, "tags/index.html");
967 assert!(index.contains("All tags"), "uses index_title:\n{index}");
968 assert!(index.contains("rust=2@../tags/rust.html"), "counts and links:\n{index}");
969 assert!(index.contains("emacs=1@"), "every tag appears:\n{index}");
970 assert!(index.contains("web=1@"), "every tag appears:\n{index}");
971 // Alphabetical, so the index reads predictably rather than in discovery order.
972 let pos = |t: &str| index.find(t).unwrap();
973 assert!(pos("emacs") < pos("rust") && pos("rust") < pos("web"), "sorted:\n{index}");
974}
975
976/// A tag page depends on its own posts. Adding a post tagged `rust` must not re-render
977/// the `emacs` page — invalidation that scales with tag count would undo the point.
978#[test]
979fn adding_a_tagged_post_rebuilds_only_the_affected_pages() {
980 let root = tmpdir("tagsinc");
981 let src = root.join("src");
982 std::fs::create_dir_all(&src).unwrap();
983 write_tagged_blog(&src, "");
984 let out = root.join("out");
985 build(&src, &out);
986 assert!(build(&src, &out).rendered.is_empty(), "unchanged rebuild renders nothing");
987
988 std::fs::write(
989 src.join("blog/e.org"),
990 "#+TITLE: Post E\n#+DATE: 2024-05-05\n#+FILETAGS: :rust:\n\nBody.\n",
991 )
992 .unwrap();
993 let report = build(&src, &out);
994
995 let mut rendered = report.rendered.clone();
996 rendered.sort();
997 assert_eq!(
998 rendered,
999 vec![
1000 Utf8PathBuf::from("blog/e.html"),
1001 Utf8PathBuf::from("tags/index.html"),
1002 Utf8PathBuf::from("tags/rust.html"),
1003 ],
1004 "the post, its tag page, and the index whose counts changed — nothing else"
1005 );
1006 assert!(page(&out, "tags/rust.html").contains("Post E"));
1007}
1008
1009/// A new tag has to produce a new page and reach the index.
1010#[test]
1011fn a_new_tag_creates_its_page_and_joins_the_index() {
1012 let root = tmpdir("newtag");
1013 let src = root.join("src");
1014 std::fs::create_dir_all(&src).unwrap();
1015 write_tagged_blog(&src, "");
1016 let out = root.join("out");
1017 build(&src, &out);
1018 assert!(!out.join("tags/zig.html").exists());
1019
1020 std::fs::write(
1021 src.join("blog/f.org"),
1022 "#+TITLE: Post F\n#+DATE: 2024-06-06\n#+FILETAGS: :zig:\n\nBody.\n",
1023 )
1024 .unwrap();
1025 build(&src, &out);
1026
1027 assert!(out.join("tags/zig.html").exists(), "the new tag gets a page");
1028 assert!(
1029 page(&out, "tags/index.html").contains("zig=1@"),
1030 "and the index knows about it"
1031 );
1032}
1033
1034/// Grouping by any `#+KEYWORD:`, not just tags — same mechanism, single-valued.
1035#[test]
1036fn a_collection_can_group_by_any_keyword() {
1037 let root = tmpdir("groupkw");
1038 let src = root.join("src");
1039 std::fs::create_dir_all(&src).unwrap();
1040 write_tagged_blog(&src, "");
1041 std::fs::write(
1042 src.join("blog/a.org"),
1043 "#+TITLE: Post A\n#+DATE: 2024-01-01\n#+CATEGORY: Notes\n\nBody.\n",
1044 )
1045 .unwrap();
1046 std::fs::write(
1047 src.join("orgo.toml"),
1048 "[[collections]]\nsource = \"blog\"\ngroup_by = \"category\"\n\
1049 output = \"cat/{tag}.html\"\ntemplate = \"tag.html\"\ntitle = \"{tag}\"\n",
1050 )
1051 .unwrap();
1052 let out = root.join("out");
1053 build(&src, &out);
1054
1055 assert!(out.join("cat/notes.html").exists(), "grouped by #+CATEGORY:");
1056 assert!(page(&out, "cat/notes.html").contains("Post A"));
1057}
1058
1059/// A grouped collection puts its *index* in the nav. A nav listing every tag is the same
1060/// mistake as a nav listing every page.
1061#[test]
1062fn a_grouped_collection_contributes_its_index_to_the_nav() {
1063 let root = tmpdir("tagnav");
1064 let src = root.join("src");
1065 std::fs::create_dir_all(&src).unwrap();
1066 write_tagged_blog(&src, "nav = true\n");
1067 let out = root.join("out");
1068 build(&src, &out);
1069
1070 let nav = nav_of(&page(&out, "index.html"));
1071 assert!(nav.contains("tags/index.html"), "the index is in the nav:\n{nav}");
1072 assert!(!nav.contains("tags/rust.html"), "individual tags are not:\n{nav}");
1073}
1074
1075/// An output path with no `{tag}` would have every group overwrite one file — a config
1076/// that looks reasonable and silently produces one page instead of many.
1077#[test]
1078fn grouping_without_a_placeholder_is_rejected() {
1079 let root = tmpdir("noplaceholder");
1080 let src = root.join("src");
1081 std::fs::create_dir_all(&src).unwrap();
1082 write_tagged_blog(&src, "");
1083 std::fs::write(
1084 src.join("orgo.toml"),
1085 "[[collections]]\nsource = \"blog\"\ngroup_by = \"tags\"\n\
1086 output = \"tags/all.html\"\ntemplate = \"tag.html\"\n",
1087 )
1088 .unwrap();
1089
1090 let err = build_site(&src, &root.join("out"), &BuildOptions::default())
1091 .expect_err("grouping without {tag} must fail");
1092 assert!(format!("{err:#}").contains("{tag}"), "explains what is missing: {err:#}");
1093}
1094
1095/// Two tags that differ only in punctuation slugify to the same path, and one page would
1096/// silently overwrite the other.
1097#[test]
1098fn tags_that_collide_in_a_url_are_rejected() {
1099 let root = tmpdir("tagcollide");
1100 let src = root.join("src");
1101 std::fs::create_dir_all(&src).unwrap();
1102 write_tagged_blog(&src, "");
1103 std::fs::write(
1104 src.join("blog/a.org"),
1105 "#+TITLE: Post A\n#+DATE: 2024-01-01\n#+FILETAGS: :web_dev:\n\nBody.\n",
1106 )
1107 .unwrap();
1108 std::fs::write(
1109 src.join("blog/b.org"),
1110 "#+TITLE: Post B\n#+DATE: 2024-02-02\n#+FILETAGS: :web@dev:\n\nBody.\n",
1111 )
1112 .unwrap();
1113
1114 let err = build_site(&src, &root.join("out"), &BuildOptions::default())
1115 .expect_err("colliding tag slugs must fail");
1116 let message = format!("{err:#}");
1117 assert!(message.contains("web_dev") && message.contains("web@dev"), "{message}");
1118}
1119
1120// ---------------------------------------------------------------------------
1121// Pagination
1122// ---------------------------------------------------------------------------
1123
1124/// A blog of `count` dated posts with a paginating collection over them.
1125fn write_paginated_blog(src: &Utf8PathBuf, count: usize, extra: &str) {
1126 std::fs::create_dir_all(src.join("blog")).unwrap();
1127 std::fs::create_dir_all(src.join("templates")).unwrap();
1128 std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nWelcome.\n").unwrap();
1129 for i in 0..count {
1130 std::fs::write(
1131 src.join(format!("blog/p{i:02}.org")),
1132 format!(
1133 "#+TITLE: Post {i:02}\n#+DATE: 2024-01-{:02}\n\nBody.\n",
1134 i + 1
1135 ),
1136 )
1137 .unwrap();
1138 }
1139 std::fs::write(
1140 src.join("templates/list.html"),
1141 "<html><body><h1>{{ page.title }}</h1>\
1142 <ul>{% for p in pages %}<li>{{ p.title }}</li>{% endfor %}</ul>\
1143 {% if paginator %}<p>page {{ paginator.current }}/{{ paginator.total }} \
1144 of {{ paginator.total_entries }}</p>\
1145 {% if paginator.prev_url %}<a id=\"prev\" href=\"{{ paginator.prev_url }}\">p</a>{% endif %}\
1146 {% if paginator.next_url %}<a id=\"next\" href=\"{{ paginator.next_url }}\">n</a>{% endif %}\
1147 <nav>{% for pg in paginator.pages %}<a href=\"{{ pg.url }}\"{% if pg.current %} \
1148 class=\"here\"{% endif %}>{{ pg.number }}</a>{% endfor %}</nav>{% endif %}</body></html>",
1149 )
1150 .unwrap();
1151 std::fs::write(
1152 src.join("orgo.toml"),
1153 format!(
1154 "[[collections]]\nsource = \"blog\"\noutput = \"blog/index.html\"\n\
1155 template = \"list.html\"\ntitle = \"Blog\"\n{extra}"
1156 ),
1157 )
1158 .unwrap();
1159}
1160
1161/// Page 1 keeps the collection's `output`, so a section's canonical URL never moves as
1162/// its page count changes.
1163#[test]
1164fn pagination_splits_entries_and_keeps_page_one_canonical() {
1165 let root = tmpdir("paginate");
1166 let src = root.join("src");
1167 std::fs::create_dir_all(&src).unwrap();
1168 write_paginated_blog(&src, 7, "paginate = 3\npaginate_output = \"blog/page/{n}.html\"\n");
1169 let out = root.join("out");
1170 build(&src, &out);
1171
1172 assert!(out.join("blog/index.html").exists(), "page 1 is the canonical URL");
1173 for n in [2, 3] {
1174 assert!(out.join(format!("blog/page/{n}.html")).exists(), "page {n} exists");
1175 }
1176 assert!(!out.join("blog/page/4.html").exists(), "7 entries at 3/page is 3 pages");
1177 assert!(!out.join("blog/page/1.html").exists(), "page 1 is not duplicated");
1178
1179 // Newest first, so page 1 holds posts 06, 05, 04.
1180 let first = page(&out, "blog/index.html");
1181 assert!(first.contains("page 1/3 of 7"), "paginator counts:\n{first}");
1182 assert!(first.contains("Post 06") && first.contains("Post 04"));
1183 assert!(!first.contains("Post 03"), "page 1 holds only its own slice:\n{first}");
1184
1185 let last = page(&out, "blog/page/3.html");
1186 assert!(last.contains("Post 00"), "the remainder lands on the last page:\n{last}");
1187 assert_eq!(last.matches("<li>").count(), 1, "7 = 3 + 3 + 1");
1188}
1189
1190/// Paginator URLs have to be relative to the page carrying them, and pages 2..N sit at a
1191/// different depth than page 1.
1192#[test]
1193fn paginator_urls_resolve_from_each_pages_own_depth() {
1194 let root = tmpdir("pageurls");
1195 let src = root.join("src");
1196 std::fs::create_dir_all(&src).unwrap();
1197 write_paginated_blog(&src, 7, "paginate = 3\npaginate_output = \"blog/page/{n}.html\"\n");
1198 let out = root.join("out");
1199 build(&src, &out);
1200
1201 let first = page(&out, "blog/index.html");
1202 assert!(first.contains("id=\"next\" href=\"page/2.html\""), "down a level:\n{first}");
1203 assert!(!first.contains("id=\"prev\""), "page 1 has no previous");
1204
1205 let middle = page(&out, "blog/page/2.html");
1206 assert!(middle.contains("id=\"prev\" href=\"../index.html\""), "back up:\n{middle}");
1207 assert!(middle.contains("id=\"next\" href=\"3.html\""), "sideways:\n{middle}");
1208
1209 let last = page(&out, "blog/page/3.html");
1210 assert!(!last.contains("id=\"next\""), "the last page has no next:\n{last}");
1211}
1212
1213/// The numbered strip marks the page it is on, so a template does not compare numbers.
1214#[test]
1215fn the_paginator_exposes_a_numbered_page_list() {
1216 let root = tmpdir("pagenums");
1217 let src = root.join("src");
1218 std::fs::create_dir_all(&src).unwrap();
1219 write_paginated_blog(&src, 7, "paginate = 3\npaginate_output = \"blog/page/{n}.html\"\n");
1220 let out = root.join("out");
1221 build(&src, &out);
1222
1223 let second = page(&out, "blog/page/2.html");
1224 assert!(second.contains(">1</a>") && second.contains(">3</a>"), "all pages listed");
1225 assert!(
1226 second.contains("class=\"here\">2</a>"),
1227 "the current page is marked:\n{second}"
1228 );
1229}
1230
1231/// An unpaginated collection must not grow a paginator, so `{% if paginator %}` is a
1232/// reliable test in a shared template.
1233#[test]
1234fn an_unpaginated_collection_has_no_paginator() {
1235 let root = tmpdir("nopaginator");
1236 let src = root.join("src");
1237 std::fs::create_dir_all(&src).unwrap();
1238 write_paginated_blog(&src, 4, "");
1239 let out = root.join("out");
1240 build(&src, &out);
1241
1242 let listing = page(&out, "blog/index.html");
1243 assert!(!listing.contains("page 1/"), "no paginator block:\n{listing}");
1244 assert_eq!(listing.matches("<li>").count(), 4, "everything on one page");
1245}
1246
1247/// A section with nothing in it should be a page saying so, not a 404.
1248#[test]
1249fn an_empty_paginated_collection_still_emits_page_one() {
1250 let root = tmpdir("pageempty");
1251 let src = root.join("src");
1252 std::fs::create_dir_all(&src).unwrap();
1253 write_paginated_blog(&src, 0, "paginate = 3\npaginate_output = \"blog/page/{n}.html\"\n");
1254 let out = root.join("out");
1255 build(&src, &out);
1256
1257 let listing = page(&out, "blog/index.html");
1258 assert!(listing.contains("page 1/1 of 0"), "one empty page:\n{listing}");
1259 assert!(!out.join("blog/page/2.html").exists());
1260}
1261
1262/// Grouped and paginated together: each group paginates independently, which is why
1263/// `paginate_output` needs both placeholders.
1264#[test]
1265fn groups_paginate_independently() {
1266 let root = tmpdir("pagegroups");
1267 let src = root.join("src");
1268 std::fs::create_dir_all(&src).unwrap();
1269 write_paginated_blog(&src, 0, "");
1270 for (name, tag, n) in [("a", "rust", 0), ("b", "rust", 1), ("c", "rust", 2), ("d", "web", 3)] {
1271 std::fs::write(
1272 src.join(format!("blog/{name}.org")),
1273 format!("#+TITLE: Post {name}\n#+DATE: 2024-01-0{}\n#+FILETAGS: :{tag}:\n\nBody.\n", n + 1),
1274 )
1275 .unwrap();
1276 }
1277 std::fs::write(
1278 src.join("orgo.toml"),
1279 "[[collections]]\nsource = \"blog\"\ngroup_by = \"tags\"\n\
1280 output = \"tags/{tag}.html\"\ntemplate = \"list.html\"\ntitle = \"{tag}\"\n\
1281 paginate = 2\npaginate_output = \"tags/{tag}/page/{n}.html\"\n",
1282 )
1283 .unwrap();
1284 let out = root.join("out");
1285 build(&src, &out);
1286
1287 assert!(out.join("tags/rust.html").exists(), "3 rust posts, page 1");
1288 assert!(out.join("tags/rust/page/2.html").exists(), "3 rust posts at 2/page needs page 2");
1289 assert!(out.join("tags/web.html").exists(), "1 web post");
1290 assert!(
1291 !out.join("tags/web/page/2.html").exists(),
1292 "one post needs no second page — groups paginate independently"
1293 );
1294}
1295
1296/// A `paginate_output` without `{n}` would have every page overwrite one file; without
1297/// `{tag}` on a grouped collection, page 2 of one group would overwrite page 2 of
1298/// another.
1299#[test]
1300fn pagination_placeholders_are_validated() {
1301 let root = tmpdir("pagevalidate");
1302 let src = root.join("src");
1303 std::fs::create_dir_all(&src).unwrap();
1304
1305 let cases = [
1306 ("paginate = 3\n", "paginate_output"),
1307 ("paginate = 3\npaginate_output = \"blog/more.html\"\n", "{n}"),
1308 ("paginate_output = \"blog/page/{n}.html\"\n", "paginate"),
1309 ];
1310 for (extra, expect) in cases {
1311 write_paginated_blog(&src, 4, extra);
1312 let err = build_site(&src, &root.join("out"), &BuildOptions::default())
1313 .expect_err("invalid pagination config must fail");
1314 let message = format!("{err:#}");
1315 assert!(message.contains(expect), "expected {expect:?} in: {message}");
1316 }
1317
1318 // Grouped without {tag} in the page pattern.
1319 std::fs::write(
1320 src.join("orgo.toml"),
1321 "[[collections]]\nsource = \"blog\"\ngroup_by = \"tags\"\n\
1322 output = \"tags/{tag}.html\"\ntemplate = \"list.html\"\n\
1323 paginate = 2\npaginate_output = \"tags/page/{n}.html\"\n",
1324 )
1325 .unwrap();
1326 let err = build_site(&src, &root.join("out2"), &BuildOptions::default())
1327 .expect_err("grouped pagination without {tag} must fail");
1328 assert!(format!("{err:#}").contains("{tag}"), "{err:#}");
1329}
1330
1331/// Adding a post shifts every entry across page boundaries, so all pages of that
1332/// collection change — but nothing else does. And when the count shrinks, the pages that
1333/// no longer exist have to be deleted rather than left serving stale content.
1334#[test]
1335fn page_count_changes_add_and_remove_page_files() {
1336 let root = tmpdir("pageshrink");
1337 let src = root.join("src");
1338 std::fs::create_dir_all(&src).unwrap();
1339 write_paginated_blog(&src, 7, "paginate = 3\npaginate_output = \"blog/page/{n}.html\"\n");
1340 let out = root.join("out");
1341 build(&src, &out);
1342 assert!(build(&src, &out).rendered.is_empty(), "unchanged rebuild renders nothing");
1343 assert!(out.join("blog/page/3.html").exists());
1344
1345 // Drop below two pages' worth.
1346 for i in 2..7 {
1347 std::fs::remove_file(src.join(format!("blog/p{i:02}.org"))).unwrap();
1348 }
1349 build(&src, &out);
1350
1351 assert!(
1352 !out.join("blog/page/2.html").exists() && !out.join("blog/page/3.html").exists(),
1353 "pages that no longer exist are deleted, not left serving stale posts"
1354 );
1355 let first = page(&out, "blog/index.html");
1356 assert!(first.contains("page 1/1 of 2"), "the paginator reflects the new size:\n{first}");
1357}
1358
1359// ---------------------------------------------------------------------------
1360// base_url and absolute URLs
1361// ---------------------------------------------------------------------------
1362
1363/// A site with a feed collection, optionally with a base URL configured.
1364fn write_feed_site(src: &Utf8PathBuf, base_url: &str) {
1365 std::fs::create_dir_all(src.join("blog")).unwrap();
1366 std::fs::create_dir_all(src.join("templates")).unwrap();
1367 std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nWelcome.\n").unwrap();
1368 std::fs::write(
1369 src.join("blog/post.org"),
1370 "#+TITLE: A Post\n#+DATE: [2026-02-02 Mon 09:15:00]\n#+FILETAGS: :rust:\n\nBody.\n",
1371 )
1372 .unwrap();
1373 std::fs::write(
1374 src.join("templates/feed.xml"),
1375 "<?xml version=\"1.0\"?><rss version=\"2.0\"><channel>\
1376 <link>{{ \"index.html\" | absolute }}</link>\
1377 {% for p in pages %}<item><link>{{ p.url | absolute }}</link>\
1378 <pubDate>{{ p.date_iso | rfc822 }}</pubDate></item>{% endfor %}\
1379 </channel></rss>",
1380 )
1381 .unwrap();
1382 std::fs::write(
1383 src.join("orgo.toml"),
1384 format!(
1385 "[site]\nbase_url = \"{base_url}\"\n\n\
1386 [[collections]]\nsource = \"blog\"\noutput = \"feed.xml\"\n\
1387 template = \"feed.xml\"\ntitle = \"Feed\"\n"
1388 ),
1389 )
1390 .unwrap();
1391}
1392
1393/// A feed is read away from the site that served it, so its links have to be absolute.
1394#[test]
1395fn a_feed_gets_absolute_urls_from_base_url() {
1396 let root = tmpdir("feedabs");
1397 let src = root.join("src");
1398 std::fs::create_dir_all(&src).unwrap();
1399 write_feed_site(&src, "https://example.com");
1400 let out = root.join("out");
1401 build(&src, &out);
1402
1403 let feed = page(&out, "feed.xml");
1404 assert!(
1405 feed.contains("<link>https://example.com/blog/post.html</link>"),
1406 "entry links are absolute:\n{feed}"
1407 );
1408 assert!(
1409 feed.contains("<link>https://example.com/index.html</link>"),
1410 "a literal path can be made absolute too:\n{feed}"
1411 );
1412 assert!(!feed.contains("<link>blog/"), "no relative link survives:\n{feed}");
1413}
1414
1415/// RSS `pubDate` has a required format, and org dates are not in it.
1416#[test]
1417fn dates_convert_to_rfc822_for_rss() {
1418 let root = tmpdir("feedrfc");
1419 let src = root.join("src");
1420 std::fs::create_dir_all(&src).unwrap();
1421 write_feed_site(&src, "https://example.com");
1422 let out = root.join("out");
1423 build(&src, &out);
1424
1425 assert!(
1426 page(&out, "feed.xml").contains("<pubDate>Mon, 02 Feb 2026 00:00:00 +0000</pubDate>"),
1427 "an org timestamp becomes an RSS date:\n{}",
1428 page(&out, "feed.xml")
1429 );
1430}
1431
1432/// Falling back to a relative URL would produce a feed that validates nowhere and looks
1433/// fine everywhere. The error has to name the setting and the fix.
1434#[test]
1435fn absolute_without_a_base_url_is_an_error_that_says_what_to_set() {
1436 let root = tmpdir("feednobase");
1437 let src = root.join("src");
1438 std::fs::create_dir_all(&src).unwrap();
1439 write_feed_site(&src, "");
1440
1441 let err = build_site(&src, &root.join("out"), &BuildOptions::default())
1442 .expect_err("absolute with no base_url must fail");
1443 let message = format!("{err:#}");
1444 assert!(message.contains("base_url"), "names the setting: {message}");
1445 assert!(message.contains("orgo.toml"), "names where to set it: {message}");
1446 assert!(message.contains("feed.xml"), "names the template: {message}");
1447}
1448
1449/// A base URL with a trailing slash would produce `https://example.com//blog/x.html`.
1450#[test]
1451fn a_trailing_slash_on_base_url_is_rejected() {
1452 let mut config = Config::default();
1453 config.site.base_url = "https://example.com/".to_string();
1454 let err = config.validate().expect_err("trailing slash must fail");
1455 assert!(format!("{err:#}").contains("slash"), "{err:#}");
1456}
1457
1458/// An already-absolute URL passes through, so a template can apply the filter uniformly
1459/// to a mix of internal paths and external links.
1460#[test]
1461fn absolute_leaves_existing_absolute_urls_alone() {
1462 let root = tmpdir("feedpass");
1463 let src = root.join("src");
1464 std::fs::create_dir_all(&src).unwrap();
1465 write_feed_site(&src, "https://example.com");
1466 std::fs::write(
1467 src.join("templates/feed.xml"),
1468 "<x>{{ \"https://other.example/a.html\" | absolute }}</x>",
1469 )
1470 .unwrap();
1471 let out = root.join("out");
1472 build(&src, &out);
1473
1474 assert_eq!(page(&out, "feed.xml"), "<x>https://other.example/a.html</x>");
1475}
1476
1477/// Canonical links need an absolute URL, so the default layout emits one only when there
1478/// is a base URL to build it from.
1479#[test]
1480fn the_default_layout_emits_a_canonical_link_only_with_a_base_url() {
1481 for (base, expect) in [("https://example.com", true), ("", false)] {
1482 let root = tmpdir("canonical");
1483 let src = root.join("src");
1484 std::fs::create_dir_all(&src).unwrap();
1485 write_site(&src);
1486 std::fs::write(
1487 src.join("orgo.toml"),
1488 format!("[site]\nbase_url = \"{base}\"\n"),
1489 )
1490 .unwrap();
1491 let out = root.join("out");
1492 build(&src, &out);
1493
1494 let html = page(&out, "blog/post.html");
1495 assert_eq!(
1496 html.contains("<link rel=\"canonical\" href=\"https://example.com/blog/post.html\">"),
1497 expect,
1498 "base_url {base:?} canonical presence:\n{html}"
1499 );
1500 }
1501}
1502
1503/// `base_url` changes every absolute URL on the site, so it has to invalidate the cache
1504/// like any other config change.
1505#[test]
1506fn changing_base_url_re_renders_the_site() {
1507 let root = tmpdir("basehash");
1508 let src = root.join("src");
1509 std::fs::create_dir_all(&src).unwrap();
1510 write_site(&src);
1511 std::fs::write(
1512 src.join("orgo.toml"),
1513 "[site]\nbase_url = \"https://example.com\"\n",
1514 )
1515 .unwrap();
1516 let out = root.join("out");
1517 build(&src, &out);
1518 assert!(build(&src, &out).rendered.is_empty(), "unchanged rebuild renders nothing");
1519
1520 std::fs::write(
1521 src.join("orgo.toml"),
1522 "[site]\nbase_url = \"https://moved.example\"\n",
1523 )
1524 .unwrap();
1525 let report = build(&src, &out);
1526
1527 assert_eq!(report.rendered.len(), 3, "every page carries the base URL");
1528 assert!(page(&out, "index.html").contains("https://moved.example/index.html"));
1529}
1530
1531// ---------------------------------------------------------------------------
1532// Excerpts, reading metadata, and drafts
1533// ---------------------------------------------------------------------------
1534
1535/// Posts with and without a `#+DESCRIPTION:`, and a template that prints the metadata.
1536fn write_excerpt_site(src: &Utf8PathBuf, extra_config: &str) {
1537 std::fs::create_dir_all(src.join("blog")).unwrap();
1538 std::fs::create_dir_all(src.join("templates")).unwrap();
1539 std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nWelcome.\n").unwrap();
1540 std::fs::write(
1541 src.join("blog/described.org"),
1542 "#+TITLE: Described\n#+DATE: 2024-02-02\n#+DESCRIPTION: A hand-written summary.\n\n\
1543 The body's first paragraph, which is not the excerpt here.\n",
1544 )
1545 .unwrap();
1546 std::fs::write(
1547 src.join("blog/plain.org"),
1548 "#+TITLE: Plain\n#+DATE: 2024-01-01\n\nThe opening paragraph stands in for a summary.\n\n\
1549 A second paragraph that should not appear in the excerpt.\n",
1550 )
1551 .unwrap();
1552 std::fs::write(
1553 src.join("templates/list.html"),
1554 "<html><body>{% for p in pages %}<li>{{ p.title }}|{{ p.excerpt }}|\
1555 {{ p.word_count }}|{{ p.reading_time }}</li>{% endfor %}</body></html>",
1556 )
1557 .unwrap();
1558 std::fs::write(
1559 src.join("orgo.toml"),
1560 format!(
1561 "[[collections]]\nsource = \"blog\"\noutput = \"blog/index.html\"\n\
1562 template = \"list.html\"\ntitle = \"Blog\"\n{extra_config}"
1563 ),
1564 )
1565 .unwrap();
1566}
1567
1568/// A listing of bare titles is thin. 176 of the 179 corpus files set a
1569/// `#+DESCRIPTION:`, so that is the excerpt when it exists — and the first paragraph
1570/// when it does not, so a page that never thought about summaries still has one.
1571#[test]
1572fn excerpts_prefer_the_description_and_fall_back_to_the_first_paragraph() {
1573 let root = tmpdir("excerpt");
1574 let src = root.join("src");
1575 std::fs::create_dir_all(&src).unwrap();
1576 write_excerpt_site(&src, "");
1577 let out = root.join("out");
1578 build(&src, &out);
1579
1580 let listing = page(&out, "blog/index.html");
1581 assert!(
1582 listing.contains("Described|A hand-written summary.|"),
1583 "an explicit description wins:\n{listing}"
1584 );
1585 assert!(
1586 listing.contains("Plain|The opening paragraph stands in for a summary.|"),
1587 "otherwise the first paragraph:\n{listing}"
1588 );
1589 assert!(
1590 !listing.contains("A second paragraph"),
1591 "only the *first* paragraph:\n{listing}"
1592 );
1593}
1594
1595/// Reading time should describe the prose someone reads, not the code they skim.
1596#[test]
1597fn word_count_and_reading_time_ignore_code_blocks() {
1598 let root = tmpdir("wordcount");
1599 let src = root.join("src");
1600 std::fs::create_dir_all(&src).unwrap();
1601 write_excerpt_site(&src, "");
1602 let prose = "word ".repeat(400);
1603 std::fs::write(
1604 src.join("blog/plain.org"),
1605 format!(
1606 "#+TITLE: Plain\n#+DATE: 2024-01-01\n\n{prose}\n\n\
1607 #+BEGIN_SRC rust\n{}\n#+END_SRC\n",
1608 "let noise = 1; ".repeat(200)
1609 ),
1610 )
1611 .unwrap();
1612 let out = root.join("out");
1613 build(&src, &out);
1614
1615 let listing = page(&out, "blog/index.html");
1616 // Exactly the 400 prose words: the 600+ words of code are not prose, and neither is
1617 // `#+TITLE:`, which is metadata the layout renders as chrome rather than body text.
1618 assert!(
1619 listing.contains("|400|2</li>"),
1620 "code must not inflate the count or the estimate:\n{listing}"
1621 );
1622}
1623
1624/// An excerpt is usually a whole paragraph, and minijinja ships no `truncate`, so
1625/// without one a listing's only options are the full paragraph or nothing.
1626#[test]
1627fn the_truncate_filter_cuts_on_a_word_boundary() {
1628 let root = tmpdir("truncate");
1629 let src = root.join("src");
1630 std::fs::create_dir_all(&src).unwrap();
1631 write_excerpt_site(&src, "");
1632 std::fs::write(
1633 src.join("templates/list.html"),
1634 "<html><body>{% for p in pages %}<li>{{ p.excerpt | truncate(20) }}</li>\
1635 {% endfor %}<x>{{ \"short\" | truncate(20) }}</x></body></html>",
1636 )
1637 .unwrap();
1638 let out = root.join("out");
1639 build(&src, &out);
1640
1641 let listing = page(&out, "blog/index.html");
1642 assert!(
1643 listing.contains("<li>A hand-written…</li>"),
1644 "cut at a space, not mid-word:\n{listing}"
1645 );
1646 assert!(
1647 listing.contains("<x>short</x>"),
1648 "text under the limit is untouched:\n{listing}"
1649 );
1650}
1651
1652/// The point of marking something a draft is that it is not ready to be read.
1653#[test]
1654fn drafts_are_excluded_from_the_build_by_default() {
1655 let root = tmpdir("draft");
1656 let src = root.join("src");
1657 std::fs::create_dir_all(&src).unwrap();
1658 write_excerpt_site(&src, "");
1659 std::fs::write(
1660 src.join("blog/wip.org"),
1661 "#+TITLE: Unfinished\n#+DRAFT: t\n#+DATE: 2024-03-03\n\nNot ready.\n",
1662 )
1663 .unwrap();
1664 let out = root.join("out");
1665 build(&src, &out);
1666
1667 assert!(!out.join("blog/wip.html").exists(), "no page is written");
1668 assert!(
1669 !page(&out, "blog/index.html").contains("Unfinished"),
1670 "and it is absent from listings, not merely unlinked"
1671 );
1672}
1673
1674/// `--drafts` is for previewing one while writing it, typically under `watch`.
1675#[test]
1676fn the_drafts_flag_includes_them() {
1677 let root = tmpdir("draftflag");
1678 let src = root.join("src");
1679 std::fs::create_dir_all(&src).unwrap();
1680 write_excerpt_site(&src, "");
1681 std::fs::write(
1682 src.join("blog/wip.org"),
1683 "#+TITLE: Unfinished\n#+DRAFT: t\n#+DATE: 2024-03-03\n\nNot ready.\n",
1684 )
1685 .unwrap();
1686 let out = root.join("out");
1687 build_site(
1688 &src,
1689 &out,
1690 &BuildOptions {
1691 drafts: true,
1692 ..Default::default()
1693 },
1694 )
1695 .expect("build");
1696
1697 assert!(out.join("blog/wip.html").exists());
1698 assert!(page(&out, "blog/index.html").contains("Unfinished"));
1699}
1700
1701/// A draft is absent from the symbol table too, so a link to one is reported as the dead
1702/// link it would be on the published site — rather than silently pointing at nothing.
1703#[test]
1704fn a_link_to_a_draft_is_reported_as_broken() {
1705 let root = tmpdir("draftlink");
1706 let src = root.join("src");
1707 std::fs::create_dir_all(&src).unwrap();
1708 write_excerpt_site(&src, "");
1709 std::fs::write(
1710 src.join("blog/wip.org"),
1711 "#+TITLE: Unfinished\n#+DRAFT: t\n\nNot ready.\n",
1712 )
1713 .unwrap();
1714 std::fs::write(
1715 src.join("index.org"),
1716 "#+TITLE: Home\n\nSee [[file:blog/wip.org][the draft]].\n",
1717 )
1718 .unwrap();
1719 let out = root.join("out");
1720 let report = build(&src, &out);
1721
1722 assert!(
1723 report.warnings().iter().any(|w| w.contains("wip.org")),
1724 "linking to a draft must be reported: {:?}",
1725 report.warnings()
1726 );
1727}
1728
1729/// Writing the keyword at all is the signal. Publishing an unfinished post because the
1730/// value was not the expected spelling is the wrong way to be strict — but an explicit
1731/// "no" has to mean no.
1732#[test]
1733fn draft_truthiness_is_forgiving_but_respects_an_explicit_negative() {
1734 use orgo::model::Keywords;
1735 let draft = |value: &str| {
1736 orgo::util::is_draft(&Keywords {
1737 entries: vec![("DRAFT".to_string(), value.to_string())],
1738 })
1739 };
1740 for yes in ["t", "true", "yes", "1", "", " ", "anything"] {
1741 assert!(draft(yes), "{yes:?} should mean draft");
1742 }
1743 for no in ["nil", "false", "no", "0", "off", "NIL"] {
1744 assert!(!draft(no), "{no:?} should mean published");
1745 }
1746 assert!(
1747 !orgo::util::is_draft(&Keywords::default()),
1748 "no keyword at all means published"
1749 );
1750}
1751
1752// ---------------------------------------------------------------------------
1753// Table of contents, section numbers, and #+OPTIONS:
1754// ---------------------------------------------------------------------------
1755
1756/// A page with nested headings, one of which sets its own `:CUSTOM_ID:`.
1757fn write_toc_site(src: &Utf8PathBuf, options: &str, config: &str) {
1758 std::fs::create_dir_all(src.join("templates")).unwrap();
1759 std::fs::write(
1760 src.join("index.org"),
1761 format!(
1762 "#+TITLE: Contents\n{options}\n\nIntro.\n\n\
1763 * First\nBody.\n** Nested\nBody.\n\
1764 * Second\n:PROPERTIES:\n:CUSTOM_ID: chosen-id\n:END:\nBody.\n"
1765 ),
1766 )
1767 .unwrap();
1768 std::fs::write(
1769 src.join("templates/base.html"),
1770 "<html><body>{% macro walk(es) %}<ul>{% for e in es %}\
1771 <li>{{ e.level }}:{{ e.title }}@{{ e.anchor }}{% if e.children %}{{ walk(e.children) }}\
1772 {% endif %}</li>{% endfor %}</ul>{% endmacro %}\
1773 <nav>{{ walk(page.toc) }}</nav>{{ body | safe }}</body></html>",
1774 )
1775 .unwrap();
1776 std::fs::write(src.join("orgo.toml"), config).unwrap();
1777}
1778
1779/// A table of contents is a tree, and reconstructing one from a flat list of levels
1780/// inside a template is the kind of thing Jinja is bad at.
1781#[test]
1782fn the_table_of_contents_mirrors_the_heading_tree() {
1783 let root = tmpdir("toc");
1784 let src = root.join("src");
1785 std::fs::create_dir_all(&src).unwrap();
1786 write_toc_site(&src, "", "");
1787 let out = root.join("out");
1788 build(&src, &out);
1789
1790 let html = page(&out, "index.html");
1791 assert!(html.contains("<li>1:First@first"), "top level:\n{html}");
1792 assert!(
1793 html.contains("<li>1:First@first<ul><li>2:Nested@nested</li></ul></li>"),
1794 "a child nests inside its parent's item:\n{html}"
1795 );
1796}
1797
1798/// The TOC links into the page, so its anchors must be the ones the headings actually
1799/// carry — including a heading that chose its own `:CUSTOM_ID:`.
1800#[test]
1801fn toc_anchors_match_the_ids_the_headings_are_emitted_with() {
1802 let root = tmpdir("tocanchor");
1803 let src = root.join("src");
1804 std::fs::create_dir_all(&src).unwrap();
1805 write_toc_site(&src, "", "");
1806 std::fs::write(
1807 src.join("templates/base.html"),
1808 "<html><body>{% for e in page.toc %}<a href=\"#{{ e.anchor }}\">x</a>{% endfor %}\
1809 {{ body | safe }}</body></html>",
1810 )
1811 .unwrap();
1812 let out = root.join("out");
1813 build(&src, &out);
1814
1815 let html = page(&out, "index.html");
1816 let links: Vec<&str> = html.matches("href=\"#").map(|_| "").collect();
1817 assert_eq!(links.len(), 2, "one link per top-level heading");
1818 assert!(html.contains("href=\"#chosen-id\""), ":CUSTOM_ID: wins:\n{html}");
1819 assert!(
1820 html.contains("<h2 id=\"chosen-id\">"),
1821 "and the heading carries that same id:\n{html}"
1822 );
1823}
1824
1825/// Org's own per-file switch. 4 of the reference corpus's 179 files use exactly this to
1826/// turn the table of contents off for one document.
1827#[test]
1828fn options_toc_nil_turns_the_toc_off_for_one_document() {
1829 let root = tmpdir("tocnil");
1830 let src = root.join("src");
1831 std::fs::create_dir_all(&src).unwrap();
1832 write_toc_site(&src, "#+OPTIONS: toc:nil", "");
1833 let out = root.join("out");
1834 build(&src, &out);
1835
1836 let html = page(&out, "index.html");
1837 assert!(html.contains("<nav><ul></ul></nav>"), "the toc is empty:\n{html}");
1838 assert!(html.contains("First"), "the page itself still renders:\n{html}");
1839}
1840
1841/// The site-wide switch, for someone who never wants one.
1842#[test]
1843fn the_toc_can_be_disabled_site_wide() {
1844 let root = tmpdir("tocoff");
1845 let src = root.join("src");
1846 std::fs::create_dir_all(&src).unwrap();
1847 write_toc_site(&src, "", "[html]\ntoc = false\n");
1848 let out = root.join("out");
1849 build(&src, &out);
1850
1851 assert!(page(&out, "index.html").contains("<nav><ul></ul></nav>"));
1852}
1853
1854/// Numbering is off by default — which differs from Emacs deliberately — and
1855/// `#+OPTIONS: num:t` gets Emacs' behaviour back for a document.
1856#[test]
1857fn section_numbers_are_off_by_default_and_enabled_per_document() {
1858 let root = tmpdir("secnum");
1859 let src = root.join("src");
1860 std::fs::create_dir_all(&src).unwrap();
1861 write_toc_site(&src, "", "");
1862 let out = root.join("out");
1863 build(&src, &out);
1864 assert!(
1865 !page(&out, "index.html").contains("section-number"),
1866 "no numbers unless asked for"
1867 );
1868
1869 write_toc_site(&src, "#+OPTIONS: num:t", "");
1870 let out2 = root.join("out2");
1871 build(&src, &out2);
1872 let html = page(&out2, "index.html");
1873 // Emacs' own class names, so output stays diffable against the oracle.
1874 assert!(html.contains("<span class=\"section-number-2\">1.</span> First"), "{html}");
1875 assert!(html.contains("<span class=\"section-number-3\">1.1.</span> Nested"), "{html}");
1876 assert!(html.contains("<span class=\"section-number-2\">2.</span> Second"), "{html}");
1877}
1878
1879/// Deeper levels have to reset when a shallower one advances, or the second chapter's
1880/// first section is numbered 1.3.
1881#[test]
1882fn section_numbering_resets_at_each_level() {
1883 let root = tmpdir("secreset");
1884 let src = root.join("src");
1885 std::fs::create_dir_all(&src).unwrap();
1886 std::fs::write(
1887 src.join("index.org"),
1888 "#+TITLE: T\n#+OPTIONS: num:t\n\n\
1889 * One\n** A\n** B\n* Two\n** C\n*** Deep\n* Three\n",
1890 )
1891 .unwrap();
1892 std::fs::write(src.join("orgo.toml"), "").unwrap();
1893 let out = root.join("out");
1894 build(&src, &out);
1895
1896 let html = page(&out, "index.html");
1897 for (number, title) in [
1898 ("1.", "One"),
1899 ("1.1.", "A"),
1900 ("1.2.", "B"),
1901 ("2.", "Two"),
1902 ("2.1.", "C"),
1903 ("2.1.1.", "Deep"),
1904 ("3.", "Three"),
1905 ] {
1906 assert!(
1907 html.contains(&format!("</span> {title}</h")),
1908 "{title} should be numbered:\n{html}"
1909 );
1910 assert!(html.contains(&format!(">{number}</span> {title}")), "{title} = {number}:\n{html}");
1911 }
1912}
1913
1914/// `#+OPTIONS:` is a space-separated list of switches, and org spells "off" several ways.
1915#[test]
1916fn export_options_parse_as_org_writes_them() {
1917 use orgo::model::Keywords;
1918 use orgo::util::option_enabled;
1919 let keywords = |v: &str| Keywords {
1920 entries: vec![("OPTIONS".to_string(), v.to_string())],
1921 };
1922
1923 assert!(!option_enabled(&keywords("toc:nil num:t"), "toc", true));
1924 assert!(option_enabled(&keywords("toc:nil num:t"), "num", false));
1925 assert!(
1926 option_enabled(&keywords("toc:nil"), "num", true),
1927 "a switch the document does not mention keeps the site default"
1928 );
1929 assert!(
1930 !option_enabled(&Keywords::default(), "toc", false),
1931 "no #+OPTIONS: at all keeps the site default"
1932 );
1933 for off in ["nil", "false", "no", "0", "off"] {
1934 assert!(!option_enabled(&keywords(&format!("toc:{off}")), "toc", true), "{off}");
1935 }
1936}
1937
1938// ---------------------------------------------------------------------------
1939// Per-page template selection
1940// ---------------------------------------------------------------------------
1941
1942/// A site with a `post.html` layout beside the default one, so a page can be shown to
1943/// render through the layout it chose rather than the one every page gets.
1944fn write_two_layouts(src: &Utf8PathBuf, config: &str) {
1945 write_site(src);
1946 std::fs::create_dir_all(src.join("templates")).unwrap();
1947 std::fs::write(
1948 src.join("templates/base.html"),
1949 "<html><body><h1>{{ page.title }}</h1>{{ body | safe }}</body></html>",
1950 )
1951 .unwrap();
1952 std::fs::write(
1953 src.join("templates/post.html"),
1954 "<html><body class=\"post\"><h1>{{ page.title }}</h1>{{ body | safe }}\
1955 <p>Reply by email</p></body></html>",
1956 )
1957 .unwrap();
1958 std::fs::write(src.join("orgo.toml"), config).unwrap();
1959}
1960
1961/// A section's layout is a property of the section: one rule covers every page under it,
1962/// however deep, without touching a single source file.
1963#[test]
1964fn a_pages_rule_gives_a_directory_its_own_layout() {
1965 let root = tmpdir("tmplrule");
1966 let src = root.join("src");
1967 std::fs::create_dir_all(&src).unwrap();
1968 write_two_layouts(
1969 &src,
1970 "[[pages]]\nmatch = \"blog\"\ntemplate = \"post.html\"\n",
1971 );
1972 std::fs::create_dir_all(src.join("blog/2026")).unwrap();
1973 std::fs::write(
1974 src.join("blog/2026/nested.org"),
1975 "#+TITLE: Nested\n\nDeep.\n",
1976 )
1977 .unwrap();
1978 let out = root.join("out");
1979 build(&src, &out);
1980
1981 assert!(
1982 page(&out, "blog/post.html").contains("Reply by email"),
1983 "a post uses the section layout"
1984 );
1985 assert!(
1986 page(&out, "blog/2026/nested.html").contains("Reply by email"),
1987 "so does a post nested deeper"
1988 );
1989 assert!(
1990 !page(&out, "about.html").contains("Reply by email"),
1991 "a page outside the section does not"
1992 );
1993}
1994
1995/// Matching is by path component, not by string prefix: `blog` must not capture
1996/// `blogroll.org`, which is a different page with a name that happens to start the same.
1997#[test]
1998fn a_pages_rule_matches_whole_path_components() {
1999 let root = tmpdir("tmplprefix");
2000 let src = root.join("src");
2001 std::fs::create_dir_all(&src).unwrap();
2002 write_two_layouts(
2003 &src,
2004 "[[pages]]\nmatch = \"blog\"\ntemplate = \"post.html\"\n",
2005 );
2006 std::fs::write(src.join("blogroll.org"), "#+TITLE: Blogroll\n\nLinks.\n").unwrap();
2007 let out = root.join("out");
2008 build(&src, &out);
2009
2010 assert!(
2011 !page(&out, "blogroll.html").contains("Reply by email"),
2012 "blogroll.org is not inside blog/"
2013 );
2014}
2015
2016/// The page's own declaration wins: it is the more local statement, written with that
2017/// page in view.
2018#[test]
2019fn a_page_template_keyword_overrides_the_rule() {
2020 let root = tmpdir("tmplkeyword");
2021 let src = root.join("src");
2022 std::fs::create_dir_all(&src).unwrap();
2023 write_two_layouts(
2024 &src,
2025 "[[pages]]\nmatch = \"blog\"\ntemplate = \"base.html\"\n",
2026 );
2027 std::fs::write(
2028 src.join("blog/post.org"),
2029 "#+TITLE: A Post\n#+TEMPLATE: post.html\n\nBody.\n",
2030 )
2031 .unwrap();
2032 let out = root.join("out");
2033 build(&src, &out);
2034
2035 assert!(
2036 page(&out, "blog/post.html").contains("Reply by email"),
2037 "the keyword beats the rule"
2038 );
2039}
2040
2041/// Two rules can both cover a page; the more specific path is the one that meant it.
2042#[test]
2043fn the_most_specific_pages_rule_wins() {
2044 let root = tmpdir("tmplspecific");
2045 let src = root.join("src");
2046 std::fs::create_dir_all(&src).unwrap();
2047 write_two_layouts(
2048 &src,
2049 // Declared before the broader rule, so passing this test means specificity
2050 // decided it and not declaration order.
2051 "[[pages]]\nmatch = \"blog/notes\"\ntemplate = \"post.html\"\n\n\
2052 [[pages]]\nmatch = \"blog\"\ntemplate = \"base.html\"\n",
2053 );
2054 std::fs::create_dir_all(src.join("blog/notes")).unwrap();
2055 std::fs::write(src.join("blog/notes/n.org"), "#+TITLE: Note\n\nBody.\n").unwrap();
2056 let out = root.join("out");
2057 build(&src, &out);
2058
2059 assert!(
2060 page(&out, "blog/notes/n.html").contains("Reply by email"),
2061 "the deeper rule wins"
2062 );
2063 assert!(
2064 !page(&out, "blog/post.html").contains("Reply by email"),
2065 "the shallower rule still covers the rest"
2066 );
2067}
2068
2069/// A template name that does not exist is a typo. Naming the page, the template and what
2070/// does exist is the difference between a fix and a hunt.
2071#[test]
2072fn a_missing_page_template_is_an_error_naming_it() {
2073 let root = tmpdir("tmplmissing");
2074 let src = root.join("src");
2075 std::fs::create_dir_all(&src).unwrap();
2076 write_two_layouts(&src, "");
2077 std::fs::write(
2078 src.join("about.org"),
2079 "#+TITLE: About\n#+TEMPLATE: nope.html\n\nAbout.\n",
2080 )
2081 .unwrap();
2082
2083 let err = build_site(&src, &root.join("out"), &BuildOptions::default())
2084 .expect_err("a missing template must fail the build");
2085 let msg = format!("{err:#}");
2086 assert!(msg.contains("about.org"), "names the page: {msg}");
2087 assert!(msg.contains("nope.html"), "names the template: {msg}");
2088 assert!(msg.contains("post.html"), "lists what exists: {msg}");
2089}
2090
2091/// Changing a page's layout has to re-render that page and no other.
2092#[test]
2093fn changing_a_page_template_keyword_rerenders_only_that_page() {
2094 let root = tmpdir("tmplinc");
2095 let src = root.join("src");
2096 std::fs::create_dir_all(&src).unwrap();
2097 write_two_layouts(&src, "");
2098 let out = root.join("out");
2099 build(&src, &out);
2100
2101 std::fs::write(
2102 src.join("about.org"),
2103 "#+TITLE: About\n#+TEMPLATE: post.html\n\nAbout.\n",
2104 )
2105 .unwrap();
2106 let second = build(&src, &out);
2107
2108 assert_eq!(
2109 second.rendered,
2110 vec![Utf8PathBuf::from("about.html")],
2111 "only the page whose layout changed"
2112 );
2113 assert!(page(&out, "about.html").contains("Reply by email"));
2114}
2115
2116/// A rule is config, so adding one re-renders the pages it covers.
2117#[test]
2118fn adding_a_pages_rule_rerenders_the_pages_it_covers() {
2119 let root = tmpdir("tmplruleinc");
2120 let src = root.join("src");
2121 std::fs::create_dir_all(&src).unwrap();
2122 write_two_layouts(&src, "");
2123 let out = root.join("out");
2124 build(&src, &out);
2125
2126 std::fs::write(
2127 src.join("orgo.toml"),
2128 "[[pages]]\nmatch = \"blog\"\ntemplate = \"post.html\"\n",
2129 )
2130 .unwrap();
2131 let second = build(&src, &out);
2132
2133 assert!(
2134 second.rendered.contains(&Utf8PathBuf::from("blog/post.html")),
2135 "the covered page re-rendered: {:?}",
2136 second.rendered
2137 );
2138 assert!(page(&out, "blog/post.html").contains("Reply by email"));
2139}
2140
2141/// A rule that names no template is a rule that does nothing.
2142#[test]
2143fn a_pages_rule_without_a_template_is_rejected() {
2144 let mut config = Config::default();
2145 config.pages.push(orgo::config::PageRule {
2146 pattern: Utf8PathBuf::from("blog"),
2147 template: String::new(),
2148 });
2149 let err = config.validate().expect_err("empty template must fail");
2150 assert!(format!("{err:#}").contains("blog"), "names it: {err:#}");
2151}
2152
2153/// An archive wants year headings, and that is a template decision — but grouping by year
2154/// needs a year to group on, which a `YYYY-MM-DD` string cannot supply to `groupby`.
2155#[test]
2156fn a_listing_can_group_its_entries_by_year() {
2157 let root = tmpdir("listyear");
2158 let src = root.join("src");
2159 std::fs::create_dir_all(&src).unwrap();
2160 write_blog(&src, "");
2161 std::fs::write(
2162 src.join("templates/list.html"),
2163 "<html><body><ul>\
2164 {% for year, posts in pages | groupby(\"year\") | reverse %}\
2165 <li class=\"year\">{{ year if year else \"undated\" }}</li>\
2166 {% for p in posts %}<li>{{ p.title }}</li>{% endfor %}\
2167 {% endfor %}</ul></body></html>",
2168 )
2169 .unwrap();
2170 // A post with no date must still appear, under the default group.
2171 std::fs::write(src.join("blog/undated.org"), "#+TITLE: Undated\n\nBody.\n").unwrap();
2172 let out = root.join("out");
2173 build(&src, &out);
2174
2175 let html = page(&out, "blog/index.html");
2176 let years: Vec<&str> = html
2177 .split("class=\"year\">")
2178 .skip(1)
2179 .map(|s| s.split('<').next().unwrap())
2180 .collect();
2181 assert_eq!(
2182 years,
2183 vec!["2025", "2024", "undated"],
2184 "newest year first, undated last:\n{html}"
2185 );
2186 assert!(html.contains("Undated"), "the undated post is still listed");
2187}
2188
2189/// Two notes written on the same day are not written at the same moment, and org records
2190/// which came first. Sorting on the date alone throws that away.
2191#[test]
2192fn same_day_entries_sort_by_time_of_day() {
2193 let root = tmpdir("sorttime");
2194 let src = root.join("src");
2195 std::fs::create_dir_all(src.join("blog")).unwrap();
2196 std::fs::create_dir_all(src.join("templates")).unwrap();
2197 for (name, title, date) in [
2198 ("morning", "Morning", "[2026-02-21 Sat 09:15:00]"),
2199 ("evening", "Evening", "[2026-02-21 Sat 21:40:00]"),
2200 ("noon", "Noon", "[2026-02-21 Sat 12:30]"),
2201 ] {
2202 std::fs::write(
2203 src.join(format!("blog/{name}.org")),
2204 format!("#+TITLE: {title}\n#+DATE: {date}\n\nBody.\n"),
2205 )
2206 .unwrap();
2207 }
2208 std::fs::write(
2209 src.join("templates/list.html"),
2210 "<html><body>{% for p in pages %}<li>{{ p.title }}</li>{% endfor %}</body></html>",
2211 )
2212 .unwrap();
2213 std::fs::write(
2214 src.join("orgo.toml"),
2215 "[[collections]]\nsource = \"blog\"\noutput = \"blog/index.html\"\n\
2216 template = \"list.html\"\ntitle = \"Blog\"\nsort = \"date\"\norder = \"desc\"\n",
2217 )
2218 .unwrap();
2219 let out = root.join("out");
2220 build(&src, &out);
2221
2222 let html = page(&out, "blog/index.html");
2223 let order: Vec<&str> = html
2224 .split("<li>")
2225 .skip(1)
2226 .map(|s| s.split('<').next().unwrap())
2227 .collect();
2228 assert_eq!(
2229 order,
2230 vec!["Evening", "Noon", "Morning"],
2231 "newest first, by the clock:\n{html}"
2232 );
2233}
2234
2235// ---------------------------------------------------------------------------
2236// Extra asset roots
2237// ---------------------------------------------------------------------------
2238
2239/// A site's static files do not always live where its writing does. A repository
2240/// migrating from a generator that published `theme/static/` to `/` should not have to
2241/// move `robots.txt` next to its blog posts to keep the URL.
2242#[test]
2243fn an_asset_root_publishes_to_the_site_root() {
2244 let root = tmpdir("assetroot");
2245 let src = root.join("src");
2246 std::fs::create_dir_all(&src).unwrap();
2247 write_site(&src);
2248 std::fs::create_dir_all(root.join("theme/static/img")).unwrap();
2249 std::fs::write(root.join("theme/static/robots.txt"), "User-agent: *\n").unwrap();
2250 std::fs::write(root.join("theme/static/img/logo.svg"), "<svg/>").unwrap();
2251 std::fs::write(
2252 src.join("orgo.toml"),
2253 "[build]\nassets = [\"../theme/static\"]\n",
2254 )
2255 .unwrap();
2256 let out = root.join("out");
2257 let report = build(&src, &out);
2258
2259 assert!(out.join("robots.txt").exists(), "flattened onto the root");
2260 assert!(
2261 out.join("img/logo.svg").exists(),
2262 "and keeps its own structure below that"
2263 );
2264 assert!(
2265 report.assets.contains(&Utf8PathBuf::from("robots.txt")),
2266 "the report counts it: {:?}",
2267 report.assets
2268 );
2269}
2270
2271/// Two files claiming one URL is a coin flip decided by directory order. A build that
2272/// stops is better than a favicon that changes when something elsewhere is renamed.
2273#[test]
2274fn two_assets_claiming_one_url_is_an_error() {
2275 let root = tmpdir("assetclash");
2276 let src = root.join("src");
2277 std::fs::create_dir_all(&src).unwrap();
2278 write_site(&src);
2279 std::fs::write(src.join("style.css"), "body{}").unwrap();
2280 std::fs::create_dir_all(root.join("static")).unwrap();
2281 std::fs::write(root.join("static/style.css"), "body{color:red}").unwrap();
2282 std::fs::write(
2283 src.join("orgo.toml"),
2284 "[build]\nassets = [\"../static\"]\n",
2285 )
2286 .unwrap();
2287
2288 let err = build_site(&src, &root.join("out"), &BuildOptions::default())
2289 .expect_err("a collision must fail the build");
2290 assert!(
2291 format!("{err:#}").contains("style.css"),
2292 "names the path: {err:#}"
2293 );
2294}
2295
2296/// A typo in a path is a typo, not an empty directory to shrug at.
2297#[test]
2298fn a_missing_asset_root_is_an_error() {
2299 let root = tmpdir("assetmissing");
2300 let src = root.join("src");
2301 std::fs::create_dir_all(&src).unwrap();
2302 write_site(&src);
2303 std::fs::write(
2304 src.join("orgo.toml"),
2305 "[build]\nassets = [\"../nope\"]\n",
2306 )
2307 .unwrap();
2308
2309 let err = build_site(&src, &root.join("out"), &BuildOptions::default())
2310 .expect_err("a missing asset root must fail");
2311 assert!(format!("{err:#}").contains("nope"), "names it: {err:#}");
2312}
2313
2314/// A feed that carries excerpts where it used to carry whole posts is a downgrade its
2315/// subscribers notice. `include_content` gives the template each entry's rendered HTML.
2316#[test]
2317fn a_collection_can_carry_its_entries_rendered_bodies() {
2318 let root = tmpdir("feedcontent");
2319 let src = root.join("src");
2320 std::fs::create_dir_all(&src).unwrap();
2321 write_blog(&src, "");
2322 std::fs::write(
2323 src.join("blog/new.org"),
2324 "#+TITLE: Newer Post\n#+DATE: [2025-06-30 Mon 09:15:00]\n\nBody with *emphasis*.\n",
2325 )
2326 .unwrap();
2327 std::fs::write(
2328 src.join("templates/feed.xml"),
2329 "<rss>{% for p in pages %}<item><body>{{ p.content }}</body></item>{% endfor %}</rss>",
2330 )
2331 .unwrap();
2332 std::fs::write(
2333 src.join("orgo.toml"),
2334 "[[collections]]\nsource = \"blog\"\noutput = \"feed.xml\"\n\
2335 template = \"feed.xml\"\ntitle = \"Feed\"\ninclude_content = true\n",
2336 )
2337 .unwrap();
2338 let out = root.join("out");
2339 build(&src, &out);
2340
2341 let feed = page(&out, "feed.xml");
2342 assert!(
2343 feed.contains("<strong>emphasis</strong>"),
2344 "the rendered body reaches the template, escaped as XML text:\n{feed}"
2345 );
2346
2347 // And it stays current: a body edit must reach a feed that embeds bodies, even when
2348 // no metadata moved.
2349 std::fs::write(
2350 src.join("blog/new.org"),
2351 "#+TITLE: Newer Post\n#+DATE: [2025-06-30 Mon 09:15:00]\n\nBody with *emphasis*.\n\nA second paragraph.\n",
2352 )
2353 .unwrap();
2354 build(&src, &out);
2355 assert!(
2356 page(&out, "feed.xml").contains("A second paragraph."),
2357 "the feed followed the edit:\n{}",
2358 page(&out, "feed.xml")
2359 );
2360}
2361
2362/// Bodies cost a render each, so a listing that does not ask for them must not pay — and
2363/// must not carry them into the template either.
2364#[test]
2365fn entries_carry_no_content_unless_asked() {
2366 let root = tmpdir("nocontent");
2367 let src = root.join("src");
2368 std::fs::create_dir_all(&src).unwrap();
2369 write_blog(&src, "");
2370 std::fs::write(
2371 src.join("templates/list.html"),
2372 "<html><body>{% for p in pages %}<li>{{ p.content is none }}</li>{% endfor %}</body></html>",
2373 )
2374 .unwrap();
2375 let out = root.join("out");
2376 build(&src, &out);
2377
2378 let html = page(&out, "blog/index.html");
2379 assert!(!html.contains("false"), "no entry carries a body:\n{html}");
2380}
2381
2382// ---------------------------------------------------------------------------
2383// Sitemap
2384// ---------------------------------------------------------------------------
2385
2386fn sitemap_site(src: &Utf8PathBuf, config: &str) {
2387 std::fs::create_dir_all(src.join("blog")).unwrap();
2388 std::fs::create_dir_all(src.join("templates")).unwrap();
2389 std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nWelcome.\n").unwrap();
2390 std::fs::write(
2391 src.join("blog/post.org"),
2392 "#+TITLE: Post\n#+DATE: <2026-01-15 Thu>\n\nBody.\n",
2393 )
2394 .unwrap();
2395 std::fs::write(src.join("blog/undated.org"), "#+TITLE: Undated\n\nBody.\n").unwrap();
2396 std::fs::write(src.join("style.css"), "body{}").unwrap();
2397 std::fs::write(
2398 src.join("templates/list.html"),
2399 "<html><body>{% for p in pages %}<li>{{ p.title }}</li>{% endfor %}</body></html>",
2400 )
2401 .unwrap();
2402 std::fs::write(src.join("orgo.toml"), config).unwrap();
2403}
2404
2405/// A sitemap covers every page the build emits, generated ones included — a crawler has no
2406/// other way to learn that `/blog/` exists.
2407#[test]
2408fn a_sitemap_lists_every_page_including_generated_ones() {
2409 let root = tmpdir("sitemap");
2410 let src = root.join("src");
2411 std::fs::create_dir_all(&src).unwrap();
2412 sitemap_site(
2413 &src,
2414 "[site]\nbase_url = \"https://example.com\"\n\n\
2415 [[collections]]\nsource = \"blog\"\noutput = \"blog/index.html\"\n\
2416 template = \"list.html\"\ntitle = \"Blog\"\n",
2417 );
2418 let out = root.join("out");
2419 build(&src, &out);
2420
2421 let xml = page(&out, "sitemap.xml");
2422 for url in [
2423 "https://example.com/index.html",
2424 "https://example.com/blog/post.html",
2425 "https://example.com/blog/index.html",
2426 ] {
2427 assert!(xml.contains(url), "{url} is in the sitemap:\n{xml}");
2428 }
2429 // A date the author wrote is the only honest `lastmod` available; a page without one
2430 // gets no element rather than a filesystem timestamp a fresh clone would reset.
2431 assert!(xml.contains("<lastmod>2026-01-15</lastmod>"), "{xml}");
2432 assert_eq!(xml.matches("<lastmod>").count(), 1, "only the dated page:\n{xml}");
2433 // Assets and the stylesheet are not pages.
2434 assert!(!xml.contains("style.css") && !xml.contains("syntax.css"), "{xml}");
2435}
2436
2437/// A sitemap has nowhere to put a relative URL, so without a base URL there is nothing
2438/// honest to write — and a build with no `base_url` set is the zero-config default.
2439#[test]
2440fn no_base_url_means_no_sitemap() {
2441 let root = tmpdir("sitemapnobase");
2442 let src = root.join("src");
2443 std::fs::create_dir_all(&src).unwrap();
2444 sitemap_site(&src, "");
2445 let out = root.join("out");
2446 build(&src, &out);
2447
2448 assert!(!out.join("sitemap.xml").exists(), "no base_url, no sitemap");
2449}
2450
2451/// And it can be turned off outright.
2452#[test]
2453fn the_sitemap_can_be_disabled() {
2454 let root = tmpdir("sitemapoff");
2455 let src = root.join("src");
2456 std::fs::create_dir_all(&src).unwrap();
2457 sitemap_site(
2458 &src,
2459 "[site]\nbase_url = \"https://example.com\"\n\n[build]\nsitemap = false\n",
2460 );
2461 let out = root.join("out");
2462 build(&src, &out);
2463
2464 assert!(!out.join("sitemap.xml").exists(), "disabled means absent");
2465}
2466
2467/// `.well-known` is the one dot-directory the web defines (RFC 8615): `security.txt`,
2468/// ACME challenges, and other files whose whole purpose is to be served. Excluding it
2469/// with the rest is how a deploy silently deletes a site's security contact — which is
2470/// exactly what happened the first time this ran against a real server.
2471#[test]
2472fn well_known_is_published_but_other_dot_entries_are_not() {
2473 let root = tmpdir("wellknown");
2474 let src = root.join("src");
2475 std::fs::create_dir_all(src.join(".well-known")).unwrap();
2476 std::fs::create_dir_all(src.join(".git")).unwrap();
2477 std::fs::create_dir_all(root.join("static/.well-known")).unwrap();
2478 write_site(&src);
2479 std::fs::write(src.join(".well-known/security.txt"), "Contact: mailto:a@b.c\n").unwrap();
2480 std::fs::write(src.join(".git/config"), "[core]\n").unwrap();
2481 std::fs::write(src.join(".env"), "SECRET=1\n").unwrap();
2482 std::fs::write(root.join("static/.well-known/assetlinks.json"), "[]\n").unwrap();
2483 std::fs::write(
2484 src.join("orgo.toml"),
2485 "[build]\nassets = [\"../static\"]\n",
2486 )
2487 .unwrap();
2488 let out = root.join("out");
2489 build(&src, &out);
2490
2491 assert!(
2492 out.join(".well-known/security.txt").exists(),
2493 "from the source directory"
2494 );
2495 assert!(
2496 out.join(".well-known/assetlinks.json").exists(),
2497 "and from an asset root"
2498 );
2499 assert!(!out.join(".git/config").exists(), ".git stays out");
2500 assert!(!out.join(".env").exists(), ".env stays out");
2501}