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