From c3dbdfec557ee2f800baceabff1e639d9d23a444 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Tue, 1 Sep 2026 18:20:03 +0200 Subject: [PATCH] feature: add `literate-nav` MkDocs plugin replacement Signed-off-by: squidfunk --- crates/zensical/src/compat/mkdocs/plugin.rs | 1 + .../src/compat/mkdocs/plugin/literate_nav.rs | 225 +++++++ .../mkdocs/plugin/literate_nav/parser.rs | 426 ++++++++++++ .../mkdocs/plugin/literate_nav/resolver.rs | 615 ++++++++++++++++++ crates/zensical/src/config/plugins.rs | 26 + crates/zensical/src/structure/markdown.rs | 36 +- crates/zensical/src/structure/nav.rs | 8 +- crates/zensical/src/structure/nav/plan.rs | 155 +++++ crates/zensical/src/workflow.rs | 26 +- python/tests/integration/test_literate_nav.py | 371 +++++++++++ python/tests/unit/test_config.py | 48 ++ python/zensical/compat/literate_nav.py | 142 ++++ python/zensical/config.py | 66 +- python/zensical/markdown/render.py | 4 - 14 files changed, 2115 insertions(+), 34 deletions(-) create mode 100644 crates/zensical/src/compat/mkdocs/plugin/literate_nav.rs create mode 100644 crates/zensical/src/compat/mkdocs/plugin/literate_nav/parser.rs create mode 100644 crates/zensical/src/compat/mkdocs/plugin/literate_nav/resolver.rs create mode 100644 crates/zensical/src/structure/nav/plan.rs create mode 100644 python/tests/integration/test_literate_nav.py create mode 100644 python/zensical/compat/literate_nav.py diff --git a/crates/zensical/src/compat/mkdocs/plugin.rs b/crates/zensical/src/compat/mkdocs/plugin.rs index 732f635..4afc80c 100644 --- a/crates/zensical/src/compat/mkdocs/plugin.rs +++ b/crates/zensical/src/compat/mkdocs/plugin.rs @@ -35,6 +35,7 @@ use crate::structure::markdown::Markdown; use super::html::{self, Visitor}; pub mod autorefs; +pub mod literate_nav; pub mod meta; pub mod minify; pub mod mkdocstrings; diff --git a/crates/zensical/src/compat/mkdocs/plugin/literate_nav.rs b/crates/zensical/src/compat/mkdocs/plugin/literate_nav.rs new file mode 100644 index 0000000..7409e83 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/literate_nav.rs @@ -0,0 +1,225 @@ +// 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 NONINFRINGEMENT. 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. + +// ---------------------------------------------------------------------------- + +//! Native compatibility pipeline for filesystem-backed literate navigation. + +use anyhow::Context; +use std::collections::BTreeMap; +use std::fs; +use std::sync::Arc; + +use zrx::id::Id; +use zrx::stream::function::Collection; +use zrx::stream::{Key, Signal, Stream, Value}; + +use crate::config::Config; +use crate::path::SourcePath; +use crate::structure::nav::{Navigation, NavigationItem}; +use crate::structure::page::Page; +use crate::watcher::Source; + +mod parser; +mod resolver; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Literate navigation pipeline. +#[derive(Clone, Debug)] +pub struct LiterateNav { + settings: Arc, +} + +/// Inputs required to derive revision-complete navigation. +pub struct Dependencies<'a> { + /// Physical sources, including navigation control files. + pub sources: &'a Stream, + /// Rendered documentation pages. + pub pages: &'a Stream, +} + +/// Immutable native plugin settings. +#[derive(Clone, Debug)] +struct Settings { + /// Whether native literate navigation participates in resolution. + enabled: bool, + /// Provider context containing documentation sources. + docs: String, + /// Folder-relative navigation document name or path. + nav_file: String, + /// Whether a folder index omitted from its list is inserted first. + implicit_index: bool, + /// Explicit MkDocs navigation used as the root fallback or seed. + configured: Vec, +} + +/// One discovered literate navigation document. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Document { + /// Canonical source-relative navigation document path. + path: SourcePath, + /// Complete Markdown source after optional byte-order-mark removal. + content: String, +} + +impl Value for Document {} + +/// Revision-complete navigation documents. +#[derive(Clone, Debug, Default)] +struct Documents( + /// Documents keyed by canonical source-relative path. + Arc>, +); + +impl Value for Documents {} + +/// Revision-complete rendered pages. +#[derive(Clone, Debug)] +struct Pages( + /// Rendered pages in the settled workflow revision. + Arc>, +); + +impl Value for Pages {} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl LiterateNav { + /// Resolves plugin settings for one workflow lifetime. + pub fn new(config: &Config) -> Self { + let plugin = &config.project.plugins.literate_nav.config; + Self { + settings: Arc::new(Settings { + enabled: plugin.enabled, + docs: config.project.docs_dir.clone(), + nav_file: plugin.nav_file.clone(), + implicit_index: plugin.implicit_index, + configured: config.project.nav.clone(), + }), + } + } + + /// Installs navigation discovery, settlement, and compilation. + pub fn setup( + &self, dependencies: Dependencies<'_>, + ) -> Signal { + let settings = self.settings.clone(); + let documents = dependencies.sources.filter_map({ + let settings = settings.clone(); + move |id: &Id, source: &Source| { + if !settings.enabled || id.context() != settings.docs { + return Ok(None); + } + let path = id.location().parse::()?; + if !is_navigation_file(&path, &settings.nav_file) { + return Ok(None); + } + let content = + fs::read_to_string(&**source).with_context(|| { + format!( + "failed to read literate navigation file {path}" + ) + })?; + Ok::<_, anyhow::Error>(Some(Document { + path, + content: content.trim_start_matches('\u{feff}').into(), + })) + } + }); + let documents = documents.reduce( + |documents: &dyn Collection, Document>| { + Some(Documents(Arc::new( + documents + .values() + .map(|document| { + ( + document.path.to_string(), + document.content.clone(), + ) + }) + .collect(), + ))) + }, + ); + let pages = dependencies.pages.reduce( + |pages: &dyn Collection, Page>| { + Some(Pages(Arc::new(pages.values().cloned().collect()))) + }, + ); + + let navigation = pages.product(&documents).map( + move |pages: &Pages, docs: &Documents| { + if settings.enabled { + resolver::resolve(&settings, &docs.0, pages.0.as_ref()) + } else { + Ok(Navigation::new( + settings.configured.clone(), + pages.0.as_ref().clone(), + )) + } + }, + ); + navigation.reduce(|navigation: &dyn Collection, Navigation>| { + navigation.values().next().cloned() + }) + } +} + +/// Returns whether a source can serve as a folder navigation document. +fn is_navigation_file(path: &SourcePath, nav_file: &str) -> bool { + path.as_str() == nav_file + || path + .as_str() + .strip_suffix(nav_file) + .is_some_and(|prefix| prefix.ends_with('/')) +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::is_navigation_file; + + #[test] + fn discovers_simple_and_nested_navigation_names() { + assert!(is_navigation_file( + &"guide/SUMMARY.md".parse().unwrap(), + "SUMMARY.md" + )); + assert!(is_navigation_file( + &"guide/nav/SUMMARY.md".parse().unwrap(), + "nav/SUMMARY.md" + )); + assert!(!is_navigation_file( + &"guide/NOT-SUMMARY.md".parse().unwrap(), + "SUMMARY.md" + )); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/literate_nav/parser.rs b/crates/zensical/src/compat/mkdocs/plugin/literate_nav/parser.rs new file mode 100644 index 0000000..98fd0a0 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/literate_nav/parser.rs @@ -0,0 +1,426 @@ +// 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. + +// ---------------------------------------------------------------------------- + +//! Native extraction of literate navigation lists from rendered HTML. + +use anyhow::{bail, Result}; +use html5gum::emitters::callback::{CallbackEmitter, CallbackEvent}; +use html5gum::{Span, Tokenizer}; +use std::collections::BTreeMap; +use std::convert::Infallible; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// One parsed literate-navigation item before path resolution. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Item { + /// Explicit Markdown link. + Reference { + title: Option, + target: String, + }, + /// Named section with ordered children. + Section { title: String, children: Vec }, + /// Bare wildcard pattern. + Wildcard(String), +} + +/// Minimal HTML node retained while extracting one Markdown list. +#[derive(Clone, Debug)] +enum Node { + Element(Element), + Text(String), + Comment, +} + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// One parsed HTML element. +#[derive(Clone, Debug)] +struct Element { + name: String, + attributes: BTreeMap, + children: Vec, +} + +/// Streaming tree builder for Python-Markdown's well-formed HTML. +#[derive(Default)] +struct Builder { + root: Vec, + stack: Vec, + pending: Option, + attribute: Option, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Builder { + /// Records one tokenizer event. + fn event(&mut self, event: CallbackEvent<'_>) { + match event { + CallbackEvent::OpenStartTag { name } => { + self.pending = Some(Element { + name: String::from_utf8_lossy(name).into_owned(), + attributes: BTreeMap::new(), + children: Vec::new(), + }); + self.attribute = None; + } + CallbackEvent::AttributeName { name } => { + self.attribute = + Some(String::from_utf8_lossy(name).into_owned()); + } + CallbackEvent::AttributeValue { value } => { + if let Some(name) = self.attribute.take() + && let Some(element) = &mut self.pending + { + element.attributes.insert( + name, + String::from_utf8_lossy(value).into_owned(), + ); + } + } + CallbackEvent::CloseStartTag { self_closing } => { + if let Some(element) = self.pending.take() { + if self_closing || is_void(&element.name) { + self.push(Node::Element(element)); + } else { + self.stack.push(element); + } + } + self.attribute = None; + } + CallbackEvent::EndTag { name } => { + let name = String::from_utf8_lossy(name); + if let Some(index) = + self.stack.iter().rposition(|element| element.name == name) + { + while self.stack.len() > index { + let element = self.stack.pop().expect("length checked"); + self.push(Node::Element(element)); + } + } + } + CallbackEvent::String { value } => { + let value = String::from_utf8_lossy(value).into_owned(); + if !value.is_empty() { + self.push(Node::Text(value)); + } + } + CallbackEvent::Comment { .. } => self.push(Node::Comment), + CallbackEvent::Doctype { .. } | CallbackEvent::Error(_) => {} + } + } + + /// Appends one node to the current parent. + fn push(&mut self, node: Node) { + let children = self + .stack + .last_mut() + .map_or(&mut self.root, |element| &mut element.children); + if let Node::Text(value) = &node + && let Some(Node::Text(previous)) = children.last_mut() + { + previous.push_str(value); + } else { + children.push(node); + } + } + + /// Completes any still-open elements. + fn finish(mut self) -> Vec { + while let Some(element) = self.stack.pop() { + self.push(Node::Element(element)); + } + self.root + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Extracts the captured root Markdown list. +pub fn parse(html: &str) -> Result>> { + let mut builder = Builder::default(); + { + let emitter = + CallbackEmitter::new(|event: CallbackEvent<'_>, _: Span| { + builder.event(event); + None:: + }); + Tokenizer::new_with_emitter(html, emitter) + .finish() + .expect("string input is infallible"); + } + let root = builder.finish(); + let mut nodes = root.iter().filter(|node| match node { + Node::Text(value) => !value.trim().is_empty(), + Node::Comment => false, + Node::Element(_) => true, + }); + let list = match nodes.next() { + None => return Ok(None), + Some(Node::Element(list)) => list, + Some(_) => bail!("captured literate navigation is not an element"), + }; + if !is_list(&list.name) { + bail!("captured literate navigation is not a list") + } + if nodes.next().is_some() { + bail!("captured literate navigation contains multiple root elements") + } + Ok(Some(parse_list(list)?)) +} + +/// Parses one generated list element. +fn parse_list(list: &Element) -> Result> { + let mut items = Vec::new(); + for node in &list.children { + match node { + Node::Element(element) if element.name == "li" => { + items.push(parse_item(element)?); + } + Node::Text(value) if value.trim().is_empty() => {} + Node::Comment => {} + _ => bail!("literate navigation lists may only contain items"), + } + } + Ok(items) +} + +/// Parses one list item using mkdocs-literate-nav's structural rules. +fn parse_item(item: &Element) -> Result { + let mut title = None; + let mut elements = Vec::new(); + let mut saw_element = false; + for node in &item.children { + match node { + Node::Text(value) if !saw_element => { + title.get_or_insert_with(String::new).push_str(value); + } + // Python-Markdown's serializer inserts formatting newlines around + // nested lists after the treeprocessor boundary used upstream. + Node::Text(value) if !value.trim().is_empty() => { + bail!( + "expected no text after an inline navigation element, but got {value:?}" + ) + } + Node::Element(element) => { + saw_element = true; + elements.push(element); + } + Node::Comment => { + saw_element = true; + } + Node::Text(_) => {} + } + } + + let mut elements = elements.into_iter(); + let mut target = None; + let first = elements.next(); + let next = if title.as_deref().is_none_or(str::is_empty) + && first.is_some_and(|element| element.name == "a") + { + let anchor = first.expect("checked above"); + if let Some(href) = anchor.attributes.get("href") + && !href.is_empty() + { + target = Some(href.clone()); + title = Some(text(anchor)); + } + elements.next() + } else { + first + }; + + let mut children = None; + let remaining = if next.is_some_and(|element| is_list(&element.name)) { + children = Some(parse_list(next.expect("checked above"))?); + elements.next() + } else { + next + }; + if let Some(element) = remaining.or_else(|| elements.next()) { + bail!("expected no more elements, but got <{}>", element.name) + } + + let Some(title) = title.filter(|title| !title.trim().is_empty()) else { + bail!("did not find any title specified") + }; + let title = decode_text(&title); + if let Some(mut children) = children { + if let Some(target) = target { + children.insert(0, Item::Reference { title: None, target }); + } + return Ok(Item::Section { title, children }); + } + if let Some(target) = target { + return Ok(Item::Reference { title: Some(title), target }); + } + if title.contains('*') { + return Ok(Item::Wildcard(title)); + } + bail!("did not find any item or section content specified") +} + +/// Collects decoded descendant text. +fn text(element: &Element) -> String { + fn collect(nodes: &[Node], output: &mut String) { + for node in nodes { + match node { + Node::Element(element) => collect(&element.children, output), + Node::Text(value) => output.push_str(value), + Node::Comment => {} + } + } + } + let mut output = String::new(); + collect(&element.children, &mut output); + decode_text(&output) +} + +/// Decodes the lossless text transport installed by the fragment renderer. +fn decode_text(value: &str) -> String { + const ESCAPE: char = '\u{f0000}'; + let mut input = value.chars(); + let mut output = String::with_capacity(value.len()); + while let Some(current) = input.next() { + if current != ESCAPE { + output.push(current); + continue; + } + match input.next() { + Some('A') => output.push('&'), + Some('S') | None => output.push(ESCAPE), + Some(next) => { + output.push(ESCAPE); + output.push(next); + } + } + } + output +} + +/// Returns whether the element is a Markdown list. +fn is_list(name: &str) -> bool { + matches!(name, "ul" | "ol") +} + +/// Returns whether an HTML element is void. +fn is_void(name: &str) -> bool { + matches!( + name, + "area" + | "base" + | "br" + | "col" + | "embed" + | "hr" + | "img" + | "input" + | "link" + | "meta" + | "param" + | "source" + | "track" + | "wbr" + ) +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::{parse, Item}; + + #[test] + fn extracts_the_captured_root_list() { + let html = + "
  1. Guide
"; + assert_eq!( + parse(html).unwrap(), + Some(vec![Item::Section { + title: "Guide".into(), + children: vec![Item::Reference { + title: Some("A".into()), + target: "a.md".into(), + }], + }]) + ); + } + + #[test] + fn retains_section_index_and_decodes_titles() { + let html = concat!( + "" + ); + assert_eq!( + parse(html).unwrap(), + Some(vec![Item::Section { + title: "A&B".into(), + children: vec![ + Item::Reference { + title: None, + target: "index.md".into(), + }, + Item::Wildcard("*.md".into()), + ], + }]) + ); + } + + #[test] + fn decodes_lossless_fragment_text() { + let html = ""; + assert_eq!( + parse(html).unwrap(), + Some(vec![Item::Reference { + title: Some("a&b".into()), + target: "a.md".into(), + }]) + ); + } + + #[test] + fn rejects_a_nested_list_without_a_section_title() { + let html = + "
  1. \n
"; + assert!(parse(html) + .unwrap_err() + .to_string() + .contains("did not find any title")); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/literate_nav/resolver.rs b/crates/zensical/src/compat/mkdocs/plugin/literate_nav/resolver.rs new file mode 100644 index 0000000..5b0e3c4 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/literate_nav/resolver.rs @@ -0,0 +1,615 @@ +// 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 NONINFRINGEMENT. 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. + +// ---------------------------------------------------------------------------- + +//! Folder navigation resolution and wildcard expansion. + +use anyhow::{Context, Result}; +use globset::Glob; +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, +}; +use crate::structure::page::Page; + +use super::parser::{self, Item}; +use super::Settings; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// Navigation entry before wildcard expansion. +#[derive(Clone, Debug)] +enum Entry { + /// Explicit page or external link. + Reference { + /// Optional display title supplied by configuration or Markdown. + title: Option, + /// Normalized source path or unchanged external target. + target: String, + }, + /// Named group of child entries. + Section { + /// Section display title. + title: String, + /// Entries nested below the section. + children: Vec, + }, + /// File or directory pattern expanded against the page catalog. + Wildcard { + /// Optional title wrapping all expanded matches. + title: Option, + /// Root-relative normalized component pattern. + pattern: String, + /// Original unresolved target retained when no match is usable. + fallback: Option, + }, + /// Folder link delegated to that folder's navigation document. + Directory { + /// Optional title wrapping the resolved folder contents. + title: Option, + /// Normalized folder root used during recursive resolution. + root: String, + /// Original link target retained when recursion is rejected. + fallback: String, + }, +} + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Ordered documentation file and directory index. +struct Catalog { + /// Page source paths in MkDocs navigation order. + files: Vec, + /// Ancestor directories in first-page occurrence order. + directories: Vec, + /// Directory membership index used for folder-link recognition. + directory_set: HashSet, + /// Preferred index source for each directory. + indexes: HashMap, +} + +/// One complete navigation resolution. +struct Resolver<'a> { + /// Immutable plugin and configured-navigation settings. + settings: &'a Settings, + /// Navigation Markdown keyed by canonical source-relative path. + documents: &'a BTreeMap, + /// Ordered pages and their derived directory facts. + files: Catalog, + /// Explicit or expanded paths already consumed by navigation. + seen: HashSet, + /// Recursive paths already reported during this resolution. + warned: HashSet, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Catalog { + /// Builds MkDocs-compatible ordered file and directory collections. + fn new(pages: &[Page]) -> Self { + let paths = pages + .iter() + .map(|page| page.source().clone()) + .collect::>(); + Self::from_paths(paths) + } + + /// Builds the catalog from validated source paths. + fn from_paths(mut paths: Vec) -> Self { + paths.sort_by_key(source_sort_key); + + let files = paths.iter().map(ToString::to_string).collect::>(); + let mut directories = Vec::new(); + let mut directory_set = HashSet::new(); + let mut indexes = HashMap::new(); + for path in &paths { + let mut parent = path + .parent() + .map_or_else(|| ".".into(), |path| path.to_string()); + if matches!(path.file_name(), "index.md" | "README.md") { + indexes.insert(parent.clone(), path.to_string()); + } + loop { + if directory_set.insert(parent.clone()) { + directories.push(parent.clone()); + } + if parent == "." { + break; + } + parent = parent_path(&parent); + } + } + Self { + files, + directories, + directory_set, + indexes, + } + } + + /// Returns whether a normalized path identifies a page ancestor. + fn is_dir(&self, path: &str) -> bool { + self.directory_set.contains(&normalize_directory(path)) + } + + /// Returns the preferred index source for one directory. + fn find_index(&self, root: &str) -> Option<&str> { + self.indexes + .get(&normalize_directory(root)) + .map(String::as_str) + } + + /// Returns file matches before directory matches, preserving source order. + fn matches(&self, pattern: &str) -> Result> { + let pattern = pattern.trim_end_matches('/'); + let expected = components(pattern); + let patterns = expected + .iter() + .map(|part| Glob::new(part).map(|glob| glob.compile_matcher())) + .collect::, _>>()?; + let candidates = self + .files + .iter() + .chain(&self.directories) + .filter(|candidate| { + let actual = components(candidate); + actual.len() == patterns.len() + && actual + .iter() + .zip(&patterns) + .all(|(part, matcher)| matcher.is_match(part)) + }) + .cloned() + .collect(); + Ok(candidates) + } +} + +impl<'a> Resolver<'a> { + /// Creates one isolated resolution over settled documents and pages. + fn new( + settings: &'a Settings, documents: &'a BTreeMap, + pages: &[Page], + ) -> Self { + Self { + settings, + documents, + files: Catalog::new(pages), + seen: HashSet::new(), + warned: HashSet::new(), + } + } + + /// Resolves root literate navigation or falls back to configured nav. + fn resolve(&mut self) -> Result { + let root_document = join(".", &self.settings.nav_file); + if self.settings.configured.is_empty() + || self.documents.contains_key(&root_document) + { + let items = self.markdown_to_nav(".", &[String::from(".")])?; + if !items.is_empty() { + return Ok(Plan::new(items)); + } + } + + let configured = self + .settings + .configured + .iter() + .map(|item| self.configured(item)) + .collect::>(); + Ok(Plan::new(self.expand(configured, &[String::from(".")])?)) + } + + /// Resolves one folder's Markdown list or inferred contents. + fn markdown_to_nav( + &mut self, root: &str, roots: &[String], + ) -> Result> { + let document_path = join(root, &self.settings.nav_file); + if let Some(content) = self.documents.get(&document_path) { + let html = render_literate_nav(content) + .with_context(|| format!("failed to render {document_path}"))?; + if let Some(items) = parser::parse(&html) + .with_context(|| format!("failed to parse {document_path}"))? + { + if !(self.settings.implicit_index + && self.files.find_index(root) == Some(&document_path)) + { + self.seen.insert(document_path); + } + let mut entries = Vec::new(); + if self.settings.implicit_index + && let Some(index) = self.files.find_index(root) + { + entries.push(Entry::Wildcard { + title: None, + pattern: index.to_owned(), + fallback: None, + }); + } + entries.extend( + items + .into_iter() + .map(|item| self.parsed(root, item)) + .collect::>(), + ); + return self.expand(entries, roots); + } + } + + let entries = vec![Entry::Wildcard { + title: None, + pattern: wildcard(root, "*", false), + fallback: None, + }]; + self.expand(entries, roots) + } + + /// Converts a parsed Markdown item and records explicit references. + fn parsed(&mut self, root: &str, item: Item) -> Entry { + match item { + Item::Reference { title, target } => { + self.reference(root, title, target) + } + Item::Section { title, children } => Entry::Section { + title, + children: children + .into_iter() + .map(|item| self.parsed(root, item)) + .collect(), + }, + Item::Wildcard(pattern) => Entry::Wildcard { + title: None, + pattern: wildcard(root, &pattern, true), + fallback: Some(pattern), + }, + } + } + + /// Converts configured MkDocs navigation and records explicit references. + fn configured(&mut self, item: &NavigationItem) -> Entry { + if !item.children.is_empty() { + return Entry::Section { + title: item.title.clone().unwrap_or_default(), + children: item + .children + .iter() + .map(|item| self.configured(item)) + .collect(), + }; + } + let target = item.url.clone().unwrap_or_default(); + if target.contains('*') { + Entry::Wildcard { + title: item.title.clone(), + pattern: wildcard("", &target, true), + fallback: Some(target), + } + } else if item.title.is_some() { + self.reference("", item.title.clone(), target) + } else { + self.seen.insert(target.clone()); + Entry::Reference { title: None, target } + } + } + + /// Converts a link into an external/page reference or folder insertion. + fn reference( + &mut self, root: &str, title: Option, target: String, + ) -> Entry { + if is_external(&target) { + return Entry::Reference { title, target }; + } + let absolute = join(root, &target); + self.seen.insert(absolute.clone()); + if target.ends_with('/') && self.files.is_dir(&absolute) { + Entry::Directory { + title, + root: normalize_directory(&absolute), + fallback: target, + } + } else { + Entry::Reference { title, target: absolute } + } + } + + /// Expands all wildcard and folder entries depth first. + fn expand( + &mut self, entries: Vec, roots: &[String], + ) -> Result> { + let mut resolved = Vec::new(); + for entry in entries { + match entry { + Entry::Reference { title, target } => { + resolved.push(PlanItem::reference(title, target)); + } + Entry::Section { title, children } => { + let children = self.expand(children, roots)?; + if !children.is_empty() { + resolved.push(PlanItem::section(title, children)); + } + } + Entry::Directory { title, root, fallback } => { + if roots.iter().any(|value| value == &root) { + self.warn_recursion(&root, roots); + resolved.push(PlanItem::reference(title, fallback)); + continue; + } + let mut next = Vec::with_capacity(roots.len() + 1); + next.push(root.clone()); + next.extend_from_slice(roots); + let children = self.markdown_to_nav(&root, &next)?; + if let Some(title) = title + && !children.is_empty() + { + resolved.push(PlanItem::section(title, children)); + } else if !children.is_empty() { + resolved.extend(children); + } + } + Entry::Wildcard { title, pattern, fallback } => { + let (mut expanded, matched) = + self.expand_wildcard(&pattern, roots)?; + let mut used_fallback = false; + if expanded.is_empty() + && (title.is_some() || !matched) + && let Some(fallback) = fallback + { + expanded.push(PlanItem::reference(None, fallback)); + used_fallback = true; + } + if let Some(title) = title { + if used_fallback + && expanded.len() == 1 + && let PlanItem::Reference { + title: item_title, .. + } = &mut expanded[0] + { + *item_title = Some(title); + resolved.extend(expanded); + continue; + } + if !expanded.is_empty() { + resolved.push(PlanItem::section(title, expanded)); + } + } else { + resolved.extend(expanded); + } + } + } + } + Ok(resolved) + } + + /// Reports each rejected recursive path once per navigation resolution. + fn warn_recursion(&mut self, root: &str, roots: &[String]) { + let mut path = Vec::with_capacity(roots.len() + 1); + path.push(root); + path.extend(roots.iter().map(String::as_str)); + path.reverse(); + let path = path + .into_iter() + .map(|item| format!("{item:?}")) + .collect::>() + .join(" -> "); + if self.warned.insert(path.clone()) { + eprintln!("WARNING - Disallowing recursion {path}"); + } + } + + /// Expands one pattern while excluding paths consumed by earlier entries. + fn expand_wildcard( + &mut self, pattern: &str, roots: &[String], + ) -> Result<(Vec, bool)> { + let mut resolved = Vec::new(); + let candidates = self.files.matches(pattern)?; + let any_match = !candidates.is_empty(); + for item in candidates { + if self.seen.contains(&item) { + continue; + } + if self.files.is_dir(&item) { + let mut next = Vec::with_capacity(roots.len() + 1); + next.push(item.clone()); + next.extend_from_slice(roots); + let children = self.markdown_to_nav(&item, &next)?; + if !children.is_empty() { + let title = to_title(item.rsplit('/').next().unwrap_or("")); + resolved.push(PlanItem::section(title, children)); + } + } else if pattern.ends_with('/') { + continue; + } else { + resolved.push(PlanItem::reference(None, item.clone())); + } + self.seen.insert(item); + } + Ok((resolved, any_match)) + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Resolves native literate navigation and attaches rendered page facts. +pub fn resolve( + settings: &Settings, documents: &BTreeMap, pages: &[Page], +) -> Result { + let plan = Resolver::new(settings, documents, pages).resolve()?; + Ok(plan.compile(pages.to_vec())) +} + +/// Joins and normalizes two POSIX paths while preserving a leading slash. +fn join(root: &str, target: &str) -> String { + let absolute = target.starts_with('/'); + let source = if absolute || root.is_empty() || root == "." { + target.to_owned() + } else if target.is_empty() { + root.to_owned() + } else { + format!("{root}/{target}") + }; + normalize(&source, absolute) +} + +/// Resolves dot segments without allowing absolute paths above their root. +fn normalize(source: &str, absolute: bool) -> String { + let mut output = Vec::new(); + for component in source.split('/') { + match component { + ".." if output.last().is_some_and(|item| *item != "..") => { + output.pop(); + } + ".." if !absolute => output.push(component), + "" | "." | ".." => {} + _ => output.push(component), + } + } + let output = output.join("/"); + if absolute { + format!("/{output}") + } else if output.is_empty() { + ".".into() + } else { + output + } +} + +/// Normalizes a path for comparison with source-relative directories. +fn normalize_directory(path: &str) -> String { + normalize(path.trim_start_matches('/'), false) +} + +/// Joins a wildcard to its folder and optionally retains a trailing slash. +fn wildcard(root: &str, pattern: &str, preserve_slash: bool) -> String { + let trailing = preserve_slash && pattern.ends_with('/'); + let value = normalize_directory(&join(root, pattern)); + if trailing { + format!("{value}/") + } else { + value + } +} + +/// Returns the normalized parent path, using `.` for the source root. +fn parent_path(path: &str) -> String { + path.rsplit_once('/').map_or(".".into(), |(parent, _)| { + if parent.is_empty() { + ".".into() + } else { + parent.into() + } + }) +} + +/// Splits a normalized path into meaningful source components. +fn components(path: &str) -> Vec<&str> { + path.trim_matches('/') + .split('/') + .filter(|part| !part.is_empty() && *part != ".") + .collect() +} + +/// Returns whether a link target has a URL scheme or network-path prefix. +fn is_external(target: &str) -> bool { + if target.starts_with("//") { + return true; + } + let Some((scheme, _)) = target.split_once(':') else { + return false; + }; + !scheme.is_empty() + && scheme.chars().enumerate().all(|(index, value)| { + value.is_ascii_alphabetic() + || (index > 0 + && (value.is_ascii_digit() + || matches!(value, '+' | '-' | '.'))) + }) +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::{is_external, join, wildcard, Catalog}; + + #[test] + fn catalogs_pages_in_navigation_order_and_prefers_index() { + let catalog = Catalog::from_paths( + [ + "guide/z.md", + "root.md", + "guide/README.md", + "guide/index.md", + "guide/a.md", + "index.md", + ] + .into_iter() + .map(|path| path.parse().unwrap()) + .collect(), + ); + + assert_eq!( + catalog.matches("guide/*.md").unwrap(), + [ + "guide/README.md", + "guide/index.md", + "guide/a.md", + "guide/z.md", + ] + ); + assert!(catalog.is_dir("guide/")); + assert_eq!(catalog.find_index("guide"), Some("guide/index.md")); + } + + #[test] + fn normalizes_links_and_wildcards() { + assert_eq!(join("guide", "../index.md"), "index.md"); + assert_eq!(join("guide", "/outside.md"), "/outside.md"); + assert_eq!(join(".", "../../page.md"), "../../page.md"); + assert_eq!(join("guide", "../../page.md"), "../page.md"); + assert_eq!(wildcard("guide", "../*.md", true), "*.md"); + assert_eq!(wildcard("guide", "sub/", true), "guide/sub/"); + } + + #[test] + fn detects_url_schemes_and_network_paths() { + assert!(is_external("https://example.com")); + assert!(is_external("mailto:test@example.com")); + assert!(is_external("//example.com/path")); + assert!(!is_external("guide/page.md")); + } +} diff --git a/crates/zensical/src/config/plugins.rs b/crates/zensical/src/config/plugins.rs index 19294ce..c1363df 100644 --- a/crates/zensical/src/config/plugins.rs +++ b/crates/zensical/src/config/plugins.rs @@ -62,12 +62,38 @@ pub struct Plugins { pub minify: MinifyPlugin, /// Material tags plugin instances. pub tags: TagsPlugin, + /// Literate navigation plugin. + pub literate_nav: LiterateNavPlugin, /// Offline plugin. pub offline: OfflinePlugin, } // ---------------------------------------------------------------------------- +/// Literate navigation plugin. +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct LiterateNavPlugin { + /// Plugin configuration. + pub config: LiterateNavPluginConfig, +} + +/// Literate navigation plugin configuration. +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct LiterateNavPluginConfig { + /// Whether literate navigation is enabled. + pub enabled: bool, + /// Folder-relative navigation file name or path. + pub nav_file: String, + /// Whether an omitted index is inserted first. + pub implicit_index: bool, + /// Markdown indentation width used for navigation lists. + pub tab_length: usize, +} + +// ---------------------------------------------------------------------------- + /// Material meta plugin. #[derive(Clone, Debug, Hash, FromPyObject, Serialize)] #[pyo3(from_item_all)] diff --git a/crates/zensical/src/structure/markdown.rs b/crates/zensical/src/structure/markdown.rs index 8934b89..24c5c22 100644 --- a/crates/zensical/src/structure/markdown.rs +++ b/crates/zensical/src/structure/markdown.rs @@ -27,7 +27,7 @@ use anyhow::Result; use pyo3::types::{PyAnyMethods, PyTracebackMethods}; -use pyo3::{FromPyObject, Python}; +use pyo3::{FromPyObject, PyErr, Python}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::ops::Deref; @@ -102,15 +102,7 @@ impl Markdown { .call_method1("render", (content, id.location(), url, meta))? .extract::() }) - .map_err(|err| { - Python::attach(|py| { - let traceback = err - .traceback(py) - .and_then(|tb| tb.format().ok()) - .unwrap_or_default(); - anyhow::anyhow!("Python error: {err}\n{traceback}") - }) - }); + .map_err(python_error); res.map(|data| { let mut data = MarkdownData { @@ -146,6 +138,30 @@ impl Markdown { } } +// ---------------------------------------------------------------------------- + +/// Extracts literate navigation with its plugin-local Markdown configuration. +#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))] +pub fn render_literate_nav(content: &str) -> Result { + Python::attach(|py| { + py.import("zensical.compat.literate_nav")? + .call_method1("render", (content,))? + .extract::() + }) + .map_err(python_error) +} + +/// Adds the formatted Python traceback to one boundary error. +fn python_error(err: PyErr) -> anyhow::Error { + Python::attach(|py| { + let traceback = err + .traceback(py) + .and_then(|tb| tb.format().ok()) + .unwrap_or_default(); + anyhow::anyhow!("Python error: {err}\n{traceback}") + }) +} + // ---------------------------------------------------------------------------- // Trait implementations // ---------------------------------------------------------------------------- diff --git a/crates/zensical/src/structure/nav.rs b/crates/zensical/src/structure/nav.rs index fb845db..5daf94c 100644 --- a/crates/zensical/src/structure/nav.rs +++ b/crates/zensical/src/structure/nav.rs @@ -40,10 +40,12 @@ use super::page::Page; mod item; mod iter; +mod plan; mod view; pub use item::NavigationItem; use iter::Iter; +pub use plan::{Plan, PlanItem}; pub use view::NavigationView; // ---------------------------------------------------------------------------- @@ -76,11 +78,15 @@ pub struct Navigation { impl Navigation { /// Creates a navigation from the given items. - pub fn new(mut items: Vec, pages: Vec) -> Self { + pub fn new(items: Vec, pages: Vec) -> Self { if items.is_empty() { return Self::from(pages); } + Self::from_plan(items, pages) + } + /// Creates navigation from an explicit plan, including an empty one. + fn from_plan(mut items: Vec, pages: Vec) -> Self { // 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 diff --git a/crates/zensical/src/structure/nav/plan.rs b/crates/zensical/src/structure/nav/plan.rs new file mode 100644 index 0000000..0189769 --- /dev/null +++ b/crates/zensical/src/structure/nav/plan.rs @@ -0,0 +1,155 @@ +// 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 plans before rendered page facts are attached. + +use super::{is_index, Navigation, NavigationItem}; +use crate::structure::page::Page; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// One unresolved navigation-plan item. +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub enum PlanItem { + /// Page reference or external URL. + Reference { + /// Optional explicit display title. + title: Option, + /// Source-relative page path or external URL. + target: String, + }, + /// Named navigation section. + Section { + /// Section display title. + title: String, + /// Ordered child items. + children: Vec, + }, +} + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Complete unresolved navigation plan. +#[derive(Clone, Debug, Default, Hash, PartialEq, Eq)] +pub struct Plan { + /// Ordered root items. + items: Vec, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Plan { + /// Creates a plan from ordered items. + pub fn new(items: Vec) -> Self { + Self { items } + } + + /// Attaches rendered page facts and creates the final navigation. + pub fn compile(self, pages: Vec) -> Navigation { + Navigation::from_plan( + self.items.into_iter().map(PlanItem::into_item).collect(), + pages, + ) + } +} + +impl PlanItem { + /// Creates a page or URL reference. + pub fn reference(title: Option, target: impl Into) -> Self { + Self::Reference { title, target: target.into() } + } + + /// Creates a named section. + pub fn section(title: impl Into, children: Vec) -> Self { + Self::Section { title: title.into(), children } + } + + /// Lowers one plan item into the existing navigation input shape. + fn into_item(self) -> NavigationItem { + match self { + Self::Reference { title, target } => NavigationItem { + title, + is_index: is_index(&target), + url: Some(target), + canonical_url: None, + meta: None, + children: Vec::new(), + active: false, + }, + Self::Section { title, children } => NavigationItem { + title: Some(title), + url: None, + canonical_url: None, + meta: None, + children: children + .into_iter() + .map(PlanItem::into_item) + .collect(), + is_index: false, + active: false, + }, + } + } +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::{Plan, PlanItem}; + + #[test] + fn lowers_references_sections_and_links() { + let navigation = Plan::new(vec![ + PlanItem::reference(None, "index.md"), + PlanItem::section( + "Guide", + vec![PlanItem::reference(Some("Start".into()), "guide.md")], + ), + PlanItem::reference(Some("Website".into()), "https://example.com"), + ]) + .compile(Vec::new()); + + 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_eq!( + navigation.items[1].children[0].title.as_deref(), + Some("Start") + ); + assert_eq!( + navigation.items[2].url.as_deref(), + Some("https://example.com") + ); + } +} diff --git a/crates/zensical/src/workflow.rs b/crates/zensical/src/workflow.rs index 7ebce11..812603c 100644 --- a/crates/zensical/src/workflow.rs +++ b/crates/zensical/src/workflow.rs @@ -44,7 +44,8 @@ use zrx::stream::{ use crate::compat::mkdocs::plugin::autorefs::UnresolvedAutorefs; use crate::compat::mkdocs::{ plugin::{ - self, autorefs, meta, minify, mkdocstrings, redirects, search, tags, + self, autorefs, literate_nav, meta, minify, mkdocstrings, redirects, + search, tags, }, resource, }; @@ -275,7 +276,12 @@ impl Main { ) }) }); - let nav = generate_nav(&self.config, &rendered_page); + let nav = literate_nav::LiterateNav::new(&self.config).setup( + literate_nav::Dependencies { + sources: &sources, + pages: &page, + }, + ); let autorefs_input = rendered_page.map(|rendered: &RenderedPage| autorefs::PageInput { source: rendered.page.source().clone(), @@ -507,22 +513,6 @@ fn generate_page( }) } -/// Derive navigation from the complete current page relation. -fn generate_nav( - config: &Config, pages: &Stream, -) -> Signal { - let config = config.clone(); - pages.reduce(move |pages: &dyn Collection, RenderedPage>| { - Some(Navigation::new( - config.project.nav.clone(), - pages - .values() - .map(|rendered| rendered.page.clone()) - .collect(), - )) - }) -} - /// Render static and extra templates. fn render_templates( config: &Config, files: &Stream, nav: &Signal, diff --git a/python/tests/integration/test_literate_nav.py b/python/tests/integration/test_literate_nav.py new file mode 100644 index 0000000..c9652ca --- /dev/null +++ b/python/tests/integration/test_literate_nav.py @@ -0,0 +1,371 @@ +# Copyright (c) 2025-2026 Zensical and contributors + +# SPDX-License-Identifier: MIT +# All contributions are certified under the DCO + +"""Integration tests for native mkdocs-literate-nav compatibility.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest +from bs4 import BeautifulSoup + +import zensical + +if TYPE_CHECKING: + from pathlib import Path + + +_BUILD_OPTIONS: dict[str, Any] = {"clean": False, "strict": False} + + +def _write_template(root: Path) -> None: + """Write a compact recursive navigation oracle.""" + overrides = root / "overrides" + overrides.mkdir() + (overrides / "main.html").write_text( + """\ +{% macro render(items, depth) %} +{% for item in items %} + +{{ render(item.children, depth + 1) }} +{% endfor %} +{% endmacro %} +{{ render(nav.items, 0) }} +""", + encoding="utf-8", + ) + + +def _items(root: Path) -> list[tuple[int, str, str]]: + """Extract the template's normalized navigation records.""" + output_path = root / "site" / "index.html" + if not output_path.exists(): + output_path = next((root / "site").rglob("*.html")) + output = output_path.read_text() + soup = BeautifulSoup(output, "html.parser") + return [ + ( + int(str(item["depth"])), + str(item["title"]), + str(item["url"]), + ) + for item in soup.find_all("item") + ] + + +def test_resolves_markers_nested_files_wildcards_and_external_links( + tmp_path: Path, +) -> None: + """The complete native pipeline reproduces a mixed literate nav.""" + docs = tmp_path / "docs" + api = docs / "guide" / "api" + api.mkdir(parents=True) + _write_template(tmp_path) + (docs / "index.md").write_text("# Home\n", encoding="utf-8") + (docs / "ignored.md").write_text("# Ignored\n", encoding="utf-8") + (docs / "SUMMARY.md").write_text( + """\ +* [Ignored before marker](ignored.md) + + + +* [Home](index.md) +* [Guide](guide/) +* [Project](https://example.com/project) +""", + encoding="utf-8", + ) + (docs / "guide" / "SUMMARY.md").write_text( + """\ +* [Overview](index.md) +* [Start](start.md) +* API + * api/*.md +* * +""", + encoding="utf-8", + ) + (docs / "guide" / "index.md").write_text( + "# Overview\n", encoding="utf-8" + ) + (docs / "guide" / "start.md").write_text( + "# Start\n", encoding="utf-8" + ) + (docs / "guide" / "advanced.md").write_text( + "# Advanced\n", encoding="utf-8" + ) + (api / "one.md").write_text("# One\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 _items(tmp_path) == [ + (0, "Home", ""), + (0, "Guide", ""), + (1, "Overview", "guide/"), + (1, "Start", "guide/start/"), + (1, "API", ""), + (2, "One", "guide/api/one/"), + (1, "Advanced", "guide/advanced/"), + (0, "Project", "https://example.com/project"), + ] + + +def test_resolves_configured_directory_through_nested_literate_nav( + tmp_path: Path, +) -> None: + """A titled directory in configured nav delegates to its own file.""" + docs = tmp_path / "docs" + guide = docs / "guide" + guide.mkdir(parents=True) + _write_template(tmp_path) + (docs / "index.md").write_text("# Home\n", encoding="utf-8") + (guide / "SUMMARY.md").write_text( + "* [Start](start.md)\n", encoding="utf-8" + ) + (guide / "start.md").write_text("# Start\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 +nav: + - Home: index.md + - Guide: guide/ +""", + encoding="utf-8", + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + assert _items(tmp_path) == [ + (0, "Home", ""), + (0, "Guide", ""), + (1, "Start", "guide/start/"), + ] + + +def test_preserves_entity_spellings_in_titles(tmp_path: Path) -> None: + """The HTML transport does not collapse distinct Markdown title text.""" + docs = tmp_path / "docs" + docs.mkdir() + _write_template(tmp_path) + (docs / "SUMMARY.md").write_text( + """\ +* [a&b](a.md) +* [a&b](b.md) +* [a&amp;b](c.md) +* [\\__init__](d.md) +* [\\`hi`](e.md) +""", + 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) + + output = next((tmp_path / "site").rglob("*.html")).read_text() + assert 'title="a&b"' in output + assert 'title="a&b"' in output + assert 'title="a&amp;b"' in output + assert 'title="__init__"' in output + assert 'title="`hi`"' in output + + +def test_marker_preserves_reference_definitions_from_the_complete_document( + tmp_path: Path, +) -> None: + """The marker changes list selection without isolating Markdown state.""" + docs = tmp_path / "docs" + docs.mkdir() + _write_template(tmp_path) + (docs / "SUMMARY.md").write_text( + """\ +[guide]: guide.md + +- [Ignored](ignored.md) + + +- [Earlier](ignored.md) + + +- [Guide][guide] + +Gap + +- [Later](ignored.md) +""", + encoding="utf-8", + ) + (docs / "guide.md").write_text("# Guide\n", encoding="utf-8") + (docs / "ignored.md").write_text("# Ignored\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 _items(tmp_path) == [(0, "Guide", "guide/")] + + +def test_applies_plugin_local_tab_length(tmp_path: Path) -> None: + """Plugin-local indentation controls the navigation Markdown parser.""" + docs = tmp_path / "docs" + docs.mkdir() + _write_template(tmp_path) + (docs / "SUMMARY.md").write_text( + "- Guide\n - [Start](start.md)\n", encoding="utf-8" + ) + (docs / "start.md").write_text("# Start\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: + tab_length: 2 +""", + encoding="utf-8", + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + assert _items(tmp_path) == [ + (0, "Guide", ""), + (1, "Start", "start/"), + ] + + +def test_directory_wildcards_do_not_consume_files(tmp_path: Path) -> None: + """A slash wildcard leaves files available to following wildcards.""" + docs = tmp_path / "docs" + section = docs / "section2" + section.mkdir(parents=True) + _write_template(tmp_path) + (docs / "SUMMARY.md").write_text( + "- */\n- *.md\n", encoding="utf-8" + ) + (docs / "item1.md").write_text("# Item 1\n", encoding="utf-8") + (docs / "item2.md").write_text("# Item 2\n", encoding="utf-8") + (section / "item.md").write_text("# Section item\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 _items(tmp_path) == [ + (0, "Section2", ""), + (1, "Section item", "section2/item/"), + (0, "Item 1", "item1/"), + (0, "Item 2", "item2/"), + ] + + +def test_explicitly_empty_literate_navigation_stays_empty( + tmp_path: Path, +) -> None: + """An exhausted wildcard must not reactivate automatic navigation.""" + docs = tmp_path / "docs" + docs.mkdir() + _write_template(tmp_path) + (docs / "SUMMARY.md").write_text("- *\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 _items(tmp_path) == [] + + +@pytest.mark.parametrize( + "summary", + [ + "* Empty section\n", + "* **[Obscured](page.md)**\n", + "* [First](first.md)[Second](second.md)\n", + "* [Page](page.md) trailing text\n", + "1. * [Item](section/item.md)\n", + "1. Section *one*\n * [Item](section/item.md)\n", + ], +) +def test_rejects_ambiguous_navigation_items( + tmp_path: Path, summary: str +) -> None: + """Invalid list items fail instead of producing surprising navigation.""" + docs = tmp_path / "docs" + docs.mkdir() + (docs / "SUMMARY.md").write_text(summary, encoding="utf-8") + config = tmp_path / "mkdocs.yml" + config.write_text( + """\ +site_name: Literate navigation +plugins: + - literate-nav +""", + encoding="utf-8", + ) + + with pytest.raises(RuntimeError): + zensical.build(str(config), _BUILD_OPTIONS) diff --git a/python/tests/unit/test_config.py b/python/tests/unit/test_config.py index 582dc2e..803f2f4 100644 --- a/python/tests/unit/test_config.py +++ b/python/tests/unit/test_config.py @@ -202,6 +202,54 @@ class TestPluginShimming: "redirect_maps": {}, } + @pytest.mark.parametrize("entry", ["literate-nav", {"literate-nav": None}]) + def test_literate_nav_presence_enables_defaults( + self, tmp_path: Path, entry: object + ) -> None: + config = self._parse_yaml(tmp_path, plugins=[entry]) + plugin = config["plugins"]["literate_nav"]["config"] + assert plugin == { + "enabled": True, + "nav_file": "SUMMARY.md", + "implicit_index": False, + "tab_length": 4, + "markdown_extensions": [], + "mdx_configs": {}, + } + + def test_literate_nav_is_disabled_when_absent( + self, tmp_path: Path + ) -> None: + config = self._parse_yaml(tmp_path, plugins=[]) + assert config["plugins"]["literate_nav"]["config"]["enabled"] is False + + def test_literate_nav_normalizes_local_markdown_extensions( + self, tmp_path: Path + ) -> None: + config = self._parse_yaml( + tmp_path, + plugins={ + "literate-nav": { + "nav_file": "NAV.md", + "implicit_index": True, + "tab_length": 2, + "markdown_extensions": [ + "abbr", + {"toc": {"permalink": False}}, + ], + } + }, + ) + plugin = config["plugins"]["literate_nav"]["config"] + assert plugin["nav_file"] == "NAV.md" + assert plugin["implicit_index"] is True + assert plugin["tab_length"] == 2 + assert plugin["markdown_extensions"] == ["abbr", "toc"] + assert plugin["mdx_configs"] == { + "abbr": {}, + "toc": {"permalink": False}, + } + def test_minify_plugin_is_normalized(self, tmp_path: Path) -> None: config = self._parse_yaml( tmp_path, diff --git a/python/zensical/compat/literate_nav.py b/python/zensical/compat/literate_nav.py new file mode 100644 index 0000000..aaade21 --- /dev/null +++ b/python/zensical/compat/literate_nav.py @@ -0,0 +1,142 @@ +# 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 NONINFRINGEMENT. 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. + +"""Narrow Python-Markdown adapter for native literate navigation.""" + +from __future__ import annotations + +from copy import deepcopy +from itertools import dropwhile +from typing import TYPE_CHECKING +from xml.etree import ElementTree + +from markdown import Markdown +from markdown.preprocessors import Preprocessor +from markdown.treeprocessors import Treeprocessor + +from zensical.config import get_config + +if TYPE_CHECKING: + from collections.abc import Callable + +# ---------------------------------------------------------------------------- +# Constants +# ---------------------------------------------------------------------------- + +# Private-use scalar framing text that must survive XML and HTML decoding. +_TEXT_ESCAPE = "\U000f0000" + +# ---------------------------------------------------------------------------- +# Classes +# ---------------------------------------------------------------------------- + + +class _MarkerPreprocessor(Preprocessor): + """Replace explicit navigation markers with a tree-visible placeholder.""" + + def __init__(self, md: Markdown): + super().__init__(md) + self.placeholder: str | None = None + + def run(self, lines: list[str]) -> list[str]: + for index, line in enumerate(lines): + if line.strip() == "": + self.placeholder = self.md.htmlStash.store("") + lines[index] = self.placeholder + "\n" + return lines + + +class _CaptureTreeprocessor(Treeprocessor): + """Capture the selected root list at literate-nav's processing phase.""" + + def __init__(self, md: Markdown, marker: _MarkerPreprocessor): + super().__init__(md) + self.marker = marker + self.nav: ElementTree.Element | None = None + + def run(self, root: ElementTree.Element) -> None: + if self.marker.placeholder is None: + candidates = reversed(root) + else: + candidates = dropwhile( + lambda element: element.text != self.marker.placeholder, + root, + ) + for element in candidates: + if element.tag in {"ul", "ol"}: + self.nav = deepcopy(element) + return + + +# ---------------------------------------------------------------------------- +# Functions +# ---------------------------------------------------------------------------- + + +def render(content: str) -> str: + """Capture literate navigation with its local Markdown configuration. + + This returns only the selected list subtree. Interpretation belongs to the + native compatibility module, keeping filesystem and navigation semantics + out of Python. + """ + plugin = get_config()["plugins"]["literate_nav"]["config"] + md = Markdown( + extensions=plugin["markdown_extensions"], + extension_configs=plugin["mdx_configs"], + tab_length=plugin["tab_length"], + ) + + # Parse the complete document so definitions and extension state before + # an explicit marker remain available to the selected navigation list. + # Keep inline HTML and entities in the captured tree instead of replacing + # them with placeholders that only a complete Markdown render can restore. + md.inlinePatterns.deregister("html", strict=False) + md.inlinePatterns.deregister("entity", strict=False) + marker = _MarkerPreprocessor(md) + capture = _CaptureTreeprocessor(md, marker) + md.preprocessors.register(marker, "zensical_literate_nav_marker", 25) + md.treeprocessors.register(capture, "zensical_literate_nav_capture", 19) + md.convert(content) + if capture.nav is None: + return "" + _encode_tree(capture.nav, md.treeprocessors["unescape"].unescape) + return ElementTree.tostring(capture.nav, encoding="unicode") + + +def _escape_text(value: str) -> str: + """Encode ampersands and the escape scalar without ambiguity.""" + return value.replace(_TEXT_ESCAPE, _TEXT_ESCAPE + "S").replace( + "&", _TEXT_ESCAPE + "A" + ) + + +def _encode_tree( + root: ElementTree.Element, unescape: Callable[[str], str] +) -> None: + """Preserve text across XML serialization and Rust's HTML tokenizer.""" + for element in root.iter(): + if element.text: + element.text = _escape_text(unescape(element.text)) + if element.tail: + element.tail = _escape_text(unescape(element.tail)) diff --git a/python/zensical/config.py b/python/zensical/config.py index a672575..8a286a2 100644 --- a/python/zensical/config.py +++ b/python/zensical/config.py @@ -31,7 +31,7 @@ import pickle from importlib.metadata import EntryPoint, entry_points from importlib.util import find_spec from pathlib import Path -from typing import IO, TYPE_CHECKING, Any +from typing import IO, TYPE_CHECKING, Any, cast from urllib.parse import urljoin, urlparse import yaml @@ -1246,6 +1246,50 @@ def _convert_markdown_extensions(value: Any) -> tuple[list[str], dict]: return mdx_exts, mdx_configs +def _convert_plugin_markdown_extensions( + value: Any, +) -> tuple[list[str], dict[str, dict[str, Any]]]: + """Normalize a plugin-local Python-Markdown configuration. + + Unlike the site renderer, plugin-local Markdown parsers do not inherit + Zensical's default extensions. This mirrors MkDocs' MarkdownExtensions + configuration option while retaining extension names and configuration in + Python, where callable values remain usable. + """ + markdown_extensions: list[str] = [] + mdx_configs: dict[str, dict[str, Any]] = {} + if value is None: + return markdown_extensions, mdx_configs + items: Any = value.items() if isinstance(value, dict) else value + for item in items: + if isinstance(item, tuple): + extension, extension_config = item + elif isinstance(item, dict): + if len(item) != 1: + raise ConfigurationError( + "Markdown extension mappings must contain one entry" + ) + extension, extension_config = next(iter(item.items())) + elif isinstance(item, str): + extension, extension_config = item, {} + else: + raise ConfigurationError( + "Markdown extensions must be strings or mappings" + ) + if not isinstance(extension, str): + raise ConfigurationError("Markdown extension names must be strings") + if extension_config is None: + extension_config = {} + if not isinstance(extension_config, dict): + raise ConfigurationError( + "Markdown extension configurations must be mappings" + ) + normalized_config = cast("dict[str, Any]", extension_config) + markdown_extensions.append(extension) + mdx_configs[extension] = normalized_config + return markdown_extensions, mdx_configs + + def _convert_plugins(value: Any, config: dict) -> dict: """Convert plugins configuration to something we can work with.""" plugins = {} @@ -1354,6 +1398,26 @@ def _convert_plugins(value: Any, config: dict) -> dict: minify["htmlmin_opts"] = htmlmin_opts plugins["minify"] = minify + # Normalize mkdocs-literate-nav without importing or executing the plugin. + # Python retains extension objects and callables for the narrow Markdown + # rendering boundary; Rust owns discovery and navigation resolution. + literate_nav: dict[str, Any] + if "literate-nav" not in plugins: + literate_nav = {"enabled": False} + else: + literate_nav_config = plugins.pop("literate-nav") + literate_nav = dict(literate_nav_config or {}) + set_default(literate_nav, "enabled", True, bool) + set_default(literate_nav, "nav_file", "SUMMARY.md", str) + set_default(literate_nav, "implicit_index", False, bool) + set_default(literate_nav, "tab_length", 4, int) + extensions, extension_configs = _convert_plugin_markdown_extensions( + literate_nav.get("markdown_extensions", []) + ) + literate_nav["markdown_extensions"] = extensions + literate_nav["mdx_configs"] = extension_configs + plugins["literate_nav"] = literate_nav + # Define defaults for offline plugin offline = set_default(plugins, "offline", {"enabled": False}, dict) set_default(offline, "enabled", True, bool) diff --git a/python/zensical/markdown/render.py b/python/zensical/markdown/render.py index fe43c5d..94632f8 100644 --- a/python/zensical/markdown/render.py +++ b/python/zensical/markdown/render.py @@ -35,10 +35,6 @@ from zensical.extensions.autorefs import set_autorefs_page from zensical.extensions.context import ContextExtension, Page from zensical.extensions.links import LinksExtension -# ---------------------------------------------------------------------------- -# Functions -# ---------------------------------------------------------------------------- - def render(content: str, path: str, url: str, metadata: str = "{}") -> dict: """Render Markdown and return HTML.