diff --git a/crates/zensical/src/structure/nav.rs b/crates/zensical/src/structure/nav.rs index 5daf94c..1da0462 100644 --- a/crates/zensical/src/structure/nav.rs +++ b/crates/zensical/src/structure/nav.rs @@ -360,8 +360,8 @@ pub fn source_sort_key(source: &SourcePath) -> (Vec, bool, String) { } /// Returns whether the given file name is an index file. -fn is_index(component: &str) -> bool { - component == "index.md" || component == "README.md" +fn is_index(path: &str) -> bool { + matches!(path.rsplit('/').next(), Some("index.md" | "README.md")) } /// Hash the navigation structure that can affect page templates. diff --git a/crates/zensical/src/structure/nav/plan.rs b/crates/zensical/src/structure/nav/plan.rs index 0189769..e6a095a 100644 --- a/crates/zensical/src/structure/nav/plan.rs +++ b/crates/zensical/src/structure/nav/plan.rs @@ -134,7 +134,10 @@ mod tests { PlanItem::reference(None, "index.md"), PlanItem::section( "Guide", - vec![PlanItem::reference(Some("Start".into()), "guide.md")], + vec![ + PlanItem::reference(None, "guide/index.md"), + PlanItem::reference(Some("Start".into()), "guide.md"), + ], ), PlanItem::reference(Some("Website".into()), "https://example.com"), ]) @@ -143,8 +146,9 @@ mod tests { assert_eq!(navigation.items[0].url.as_deref(), Some("index.md")); 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_eq!( - navigation.items[1].children[0].title.as_deref(), + navigation.items[1].children[1].title.as_deref(), Some("Start") ); assert_eq!( diff --git a/crates/zensical/src/structure/page.rs b/crates/zensical/src/structure/page.rs index 9e37394..62d966a 100644 --- a/crates/zensical/src/structure/page.rs +++ b/crates/zensical/src/structure/page.rs @@ -26,6 +26,7 @@ //! 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}; @@ -69,13 +70,11 @@ 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, Serialize)] +#[derive(Clone, Debug)] 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, @@ -85,8 +84,9 @@ pub struct PageData { pub edit_url: Option, /// Page file system path. pub path: String, + /// Effective page title, including an explicit navigation title. + pub title: String, /// Rendered Markdown shared with the upstream value. - #[serde(flatten)] markdown: Markdown, } @@ -191,6 +191,7 @@ impl Page { .to_str() .expect("configured output path is valid UTF-8") .into(), + title: markdown.title.clone(), markdown, }), ancestors: Vec::new(), @@ -286,6 +287,13 @@ impl Page { } self.template_variables = Some(variables); } + + /// Applies the title assigned to this page by navigation. + pub(crate) fn apply_navigation_title(&mut self, title: &str) { + if title != self.title { + title.clone_into(&mut Arc::make_mut(&mut self.data).title); + } + } } // ---------------------------------------------------------------------------- @@ -296,6 +304,26 @@ 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 @@ -410,6 +438,7 @@ mod tests { canonical_url: None, edit_url: None, path: String::from("site/index.html"), + title: String::from("Home"), markdown, }), ancestors: Vec::new(), diff --git a/crates/zensical/src/workflow.rs b/crates/zensical/src/workflow.rs index 3fb49c0..049f691 100644 --- a/crates/zensical/src/workflow.rs +++ b/crates/zensical/src/workflow.rs @@ -27,6 +27,7 @@ use regex::Regex; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::fs; use std::hash::{DefaultHasher, Hash, Hasher}; use std::ops::Deref; @@ -173,6 +174,34 @@ 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 { @@ -256,26 +285,11 @@ impl Main { let plugins = plugin::Settings::new(&self.config, self.serve); let rendered = process_markdown(&self.config, &plugins, &markdown); - // Construct pages, apply any module-owned settlement, then retain - // page-local products as relations. Only genuinely site-wide facts - // cross a settlement boundary. + // 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 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(), - }); - let search_document = - rendered_page.filter_map(|rendered: &RenderedPage| { - (!rendered.html.search.is_empty()).then(|| { - search::Document::new( - &rendered.page, - rendered.html.search.clone(), - ) - }) - }); let awesome_nav = awesome_nav::AwesomeNav::new(&self.config, self.strict).expect( "awesome-nav configuration is validated during loading", @@ -293,6 +307,25 @@ impl Main { }, ) }; + // 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_tags(&plugins.tags, &rendered_page); + let site_page = rendered_page.map(|rendered: &RenderedPage| SitePage { + page: rendered.page.clone(), + autorefs: rendered.html.autorefs.clone(), + }); + let search_document = + rendered_page.filter_map(|rendered: &RenderedPage| { + (!rendered.html.search.is_empty()).then(|| { + search::Document::new( + &rendered.page, + rendered.html.search.clone(), + ) + }) + }); let autorefs_input = rendered_page.map(|rendered: &RenderedPage| autorefs::PageInput { source: rendered.page.source().clone(), @@ -324,6 +357,22 @@ impl Main { // Functions // ---------------------------------------------------------------------------- +/// Applies explicit navigation titles to their pages. +fn apply_navigation_titles( + pages: &Stream, nav: &Signal, +) -> Stream { + let titles = nav.map(PageTitles::new); + pages.product(&titles).map( + |rendered: &RenderedPage, titles: &PageTitles| { + let mut rendered = rendered.clone(); + if let Some(title) = titles.get(&rendered.page.url) { + rendered.page.apply_navigation_title(title); + } + rendered + }, + ) +} + /// Create a stream to collect references from all Markdown files. fn collect_references( config: &Config, files: &Stream, @@ -669,9 +718,45 @@ pub fn create_workflow( #[cfg(test)] mod tests { + use std::collections::BTreeMap; + use std::sync::Arc; + use zrx::id::Id; - use super::template_output; + 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); + } #[test] fn template_outputs_use_logical_provider_identity() { diff --git a/python/tests/integration/test_awesome_nav.py b/python/tests/integration/test_awesome_nav.py index 9e4b27d..00a5d5f 100644 --- a/python/tests/integration/test_awesome_nav.py +++ b/python/tests/integration/test_awesome_nav.py @@ -52,7 +52,7 @@ def _write_template(root: Path) -> None: {% macro render(items, depth) %} {% for item in items %} + url="{{ item.url or '' }}" index="{{ item.is_index }}" /> {{ render(item.children, depth + 1) }} {% endfor %} {% endmacro %} @@ -81,6 +81,17 @@ def _items_or_none(root: Path) -> list[tuple[int, str, str]] | None: return None +def _index_flags(root: Path) -> list[bool]: + """Extract whether each normalized navigation item is an index page.""" + output_path = root / "site" / "index.html" + if not output_path.exists(): + output_path = next((root / "site").rglob("*.html")) + soup = BeautifulSoup(output_path.read_text(), "html.parser") + return [ + str(item["index"]).lower() == "true" for item in soup.find_all("item") + ] + + def _write_config(root: Path, plugin: str = "awesome-nav") -> Path: config = root / "mkdocs.yml" config.write_text( @@ -218,6 +229,59 @@ def test_default_navigation_prefers_index_over_readme(tmp_path: Path) -> None: ] +def test_nested_index_is_classified_for_theme_section_merging( + tmp_path: Path, +) -> None: + """Nested index paths retain the index marker used by Material's theme.""" + docs = tmp_path / "docs" + section = docs / "tech-stack" + section.mkdir(parents=True) + _write_template(tmp_path) + (section / "index.md").write_text( + "# Tech-Stack Home Page Title\n", encoding="utf-8" + ) + (section / "page.md").write_text("# Page\n", encoding="utf-8") + (section / ".nav.yaml").write_text( + "title: Tech-Stack\nnav: ['*']\n", encoding="utf-8" + ) + + zensical.build( + str(_write_config(tmp_path, "awesome-nav:\n filename: .nav.yaml")), + _BUILD_OPTIONS, + ) + + assert _items(tmp_path) == [ + (0, "Tech-Stack", ""), + (1, "Tech-Stack Home Page Title", "tech-stack/"), + (1, "Page", "tech-stack/page/"), + ] + assert _index_flags(tmp_path) == [False, True, False] + + +def test_explicit_page_title_precedes_metadata_and_heading( + tmp_path: Path, +) -> None: + """Awesome Nav titles become MkDocs-compatible page titles.""" + 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 / ".nav.yml").write_text( + "nav:\n - Configured title: index.md\n", encoding="utf-8" + ) + + zensical.build(str(_write_config(tmp_path)), _BUILD_OPTIONS) + + assert (tmp_path / "site" / "index.html").read_text() == "Configured title" + + def test_pattern_options_hide_directories_flatten_and_sort_by_metadata( tmp_path: Path, ) -> None: diff --git a/python/tests/integration/test_config.py b/python/tests/integration/test_config.py index b675277..0102566 100644 --- a/python/tests/integration/test_config.py +++ b/python/tests/integration/test_config.py @@ -129,6 +129,28 @@ def test_symlinked_config_anchors_relative_paths_to_its_target( assert not (alias_dir / "site").exists() +def test_navigation_title_precedes_metadata_and_heading(tmp_path: Path) -> None: + """Configured titles have the same highest precedence as in MkDocs.""" + config = _make_yml_project( + tmp_path, + yml_extra=( + " custom_dir: overrides\n" + "nav:\n" + " - Configured title: index.md" + ), + ) + (tmp_path / "docs" / "index.md").write_text( + "---\ntitle: Metadata title\n---\n\n# Heading title\n", + encoding="utf-8", + ) + 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() == "Configured title" + + # --------------------------------------------------------------------------- # Theme loading: both zensical.toml and mkdocs.yml # --------------------------------------------------------------------------- diff --git a/python/tests/integration/test_search.py b/python/tests/integration/test_search.py index 8febfae..7c162db 100644 --- a/python/tests/integration/test_search.py +++ b/python/tests/integration/test_search.py @@ -112,7 +112,7 @@ def test_search_artifacts_match_mkdocs_contract(tmp_path: Path) -> None: "level": 1, "title": "Landing", "text": "

Intro with fine print.

", - "path": ["Landing"], + "path": ["Home"], "tags": ["alpha", "beta"], }, { @@ -120,15 +120,15 @@ def test_search_artifacts_match_mkdocs_contract(tmp_path: Path) -> None: "level": 2, "title": "Overview", "text": "

Overview body.

", - "path": ["Landing"], + "path": ["Home"], "tags": ["alpha", "beta"], }, { "location": "guide/topic.html", "level": 1, - "title": "Metadata title", + "title": "Topic", "text": "

Preface before a heading.

", - "path": ["Guides", "Metadata title"], + "path": ["Guides", "Topic"], "tags": ["guide"], }, { @@ -136,7 +136,7 @@ def test_search_artifacts_match_mkdocs_contract(tmp_path: Path) -> None: "level": 2, "title": "Details", "text": "

Detailed body.

", - "path": ["Guides", "Metadata title"], + "path": ["Guides", "Topic"], "tags": ["guide"], }, ], @@ -234,7 +234,7 @@ def test_search_rebuild_replaces_changed_and_removed_pages( "level": 1, "title": "Changed", "text": "

Fresh body.

", - "path": ["Changed"], + "path": ["Home"], "tags": [], } ]