From edbaf5516b4cd281979b0da9f2ff2b2c1ef2a5dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Tue, 6 Oct 2026 18:20:27 +0200 Subject: [PATCH] feature: support mkdocs-nav-weight plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Timothée Mazzucotelli --- crates/zensical/src/compat/mkdocs/plugin.rs | 1 + .../src/compat/mkdocs/plugin/autorefs.rs | 4 +- .../compat/mkdocs/plugin/meta/admission.rs | 2 +- .../src/compat/mkdocs/plugin/minify/style.rs | 6 +- .../src/compat/mkdocs/plugin/nav_weight.rs | 332 +++++++++++++ .../src/compat/mkdocs/plugin/search/parser.rs | 2 +- crates/zensical/src/config/plugins.rs | 4 + .../zensical/src/config/plugins/nav_weight.rs | 64 +++ crates/zensical/src/structure/nav.rs | 107 ++++- crates/zensical/src/structure/nav/view.rs | 9 +- crates/zensical/src/workflow.rs | 6 +- python/tests/integration/test_nav_weight.py | 441 ++++++++++++++++++ python/tests/unit/test_plugin_config.py | 58 +++ python/zensical/config.py | 34 ++ 14 files changed, 1043 insertions(+), 27 deletions(-) create mode 100644 crates/zensical/src/compat/mkdocs/plugin/nav_weight.rs create mode 100644 crates/zensical/src/config/plugins/nav_weight.rs create mode 100644 python/tests/integration/test_nav_weight.py diff --git a/crates/zensical/src/compat/mkdocs/plugin.rs b/crates/zensical/src/compat/mkdocs/plugin.rs index 4a1ad6d..f97b375 100644 --- a/crates/zensical/src/compat/mkdocs/plugin.rs +++ b/crates/zensical/src/compat/mkdocs/plugin.rs @@ -45,6 +45,7 @@ pub mod llmstxt; pub mod meta; pub mod minify; pub mod mkdocstrings; +pub mod nav_weight; pub mod redirects; pub mod rss; pub mod search; diff --git a/crates/zensical/src/compat/mkdocs/plugin/autorefs.rs b/crates/zensical/src/compat/mkdocs/plugin/autorefs.rs index c530379..9702b89 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/autorefs.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/autorefs.rs @@ -1317,9 +1317,7 @@ mod tests { .data .get() .is_none()); - assert!(resolver - .get_backlinks(&["target".into()], "api/") - .is_empty()); + assert_eq!(resolver.get_backlinks(&["target".into()], "api/"), []); assert!(resolver .backlink_index .as_ref() diff --git a/crates/zensical/src/compat/mkdocs/plugin/meta/admission.rs b/crates/zensical/src/compat/mkdocs/plugin/meta/admission.rs index 867a61f..ea04ce6 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/meta/admission.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/meta/admission.rs @@ -304,7 +304,7 @@ mod tests { ]; let mut metadata = admission(docs); - assert!(metadata.prepare(&changes).unwrap().dependents.is_empty()); + assert_eq!(metadata.prepare(&changes).unwrap().dependents, []); } #[test] diff --git a/crates/zensical/src/compat/mkdocs/plugin/minify/style.rs b/crates/zensical/src/compat/mkdocs/plugin/minify/style.rs index e3cf282..36ec247 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/minify/style.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/minify/style.rs @@ -148,7 +148,9 @@ mod tests { #[test] fn ignores_legal_comment_markers_inside_strings() { - assert!(legal_comments(r#".a { content: \"/*! not legal */\" }"#) - .is_empty()); + assert_eq!( + legal_comments(r#".a { content: \"/*! not legal */\" }"#), + [] as [&str; 0] + ); } } diff --git a/crates/zensical/src/compat/mkdocs/plugin/nav_weight.rs b/crates/zensical/src/compat/mkdocs/plugin/nav_weight.rs new file mode 100644 index 0000000..a89e549 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/nav_weight.rs @@ -0,0 +1,332 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! MkDocs navigation weight compatibility. + +use anyhow::{bail, Result}; +use std::cmp::Ordering; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::sync::Arc; + +use zrx::id::Id; +use zrx::stream::function::Collection; +use zrx::stream::{Key, Signal}; + +use crate::config::{plugins::NavWeightPluginConfig, Config}; +use crate::structure::dynamic::Dynamic; +use crate::structure::nav::{NavigationItem, NavigationResolution}; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Navigation is sorted and filtered using page metadata. +#[derive(Clone, Debug)] +pub struct NavWeight { + settings: NavWeightPluginConfig, + strict: bool, +} + +/// Section settings are supplied by its last direct index page. +#[derive(Default)] +struct Section { + weight: Number, + hidden: Option, + title: Option, +} + +/// Integer precision is retained when numeric weights are compared. +#[derive(Clone, Copy, Default)] +struct Number { + integer: Option, + real: f64, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl NavWeight { + /// Validated plugin settings are retained for navigation updates. + pub fn new(config: &Config, strict: bool) -> Self { + Self { + settings: config.project.plugins.nav_weight.config.clone(), + strict, + } + } + + /// The resolved navigation is updated whenever its page facts change. + pub fn setup( + &self, resolution: &Signal, + ) -> Signal { + if !self.settings.enabled { + return resolution.clone(); + } + let plugin = self.clone(); + resolution + .map(move |resolution: &NavigationResolution| { + plugin.resolve(resolution) + }) + .reduce(|values: &dyn Collection, NavigationResolution>| { + values.values().next().cloned() + }) + } + + /// Weights, section titles, and visibility are applied to a resolved tree. + fn resolve( + &self, resolution: &NavigationResolution, + ) -> Result { + let mut resolution = resolution.clone(); + let navigation = &mut resolution.navigation; + let mut omitted = Vec::new(); + let mut diagnostics = Vec::new(); + let items = navigation.items.as_ref().clone(); + let (items, _, ancestry) = + self.sort(items, false, &mut omitted, &mut 0, &mut diagnostics); + omitted.sort_by_key(|(order, _)| *order); + let omitted = omitted + .into_iter() + .map(|(_, item)| item) + .collect::>(); + + for diagnostic in &diagnostics { + eprintln!("WARNING - [mkdocs-nav-weight]: {diagnostic}"); + } + if self.strict && !diagnostics.is_empty() { + bail!("Aborted because mkdocs-nav-weight reported warnings"); + } + + let mut hasher = DefaultHasher::default(); + items.hash(&mut hasher); + omitted.hash(&mut hasher); + ancestry.hash(&mut hasher); + self.settings.headless_included.hash(&mut hasher); + navigation.hash = hasher.finish(); + navigation.items = Arc::new(items); + navigation.omitted = Arc::new(omitted); + navigation.ancestry = Arc::new(ancestry); + navigation.headless_included = self.settings.headless_included; + navigation.page_only_connections = true; + Ok(resolution) + } + + /// Each sibling list is stably sorted before hidden items are removed. + fn sort( + &self, items: Vec, nested: bool, + omitted: &mut Vec<(usize, NavigationItem)>, order: &mut usize, + diagnostics: &mut Vec, + ) -> (Vec, Section, Vec) { + let mut parent = Section::default(); + let mut weighted = Vec::new(); + for mut item in items { + let mut hidden = None; + let mut empty = false; + let mut full_children = Vec::new(); + let weight; + if item.url.is_none() { + let (children, section, ancestry) = self.sort( + std::mem::take(&mut item.children), + true, + omitted, + order, + diagnostics, + ); + item.children = children; + full_children = ancestry; + weight = section.weight; + hidden = section.hidden; + if let Some(title) = section.title { + item.title = Some(title); + } + } else if item.meta.is_some() { + if item.is_index { + weight = number(&self.settings.index_weight) + .expect("validated index weight"); + if nested { + parent.weight = self.weight(&item, diagnostics); + if self.boolean(&item, "headless", diagnostics) + && parent.hidden.is_none() + { + parent.hidden = Some(*order); + *order += 1; + } + if self.settings.section_renamed + || self.boolean(&item, "retitled", diagnostics) + { + parent.title.clone_from(&item.title); + } + empty = self.boolean(&item, "empty", diagnostics); + } + } else { + if self.boolean(&item, "headless", diagnostics) { + hidden = Some(*order); + *order += 1; + } + weight = self.weight(&item, diagnostics); + } + } else { + // External links are assigned the upstream weight of zero. + weight = Number::default(); + } + let children = std::mem::take(&mut item.children); + let mut original = item.clone(); + original.children = full_children; + item.children = children; + weighted.push((item, weight, hidden, empty, original)); + } + + weighted.sort_by(|(_, left, _, _, _), (_, right, _, _, _)| { + let ordering = left.compare(*right); + if self.settings.reverse { + ordering.reverse() + } else { + ordering + } + }); + + let mut visible = Vec::new(); + let mut ancestry = Vec::new(); + for (item, _, hidden, empty, original) in weighted { + ancestry.push(original); + if let Some(order) = hidden { + omitted.push((order, item)); + } else if !empty { + visible.push(item); + } + } + (visible, parent, ancestry) + } + + /// Invalid or missing numeric metadata is replaced with the default. + fn weight( + &self, item: &NavigationItem, diagnostics: &mut Vec, + ) -> Number { + let fallback = number(&self.settings.default_page_weight) + .expect("validated default weight"); + let Some(value) = + item.meta.as_ref().and_then(|meta| meta.get("weight")) + else { + return fallback; + }; + if let Some(value) = number(value) { + return value; + } + self.warn( + item, + "weight", + &self.settings.default_page_weight, + diagnostics, + ); + fallback + } + + /// Only Boolean values are accepted for visibility and section flags. + fn boolean( + &self, item: &NavigationItem, key: &str, diagnostics: &mut Vec, + ) -> bool { + match item.meta.as_ref().and_then(|meta| meta.get(key)) { + Some(Dynamic::Bool(value)) => *value, + None => false, + Some(_) => { + self.warn(item, key, &Dynamic::Bool(false), diagnostics); + false + } + } + } + + /// Invalid metadata is reported when warnings are enabled. + fn warn( + &self, item: &NavigationItem, key: &str, fallback: &Dynamic, + diagnostics: &mut Vec, + ) { + if self.settings.warning { + diagnostics.push(format!( + "Invalid value for \"{key}\" in {} ({}), setting to \"{fallback}\"", + item.title.as_deref().unwrap_or_default(), + item.url.as_deref().unwrap_or_default(), + )); + } + } +} + +/// Python Boolean values are also numbers, as in the upstream plugin. +#[allow( + clippy::cast_precision_loss, + reason = "Integer weights are retained separately for exact comparisons." +)] +fn number(value: &Dynamic) -> Option { + match value { + Dynamic::Bool(value) => number(&Dynamic::Integer(i64::from(*value))), + Dynamic::Integer(value) => Some(Number { + integer: Some(*value), + real: *value as f64, + }), + Dynamic::Float(value) => Some(Number { + integer: None, + real: value.get(), + }), + _ => None, + } +} + +impl Number { + /// Mixed integer and floating-point weights are compared without rounding integers. + fn compare(self, other: Self) -> Ordering { + match (self.integer, other.integer) { + (Some(left), Some(right)) => left.cmp(&right), + (Some(left), None) => compare_integer_float(left, other.real), + (None, Some(right)) => { + compare_integer_float(right, self.real).reverse() + } + (None, None) => self + .real + .partial_cmp(&other.real) + .unwrap_or(Ordering::Equal), + } + } +} + +/// Floating-point bounds are checked before conversion to a signed integer. +#[allow( + clippy::cast_possible_truncation, + clippy::cast_precision_loss, + reason = "Bounds are checked before truncation, and float comparisons are only used to break integer ties." +)] +fn compare_integer_float(integer: i64, real: f64) -> Ordering { + if real.is_nan() { + return Ordering::Equal; + } + if real >= 9_223_372_036_854_775_808.0 { + return Ordering::Less; + } + if real < -9_223_372_036_854_775_808.0 { + return Ordering::Greater; + } + integer.cmp(&(real as i64)).then_with(|| { + (integer as f64) + .partial_cmp(&real) + .unwrap_or(Ordering::Equal) + }) +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/search/parser.rs b/crates/zensical/src/compat/mkdocs/plugin/search/parser.rs index c85b140..d24a08f 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/search/parser.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/search/parser.rs @@ -827,7 +827,7 @@ mod tests { let output = scan(html, &mut [&mut parser]).expect("search edit"); assert_eq!(output, "

Drop

"); - assert!(parser.finish().is_empty()); + assert_eq!(parser.finish(), []); } #[test] diff --git a/crates/zensical/src/config/plugins.rs b/crates/zensical/src/config/plugins.rs index 4815b46..5a102d6 100644 --- a/crates/zensical/src/config/plugins.rs +++ b/crates/zensical/src/config/plugins.rs @@ -33,6 +33,7 @@ mod api_autonav; mod autoapi; mod blog; mod llmstxt; +mod nav_weight; mod rss; mod social; mod tags; @@ -41,6 +42,7 @@ pub use api_autonav::{ApiAutonavConfig, ApiAutonavPlugin}; pub use autoapi::AutoApiPlugin; pub use blog::{BlogPlugin, BlogPluginConfig, CategorySort, ExcerptPolicy}; pub use llmstxt::{LlmstxtPlugin, LlmstxtPluginConfig}; +pub use nav_weight::{NavWeightPlugin, NavWeightPluginConfig}; pub use rss::{RssDateConfig, RssPlugin, RssPluginConfig}; pub use social::{SocialPlugin, SocialPluginConfig, SocialPluginInstance}; pub use tags::{ @@ -100,6 +102,8 @@ pub struct Plugins { pub literate_nav: LiterateNavPlugin, /// Awesome navigation plugin. pub awesome_nav: AwesomeNavPlugin, + /// Navigation weight plugin. + pub nav_weight: NavWeightPlugin, /// Offline plugin. pub offline: OfflinePlugin, } diff --git a/crates/zensical/src/config/plugins/nav_weight.rs b/crates/zensical/src/config/plugins/nav_weight.rs new file mode 100644 index 0000000..95d3cb3 --- /dev/null +++ b/crates/zensical/src/config/plugins/nav_weight.rs @@ -0,0 +1,64 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Navigation weight plugin settings. + +use pyo3::FromPyObject; +use serde::Serialize; + +use crate::structure::dynamic::Dynamic; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Navigation weight plugin. +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct NavWeightPlugin { + /// Plugin configuration. + pub config: NavWeightPluginConfig, +} + +/// Settings supported by mkdocs-nav-weight 0.3.0. +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +#[allow(clippy::struct_excessive_bools)] +pub struct NavWeightPluginConfig { + /// Whether navigation weights are enabled. + pub enabled: bool, + /// Whether all sections are renamed from their index titles. + pub section_renamed: bool, + /// Weight assigned to index pages within their section. + pub index_weight: Dynamic, + /// Whether invalid metadata is reported. + pub warning: bool, + /// Whether weights are sorted in descending order. + pub reverse: bool, + /// Whether hidden pages are included in the flat page list. + pub headless_included: bool, + /// Weight assigned when page metadata is missing or invalid. + pub default_page_weight: Dynamic, +} diff --git a/crates/zensical/src/structure/nav.rs b/crates/zensical/src/structure/nav.rs index 8e07dbe..efc0183 100644 --- a/crates/zensical/src/structure/nav.rs +++ b/crates/zensical/src/structure/nav.rs @@ -70,6 +70,22 @@ pub struct Navigation { /// Site snapshot generation this navigation was created from. #[serde(skip)] pub generation: u64, + /// Hidden items are retained for isolated section traversal. + #[pyo3(default, from_py_with = extract_shared_items)] + #[serde(skip)] + pub(crate) omitted: Arc>, + /// Whether hidden pages are included in the template-facing page list. + #[pyo3(default)] + #[serde(skip)] + pub(crate) headless_included: bool, + /// Navigation ancestry before entries are hidden or removed. + #[pyo3(default, from_py_with = extract_shared_items)] + #[serde(skip)] + pub(crate) ancestry: Arc>, + /// Whether previous and next connections are limited to resolved pages. + #[pyo3(default)] + #[serde(skip)] + pub(crate) page_only_connections: bool, } // ---------------------------------------------------------------------------- @@ -179,6 +195,10 @@ impl Navigation { homepage, hash, generation: 0, + omitted: Arc::default(), + headless_included: false, + ancestry: Arc::default(), + page_only_connections: false, }, title_overrides: Arc::new(title_overrides), } @@ -222,7 +242,12 @@ impl Navigation { // Clone the ancestors into owned items and reverse them, so we start // at the ancestor closest to the page, not the root itself let mut items: Vec<&NavigationItem> = Vec::new(); - let _ = recurse(&self.items, url, &mut items); + let ancestry = if self.ancestry.is_empty() { + &self.items + } else { + &self.ancestry + }; + let _ = recurse(ancestry, url, &mut items); items.into_iter().rev().cloned().collect() } @@ -231,6 +256,20 @@ impl Navigation { Iter::new(&self.items) } + /// The flat page list is returned in navigation order. + pub fn pages(&self) -> Vec<&NavigationItem> { + let mut pages = self + .iter() + .filter(|item| item.meta.is_some()) + .collect::>(); + if self.headless_included { + pages.extend( + Iter::new(&self.omitted).filter(|item| item.meta.is_some()), + ); + } + pages + } + /// Return the next page for the given page in pre-order, if any. pub fn next_page(&self, page: &Page) -> Option { self.next_page_for_url(&page.url) @@ -238,16 +277,20 @@ impl Navigation { /// Returns the next page after a URL in pre-order, if any. pub fn next_page_for_url(&self, url: &str) -> Option { - let mut found = false; - for item in self { - if found { - if item.url.is_some() { - return Some(item.clone()); + for group in self.connected_groups() { + let mut found = false; + for item in Iter::new(group).filter(|item| { + !self.page_only_connections || item.meta.is_some() + }) { + if found { + if item.url.is_some() { + return Some(item.clone()); + } + continue; + } + if item.url.as_deref() == Some(url) { + found = true; } - continue; - } - if item.url.as_deref() == Some(url) { - found = true; } } None @@ -260,17 +303,31 @@ impl Navigation { /// Returns the previous page before a URL in pre-order, if any. pub fn previous_page_for_url(&self, url: &str) -> Option { - let mut prev: Option = None; - for item in self { - if item.url.as_deref() == Some(url) { - return prev; - } - if item.url.is_some() { - prev = Some(item.clone()); + for group in self.connected_groups() { + let mut prev: Option = None; + for item in Iter::new(group).filter(|item| { + !self.page_only_connections || item.meta.is_some() + }) { + if item.url.as_deref() == Some(url) { + return prev; + } + if item.url.is_some() { + prev = Some(item.clone()); + } } } None } + + /// Visible navigation and hidden sections are traversed independently. + fn connected_groups(&self) -> impl Iterator { + std::iter::once(self.items.as_slice()).chain( + self.omitted + .iter() + .filter(|item| item.url.is_none()) + .map(|item| item.children.as_slice()), + ) + } } // ---------------------------------------------------------------------------- @@ -380,6 +437,10 @@ impl From> for Navigation { items: Arc::new(items), hash, generation: 0, + omitted: Arc::default(), + headless_included: false, + ancestry: Arc::default(), + page_only_connections: false, } } } @@ -534,6 +595,10 @@ mod tests { homepage: None, hash: 0, generation: 0, + omitted: Arc::default(), + headless_included: false, + ancestry: Arc::default(), + page_only_connections: false, }; let clone = nav.clone(); @@ -548,6 +613,10 @@ mod tests { homepage: None, hash: navigation_hash(&[]), generation: 0, + omitted: Arc::default(), + headless_included: false, + ancestry: Arc::default(), + page_only_connections: false, }; let value = serde_json::to_value(nav).expect("invariant"); @@ -601,6 +670,10 @@ mod tests { homepage: None, hash: 0, generation: 0, + omitted: Arc::default(), + headless_included: false, + ancestry: Arc::default(), + page_only_connections: false, }, title_overrides: Arc::new(HashMap::default()), }; diff --git a/crates/zensical/src/structure/nav/view.rs b/crates/zensical/src/structure/nav/view.rs index 069aba2..82f6a6a 100644 --- a/crates/zensical/src/structure/nav/view.rs +++ b/crates/zensical/src/structure/nav/view.rs @@ -35,7 +35,7 @@ use super::{Navigation, NavigationItem}; // ---------------------------------------------------------------------------- /// Navigation fields visible to templates. -const NAVIGATION_FIELDS: &[&str] = &["items", "homepage", "hash"]; +const NAVIGATION_FIELDS: &[&str] = &["items", "pages", "homepage", "hash"]; /// Navigation item fields visible to templates. const ITEM_FIELDS: &[&str] = &[ @@ -131,6 +131,9 @@ impl NavigationView { fn field(&self, field: &str) -> Option { match field { "items" => Some(items(&self.overlay, &[])), + "pages" => { + Some(Value::from_serialize(self.overlay.navigation.pages())) + } "homepage" => { Some(Value::from_serialize(&self.overlay.navigation.homepage)) } @@ -265,6 +268,10 @@ mod tests { homepage: None, hash: 42, generation: 0, + omitted: Arc::default(), + headless_included: false, + ancestry: Arc::default(), + page_only_connections: false, } } diff --git a/crates/zensical/src/workflow.rs b/crates/zensical/src/workflow.rs index 50a0b55..2b3b967 100644 --- a/crates/zensical/src/workflow.rs +++ b/crates/zensical/src/workflow.rs @@ -49,7 +49,8 @@ use crate::compat::mkdocs::{ html, plugin::{ self, autorefs, awesome_nav, blog, exclude, literate_nav, llmstxt, - meta, minify, mkdocstrings, redirects, rss, search, social, tags, + meta, minify, mkdocstrings, nav_weight, redirects, rss, search, social, + tags, }, resource, }; @@ -554,7 +555,8 @@ fn resolve_navigation( }, ) }; - blogs.navigation(&resolution, all_pages, view_pages) + let resolution = blogs.navigation(&resolution, all_pages, view_pages); + nav_weight::NavWeight::new(config, strict).setup(&resolution) } /// Retains the lightweight autorefs facts needed for ordinary link resolution. diff --git a/python/tests/integration/test_nav_weight.py b/python/tests/integration/test_nav_weight.py new file mode 100644 index 0000000..51e58de --- /dev/null +++ b/python/tests/integration/test_nav_weight.py @@ -0,0 +1,441 @@ +# Copyright (c) 2025-2026 Zensical and contributors + +# SPDX-License-Identifier: MIT +# All contributions are certified under the DCO + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +"""Integration tests for native mkdocs-nav-weight 0.3.0 compatibility.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest +import yaml +from bs4 import BeautifulSoup + +import zensical + +if TYPE_CHECKING: + from pathlib import Path + + from bs4 import Tag + + +_TEMPLATE = """\ +{% macro render(items, depth) %} +{% for item in items %} + +{{ render(item.children, depth + 1) }} +{% endfor %} +{% endmacro %} +{{ render(nav.items, 0) }} +{% for item in nav.pages %}{% endfor %} +{% for item in page.ancestors %}{% endfor %} + +""" # noqa: E501 + + +def _markdown(title: str, **metadata: Any) -> str: + """Create a Markdown source with the metadata relevant to a test.""" + front_matter = yaml.safe_dump(metadata) if metadata else "" + return f"---\n{front_matter}---\n# {title}\n" + + +def _project( + root: Path, + documents: dict[str, str], + settings: dict[str, Any] | None = None, + nav: list[Any] | None = None, +) -> Path: + """Write a project with observable navigation and page connections.""" + overrides = root / "overrides" + overrides.mkdir() + (overrides / "main.html").write_text(_TEMPLATE, encoding="utf-8") + + for name, content in documents.items(): + path = root / "docs" / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + config: dict[str, Any] = { + "site_name": "Navigation weights", + "theme": {"name": "material", "custom_dir": "overrides"}, + "plugins": [{"mkdocs-nav-weight": settings or {}}], + } + if nav is not None: + config["nav"] = nav + path = root / "mkdocs.yml" + path.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8") + return path + + +def _build(config: Path, *, strict: bool = False) -> None: + zensical.build(str(config), {"clean": False, "strict": strict}) + + +def _output(root: Path, page: str = "index.html") -> BeautifulSoup: + return BeautifulSoup( + (root / "site" / page).read_text(encoding="utf-8"), "html.parser" + ) + + +def _items(output: BeautifulSoup) -> list[tuple[int, str, str]]: + return [ + (int(str(item["depth"])), str(item["title"]), str(item["url"])) + for item in output.find_all("item") + ] + + +def _pages(output: BeautifulSoup) -> list[str]: + return [str(item["url"]) for item in output.find_all("flat")] + + +def _current(root: Path, page: str) -> Tag: + """Return the current page element and assert that it exists.""" + current = _output(root, page).select_one("current") + assert current is not None + return current + + +def test_sorts_pages_sections_and_empty_indexes(tmp_path: Path) -> None: + # Section metadata is supplied by an empty index; all sources are rendered. + config = _project( + tmp_path, + { + "index.md": "# Home\n", + "a.md": _markdown("Later", weight=3.5), + "z.md": _markdown("Early", weight=-2), + "b.md": "# Default\n", + "guide/index.md": _markdown( + "Handbook", weight=-1, retitled=True, empty=True + ), + "guide/a.md": _markdown("Second", weight=2), + "guide/z.md": _markdown("First", weight=1), + "reference/README.md": _markdown("Reference index", weight=5), + "reference/topic.md": "# Topic\n", + "unindexed/topic.md": "# Unindexed\n", + }, + {"default_page_weight": 4}, + ) + + _build(config) + output = _output(tmp_path) + + assert _items(output) == [ + (0, "Home", ""), + (0, "Early", "z/"), + (0, "Handbook", ""), + (1, "First", "guide/z/"), + (1, "Second", "guide/a/"), + (0, "Unindexed", ""), + (1, "Unindexed", "unindexed/topic/"), + (0, "Later", "a/"), + (0, "Default", "b/"), + (0, "Reference", ""), + (1, "Reference index", "reference/"), + (1, "Topic", "reference/topic/"), + ] + assert "guide/" not in _pages(output) + assert (tmp_path / "site" / "guide" / "index.html").is_file() + assert _current(tmp_path, "guide/z/index.html")["next"] == "guide/a/" + + +@pytest.mark.parametrize("included", [False, True]) +def test_hides_pages_and_isolates_hidden_sections( + tmp_path: Path, + included: bool, +) -> None: + config = _project( + tmp_path, + { + # Root index metadata is ignored, as in the released plugin. + "index.md": _markdown( + "Home", headless=True, empty=True, retitled=True, weight=100 + ), + "a.md": "# Visible\n", + "hidden.md": _markdown("Hidden page", headless=True, weight=5), + "secret/index.md": _markdown("Secret", headless=True), + "secret/next.md": "# Secret next\n", + "secret/hidden.md": _markdown("Hidden child", headless=True), + }, + {"headless_included": included}, + ) + + _build(config) + output = _output(tmp_path) + + assert _items(output) == [(0, "Home", ""), (0, "Visible", "a/")] + expected_pages = ["", "a/"] + if included: + expected_pages += [ + "hidden/", + "secret/", + "secret/next/", + "secret/hidden/", + ] + assert _pages(output) == expected_pages + + # Page sequences within hidden sections are kept separate. + secret = _current(tmp_path, "secret/index.html") + assert secret["previous"] == "" + assert secret["next"] == "secret/next/" + assert _current(tmp_path, "secret/next/index.html")["previous"] == "secret/" + hidden = _current(tmp_path, "hidden/index.html") + assert hidden["previous"] == hidden["next"] == "" + hidden_child = _output(tmp_path, "secret/hidden/index.html") + assert [item["title"] for item in hidden_child.find_all("ancestor")] == [ + "Secret" + ] + assert _current(tmp_path, "a/index.html")["next"] == "" + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_preserves_equal_weight_order_in_configured_navigation( + tmp_path: Path, + reverse: bool, +) -> None: + config = _project( + tmp_path, + { + "index.md": "# Home\n", + "first.md": _markdown("First", weight=1), + "second.md": _markdown("Second", weight=True), + "group/README.md": _markdown("Source title", weight=2), + "group/child.md": _markdown("Child", weight=-4), + }, + {"reverse": reverse, "index_weight": -3, "section_renamed": True}, + [ + {"Home": "index.md"}, + {"Second alias": "second.md"}, + {"First alias": "first.md"}, + {"Website": "https://example.com"}, + {"Group": [{"Index alias": "group/README.md"}, "group/child.md"]}, + ], + ) + + _build(config) + output = _output(tmp_path) + + expected = [ + (0, "Home", ""), + (0, "Website", "https://example.com"), + (0, "Second alias", "second/"), + (0, "First alias", "first/"), + (0, "Index alias", ""), + (1, "Child", "group/child/"), + (1, "Index alias", "group/"), + ] + if reverse: + expected = [ + expected[4], + expected[6], + expected[5], + expected[2], + expected[3], + expected[1], + expected[0], + ] + assert _items(output) == expected + first = _current(tmp_path, "first/index.html") + assert first["title"] == "First alias" + second = _current(tmp_path, "second/index.html") + assert second["next"] == "first/" + if reverse: + assert first["next"] == "" + else: + assert second["previous"] == "" + + +@pytest.mark.parametrize("warning", [False, True]) +def test_invalid_metadata_falls_back_and_obeys_strict_mode( + tmp_path: Path, + warning: bool, +) -> None: + config = _project( + tmp_path, + { + "index.md": "# Home\n", + "first.md": _markdown("First", weight="1", headless="true"), + "second.md": _markdown("Second", weight=2), + "group/index.md": _markdown( + "Group index", weight=[], retitled=1, empty="true" + ), + }, + {"warning": warning, "default_page_weight": 3}, + ) + + if warning: + with pytest.raises( + RuntimeError, match="mkdocs-nav-weight reported warnings" + ): + _build(config, strict=True) + else: + _build(config, strict=True) + + assert _items(_output(tmp_path)) == [ + (0, "Home", ""), + (0, "Second", "second/"), + (0, "First", "first/"), + (0, "Group", ""), + (1, "Group index", "group/"), + ] + + +def test_disabled_plugin_preserves_navigation(tmp_path: Path) -> None: + config = _project( + tmp_path, + { + "index.md": "# Home\n", + "a.md": _markdown("Alpha", headless=True, weight=10), + "z.md": _markdown("Zeta", weight=-10), + }, + {"enabled": False}, + ) + + _build(config) + + assert _items(_output(tmp_path)) == [ + (0, "Home", ""), + (0, "Alpha", "a/"), + (0, "Zeta", "z/"), + ] + + +def test_metadata_changes_update_cached_navigation(tmp_path: Path) -> None: + config = _project( + tmp_path, + { + "index.md": "# Home\n", + "a.md": _markdown("Alpha", weight=1), + "z.md": _markdown("Zeta", weight=2), + }, + ) + _build(config) + assert _pages(_output(tmp_path)) == ["", "a/", "z/"] + + # Ordering, titles, and visibility must be updated during a cached rebuild. + (tmp_path / "docs" / "z.md").write_text( + _markdown("Renamed", weight=-1), encoding="utf-8" + ) + (tmp_path / "docs" / "a.md").write_text( + _markdown("Alpha", headless=True), encoding="utf-8" + ) + _build(config) + + assert _items(_output(tmp_path)) == [(0, "Home", ""), (0, "Renamed", "z/")] + + +@pytest.mark.parametrize("plugin", ["awesome-nav", "literate-nav"]) +def test_applies_weights_after_other_navigation_plugins( + tmp_path: Path, plugin: str +) -> None: + config = _project( + tmp_path, + { + "index.md": "# Home\n", + "a.md": _markdown("Alpha", weight=2), + "z.md": _markdown("Zeta", weight=1), + }, + ) + data = yaml.safe_load(config.read_text(encoding="utf-8")) + data["plugins"].append(plugin) + config.write_text(yaml.safe_dump(data), encoding="utf-8") + + if plugin == "awesome-nav": + control = tmp_path / "docs" / ".nav.yml" + control.write_text( + "nav:\n - index.md\n - First alias: a.md\n" + " - Second alias: z.md\n", + encoding="utf-8", + ) + else: + control = tmp_path / "docs" / "SUMMARY.md" + control.write_text( + "* [Home](index.md)\n* [First alias](a.md)\n" + "* [Second alias](z.md)\n", + encoding="utf-8", + ) + + _build(config) + + assert _items(_output(tmp_path)) == [ + (0, "Home", ""), + (0, "Second alias", "z/"), + (0, "First alias", "a/"), + ] + + +def test_preserves_large_integer_weights(tmp_path: Path) -> None: + # Adjacent integers must remain distinct above floating-point precision. + config = _project( + tmp_path, + { + "index.md": "# Home\n", + "a.md": _markdown("Larger", weight=9007199254740993), + "b.md": _markdown("Float", weight=9007199254740992.0), + "z.md": _markdown("Smaller", weight=9007199254740992), + }, + ) + + _build(config) + + assert _pages(_output(tmp_path)) == ["", "b/", "z/", "a/"] + + +@pytest.mark.parametrize("enabled", [None, False, True]) +def test_connections_skip_links_only_when_nav_weight_is_enabled( + tmp_path: Path, + enabled: bool | None, +) -> None: + # External URLs and unresolved references are kept in configured navigation. + config = _project( + tmp_path, + {"index.md": "# Home\n", "last.md": "# Last\n"}, + {"enabled": enabled} if enabled is not None else None, + [ + {"Home": "index.md"}, + {"Website": "https://example.com"}, + {"Missing": "missing.md"}, + {"Download": "manual.pdf"}, + {"Notebook": "notebook.html"}, + {"Last": "last.md"}, + {"Author's website": "https://author.example.com"}, + ], + ) + if enabled is None: + settings = yaml.safe_load(config.read_text(encoding="utf-8")) + settings["plugins"] = [] + config.write_text(yaml.safe_dump(settings), encoding="utf-8") + + _build(config) + home = _current(tmp_path, "index.html") + last = _current(tmp_path, "last/index.html") + + if enabled: + assert home["next"] == "last/" + assert last["previous"] == "" + assert last["next"] == "" + else: + assert home["next"] == "https://example.com" + assert last["previous"] == "notebook.html" + assert last["next"] == "https://author.example.com" diff --git a/python/tests/unit/test_plugin_config.py b/python/tests/unit/test_plugin_config.py index 39ae4de..bac32cf 100644 --- a/python/tests/unit/test_plugin_config.py +++ b/python/tests/unit/test_plugin_config.py @@ -40,6 +40,7 @@ PYTHON_PLUGINS = ( "minify", "literate-nav", "awesome-nav", + "mkdocs-nav-weight", "offline", "mike", "autorefs", @@ -118,6 +119,7 @@ def test_preserves_plugin_presence_semantics() -> None: "minify", "literate_nav", "awesome_nav", + "nav_weight", "offline", ): assert plugins[name]["config"]["enabled"] is False @@ -859,3 +861,59 @@ def test_api_autonav_preserves_module_option_order() -> None: "pkg.*", ".*", ] + + +@pytest.mark.parametrize( + "entry", + [ + "mkdocs-nav-weight", + {"mkdocs-nav-weight": None}, + {"mkdocs-nav-weight": {}}, + ], +) +def test_nav_weight_defaults(entry: Any) -> None: + plugins = _convert_plugins([entry]) + + assert plugins["nav_weight"]["config"] == { + "enabled": True, + "section_renamed": False, + "index_weight": -10, + "warning": True, + "reverse": False, + "headless_included": False, + "default_page_weight": 0, + } + + +@pytest.mark.parametrize("option", ["index_weight", "default_page_weight"]) +@pytest.mark.parametrize("value", [-3, 1.25, True]) +def test_nav_weight_accepts_numeric_options(option: str, value: Any) -> None: + plugins = _convert_plugins({"mkdocs-nav-weight": {option: value}}) + + assert plugins["nav_weight"]["config"][option] == value + + +@pytest.mark.parametrize("option", ["index_weight", "default_page_weight"]) +@pytest.mark.parametrize("value", ["1", [], {}]) +def test_nav_weight_rejects_invalid_numeric_options( + option: str, value: Any +) -> None: + with pytest.raises( + ConfigurationError, match=f"mkdocs-nav-weight {option} must be a number" + ): + _convert_plugins({"mkdocs-nav-weight": {option: value}}) + + +@pytest.mark.parametrize( + "option", + ["enabled", "section_renamed", "warning", "reverse", "headless_included"], +) +@pytest.mark.parametrize("value", [1, "true", [], {}]) +def test_nav_weight_rejects_invalid_boolean_options( + option: str, value: Any +) -> None: + with pytest.raises( + ConfigurationError, + match=f"mkdocs-nav-weight {option} must be a boolean", + ): + _convert_plugins({"mkdocs-nav-weight": {option: value}}) diff --git a/python/zensical/config.py b/python/zensical/config.py index ad023e1..264fd3d 100644 --- a/python/zensical/config.py +++ b/python/zensical/config.py @@ -143,6 +143,7 @@ _PLUGIN_UNSUPPORTED_OPTIONS = { "literate-nav": (), "llmstxt": ("preprocess",), "macros": (), + "mkdocs-nav-weight": (), "markdown-exec": (), "meta": (), "mike": ( @@ -2292,6 +2293,39 @@ def _convert_plugins(value: Any, config: dict) -> dict: awesome_nav["logs"] = logs plugins["awesome_nav"] = awesome_nav + # Navigation weights are applied after the navigation tree is resolved. + present = "mkdocs-nav-weight" in plugins + nav_weight = plugins.pop("mkdocs-nav-weight", {}) + defaults = { + "enabled": present, + "section_renamed": False, + "index_weight": -10, + "warning": True, + "reverse": False, + "headless_included": False, + "default_page_weight": 0, + } + _reject_unknown_options("mkdocs-nav-weight", nav_weight, set(defaults)) + for option, default in defaults.items(): + set_default(nav_weight, option, default) + _validate_boolean_options( + "mkdocs-nav-weight", + nav_weight, + ( + "enabled", + "section_renamed", + "warning", + "reverse", + "headless_included", + ), + ) + for option in ("index_weight", "default_page_weight"): + if not isinstance(nav_weight[option], (int, float)): + raise ConfigurationError( + f"mkdocs-nav-weight {option} must be a number" + ) + plugins["nav_weight"] = nav_weight + # Offline is always materialized for Rust and enabled by plugin presence. present = "offline" in plugins offline = plugins.pop("offline", {})