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