krz/orgo

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

Commit c3d46452b5

c3d46452b5ce6edc9550c07a5314a1147939f190

parent: 57bf33c265

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-11 07:27 UTC

Let an explicit nav order generated pages among authored ones

`nav.pages` could only name source files, so pages generated by a collection
were always appended after everything listed — a site whose nav should read
Blog, Garden, Salary could not have it, because Salary has a source file and
the other two do not.

Nav selection now runs over one list of candidates holding both kinds. A
generated page is named by its output path (`blog/index.html`), an authored one
by its source (`about.org`, which survives a `#+SLUG:` moving its URL), and
either spelling resolves to either kind. Collections with `nav = true` that go
unlisted are still appended, so turning the flag on never silently does
nothing.

Two consequences worth naming:

- `mode = "none"` now really means none. The append loop used to run outside
  the mode match, so a `nav = true` collection was the sole entry in a nav that
  was supposed to be off.
- The site structure hash over the nav is now order-sensitive. Reordering the
  nav changes every page, and a sorted hash would have called that no change.

Layout: unified · split

docs/guide/02-configuration.org +14
@@ -103,6 +103,20 @@ If your sections live in subdirectories, none of them are top-level pages. Put t
103section's *generated* index in the nav instead, with =nav = true= on its collection — 103section's *generated* index in the nav instead, with =nav = true= on its collection —
104that is the page a nav entry should point at anyway. 104that is the page a nav entry should point at anyway.
105 105
106A generated page has no source file, so name it in =pages= by its *output* path:
107
108#+BEGIN_SRC toml
109[nav]
110mode = "explicit"
111pages = ["blog/index.html", "garden/index.html", "about.org"]
112#+END_SRC
113
114That is the only way to interleave the two: a collection that sets =nav = true= without
115being listed is appended after everything you did list, so ="about.org"= alone would put
116=About= first and the sections after it. Listing all of them puts each exactly where you
117said. Either spelling works for an authored page too — its source path or its output
118path — though the source path is the one that survives a =#+SLUG:=.
119
106* [templates] 120* [templates]
107 121
108| Key | Default | Meaning | 122| Key | Default | Meaning |
src/site.rs +97 −41
@@ -486,25 +486,87 @@ fn listing_context(listing: &Listing) -> PageContext {
486} 486}
487 487
488/// Which pages the configured [`NavMode`] selects, in nav order. 488/// Which pages the configured [`NavMode`] selects, in nav order.
489fn nav_selection<'a>( 489fn nav_selection<'a>(config: &Config, candidates: &'a [NavCandidate]) -> Vec<&'a NavCandidate> {
490 config: &Config,
491 pages: &'a [(Utf8PathBuf, Utf8PathBuf, String)],
492) -> Vec<&'a (Utf8PathBuf, Utf8PathBuf, String)> {
493 match config.nav.mode { 490 match config.nav.mode {
494 NavMode::None => Vec::new(), 491 NavMode::None => Vec::new(),
495 NavMode::All => pages.iter().collect(), 492 NavMode::All => candidates.iter().collect(),
496 NavMode::TopLevel => pages.iter().filter(|(_, out, _)| is_top_level(out)).collect(), 493 // Generated pages are a section's landing page, which is what a nav entry should
497 // Configured order wins over discovery order — a hand-written nav is a designed 494 // point at whatever depth the section lives at.
498 // sequence, not an alphabetical one. 495 NavMode::TopLevel => candidates
499 NavMode::Explicit => config
500 .nav
501 .pages
502 .iter() 496 .iter()
503 .filter_map(|want| pages.iter().find(|(source, _, _)| source == want)) 497 .filter(|c| c.generated || is_top_level(&c.output))
504 .collect(), 498 .collect(),
499 // Configured order wins over discovery order — a hand-written nav is a designed
500 // sequence, not an alphabetical one.
501 NavMode::Explicit => {
502 let mut chosen: Vec<&NavCandidate> = config
503 .nav
504 .pages
505 .iter()
506 .filter_map(|want| candidates.iter().find(|c| c.matches(want)))
507 .collect();
508 // A collection that asked for the nav but was not listed is appended rather
509 // than dropped, so `nav = true` never silently does nothing. Listing it puts
510 // it exactly where you said instead.
511 for candidate in candidates.iter().filter(|c| c.generated) {
512 if !chosen.iter().any(|c| c.output == candidate.output) {
513 chosen.push(candidate);
514 }
515 }
516 chosen
517 }
505 } 518 }
506} 519}
507 520
521/// A page the navigation could contain: one written as `.org`, or one generated by a
522/// collection.
523struct NavCandidate {
524 /// The source path of an authored page. Empty for a generated one, which has none.
525 source: Utf8PathBuf,
526 output: Utf8PathBuf,
527 title: String,
528 /// Generated pages are appended when an explicit nav does not name them.
529 generated: bool,
530}
531
532impl NavCandidate {
533 /// Does `name` in `nav.pages` refer to this entry?
534 ///
535 /// Authored pages are named by their source — `about.org` — because that is the file
536 /// you wrote and its output path may be moved by `#+SLUG:`. Generated pages have no
537 /// source, so they are named by their output — `blog/index.html`. Either spelling is
538 /// accepted for either, so a config that names an output path still works.
539 fn matches(&self, name: &Utf8Path) -> bool {
540 (!self.source.as_str().is_empty() && self.source == name) || self.output == name
541 }
542}
543
544/// Every page the navigation could contain, authored pages first.
545fn nav_candidates(
546 config: &Config,
547 pages: &[(Utf8PathBuf, Utf8PathBuf, String)],
548) -> Vec<NavCandidate> {
549 let mut candidates: Vec<NavCandidate> = pages
550 .iter()
551 .map(|(source, output, title)| NavCandidate {
552 source: source.clone(),
553 output: output.clone(),
554 title: title.clone(),
555 generated: false,
556 })
557 .collect();
558 for collection in config.collections.iter().filter(|c| c.nav) {
559 let (output, title) = nav_target(collection);
560 candidates.push(NavCandidate {
561 source: Utf8PathBuf::new(),
562 output,
563 title,
564 generated: true,
565 });
566 }
567 candidates
568}
569
508/// DISCOVER + PARSE + INDEX + RESOLVE the whole site, returning per-page prep and the 570/// DISCOVER + PARSE + INDEX + RESOLVE the whole site, returning per-page prep and the
509/// global symbol table. RENDER/TEMPLATE is deferred to the caller so the incremental 571/// global symbol table. RENDER/TEMPLATE is deferred to the caller so the incremental
510/// build can render only the pages it must. PARSE/INDEX/RESOLVE are cheap and pure, so 572/// build can render only the pages it must. PARSE/INDEX/RESOLVE are cheap and pure, so
@@ -571,26 +633,26 @@ fn prepare_pages(
571 } 633 }
572 } 634 }
573 635
574 // An explicit nav naming a page that does not exist is a typo, and a silently 636 // Authored pages and the landing pages collections generate, in one list so an
637 // explicit nav can order them together.
638 let candidates = nav_candidates(config, &all_pages);
639
640 // An explicit nav naming something that does not exist is a typo, and a silently
575 // shorter nav is a poor way to learn about it. 641 // shorter nav is a poor way to learn about it.
576 if config.nav.mode == NavMode::Explicit { 642 if config.nav.mode == NavMode::Explicit {
577 for want in &config.nav.pages { 643 for want in &config.nav.pages {
578 if !all_pages.iter().any(|(source, _, _)| source == want) { 644 if !candidates.iter().any(|c| c.matches(want)) {
579 anyhow::bail!("nav.pages lists {want}, which is not a page in {src}"); 645 anyhow::bail!(
646 "nav.pages lists {want}, which is neither a page in {src} nor a \
647 collection with `nav = true`"
648 );
580 } 649 }
581 } 650 }
582 } 651 }
583 let mut entries: Vec<(Utf8PathBuf, String)> = nav_selection(config, &all_pages) 652 let entries: Vec<(Utf8PathBuf, String)> = nav_selection(config, &candidates)
584 .into_iter() 653 .into_iter()
585 .map(|(_, out, title)| (out.clone(), title.clone())) 654 .map(|c| (c.output.clone(), c.title.clone()))
586 .collect(); 655 .collect();
587 // A listing page is exactly what a section's nav entry should point at — `/blog/`
588 // rather than any one post — so collections can opt into the nav directly. For a
589 // grouped collection that means its *index*: a nav listing every tag is the same
590 // mistake as a nav listing every page.
591 for (output, title) in config.collections.iter().filter(|c| c.nav).map(nav_target) {
592 entries.push((output, title));
593 }
594 656
595 // RESOLVE reads the shared symbol table and writes only into its own page's output, 657 // RESOLVE reads the shared symbol table and writes only into its own page's output,
596 // so it parallelizes for free once INDEX has finished building the table. 658 // so it parallelizes for free once INDEX has finished building the table.
@@ -756,28 +818,22 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
756 .iter() 818 .iter()
757 .map(|p| (p.source.clone(), p.output.clone(), p.title.clone())) 819 .map(|p| (p.source.clone(), p.output.clone(), p.title.clone()))
758 .collect(); 820 .collect();
759 let structure: Vec<(String, String)> = if cfg.templates.expose_page_list { 821 let structure_hash = if cfg.templates.expose_page_list {
760 all_pages 822 let entries: Vec<(String, String)> = all_pages
761 .iter() 823 .iter()
762 .map(|(_, out, title)| (out.to_string(), title.clone())) 824 .map(|(_, out, title)| (out.to_string(), title.clone()))
763 .collect() 825 .collect();
826 site_structure_hash(&entries)
764 } else { 827 } else {
765 // The same selection the nav itself is built from, so the two can never drift — 828 // The same selection the nav itself is built from, so the two can never drift.
766 // including the listing pages that opted into the nav, whose titles appear on 829 // Hashed in order, because the nav's order is itself part of every page.
767 // every page just as a source page's would. 830 let entries: Vec<(String, String)> = nav_selection(&cfg, &nav_candidates(&cfg, &all_pages))
768 nav_selection(&cfg, &all_pages)
769 .into_iter() 831 .into_iter()
770 .map(|(_, out, title)| (out.to_string(), title.clone())) 832 .map(|c| (c.output.to_string(), c.title.clone()))
771 .chain( 833 .collect();
772 cfg.collections 834 site_structure_hash_ordered(&entries)
773 .iter()
774 .filter(|c| c.nav)
775 .map(nav_target)
776 .map(|(out, title)| (out.to_string(), title)),
777 )
778 .collect()
779 }; 835 };
780 let cfg_hash = combine(config_hash(&cfg), site_structure_hash(&structure)); 836 let cfg_hash = combine(config_hash(&cfg), structure_hash);
781 let tmpl_hash = template_hash(templater.sources()); 837 let tmpl_hash = template_hash(templater.sources());
782 838
783 // Compose each page's render key and record its dependency edges. 839 // Compose each page's render key and record its dependency edges.
tests/config.rs +37
@@ -595,6 +595,43 @@ fn a_collection_can_join_the_nav() {
595 ); 595 );
596} 596}
597 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
598/// A listing page depends on every page it lists — and on nothing else. Adding a post 635/// A listing page depends on every page it lists — and on nothing else. Adding a post
599/// must re-render the index without re-rendering the rest of the site. 636/// must re-render the index without re-rendering the rest of the site.
600#[test] 637#[test]