krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

v0.22.0: tests/config.rs · raw

   1//! Configuration, templating and discovery — the surface that decides whether this is a
   2//! generator for one site or for anyone's.
   3//!
   4//! The theme running through these tests is that **the zero-config path has to work**.
   5//! A directory of `.org` files with no `orgo.toml`, no templates and no knowledge of
   6//! this tool must build into a real site; configuration is how you change the output,
   7//! never how you make it work at all.
   8
   9use std::sync::atomic::{AtomicU32, Ordering};
  10
  11use camino::Utf8PathBuf;
  12
  13use orgo::config::{Config, NavMode};
  14use orgo::site::{build_site, BuildOptions};
  15
  16fn tmpdir(tag: &str) -> Utf8PathBuf {
  17    static N: AtomicU32 = AtomicU32::new(0);
  18    let n = N.fetch_add(1, Ordering::Relaxed);
  19    let base = Utf8PathBuf::from_path_buf(std::env::temp_dir())
  20        .expect("utf-8 temp dir")
  21        .join(format!("orgo-cfg-{}-{tag}-{n}", std::process::id()));
  22    let _ = std::fs::remove_dir_all(&base);
  23    std::fs::create_dir_all(&base).unwrap();
  24    base
  25}
  26
  27/// A site with a root page, a second root page, and one nested page.
  28fn write_site(src: &Utf8PathBuf) {
  29    std::fs::create_dir_all(src.join("blog")).unwrap();
  30    std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nWelcome.\n").unwrap();
  31    std::fs::write(src.join("about.org"), "#+TITLE: About\n\nAbout.\n").unwrap();
  32    std::fs::write(
  33        src.join("blog/post.org"),
  34        "#+TITLE: A Post\n#+DATE: 2024-05-01\n#+FILETAGS: :rust:web:\n\nBody.\n",
  35    )
  36    .unwrap();
  37}
  38
  39fn build(src: &Utf8PathBuf, out: &Utf8PathBuf) -> orgo::site::SiteReport {
  40    build_site(src, out, &BuildOptions::default()).expect("build")
  41}
  42
  43fn page(out: &Utf8PathBuf, rel: &str) -> String {
  44    std::fs::read_to_string(out.join(rel)).unwrap_or_else(|e| panic!("reading {rel}: {e}"))
  45}
  46
  47// ---------------------------------------------------------------------------
  48// Zero config
  49// ---------------------------------------------------------------------------
  50
  51/// The headline promise: point it at a directory of org files and get a site.
  52#[test]
  53fn a_bare_directory_of_org_files_builds_with_no_config() {
  54    let root = tmpdir("bare");
  55    let src = root.join("src");
  56    std::fs::create_dir_all(&src).unwrap();
  57    write_site(&src);
  58    let out = root.join("out");
  59
  60    let report = build(&src, &out);
  61    assert_eq!(report.pages.len(), 3);
  62
  63    let home = page(&out, "index.html");
  64    assert!(home.contains("<!DOCTYPE html>"), "a full page, not a fragment");
  65    assert!(home.contains("Welcome."), "the content is there");
  66    assert!(
  67        out.join("syntax.css").exists(),
  68        "the stylesheet the highlighter needs is emitted too"
  69    );
  70}
  71
  72/// A missing config is normal. A *malformed* one is not: someone who wrote a config
  73/// meant it, and quietly building the default site would hide their typo behind
  74/// plausible-looking output.
  75#[test]
  76fn a_malformed_config_is_an_error_but_a_missing_one_is_not() {
  77    let root = tmpdir("malformed");
  78    let src = root.join("src");
  79    std::fs::create_dir_all(&src).unwrap();
  80    write_site(&src);
  81
  82    assert_eq!(Config::load(&src).unwrap(), Config::default());
  83
  84    std::fs::write(src.join("orgo.toml"), "[site\ntitle = broken").unwrap();
  85    let err = Config::load(&src).expect_err("malformed config must fail");
  86    assert!(format!("{err:#}").contains("orgo.toml"), "names the file: {err:#}");
  87}
  88
  89/// A misspelled key is a silent no-op in most config formats, which is exactly how
  90/// someone spends an afternoon wondering why a setting does nothing.
  91#[test]
  92fn an_unknown_config_key_is_rejected() {
  93    let root = tmpdir("unknownkey");
  94    let src = root.join("src");
  95    std::fs::create_dir_all(&src).unwrap();
  96    std::fs::write(src.join("orgo.toml"), "[site]\ntittle = \"typo\"\n").unwrap();
  97
  98    let err = Config::load(&src).expect_err("unknown key must fail");
  99    assert!(
 100        format!("{err:#}").contains("tittle"),
 101        "the error names the offending key: {err:#}"
 102    );
 103}
 104
 105// ---------------------------------------------------------------------------
 106// Nav modes
 107// ---------------------------------------------------------------------------
 108
 109fn nav_of(html: &str) -> String {
 110    html.split("<nav>")
 111        .nth(1)
 112        .and_then(|s| s.split("</nav>").next())
 113        .unwrap_or("")
 114        .to_string()
 115}
 116
 117#[test]
 118fn nav_modes_select_different_pages() {
 119    for (mode, expect_post, expect_about) in [
 120        ("top-level", false, true),
 121        ("all", true, true),
 122        ("none", false, false),
 123    ] {
 124        let root = tmpdir(&format!("nav-{mode}"));
 125        let src = root.join("src");
 126        std::fs::create_dir_all(&src).unwrap();
 127        write_site(&src);
 128        std::fs::write(
 129            src.join("orgo.toml"),
 130            format!("[nav]\nmode = \"{mode}\"\n"),
 131        )
 132        .unwrap();
 133        let out = root.join("out");
 134        build(&src, &out);
 135
 136        let nav = nav_of(&page(&out, "index.html"));
 137        assert_eq!(
 138            nav.contains("A Post"),
 139            expect_post,
 140            "mode {mode} nested page presence, nav was:\n{nav}"
 141        );
 142        assert_eq!(
 143            nav.contains("About"),
 144            expect_about,
 145            "mode {mode} root page presence, nav was:\n{nav}"
 146        );
 147    }
 148}
 149
 150/// An explicit nav is a designed sequence, so configured order beats discovery order.
 151#[test]
 152fn explicit_nav_uses_the_configured_order() {
 153    let root = tmpdir("navexplicit");
 154    let src = root.join("src");
 155    std::fs::create_dir_all(&src).unwrap();
 156    write_site(&src);
 157    std::fs::write(
 158        src.join("orgo.toml"),
 159        "[nav]\nmode = \"explicit\"\npages = [\"blog/post.org\", \"index.org\"]\n",
 160    )
 161    .unwrap();
 162    let out = root.join("out");
 163    build(&src, &out);
 164
 165    let nav = nav_of(&page(&out, "index.html"));
 166    let post = nav.find("A Post").expect("post in nav");
 167    let home = nav.find("Home").expect("home in nav");
 168    assert!(post < home, "configured order wins:\n{nav}");
 169    assert!(!nav.contains("About"), "unlisted pages stay out:\n{nav}");
 170}
 171
 172/// A nav entry naming a page that does not exist is a typo, and a silently shorter nav
 173/// is a poor way to find out.
 174#[test]
 175fn explicit_nav_rejects_a_page_that_does_not_exist() {
 176    let root = tmpdir("navmissing");
 177    let src = root.join("src");
 178    std::fs::create_dir_all(&src).unwrap();
 179    write_site(&src);
 180    std::fs::write(
 181        src.join("orgo.toml"),
 182        "[nav]\nmode = \"explicit\"\npages = [\"nope.org\"]\n",
 183    )
 184    .unwrap();
 185
 186    let err = build_site(&src, &root.join("out"), &BuildOptions::default())
 187        .expect_err("missing nav page must fail");
 188    assert!(format!("{err:#}").contains("nope.org"), "names it: {err:#}");
 189}
 190
 191/// `mode` and `pages` disagreeing means one of them is being ignored.
 192#[test]
 193fn contradictory_nav_settings_are_rejected() {
 194    let mut config = Config::default();
 195    config.nav.pages = vec![Utf8PathBuf::from("index.org")];
 196    assert!(config.validate().is_err(), "pages without explicit mode");
 197
 198    let mut config = Config::default();
 199    config.nav.mode = NavMode::Explicit;
 200    assert!(config.validate().is_err(), "explicit mode without pages");
 201}
 202
 203// ---------------------------------------------------------------------------
 204// Templates
 205// ---------------------------------------------------------------------------
 206
 207/// The single biggest blocker to general use: without this every site built with this
 208/// tool looks identical.
 209#[test]
 210fn a_user_template_replaces_the_built_in_layout() {
 211    let root = tmpdir("template");
 212    let src = root.join("src");
 213    std::fs::create_dir_all(src.join("templates")).unwrap();
 214    write_site(&src);
 215    std::fs::write(
 216        src.join("templates/base.html"),
 217        "<html><body class=\"mine\"><h1>{{ page.title }}</h1>{{ body | safe }}</body></html>",
 218    )
 219    .unwrap();
 220    let out = root.join("out");
 221    build(&src, &out);
 222
 223    let home = page(&out, "index.html");
 224    assert!(home.contains("class=\"mine\""), "the user layout is used:\n{home}");
 225    assert!(!home.contains("<nav>"), "nothing of the default layout leaks in");
 226    assert!(home.contains("Welcome."), "content still renders");
 227}
 228
 229/// Templates are a hashing input (spec §4.1). If editing a layout did not invalidate,
 230/// a design change would leave a site half-updated — the worst kind of caching bug,
 231/// because it looks like it worked.
 232#[test]
 233fn editing_a_template_re_renders_every_page_that_uses_it() {
 234    let root = tmpdir("templatehash");
 235    let src = root.join("src");
 236    std::fs::create_dir_all(src.join("templates")).unwrap();
 237    write_site(&src);
 238    let tpl = src.join("templates/base.html");
 239    std::fs::write(&tpl, "<html><body>v1{{ body | safe }}</body></html>").unwrap();
 240    let out = root.join("out");
 241
 242    build(&src, &out);
 243    std::fs::write(&tpl, "<html><body>v2{{ body | safe }}</body></html>").unwrap();
 244    let report = build(&src, &out);
 245
 246    assert_eq!(report.rendered.len(), 3, "a layout edit re-renders every page");
 247    assert!(page(&out, "index.html").contains("v2"), "and the change lands");
 248}
 249
 250/// A template that does not compile means someone is actively editing their layout.
 251/// Falling back to the built-in would look like their edit silently did nothing.
 252#[test]
 253fn a_broken_template_fails_the_build() {
 254    let root = tmpdir("badtemplate");
 255    let src = root.join("src");
 256    std::fs::create_dir_all(src.join("templates")).unwrap();
 257    write_site(&src);
 258    std::fs::write(src.join("templates/base.html"), "{% if %}unclosed").unwrap();
 259
 260    let err = build_site(&src, &root.join("out"), &BuildOptions::default())
 261        .expect_err("a broken template must fail the build");
 262    assert!(
 263        format!("{err:#}").contains("base"),
 264        "the error names the template: {err:#}"
 265    );
 266}
 267
 268/// Templates get page metadata, including arbitrary `#+KEYWORD:`s this crate has never
 269/// heard of — otherwise every new bit of metadata would need a release.
 270#[test]
 271fn templates_receive_page_metadata_including_unknown_keywords() {
 272    let root = tmpdir("meta");
 273    let src = root.join("src");
 274    std::fs::create_dir_all(src.join("templates")).unwrap();
 275    write_site(&src);
 276    std::fs::write(
 277        src.join("blog/post.org"),
 278        "#+TITLE: A Post\n#+DATE: 2024-05-01\n#+FILETAGS: :rust:web:\n#+CUSTOM_THING: hello\n\nBody.\n",
 279    )
 280    .unwrap();
 281    std::fs::write(
 282        src.join("templates/base.html"),
 283        "<html><body>date={{ page.date }} tags={{ page.tags | join(\",\") }} \
 284         custom={{ page.keywords.custom_thing }} url={{ page.url }} \
 285         site={{ site.title }}{{ body | safe }}</body></html>",
 286    )
 287    .unwrap();
 288    let out = root.join("out");
 289    build(&src, &out);
 290
 291    let post = page(&out, "blog/post.html");
 292    assert!(post.contains("date=2024-05-01"), "#+DATE: reaches the template:\n{post}");
 293    assert!(post.contains("tags=rust,web"), "#+FILETAGS: is split:\n{post}");
 294    assert!(post.contains("custom=hello"), "unknown keywords pass through:\n{post}");
 295    assert!(post.contains("url=blog/post.html"), "the page URL is available:\n{post}");
 296}
 297
 298/// Off by default, because it trades incremental precision for the ability to write
 299/// listing pages — and that trade should be a choice.
 300#[test]
 301fn the_page_list_is_opt_in_and_widens_invalidation() {
 302    let root = tmpdir("pagelist");
 303    let src = root.join("src");
 304    std::fs::create_dir_all(src.join("templates")).unwrap();
 305    write_site(&src);
 306    std::fs::write(
 307        src.join("orgo.toml"),
 308        "[templates]\nexpose_page_list = true\n",
 309    )
 310    .unwrap();
 311    std::fs::write(
 312        src.join("templates/base.html"),
 313        "<html><body><ul>{% for p in pages %}<li>{{ p.title }}</li>{% endfor %}</ul>\
 314         {{ body | safe }}</body></html>",
 315    )
 316    .unwrap();
 317    let out = root.join("out");
 318    build(&src, &out);
 319
 320    let home = page(&out, "index.html");
 321    for title in ["Home", "About", "A Post"] {
 322        assert!(home.contains(title), "an index can list {title}:\n{home}");
 323    }
 324
 325    // With every page visible to every template, adding one must re-render them all —
 326    // the opposite of the default, and the documented cost of turning this on.
 327    std::fs::write(src.join("blog/second.org"), "#+TITLE: Second\n\nBody.\n").unwrap();
 328    let report = build(&src, &out);
 329    assert_eq!(
 330        report.rendered.len(),
 331        4,
 332        "with the page list exposed, adding a page re-renders the site"
 333    );
 334}
 335
 336// ---------------------------------------------------------------------------
 337// Output settings
 338// ---------------------------------------------------------------------------
 339
 340/// The default layout renders the page title as `<h1>`, so section headings belong
 341/// beneath it — which is also what Emacs does by default.
 342#[test]
 343fn heading_offset_shifts_content_headings_below_the_page_title() {
 344    let root = tmpdir("hoffset");
 345    let src = root.join("src");
 346    std::fs::create_dir_all(&src).unwrap();
 347    std::fs::write(src.join("index.org"), "#+TITLE: T\n\n* Section\n\nBody.\n").unwrap();
 348    let out = root.join("out");
 349    build(&src, &out);
 350    assert!(
 351        page(&out, "index.html").contains("<h2 id=\"section\">"),
 352        "a level-1 org heading renders as <h2> by default"
 353    );
 354
 355    std::fs::write(src.join("orgo.toml"), "[html]\nheading_offset = 0\n").unwrap();
 356    let out2 = root.join("out2");
 357    build(&src, &out2);
 358    assert!(
 359        page(&out2, "index.html").contains("<h1 id=\"section\">"),
 360        "offset 0 leaves headings where the document put them"
 361    );
 362}
 363
 364/// An unknown theme silently produces an empty stylesheet, which looks exactly like
 365/// highlighting being broken. Naming the valid options turns a mystery into a typo.
 366#[test]
 367fn an_unknown_highlight_theme_is_rejected_with_the_available_ones() {
 368    let root = tmpdir("theme");
 369    let src = root.join("src");
 370    std::fs::create_dir_all(&src).unwrap();
 371    write_site(&src);
 372    std::fs::write(src.join("orgo.toml"), "[highlight]\ntheme = \"nope\"\n").unwrap();
 373
 374    let err = build_site(&src, &root.join("out"), &BuildOptions::default())
 375        .expect_err("unknown theme must fail");
 376    let message = format!("{err:#}");
 377    assert!(message.contains("nope"), "names the bad theme: {message}");
 378    assert!(
 379        message.contains("InspiredGitHub"),
 380        "lists what is available: {message}"
 381    );
 382}
 383
 384/// 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 `&#x2f;` 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("&#x2f;"), "no escaped slashes:\n{listing}");
 879    assert!(
 880        listing.contains("&lt;script&gt;"),
 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("&lt;strong&gt;emphasis&lt;/strong&gt;"),
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}