diff --git a/Cargo.lock b/Cargo.lock index 5b5a9fe..e100dbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -558,6 +558,28 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "icu_casemap" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "070f98b5b82798fcb93654bf96ed9f40064fc44c86f51a09ea711092cd5cc5be" +dependencies = [ + "icu_casemap_data", + "icu_collections", + "icu_locale_core", + "icu_properties", + "icu_provider", + "potential_utf", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_casemap_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "846b0857ca091204be3c874bc93daaf89d4777e8d2d20b0d3ffe8f671d98014b" + [[package]] name = "icu_collections" version = "2.2.0" @@ -566,6 +588,7 @@ checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "serde", "utf8_iter", "yoke", "zerofrom", @@ -580,6 +603,7 @@ checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", @@ -596,6 +620,9 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] @@ -633,6 +660,8 @@ checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -1458,6 +1487,8 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -2133,6 +2164,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -2274,6 +2306,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2498,6 +2536,12 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" version = "0.6.3" @@ -2544,6 +2588,9 @@ dependencies = [ "fluent-uri", "globset", "html5gum", + "icu_casemap", + "icu_locale_core", + "icu_normalizer", "minijinja", "minijinja-contrib", "mio", @@ -2653,6 +2700,7 @@ dependencies = [ "displaydoc", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -2661,6 +2709,7 @@ version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/Cargo.toml b/Cargo.toml index 3b90a2b..6923a2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ strip = "symbols" [workspace.package] edition = "2024" -rust-version = "1.86" +rust-version = "1.89" homepage = "https://github.com/zensical/zensical" documentation = "https://github.com/zensical/zensical" repository = "https://github.com/zensical/zensical" @@ -58,6 +58,9 @@ globset = "0.4.18" html5gum = { version = "0.8.4", default-features = false } httparse = "1.10" httpdate = "1.0" +icu_casemap = "2.2.0" +icu_locale_core = "2.2.0" +icu_normalizer = "2.2.0" indicatif = "0.18" matchit = "0.9.2" mio = "1.2.0" diff --git a/crates/zensical-serve/src/handler/matcher/route.rs b/crates/zensical-serve/src/handler/matcher/route.rs index 811b060..7511c76 100644 --- a/crates/zensical-serve/src/handler/matcher/route.rs +++ b/crates/zensical-serve/src/handler/matcher/route.rs @@ -110,7 +110,9 @@ impl FromStr for Route { /// /// # Errors /// - /// In case conversion fails, an [`Error`] is returned. + /// In case conversion fails, a [`matcher::Error`] is returned. + /// + /// [`matcher::Error`]: crate::handler::matcher::Error /// /// # Examples /// diff --git a/crates/zensical-serve/src/handler/stack/builder.rs b/crates/zensical-serve/src/handler/stack/builder.rs index 3dd6bbc..4b5881c 100644 --- a/crates/zensical-serve/src/handler/stack/builder.rs +++ b/crates/zensical-serve/src/handler/stack/builder.rs @@ -205,8 +205,9 @@ impl TryIntoHandler for Builder { /// Attempts to convert the stack into a handler. /// - /// This method is equivalent to calling [`Stack::try_into_middleware`] - /// with [`Scope::default`], scoping all middlewares to `/`. + /// This method is equivalent to calling + /// [`TryIntoMiddleware::try_into_middleware`] with [`Scope::default`], + /// scoping all middlewares to `/`. /// /// # Errors /// diff --git a/crates/zensical-serve/src/http/component/header.rs b/crates/zensical-serve/src/http/component/header.rs index 34cdd0c..d1def42 100644 --- a/crates/zensical-serve/src/http/component/header.rs +++ b/crates/zensical-serve/src/http/component/header.rs @@ -91,8 +91,8 @@ macro_rules! define_and_impl_header { /// While the HTTP specification allows those specific headers to appear /// multiple times, our implementation only supports setting them once. /// - /// [`Request`]: crate::connection::request::Request - /// [`Response`]: crate::connection::response::Response + /// [`Request`]: crate::http::Request + /// [`Response`]: crate::http::Response #[allow(dead_code)] #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum Header { diff --git a/crates/zensical-serve/src/middleware/files.rs b/crates/zensical-serve/src/middleware/files.rs index 7c65156..c48348d 100644 --- a/crates/zensical-serve/src/middleware/files.rs +++ b/crates/zensical-serve/src/middleware/files.rs @@ -124,15 +124,15 @@ impl Middleware for StaticFiles { // In case we can both extract the date from the header and the file // system lookup is successful, check if we can just return a 304 - if let (Some(date), Ok(meta)) = (header, fs::metadata(full)) { - if let Ok(mut last) = meta.modified() { - // Subtract one second to account for rounding issues - last -= Duration::from_secs(1); - if date >= last { - return Response::new() - .status(Status::NotModified) - .header(Header::ContentLength, 0); - } + if let (Some(date), Ok(meta)) = (header, fs::metadata(full)) + && let Ok(mut last) = meta.modified() + { + // Subtract one second to account for rounding issues + last -= Duration::from_secs(1); + if date >= last { + return Response::new() + .status(Status::NotModified) + .header(Header::ContentLength, 0); } } diff --git a/crates/zensical-serve/src/server.rs b/crates/zensical-serve/src/server.rs index 4f751e7..f34ce2d 100644 --- a/crates/zensical-serve/src/server.rs +++ b/crates/zensical-serve/src/server.rs @@ -137,13 +137,13 @@ where pub fn poll( &mut self, receiver: Option<&Receiver>, ) -> Result { - if let Err(err) = self.events.poll(Some(Duration::from_secs(10))) { - if !matches!( + if let Err(err) = self.events.poll(Some(Duration::from_secs(10))) + && !matches!( err, Error::Io(ref io) if io.kind() == ErrorKind::Interrupted - ) { - return Err(err); - } + ) + { + return Err(err); } // Check if we need to clean up timed out connections diff --git a/crates/zensical-watch/src/agent/manager.rs b/crates/zensical-watch/src/agent/manager.rs index 5b9af1d..4f60270 100644 --- a/crates/zensical-watch/src/agent/manager.rs +++ b/crates/zensical-watch/src/agent/manager.rs @@ -188,10 +188,10 @@ impl Manager { // inside a symbolic link. If the path can be canonicalized // and is different from the previous one, we replace it. Entry::Occupied(mut entry) => { - if let Ok(to) = fs::canonicalize(&path) { - if *entry.get() != to { - entry.insert(path); - } + if let Ok(to) = fs::canonicalize(&path) + && *entry.get() != to + { + entry.insert(path); } } } @@ -210,11 +210,11 @@ impl Manager { // the same file identifier in this iteration, we know that the // path was renamed, and we can coalesce the two events into a // single rename instead of a removal and creation - if let Some((id, _)) = self.paths.get(&path) { - if let Some(to) = changes.remove(id) { - results.append(&mut self.handle_rename(&to)); - return None; - } + if let Some((id, _)) = self.paths.get(&path) + && let Some(to) = changes.remove(id) + { + results.append(&mut self.handle_rename(&to)); + return None; } // The path does not point to a file or folder, and it's also @@ -254,10 +254,10 @@ impl Manager { // In case the event doesn't contain a path that is a symbolic link // itself, try to spread it to all symbolic links, if inside any for (i, result) in results.iter().enumerate() { - if let Ok(event) = result { - if event.kind() != Kind::Link { - inserts.push((i, self.spread(event))); - } + if let Ok(event) = result + && event.kind() != Kind::Link + { + inserts.push((i, self.spread(event))); } } @@ -275,10 +275,10 @@ impl Manager { // mentioned above, this must be done after spreading symbolic links, // or we'll end up with duplicate events in the result set. for (i, result) in results.iter().enumerate() { - if let Ok(event) = result { - if event.kind() == Kind::Link { - inserts.push((i, self.follow(event))); - } + if let Ok(event) = result + && event.kind() == Kind::Link + { + inserts.push((i, self.follow(event))); } } @@ -720,20 +720,20 @@ impl Manager { // emit a removal event for all paths inside the symbolic link. let target = Some(Ok(event.clone())); let mut iter = iter.peekable(); - if let Event::Rename { kind, from, .. } = event { - if iter.peek().is_none() { - let path = Arc::clone(from); + if let Event::Rename { kind, from, .. } = event + && iter.peek().is_none() + { + let path = Arc::clone(from); - // Create a temporary removal event, so we can spread it to all - // paths inside the symbolic link, and then return the original. - // It's easier to just reuse the removal business logic, as - // otherwise we'd need more code for an edge case. - let event = Event::Remove { kind: *kind, path }; - return target - .into_iter() - .chain(self.spread(&event).into_iter().skip(1)) - .collect(); - } + // Create a temporary removal event, so we can spread it to all + // paths inside the symbolic link, and then return the original. + // It's easier to just reuse the removal business logic, as + // otherwise we'd need more code for an edge case. + let event = Event::Remove { kind: *kind, path }; + return target + .into_iter() + .chain(self.spread(&event).into_iter().skip(1)) + .collect(); } // Collect results from iterator diff --git a/crates/zensical/Cargo.toml b/crates/zensical/Cargo.toml index 83fe708..800f0b5 100644 --- a/crates/zensical/Cargo.toml +++ b/crates/zensical/Cargo.toml @@ -52,6 +52,9 @@ crossbeam.workspace = true fluent-uri.workspace = true globset.workspace = true html5gum.workspace = true +icu_casemap.workspace = true +icu_locale_core.workspace = true +icu_normalizer.workspace = true minijinja = { workspace = true, features = [ "json", "loader", "builtins", "urlencode" ] } diff --git a/crates/zensical/src/compat/mkdocs/html.rs b/crates/zensical/src/compat/mkdocs/html.rs index 0b5bddd..b23140a 100644 --- a/crates/zensical/src/compat/mkdocs/html.rs +++ b/crates/zensical/src/compat/mkdocs/html.rs @@ -79,6 +79,11 @@ impl<'a> Editor<'a> { &self.input[range] } + /// Returns whether the original HTML contains a prospective marker. + pub fn contains(&self, value: &str) -> bool { + self.input.contains(value) + } + /// Replaces a byte range after all visitors have observed the input. pub fn replace( &mut self, range: Range, replacement: impl Into>, @@ -158,20 +163,20 @@ impl<'a> Editor<'a> { let mut edits: Vec = Vec::with_capacity(self.edits.len()); for edit in self.edits { - if let Some(previous) = edits.last() { - if edit.range.start < previous.range.end { - assert!( - edit.range.end <= previous.range.end, - "HTML edits partially overlap" + if let Some(previous) = edits.last() + && edit.range.start < previous.range.end + { + assert!( + edit.range.end <= previous.range.end, + "HTML edits partially overlap" + ); + if edit.range == previous.range { + assert_eq!( + edit.replacement, previous.replacement, + "HTML edits disagree on the same span" ); - if edit.range == previous.range { - assert_eq!( - edit.replacement, previous.replacement, - "HTML edits disagree on the same span" - ); - } - continue; } + continue; } edits.push(edit); } diff --git a/crates/zensical/src/compat/mkdocs/plugin.rs b/crates/zensical/src/compat/mkdocs/plugin.rs index 937d214..732f635 100644 --- a/crates/zensical/src/compat/mkdocs/plugin.rs +++ b/crates/zensical/src/compat/mkdocs/plugin.rs @@ -29,6 +29,7 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use crate::config::Config; +use crate::path::SourcePath; use crate::structure::markdown::Markdown; use super::html::{self, Visitor}; @@ -39,6 +40,7 @@ pub mod minify; pub mod mkdocstrings; pub mod redirects; pub mod search; +pub mod tags; // ---------------------------------------------------------------------------- // Structs @@ -51,17 +53,21 @@ pub struct HtmlFacts { pub autorefs: Arc, /// Page-local search sections. pub search: Arc, + /// Page-local tag mappings and listing slots. + pub tags: Arc, } // ---------------------------------------------------------------------------- /// Enabled MkDocs-compatible participants in the shared Markdown HTML pass. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub struct Settings { - /// Whether autorefs extraction and settlement are active. - pub autorefs: bool, - /// Whether search extraction is active. - search: bool, + /// MkDocs-compatible autorefs pipeline. + pub autorefs: Arc, + /// MkDocs-compatible search pipeline. + pub search: Arc, + /// Material tags compatibility pipeline. + pub tags: tags::Tags, } // ---------------------------------------------------------------------------- @@ -70,10 +76,11 @@ pub struct Settings { impl Settings { /// Derives active compatibility participants from resolved configuration. - pub fn new(config: &Config) -> Self { + pub fn new(config: &Config, serve: bool) -> Self { Self { - autorefs: autorefs::is_enabled(config), - search: config.project.plugins.search.config.enabled, + autorefs: Arc::new(autorefs::Autorefs::new(config)), + search: Arc::new(search::Search::new(config)), + tags: tags::Tags::new(config, serve), } } } @@ -83,34 +90,61 @@ impl Settings { // ---------------------------------------------------------------------------- /// Runs enabled MkDocs-compatible visitors in one page-local HTML pass. -pub fn prepare(markdown: &mut Markdown, settings: Settings) -> HtmlFacts { +pub fn prepare( + markdown: &mut Markdown, source: &SourcePath, settings: &Settings, +) -> anyhow::Result { let mut autorefs = autorefs::Parser::default(); + let defer_search_cleanup = + settings.search.is_enabled() && !settings.tags.is_empty(); let mut search = search::parser(&markdown.meta); - - let content = match (settings.search, settings.autorefs) { - (true, true) => { - let mut visitors: [&mut dyn Visitor; 2] = - [&mut search, &mut autorefs]; - html::scan(&markdown.content, &mut visitors) - } - (true, false) => html::scan(&markdown.content, &mut [&mut search]), - (false, true) => html::scan(&markdown.content, &mut [&mut autorefs]), - (false, false) => None, + if defer_search_cleanup { + search = search.retaining_directives(); + } + let mut tags = if settings.tags.is_empty() { + None + } else { + Some(tags::Parser::new(&settings.tags, source, &markdown.meta)?) }; + + // Compose enabled observers dynamically so adding a compatibility module + // doesn't grow an exhaustive Boolean match or another HTML traversal. + let mut visitors = Vec::<&mut dyn Visitor>::new(); + if settings.search.is_enabled() { + visitors.push(&mut search); + } + if settings.autorefs.is_enabled() { + visitors.push(&mut autorefs); + } + if let Some(parser) = &mut tags { + visitors.push(parser); + } + let content = (!visitors.is_empty()) + .then(|| html::scan(&markdown.content, &mut visitors)) + .flatten(); if let Some(content) = content { markdown.replace_content(content); } - HtmlFacts { - autorefs: if settings.autorefs { + let requires_search_cleanup = search.requires_cleanup(); + let mut tag_facts = match tags { + Some(parser) => parser.finish()?, + None => tags::Facts::default(), + }; + if requires_search_cleanup { + tag_facts.require_search_cleanup(); + } + + Ok(HtmlFacts { + autorefs: if settings.autorefs.is_enabled() { Arc::new(autorefs.finish()) } else { Arc::default() }, - search: if settings.search { + search: if settings.search.is_enabled() { search::finish(search) } else { Arc::default() }, - } + tags: Arc::new(tag_facts), + }) } diff --git a/crates/zensical/src/compat/mkdocs/plugin/autorefs.rs b/crates/zensical/src/compat/mkdocs/plugin/autorefs.rs index 01b79c9..02c88e2 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/autorefs.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/autorefs.rs @@ -29,10 +29,13 @@ use ahash::HashMap; use pyo3::types::PyAnyMethods; use pyo3::{FromPyObject, Python}; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; use std::string::ToString; use std::sync::Arc; -use zrx::stream::Value; +use zrx::id::Id; +use zrx::stream::function::Collection; +use zrx::stream::{Key, Signal, Stream, Value}; use crate::compat::mkdocs::html; use crate::config::Config; @@ -70,6 +73,36 @@ const EXTENSION_NAME: &str = "zensical.extensions.autorefs"; // Structs // ---------------------------------------------------------------------------- +/// MkDocs-compatible autorefs pipeline. +#[derive(Clone, Debug)] +pub struct Autorefs { + /// Whether autorefs extraction and settlement are active. + enabled: bool, + /// Cache directory containing external inventory facts. + cache: PathBuf, +} + +// ---------------------------------------------------------------------------- + +/// Inputs required to derive the revision-complete autorefs registry. +pub struct Dependencies<'a> { + /// Page-local autorefs registrations. + pub pages: &'a Stream, +} + +// ---------------------------------------------------------------------------- + +/// One page's registrations keyed by its documentation source. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PageInput { + /// Documentation-relative source used for deterministic ordering. + pub source: SourcePath, + /// Registrations produced while rendering the page. + pub facts: Arc, +} + +// ---------------------------------------------------------------------------- + /// Autoref identifiers that could not be resolved in a single page. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct UnresolvedAutorefs { @@ -81,7 +114,7 @@ pub struct UnresolvedAutorefs { /// Shared immutable registry used to resolve page-local autorefs. #[derive(Clone, Debug)] -pub struct Registry(Option>); +pub struct Registry(Option>); // ---------------------------------------------------------------------------- @@ -137,7 +170,7 @@ pub struct Facts { /// - No secondary URL mapped to an identifier? Try using absolute URLs /// (typically registered by loading inventories in mkdocstrings). #[derive(Clone, Debug, Default, PartialEq, Eq)] -struct Autorefs { +struct Resolver { // Primary URLs. primary: HashMap>, // Secondary URLs. @@ -153,6 +186,61 @@ struct Autorefs { // ---------------------------------------------------------------------------- impl Autorefs { + /// Resolves the private settings owned by this pipeline instance. + pub fn new(config: &Config) -> Self { + Self { + enabled: config.has_markdown_extension(EXTENSION_NAME), + cache: config.get_cache_dir(), + } + } + + /// Returns whether autorefs participates in page processing. + pub fn is_enabled(&self) -> bool { + self.enabled + } + + /// Installs revision-complete autorefs registry derivation. + pub fn setup( + &self, dependencies: Dependencies<'_>, + ) -> Signal { + let pipeline = self.clone(); + dependencies.pages.reduce( + move |pages: &dyn Collection, PageInput>| { + if !pipeline.enabled { + return Some(Registry(None)); + } + let mut pages = pages.values().cloned().collect::>(); + pages.sort_by_key(|page| source_sort_key(&page.source)); + let mut registry = Resolver::new(); + for page in pages { + registry.merge(&page.facts); + } + registry.inventory = inventory::load(&pipeline.cache); + Some(Registry(Some(Arc::new(registry)))) + }, + ) + } + + /// Takes registrations produced by the most recently rendered page. + pub fn take_page(&self, url: &str) -> Arc { + if !self.enabled { + return Arc::default(); + } + Arc::new( + Python::attach(|py| { + let module = py.import("zensical.extensions.autorefs")?; + module + .call_method1("get_autorefs_page_data", (url,))? + .extract::() + }) + .unwrap_or_default(), + ) + } +} + +// ---------------------------------------------------------------------------- + +impl Resolver { /// Creates a new, empty autorefs. pub fn new() -> Self { Self::default() @@ -448,48 +536,16 @@ impl Value for Registry {} // ---------------------------------------------------------------------------- +impl Value for PageInput {} + +// ---------------------------------------------------------------------------- + impl Value for UnresolvedAutorefs {} // ---------------------------------------------------------------------------- // Functions // ---------------------------------------------------------------------------- -/// Assemble a complete immutable registry from settled page-local facts. -pub fn assemble( - config: &Config, mut facts: Vec<(SourcePath, Arc)>, -) -> Registry { - if !is_enabled(config) { - return Registry(None); - } - - facts.sort_by_key(|(source, _)| source_sort_key(source)); - - let mut registry = Autorefs::new(); - for (_, facts) in facts { - registry.merge(&facts); - } - registry.inventory = inventory::load(&config.get_cache_dir()); - Registry(Some(Arc::new(registry))) -} - -/// Returns whether autorefs is active after configuration shims are applied. -pub fn is_enabled(config: &Config) -> bool { - config.has_markdown_extension(EXTENSION_NAME) -} - -/// Take registrations produced by the most recently rendered page. -pub fn take_page(url: &str) -> Arc { - Arc::new( - Python::attach(|py| { - let module = py.import("zensical.extensions.autorefs")?; - module - .call_method1("get_autorefs_page_data", (url,))? - .extract::() - }) - .unwrap_or_default(), - ) -} - /// Merge URL lists while preserving registration order and uniqueness. fn merge_url_map( target: &mut HashMap>, @@ -524,7 +580,7 @@ mod tests { use crate::compat::mkdocs::html; - use super::{Autorefs, Facts, Parser, References}; + use super::{Facts, Parser, References, Resolver}; fn prepare(input: &str) -> (String, References) { let mut parser = Parser::default(); @@ -535,7 +591,7 @@ mod tests { #[test] fn page_facts_merge_without_overwriting_shared_identifiers() { - let mut autorefs = Autorefs::new(); + let mut autorefs = Resolver::new(); autorefs.merge(&Facts { primary: HashMap::from_iter([( "shared".to_string(), @@ -556,7 +612,7 @@ mod tests { #[test] fn unresolved_autorefs_are_collected_while_replacing() { - let mut autorefs = Autorefs::new(); + let mut autorefs = Resolver::new(); autorefs .primary .insert("known".to_string(), vec!["reference/#known".to_string()]); @@ -581,7 +637,7 @@ mod tests { #[test] fn cached_slots_preserve_autoref_rendering_contract() { - let mut autorefs = Autorefs::new(); + let mut autorefs = Resolver::new(); autorefs .primary .insert("known".to_string(), vec!["reference/#known".to_string()]); @@ -613,7 +669,7 @@ mod tests { #[test] fn slug_is_used_as_a_resolution_fallback() { - let mut autorefs = Autorefs::new(); + let mut autorefs = Resolver::new(); autorefs.primary.insert( "foo-bar".to_string(), vec!["reference/#foo-bar".to_string()], diff --git a/crates/zensical/src/compat/mkdocs/plugin/meta.rs b/crates/zensical/src/compat/mkdocs/plugin/meta.rs index 9714b67..6d61056 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/meta.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/meta.rs @@ -31,6 +31,7 @@ use std::collections::BTreeMap; use std::hash::Hash; use std::ops::Range; use std::path::Path; +use std::sync::Arc; use crate::config::Config; use crate::path::{SourcePath, SourceRoot}; @@ -71,6 +72,25 @@ enum Value { // Structs // ---------------------------------------------------------------------------- +/// MkDocs Material metadata pipeline. +#[derive(Clone, Debug)] +pub struct Meta { + /// Immutable settings shared with provider admission and page resolution. + settings: Arc, +} + +// ---------------------------------------------------------------------------- + +/// Inputs required to install metadata admission. +pub struct Dependencies { + /// Documentation root used to resolve metadata descendants. + pub docs: SourceRoot, + /// Provider context whose metadata changes are admitted. + pub context: String, +} + +// ---------------------------------------------------------------------------- + /// Metadata plugin settings used by the workflow. #[derive(Clone, Debug)] pub struct Settings { @@ -135,6 +155,31 @@ pub struct Index { // Implementations // ---------------------------------------------------------------------------- +impl Meta { + /// Resolves the private settings owned by this pipeline instance. + pub fn new(config: &Config) -> Self { + Self { + settings: Arc::new(Settings::new(config)), + } + } + + /// Installs the provider-side metadata admission boundary. + pub fn setup(&self, dependencies: Dependencies) -> Admission { + Admission::new( + dependencies.docs, + dependencies.context, + self.settings.clone(), + ) + } + + /// Returns the immutable settings shared with resource classification. + pub(crate) fn settings(&self) -> &Settings { + &self.settings + } +} + +// ---------------------------------------------------------------------------- + impl Settings { /// Extracts native meta settings from resolved configuration. pub fn new(config: &Config) -> Self { diff --git a/crates/zensical/src/compat/mkdocs/plugin/minify.rs b/crates/zensical/src/compat/mkdocs/plugin/minify.rs index 39f564e..71a6268 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/minify.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/minify.rs @@ -26,43 +26,83 @@ //! MkDocs-compatible minify plugin. use std::path::Path; +use std::sync::Arc; + +use zrx::id::Id; +use zrx::stream::{Signal, Stream}; use crate::config::plugins::MinifyPluginConfig; -use crate::config::Config; +use crate::config::{Config, Project}; +use crate::path::OutputRoot; -pub mod asset; +use super::super::resource::Resource; + +mod asset; mod html; mod script; mod style; +pub use asset::Manifest; + // ---------------------------------------------------------------------------- // Structs // ---------------------------------------------------------------------------- -/// Resolved minification settings shared by page render tasks. +/// MkDocs-compatible minify pipeline. #[derive(Clone, Debug)] -pub struct Settings { +pub struct Minify { /// Normalized MkDocs-compatible minification configuration. config: MinifyPluginConfig, + /// Site output directory shared with the asset writer. + output: OutputRoot, + /// Template-visible project configuration projected by asset mappings. + project: Arc, +} + +// ---------------------------------------------------------------------------- + +/// Inputs required to derive and write effective assets. +pub struct Dependencies<'a> { + /// Resources after MkDocs source precedence has been resolved. + pub resources: &'a Stream, } // ---------------------------------------------------------------------------- // Implementations // ---------------------------------------------------------------------------- -impl Settings { - /// Resolves minification settings from project configuration. +impl Minify { + /// Resolves the private settings owned by this pipeline instance. pub fn new(config: &Config) -> Self { Self { config: config.project.plugins.minify.config.clone(), + output: config.output_root().clone(), + project: config.project.clone(), } } + /// Installs asset transformation, output, and manifest derivation. + pub fn setup( + &self, dependencies: Dependencies<'_>, + ) -> Signal { + asset::setup(self, dependencies.resources) + } + /// Returns the normalized plugin configuration for the asset stage. - pub fn config(&self) -> &MinifyPluginConfig { + pub(super) fn config(&self) -> &MinifyPluginConfig { &self.config } + /// Returns the site output directory owned by this pipeline. + pub(super) fn output(&self) -> &OutputRoot { + &self.output + } + + /// Returns the template-visible project configuration. + pub(super) fn project(&self) -> &Arc { + &self.project + } + /// Processes one final HTML document after all compatibility mutations. /// /// External asset options stay in the resolved plugin configuration for diff --git a/crates/zensical/src/compat/mkdocs/plugin/minify/asset.rs b/crates/zensical/src/compat/mkdocs/plugin/minify/asset.rs index c9a55de..5cc7bb8 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/minify/asset.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/minify/asset.rs @@ -38,12 +38,11 @@ use zrx::stream::{Key, Signal, Stream, Value}; use crate::compat::mkdocs::resource::Resource; use crate::config::plugins::MinifyPluginConfig; -use crate::config::{Config, Project}; +use crate::config::Project; use crate::path::SitePath; use crate::watcher::Source; -use super::Settings as PluginSettings; -use super::{script, style}; +use super::{script, style, Minify}; mod selector; mod writer; @@ -337,8 +336,8 @@ impl Value for Manifest {} /// Transforms selected resources, writes every effective asset, and publishes /// the project view whose configured asset paths name the emitted files. -pub fn attach( - config: &Config, plugin: &PluginSettings, resources: &Stream, +pub(super) fn setup( + plugin: &Minify, resources: &Stream, ) -> Signal { let settings = Settings::new(plugin.config()); let settings_for_transform = settings.clone(); @@ -349,9 +348,9 @@ pub fn attach( let outputs = emissions.unique_by_key(move |emission: &Emission| { output_key(&emission.output_path) }); - writer::attach(config.output_root().clone(), &outputs); + writer::setup(plugin.output().clone(), &outputs); - let project = config.project.clone(); + let project = plugin.project().clone(); let settings_for_manifest = settings; outputs .filter(|emission: &Emission| emission.claimed) diff --git a/crates/zensical/src/compat/mkdocs/plugin/minify/asset/writer.rs b/crates/zensical/src/compat/mkdocs/plugin/minify/asset/writer.rs index c754c49..b522e97 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/minify/asset/writer.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/minify/asset/writer.rs @@ -117,8 +117,8 @@ impl Action> for Writer { // Functions // ---------------------------------------------------------------------------- -/// Attaches a removal-aware writer to the emission relation. -pub fn attach(output: OutputRoot, emissions: &Stream) { +/// Installs a removal-aware writer for the emission relation. +pub fn setup(output: OutputRoot, emissions: &Stream) { let _ = emissions.subscribe(Writer { output }); } diff --git a/crates/zensical/src/compat/mkdocs/plugin/mkdocstrings.rs b/crates/zensical/src/compat/mkdocs/plugin/mkdocstrings.rs index 521f68e..04c7999 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/mkdocstrings.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/mkdocstrings.rs @@ -28,41 +28,74 @@ use pyo3::types::PyAnyMethods; use pyo3::Python; use std::fs; +use std::path::PathBuf; use zrx::id::Id; use zrx::stream::Signal; use crate::config::Config; -use crate::path::SitePath; +use crate::path::{OutputRoot, SitePath}; use crate::structure::nav::Navigation; // ---------------------------------------------------------------------------- -// Functions +// Structs // ---------------------------------------------------------------------------- -/// Attach object inventory generation to the settled navigation stream. -pub fn attach(config: &Config, nav: &Signal) { - let config = config.clone(); - let _ = nav.map(move |_: &Navigation| { - let cache_dir = config.get_cache_dir(); - let cache_path = cache_dir.join("objects.inv"); - let cached = fs::read(&cache_path).ok(); +/// Mkdocstrings compatibility pipeline. +#[derive(Clone, Debug)] +pub struct Mkdocstrings { + /// Cache directory shared with the Python compatibility layer. + cache: PathBuf, + /// Site output directory. + output: OutputRoot, +} - let data = Python::attach(|py| { - let module = py.import("zensical.compat.mkdocstrings")?; - module - .call_method1("get_inventory", (cached,))? - .extract::>() - }); +// ---------------------------------------------------------------------------- + +/// Inputs required to generate the object inventory. +pub struct Dependencies<'a> { + /// Revision-complete site navigation. + pub navigation: &'a Signal, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- - if let Ok(data) = data { - let path = config.output_root().join( - &"objects.inv".parse::().expect("static site path"), - ); - let _ = fs::create_dir_all(path.parent().expect("invariant")); - let _ = fs::write(path, &data); - let _ = fs::create_dir_all(&cache_dir); - let _ = fs::write(&cache_path, &data); +impl Mkdocstrings { + /// Resolves the private settings owned by this pipeline instance. + pub fn new(config: &Config) -> Self { + Self { + cache: config.get_cache_dir(), + output: config.output_root().clone(), } - }); + } + + /// Installs object inventory generation. + pub fn setup(&self, dependencies: Dependencies<'_>) { + let pipeline = self.clone(); + let _ = dependencies.navigation.map(move |_: &Navigation| { + let cache_path = pipeline.cache.join("objects.inv"); + let cached = fs::read(&cache_path).ok(); + + let data = Python::attach(|py| { + let module = py.import("zensical.compat.mkdocstrings")?; + module + .call_method1("get_inventory", (cached,))? + .extract::>() + }); + + if let Ok(data) = data { + let path = pipeline.output.join( + &"objects.inv" + .parse::() + .expect("static site path"), + ); + let _ = fs::create_dir_all(path.parent().expect("invariant")); + let _ = fs::write(path, &data); + let _ = fs::create_dir_all(&pipeline.cache); + let _ = fs::write(&cache_path, &data); + } + }); + } } diff --git a/crates/zensical/src/compat/mkdocs/plugin/search.rs b/crates/zensical/src/compat/mkdocs/plugin/search.rs index 94d8b2e..8cc930e 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/search.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/search.rs @@ -33,11 +33,12 @@ use std::sync::Arc; use zrx::id::Id; use zrx::scheduler::Value; -use zrx::stream::Signal; +use zrx::stream::function::Collection; +use zrx::stream::{Key, Signal, Stream}; use crate::config::plugins::SearchPluginConfig; use crate::config::Config; -use crate::path::{SitePath, SourcePath}; +use crate::path::{OutputRoot, SitePath, SourcePath}; use crate::structure::dynamic::Dynamic; use crate::structure::nav::{source_sort_key, Navigation}; use crate::structure::page::Page; @@ -52,6 +53,31 @@ use parser::Parser; // Structs // ---------------------------------------------------------------------------- +/// MkDocs-compatible search pipeline. +#[derive(Clone, Debug)] +pub struct Search { + /// Normalized tokenizer configuration. + config: SearchPluginConfig, + /// Theme language used by the tokenizer. + language: String, + /// Whether the offline JavaScript index is emitted. + offline: bool, + /// Site output directory. + output: OutputRoot, +} + +// ---------------------------------------------------------------------------- + +/// Inputs required to derive and write search artifacts. +pub struct Dependencies<'a> { + /// Page-local search documents. + pub documents: &'a Stream, + /// Revision-complete site navigation. + pub navigation: &'a Signal, +} + +// ---------------------------------------------------------------------------- + /// Search configuration. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] struct SearchConfig { @@ -92,19 +118,79 @@ pub struct Document { facts: Arc, } -/// Revision-aligned search inputs from the site settlement boundary. +/// Complete current search-document relation at the artifact boundary. #[derive(Clone, Debug)] -pub struct Snapshot { - /// Compact page documents. - documents: Arc>, - /// Navigation from the same page revision. - nav: Navigation, -} +struct Documents(Arc>); // ---------------------------------------------------------------------------- // Implementations // ---------------------------------------------------------------------------- +impl Search { + /// Resolves the private settings owned by this pipeline instance. + pub fn new(config: &Config) -> Self { + Self { + config: config.project.plugins.search.config.clone(), + language: config.project.theme.language.clone(), + offline: config.project.plugins.offline.config.enabled, + output: config.output_root().clone(), + } + } + + /// Returns whether search extraction participates in page processing. + pub fn is_enabled(&self) -> bool { + self.config.enabled + } + + /// Installs revision-complete search artifact generation. + pub fn setup(&self, dependencies: Dependencies<'_>) { + let documents = collect_documents(dependencies.documents); + let search = self.clone(); + let _ = documents.product(dependencies.navigation).map( + move |documents: &Documents, navigation: &Navigation| { + let documents = if search.config.enabled { + documents.0.as_ref().clone() + } else { + Vec::new() + }; + let index = SearchIndex::new( + documents, + navigation, + search.config.clone(), + &search.language, + ); + search.write(&index) + }, + ); + } + + /// Writes search artifacts without retaining serialized copies. + fn write(&self, index: &SearchIndex) -> anyhow::Result<()> { + let path = self.output.join( + &"search.json".parse::().expect("static site path"), + ); + fs::create_dir_all(path.parent().expect("invariant"))?; + let mut writer = BufWriter::new(fs::File::create(path)?); + serde_json::to_writer(&mut writer, index)?; + writer.flush()?; + + if self.offline { + let path = self.output.join( + &"search.js".parse::().expect("static site path"), + ); + fs::create_dir_all(path.parent().expect("invariant"))?; + let mut writer = BufWriter::new(fs::File::create(path)?); + writer.write_all(b"var __index = ")?; + serde_json::to_writer(&mut writer, index)?; + writer.write_all(b";")?; + writer.flush()?; + } + Ok(()) + } +} + +// ---------------------------------------------------------------------------- + impl SearchConfig { /// Creates search configuration for the configured theme language. fn new(config: SearchPluginConfig, language: &str) -> Self { @@ -132,18 +218,6 @@ impl Document { // ---------------------------------------------------------------------------- -impl Snapshot { - /// Creates a search snapshot without another site-wide reduction. - pub fn new(documents: Vec, nav: Navigation) -> Self { - Self { - documents: Arc::new(documents), - nav, - } - } -} - -// ---------------------------------------------------------------------------- - impl Facts { /// Returns whether this page contributes anything to the search index. pub fn is_empty(&self) -> bool { @@ -219,29 +293,19 @@ impl SearchIndex { // ---------------------------------------------------------------------------- impl Value for Document {} -impl Value for Snapshot {} +impl Value for Documents {} // ---------------------------------------------------------------------------- // Functions // ---------------------------------------------------------------------------- -/// Attach MkDocs-compatible search artifact generation to the build graph. -pub fn attach(config: &Config, snapshot: &Signal) { - let config = config.clone(); - let _ = snapshot.map(move |snapshot: &Snapshot| { - let documents = if config.project.plugins.search.config.enabled { - snapshot.documents.as_ref().clone() - } else { - Vec::new() - }; - let search = SearchIndex::new( - documents, - &snapshot.nav, - config.project.plugins.search.config.clone(), - &config.project.theme.language, - ); - write(&config, &search) - }); +/// Collects the current document relation at its artifact boundary. +fn collect_documents( + documents: &Stream, +) -> Signal { + documents.reduce(|documents: &dyn Collection, Document>| { + Some(Documents(Arc::new(documents.values().cloned().collect()))) + }) } /// Creates the page-local search visitor. @@ -258,31 +322,6 @@ pub fn finish(parser: Parser) -> Arc { Arc::new(Facts { sections: parser.finish() }) } -/// Write search artifacts without retaining a second serialized copy. -fn write(config: &Config, search: &SearchIndex) -> anyhow::Result<()> { - let output_root = config.output_root(); - let path = output_root - .join(&"search.json".parse::().expect("static site path")); - fs::create_dir_all(path.parent().expect("invariant"))?; - let mut writer = BufWriter::new(fs::File::create(path)?); - serde_json::to_writer(&mut writer, search)?; - writer.flush()?; - - // Offline mode embeds the same index in the JavaScript global expected by - // the theme instead of changing its schema. - if config.project.plugins.offline.config.enabled { - let path = output_root - .join(&"search.js".parse::().expect("static site path")); - fs::create_dir_all(path.parent().expect("invariant"))?; - let mut writer = BufWriter::new(fs::File::create(path)?); - writer.write_all(b"var __index = ")?; - serde_json::to_writer(&mut writer, search)?; - writer.write_all(b";")?; - writer.flush()?; - } - Ok(()) -} - /// Returns whether a page is excluded from search through its metadata. fn is_search_excluded(meta: &BTreeMap) -> bool { let Some(Dynamic::Map(search)) = meta.get("search") else { @@ -298,10 +337,73 @@ fn is_search_excluded(meta: &BTreeMap) -> bool { #[cfg(test)] mod tests { use std::collections::BTreeMap; + use std::sync::Arc; + use zrx::id::Id; + use zrx::stream::function::Collection; + use zrx::stream::{Change, Key, Run, Workflow}; + + use crate::path::SourcePath; use crate::structure::dynamic::Dynamic; - use super::is_search_excluded; + use super::{ + collect_documents, is_search_excluded, Document, Documents, Facts, + }; + + fn document(source: &str, title: &str) -> Document { + Document { + source: source.parse::().unwrap(), + url: source.replace(".md", ".html"), + title: title.to_owned(), + tags: Vec::new(), + facts: Arc::new(Facts::default()), + } + } + + fn key(location: &str) -> Key { + Key::from( + Id::builder() + .provider("test") + .context("search") + .location(location) + .build() + .unwrap(), + ) + } + + fn snapshot(run: &mut Run) -> (Vec, usize, usize) { + let changes = run + .output::<((Documents, usize), usize)>() + .unwrap() + .collect::>(); + let [Change::Insert(_, ((documents, count), title_bytes))] = + changes.as_slice() + else { + panic!("expected one coherent search snapshot, got {changes:?}"); + }; + let mut titles = documents + .0 + .iter() + .map(|document| document.title.clone()) + .collect::>(); + titles.sort(); + (titles, *count, *title_bytes) + } + + fn rendered(run: &mut Run) -> Vec> { + let mut changes = run + .output::<((Document, usize), usize)>() + .unwrap() + .map(|change| match change { + Change::Insert(_, ((document, count), title_bytes)) => { + Some((document.title, count, title_bytes)) + } + Change::Remove(_) => None, + }) + .collect::>(); + changes.sort(); + changes + } #[test] fn search_exclusion_is_read_from_page_metadata() { @@ -315,4 +417,67 @@ mod tests { meta.clear(); assert!(!is_search_excluded(&meta)); } + + #[test] + fn page_relations_converge_with_sibling_site_facts() { + let workflow = Workflow::::build(|workflow| { + let source = workflow.input::(); + let documents = collect_documents(&source); + let count = source.reduce( + |documents: &dyn Collection, Document>| { + Some(documents.len()) + }, + ); + let title_bytes = source.reduce( + |documents: &dyn Collection, Document>| { + Some( + documents + .values() + .map(|document| document.title.len()) + .sum::(), + ) + }, + ); + workflow.output(&documents.product(&count).product(&title_bytes)); + workflow.output(&source.product(&count).product(&title_bytes)); + }); + let mut runner = workflow.runner().unwrap(); + let input = runner.input::().unwrap(); + + let mut revision = input.begin().unwrap(); + revision + .insert(key("a.md"), document("a.md", "Alpha")) + .unwrap(); + revision + .insert(key("b.md"), document("b.md", "Beta")) + .unwrap(); + let input = revision.seal().unwrap(); + let mut run = runner.settle().unwrap(); + assert_eq!( + snapshot(&mut run), + (vec![String::from("Alpha"), String::from("Beta")], 2, 9) + ); + assert_eq!( + rendered(&mut run), + [ + Some((String::from("Alpha"), 2, 9)), + Some((String::from("Beta"), 2, 9)), + ] + ); + + let mut revision = input.begin().unwrap(); + revision + .insert(key("a.md"), document("a.md", "Changed")) + .unwrap(); + revision.remove(key("b.md")).unwrap(); + let input = revision.seal().unwrap(); + let mut run = runner.settle().unwrap(); + assert_eq!(snapshot(&mut run), (vec![String::from("Changed")], 1, 7)); + assert_eq!( + rendered(&mut run), + [None, Some((String::from("Changed"), 1, 7))] + ); + + drop(input); + } } diff --git a/crates/zensical/src/compat/mkdocs/plugin/search/parser.rs b/crates/zensical/src/compat/mkdocs/plugin/search/parser.rs index 59e17e3..c85b140 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/search/parser.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/search/parser.rs @@ -101,6 +101,10 @@ pub struct Parser { start: Option, /// Current attribute of the pending start tag. attribute: Attribute, + /// Whether directive attributes are retained for a later final pass. + retain_directives: bool, + /// Whether this document contains a search exclusion directive. + has_directives: bool, } /// Section being assembled. @@ -161,6 +165,17 @@ impl Parser { } } + /// Retains cleanup directives for a later post-transformation pass. + pub fn retaining_directives(mut self) -> Self { + self.retain_directives = true; + self + } + + /// Returns whether a retained directive still requires final cleanup. + pub fn requires_cleanup(&self) -> bool { + self.retain_directives && self.has_directives + } + /// Handles a tokenizer event. fn handle( &mut self, event: &CallbackEvent<'_>, span: Span, @@ -171,7 +186,10 @@ impl Parser { if let CallbackEvent::AttributeName { name } = event && *name == b"data-search-exclude" { - editor.remove_attribute(name, span); + self.has_directives = true; + if !self.retain_directives { + editor.remove_attribute(name, span); + } } // Page-level exclusion disables fact collection, but not HTML cleanup. @@ -708,6 +726,19 @@ mod tests { parser.finish() } + #[test] + fn retained_directives_are_cleaned_only_by_the_final_pass() { + let html = r"
Drop

Keep

"; + let mut parser = Parser::default().retaining_directives(); + assert_eq!(scan(html, &mut [&mut parser]), None); + assert!(parser.requires_cleanup()); + assert_eq!(parser.finish(), vec![item(None, 1, "", "

Keep

")]); + + let mut final_parser = Parser::default(); + let output = scan(html, &mut [&mut final_parser]).unwrap(); + assert_eq!(output, "
Drop

Keep

"); + } + fn item( location: Option<&str>, level: u32, title: &str, text: &str, ) -> SearchSection { diff --git a/crates/zensical/src/compat/mkdocs/plugin/tags.rs b/crates/zensical/src/compat/mkdocs/plugin/tags.rs new file mode 100644 index 0000000..d486b3c --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/tags.rs @@ -0,0 +1,629 @@ +// 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 Material tags compatibility pipeline. + +use anyhow::{anyhow, Result}; +use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; +use html5gum::emitters::callback::CallbackEvent; +use html5gum::Span; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::sync::Arc; + +use zrx::id::Id; +use zrx::scheduler::Value; +use zrx::stream::{Key, Stream, StreamTupleExt}; + +use crate::compat::mkdocs::html::{self, Editor, Visitor}; +use crate::compat::mkdocs::plugin::search; +use crate::config::plugins::TagsPluginConfig; +use crate::config::{Config, Project}; +use crate::path::SourcePath; +use crate::structure::page::Page; +use crate::structure::tag::{Tag as TemplateTag, TagLink}; +use crate::structure::toc::Section; +use crate::template::Template; + +mod listing; +mod parser; +mod render; +mod select; +mod tag; + +pub use parser::Parser; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// MkDocs Material tags compatibility pipeline. +#[derive(Clone, Debug)] +pub struct Tags { + /// Enabled instances in configuration order. + instances: Arc<[Instance]>, + /// Template-visible project configuration shared by fragments. + project: Arc, +} + +// ---------------------------------------------------------------------------- + +/// Inputs required to derive tags patches. +pub struct Dependencies<'a> { + /// Rendered pages and their page-local tags facts. + pub pages: &'a Stream, +} + +// ---------------------------------------------------------------------------- + +/// One configured tags plugin instance. +#[derive(Clone, Debug)] +struct Instance { + /// Stable configuration-order identity. + id: usize, + /// Public MkDocs plugin instance name. + name: String, + /// Fully normalized compatibility configuration. + config: Arc, + /// Compiled source admission patterns. + filter: SourceFilter, +} + +// ---------------------------------------------------------------------------- + +/// Compiled include-then-exclude source filter. +#[derive(Clone, Debug)] +struct SourceFilter { + /// Sources admitted before exclusions are applied. + include: GlobSet, + /// Sources removed after inclusion. + exclude: GlobSet, + /// Whether inclusion is restricted by at least one pattern. + has_include: bool, + /// Deferred pattern compilation failure. + error: Option, +} + +// ---------------------------------------------------------------------------- + +/// Cached page-local facts produced during the shared HTML pass. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +pub struct Facts { + /// Per-instance normalized mappings for this page. + mappings: Vec, + /// Listing directives in source order. + listings: Vec, + /// Whether final HTML must remove a retained search directive. + search_cleanup: bool, +} + +// ---------------------------------------------------------------------------- + +/// Page-local input relation consumed by the tags pipeline. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PageInput { + /// Rendered page shared with downstream workflow branches. + pub page: Page, + /// Cached mapping and listing facts from the shared HTML pass. + pub facts: Arc, +} + +// ---------------------------------------------------------------------------- + +/// Derived page patch emitted for every live page. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Patch { + /// HTML after owned listing slots or deferred directives changed it. + pub content: Option, + /// TOC after owned listing subtrees have been inserted. + pub toc: Option>, + /// Page-level template variables keyed by configured names. + pub variables: BTreeMap>, + /// Refreshed search facts when listing HTML changed. + pub search: Option>, +} + +// ---------------------------------------------------------------------------- + +/// Exact replacements applied to generated listing markers in one HTML pass. +struct SlotPatcher<'a> { + /// Marker and rendered-fragment pairs. + replacements: Vec<(&'a str, &'a str)>, + /// Number of markers replaced during the pass. + replaced: usize, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Tags { + /// Resolves the private settings owned by this pipeline instance. + pub fn new(config: &Config, serve: bool) -> Self { + let instances = config + .project + .plugins + .tags + .config + .iter() + .enumerate() + .map(|(id, plugin)| { + let mut config = plugin.config.clone(); + if serve && config.shadow_on_serve { + config.shadow = true; + } + Instance { + id, + name: plugin.name.clone(), + filter: SourceFilter::new( + &config.filters.include, + &config.filters.exclude, + ), + config: Arc::new(config), + } + }) + .filter(|instance| instance.config.enabled) + .collect::>(); + Self { + instances: instances.into(), + project: config.project.clone(), + } + } + + /// Returns whether no tags instance participates in page processing. + pub fn is_empty(&self) -> bool { + self.instances.is_empty() + } + + /// Derives listing and page patches from revision-complete selections. + pub fn setup(&self, dependencies: Dependencies<'_>) -> Stream { + let pages = dependencies.pages; + let mappings = pages.map(PageInput::mapping); + let listings = pages.flat_map(|input: &PageInput| { + input + .facts + .listings + .iter() + .map(|prepared| { + ( + listing_key(prepared.instance, prepared.ordinal), + listing::Listing { + prepared: prepared.clone(), + page: input.page.clone(), + }, + ) + }) + .collect::>() + }); + + // Materialize one bounded member set for every live listing. + let members = + mappings.select(&listings, |listing: &listing::Listing| { + let listing = listing.clone(); + move |mapping: &listing::PageMapping| listing.matches(mapping) + }); + let tags = self.clone(); + let template = Template::new(self.project.theme_dirs.clone()); + let rendered = (listings, members).join().map( + move |(listing, members): &( + listing::Listing, + Vec<(Key, listing::PageMapping)>, + )| { + render::listing( + select::Selection { + configuration: listing.clone(), + members: members.clone(), + }, + &tags, + &template, + ) + }, + ); + + // Materialize the rendered listings that can affect each live page. + let members = rendered.select(pages, |input: &PageInput| { + let input = input.clone(); + move |rendered: &render::Rendered| affects(&input, rendered) + }); + let tags = self.clone(); + (pages.clone(), members).join().map( + move |(input, members): &( + PageInput, + Vec<(Key, render::Rendered)>, + )| { + patch( + select::Selection { + configuration: input.clone(), + members: members.clone(), + }, + &tags, + ) + }, + ) + } +} + +// ---------------------------------------------------------------------------- + +impl PageInput { + /// Projects this page into the relation consumed by listing selection. + fn mapping(&self) -> listing::PageMapping { + listing::PageMapping { + page: self.page.clone(), + mappings: self.facts.mappings.clone(), + } + } +} + +// ---------------------------------------------------------------------------- + +impl Facts { + /// Records cleanup deferred until listings have patched final page HTML. + pub fn require_search_cleanup(&mut self) { + self.search_cleanup = true; + } +} + +// ---------------------------------------------------------------------------- + +impl SourceFilter { + /// Compiles source patterns once for the workflow lifetime. + fn new(include: &[String], exclude: &[String]) -> Self { + let mut error = None; + let include_set = compile_globs(include, &mut error); + let exclude_set = compile_globs(exclude, &mut error); + Self { + include: include_set, + exclude: exclude_set, + has_include: !include.is_empty(), + error, + } + } + + /// Applies Material's inclusion-first, exclusion-second semantics. + fn accepts(&self, source: &SourcePath) -> Result { + if let Some(error) = &self.error { + return Err(anyhow!("invalid tags source filter: {error}")); + } + let source = source.as_str(); + Ok((!self.has_include || self.include.is_match(source)) + && !self.exclude.is_match(source)) + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Visitor for SlotPatcher<'_> { + fn visit( + &mut self, event: &CallbackEvent<'_>, span: Span, + editor: &mut Editor<'_>, + ) { + if !matches!(event, CallbackEvent::Comment { .. }) { + return; + } + let source = editor.text(span.start..span.end); + let replacement = self + .replacements + .iter() + .find(|(slot, _)| *slot == source) + .map(|(_, replacement)| *replacement); + if let Some(replacement) = replacement { + editor.replace(span.start..span.end, replacement); + self.replaced += 1; + } + } +} + +// ---------------------------------------------------------------------------- + +impl Value for PageInput {} + +// ---------------------------------------------------------------------------- + +impl Value for Patch {} + +// ---------------------------------------------------------------------------- + +impl Value for listing::Listing {} + +// ---------------------------------------------------------------------------- + +impl Value for listing::PageMapping {} + +// ---------------------------------------------------------------------------- + +impl Value for render::Rendered {} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Compiles fnmatch-like patterns whose wildcards may cross path separators. +fn compile_globs(patterns: &[String], error: &mut Option) -> GlobSet { + let mut builder = GlobSetBuilder::new(); + for pattern in patterns { + match GlobBuilder::new(pattern) + .literal_separator(false) + .backslash_escape(false) + .build() + { + Ok(pattern) => { + builder.add(pattern); + } + Err(reason) => { + error.get_or_insert_with(|| reason.to_string()); + } + } + } + builder.build().unwrap_or_else(|reason| { + error.get_or_insert_with(|| reason.to_string()); + GlobSetBuilder::new().build().expect("empty glob set") + }) +} + +/// Creates a page-local suffix key for one listing ordinal. +fn listing_key(instance: usize, ordinal: u32) -> Key { + Key::from( + Id::builder() + .provider("tags") + .context(instance.to_string()) + .location(ordinal.to_string()) + .build() + .expect("numeric tags listing identity is valid"), + ) +} + +/// Returns whether one rendered listing can affect one page. +fn affects(input: &PageInput, rendered: &render::Rendered) -> bool { + if rendered.listing.page.source() == input.page.source() { + return true; + } + let mapping = input.mapping(); + rendered + .listing + .selected_tags(&mapping) + .into_iter() + .any(|tag| { + rendered.targets.iter().any(|target| { + target.instance == rendered.listing.prepared.instance + && target.name == tag.name + }) + }) +} + +/// Builds one coherent page patch from revision-complete rendered listings. +fn patch( + selection: select::Selection, tags: &Tags, +) -> anyhow::Result { + let input = selection.configuration; + let mut rendered = selection + .members + .into_iter() + .map(|(_, rendered)| rendered) + .collect::>(); + rendered.sort_by(|left, right| { + left.listing + .page + .source() + .cmp(right.listing.page.source()) + .then( + left.listing + .prepared + .ordinal + .cmp(&right.listing.prepared.ordinal), + ) + }); + + let owned = rendered + .iter() + .filter(|listing| listing.listing.page.source() == input.page.source()) + .collect::>(); + let changed = !owned.is_empty(); + let mut content = (changed || input.facts.search_cleanup) + .then(|| input.page.content.clone()); + let mut toc = changed.then(|| input.page.toc.clone()); + if changed { + let html = content.as_mut().expect("owned listings require HTML"); + let mut patcher = SlotPatcher { + replacements: owned + .iter() + .map(|listing| { + ( + listing.listing.prepared.slot.as_str(), + listing.html.as_str(), + ) + }) + .collect(), + replaced: 0, + }; + let expected = patcher.replacements.len(); + *html = html::scan(html, &mut [&mut patcher]).ok_or_else(|| { + anyhow!("generated tags listing markers are missing from page HTML") + })?; + if patcher.replaced != expected { + return Err(anyhow!( + "replaced {} of {expected} generated tags listing markers", + patcher.replaced + )); + } + } + for listing in owned { + insert_toc( + toc.as_mut().expect("owned listings require a TOC"), + listing, + ); + } + + let variables = derive_variables(&input, &rendered, tags)?; + let search = if (changed || input.facts.search_cleanup) + && tags.project.plugins.search.config.enabled + { + let mut parser = search::parser(&input.page.meta); + let html = content + .as_mut() + .expect("search refresh requires final page HTML"); + if let Some(cleaned) = html::scan(html, &mut [&mut parser]) { + *html = cleaned; + } + Some(search::finish(parser)) + } else { + None + }; + Ok(Patch { + content, + toc, + variables, + search, + }) +} + +/// Derives template-visible tag references and their nearest listing links. +fn derive_variables( + input: &PageInput, rendered: &[render::Rendered], tags: &Tags, +) -> Result>> { + let mapping = input.mapping(); + let mut variables = Vec::new(); + for instance in tags + .instances + .iter() + .filter(|instance| instance.config.tags) + { + let Some(tags) = mapping + .mappings + .iter() + .find(|mapping| mapping.instance == instance.id) + else { + continue; + }; + let mut references = Vec::with_capacity(tags.tags.len()); + for tag in &tags.tags { + let template = tag.template(); + let mut links = rendered + .iter() + .filter_map(|listing| { + tag_link(listing, &mapping, instance.id, &tag.name) + }) + .collect::>(); + links.sort_by(|left, right| { + right.0.cmp(&left.0).then(left.1.url.cmp(&right.1.url)) + }); + let links = + links.into_iter().map(|(_, link)| link).collect::>(); + references.push(TemplateTag { + name: template.name, + parent: template.parent.map(|parent| *parent), + url: links.first().map(|link| link.url.clone()), + hidden: template.hidden, + links, + }); + } + tag::sort_references(&mut references, &instance.config)?; + variables + .push((instance.config.tags_name_variable.clone(), references)); + } + Ok(tag::variables(variables)) +} + +/// Resolves one page tag to a rendered listing target, if it participates. +fn tag_link( + rendered: &render::Rendered, mapping: &listing::PageMapping, + instance: usize, name: &str, +) -> Option<(usize, TagLink)> { + if rendered.listing.prepared.instance != instance + || !rendered + .listing + .selected_tags(mapping) + .iter() + .any(|selected| selected.name == name) + { + return None; + } + let target = rendered.targets.iter().find(|target| target.name == name)?; + let base = if rendered.listing.page.url.is_empty() { + "." + } else { + &rendered.listing.page.url + }; + Some(( + closeness(&mapping.page.url, &rendered.listing.page.url), + TagLink { + title: rendered.listing.page.title.clone(), + url: format!("{base}#{}", target.slug), + }, + )) +} + +/// Inserts one listing TOC subtree below its recorded heading host. +fn insert_toc(toc: &mut Vec
, rendered: &render::Rendered) { + if rendered.toc.is_empty() { + return; + } + let prepared = &rendered.listing.prepared; + let children = match &prepared.host { + Some(host) => { + find_section_mut(toc, host).map(|section| &mut section.children) + } + None => Some(toc), + }; + let Some(children) = children else { + return; + }; + let index = prepared + .following + .as_ref() + .and_then(|following| { + children.iter().position(|section| §ion.id == following) + }) + .unwrap_or(children.len()); + children.splice(index..index, rendered.toc.clone()); +} + +/// Finds one section recursively by its stable heading ID. +fn find_section_mut<'a>( + sections: &'a mut [Section], id: &str, +) -> Option<&'a mut Section> { + for section in sections { + if section.id == id { + return Some(section); + } + if let Some(section) = find_section_mut(&mut section.children, id) { + return Some(section); + } + } + None +} + +/// Computes Material's path-closeness ordering without filesystem access. +fn closeness(left: &str, right: &str) -> usize { + let common = left + .split('/') + .zip(right.split('/')) + .take_while(|(left, right)| left == right) + .map(|(component, _)| component.len() + 1) + .sum::(); + common.saturating_sub(1) +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/tags/listing.rs b/crates/zensical/src/compat/mkdocs/plugin/tags/listing.rs new file mode 100644 index 0000000..2dbddb8 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/tags/listing.rs @@ -0,0 +1,367 @@ +// 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. + +// ---------------------------------------------------------------------------- + +//! Listing configuration, membership, and tree construction. + +use anyhow::{bail, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +use crate::config::plugins::{TagsListingConfig, TagsPluginConfig}; +use crate::structure::page::Page; +use crate::structure::tag::TagNode as TemplateTagNode; + +use super::tag::{Tag, TagNode}; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Fully resolved configuration for one listing directive. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct Config { + /// Whether mappings are restricted to the listing directory. + pub scope: bool, + /// Whether hidden tags are rendered. + pub shadow: bool, + /// Fragment layout name. + pub layout: String, + /// Whether tag nodes are added to the page table of contents. + pub toc: bool, + /// Included tag names. + pub include: BTreeSet, + /// Excluded tag names. + pub exclude: BTreeSet, +} + +// ---------------------------------------------------------------------------- + +/// One listing discovered in a rendered Markdown page. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct Prepared { + /// Plugin instance index. + pub instance: usize, + /// Page-local source ordinal. + pub ordinal: u32, + /// Unambiguous HTML replacement slot. + pub slot: String, + /// Nearest owning heading identifier, or root when absent. + pub host: Option, + /// Level of the owning heading or the synthetic root. + pub host_level: u8, + /// First following child heading used to retain source ordering. + pub following: Option, + /// Resolved listing configuration. + pub config: Config, +} + +// ---------------------------------------------------------------------------- + +/// Listing enriched with its owner page facts. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Listing { + /// Prepared page-local listing facts. + pub prepared: Prepared, + /// Shared owner page context used by fragment rendering. + pub page: Page, +} + +// ---------------------------------------------------------------------------- + +/// One page mapping for one plugin instance. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct Mapping { + /// Plugin instance index. + pub instance: usize, + /// Normalized leaf tags. + pub tags: Vec, +} + +// ---------------------------------------------------------------------------- + +/// Page facts consumed by listing membership. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PageMapping { + /// Complete page object exposed to listing fragments. + pub page: Page, + /// Per-instance mapping facts. + pub mappings: Vec, +} + +// ---------------------------------------------------------------------------- + +impl PageMapping { + /// Returns the normalized tags owned by one configured plugin instance. + fn tags(&self, instance: usize) -> Option<&[Tag]> { + self.mappings + .iter() + .find(|mapping| mapping.instance == instance) + .map(|mapping| mapping.tags.as_slice()) + } +} + +// ---------------------------------------------------------------------------- + +/// One hierarchical listing node. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct Tree { + /// Cumulative tag represented by this node. + pub tag: TagNode, + /// Pages carrying this exact leaf tag. + pub mappings: Vec, + /// Child tag nodes. + pub children: Vec, + /// Rendered heading content assigned by the renderer. + pub content: String, +} + +// ---------------------------------------------------------------------------- + +/// MkDocs-compatible mapping exposed to listing fragments. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct TemplateMapping { + /// MkDocs-compatible page nested below the mapping. + pub item: Page, + /// Complete normalized leaf tags carried by the page mapping. + pub tags: Vec, +} + +// ---------------------------------------------------------------------------- + +/// Mutable construction node converted into the public ordered tree. +struct Node { + /// Cumulative tag represented by this node. + tag: TagNode, + /// Pages carrying this exact leaf tag, keyed by URL. + mappings: BTreeMap, + /// Child nodes keyed by cumulative tag name. + children: BTreeMap, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Config { + /// Resolves optional per-listing values against plugin defaults. + pub fn new(value: &TagsListingConfig, plugin: &TagsPluginConfig) -> Self { + Self { + scope: value.scope.unwrap_or(false), + shadow: value.shadow.unwrap_or(plugin.shadow), + layout: value + .layout + .clone() + .unwrap_or_else(|| plugin.listings_layout.clone()), + toc: value.toc.unwrap_or(plugin.listings_toc), + include: value + .include + .clone() + .unwrap_or_default() + .into_iter() + .collect(), + exclude: value + .exclude + .clone() + .unwrap_or_default() + .into_iter() + .collect(), + } + } + + /// Creates the default listing configuration. + pub fn default_for(plugin: &TagsPluginConfig) -> Self { + Self::new( + &TagsListingConfig { + scope: None, + shadow: None, + layout: None, + toc: None, + include: None, + exclude: None, + }, + plugin, + ) + } +} + +// ---------------------------------------------------------------------------- + +impl Listing { + /// Returns the leaf tags from one mapping included by this listing. + pub fn selected_tags<'a>(&self, mapping: &'a PageMapping) -> Vec<&'a Tag> { + if mapping.page.source() == self.page.source() { + return Vec::new(); + } + if self.prepared.config.scope { + let inside = self.page.source().parent().is_none_or(|parent| { + mapping.page.source().is_descendant_of(&parent) + }); + if !inside { + return Vec::new(); + } + } + let Some(tags) = mapping.tags(self.prepared.instance) else { + return Vec::new(); + }; + if tags + .iter() + .any(|tag| tag.contains(&self.prepared.config.exclude)) + { + return Vec::new(); + } + tags.iter() + .filter(|tag| { + self.prepared.config.include.is_empty() + || tag.contains(&self.prepared.config.include) + }) + .collect() + } + + /// Returns whether a page contributes at least one tag to this listing. + pub fn matches(&self, mapping: &PageMapping) -> bool { + !self.selected_tags(mapping).is_empty() + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Builds and deterministically sorts the listing tree. +pub fn tree( + listing: &Listing, mappings: impl IntoIterator, + plugin: &TagsPluginConfig, +) -> Result> { + let mut roots = BTreeMap::::new(); + for mapping in mappings { + let tags = mapping + .tags(listing.prepared.instance) + .into_iter() + .flatten() + .map(Tag::template) + .collect(); + let item = TemplateMapping { + item: mapping.page.clone(), + tags, + }; + for tag in listing.selected_tags(&mapping) { + if tag.hidden() && !listing.prepared.config.shadow { + continue; + } + insert(&mut roots, &tag.hierarchy, item.clone()); + } + } + finalize(roots, plugin) +} + +/// Inserts one leaf and its ancestors into the mutable tree. +fn insert( + roots: &mut BTreeMap, hierarchy: &[TagNode], + item: TemplateMapping, +) { + let mut level = roots; + for (index, tag) in hierarchy.iter().enumerate() { + let node = level.entry(tag.name.clone()).or_insert_with(|| Node { + tag: tag.clone(), + mappings: BTreeMap::new(), + children: BTreeMap::new(), + }); + if index + 1 == hierarchy.len() { + node.mappings.insert(item.item.url.clone(), item.clone()); + } + level = &mut node.children; + } +} + +/// Converts and sorts every level of the mutable tree. +fn finalize( + nodes: BTreeMap, config: &TagsPluginConfig, +) -> Result> { + let mut output = nodes + .into_values() + .map(|node| { + let mut mappings = node.mappings.into_values().collect::>(); + match config.listings_sort_by.as_str() { + "item_title" => mappings.sort_by(|left, right| { + left.item + .title + .cmp(&right.item.title) + .then(left.item.url.cmp(&right.item.url)) + }), + "item_url" => mappings + .sort_by(|left, right| left.item.url.cmp(&right.item.url)), + strategy => { + bail!("unsupported listing sort strategy: {strategy}") + } + } + if config.listings_sort_reverse { + mappings.reverse(); + } + Ok(Tree { + tag: node.tag, + mappings, + children: finalize(node.children, config)?, + content: String::new(), + }) + }) + .collect::>>()?; + match config.listings_tags_sort_by.as_str() { + "tag_name" => { + output.sort_by(|left, right| left.tag.name.cmp(&right.tag.name)); + } + "tag_name_casefold" => output + .sort_by_cached_key(|tree| super::tag::casefold(&tree.tag.name)), + strategy => bail!("unsupported listing tag sort strategy: {strategy}"), + } + if config.listings_tags_sort_reverse { + output.reverse(); + } + Ok(output) +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use super::Config; + + #[test] + fn include_and_exclude_are_exact_sets() { + let config = Config { + scope: false, + shadow: false, + layout: "default".into(), + toc: true, + include: ["A".into()].into_iter().collect(), + exclude: ["B".into()].into_iter().collect(), + }; + assert_eq!(config.include, BTreeSet::from(["A".into()])); + assert_eq!(config.exclude, BTreeSet::from(["B".into()])); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/tags/parser.rs b/crates/zensical/src/compat/mkdocs/plugin/tags/parser.rs new file mode 100644 index 0000000..d9d0e9e --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/tags/parser.rs @@ -0,0 +1,424 @@ +// 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. + +// ---------------------------------------------------------------------------- + +//! Streaming discovery of tag mappings and listing directives. + +use anyhow::{anyhow, bail, Context, Result}; +use html5gum::emitters::callback::CallbackEvent; +use html5gum::Span; +use saphyr::{LoadableYamlNode, Yaml}; +use std::collections::BTreeMap; +use std::mem; + +use crate::compat::mkdocs::html::{Editor, Visitor}; +use crate::config::plugins::{ + python_bool, python_float, TagsListingConfig, TagsPluginConfig, +}; +use crate::path::SourcePath; +use crate::structure::dynamic::Dynamic; + +use super::listing::{Config as ListingConfig, Mapping, Prepared}; +use super::tag; +use super::{Facts, Instance, Tags}; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Page-local tags visitor. +pub struct Parser { + /// Source currently being scanned. + source: SourcePath, + /// Enabled plugin instances that accept this source. + instances: Vec, + /// Per-instance mappings derived from page metadata. + mappings: Vec, + /// Listing directives discovered in source order. + listings: Vec, + /// Open heading ancestry. + headings: Vec, + /// Start tag currently receiving heading attributes. + pending: Option, + /// Listings awaiting their first following heading. + waiting: Vec, + /// First failure deferred until the shared HTML pass completes. + error: Option, +} + +// ---------------------------------------------------------------------------- + +/// Heading currently receiving attributes. +struct PendingHeading { + /// Parsed heading level. + level: u8, + /// Decoded heading identifier, when present. + id: Option, + /// Whether the current attribute is the identifier. + attribute_is_id: bool, +} + +// ---------------------------------------------------------------------------- + +/// Most recent open heading at one logical document level. +#[derive(Clone)] +struct Heading { + /// Parsed heading level. + level: u8, + /// Decoded heading identifier. + id: String, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Parser { + /// Prepares per-instance mappings before visiting the page HTML. + pub fn new( + tags: &Tags, source: &SourcePath, meta: &BTreeMap, + ) -> Result { + let mut instances = Vec::new(); + let mut mappings = Vec::new(); + for instance in tags.instances.iter() { + if !instance.filter.accepts(source)? { + continue; + } + let tags = tag::normalize( + meta.get(&instance.config.tags_name_property), + &instance.config, + ) + .with_context(|| { + format!( + "error reading tags of page '{source}' for plugin '{}'", + instance.name + ) + })?; + mappings.push(Mapping { instance: instance.id, tags }); + instances.push(instance.clone()); + } + Ok(Self { + source: source.clone(), + instances, + mappings, + listings: Vec::new(), + headings: Vec::new(), + pending: None, + waiting: Vec::new(), + error: None, + }) + } + + /// Finishes cached page-local facts or reports a deferred visitor error. + pub fn finish(self) -> Result { + if let Some(error) = self.error { + Err(error) + } else { + Ok(Facts { + mappings: self.mappings, + listings: self.listings, + search_cleanup: false, + }) + } + } + + /// Handles one tokenizer event without allowing malformed directives to + /// prevent the remaining visitors from completing the shared pass. + fn handle( + &mut self, event: &CallbackEvent<'_>, span: Span, + editor: &mut Editor<'_>, + ) -> Result<()> { + match event { + CallbackEvent::OpenStartTag { name } => { + if let Some(level) = heading_level(name) { + self.pending = Some(PendingHeading { + level, + id: None, + attribute_is_id: false, + }); + } + } + CallbackEvent::AttributeName { name } => { + if let Some(pending) = &mut self.pending { + pending.attribute_is_id = *name == b"id"; + } + } + CallbackEvent::AttributeValue { value } => { + if let Some(pending) = &mut self.pending + && pending.attribute_is_id + { + pending.id = + Some(String::from_utf8_lossy(value).into_owned()); + } + } + CallbackEvent::CloseStartTag { .. } => { + if let Some(pending) = self.pending.take() + && let Some(id) = pending.id + { + self.open_heading(pending.level, id); + } + } + CallbackEvent::Comment { value } => { + self.comment(value, span, editor)?; + } + _ => {} + } + Ok(()) + } + + /// Updates heading ownership and source-order anchors. + fn open_heading(&mut self, level: u8, id: String) { + for index in mem::take(&mut self.waiting) { + let listing = &mut self.listings[index]; + if listing.host.is_none() || level > listing.host_level { + listing.following = Some(id.clone()); + } + } + self.headings.retain(|heading| heading.level < level); + self.headings.push(Heading { level, id }); + } + + /// Claims one directive for the first eligible matching instance. + fn comment( + &mut self, value: &[u8], span: Span, editor: &mut Editor<'_>, + ) -> Result<()> { + let value = String::from_utf8_lossy(value); + let value = value.trim(); + for instance in &self.instances { + let Some(arguments) = + directive_arguments(value, &instance.config.listings_directive) + else { + continue; + }; + let config = + resolve(arguments, &instance.config).with_context(|| { + format!("error reading tags listing in '{}'", self.source) + })?; + if !instance.config.listings { + editor.replace(span.start..span.end, Box::default()); + return Ok(()); + } + + let ordinal = u32::try_from(self.listings.len()) + .context("too many tag listings on one page")?; + let mut nonce = 0_u32; + let slot = loop { + let candidate = format!( + "", + instance.id + ); + if !editor.contains(&candidate) { + break candidate; + } + nonce = nonce + .checked_add(1) + .context("too many colliding tags listing markers")?; + }; + let host = self + .headings + .iter() + .rev() + .find(|heading| heading.level < 6) + .cloned(); + self.listings.push(Prepared { + instance: instance.id, + ordinal, + slot: slot.clone(), + host: host.as_ref().map(|heading| heading.id.clone()), + host_level: host.as_ref().map_or(1, |heading| heading.level), + following: None, + config, + }); + self.waiting.push(self.listings.len() - 1); + editor.replace(span.start..span.end, slot); + return Ok(()); + } + Ok(()) + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Visitor for Parser { + fn visit( + &mut self, event: &CallbackEvent<'_>, span: Span, + editor: &mut Editor<'_>, + ) { + if self.error.is_none() + && let Err(error) = self.handle(event, span, editor) + { + self.error = Some(error); + } + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Returns a heading level for one HTML tag name. +fn heading_level(name: &[u8]) -> Option { + match name { + b"h1" => Some(1), + b"h2" => Some(2), + b"h3" => Some(3), + b"h4" => Some(4), + b"h5" => Some(5), + b"h6" => Some(6), + _ => None, + } +} + +/// Extracts a literal, case-insensitive directive's YAML tail. +fn directive_arguments<'a>(value: &'a str, directive: &str) -> Option<&'a str> { + let prefix = value.get(..directive.len())?; + if !prefix.eq_ignore_ascii_case(directive) { + return None; + } + let tail = &value[directive.len()..]; + if tail.is_empty() || tail.starts_with(char::is_whitespace) { + Some(tail.trim()) + } else { + None + } +} + +/// Resolves empty, named, or inline YAML listing configuration. +fn resolve( + arguments: &str, plugin: &TagsPluginConfig, +) -> Result { + if arguments.is_empty() { + return Ok(ListingConfig::default_for(plugin)); + } + let documents = Yaml::load_from_str(arguments)?; + if documents.len() != 1 { + bail!("listing directive must contain exactly one YAML document") + } + let document = &documents[0]; + if let Some(name) = document.as_str() { + let Some(value) = plugin.listings_map.get(name) else { + let available = plugin + .listings_map + .keys() + .cloned() + .collect::>() + .join(", "); + bail!( + "couldn't find listing configuration: {name}. Available configurations: {available}" + ) + }; + return Ok(ListingConfig::new(value, plugin)); + } + let Some(mapping) = document.as_mapping() else { + bail!("listing configuration must be a name or mapping") + }; + let mut value = TagsListingConfig { + scope: None, + shadow: None, + layout: None, + toc: None, + include: None, + exclude: None, + }; + for (key, item) in mapping { + let Some(key) = key.as_str() else { + bail!("listing configuration keys must be strings") + }; + match key { + "scope" => value.scope = Some(boolean(item, key)?), + "shadow" => value.shadow = Some(boolean(item, key)?), + "layout" => value.layout = Some(string(item, key)?), + "toc" => value.toc = Some(boolean(item, key)?), + "include" => value.include = Some(tags(item, key)?), + "exclude" => value.exclude = Some(tags(item, key)?), + _ => bail!("unknown listing configuration option: {key}"), + } + } + Ok(ListingConfig::new(&value, plugin)) +} + +/// Requires a Boolean YAML value. +fn boolean(value: &Yaml<'_>, name: &str) -> Result { + value + .as_bool() + .ok_or_else(|| anyhow!("listing option '{name}' must be a Boolean")) +} + +/// Requires a string YAML value. +fn string(value: &Yaml<'_>, name: &str) -> Result { + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| anyhow!("listing option '{name}' must be a string")) +} + +/// Requires a Material-compatible iterable tag set. +fn tags(value: &Yaml<'_>, name: &str) -> Result> { + let Some(values) = value.as_sequence() else { + bail!("listing option '{name}' must be an iterable tag set") + }; + values + .iter() + .enumerate() + .map(|(index, value)| { + if let Some(value) = value.as_str() { + Ok(value.to_owned()) + } else if let Some(value) = value.as_integer() { + Ok(value.to_string()) + } else if let Some(value) = value.as_floating_point() { + Ok(python_float(value)) + } else if let Some(value) = value.as_bool() { + Ok(python_bool(value).into()) + } else { + bail!("invalid tag at index {index} in listing option '{name}'") + } + }) + .collect() +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::directive_arguments; + + #[test] + fn directive_names_are_literal_case_insensitive_tokens() { + assert_eq!( + directive_arguments( + "MATERIAL/TAGS { toc: false }", + "material/tags" + ), + Some("{ toc: false }") + ); + assert_eq!( + directive_arguments("material/tags-extra", "material/tags"), + None + ); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/tags/render.rs b/crates/zensical/src/compat/mkdocs/plugin/tags/render.rs new file mode 100644 index 0000000..93c1b98 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/tags/render.rs @@ -0,0 +1,297 @@ +// 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. + +// ---------------------------------------------------------------------------- + +//! Listing fragment rendering and coherent derived facts. + +use anyhow::{anyhow, Result}; +use minijinja::value::{Enumerator, Object, ObjectExt, ObjectRepr}; +use minijinja::{context, Value}; +use std::fmt; +use std::sync::Arc; + +use crate::config::relative_base_url; +use crate::config::Project; +use crate::structure::toc::Section; +use crate::template::Template; + +use super::listing::{self, Listing, PageMapping, Tree}; +use super::select::Selection; +use super::Tags; + +// ---------------------------------------------------------------------------- +// Constants +// ---------------------------------------------------------------------------- + +/// Native fallback for Material's default tag fragment. +const TAG_TEMPLATE: &str = r#"{%- set class = "md-tag" -%} +{%- if tag.hidden %}{% set class = class ~ " md-tag-shadow" %}{% endif -%} +{%- if config.extra.tags -%} + {%- set class = class ~ " md-tag-icon" -%} + {%- if tag.name in config.extra.tags -%} + {%- set class = class ~ " md-tag--" ~ config.extra.tags[tag.name] -%} + {%- endif -%} +{%- endif -%} +{{ tag.name }}"#; + +/// Native fallback for Material's default recursive listing fragment. +const LISTING_TEMPLATE: &str = r#"{% macro render(listing) %} + {{ listing.content }} +
    + {% for mapping in listing.mappings %} +
  • + + {{ mapping.item.title }} + +
  • + {% endfor %} + {% for child in listing %} +
  • {{ render(child) }}
  • + {% endfor %} +
+{% endmacro %} +{{ render(listing) }}"#; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// One rendered listing and every derived fact from the same tree. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Rendered { + /// Source listing configuration. + pub listing: Listing, + /// Rendered listing HTML. + pub html: String, + /// Optional table-of-contents subtree. + pub toc: Vec
, + /// Public tag anchors emitted by the rendered tree. + pub targets: Vec, +} + +// ---------------------------------------------------------------------------- + +/// Public anchor emitted for one listing tag. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Target { + /// Plugin instance index. + pub instance: usize, + /// Cumulative tag name. + pub name: String, + /// Listing fragment. + pub slug: String, + /// Whether the tag is hidden. + pub hidden: bool, +} + +// ---------------------------------------------------------------------------- + +/// MiniJinja view preserving Material's iterable listing-tree contract. +#[derive(Clone)] +struct TreeView(Tree); + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl fmt::Debug for TreeView { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("TreeView") + .field(&self.0.tag.name) + .finish() + } +} + +impl Object for TreeView { + fn repr(self: &Arc) -> ObjectRepr { + ObjectRepr::Iterable + } + + fn get_value_by_str(self: &Arc, key: &str) -> Option { + match key { + "tag" => Some(Value::from_serialize(&self.0.tag)), + "content" => Some(Value::from(self.0.content.clone())), + "mappings" => Some(Value::from_serialize(&self.0.mappings)), + "children" => Some(Value::from_serialize(&self.0.children)), + _ => None, + } + } + + fn enumerate(self: &Arc) -> Enumerator { + self.mapped_enumerator(|this| { + Box::new( + this.0 + .children + .iter() + .cloned() + .map(TreeView) + .map(Value::from_object), + ) + }) + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Renders one revision-complete listing selection. +pub fn listing( + selection: Selection, tags: &Tags, + template: &Template<'_>, +) -> Result { + let listing = selection.configuration; + let instance = tags + .instances + .iter() + .find(|instance| instance.id == listing.prepared.instance) + .ok_or_else(|| { + anyhow!("tags listing references a missing plugin instance") + })?; + let mappings = selection + .members + .into_iter() + .map(|(_, mapping)| mapping) + .collect::>(); + let mut trees = listing::tree(&listing, mappings, &instance.config)?; + prepare_tree( + &mut trees, + listing.prepared.host_level + 1, + &listing, + &tags.project, + template, + )?; + + let layout = &listing.prepared.config.layout; + let name = format!("fragments/tags/{layout}/listing.html"); + let base_url = relative_base_url(&listing.page.url); + let mut html = Vec::with_capacity(trees.len()); + for tree in &trees { + html.push(template.render_fragment( + &name, + LISTING_TEMPLATE, + context! { + config => &tags.project, + page => &listing.page, + base_url => &base_url, + listing => Value::from_object(TreeView(tree.clone())), + }, + )?); + } + let toc = if listing.prepared.config.toc { + trees.iter().map(section).collect() + } else { + Vec::new() + }; + let mut targets = Vec::new(); + collect_targets(&trees, listing.prepared.instance, &mut targets); + Ok(Rendered { + listing, + html: html.join("\n"), + toc, + targets, + }) +} + +/// Renders tag headings and recursively prepares child nodes. +fn prepare_tree( + trees: &mut [Tree], level: u8, listing: &Listing, project: &Arc, + template: &Template<'_>, +) -> Result<()> { + let level = level.min(6); + let name = + format!("fragments/tags/{}/tag.html", listing.prepared.config.layout); + let base_url = relative_base_url(&listing.page.url); + for tree in trees { + let tag = template.render_fragment( + &name, + TAG_TEMPLATE, + context! { + config => project, + page => &listing.page, + base_url => &base_url, + tag => &tree.tag, + }, + )?; + tree.content = + format!("{tag}", tree.tag.slug); + prepare_tree( + &mut tree.children, + level.saturating_add(1).min(6), + listing, + project, + template, + )?; + } + Ok(()) +} + +/// Converts one rendered tree into the common table-of-contents model. +fn section(tree: &Tree) -> Section { + let level = heading_level(&tree.content).unwrap_or(2); + Section { + title: tree.tag.name.clone(), + content: tree.tag.name.clone(), + id: tree.tag.slug.clone(), + url: format!("#{}", tree.tag.slug), + children: tree.children.iter().map(section).collect(), + level, + } +} + +/// Reads the level from the generated opening heading. +fn heading_level(content: &str) -> Option { + content + .as_bytes() + .get(2) + .and_then(|byte| byte.is_ascii_digit().then_some(byte - b'0')) +} + +/// Collects every public anchor once from the listing tree. +fn collect_targets(trees: &[Tree], instance: usize, output: &mut Vec) { + for tree in trees { + output.push(Target { + instance, + name: tree.tag.name.clone(), + slug: tree.tag.slug.clone(), + hidden: tree.tag.hidden, + }); + collect_targets(&tree.children, instance, output); + } +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::heading_level; + + #[test] + fn reads_generated_heading_levels() { + assert_eq!(heading_level("

X

"), Some(3)); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/tags/select.rs b/crates/zensical/src/compat/mkdocs/plugin/tags/select.rs new file mode 100644 index 0000000..785a3de --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/tags/select.rs @@ -0,0 +1,42 @@ +// 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. + +// ---------------------------------------------------------------------------- + +//! Revision-complete selection values for rendering and page patching. + +use zrx::id::Id; +use zrx::stream::Key; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// One live selector configuration and its revision-complete members. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Selection { + /// Selector configuration paired with the completed member snapshot. + pub configuration: N, + /// Selected source values in deterministic key order. + pub members: Vec<(Key, T)>, +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/tags/tag.rs b/crates/zensical/src/compat/mkdocs/plugin/tags/tag.rs new file mode 100644 index 0000000..1e4e250 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/tags/tag.rs @@ -0,0 +1,430 @@ +// 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. + +// ---------------------------------------------------------------------------- + +//! Tag normalization, hierarchy, sorting, and references. + +use anyhow::{bail, Result}; +use icu_casemap::CaseMapper; +use icu_locale_core::LanguageIdentifier; +use icu_normalizer::{ComposingNormalizer, DecomposingNormalizer}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +use crate::config::plugins::{python_scalar, TagsPluginConfig}; +use crate::structure::dynamic::Dynamic; +use crate::structure::tag::{Tag as TemplateTag, TagNode as TemplateTagNode}; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// One normalized leaf tag and its cumulative hierarchy. +#[derive(Clone, Debug, Deserialize, Hash, PartialEq, Eq, Serialize)] +pub struct Tag { + /// Full leaf name. + pub name: String, + /// Root-to-leaf cumulative tag names. + pub hierarchy: Vec, +} + +// ---------------------------------------------------------------------------- + +/// One cumulative tag in a hierarchy. +#[derive(Clone, Debug, Deserialize, Hash, PartialEq, Eq, Serialize)] +pub struct TagNode { + /// Cumulative tag name. + pub name: String, + /// Parent tag, if this tag belongs to a hierarchy. + pub parent: Option>, + /// Stable public fragment. + pub slug: String, + /// Whether the tag is classified as a shadow tag. + pub hidden: bool, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Tag { + /// Returns whether this leaf or one of its parents has the given name. + pub fn contains(&self, names: &BTreeSet) -> bool { + self.hierarchy.iter().any(|tag| names.contains(&tag.name)) + } + + /// Returns whether this leaf is hidden. + pub fn hidden(&self) -> bool { + self.hierarchy.last().is_some_and(|tag| tag.hidden) + } + + /// Returns the template-visible leaf tag and its parent chain. + pub fn template(&self) -> TemplateTagNode { + template_node( + self.hierarchy + .last() + .expect("configured tag always contains one hierarchy node"), + ) + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Normalizes one configured metadata property into deterministic leaf tags. +pub fn normalize( + value: Option<&Dynamic>, config: &TagsPluginConfig, +) -> Result> { + let Some(value) = value else { + return Ok(Vec::new()); + }; + let Dynamic::List(values) = value else { + bail!("expected iterable tags, but received: {value}") + }; + + let allowed = config.tags_allowed.iter().cloned().collect::>(); + let mut names = BTreeSet::new(); + for (index, value) in values.iter().enumerate() { + let name = python_scalar(value).ok_or_else(|| { + anyhow::anyhow!( + "expected a string, integer, float or Boolean tag at index {index}" + ) + })?; + if !allowed.is_empty() && !allowed.contains(&name) { + bail!("tag not in allow list: {name}") + } + names.insert(name); + } + + names + .into_iter() + .map(|name| configure(name, config)) + .collect() +} + +/// Applies hierarchy, shadow, and slug configuration to one tag. +fn configure(name: String, config: &TagsPluginConfig) -> Result { + let components = if config.tags_hierarchy { + if config.tags_hierarchy_separator.is_empty() { + bail!("tags_hierarchy_separator must not be empty") + } + name.split(&config.tags_hierarchy_separator) + .collect::>() + } else { + vec![name.as_str()] + }; + let shadows = config.shadow_tags.iter().cloned().collect::>(); + let mut hierarchy = Vec::with_capacity(components.len()); + let mut cumulative = String::new(); + let mut hidden = false; + for (index, component) in components.into_iter().enumerate() { + if index > 0 { + cumulative.push_str(&config.tags_hierarchy_separator); + } + cumulative.push_str(component); + hidden = hidden + || shadows.contains(&cumulative) + || (!config.shadow_tags_prefix.is_empty() + && component.starts_with(&config.shadow_tags_prefix)) + || (!config.shadow_tags_suffix.is_empty() + && component.ends_with(&config.shadow_tags_suffix)); + let parent = hierarchy.last().cloned().map(Arc::new); + hierarchy.push(TagNode { + name: cumulative.clone(), + parent, + slug: slug(&cumulative, config)?, + hidden, + }); + } + Ok(Tag { name, hierarchy }) +} + +/// Projects one internal tag node into Material's fragment object model. +fn template_node(tag: &TagNode) -> TemplateTagNode { + TemplateTagNode { + name: tag.name.clone(), + parent: tag.parent.as_deref().map(template_node).map(Box::new), + hidden: tag.hidden, + } +} + +/// Produces the configured public fragment for one cumulative tag name. +pub fn slug(name: &str, config: &TagsPluginConfig) -> Result { + let parts = if config.tags_hierarchy { + name.split(&config.tags_hierarchy_separator) + .collect::>() + } else { + vec![name] + }; + let slug = parts + .into_iter() + .map(|part| { + slug_part( + part, + &config.tags_slugify_separator, + &config.tags_slugify, + ) + }) + .collect::>>()? + .join(&config.tags_hierarchy_separator); + if !config.tags_slugify_format.contains("{slug}") { + bail!("tags_slugify_format must contain '{{slug}}'") + } + Ok(config.tags_slugify_format.replace("{slug}", &slug)) +} + +/// Implements the supported Python Markdown and pymdownx slug strategies. +fn slug_part(value: &str, separator: &str, strategy: &str) -> Result { + match strategy { + "pymdownx:lower" => Ok(slug_pymdownx(value, separator, false)), + "pymdownx:fold" => Ok(slug_pymdownx(value, separator, true)), + "markdown:slugify" => Ok(slug_markdown(value, separator)), + _ => bail!("unsupported tags slug strategy: {strategy}"), + } +} + +/// Matches pymdownx's NFC, HTML stripping, case, and character policy. +fn slug_pymdownx(value: &str, separator: &str, fold: bool) -> String { + let stripped = strip_html(value); + let normalized = ComposingNormalizer::new_nfc().normalize(&stripped); + let normalized = normalized.trim(); + let cased = if fold { + CaseMapper::new().fold_string(normalized).into_owned() + } else { + CaseMapper::new() + .lowercase_to_string(normalized, &LanguageIdentifier::UNKNOWN) + .into_owned() + }; + let mut output = String::with_capacity(cased.len()); + for character in cased.chars() { + if character.is_alphanumeric() || matches!(character, '_' | '-') { + output.push(character); + } else if character == ' ' { + output.push_str(separator); + } + } + output +} + +/// Matches Python Markdown's ASCII NFKD slug function. +fn slug_markdown(value: &str, separator: &str) -> String { + let normalized = DecomposingNormalizer::new_nfkd().normalize(value); + let filtered = normalized + .chars() + .filter(|character| { + character.is_ascii_alphanumeric() + || character.is_ascii_whitespace() + || matches!(character, '_' | '-') + }) + .flat_map(char::to_lowercase) + .collect::(); + let mut output = String::with_capacity(filtered.len()); + let mut inside_separator = false; + for character in filtered.trim().chars() { + if character.is_whitespace() || separator.contains(character) { + if !inside_separator { + output.push_str(separator); + inside_separator = true; + } + } else { + inside_separator = false; + output.push(character); + } + } + output +} + +/// Removes HTML tags using pymdownx's permissive non-nesting semantics. +fn strip_html(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + let mut rest = value; + while let Some(start) = rest.find('<') { + output.push_str(&rest[..start]); + let candidate = &rest[start + 1..]; + if let Some(end) = candidate.find('>') { + rest = &candidate[end + 1..]; + } else { + output.push_str(&rest[start..]); + return output; + } + } + output.push_str(rest); + output +} + +/// Computes the Unicode default case-fold key used by Material sorting. +pub fn casefold(value: &str) -> String { + CaseMapper::new().fold_string(value).into_owned() +} + +/// Sorts page tag references according to the configured built-in strategy. +pub fn sort_references( + references: &mut [TemplateTag], config: &TagsPluginConfig, +) -> Result<()> { + match config.tags_sort_by.as_str() { + "tag_name" => { + references.sort_by(|left, right| left.name.cmp(&right.name)); + } + "tag_name_casefold" => { + references.sort_by_cached_key(|tag| casefold(&tag.name)); + } + strategy => bail!("unsupported tags sort strategy: {strategy}"), + } + if config.tags_sort_reverse { + references.reverse(); + } + Ok(()) +} + +/// Groups references by their configured template variable. +pub fn variables( + values: impl IntoIterator)>, +) -> BTreeMap> { + let mut output = BTreeMap::new(); + for (name, references) in values { + if references.is_empty() { + continue; + } + output.entry(name).or_insert(references); + } + output +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::{normalize, slug_part, variables, TemplateTag}; + use crate::config::plugins::TagsPluginConfig; + use crate::structure::dynamic::Dynamic; + + fn config() -> TagsPluginConfig { + TagsPluginConfig { + tags_hierarchy: true, + shadow_tags_prefix: "_".into(), + ..TagsPluginConfig::default() + } + } + + #[test] + fn normalizes_scalars_duplicates_hierarchy_and_shadow_state() { + let tags = normalize( + Some(&Dynamic::List(vec![ + Dynamic::String("Guide/_Internal".into()), + Dynamic::String("Guide/_Internal".into()), + Dynamic::Integer(7), + ])), + &config(), + ) + .unwrap(); + + assert_eq!(tags.len(), 2); + let tag = tags + .iter() + .find(|tag| tag.name.starts_with("Guide")) + .unwrap(); + assert_eq!(tag.hierarchy[0].name, "Guide"); + assert!(tag.hierarchy[0].parent.is_none()); + assert_eq!( + tag.hierarchy[1] + .parent + .as_deref() + .map(|parent| parent.name.as_str()), + Some("Guide") + ); + assert_eq!(tag.hierarchy[1].slug, "tag:guide/_internal"); + assert!(tag.hidden()); + } + + #[test] + fn preserves_leading_empty_hierarchy_components() { + let tags = normalize( + Some(&Dynamic::List(vec![Dynamic::String("//Child".into())])), + &config(), + ) + .unwrap(); + let hierarchy = &tags[0].hierarchy; + + assert_eq!( + hierarchy + .iter() + .map(|tag| tag.name.as_str()) + .collect::>(), + ["", "/", "//Child"] + ); + assert_eq!(hierarchy[2].slug, "tag://child"); + assert_eq!( + hierarchy[2] + .parent + .as_deref() + .map(|parent| parent.name.as_str()), + Some("/") + ); + } + + #[test] + fn matches_supported_python_slug_strategies() { + assert_eq!( + slug_part("A Straße", "-", "pymdownx:lower").unwrap(), + "a--straße" + ); + assert_eq!( + slug_part("Straße ςΣ", "-", "pymdownx:fold").unwrap(), + "strasse-σσ" + ); + assert_eq!( + slug_part("Café\tand more", "-", "markdown:slugify").unwrap(), + "cafe-and-more" + ); + assert_eq!(slug_part("a Self { + pub fn new(config: &Config, meta: &meta::Meta) -> Self { Self { classifier: Classifier::new(config, meta), } @@ -112,12 +112,12 @@ impl Resources { impl Classifier { /// Resolves classification settings once for the workflow lifetime. - fn new(config: &Config, meta: &meta::Settings) -> Self { + fn new(config: &Config, meta: &meta::Meta) -> Self { Self { docs: config.project.docs_dir.clone(), extra_templates: config.project.extra_templates.clone(), static_templates: config.project.theme.static_templates.clone(), - meta: meta.clone(), + meta: meta.settings().clone(), } } diff --git a/crates/zensical/src/config.rs b/crates/zensical/src/config.rs index bbe41c9..c71c421 100644 --- a/crates/zensical/src/config.rs +++ b/crates/zensical/src/config.rs @@ -35,6 +35,7 @@ use std::sync::Arc; use zrx::path::PathExt; +use crate::config::plugins::TagsPlugin; use crate::path::{OutputRoot, SourceRoot}; mod error; @@ -115,6 +116,14 @@ impl Config { let markdown_extensions = config .get_item("markdown_extensions")? .extract::>()?; + + // Validate raw native tags configuration before derived project + // extraction can replace its precise diagnostic with generic + // nested-field context from PyO3. + config + .get_item("plugins")? + .get_item("tags")? + .extract::()?; let project = config.extract::()?; // Return configuration and theme directory @@ -196,10 +205,7 @@ impl Config { where P: AsRef, { - PathBuf::from(".") - .relative_to(path) - .to_string_lossy() - .replace('\\', "/") + relative_base_url(path) } /// Returns the base path, derived from the site URL if available. @@ -226,6 +232,21 @@ impl Config { } } +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Computes the relative root used by page and fragment template contexts. +pub fn relative_base_url

(path: P) -> String +where + P: AsRef, +{ + PathBuf::from(".") + .relative_to(path) + .to_string_lossy() + .replace('\\', "/") +} + // ---------------------------------------------------------------------------- // Trait implementations // ---------------------------------------------------------------------------- diff --git a/crates/zensical/src/config/plugins.rs b/crates/zensical/src/config/plugins.rs index 255a3c2..19294ce 100644 --- a/crates/zensical/src/config/plugins.rs +++ b/crates/zensical/src/config/plugins.rs @@ -29,6 +29,13 @@ use pyo3::FromPyObject; use serde::Serialize; use std::collections::BTreeMap; +mod tags; + +pub use tags::{ + python_bool, python_float, python_scalar, TagsListingConfig, TagsPlugin, + TagsPluginConfig, +}; + // ---------------------------------------------------------------------------- // Structs // ---------------------------------------------------------------------------- @@ -53,6 +60,8 @@ pub struct Plugins { pub redirects: RedirectsPlugin, /// Minify plugin. pub minify: MinifyPlugin, + /// Material tags plugin instances. + pub tags: TagsPlugin, /// Offline plugin. pub offline: OfflinePlugin, } @@ -161,8 +170,6 @@ pub struct HtmlMinOptions { pub pre_attr: String, } -// ---------------------------------------------------------------------------- - /// Search plugin. #[derive(Clone, Debug, Hash, FromPyObject, Serialize)] #[pyo3(from_item_all)] diff --git a/crates/zensical/src/config/plugins/tags.rs b/crates/zensical/src/config/plugins/tags.rs new file mode 100644 index 0000000..d61b76e --- /dev/null +++ b/crates/zensical/src/config/plugins/tags.rs @@ -0,0 +1,879 @@ +// 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 configuration for Material tags compatibility. + +use pyo3::exceptions::PyValueError; +use pyo3::types::{ + PyAny, PyAnyMethods, PyDict, PyDictMethods, PyList, PyListMethods, +}; +use pyo3::{Borrowed, Bound, FromPyObject, PyErr, PyResult}; +use serde::Serialize; +use std::collections::{BTreeMap, BTreeSet}; + +use crate::structure::dynamic::Dynamic; + +// ---------------------------------------------------------------------------- +// Constants +// ---------------------------------------------------------------------------- + +/// Complete supported configuration surface after deprecated keys are removed. +const OPTIONS: &[&str] = &[ + "enabled", + "filters", + "tags", + "tags_slugify", + "tags_slugify_separator", + "tags_slugify_format", + "tags_hierarchy", + "tags_hierarchy_separator", + "tags_sort_by", + "tags_sort_reverse", + "tags_name_property", + "tags_name_variable", + "tags_allowed", + "listings", + "listings_map", + "listings_sort_by", + "listings_sort_reverse", + "listings_tags_sort_by", + "listings_tags_sort_reverse", + "listings_directive", + "listings_layout", + "listings_toc", + "shadow", + "shadow_on_serve", + "shadow_tags", + "shadow_tags_prefix", + "shadow_tags_suffix", + "export", + "export_file", + "export_only", +]; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// Finite native behavior selected from a compatibility callable. +#[derive(Clone, Copy)] +enum Strategy { + /// Tag slug construction. + Slug, + /// Tag ordering. + Tag, + /// Listing item ordering. + Item, +} + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Material tags plugins. +#[derive(Clone, Debug, Hash, Serialize)] +pub struct TagsPlugin { + /// Ordered plugin instances. + pub config: Vec, +} + +// ---------------------------------------------------------------------------- + +/// One named Material tags plugin instance. +#[derive(Clone, Debug, Hash, Serialize)] +pub struct TagsPluginInstance { + /// Public MkDocs plugin instance name. + pub name: String, + /// Plugin configuration. + pub config: TagsPluginConfig, +} + +// ---------------------------------------------------------------------------- + +/// Source admission filters used by one tags instance. +#[derive(Clone, Debug, Hash, Serialize)] +pub struct TagsFilterConfig { + /// Inclusion patterns. + pub include: Vec, + /// Exclusion patterns. + pub exclude: Vec, +} + +// ---------------------------------------------------------------------------- + +/// Per-listing tags configuration. +#[derive(Clone, Debug, Default, Hash, Serialize)] +pub struct TagsListingConfig { + /// Whether membership is restricted to the listing directory. + pub scope: Option, + /// Whether shadow tags are rendered. + pub shadow: Option, + /// Fragment layout name. + pub layout: Option, + /// Whether listing anchors are added to the page table of contents. + pub toc: Option, + /// Included tag names. + pub include: Option>, + /// Excluded tag names. + pub exclude: Option>, +} + +// ---------------------------------------------------------------------------- + +/// Material tags plugin configuration. +#[allow(clippy::struct_excessive_bools)] +#[derive(Clone, Debug, Hash, Serialize)] +pub struct TagsPluginConfig { + /// Whether the instance is enabled. + pub enabled: bool, + /// Source admission filters. + pub filters: TagsFilterConfig, + /// Whether page tag references are exposed to templates. + pub tags: bool, + /// Built-in tag slug strategy. + pub tags_slugify: String, + /// Separator supplied to the slug strategy. + pub tags_slugify_separator: String, + /// Format containing the generated `{slug}` placeholder. + pub tags_slugify_format: String, + /// Whether slash-separated tags form a hierarchy. + pub tags_hierarchy: bool, + /// Hierarchy separator. + pub tags_hierarchy_separator: String, + /// Built-in page-tag sort strategy. + pub tags_sort_by: String, + /// Whether page tags are sorted in reverse. + pub tags_sort_reverse: bool, + /// Metadata property containing page tags. + pub tags_name_property: String, + /// Template variable receiving tag references. + pub tags_name_variable: String, + /// Allowed exact tag names. + pub tags_allowed: Vec, + /// Whether listing directives are rendered. + pub listings: bool, + /// Named listing configurations. + pub listings_map: BTreeMap, + /// Built-in listing item sort strategy. + pub listings_sort_by: String, + /// Whether listing items are sorted in reverse. + pub listings_sort_reverse: bool, + /// Built-in listing tag sort strategy. + pub listings_tags_sort_by: String, + /// Whether listing tags are sorted in reverse. + pub listings_tags_sort_reverse: bool, + /// HTML comment directive name. + pub listings_directive: String, + /// Default listing fragment layout. + pub listings_layout: String, + /// Whether listings populate the page table of contents. + pub listings_toc: bool, + /// Whether shadow tags are rendered by default. + pub shadow: bool, + /// Whether serve mode enables shadow tags. + pub shadow_on_serve: bool, + /// Exact shadow tag names. + pub shadow_tags: Vec, + /// Shadow tag prefix. + pub shadow_tags_prefix: String, + /// Shadow tag suffix. + pub shadow_tags_suffix: String, +} + +// ---------------------------------------------------------------------------- + +/// Strict reader for one Python mapping. +struct Reader<'py> { + /// Python configuration mapping. + value: &'py Bound<'py, PyDict>, + /// Qualified path used in configuration errors. + path: String, +} + +// ---------------------------------------------------------------------------- + +/// Callable identity lowered without invoking Python code. +struct Callable { + /// Qualified or short callable name. + name: String, + /// Declarative keyword arguments. + keywords: BTreeMap, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl TagsPluginConfig { + /// Normalizes and validates one raw plugin mapping. + fn from_python(value: &Bound<'_, PyAny>, path: String) -> PyResult { + let value = value.cast::().map_err(|_| { + configuration_error(&path, "expected a configuration mapping") + })?; + let reader = Reader { value, path }; + reject_deprecated(&reader)?; + reader.reject_unknown(OPTIONS)?; + + // Accepted export switches are consumed here. Native tags deliberately + // never emit the legacy JSON artifact, so they must not enter typed + // configuration, hashing, or downstream module dependencies. + reader.optional_bool("export")?; + reader.optional_string("export_file")?; + if reader.optional_bool("export_only")?.unwrap_or(false) { + return Err(reader.error( + "export_only", + "is not supported because native tags do not export JSON", + )); + } + + let config = Self::read(&reader)?; + validate(&config, &reader)?; + Ok(config) + } + + /// Reads all supported values after the surface has been validated. + fn read(reader: &Reader<'_>) -> PyResult { + let mut config = Self::default(); + config.enabled = reader.bool("enabled", config.enabled)?; + config.filters = filters(reader)?; + config.tags = reader.bool("tags", config.tags)?; + config.tags_slugify = reader.strategy( + "tags_slugify", + &config.tags_slugify, + Strategy::Slug, + )?; + config.tags_slugify_separator = reader + .string("tags_slugify_separator", &config.tags_slugify_separator)?; + config.tags_slugify_format = reader + .string("tags_slugify_format", &config.tags_slugify_format)?; + config.tags_hierarchy = + reader.bool("tags_hierarchy", config.tags_hierarchy)?; + config.tags_hierarchy_separator = reader.string( + "tags_hierarchy_separator", + &config.tags_hierarchy_separator, + )?; + config.tags_sort_by = reader.strategy( + "tags_sort_by", + &config.tags_sort_by, + Strategy::Tag, + )?; + config.tags_sort_reverse = + reader.bool("tags_sort_reverse", config.tags_sort_reverse)?; + config.tags_name_property = + reader.string("tags_name_property", &config.tags_name_property)?; + config.tags_name_variable = + reader.string("tags_name_variable", &config.tags_name_variable)?; + config.tags_allowed = reader.scalar_list("tags_allowed")?; + config.listings = reader.bool("listings", config.listings)?; + config.listings_map = listings(reader)?; + config.listings_sort_by = reader.strategy( + "listings_sort_by", + &config.listings_sort_by, + Strategy::Item, + )?; + config.listings_sort_reverse = reader + .bool("listings_sort_reverse", config.listings_sort_reverse)?; + config.listings_tags_sort_by = reader.strategy( + "listings_tags_sort_by", + &config.listings_tags_sort_by, + Strategy::Tag, + )?; + config.listings_tags_sort_reverse = reader.bool( + "listings_tags_sort_reverse", + config.listings_tags_sort_reverse, + )?; + config.listings_directive = + reader.string("listings_directive", &config.listings_directive)?; + config.listings_layout = + reader.string("listings_layout", &config.listings_layout)?; + config.listings_toc = + reader.bool("listings_toc", config.listings_toc)?; + config.shadow = reader.bool("shadow", config.shadow)?; + config.shadow_on_serve = + reader.bool("shadow_on_serve", config.shadow_on_serve)?; + config.shadow_tags = reader.scalar_list("shadow_tags")?; + config.shadow_tags_prefix = + reader.string("shadow_tags_prefix", &config.shadow_tags_prefix)?; + config.shadow_tags_suffix = + reader.string("shadow_tags_suffix", &config.shadow_tags_suffix)?; + + Ok(config) + } +} + +// ---------------------------------------------------------------------------- + +impl<'py> Reader<'py> { + /// Rejects misspelled and unsupported keys before defaults hide them. + fn reject_unknown(&self, allowed: &[&str]) -> PyResult<()> { + let allowed = allowed.iter().copied().collect::>(); + for (key, _) in self.value.iter() { + let key = key.extract::().map_err(|_| { + configuration_error(&self.path, "option names must be strings") + })?; + if !allowed.contains(key.as_str()) { + return Err(self.error(&key, "is not a supported option")); + } + } + Ok(()) + } + + /// Returns a present non-null option. + fn get(&self, name: &str) -> PyResult>> { + Ok(self.value.get_item(name)?.filter(|value| !value.is_none())) + } + + /// Reads a Boolean with a native default. + fn bool(&self, name: &str, default: bool) -> PyResult { + self.optional_bool(name) + .map(|value| value.unwrap_or(default)) + } + + /// Reads an optional Boolean. + fn optional_bool(&self, name: &str) -> PyResult> { + self.get(name)? + .map(|value| { + value + .extract::() + .map_err(|_| self.error(name, "must be a Boolean")) + }) + .transpose() + } + + /// Reads a string with a native default. + fn string(&self, name: &str, default: &str) -> PyResult { + self.optional_string(name) + .map(|value| value.unwrap_or_else(|| default.into())) + } + + /// Reads an optional string. + fn optional_string(&self, name: &str) -> PyResult> { + self.get(name)? + .map(|value| { + value + .extract::() + .map_err(|_| self.error(name, "must be a string")) + }) + .transpose() + } + + /// Reads and Python-coerces one list of public tag names. + fn scalar_list(&self, name: &str) -> PyResult> { + let Some(value) = self.get(name)? else { + return Ok(Vec::new()); + }; + let values = value + .cast::() + .map_err(|_| self.error(name, "must be a list"))?; + values + .iter() + .enumerate() + .map(|(index, value)| { + let value = value.extract::().map_err(|_| { + self.error(name, &format!("item {index} must be scalar")) + })?; + python_scalar(&value).ok_or_else(|| { + self.error(name, &format!("item {index} must be scalar")) + }) + }) + .collect() + } + + /// Lowers one supported callable or textual alias to a native strategy. + fn strategy( + &self, name: &str, default: &str, strategy: Strategy, + ) -> PyResult { + let Some(value) = self.get(name)? else { + return Ok(default.into()); + }; + let callable = + callable(&value).map_err(|reason| self.error(name, &reason))?; + strategy + .lower(callable) + .map_err(|reason| self.error(name, &reason)) + } + + /// Creates a path-qualified configuration error. + fn error(&self, name: &str, reason: &str) -> PyErr { + configuration_error(&format!("{}.{}", self.path, name), reason) + } +} + +// ---------------------------------------------------------------------------- + +impl Strategy { + /// Maps compatibility callable identities to finite native behavior. + fn lower(self, callable: Callable) -> Result { + match self { + Self::Slug => lower_slug(callable), + Self::Tag => lower_simple( + callable, + &[ + ("tag_name", "tag_name"), + ("material.plugins.tags.tag_name", "tag_name"), + ("tag_name_casefold", "tag_name_casefold"), + ( + "material.plugins.tags.tag_name_casefold", + "tag_name_casefold", + ), + ], + ), + Self::Item => lower_simple( + callable, + &[ + ("item_title", "item_title"), + ("material.plugins.tags.item_title", "item_title"), + ("item_url", "item_url"), + ("material.plugins.tags.item_url", "item_url"), + ], + ), + } + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Default for TagsPluginConfig { + fn default() -> Self { + Self { + enabled: true, + filters: TagsFilterConfig { + include: Vec::new(), + exclude: Vec::new(), + }, + tags: true, + tags_slugify: "pymdownx:lower".into(), + tags_slugify_separator: "-".into(), + tags_slugify_format: "tag:{slug}".into(), + tags_hierarchy: false, + tags_hierarchy_separator: "/".into(), + tags_sort_by: "tag_name".into(), + tags_sort_reverse: false, + tags_name_property: "tags".into(), + tags_name_variable: "tags".into(), + tags_allowed: Vec::new(), + listings: true, + listings_map: BTreeMap::new(), + listings_sort_by: "item_title".into(), + listings_sort_reverse: false, + listings_tags_sort_by: "tag_name".into(), + listings_tags_sort_reverse: false, + listings_directive: "material/tags".into(), + listings_layout: "default".into(), + listings_toc: true, + shadow: false, + shadow_on_serve: true, + shadow_tags: Vec::new(), + shadow_tags_prefix: String::new(), + shadow_tags_suffix: String::new(), + } + } +} + +// ---------------------------------------------------------------------------- + +impl<'a, 'py> FromPyObject<'a, 'py> for TagsPlugin { + type Error = PyErr; + + fn extract(obj: Borrowed<'a, 'py, PyAny>) -> PyResult { + let root = obj.cast::().map_err(|_| { + configuration_error("plugins.tags", "expected a mapping") + })?; + let entries = root.get_item("config")?.ok_or_else(|| { + configuration_error("plugins.tags", "missing configuration") + })?; + let entries = entries.cast::().map_err(|_| { + configuration_error("plugins.tags", "expected an instance list") + })?; + let mut config = Vec::with_capacity(entries.len()); + for (index, entry) in entries.iter().enumerate() { + let entry = entry.cast::().map_err(|_| { + configuration_error( + &format!("plugins.tags[{index}]"), + "expected an instance mapping", + ) + })?; + let name = entry + .get_item("name")? + .ok_or_else(|| { + configuration_error( + &format!("plugins.tags[{index}]"), + "missing instance name", + ) + })? + .extract::()?; + let raw = entry.get_item("config")?.ok_or_else(|| { + configuration_error( + &format!("plugins.tags[{index}]"), + "missing instance configuration", + ) + })?; + let path = format!("plugins.{name}"); + config.push(TagsPluginInstance { + name, + config: TagsPluginConfig::from_python(&raw, path)?, + }); + } + Ok(Self { config }) + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Reads strict source filters. +fn filters(reader: &Reader<'_>) -> PyResult { + let Some(value) = reader.get("filters")? else { + return Ok(TagsFilterConfig { + include: Vec::new(), + exclude: Vec::new(), + }); + }; + let value = value + .cast::() + .map_err(|_| reader.error("filters", "must be a mapping"))?; + let nested = Reader { + value, + path: format!("{}.filters", reader.path), + }; + nested.reject_unknown(&["include", "exclude"])?; + Ok(TagsFilterConfig { + include: nested.scalar_list("include")?, + exclude: nested.scalar_list("exclude")?, + }) +} + +/// Reads strict named listing configurations. +fn listings( + reader: &Reader<'_>, +) -> PyResult> { + let Some(value) = reader.get("listings_map")? else { + return Ok(BTreeMap::new()); + }; + let value = value + .cast::() + .map_err(|_| reader.error("listings_map", "must be a mapping"))?; + let mut listings = BTreeMap::new(); + for (name, value) in value.iter() { + let name = name.extract::().map_err(|_| { + reader.error("listings_map", "listing names must be strings") + })?; + let value = value.cast::().map_err(|_| { + configuration_error( + &format!("{}.listings_map.{name}", reader.path), + "must be a mapping", + ) + })?; + let nested = Reader { + value, + path: format!("{}.listings_map.{name}", reader.path), + }; + nested.reject_unknown(&[ + "scope", "shadow", "layout", "toc", "include", "exclude", + ])?; + listings.insert( + name, + TagsListingConfig { + scope: nested.optional_bool("scope")?, + shadow: nested.optional_bool("shadow")?, + layout: nested.optional_string("layout")?, + toc: nested.optional_bool("toc")?, + include: nested + .get("include")? + .map(|_| nested.scalar_list("include")) + .transpose()?, + exclude: nested + .get("exclude")? + .map(|_| nested.scalar_list("exclude")) + .transpose()?, + }, + ); + } + Ok(listings) +} + +/// Rejects configuration removed from the supported Material surface. +fn reject_deprecated(reader: &Reader<'_>) -> PyResult<()> { + for (name, replacement) in [ + ("tags_compare", Some("tags_sort_by")), + ("tags_compare_reverse", Some("tags_sort_reverse")), + ("tags_pages_compare", Some("listings_sort_by")), + ("tags_pages_compare_reverse", Some("listings_sort_reverse")), + ("tags_file", None), + ("tags_extra_files", None), + ] { + if reader.value.contains(name)? { + let reason = replacement.map_or_else( + || { + "is deprecated; use a material/tags listing directive" + .into() + }, + |replacement| { + format!("is deprecated; use '{replacement}' instead") + }, + ); + return Err(reader.error(name, &reason)); + } + } + Ok(()) +} + +/// Validates configuration whose correctness is independent of page content. +fn validate(config: &TagsPluginConfig, reader: &Reader<'_>) -> PyResult<()> { + if !config.tags_slugify_format.contains("{slug}") { + return Err(reader.error( + "tags_slugify_format", + "must contain the '{slug}' placeholder", + )); + } + if config.tags_hierarchy && config.tags_hierarchy_separator.is_empty() { + return Err(reader.error( + "tags_hierarchy_separator", + "must not be empty when hierarchy is enabled", + )); + } + if config.listings_directive.trim().is_empty() { + return Err(reader.error("listings_directive", "must not be empty")); + } + Ok(()) +} + +/// Extracts a string, declarative object descriptor, or Python callable name. +fn callable(value: &Bound<'_, PyAny>) -> Result { + if let Ok(name) = value.extract::() { + return Ok(Callable { + name, + keywords: BTreeMap::new(), + }); + } + if let Ok(value) = value.cast::() { + let name = value + .get_item("object") + .map_err(|error| error.to_string())? + .ok_or_else(|| "callable descriptor requires 'object'".to_string())? + .extract::() + .map_err(|_| { + "callable descriptor 'object' must be a string".to_string() + })?; + let keywords = value + .get_item("kwds") + .map_err(|error| error.to_string())? + .filter(|value| !value.is_none()) + .map(|value| value.extract::>()) + .transpose() + .map_err(|_| { + "callable descriptor 'kwds' must be a mapping".to_string() + })? + .unwrap_or_default(); + return Ok(Callable { name, keywords }); + } + + let (target, keywords) = + if value.hasattr("func").map_err(|error| error.to_string())? { + let keywords = value + .getattr("keywords") + .ok() + .filter(|value| !value.is_none()) + .map(|value| value.extract::>()) + .transpose() + .map_err(|_| "partial keywords must be a mapping".to_string())? + .unwrap_or_default(); + ( + value.getattr("func").map_err(|error| error.to_string())?, + keywords, + ) + } else { + (value.clone(), BTreeMap::new()) + }; + let module = target + .getattr("__module__") + .and_then(|value| value.extract::()) + .map_err(|_| "unsupported callable without a module".to_string())?; + let name = target + .getattr("__name__") + .and_then(|value| value.extract::()) + .map_err(|_| "unsupported callable without a name".to_string())?; + Ok(Callable { + name: format!("{module}.{name}"), + keywords, + }) +} + +/// Lowers the supported native slug functions. +fn lower_slug(callable: Callable) -> Result { + match callable.name.as_str() { + "pymdownx:lower" | "pymdownx.slugs.uslugify" => { + require_no_keywords(&callable)?; + Ok("pymdownx:lower".into()) + } + "pymdownx:fold" => { + require_no_keywords(&callable)?; + Ok("pymdownx:fold".into()) + } + "markdown:slugify" | "markdown.extensions.toc.slugify" => { + require_no_keywords(&callable)?; + Ok("markdown:slugify".into()) + } + "pymdownx.slugs.slugify" | "pymdownx.slugs._uslugify" => { + let case = keyword_string(&callable, "case", "lower")?; + let normalize = keyword_string(&callable, "normalize", "NFC")?; + let percent = keyword_bool(&callable, "percent_encode", false)?; + let supported = ["case", "normalize", "percent_encode"]; + if callable + .keywords + .keys() + .any(|name| !supported.contains(&name.as_str())) + { + return Err("slug callable has unsupported keywords".into()); + } + if normalize != "NFC" || percent { + return Err( + "only NFC, non-percent-encoded pymdownx slugs are supported" + .into(), + ); + } + match case.as_str() { + "lower" | "fold" => Ok(format!("pymdownx:{case}")), + _ => Err("pymdownx slug case must be 'lower' or 'fold'".into()), + } + } + _ => Err(format!("unsupported slug callable '{}'", callable.name)), + } +} + +/// Lowers a keyword-free sorting callable. +fn lower_simple( + callable: Callable, aliases: &[(&str, &str)], +) -> Result { + require_no_keywords(&callable)?; + aliases + .iter() + .find(|(name, _)| *name == callable.name) + .map(|(_, strategy)| (*strategy).into()) + .ok_or_else(|| format!("unsupported callable '{}'", callable.name)) +} + +/// Rejects arguments for compatibility functions that accept none. +fn require_no_keywords(callable: &Callable) -> Result<(), String> { + if callable.keywords.is_empty() { + Ok(()) + } else { + Err(format!( + "callable '{}' does not accept keywords", + callable.name + )) + } +} + +/// Reads a string keyword with a default. +fn keyword_string( + callable: &Callable, name: &str, default: &str, +) -> Result { + match callable.keywords.get(name) { + None => Ok(default.into()), + Some(Dynamic::String(value)) => Ok(value.clone()), + Some(_) => Err(format!("slug keyword '{name}' must be a string")), + } +} + +/// Reads a Boolean keyword with a default. +fn keyword_bool( + callable: &Callable, name: &str, default: bool, +) -> Result { + match callable.keywords.get(name) { + None => Ok(default), + Some(Dynamic::Bool(value)) => Ok(*value), + Some(_) => Err(format!("slug keyword '{name}' must be a Boolean")), + } +} + +/// Converts Material's accepted scalar domain using Python `str` semantics. +pub fn python_scalar(value: &Dynamic) -> Option { + match value { + Dynamic::String(value) => Some(value.clone()), + Dynamic::Bool(value) => Some(python_bool(*value).into()), + Dynamic::Integer(value) => Some(value.to_string()), + Dynamic::Float(value) => Some(python_float(value.get())), + Dynamic::Null | Dynamic::List(_) | Dynamic::Map(_) => None, + } +} + +/// Formats a Boolean using Python's public scalar spelling. +pub fn python_bool(value: bool) -> &'static str { + if value { + "True" + } else { + "False" + } +} + +/// Formats a floating-point tag closely following Python `str` semantics. +pub fn python_float(value: f64) -> String { + if value.is_nan() { + return "nan".into(); + } + let mut output = value.to_string(); + if value.is_finite() && !output.contains(['.', 'e', 'E']) { + output.push_str(".0"); + } + output +} + +/// Creates one consistent configuration diagnostic. +fn configuration_error(path: &str, reason: &str) -> PyErr { + PyValueError::new_err(format!( + "invalid tags configuration at '{path}': {reason}" + )) +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use crate::structure::dynamic::Dynamic; + + use super::python_scalar; + + #[test] + fn scalar_names_match_python_spelling() { + assert_eq!( + python_scalar(&Dynamic::Bool(true)).as_deref(), + Some("True") + ); + assert_eq!( + python_scalar(&Dynamic::from_float(1.0)).as_deref(), + Some("1.0") + ); + assert_eq!( + python_scalar(&Dynamic::from_float(1.5)).as_deref(), + Some("1.5") + ); + } +} diff --git a/crates/zensical/src/lib.rs b/crates/zensical/src/lib.rs index 7dbe142..45d589a 100644 --- a/crates/zensical/src/lib.rs +++ b/crates/zensical/src/lib.rs @@ -39,7 +39,6 @@ use pyo3::{ }; use std::path::{Path, PathBuf}; use std::process; -use std::sync::Arc; use std::time::{Duration, Instant}; use std::{fs, io, thread}; @@ -177,13 +176,13 @@ fn run(config_file: &PathBuf, mode: Mode) -> PyResult { }; // Clean cache directory if requested - if let Mode::Build(options) = &mode { - if options.clean.unwrap_or(false) { - let cache_dir = config.get_cache_dir(); - if cache_dir.exists() { - std::fs::remove_dir_all(&cache_dir) - .expect("cache directory could not be removed"); - } + if let Mode::Build(options) = &mode + && options.clean.unwrap_or(false) + { + let cache_dir = config.get_cache_dir(); + if cache_dir.exists() { + std::fs::remove_dir_all(&cache_dir) + .expect("cache directory could not be removed"); } } @@ -204,14 +203,18 @@ fn run(config_file: &PathBuf, mode: Mode) -> PyResult { Mode::Serve(_, _) => false, }; - // Resolve metadata settings once for the workflow and provider boundary. - // The provider still needs them while metadata remains a revision fact - // workaround, so share the module-owned value rather than reprojecting - // configuration independently on both sides. - let meta_settings = Arc::new(meta::Settings::new(&config)); + // Resolve the metadata pipeline once for the workflow and provider + // boundary. Provider admission remains a revision-fact workaround, so + // share the module-owned value across both sides. + let meta = meta::Meta::new(&config); // Create workflow runner and acquire its source input - let workflow = create_workflow(&config, strict, meta_settings.clone()); + let workflow = create_workflow( + &config, + strict, + matches!(&mode, Mode::Serve(_, _)), + meta.clone(), + ); let mut runner = workflow .runner() .map_err(|err| PyRuntimeError::new_err(err.to_string()))?; @@ -265,11 +268,10 @@ fn run(config_file: &PathBuf, mode: Mode) -> PyResult { let serve = matches!(mode, Mode::Serve(_, _)); let watcher = Watcher::new(&config, serve, sender, waker.clone())?; - let mut metadata = meta::Admission::new( - config.docs_root().clone(), - config.project.docs_dir.clone(), - meta_settings, - ); + let mut metadata = meta.setup(meta::Dependencies { + docs: config.docs_root().clone(), + context: config.project.docs_dir.clone(), + }); // Start the event loop. Each debounced watcher batch is admitted as one // source revision and fully settled before the next batch is accepted. diff --git a/crates/zensical/src/python/issues.rs b/crates/zensical/src/python/issues.rs index 022806e..3526d20 100644 --- a/crates/zensical/src/python/issues.rs +++ b/crates/zensical/src/python/issues.rs @@ -330,13 +330,13 @@ impl<'a> Issues<'a> { // introduced by a template, fall back to a page-level issue. let mut spans = HashMap::<_, Vec>::default(); for reference in references.iter() { - if let Reference::LinkReference(link) = reference { - if let Some(id) = autoref_id(markdown, link) { - spans - .entry(id) - .or_default() - .push((link.id.start..link.id.end).into()); - } + if let Reference::LinkReference(link) = reference + && let Some(id) = autoref_id(markdown, link) + { + spans + .entry(id) + .or_default() + .push((link.id.start..link.id.end).into()); } } for id in autorefs.iter() { diff --git a/crates/zensical/src/server/client.rs b/crates/zensical/src/server/client.rs index 50c3829..20ce923 100644 --- a/crates/zensical/src/server/client.rs +++ b/crates/zensical/src/server/client.rs @@ -102,15 +102,15 @@ impl Middleware for Client { let mut res = next.handle(req); // In case an HTML file is served, inject the client script - if let Some(value) = res.headers.get(Header::ContentType) { - if value.contains("text/html") { - res.body.extend(b""); + if let Some(value) = res.headers.get(Header::ContentType) + && value.contains("text/html") + { + res.body.extend(b""); - // Update content length - res.headers.insert(Header::ContentLength, res.body.len()); - } + // Update content length + res.headers.insert(Header::ContentLength, res.body.len()); } // Never cache JavaScript or CSS files, so reloading works smoothly diff --git a/crates/zensical/src/structure/dynamic/float.rs b/crates/zensical/src/structure/dynamic/float.rs index f456a4e..1a2a6d1 100644 --- a/crates/zensical/src/structure/dynamic/float.rs +++ b/crates/zensical/src/structure/dynamic/float.rs @@ -38,6 +38,17 @@ use std::hash::{Hash, Hasher}; #[derive(Clone, Debug, FromPyObject, Serialize, Deserialize)] pub struct Float(pub f64); +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Float { + /// Returns the represented floating-point value. + pub fn get(&self) -> f64 { + self.0 + } +} + // ---------------------------------------------------------------------------- // Trait implementations // ---------------------------------------------------------------------------- diff --git a/crates/zensical/src/structure/markdown.rs b/crates/zensical/src/structure/markdown.rs index 96e3796..8934b89 100644 --- a/crates/zensical/src/structure/markdown.rs +++ b/crates/zensical/src/structure/markdown.rs @@ -58,7 +58,7 @@ pub struct Markdown { } /// Immutable rendered Markdown data. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct MarkdownData { /// Markdown metadata. pub meta: BTreeMap, @@ -124,11 +124,25 @@ impl Markdown { }) } - /// Replaces rendered HTML before the Markdown value enters the workflow. + /// Replaces rendered HTML, cloning shared facts only when necessary. pub fn replace_content(&mut self, content: String) { - Arc::get_mut(&mut self.data) - .expect("rendered Markdown is not shared yet") - .content = content; + Arc::make_mut(&mut self.data).content = content; + } + + /// Applies optional derived HTML and TOC values with one copy-on-write. + pub fn replace_derived( + &mut self, content: Option, toc: Option>, + ) { + if content.is_none() && toc.is_none() { + return; + } + let data = Arc::make_mut(&mut self.data); + if let Some(content) = content { + data.content = content; + } + if let Some(toc) = toc { + data.toc = toc; + } } } diff --git a/crates/zensical/src/structure/nav.rs b/crates/zensical/src/structure/nav.rs index c1718e7..fb845db 100644 --- a/crates/zensical/src/structure/nav.rs +++ b/crates/zensical/src/structure/nav.rs @@ -133,20 +133,19 @@ impl Navigation { if homepage.is_none() { // However, if we couldn't find anything, but there's still an index // page, we check if it's out of navigation, and if so, use it - if let Some(page) = pages.get("index.md") { - if !Iter::new(&items) + if let Some(page) = pages.get("index.md") + && !Iter::new(&items) .any(|item| item.url.as_deref() == Some(&page.url)) - { - homepage = Some(NavigationItem { - title: Some(page.title.clone()), - url: Some(page.url.clone()), - canonical_url: page.canonical_url.clone(), - meta: Some(page.meta.clone()), - children: Vec::new(), - is_index: true, - active: false, - }); - } + { + homepage = Some(NavigationItem { + title: Some(page.title.clone()), + url: Some(page.url.clone()), + canonical_url: page.canonical_url.clone(), + meta: Some(page.meta.clone()), + children: Vec::new(), + is_index: true, + active: false, + }); } } diff --git a/crates/zensical/src/structure/page.rs b/crates/zensical/src/structure/page.rs index 03d1c76..9e37394 100644 --- a/crates/zensical/src/structure/page.rs +++ b/crates/zensical/src/structure/page.rs @@ -28,6 +28,7 @@ use minijinja::{context, Error, Value as TemplateValue}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; +use std::hash::{Hash, Hasher}; use std::ops::Deref; use std::sync::Arc; @@ -68,7 +69,7 @@ impl Value for PageRoute {} /// Page values are cloned by the scheduler as they fan out into navigation, /// search, validation, and rendering branches. Keeping the immutable payload /// behind an [`Arc`] makes those clones constant-sized. -#[derive(Debug, Serialize)] +#[derive(Clone, Debug, Serialize)] pub struct PageData { /// Validated documentation-relative source used by internal consumers. #[serde(skip)] @@ -107,6 +108,9 @@ pub struct Page { pub previous_page: Option, /// Next page. pub next_page: Option, + /// Dynamic page-level template variables supplied by compatibility modules. + #[serde(skip)] + template_variables: Option>>, } // ---------------------------------------------------------------------------- @@ -192,6 +196,7 @@ impl Page { ancestors: Vec::new(), previous_page: None, next_page: None, + template_variables: None, } } @@ -216,6 +221,9 @@ impl Page { // Add the page-local active overlay without cloning the navigation tree. let nav = NavigationView::new(nav, Some(&self.url)); + let variables = self.template_variables.clone().unwrap_or_else(|| { + BTreeMap::from([(String::from("tags"), self.tags())]) + }); let output = template.render_with_context( &name, context! { @@ -225,8 +233,8 @@ impl Page { extra_css => project.extra_css.clone(), extra_javascript => project.extra_javascript.clone(), config => project.clone(), - tags => self.tags(), page => self, + .. TemplateValue::from_serialize(&variables), }, )?; @@ -238,7 +246,13 @@ impl Page { let mut tags = Vec::new(); if let Some(Dynamic::List(values)) = self.meta.get("tags") { for name in values { - tags.push(Tag { name: name.to_string() }); + tags.push(Tag { + name: name.to_string(), + parent: None, + url: None, + hidden: false, + links: Vec::new(), + }); } } tags @@ -253,6 +267,25 @@ impl Page { pub fn source(&self) -> &SourcePath { &self.source } + + /// Adds module-derived template context to a page-render cache key. + pub fn hash_derived_template_context(&self, state: &mut H) { + self.template_variables.hash(state); + } + + /// Replaces page-local content, table of contents, and template variables. + pub fn apply_derived( + &mut self, content: Option, + toc: Option>, + variables: BTreeMap>, + ) { + if content.is_some() || toc.is_some() { + Arc::make_mut(&mut self.data) + .markdown + .replace_derived(content, toc); + } + self.template_variables = Some(variables); + } } // ---------------------------------------------------------------------------- @@ -382,6 +415,7 @@ mod tests { ancestors: Vec::new(), previous_page: None, next_page: None, + template_variables: None, } } diff --git a/crates/zensical/src/structure/tag.rs b/crates/zensical/src/structure/tag.rs index a943f0a..a060dcc 100644 --- a/crates/zensical/src/structure/tag.rs +++ b/crates/zensical/src/structure/tag.rs @@ -25,7 +25,8 @@ //! Tag. -use pyo3::FromPyObject; +use pyo3::types::{PyAny, PyAnyMethods}; +use pyo3::{Bound, FromPyObject, PyResult}; use serde::Serialize; // ---------------------------------------------------------------------------- @@ -33,8 +34,48 @@ use serde::Serialize; // ---------------------------------------------------------------------------- /// Tag. -#[derive(Clone, Debug, PartialEq, Eq, FromPyObject, Serialize)] +#[derive(Clone, Debug, Hash, PartialEq, Eq, FromPyObject, Serialize)] pub struct Tag { /// Tag name. pub name: String, + /// Parent tag, if this tag belongs to a hierarchy. + pub parent: Option, + /// Primary listing URL, if any. + pub url: Option, + /// Whether presentation classifies the tag as hidden. + pub hidden: bool, + /// Every matching listing URL in preference order. + pub links: Vec, +} + +/// Template-visible tag node without listing references. +#[derive(Clone, Debug, Hash, PartialEq, Eq, FromPyObject, Serialize)] +pub struct TagNode { + /// Cumulative tag name. + pub name: String, + /// Parent tag, if this tag belongs to a hierarchy. + #[pyo3(default, from_py_with = extract_parent)] + pub parent: Option>, + /// Whether presentation classifies the tag as hidden. + pub hidden: bool, +} + +/// Link from a page tag to one listing. +#[derive(Clone, Debug, Hash, PartialEq, Eq, FromPyObject, Serialize)] +pub struct TagLink { + /// Listing page title. + pub title: String, + /// Listing tag URL. + pub url: String, +} + +// ---------------------------------------------------------------------------- + +/// Extracts an optional recursive tag parent from a Python tag object. +fn extract_parent(value: &Bound<'_, PyAny>) -> PyResult>> { + if value.is_none() { + Ok(None) + } else { + value.extract().map(Box::new).map(Some) + } } diff --git a/crates/zensical/src/template.rs b/crates/zensical/src/template.rs index 5bc988d..674f6c6 100644 --- a/crates/zensical/src/template.rs +++ b/crates/zensical/src/template.rs @@ -25,7 +25,7 @@ //! MiniJinja template engine. -use minijinja::{context, AutoEscape, Environment, Error, Value}; +use minijinja::{context, AutoEscape, Environment, Error, ErrorKind, Value}; use minijinja_contrib::filters::striptags; use serde::Serialize; use std::path::PathBuf; @@ -91,6 +91,22 @@ impl Template<'_> { template.render(context) } + /// Renders an overrideable fragment with a native fallback template. + pub fn render_fragment( + &self, name: &str, fallback: &str, context: C, + ) -> Result + where + C: Serialize, + { + match self.env.get_template(name) { + Ok(template) => template.render(context), + Err(error) if error.kind() == ErrorKind::TemplateNotFound => { + self.env.render_str(fallback, context) + } + Err(error) => Err(error), + } + } + /// Renders the template. pub fn render( &self, name: &str, config: &Config, nav: &Navigation, diff --git a/crates/zensical/src/template/filter.rs b/crates/zensical/src/template/filter.rs index 687b2d8..53d9023 100644 --- a/crates/zensical/src/template/filter.rs +++ b/crates/zensical/src/template/filter.rs @@ -138,17 +138,17 @@ pub fn script_tag_filter(state: &State, value: Value) -> String { } // Set `async` attribute, if given - if let Ok(flag) = value.get_attr("async") { - if flag.is_true() { - html.push_str(" async"); - } + if let Ok(flag) = value.get_attr("async") + && flag.is_true() + { + html.push_str(" async"); } // Set `defer` attribute, if given - if let Ok(flag) = value.get_attr("defer") { - if flag.is_true() { - html.push_str(" defer"); - } + if let Ok(flag) = value.get_attr("defer") + && flag.is_true() + { + html.push_str(" defer"); } // Return script tag diff --git a/crates/zensical/src/workflow.rs b/crates/zensical/src/workflow.rs index 42e05a5..7ebce11 100644 --- a/crates/zensical/src/workflow.rs +++ b/crates/zensical/src/workflow.rs @@ -43,7 +43,9 @@ use zrx::stream::{ use crate::compat::mkdocs::plugin::autorefs::UnresolvedAutorefs; use crate::compat::mkdocs::{ - plugin::{self, autorefs, meta, minify, mkdocstrings, redirects, search}, + plugin::{ + self, autorefs, meta, minify, mkdocstrings, redirects, search, tags, + }, resource, }; use crate::config::Config; @@ -85,8 +87,10 @@ struct Main { config: Config, /// Strict mode. strict: bool, - /// Resolved metadata settings shared with source admission. - meta: Arc, + /// Whether the retained workflow serves live updates. + serve: bool, + /// Metadata pipeline shared with source admission. + meta: meta::Meta, } /// File input enriched with immutable facts for the current revision. @@ -136,23 +140,6 @@ impl Deref for Input { } } -/// Revision-settled site batch derived from the current page relation. -#[derive(Clone, Debug)] -struct Site { - /// Complete pages selected for this batch. - pages: Arc, SitePage)>>, - /// Navigation derived from the current pages. - nav: Navigation, - /// Autoref registry derived from the same settled page snapshot. - autorefs: autorefs::Registry, - /// Search inputs derived from the same settled page snapshot. - search: search::Snapshot, -} - -impl Value for Site {} - -// ---------------------------------------------------------------------------- - /// Page render input retained after site-wide settlement. #[derive(Clone, Debug)] struct SitePage { @@ -228,6 +215,8 @@ struct RenderedPage { impl Value for RenderedPage {} +// ---------------------------------------------------------------------------- + // ---------------------------------------------------------------------------- // Implementations // ---------------------------------------------------------------------------- @@ -237,13 +226,14 @@ impl Main { fn setup(&self, ctx: &mut Builder) { let files = ctx.input::(); let configuration = ctx.input::(); - let minify = minify::Settings::new(&self.config); + let minify = minify::Minify::new(&self.config); // Set up workflow to process static assets and Markdown files. let sources = files.map(|input: &Input| input.source.clone()); let resources = resource::Resources::new(&self.config, &self.meta) .setup(resource::Dependencies { sources: &sources }); - let assets = minify::asset::attach(&self.config, &minify, &resources); + let assets = + minify.setup(minify::Dependencies { resources: &resources }); let markdown = route_markdown(&self.config, &files); // Redirects depend on routes, not rendered Markdown. Settle their @@ -262,20 +252,53 @@ impl Main { routes: &routes, }); - let rendered = process_markdown(&self.config, &markdown); + let plugins = plugin::Settings::new(&self.config, self.serve); + let rendered = process_markdown(&self.config, &plugins, &markdown); - // Cross the one global settlement boundary, derive all site-wide - // state, then expand the resulting batch into independent page work. + // Construct pages, apply any module-owned settlement, then retain + // page-local products as relations. Only genuinely site-wide facts + // cross a settlement boundary. let rendered_page = generate_page(&self.config, &rendered); + let rendered_page = apply_tags(&plugins.tags, &rendered_page); let page = rendered_page.map(|rendered: &RenderedPage| rendered.page.clone()); - let site = generate_site(&self.config, &rendered_page); - let nav = generate_nav(&site); - let search = site.map(|site: &Site| site.search.clone()); - search::attach(&self.config, &search); - mkdocstrings::attach(&self.config, &nav); + let site_page = rendered_page.map(|rendered: &RenderedPage| SitePage { + page: rendered.page.clone(), + autorefs: rendered.html.autorefs.clone(), + }); + let search_document = + rendered_page.filter_map(|rendered: &RenderedPage| { + (!rendered.html.search.is_empty()).then(|| { + search::Document::new( + &rendered.page, + rendered.html.search.clone(), + ) + }) + }); + let nav = generate_nav(&self.config, &rendered_page); + let autorefs_input = + rendered_page.map(|rendered: &RenderedPage| autorefs::PageInput { + source: rendered.page.source().clone(), + facts: rendered.registrations.clone(), + }); + let autorefs = plugins + .autorefs + .setup(autorefs::Dependencies { pages: &autorefs_input }); + plugins.search.setup(search::Dependencies { + documents: &search_document, + navigation: &nav, + }); + mkdocstrings::Mkdocstrings::new(&self.config) + .setup(mkdocstrings::Dependencies { navigation: &nav }); let _ = render_templates(&self.config, &files, &nav, &assets, &minify); - let unresolved = render_pages(&self.config, &site, &assets, &minify); + let unresolved = render_pages( + &self.config, + &site_page, + &nav, + &autorefs, + &assets, + &minify, + ); validate(&self.config, self.strict, &files, &page, &unresolved); } } @@ -345,6 +368,7 @@ fn page_hash(page: &Page, autorefs: &autorefs::References) -> u64 { let mut hasher = DefaultHasher::new(); page.content.hash(&mut hasher); page.meta.hash(&mut hasher); + page.hash_derived_template_context(&mut hasher); autorefs.hash(&mut hasher); hasher.finish() } @@ -373,10 +397,11 @@ fn route_markdown( /// Create a stream to process routed Markdown files. fn process_markdown( - config: &Config, routed: &Stream, + config: &Config, plugins: &plugin::Settings, + routed: &Stream, ) -> Stream { // Create pipeline to render Markdown files - let plugins = plugin::Settings::new(config); + let plugins = plugins.clone(); let config = config.clone(); routed // Render Markdown if we don't have a recent cached version at our own @@ -394,7 +419,7 @@ fn process_markdown( // This is a hack while waiting for CommonMark (AST) and components, // as well as topic-based authoring functionality. if SNIPPET_RE.is_match(&data) { - render_markdown(id, route, data, plugins, resolved) + render_markdown(id, route, data, plugins.clone(), resolved) } else { cached( &config, @@ -407,13 +432,48 @@ fn process_markdown( resolved.clone(), ), |(_, _, data, route, resolved)| { - render_markdown(id, route, data, plugins, resolved) + render_markdown( + id, + route, + data, + plugins.clone(), + resolved, + ) }, ) } })) } +/// Applies revision-complete tag listings and page-level tag references. +fn apply_tags( + pipeline: &tags::Tags, pages: &Stream, +) -> Stream { + if pipeline.is_empty() { + return pages.clone(); + } + + let inputs = pages.map(|rendered: &RenderedPage| tags::PageInput { + page: rendered.page.clone(), + facts: rendered.html.tags.clone(), + }); + let patches = pipeline.setup(tags::Dependencies { pages: &inputs }); + (pages.clone(), patches).join().map( + |(rendered, patch): &(RenderedPage, tags::Patch)| { + let mut rendered = rendered.clone(); + rendered.page.apply_derived( + patch.content.clone(), + patch.toc.clone(), + patch.variables.clone(), + ); + if let Some(search) = &patch.search { + rendered.html.search = search.clone(); + } + rendered + }, + ) +} + /// Render Markdown and collect the page-local facts produced alongside it. fn render_markdown( id: &Id, route: PageRoute, content: String, plugins: plugin::Settings, @@ -421,12 +481,8 @@ fn render_markdown( ) -> anyhow::Result { let mut markdown = Markdown::new(id, route.url.clone(), content, meta.values())?; - let html = plugin::prepare(&mut markdown, plugins); - let registrations = if plugins.autorefs { - autorefs::take_page(&route.url) - } else { - Arc::default() - }; + let html = plugin::prepare(&mut markdown, &route.source, &plugins)?; + let registrations = plugins.autorefs.take_page(&route.url); Ok(RenderedMarkdown { route, markdown, @@ -451,58 +507,26 @@ fn generate_page( }) } -/// Derive one complete site batch at the page-relation terminal. -fn generate_site( +/// Derive navigation from the complete current page relation. +fn generate_nav( config: &Config, pages: &Stream, -) -> Signal { +) -> Signal { let config = config.clone(); pages.reduce(move |pages: &dyn Collection, RenderedPage>| { - let mut nav_pages = Vec::new(); - let mut site_pages = Vec::new(); - let mut facts = Vec::new(); - let mut documents = Vec::new(); - for (key, rendered) in pages.iter() { - nav_pages.push(rendered.page.clone()); - site_pages.push(( - key.clone(), - SitePage { - page: rendered.page.clone(), - autorefs: rendered.html.autorefs.clone(), - }, - )); - facts.push(( - rendered.page.source().clone(), - rendered.registrations.clone(), - )); - if !rendered.html.search.is_empty() { - documents.push(search::Document::new( - &rendered.page, - rendered.html.search.clone(), - )); - } - } - - let nav = Navigation::new(config.project.nav.clone(), nav_pages); - let autorefs = autorefs::assemble(&config, facts); - let search = search::Snapshot::new(documents, nav.clone()); - Ok::<_, anyhow::Error>(Some(Site { - pages: Arc::new(site_pages), - nav, - autorefs, - search, - })) + Some(Navigation::new( + config.project.nav.clone(), + pages + .values() + .map(|rendered| rendered.page.clone()) + .collect(), + )) }) } -/// Project navigation from the current site batch. -fn generate_nav(site: &Signal) -> Signal { - site.map(|site: &Site| site.nav.clone()) -} - /// Render static and extra templates. fn render_templates( config: &Config, files: &Stream, nav: &Signal, - assets: &Signal, minify: &minify::Settings, + assets: &Signal, minify: &minify::Minify, ) -> Stream { let docs_dir = config.project.docs_dir.clone(); @@ -538,7 +562,7 @@ fn render_templates( templates.product(nav).product(assets).map( move |id: &Id, input: &(Input, Navigation), - assets: &minify::asset::Manifest| { + assets: &minify::Manifest| { let (_, nav) = input; let output = template_output(id)?; let name = output.as_str(); @@ -562,26 +586,21 @@ fn template_output(id: &Id) -> Result { /// Render pages. fn render_pages( - config: &Config, site: &Signal, - assets: &Signal, minify: &minify::Settings, + config: &Config, pages: &Stream, + nav: &Signal, autorefs: &Signal, + assets: &Signal, minify: &minify::Minify, ) -> Stream { - let pages = site.product(assets).flat_map( - |site: &Site, assets: &minify::asset::Manifest| { - site.pages - .iter() - .map(|(key, page)| { - ( - key.clone(), - PageRender { - input: page.clone(), - nav: site.nav.clone(), - autorefs: site.autorefs.clone(), - project: assets.project.clone(), - asset_hash: assets.hash, - }, - ) - }) - .collect::>() + let pages = pages.product(nav).product(autorefs).product(assets).map( + |input: &((SitePage, Navigation), autorefs::Registry), + assets: &minify::Manifest| { + let ((page, nav), autorefs) = input; + PageRender { + input: page.clone(), + nav: nav.clone(), + autorefs: autorefs.clone(), + project: assets.project.clone(), + asset_hash: assets.hash, + } }, ); @@ -630,12 +649,13 @@ fn render_pages( /// Creates a workflow for the given config. pub fn create_workflow( - config: &Config, strict: bool, meta: Arc, + config: &Config, strict: bool, serve: bool, meta: meta::Meta, ) -> Workflow { Workflow::build(|workflow| { Main { config: config.clone(), strict, + serve, meta, } .setup(workflow); diff --git a/python/tests/integration/test_tags.py b/python/tests/integration/test_tags.py new file mode 100644 index 0000000..960b061 --- /dev/null +++ b/python/tests/integration/test_tags.py @@ -0,0 +1,673 @@ +# Copyright (c) 2025-2026 Zensical and contributors + +# SPDX-License-Identifier: MIT +# All contributions are certified under the DCO + +"""Integration tests for native MkDocs Material tags compatibility.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +import pytest + +import zensical + +if TYPE_CHECKING: + from pathlib import Path + + +_BUILD_OPTIONS: dict[str, Any] = {"clean": False, "strict": False} + + +def _write_project(root: Path, *, plugin: str = "") -> Path: + """Create a small hierarchical tags project with observable templates.""" + docs = root / "docs" + guide = docs / "guide" + overrides = root / "overrides" + guide.mkdir(parents=True) + overrides.mkdir() + (docs / "index.md").write_text( + """\ +# Catalog + + + +## After + +Trailing content. + +

Secret listing-page text.
+""", + encoding="utf-8", + ) + (guide / "rust.md").write_text( + """\ +--- +title: Rust page +tags: + - Guide/Rust + - Public +--- +# Rust +""", + encoding="utf-8", + ) + (guide / "python.md").write_text( + """\ +--- +title: Python page +tags: + - Guide/Python +--- +# Python +""", + encoding="utf-8", + ) + (overrides / "main.html").write_text( + """\ +{{ page.content }} +{% for tag in tags %} +{% endfor %} + +{{ page.toc | tojson }} +""", + encoding="utf-8", + ) + config = root / "mkdocs.yml" + config.write_text( + f"""\ +site_name: Tags +theme: + name: material + custom_dir: overrides +plugins: + - search + - material/tags: + tags_hierarchy: true + export: true + export_file: ignored-tags.json +{plugin} +""", + encoding="utf-8", + ) + return config + + +def test_builds_listings_references_toc_and_search_without_export( + tmp_path: Path, +) -> None: + """One revision-complete listing drives all derived page facts.""" + config = _write_project(tmp_path) + + zensical.build(str(config), _BUILD_OPTIONS) + + listing = (tmp_path / "site" / "index.html").read_text() + rust = (tmp_path / "site" / "guide" / "rust" / "index.html").read_text() + search = json.loads((tmp_path / "site" / "search.json").read_text()) + + assert "material/tags" not in listing + assert "zensical:tags" not in listing + assert '

' in listing + assert '

' in listing + assert "Rust page" in listing + assert "data-search-exclude" not in listing + assert listing.index('id="tag:guide"') < listing.index('id="after"') + assert 'name="Guide/Rust" url=".#tag:guide/rust"' in rust + assert 'title="Catalog" url=".#tag:guide/rust"' in rust + assert any("Rust page" in item["text"] for item in search["items"]) + assert not any( + "Secret listing-page text" in item["text"] for item in search["items"] + ) + assert not (tmp_path / "site" / "ignored-tags.json").exists() + assert not (tmp_path / "site" / "tags.json").exists() + + +def test_inline_selection_and_literal_html_discovery(tmp_path: Path) -> None: + """Inline filters apply while escaped examples remain ordinary content.""" + config = _write_project(tmp_path) + index = tmp_path / "docs" / "index.md" + index.write_text( + """\ +# Catalog + + + +```html + +``` +""", + encoding="utf-8", + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + output = (tmp_path / "site" / "index.html").read_text() + assert "Rust page" in output + assert "Python page" not in output + assert "<!-- material/tags -->" in output + assert '"id":"tag:public"' not in output + + +def test_invalid_tag_metadata_reports_the_page(tmp_path: Path) -> None: + """Mapping validation fails with the affected page in the diagnostic.""" + config = _write_project(tmp_path) + (tmp_path / "docs" / "guide" / "rust.md").write_text( + "---\ntags: scalar\n---\n# Rust\n", + encoding="utf-8", + ) + + with pytest.raises(RuntimeError, match=r"guide/rust\.md"): + zensical.build(str(config), _BUILD_OPTIONS) + + +def test_named_layout_uses_project_fragment_overrides(tmp_path: Path) -> None: + """Named listing configuration retains Material's fragment contract.""" + config = _write_project( + tmp_path, + plugin="""\ + listings_map: + cards: + include: [Public] + layout: cards + toc: false +""", + ) + (tmp_path / "docs" / "index.md").write_text( + "# Catalog\n\n\n", + encoding="utf-8", + ) + fragments = tmp_path / "overrides" / "fragments" / "tags" / "cards" + fragments.mkdir(parents=True) + (fragments / "tag.html").write_text( + '{{ tag.name }}', + encoding="utf-8", + ) + (fragments / "listing.html").write_text( + """\ +{{ listing.content }} +{% for mapping in listing.mappings %} +{{ mapping.item.title }} +{% endfor %} +""", + encoding="utf-8", + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + output = (tmp_path / "site" / "index.html").read_text() + assert '' in output + assert 'Public' in output + assert 'Rust page' in output + assert "Python page" not in output + assert '"id":"tag:public"' not in output + + +def test_custom_templates_receive_complete_tag_and_mapping_objects( + tmp_path: Path, +) -> None: + """Fragments receive parent chains, mapping tags, and complete pages.""" + config = _write_project(tmp_path) + (tmp_path / "docs" / "guide" / "rust.md").write_text( + """\ +--- +title: Rust page +audience: developers +tags: + - Guide/Rust + - Public +--- +# Rust +""", + encoding="utf-8", + ) + (tmp_path / "overrides" / "main.html").write_text( + """\ +{{ page.content }} +{% for tag in tags %}{% endfor %} +""", + encoding="utf-8", + ) + fragments = tmp_path / "overrides" / "fragments" / "tags" / "default" + fragments.mkdir(parents=True) + (fragments / "tag.html").write_text( + '', + encoding="utf-8", + ) + (fragments / "listing.html").write_text( + """\ +{% macro render(tree) %} ++{{ tree.content }} +{% for mapping in tree.mappings %} +{% endfor %} +{% for child in tree %}{{ render(child) }}{% endfor %} + +{% endmacro %} +{{ render(listing) }} +""", + encoding="utf-8", + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + listing = (tmp_path / "site" / "index.html").read_text() + page = (tmp_path / "site" / "guide" / "rust" / "index.html").read_text() + assert '' in listing + assert 'audience="developers"' in listing + assert 'tags="Guide/Rust;Public;"' in listing + assert '' in page + + +def test_later_nonempty_instance_can_populate_shared_variable( + tmp_path: Path, +) -> None: + """An empty earlier mapping does not claim shared template context.""" + docs = tmp_path / "docs" + overrides = tmp_path / "overrides" + docs.mkdir() + overrides.mkdir() + (docs / "index.md").write_text( + "---\nsecond: [Visible]\n---\n# Home\n", encoding="utf-8" + ) + (overrides / "main.html").write_text( + "{% for tag in tags %}{{ tag.name }}{% endfor %}", encoding="utf-8" + ) + config = tmp_path / "mkdocs.yml" + config.write_text( + """\ +site_name: Shared variable +theme: + name: material + custom_dir: overrides +plugins: + - material/tags: + tags_name_property: first + tags_name_variable: tags + - material/tags: + tags_name_property: second + tags_name_variable: tags +""", + encoding="utf-8", + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + assert (tmp_path / "site" / "index.html").read_text() == "Visible" + + +def test_deprecated_tags_marker_is_ordinary_markdown(tmp_path: Path) -> None: + """Only the native HTML comment directive creates a listing.""" + config = _write_project(tmp_path) + (tmp_path / "docs" / "index.md").write_text( + """\ +# Catalog + +[TAGS] +""", + encoding="utf-8", + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + output = (tmp_path / "site" / "index.html").read_text() + assert "[TAGS]" in output + assert "Guide/Rust" not in output + assert not (tmp_path / "site" / "ignored-tags.json").exists() + + +def test_top_level_listing_keeps_table_of_contents_order( + tmp_path: Path, +) -> None: + """A directive before the first heading remains first in the root TOC.""" + config = _write_project(tmp_path) + (tmp_path / "docs" / "index.md").write_text( + "\n\n# Catalog\n", + encoding="utf-8", + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + output = (tmp_path / "site" / "index.html").read_text() + toc = output.partition("")[2].partition("")[0] + assert toc.index('"id":"tag:guide"') < toc.index('"id":"catalog"') + + +def test_tags_false_suppresses_template_references_but_keeps_listings( + tmp_path: Path, +) -> None: + """Mapping facts remain useful when page-level tag context is disabled.""" + config = _write_project(tmp_path, plugin=" tags: false\n") + + zensical.build(str(config), _BUILD_OPTIONS) + + listing = (tmp_path / "site" / "index.html").read_text() + rust = (tmp_path / "site" / "guide" / "rust" / "index.html").read_text() + assert "Rust page" in listing + assert "Guide/Rust" not in rust + + +def test_non_clean_rebuild_retracts_listing_nodes_and_tag_links( + tmp_path: Path, +) -> None: + """Revision-complete selections retract stale derived facts.""" + config = _write_project(tmp_path) + rust_source = tmp_path / "docs" / "guide" / "rust.md" + listing_source = tmp_path / "docs" / "index.md" + zensical.build(str(config), _BUILD_OPTIONS) + + rust_source.write_text( + "---\ntitle: Rust page\ntags: [Public]\n---\n# Rust\n", + encoding="utf-8", + ) + zensical.build(str(config), _BUILD_OPTIONS) + listing = (tmp_path / "site" / "index.html").read_text() + rust = (tmp_path / "site" / "guide" / "rust" / "index.html").read_text() + assert 'id="tag:guide/rust"' not in listing + assert "Guide/Rust" not in rust + assert 'name="Public" url=".#tag:public"' in rust + + listing_source.write_text("# Catalog\n", encoding="utf-8") + zensical.build(str(config), _BUILD_OPTIONS) + listing = (tmp_path / "site" / "index.html").read_text() + rust = (tmp_path / "site" / "guide" / "rust" / "index.html").read_text() + assert 'id="tag:public"' not in listing + assert 'name="Public" url=""' in rust + + +def test_multiple_instances_keep_filters_properties_and_directives_isolated( + tmp_path: Path, +) -> None: + """Ordered instances retain independent mappings and listing ownership.""" + docs = tmp_path / "docs" + private = docs / "private" + overrides = tmp_path / "overrides" + private.mkdir(parents=True) + overrides.mkdir() + (docs / "index.md").write_text( + "# Public\n\n\n", encoding="utf-8" + ) + (docs / "page.md").write_text( + "---\ntags: [Public]\n---\n# Page\n", encoding="utf-8" + ) + (private / "index.md").write_text( + "# Private\n\n\n", encoding="utf-8" + ) + (private / "secret.md").write_text( + "---\ntitle: Secret\nlabels: [Internal]\n---\n# Secret\n", + encoding="utf-8", + ) + (overrides / "main.html").write_text( + """\ +{{ page.content }} +{% for tag in tags %}{{ tag.name }}={{ tag.url or '' }}; +{% endfor %} +{% for tag in labels %}{{ tag.name }}={{ tag.url or '' }}; +{% endfor %} +""", + encoding="utf-8", + ) + config = tmp_path / "mkdocs.yml" + config.write_text( + """\ +site_name: Multiple tags +theme: + name: material + custom_dir: overrides +plugins: + - material/tags: + listings_directive: public/tags + filters: + exclude: [private/**] + - material/tags: + listings_directive: private/tags + filters: + include: [private/**] + tags_name_property: labels + tags_name_variable: labels +""", + encoding="utf-8", + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + public = (tmp_path / "site" / "index.html").read_text() + private_listing = (tmp_path / "site" / "private" / "index.html").read_text() + secret = ( + tmp_path / "site" / "private" / "secret" / "index.html" + ).read_text() + assert "Page" in public + assert "Secret" not in public + assert "Secret" in private_listing + assert "Page" not in private_listing + assert "Internal=private/#tag:internal" in secret + + +def test_leading_hierarchy_separator_keeps_identity_and_listing_link( + tmp_path: Path, +) -> None: + """Empty root components do not lose hierarchy separators.""" + config = _write_project(tmp_path) + (tmp_path / "docs" / "guide" / "rust.md").write_text( + "---\ntitle: Leading\ntags: [/Child]\n---\n# Leading\n", + encoding="utf-8", + ) + (tmp_path / "docs" / "guide" / "python.md").write_text( + "# Untagged\n", encoding="utf-8" + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + listing = (tmp_path / "site" / "index.html").read_text() + page = (tmp_path / "site" / "guide" / "rust" / "index.html").read_text() + assert '

' in listing + assert 'name="/Child" url=".#tag:/child"' in page + + +@pytest.mark.parametrize( + ("option", "replacement"), + [ + ("tags_compare", "tags_sort_by"), + ("tags_compare_reverse", "tags_sort_reverse"), + ("tags_pages_compare", "listings_sort_by"), + ("tags_pages_compare_reverse", "listings_sort_reverse"), + ("tags_file", "material/tags"), + ("tags_extra_files", "material/tags"), + ], +) +def test_rust_rejects_deprecated_tags_options( + tmp_path: Path, option: str, replacement: str +) -> None: + """The native configuration boundary owns deprecated-option errors.""" + config = _write_project(tmp_path, plugin=f" {option}: value\n") + + with pytest.raises(ValueError, match=option) as error: + zensical.build(str(config), _BUILD_OPTIONS) + + assert replacement in str(error.value) + + +@pytest.mark.parametrize("option", ["export_only", "tags_hierachy"]) +def test_rust_rejects_unsupported_tags_options( + tmp_path: Path, option: str +) -> None: + """Unsupported behavior and misspellings cannot silently disappear.""" + config = _write_project(tmp_path, plugin=f" {option}: true\n") + + with pytest.raises(ValueError, match=option): + zensical.build(str(config), _BUILD_OPTIONS) + + +def test_scalar_configuration_and_metadata_match_python_names( + tmp_path: Path, +) -> None: + """Booleans and integral floats retain Python's public spelling.""" + config = _write_project( + tmp_path, + plugin=" tags_allowed: [true, 1, 1.0, 1.5]\n", + ) + (tmp_path / "docs" / "guide" / "rust.md").write_text( + "---\ntitle: Scalars\ntags: [true, 1, 1.0, 1.5]\n---\n# Scalars\n", + encoding="utf-8", + ) + (tmp_path / "docs" / "guide" / "python.md").write_text( + "# Untagged\n", encoding="utf-8" + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + listing = (tmp_path / "site" / "index.html").read_text() + page = (tmp_path / "site" / "guide" / "rust" / "index.html").read_text() + for name, slug in [ + ("True", "true"), + ("1", "1"), + ("1.0", "10"), + ("1.5", "15"), + ]: + assert f'id="tag:{slug}"' in listing + assert f'name="{name}"' in page + + +def test_inline_listing_filters_use_python_scalar_names(tmp_path: Path) -> None: + """Inline YAML applies the same scalar domain as plugin configuration.""" + config = _write_project(tmp_path) + (tmp_path / "docs" / "index.md").write_text( + "# Catalog\n\n\n", + encoding="utf-8", + ) + (tmp_path / "docs" / "guide" / "rust.md").write_text( + "---\ntitle: Scalars\ntags: [true, 1.0, 1.5]\n---\n# Scalars\n", + encoding="utf-8", + ) + (tmp_path / "docs" / "guide" / "python.md").write_text( + "# Untagged\n", encoding="utf-8" + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + listing = (tmp_path / "site" / "index.html").read_text() + assert 'id="tag:true"' in listing + assert 'id="tag:10"' in listing + assert 'id="tag:15"' not in listing + + +def test_declarative_slug_callable_is_lowered_and_run_in_rust( + tmp_path: Path, +) -> None: + """Supported callable descriptors select native slug implementations.""" + config = _write_project( + tmp_path, + plugin="""\ + tags_slugify: + object: pymdownx.slugs.slugify + kwds: + case: fold +""", + ) + (tmp_path / "docs" / "guide" / "rust.md").write_text( + "---\ntitle: Folded\ntags: [Straße]\n---\n# Folded\n", + encoding="utf-8", + ) + (tmp_path / "docs" / "guide" / "python.md").write_text( + "# Untagged\n", encoding="utf-8" + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + listing = (tmp_path / "site" / "index.html").read_text() + assert 'id="tag:strasse"' in listing + + +def test_python_slug_callable_is_identified_but_not_invoked( + tmp_path: Path, +) -> None: + """Python callable compatibility ends at native strategy selection.""" + config = _write_project( + tmp_path, + plugin="""\ + tags_slugify: !!python/object/apply:pymdownx.slugs.slugify + kwds: + case: fold +""", + ) + (tmp_path / "docs" / "guide" / "rust.md").write_text( + "---\ntitle: Folded\ntags: [Straße]\n---\n# Folded\n", + encoding="utf-8", + ) + (tmp_path / "docs" / "guide" / "python.md").write_text( + "# Untagged\n", encoding="utf-8" + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + listing = (tmp_path / "site" / "index.html").read_text() + assert 'id="tag:strasse"' in listing + + +def test_listing_marker_cannot_claim_a_user_comment(tmp_path: Path) -> None: + """Generated marker allocation avoids existing page HTML.""" + config = _write_project(tmp_path) + (tmp_path / "docs" / "index.md").write_text( + """\ +# Catalog + + + + +""", + encoding="utf-8", + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + output = (tmp_path / "site" / "index.html").read_text() + assert output.count('id="tag:public"') == 1 + assert "" in output + + +def test_nested_listing_fragments_receive_relative_base_url( + tmp_path: Path, +) -> None: + """Fragment context uses the listing owner's page depth.""" + config = _write_project(tmp_path) + (tmp_path / "docs" / "guide" / "index.md").write_text( + "# Nested\n\n\n", encoding="utf-8" + ) + fragments = tmp_path / "overrides" / "fragments" / "tags" / "default" + fragments.mkdir(parents=True) + (fragments / "tag.html").write_text( + "{{ base_url }}", encoding="utf-8" + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + output = (tmp_path / "site" / "guide" / "index.html").read_text() + assert ".." in output + + +def test_listing_after_level_six_heading_never_emits_level_seven( + tmp_path: Path, +) -> None: + """A level-six predecessor falls back to its eligible ancestor.""" + config = _write_project(tmp_path) + headings = "\n\n".join( + f"{'#' * level} Level {level}" for level in range(1, 7) + ) + (tmp_path / "docs" / "index.md").write_text( + f"{headings}\n\n\n", encoding="utf-8" + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + output = (tmp_path / "site" / "index.html").read_text() + assert "' in output diff --git a/python/tests/unit/test_config.py b/python/tests/unit/test_config.py index 9956b14..582dc2e 100644 --- a/python/tests/unit/test_config.py +++ b/python/tests/unit/test_config.py @@ -251,6 +251,33 @@ class TestPluginShimming: assert plugin["minify_html"] is False assert plugin["htmlmin_opts"]["pre_tags"] == ["pre", "textarea"] + def test_tags_plugin_instances_are_preserved_for_rust_normalization( + self, tmp_path: Path + ) -> None: + config = self._parse_yaml( + tmp_path, + plugins=[ + {"tags": {"listings_directive": "$tags"}}, + { + "material/tags/private": { + "filters": {"include": ["private/**"]}, + "tags_name_property": "labels", + } + }, + ], + ) + instances = config["plugins"]["tags"]["config"] + assert [instance["name"] for instance in instances] == [ + "tags", + "material/tags/private", + ] + assert instances[0]["config"]["listings_directive"] == "$tags" + assert instances[1]["config"]["filters"] == { + "include": ["private/**"] + } + assert instances[1]["config"]["tags_name_property"] == "labels" + assert "tags_slugify" not in instances[0]["config"] + def test_mike_plugin_defaults_with_versioned_build( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: diff --git a/python/zensical/config.py b/python/zensical/config.py index 1743224..a672575 100644 --- a/python/zensical/config.py +++ b/python/zensical/config.py @@ -1249,19 +1249,37 @@ def _convert_markdown_extensions(value: Any) -> tuple[list[str], dict]: def _convert_plugins(value: Any, config: dict) -> dict: """Convert plugins configuration to something we can work with.""" plugins = {} + tags = [] + + def add(name: str, data: Any) -> None: + """Preserve tags instances while retaining legacy map semantics.""" + if name in ("tags", "material/tags") or name.startswith( + ("tags/", "material/tags/") + ): + tags.append({"name": name, "config": dict(data or {})}) + else: + plugins[name] = data # Plugins can be defined as a dict if isinstance(value, dict): - plugins.update(value) + for name, data in value.items(): + add(name, data) # Plugins can also be defined as a list else: for item in value: if isinstance(item, dict): - name, data = item.popitem() - plugins[name] = data + name, data = next(iter(item.items())) + if not isinstance(name, str): + raise ConfigurationError("Plugin names must be strings") + add(name, data) elif isinstance(item, str): - plugins[item] = {} + add(item, {}) + + # Rust owns all tags defaults, validation, scalar coercion and callable + # lowering. Python only preserves ordered plugin instances and their raw + # configuration, as it does for future native compatibility modules. + plugins["tags"] = tags # Define defaults for search plugin search = set_default(plugins, "search", {}, dict)