krz/orgo

Lightning fast org-mode static site generator.

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

v0.19.1: tests/config.rs · raw

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