diff --git a/crates/zensical/src/compat/mkdocs/plugin/awesome_nav.rs b/crates/zensical/src/compat/mkdocs/plugin/awesome_nav.rs index 52cb5a4..76a1dea 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/awesome_nav.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/awesome_nav.rs @@ -37,7 +37,7 @@ use zrx::stream::{Key, Signal, Stream, Value}; use crate::config::plugins::AwesomeNavLogs; use crate::config::Config; use crate::path::SourcePath; -use crate::structure::nav::Navigation; +use crate::structure::nav::NavigationResolution; use crate::structure::page::Page; use crate::watcher::Source; @@ -148,7 +148,7 @@ impl AwesomeNav { /// Installs control-file discovery, settlement and navigation compilation. pub fn setup( &self, dependencies: Dependencies<'_>, - ) -> Signal { + ) -> Signal { let settings = self.settings.clone(); let documents = dependencies.sources.filter_map({ let settings = settings.clone(); @@ -198,9 +198,11 @@ impl AwesomeNav { Ok::<_, anyhow::Error>(navigation) }, ); - navigation.reduce(|navigation: &dyn Collection, Navigation>| { - navigation.values().next().cloned() - }) + navigation.reduce( + |navigation: &dyn Collection, NavigationResolution>| { + navigation.values().next().cloned() + }, + ) } } diff --git a/crates/zensical/src/compat/mkdocs/plugin/awesome_nav/resolver.rs b/crates/zensical/src/compat/mkdocs/plugin/awesome_nav/resolver.rs index 29fb37e..1adc7af 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/awesome_nav/resolver.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/awesome_nav/resolver.rs @@ -30,7 +30,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use crate::structure::dynamic::Dynamic; use crate::structure::nav::{ - source_sort_key, to_title, Navigation, Plan, PlanItem, + source_sort_key, to_title, NavigationResolution, Plan, PlanItem, }; use crate::structure::page::Page; @@ -233,7 +233,7 @@ impl<'a> Resolver<'a> { } } - fn resolve(mut self) -> Result<(Navigation, Vec)> { + fn resolve(mut self) -> Result<(NavigationResolution, Vec)> { let config = self.directory_config(".")?.clone(); if !self.settings.configured.is_empty() { self.diagnostic( @@ -813,7 +813,7 @@ fn components(path: &str) -> usize { /// Resolves native awesome-nav configuration and returns diagnostics. pub fn resolve( settings: &Settings, documents: &BTreeMap, pages: &[Page], -) -> Result<(Navigation, Vec)> { +) -> Result<(NavigationResolution, Vec)> { Resolver::new(settings, documents, pages) .resolve() .context("failed to resolve awesome navigation") diff --git a/crates/zensical/src/compat/mkdocs/plugin/literate_nav.rs b/crates/zensical/src/compat/mkdocs/plugin/literate_nav.rs index f64a5a2..b7ff89d 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/literate_nav.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/literate_nav.rs @@ -36,7 +36,7 @@ use zrx::stream::{Key, Signal, Stream, Value}; use crate::config::Config; use crate::path::SourcePath; -use crate::structure::nav::{Navigation, NavigationItem}; +use crate::structure::nav::{Navigation, NavigationItem, NavigationResolution}; use crate::structure::page::Page; use crate::watcher::Source; @@ -127,7 +127,7 @@ impl LiterateNav { /// Installs navigation discovery, settlement, and compilation. pub fn setup( &self, dependencies: Dependencies<'_>, - ) -> Signal { + ) -> Signal { let settings = self.settings.clone(); let documents = dependencies.sources.filter_map({ let settings = settings.clone(); @@ -177,16 +177,18 @@ impl LiterateNav { if settings.enabled { resolver::resolve(&settings, &docs.0, pages.0.as_ref()) } else { - Ok(Navigation::new( + Ok(Navigation::resolve( settings.configured.clone(), pages.0.as_ref().clone(), )) } }, ); - navigation.reduce(|navigation: &dyn Collection, Navigation>| { - navigation.values().next().cloned() - }) + navigation.reduce( + |navigation: &dyn Collection, NavigationResolution>| { + navigation.values().next().cloned() + }, + ) } } diff --git a/crates/zensical/src/compat/mkdocs/plugin/literate_nav/resolver.rs b/crates/zensical/src/compat/mkdocs/plugin/literate_nav/resolver.rs index d4007fa..05c132f 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/literate_nav/resolver.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/literate_nav/resolver.rs @@ -32,7 +32,8 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use crate::path::SourcePath; use crate::structure::markdown::render_literate_nav; use crate::structure::nav::{ - source_sort_key, to_title, Navigation, NavigationItem, Plan, PlanItem, + source_sort_key, to_title, NavigationItem, NavigationResolution, Plan, + PlanItem, }; use crate::structure::page::Page; @@ -465,7 +466,7 @@ impl<'a> Resolver<'a> { /// Resolves native literate navigation and attaches rendered page facts. pub fn resolve( settings: &Settings, documents: &BTreeMap, pages: &[Page], -) -> Result { +) -> Result { let plan = Resolver::new(settings, documents, pages).resolve()?; Ok(plan.compile(pages.to_vec())) } diff --git a/crates/zensical/src/structure/markdown.rs b/crates/zensical/src/structure/markdown.rs index 24c5c22..12e53c3 100644 --- a/crates/zensical/src/structure/markdown.rs +++ b/crates/zensical/src/structure/markdown.rs @@ -64,8 +64,6 @@ pub struct MarkdownData { pub meta: BTreeMap, /// Markdown content. pub content: String, - /// Page title extracted from Markdown. - pub title: String, /// Table of contents. pub toc: Vec
, } @@ -78,8 +76,6 @@ struct RenderedMarkdown { meta: BTreeMap, /// Markdown content. content: String, - /// Page title extracted from Markdown. - title: String, /// Table of contents. toc: Vec
, } @@ -93,7 +89,7 @@ impl Markdown { #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))] pub fn new( id: &Id, url: String, content: String, meta: BTreeMap, - ) -> Result { + ) -> Result<(Markdown, String)> { let id = id.clone(); let meta = serde_json::to_string(&meta)?; let res = Python::attach(|py| { @@ -105,14 +101,13 @@ impl Markdown { .map_err(python_error); res.map(|data| { - let mut data = MarkdownData { + let data = MarkdownData { meta: data.meta, content: data.content, - title: data.title, toc: data.toc, }; - data.title = extract_title(&id, &data); - Markdown { data: Arc::new(data) } + let title = extract_title(&id, &data); + (Markdown { data: Arc::new(data) }, title) }) } @@ -239,7 +234,6 @@ mod tests { data: Arc::new(MarkdownData { meta: BTreeMap::new(), content: String::from("

Home

"), - title: String::from("Home"), toc: Vec::new(), }), } @@ -258,12 +252,11 @@ mod tests { let value = serde_json::to_value(markdown()).unwrap(); assert_eq!(value["content"], "

Home

"); - assert_eq!(value["title"], "Home"); + assert!(value.get("title").is_none()); assert!(value.get("data").is_none()); assert!(value.get("search").is_none()); let markdown: Markdown = serde_json::from_value(value).unwrap(); assert_eq!(markdown.content, "

Home

"); - assert_eq!(markdown.title, "Home"); } } diff --git a/crates/zensical/src/structure/nav.rs b/crates/zensical/src/structure/nav.rs index 1da0462..b94f9c9 100644 --- a/crates/zensical/src/structure/nav.rs +++ b/crates/zensical/src/structure/nav.rs @@ -25,7 +25,7 @@ //! Navigation. -use ahash::HashMap; +use ahash::{HashMap, HashSet}; use pyo3::types::{PyAny, PyAnyMethods}; use pyo3::{Bound, FromPyObject, PyResult}; use serde::Serialize; @@ -72,6 +72,17 @@ pub struct Navigation { pub generation: u64, } +// ---------------------------------------------------------------------------- + +/// Navigation together with page facts established while resolving it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NavigationResolution { + /// Template-facing navigation. + pub navigation: Navigation, + /// Explicit titles from the first navigation occurrence of each page. + title_overrides: Arc>, +} + // ---------------------------------------------------------------------------- // Implementations // ---------------------------------------------------------------------------- @@ -79,14 +90,26 @@ pub struct Navigation { impl Navigation { /// Creates a navigation from the given items. pub fn new(items: Vec, pages: Vec) -> Self { + Self::resolve(items, pages).navigation + } + + /// Resolves navigation and retains page facts needed by the workflow. + pub fn resolve( + items: Vec, pages: Vec, + ) -> NavigationResolution { if items.is_empty() { - return Self::from(pages); + return NavigationResolution { + navigation: Self::from(pages), + title_overrides: Arc::default(), + }; } Self::from_plan(items, pages) } /// Creates navigation from an explicit plan, including an empty one. - fn from_plan(mut items: Vec, pages: Vec) -> Self { + fn from_plan( + mut items: Vec, pages: Vec, + ) -> NavigationResolution { // Create a map of pages for easy lookup, so we can resolve titles and // icons from the file location of the respective page. let pages = pages @@ -94,76 +117,50 @@ impl Navigation { .map(|page| (page.source().to_string(), page)) .collect::>(); - // Since a navigation structure is given, we just need to add titles and - // icons where necessary and defined in page metadata - let mut stack = vec![&mut items]; - while let Some(children) = stack.pop() { - for item in children.iter_mut() { - // Here, we differ from MkDocs, in that navigation items can or - // cannot have URLs, since we model sections and pages with the - // same data type. This is definitely not the final design that - // we want, and we'll switch to a much more flexible approach - // once we work on modular navigation. The component system - // will also make things much easier here. - if let Some(url) = &item.url { - // Try to obtain a page for the given url. Users might also - // refer to non-existing pages, which we just ignore for now - if let Some(page) = pages.get(url) { - // Set URLs from page - we currently resolve the final - // URL during rendering, so we just need to set it here. - // Once we start working on the component and module - // system, all of this is going to change anyway - item.url = Some(page.url.clone()); - item.canonical_url = page.canonical_url.clone(); + // Since a navigation structure is given, attach page facts and retain + // the explicit title from the first occurrence of each resolved page. + let mut seen = HashSet::default(); + let mut title_overrides = HashMap::default(); + resolve_items(&mut items, &pages, &mut seen, &mut title_overrides); - // Set item title from page if not set - if item.title.is_none() { - item.title = Some(page.title.clone()); - } - - // Extract page metadata for selected keys - item.meta = Some(page.meta.clone()); - } - } - - // Push children onto the stack for further processing - if !item.children.is_empty() { - stack.push(&mut item.children); - } - } - } - - // Determine homepage - here, we mirror MkDocs behavior, which only - // considers index pages at the root level as potential homepages - let mut homepage = items.iter().find(|item| item.is_index).cloned(); - if homepage.is_none() { - // However, if we couldn't find anything, but there's still an index - // page, we check if it's out of navigation, and if so, use it - if let Some(page) = pages.get("index.md") - && !Iter::new(&items) - .any(|item| item.url.as_deref() == Some(&page.url)) - { - homepage = Some(NavigationItem { - title: Some(page.title.clone()), - url: Some(page.url.clone()), - canonical_url: page.canonical_url.clone(), - meta: Some(page.meta.clone()), - children: Vec::new(), - is_index: true, - active: false, - }); - } + // A homepage must be a root index source, not merely an index page at + // the top navigation level. This mirrors MkDocs' URL constraint and + // prevents nested index pages and external links from becoming home. + let home = pages.get("index.md").or_else(|| pages.get("README.md")); + let mut homepage = home.and_then(|page| { + items + .iter() + .find(|item| item.url.as_deref() == Some(&page.url)) + .cloned() + }); + if homepage.is_none() + && let Some(page) = home + && !Iter::new(&items) + .any(|item| item.url.as_deref() == Some(&page.url)) + { + homepage = Some(NavigationItem { + title: Some(page.title.clone()), + url: Some(page.url.clone()), + canonical_url: page.canonical_url.clone(), + meta: Some(page.meta.clone()), + children: Vec::new(), + is_index: true, + active: false, + }); } // Precompute hash let hash = navigation_hash(&items); // Return navigation - Self { - items: Arc::new(items), - homepage, - hash, - generation: 0, + NavigationResolution { + navigation: Self { + items: Arc::new(items), + homepage, + hash, + generation: 0, + }, + title_overrides: Arc::new(title_overrides), } } @@ -254,6 +251,17 @@ impl Value for Navigation {} // ---------------------------------------------------------------------------- +impl NavigationResolution { + /// Returns the explicit title assigned to the page's first occurrence. + pub fn title(&self, source: &SourcePath) -> Option<&str> { + self.title_overrides.get(source).map(String::as_str) + } +} + +impl Value for NavigationResolution {} + +// ---------------------------------------------------------------------------- + impl From> for Navigation { /// Creates a navigation from pages. /// @@ -347,6 +355,42 @@ impl<'a> IntoIterator for &'a Navigation { // Functions // ---------------------------------------------------------------------------- +/// Attaches page facts while preserving first-occurrence title provenance. +fn resolve_items( + items: &mut [NavigationItem], pages: &HashMap, + seen: &mut HashSet, + title_overrides: &mut HashMap, +) { + for item in items { + // Here, we differ from MkDocs, in that navigation items can or cannot + // have URLs, since we model sections and pages with the same data type. + if let Some(url) = &item.url + && let Some(page) = pages.get(url) + { + let source = page.source().clone(); + if seen.insert(source.clone()) + && let Some(title) = &item.title + { + title_overrides.insert(source, title.clone()); + } + + item.url = Some(page.url.clone()); + item.canonical_url = page.canonical_url.clone(); + item.is_index = is_index(page.source().file_name()); + if item.title.is_none() { + item.title = Some(page.title.clone()); + } + item.meta = Some(page.meta.clone()); + } + + // Resolve children in pre-order, matching MkDocs' first-page + // occurrence semantics for duplicate references. + resolve_items(&mut item.children, pages, seen, title_overrides); + } +} + +// ---------------------------------------------------------------------------- + /// Returns the MkDocs navigation sort key for one validated source path. pub fn source_sort_key(source: &SourcePath) -> (Vec, bool, String) { let file = source.file_name().to_owned(); diff --git a/crates/zensical/src/structure/nav/plan.rs b/crates/zensical/src/structure/nav/plan.rs index e6a095a..3363c5d 100644 --- a/crates/zensical/src/structure/nav/plan.rs +++ b/crates/zensical/src/structure/nav/plan.rs @@ -25,7 +25,7 @@ //! Navigation plans before rendered page facts are attached. -use super::{is_index, Navigation, NavigationItem}; +use super::{Navigation, NavigationItem, NavigationResolution}; use crate::structure::page::Page; // ---------------------------------------------------------------------------- @@ -73,7 +73,7 @@ impl Plan { } /// Attaches rendered page facts and creates the final navigation. - pub fn compile(self, pages: Vec) -> Navigation { + pub fn compile(self, pages: Vec) -> NavigationResolution { Navigation::from_plan( self.items.into_iter().map(PlanItem::into_item).collect(), pages, @@ -97,7 +97,7 @@ impl PlanItem { match self { Self::Reference { title, target } => NavigationItem { title, - is_index: is_index(&target), + is_index: false, url: Some(target), canonical_url: None, meta: None, @@ -130,7 +130,7 @@ mod tests { #[test] fn lowers_references_sections_and_links() { - let navigation = Plan::new(vec![ + let resolution = Plan::new(vec![ PlanItem::reference(None, "index.md"), PlanItem::section( "Guide", @@ -139,21 +139,26 @@ mod tests { PlanItem::reference(Some("Start".into()), "guide.md"), ], ), - PlanItem::reference(Some("Website".into()), "https://example.com"), + PlanItem::reference( + Some("Website".into()), + "https://example.com/index.md", + ), ]) .compile(Vec::new()); + let navigation = resolution.navigation; assert_eq!(navigation.items[0].url.as_deref(), Some("index.md")); - assert!(navigation.items[0].is_index); + assert!(!navigation.items[0].is_index); assert_eq!(navigation.items[1].title.as_deref(), Some("Guide")); - assert!(navigation.items[1].children[0].is_index); + assert!(!navigation.items[1].children[0].is_index); assert_eq!( navigation.items[1].children[1].title.as_deref(), Some("Start") ); assert_eq!( navigation.items[2].url.as_deref(), - Some("https://example.com") + Some("https://example.com/index.md") ); + assert!(!navigation.items[2].is_index); } } diff --git a/crates/zensical/src/structure/page.rs b/crates/zensical/src/structure/page.rs index 62d966a..6d24f15 100644 --- a/crates/zensical/src/structure/page.rs +++ b/crates/zensical/src/structure/page.rs @@ -26,7 +26,6 @@ //! Page. use minijinja::{context, Error, Value as TemplateValue}; -use serde::ser::{SerializeStruct, Serializer}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::hash::{Hash, Hasher}; @@ -70,11 +69,13 @@ impl Value for PageRoute {} /// Page values are cloned by the scheduler as they fan out into navigation, /// search, validation, and rendering branches. Keeping the immutable payload /// behind an [`Arc`] makes those clones constant-sized. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize)] pub struct PageData { /// Validated documentation-relative source used by internal consumers. + #[serde(skip)] source: SourcePath, /// Validated site-relative output used by the writer. + #[serde(skip)] destination: SitePath, /// Page target URL. pub url: String, @@ -87,6 +88,7 @@ pub struct PageData { /// Effective page title, including an explicit navigation title. pub title: String, /// Rendered Markdown shared with the upstream value. + #[serde(flatten)] markdown: Markdown, } @@ -146,7 +148,9 @@ impl PageRoute { impl Page { /// Creates a page. #[allow(clippy::similar_names)] - pub fn new(config: &Config, route: PageRoute, markdown: Markdown) -> Page { + pub fn new( + config: &Config, route: PageRoute, markdown: Markdown, title: String, + ) -> Page { let path = config.output_root().join(&route.destination); let source = route.source; let destination = route.destination; @@ -191,7 +195,7 @@ impl Page { .to_str() .expect("configured output path is valid UTF-8") .into(), - title: markdown.title.clone(), + title, markdown, }), ancestors: Vec::new(), @@ -304,26 +308,6 @@ impl Value for Page {} // ---------------------------------------------------------------------------- -impl Serialize for PageData { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - let mut state = serializer.serialize_struct("PageData", 8)?; - state.serialize_field("url", &self.url)?; - state.serialize_field("canonical_url", &self.canonical_url)?; - state.serialize_field("edit_url", &self.edit_url)?; - state.serialize_field("path", &self.path)?; - state.serialize_field("meta", &self.meta)?; - state.serialize_field("content", &self.content)?; - state.serialize_field("title", &self.title)?; - state.serialize_field("toc", &self.toc)?; - state.end() - } -} - -// ---------------------------------------------------------------------------- - impl PartialEq for PageData { fn eq(&self, other: &Self) -> bool { self.url == other.url diff --git a/crates/zensical/src/workflow.rs b/crates/zensical/src/workflow.rs index 049f691..990fa0a 100644 --- a/crates/zensical/src/workflow.rs +++ b/crates/zensical/src/workflow.rs @@ -27,7 +27,6 @@ use regex::Regex; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use std::fs; use std::hash::{DefaultHasher, Hash, Hasher}; use std::ops::Deref; @@ -54,7 +53,7 @@ use crate::config::Config; use crate::path::{PathError, SitePath, SourcePath}; use crate::python::{Anchors, Issues, References, SharedReferences}; use crate::structure::markdown::Markdown; -use crate::structure::nav::Navigation; +use crate::structure::nav::{Navigation, NavigationResolution}; use crate::structure::page::{Page, PageRoute}; use crate::template::Template; use crate::watcher::Source; @@ -174,34 +173,6 @@ impl Value for PageRender {} // ---------------------------------------------------------------------------- -/// Effective navigation title indexed by page URL. -#[derive(Clone, Debug)] -struct PageTitles(Arc>); - -impl Value for PageTitles {} - -impl PageTitles { - /// Indexes the first navigation occurrence of each page, like MkDocs. - fn new(nav: &Navigation) -> Self { - let mut titles = HashMap::new(); - for item in nav { - if item.meta.is_some() - && let (Some(url), Some(title)) = (&item.url, &item.title) - { - titles.entry(url.clone()).or_insert_with(|| title.clone()); - } - } - Self(Arc::new(titles)) - } - - /// Returns the effective title assigned to a page in navigation. - fn get(&self, url: &str) -> Option<&str> { - self.0.get(url).map(String::as_str) - } -} - -// ---------------------------------------------------------------------------- - /// Markdown source paired with route facts available before rendering. #[derive(Clone, Debug, PartialEq, Eq)] struct RoutedMarkdown { @@ -222,6 +193,8 @@ struct RenderedMarkdown { route: PageRoute, /// Rendered Markdown consumed by page construction. markdown: Markdown, + /// Page title derived from metadata, Markdown, or source name. + title: String, /// Page-local registrations consumed during site settlement. registrations: Arc, /// Facts extracted by the shared MkDocs-compatible HTML pass. @@ -287,32 +260,46 @@ impl Main { // Construct pages before resolving navigation, which needs the titles // derived from Markdown for entries without an explicit title. - let rendered_page = generate_page(&self.config, &rendered); - let page = - rendered_page.map(|rendered: &RenderedPage| rendered.page.clone()); + let provisional = generate_page(&self.config, &rendered); + let provisional_page = + provisional.map(|rendered: &RenderedPage| rendered.page.clone()); + // Autorefs only consumes registrations gathered during Markdown + // rendering, so keep it independent of finalized navigation titles. + let autorefs_input = + provisional.map(|rendered: &RenderedPage| autorefs::PageInput { + source: rendered.page.source().clone(), + facts: rendered.registrations.clone(), + }); + let autorefs = plugins + .autorefs + .setup(autorefs::Dependencies { pages: &autorefs_input }); let awesome_nav = awesome_nav::AwesomeNav::new(&self.config, self.strict).expect( "awesome-nav configuration is validated during loading", ); - let nav = if awesome_nav.is_enabled() { + let resolution = if awesome_nav.is_enabled() { awesome_nav.setup(awesome_nav::Dependencies { sources: &sources, - pages: &page, + pages: &provisional_page, }) } else { literate_nav::LiterateNav::new(&self.config).setup( literate_nav::Dependencies { sources: &sources, - pages: &page, + pages: &provisional_page, }, ) }; + let nav = resolution + .map(|value: &NavigationResolution| value.navigation.clone()); // MkDocs assigns configured navigation titles when constructing Page // objects, before metadata and Markdown fallbacks are evaluated. Our // navigation is resolved later, so apply that highest-precedence title // once the complete navigation is available. - let rendered_page = apply_navigation_titles(&rendered_page, &nav); + let rendered_page = apply_navigation_titles(&provisional, &resolution); let rendered_page = apply_tags(&plugins.tags, &rendered_page); + let page = + rendered_page.map(|rendered: &RenderedPage| rendered.page.clone()); let site_page = rendered_page.map(|rendered: &RenderedPage| SitePage { page: rendered.page.clone(), autorefs: rendered.html.autorefs.clone(), @@ -326,14 +313,6 @@ impl Main { ) }) }); - let autorefs_input = - rendered_page.map(|rendered: &RenderedPage| autorefs::PageInput { - source: rendered.page.source().clone(), - facts: rendered.registrations.clone(), - }); - let autorefs = plugins - .autorefs - .setup(autorefs::Dependencies { pages: &autorefs_input }); plugins.search.setup(search::Dependencies { documents: &search_document, navigation: &nav, @@ -359,13 +338,13 @@ impl Main { /// Applies explicit navigation titles to their pages. fn apply_navigation_titles( - pages: &Stream, nav: &Signal, + pages: &Stream, + resolution: &Signal, ) -> Stream { - let titles = nav.map(PageTitles::new); - pages.product(&titles).map( - |rendered: &RenderedPage, titles: &PageTitles| { + pages.product(resolution).map( + |rendered: &RenderedPage, resolution: &NavigationResolution| { let mut rendered = rendered.clone(); - if let Some(title) = titles.get(&rendered.page.url) { + if let Some(title) = resolution.title(rendered.page.source()) { rendered.page.apply_navigation_title(title); } rendered @@ -545,13 +524,14 @@ fn render_markdown( id: &Id, route: PageRoute, content: String, plugins: plugin::Settings, meta: meta::Resolved, ) -> anyhow::Result { - let mut markdown = + let (mut markdown, title) = Markdown::new(id, route.url.clone(), content, meta.values())?; let html = plugin::prepare(&mut markdown, &route.source, &plugins)?; let registrations = plugins.autorefs.take_page(&route.url); Ok(RenderedMarkdown { route, markdown, + title, registrations, html, }) @@ -567,6 +547,7 @@ fn generate_page( &config, markdown.route.clone(), markdown.markdown.clone(), + markdown.title.clone(), ), registrations: markdown.registrations.clone(), html: markdown.html.clone(), @@ -718,45 +699,9 @@ pub fn create_workflow( #[cfg(test)] mod tests { - use std::collections::BTreeMap; - use std::sync::Arc; - use zrx::id::Id; - use crate::structure::nav::{Navigation, NavigationItem}; - - use super::{template_output, PageTitles}; - - fn item(title: &str, url: &str, page: bool) -> NavigationItem { - NavigationItem { - title: Some(title.into()), - url: Some(url.into()), - canonical_url: None, - meta: page.then(BTreeMap::new), - children: Vec::new(), - is_index: false, - active: false, - } - } - - #[test] - fn page_titles_index_first_page_occurrence_and_ignores_links() { - let navigation = Navigation { - items: Arc::new(vec![ - item("First", "page/", true), - item("Second", "page/", true), - item("Link", "link/", false), - ]), - homepage: None, - hash: 0, - generation: 0, - }; - - let titles = PageTitles::new(&navigation); - - assert_eq!(titles.get("page/"), Some("First")); - assert_eq!(titles.get("link/"), None); - } + use super::template_output; #[test] fn template_outputs_use_logical_provider_identity() { diff --git a/python/tests/integration/test_config.py b/python/tests/integration/test_config.py index 0102566..1c5d1f3 100644 --- a/python/tests/integration/test_config.py +++ b/python/tests/integration/test_config.py @@ -134,9 +134,7 @@ def test_navigation_title_precedes_metadata_and_heading(tmp_path: Path) -> None: config = _make_yml_project( tmp_path, yml_extra=( - " custom_dir: overrides\n" - "nav:\n" - " - Configured title: index.md" + " custom_dir: overrides\nnav:\n - Configured title: index.md" ), ) (tmp_path / "docs" / "index.md").write_text( @@ -151,6 +149,56 @@ def test_navigation_title_precedes_metadata_and_heading(tmp_path: Path) -> None: assert (tmp_path / "site" / "index.html").read_text() == "Configured title" +@pytest.mark.parametrize( + ("navigation", "expected"), + [ + (" - index.md\n - Later: index.md", "Hello"), + (" - First: index.md\n - Second: index.md", "First"), + ], +) +def test_first_navigation_occurrence_owns_page_title( + tmp_path: Path, navigation: str, expected: str +) -> None: + """Duplicate pages retain the title assigned by their first occurrence.""" + config = _make_yml_project( + tmp_path, + yml_extra=(f" custom_dir: overrides\nnav:\n{navigation}"), + ) + custom = _make_custom_dir(tmp_path) + (custom / "main.html").write_text("{{ page.title }}", encoding="utf-8") + + _build(config) + + assert (tmp_path / "site" / "index.html").read_text() == expected + + +def test_only_root_index_page_becomes_navigation_homepage( + tmp_path: Path, +) -> None: + """Nested index paths and similarly named links are never the homepage.""" + config = _make_yml_project( + tmp_path, + yml_extra=( + " custom_dir: overrides\n" + "nav:\n" + " - Guide: guide/index.md\n" + " - Website: https://example.com/index.md" + ), + ) + guide = tmp_path / "docs" / "guide" + guide.mkdir() + (guide / "index.md").write_text("# Guide\n", encoding="utf-8") + custom = _make_custom_dir(tmp_path) + (custom / "main.html").write_text( + "{{ nav.homepage.title if nav.homepage else 'none' }}", + encoding="utf-8", + ) + + _build(config) + + assert (tmp_path / "site" / "index.html").read_text() == "Hello" + + # --------------------------------------------------------------------------- # Theme loading: both zensical.toml and mkdocs.yml # --------------------------------------------------------------------------- diff --git a/python/tests/integration/test_literate_nav.py b/python/tests/integration/test_literate_nav.py index fbcd548..ebe55b9 100644 --- a/python/tests/integration/test_literate_nav.py +++ b/python/tests/integration/test_literate_nav.py @@ -140,6 +140,40 @@ plugins: ] +def test_explicit_page_title_precedes_metadata_and_heading( + tmp_path: Path, +) -> None: + """Literate navigation supplies the same page-title precedence as MkDocs.""" + docs = tmp_path / "docs" + docs.mkdir() + overrides = tmp_path / "overrides" + overrides.mkdir() + (overrides / "main.html").write_text("{{ page.title }}", encoding="utf-8") + (docs / "index.md").write_text( + "---\ntitle: Metadata title\n---\n\n# Heading title\n", + encoding="utf-8", + ) + (docs / "SUMMARY.md").write_text( + "* [Configured title](index.md)\n", encoding="utf-8" + ) + config = tmp_path / "mkdocs.yml" + config.write_text( + """\ +site_name: Literate navigation +theme: + name: material + custom_dir: overrides +plugins: + - literate-nav +""", + encoding="utf-8", + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + assert (tmp_path / "site" / "index.html").read_text() == "Configured title" + + def test_resolves_configured_directory_through_nested_literate_nav( tmp_path: Path, ) -> None: diff --git a/python/tests/integration/test_tags.py b/python/tests/integration/test_tags.py index 6acadc1..1c92141 100644 --- a/python/tests/integration/test_tags.py +++ b/python/tests/integration/test_tags.py @@ -145,6 +145,30 @@ def test_builds_listings_references_toc_and_search_without_export( assert not (tmp_path / "site" / "tags.json").exists() +def test_navigation_titles_flow_through_listings_and_search( + tmp_path: Path, +) -> None: + """Title-sensitive plugin outputs consume finalized page titles.""" + config = _write_project(tmp_path) + with config.open("a", encoding="utf-8") as file: + file.write( + "nav:\n" + " - Catalog: index.md\n" + " - Configured Rust: guide/rust.md\n" + " - Python: guide/python.md\n" + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + listing = (tmp_path / "site" / "index.html").read_text() + search = json.loads((tmp_path / "site" / "search.json").read_text()) + assert "Configured Rust" in listing + assert "Rust page" not in listing + assert any( + item["path"][-1] == "Configured Rust" for item in search["items"] + ) + + def test_inherits_tags_from_meta_file(tmp_path: Path) -> None: """Tags supplied by Material meta participate in page tag mappings.""" _write_project(tmp_path)