| @@ -486,25 +486,87 @@ fn listing_context(listing: &Listing) -> PageContext { |
| 486 | 486 | } |
| 487 | 487 | |
| 488 | 488 | /// Which pages the configured [`NavMode`] selects, in nav order. |
| 489 | | fn nav_selection<'a>( |
| 490 | | config: &Config, |
| 491 | | pages: &'a [(Utf8PathBuf, Utf8PathBuf, String)], |
| 492 | | ) -> Vec<&'a (Utf8PathBuf, Utf8PathBuf, String)> { |
| 489 | fn nav_selection<'a>(config: &Config, candidates: &'a [NavCandidate]) -> Vec<&'a NavCandidate> { |
| 493 | 490 | match config.nav.mode { |
| 494 | 491 | NavMode::None => Vec::new(), |
| 495 | | NavMode::All => pages.iter().collect(), |
| 496 | | NavMode::TopLevel => pages.iter().filter(|(_, out, _)| is_top_level(out)).collect(), |
| 497 | | // Configured order wins over discovery order — a hand-written nav is a designed |
| 498 | | // sequence, not an alphabetical one. |
| 499 | | NavMode::Explicit => config |
| 500 | | .nav |
| 501 | | .pages |
| 492 | NavMode::All => candidates.iter().collect(), |
| 493 | // Generated pages are a section's landing page, which is what a nav entry should |
| 494 | // point at whatever depth the section lives at. |
| 495 | NavMode::TopLevel => candidates |
| 502 | 496 | .iter() |
| 503 | | .filter_map(|want| pages.iter().find(|(source, _, _)| source == want)) |
| 497 | .filter(|c| c.generated || is_top_level(&c.output)) |
| 504 | 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. |
| 523 | struct 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 | |
| 532 | impl 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. |
| 545 | fn 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 | 570 | /// DISCOVER + PARSE + INDEX + RESOLVE the whole site, returning per-page prep and the |
| 509 | 571 | /// global symbol table. RENDER/TEMPLATE is deferred to the caller so the incremental |
| 510 | 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 | 641 | // shorter nav is a poor way to learn about it. |
| 576 | 642 | if config.nav.mode == NavMode::Explicit { |
| 577 | 643 | for want in &config.nav.pages { |
| 578 | | if !all_pages.iter().any(|(source, _, _)| source == want) { |
| 579 | | anyhow::bail!("nav.pages lists {want}, which is not a page in {src}"); |
| 644 | if !candidates.iter().any(|c| c.matches(want)) { |
| 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 | 653 | .into_iter() |
| 585 | | .map(|(_, out, title)| (out.clone(), title.clone())) |
| 654 | .map(|c| (c.output.clone(), c.title.clone())) |
| 586 | 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 | 657 | // RESOLVE reads the shared symbol table and writes only into its own page's output, |
| 596 | 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 | 818 | .iter() |
| 757 | 819 | .map(|p| (p.source.clone(), p.output.clone(), p.title.clone())) |
| 758 | 820 | .collect(); |
| 759 | | let structure: Vec<(String, String)> = if cfg.templates.expose_page_list { |
| 760 | | all_pages |
| 821 | let structure_hash = if cfg.templates.expose_page_list { |
| 822 | let entries: Vec<(String, String)> = all_pages |
| 761 | 823 | .iter() |
| 762 | 824 | .map(|(_, out, title)| (out.to_string(), title.clone())) |
| 763 | | .collect() |
| 825 | .collect(); |
| 826 | site_structure_hash(&entries) |
| 764 | 827 | } else { |
| 765 | | // 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 |
| 767 | | // every page just as a source page's would. |
| 768 | | nav_selection(&cfg, &all_pages) |
| 828 | // The same selection the nav itself is built from, so the two can never drift. |
| 829 | // Hashed in order, because the nav's order is itself part of every page. |
| 830 | let entries: Vec<(String, String)> = nav_selection(&cfg, &nav_candidates(&cfg, &all_pages)) |
| 769 | 831 | .into_iter() |
| 770 | | .map(|(_, out, title)| (out.to_string(), title.clone())) |
| 771 | | .chain( |
| 772 | | cfg.collections |
| 773 | | .iter() |
| 774 | | .filter(|c| c.nav) |
| 775 | | .map(nav_target) |
| 776 | | .map(|(out, title)| (out.to_string(), title)), |
| 777 | | ) |
| 778 | | .collect() |
| 832 | .map(|c| (c.output.to_string(), c.title.clone())) |
| 833 | .collect(); |
| 834 | site_structure_hash_ordered(&entries) |
| 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 | 837 | let tmpl_hash = template_hash(templater.sources()); |
| 782 | 838 | |
| 783 | 839 | // Compose each page's render key and record its dependency edges. |