krz/orgo

Lightning fast org-mode static site generator.

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

c3d46452b5ce6edc9550c07a5314a1147939f190

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T07:27:21Z

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.
 docs/guide/02-configuration.org |  14 ++++
 src/site.rs                     | 138 ++++++++++++++++++++++++++++------------
 tests/config.rs                 |  37 +++++++++++
 3 files changed, 148 insertions(+), 41 deletions(-)

diff --git a/docs/guide/02-configuration.org b/docs/guide/02-configuration.org
index c354143..94430db 100644
--- a/docs/guide/02-configuration.org
+++ b/docs/guide/02-configuration.org
@@ -103,6 +103,20 @@ If your sections live in subdirectories, none of them are top-level pages. Put t
 section's *generated* index in the nav instead, with =nav = true= on its collection —
 that is the page a nav entry should point at anyway.
 
+A generated page has no source file, so name it in =pages= by its *output* path:
+
+#+BEGIN_SRC toml
+[nav]
+mode = "explicit"
+pages = ["blog/index.html", "garden/index.html", "about.org"]
+#+END_SRC
+
+That is the only way to interleave the two: a collection that sets =nav = true= without
+being listed is appended after everything you did list, so ="about.org"= alone would put
+=About= first and the sections after it. Listing all of them puts each exactly where you
+said. Either spelling works for an authored page too — its source path or its output
+path — though the source path is the one that survives a =#+SLUG:=.
+
 * [templates]
 
 | Key | Default | Meaning |
diff --git a/src/site.rs b/src/site.rs
index f6d7e3a..4272a89 100644
--- a/src/site.rs
+++ b/src/site.rs
@@ -486,25 +486,87 @@ fn listing_context(listing: &Listing) -> PageContext {
 }
 
 /// Which pages the configured [`NavMode`] selects, in nav order.
-fn nav_selection<'a>(
-    config: &Config,
-    pages: &'a [(Utf8PathBuf, Utf8PathBuf, String)],
-) -> Vec<&'a (Utf8PathBuf, Utf8PathBuf, String)> {
+fn nav_selection<'a>(config: &Config, candidates: &'a [NavCandidate]) -> Vec<&'a NavCandidate> {
     match config.nav.mode {
         NavMode::None => Vec::new(),
-        NavMode::All => pages.iter().collect(),
-        NavMode::TopLevel => pages.iter().filter(|(_, out, _)| is_top_level(out)).collect(),
-        // Configured order wins over discovery order — a hand-written nav is a designed
-        // sequence, not an alphabetical one.
-        NavMode::Explicit => config
-            .nav
-            .pages
+        NavMode::All => candidates.iter().collect(),
+        // Generated pages are a section's landing page, which is what a nav entry should
+        // point at whatever depth the section lives at.
+        NavMode::TopLevel => candidates
             .iter()
-            .filter_map(|want| pages.iter().find(|(source, _, _)| source == want))
+            .filter(|c| c.generated || is_top_level(&c.output))
             .collect(),
+        // Configured order wins over discovery order — a hand-written nav is a designed
+        // sequence, not an alphabetical one.
+        NavMode::Explicit => {
+            let mut chosen: Vec<&NavCandidate> = config
+                .nav
+                .pages
+                .iter()
+                .filter_map(|want| candidates.iter().find(|c| c.matches(want)))
+                .collect();
+            // A collection that asked for the nav but was not listed is appended rather
+            // than dropped, so `nav = true` never silently does nothing. Listing it puts
+            // it exactly where you said instead.
+            for candidate in candidates.iter().filter(|c| c.generated) {
+                if !chosen.iter().any(|c| c.output == candidate.output) {
+                    chosen.push(candidate);
+                }
+            }
+            chosen
+        }
     }
 }
 
+/// A page the navigation could contain: one written as `.org`, or one generated by a
+/// collection.
+struct NavCandidate {
+    /// The source path of an authored page. Empty for a generated one, which has none.
+    source: Utf8PathBuf,
+    output: Utf8PathBuf,
+    title: String,
+    /// Generated pages are appended when an explicit nav does not name them.
+    generated: bool,
+}
+
+impl NavCandidate {
+    /// Does `name` in `nav.pages` refer to this entry?
+    ///
+    /// Authored pages are named by their source — `about.org` — because that is the file
+    /// you wrote and its output path may be moved by `#+SLUG:`. Generated pages have no
+    /// source, so they are named by their output — `blog/index.html`. Either spelling is
+    /// accepted for either, so a config that names an output path still works.
+    fn matches(&self, name: &Utf8Path) -> bool {
+        (!self.source.as_str().is_empty() && self.source == name) || self.output == name
+    }
+}
+
+/// Every page the navigation could contain, authored pages first.
+fn nav_candidates(
+    config: &Config,
+    pages: &[(Utf8PathBuf, Utf8PathBuf, String)],
+) -> Vec<NavCandidate> {
+    let mut candidates: Vec<NavCandidate> = pages
+        .iter()
+        .map(|(source, output, title)| NavCandidate {
+            source: source.clone(),
+            output: output.clone(),
+            title: title.clone(),
+            generated: false,
+        })
+        .collect();
+    for collection in config.collections.iter().filter(|c| c.nav) {
+        let (output, title) = nav_target(collection);
+        candidates.push(NavCandidate {
+            source: Utf8PathBuf::new(),
+            output,
+            title,
+            generated: true,
+        });
+    }
+    candidates
+}
+
 /// DISCOVER + PARSE + INDEX + RESOLVE the whole site, returning per-page prep and the
 /// global symbol table. RENDER/TEMPLATE is deferred to the caller so the incremental
 /// build can render only the pages it must. PARSE/INDEX/RESOLVE are cheap and pure, so
@@ -571,26 +633,26 @@ fn prepare_pages(
         }
     }
 
-    // An explicit nav naming a page that does not exist is a typo, and a silently
+    // Authored pages and the landing pages collections generate, in one list so an
+    // explicit nav can order them together.
+    let candidates = nav_candidates(config, &all_pages);
+
+    // An explicit nav naming something that does not exist is a typo, and a silently
     // shorter nav is a poor way to learn about it.
     if config.nav.mode == NavMode::Explicit {
         for want in &config.nav.pages {
-            if !all_pages.iter().any(|(source, _, _)| source == want) {
-                anyhow::bail!("nav.pages lists {want}, which is not a page in {src}");
+            if !candidates.iter().any(|c| c.matches(want)) {
+                anyhow::bail!(
+                    "nav.pages lists {want}, which is neither a page in {src} nor a \
+                     collection with `nav = true`"
+                );
             }
         }
     }
-    let mut entries: Vec<(Utf8PathBuf, String)> = nav_selection(config, &all_pages)
+    let entries: Vec<(Utf8PathBuf, String)> = nav_selection(config, &candidates)
         .into_iter()
-        .map(|(_, out, title)| (out.clone(), title.clone()))
+        .map(|c| (c.output.clone(), c.title.clone()))
         .collect();
-    // A listing page is exactly what a section's nav entry should point at — `/blog/`
-    // rather than any one post — so collections can opt into the nav directly. For a
-    // grouped collection that means its *index*: a nav listing every tag is the same
-    // mistake as a nav listing every page.
-    for (output, title) in config.collections.iter().filter(|c| c.nav).map(nav_target) {
-        entries.push((output, title));
-    }
 
     // RESOLVE reads the shared symbol table and writes only into its own page's output,
     // 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
         .iter()
         .map(|p| (p.source.clone(), p.output.clone(), p.title.clone()))
         .collect();
-    let structure: Vec<(String, String)> = if cfg.templates.expose_page_list {
-        all_pages
+    let structure_hash = if cfg.templates.expose_page_list {
+        let entries: Vec<(String, String)> = all_pages
             .iter()
             .map(|(_, out, title)| (out.to_string(), title.clone()))
-            .collect()
+            .collect();
+        site_structure_hash(&entries)
     } else {
-        // The same selection the nav itself is built from, so the two can never drift —
-        // including the listing pages that opted into the nav, whose titles appear on
-        // every page just as a source page's would.
-        nav_selection(&cfg, &all_pages)
+        // The same selection the nav itself is built from, so the two can never drift.
+        // Hashed in order, because the nav's order is itself part of every page.
+        let entries: Vec<(String, String)> = nav_selection(&cfg, &nav_candidates(&cfg, &all_pages))
             .into_iter()
-            .map(|(_, out, title)| (out.to_string(), title.clone()))
-            .chain(
-                cfg.collections
-                    .iter()
-                    .filter(|c| c.nav)
-                    .map(nav_target)
-                    .map(|(out, title)| (out.to_string(), title)),
-            )
-            .collect()
+            .map(|c| (c.output.to_string(), c.title.clone()))
+            .collect();
+        site_structure_hash_ordered(&entries)
     };
-    let cfg_hash = combine(config_hash(&cfg), site_structure_hash(&structure));
+    let cfg_hash = combine(config_hash(&cfg), structure_hash);
     let tmpl_hash = template_hash(templater.sources());
 
     // Compose each page's render key and record its dependency edges.
diff --git a/tests/config.rs b/tests/config.rs
index 65f7b9a..8e120be 100644
--- a/tests/config.rs
+++ b/tests/config.rs
@@ -595,6 +595,43 @@ fn a_collection_can_join_the_nav() {
     );
 }
 
+/// `mode = "none"` means none. A collection asking for a nav that was turned off does
+/// not get to be the only thing in it.
+#[test]
+fn nav_mode_none_drops_a_collection_that_asked_for_the_nav() {
+    let root = tmpdir("navnonelist");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_blog(&src, "nav = true\n\n[nav]\nmode = \"none\"\n");
+    let out = root.join("out");
+    build(&src, &out);
+
+    let nav = nav_of(&page(&out, "index.html"));
+    assert!(!nav.contains("Blog"), "nav is empty:\n{nav}");
+}
+
+/// A generated page can be positioned like any other: an explicit nav names it by its
+/// output path, and it lands exactly there rather than being appended after the pages
+/// that have source files.
+#[test]
+fn an_explicit_nav_can_order_a_generated_page_before_an_authored_one() {
+    let root = tmpdir("navgenorder");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_blog(
+        &src,
+        "nav = true\n\n[nav]\nmode = \"explicit\"\n\
+         pages = [\"blog/index.html\", \"index.org\"]\n",
+    );
+    let out = root.join("out");
+    build(&src, &out);
+
+    let nav = nav_of(&page(&out, "index.html"));
+    let blog = nav.find("Blog").expect("the listing page is in the nav");
+    let home = nav.find("Home").expect("the authored page is in the nav");
+    assert!(blog < home, "configured order wins:\n{nav}");
+}
+
 /// A listing page depends on every page it lists — and on nothing else. Adding a post
 /// must re-render the index without re-rendering the rest of the site.
 #[test]