| @@ -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. |
| 489 | fn nav_selection<'a>( |
489 | fn 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. |
| |
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 | /// 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. |