refactor: separate navigation title resolution

Signed-off-by: squidfunk <martin.donath@squidfunk.com>
This commit is contained in:
squidfunk
2026-09-03 12:24:56 +02:00
parent 05f297e530
commit 6398ebddd8
12 changed files with 299 additions and 217 deletions
@@ -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<Id, Navigation> {
) -> Signal<Id, NavigationResolution> {
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<Key<Id>, Navigation>| {
navigation.values().next().cloned()
})
navigation.reduce(
|navigation: &dyn Collection<Key<Id>, NavigationResolution>| {
navigation.values().next().cloned()
},
)
}
}
@@ -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<Diagnostic>)> {
fn resolve(mut self) -> Result<(NavigationResolution, Vec<Diagnostic>)> {
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<String, String>, pages: &[Page],
) -> Result<(Navigation, Vec<Diagnostic>)> {
) -> Result<(NavigationResolution, Vec<Diagnostic>)> {
Resolver::new(settings, documents, pages)
.resolve()
.context("failed to resolve awesome navigation")
@@ -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<Id, Navigation> {
) -> Signal<Id, NavigationResolution> {
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<Key<Id>, Navigation>| {
navigation.values().next().cloned()
})
navigation.reduce(
|navigation: &dyn Collection<Key<Id>, NavigationResolution>| {
navigation.values().next().cloned()
},
)
}
}
@@ -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<String, String>, pages: &[Page],
) -> Result<Navigation> {
) -> Result<NavigationResolution> {
let plan = Resolver::new(settings, documents, pages).resolve()?;
Ok(plan.compile(pages.to_vec()))
}
+5 -12
View File
@@ -64,8 +64,6 @@ pub struct MarkdownData {
pub meta: BTreeMap<String, Dynamic>,
/// Markdown content.
pub content: String,
/// Page title extracted from Markdown.
pub title: String,
/// Table of contents.
pub toc: Vec<Section>,
}
@@ -78,8 +76,6 @@ struct RenderedMarkdown {
meta: BTreeMap<String, Dynamic>,
/// Markdown content.
content: String,
/// Page title extracted from Markdown.
title: String,
/// Table of contents.
toc: Vec<Section>,
}
@@ -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<String, Dynamic>,
) -> Result<Markdown> {
) -> 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("<h1>Home</h1>"),
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"], "<h1>Home</h1>");
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, "<h1>Home</h1>");
assert_eq!(markdown.title, "Home");
}
}
+110 -66
View File
@@ -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<HashMap<SourcePath, String>>,
}
// ----------------------------------------------------------------------------
// Implementations
// ----------------------------------------------------------------------------
@@ -79,14 +90,26 @@ pub struct Navigation {
impl Navigation {
/// Creates a navigation from the given items.
pub fn new(items: Vec<NavigationItem>, pages: Vec<Page>) -> Self {
Self::resolve(items, pages).navigation
}
/// Resolves navigation and retains page facts needed by the workflow.
pub fn resolve(
items: Vec<NavigationItem>, pages: Vec<Page>,
) -> 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<NavigationItem>, pages: Vec<Page>) -> Self {
fn from_plan(
mut items: Vec<NavigationItem>, pages: Vec<Page>,
) -> 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::<HashMap<_, _>>();
// 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<Vec<Page>> 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<String, Page>,
seen: &mut HashSet<SourcePath>,
title_overrides: &mut HashMap<SourcePath, String>,
) {
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<String>, bool, String) {
let file = source.file_name().to_owned();
+13 -8
View File
@@ -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<Page>) -> Navigation {
pub fn compile(self, pages: Vec<Page>) -> 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);
}
}
+8 -24
View File
@@ -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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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
+33 -88
View File
@@ -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<HashMap<String, String>>);
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<autorefs::Facts>,
/// 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<Id, RenderedPage>, nav: &Signal<Id, Navigation>,
pages: &Stream<Id, RenderedPage>,
resolution: &Signal<Id, NavigationResolution>,
) -> Stream<Id, RenderedPage> {
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<RenderedMarkdown> {
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() {
+51 -3
View File
@@ -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
# ---------------------------------------------------------------------------
+34
View File
@@ -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:
+24
View File
@@ -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)