feature: add tags MkDocs plugin replacement

Signed-off-by: squidfunk <martin.donath@squidfunk.com>
This commit is contained in:
squidfunk
2026-09-01 18:36:01 +02:00
parent 86646a4aaf
commit 32bd870744
43 changed files with 4836 additions and 419 deletions
Generated
+49
View File
@@ -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",
+4 -1
View File
@@ -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"
@@ -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
///
@@ -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
///
@@ -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 {
@@ -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);
}
}
+5 -5
View File
@@ -137,13 +137,13 @@ where
pub fn poll(
&mut self, receiver: Option<&Receiver<String>>,
) -> Result<bool> {
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
+30 -30
View File
@@ -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
+3
View File
@@ -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"
] }
+17 -12
View File
@@ -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<usize>, replacement: impl Into<Box<str>>,
@@ -158,20 +163,20 @@ impl<'a> Editor<'a> {
let mut edits: Vec<Edit> = 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);
}
+57 -23
View File
@@ -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<autorefs::References>,
/// Page-local search sections.
pub search: Arc<search::Facts>,
/// Page-local tag mappings and listing slots.
pub tags: Arc<tags::Facts>,
}
// ----------------------------------------------------------------------------
/// 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<autorefs::Autorefs>,
/// MkDocs-compatible search pipeline.
pub search: Arc<search::Search>,
/// 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<HtmlFacts> {
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),
})
}
@@ -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<Id, PageInput>,
}
// ----------------------------------------------------------------------------
/// 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<Facts>,
}
// ----------------------------------------------------------------------------
/// 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<Arc<Autorefs>>);
pub struct Registry(Option<Arc<Resolver>>);
// ----------------------------------------------------------------------------
@@ -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<String, Vec<String>>,
// 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<Id, Registry> {
let pipeline = self.clone();
dependencies.pages.reduce(
move |pages: &dyn Collection<Key<Id>, PageInput>| {
if !pipeline.enabled {
return Some(Registry(None));
}
let mut pages = pages.values().cloned().collect::<Vec<_>>();
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<Facts> {
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::<Facts>()
})
.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<Facts>)>,
) -> 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<Facts> {
Arc::new(
Python::attach(|py| {
let module = py.import("zensical.extensions.autorefs")?;
module
.call_method1("get_autorefs_page_data", (url,))?
.extract::<Facts>()
})
.unwrap_or_default(),
)
}
/// Merge URL lists while preserving registration order and uniqueness.
fn merge_url_map(
target: &mut HashMap<String, Vec<String>>,
@@ -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()],
@@ -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<Settings>,
}
// ----------------------------------------------------------------------------
/// 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 {
@@ -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<Project>,
}
// ----------------------------------------------------------------------------
/// Inputs required to derive and write effective assets.
pub struct Dependencies<'a> {
/// Resources after MkDocs source precedence has been resolved.
pub resources: &'a Stream<Id, Resource>,
}
// ----------------------------------------------------------------------------
// 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<Id, Manifest> {
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<Project> {
&self.project
}
/// Processes one final HTML document after all compatibility mutations.
///
/// External asset options stay in the resolved plugin configuration for
@@ -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<Id, Resource>,
pub(super) fn setup(
plugin: &Minify, resources: &Stream<Id, Resource>,
) -> Signal<Id, Manifest> {
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)
@@ -117,8 +117,8 @@ impl Action<Key<Id>> for Writer {
// Functions
// ----------------------------------------------------------------------------
/// Attaches a removal-aware writer to the emission relation.
pub fn attach(output: OutputRoot, emissions: &Stream<Id, Emission>) {
/// Installs a removal-aware writer for the emission relation.
pub fn setup(output: OutputRoot, emissions: &Stream<Id, Emission>) {
let _ = emissions.subscribe(Writer { output });
}
@@ -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<Id, Navigation>) {
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::<Vec<u8>>()
});
// ----------------------------------------------------------------------------
/// Inputs required to generate the object inventory.
pub struct Dependencies<'a> {
/// Revision-complete site navigation.
pub navigation: &'a Signal<Id, Navigation>,
}
// ----------------------------------------------------------------------------
// Implementations
// ----------------------------------------------------------------------------
if let Ok(data) = data {
let path = config.output_root().join(
&"objects.inv".parse::<SitePath>().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::<Vec<u8>>()
});
if let Ok(data) = data {
let path = pipeline.output.join(
&"objects.inv"
.parse::<SitePath>()
.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);
}
});
}
}
@@ -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<Id, Document>,
/// Revision-complete site navigation.
pub navigation: &'a Signal<Id, Navigation>,
}
// ----------------------------------------------------------------------------
/// Search configuration.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
struct SearchConfig {
@@ -92,19 +118,79 @@ pub struct Document {
facts: Arc<Facts>,
}
/// 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<Vec<Document>>,
/// Navigation from the same page revision.
nav: Navigation,
}
struct Documents(Arc<Vec<Document>>);
// ----------------------------------------------------------------------------
// 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::<SitePath>().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::<SitePath>().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<Document>, 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<Id, Snapshot>) {
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<Id, Document>,
) -> Signal<Id, Documents> {
documents.reduce(|documents: &dyn Collection<Key<Id>, 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<Facts> {
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::<SitePath>().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::<SitePath>().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<String, Dynamic>) -> bool {
let Some(Dynamic::Map(search)) = meta.get("search") else {
@@ -298,10 +337,73 @@ fn is_search_excluded(meta: &BTreeMap<String, Dynamic>) -> 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::<SourcePath>().unwrap(),
url: source.replace(".md", ".html"),
title: title.to_owned(),
tags: Vec::new(),
facts: Arc::new(Facts::default()),
}
}
fn key(location: &str) -> Key<Id> {
Key::from(
Id::builder()
.provider("test")
.context("search")
.location(location)
.build()
.unwrap(),
)
}
fn snapshot(run: &mut Run<Id>) -> (Vec<String>, usize, usize) {
let changes = run
.output::<((Documents, usize), usize)>()
.unwrap()
.collect::<Vec<_>>();
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::<Vec<_>>();
titles.sort();
(titles, *count, *title_bytes)
}
fn rendered(run: &mut Run<Id>) -> Vec<Option<(String, usize, usize)>> {
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::<Vec<_>>();
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::<Id>::build(|workflow| {
let source = workflow.input::<Document>();
let documents = collect_documents(&source);
let count = source.reduce(
|documents: &dyn Collection<Key<Id>, Document>| {
Some(documents.len())
},
);
let title_bytes = source.reduce(
|documents: &dyn Collection<Key<Id>, Document>| {
Some(
documents
.values()
.map(|document| document.title.len())
.sum::<usize>(),
)
},
);
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::<Document>().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);
}
}
@@ -101,6 +101,10 @@ pub struct Parser {
start: Option<StartTag>,
/// 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<usize>,
@@ -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"<div data-search-exclude>Drop</div><p>Keep</p>";
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, "", "<p>Keep</p>")]);
let mut final_parser = Parser::default();
let output = scan(html, &mut [&mut final_parser]).unwrap();
assert_eq!(output, "<div>Drop</div><p>Keep</p>");
}
fn item(
location: Option<&str>, level: u32, title: &str, text: &str,
) -> SearchSection {
@@ -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<Project>,
}
// ----------------------------------------------------------------------------
/// Inputs required to derive tags patches.
pub struct Dependencies<'a> {
/// Rendered pages and their page-local tags facts.
pub pages: &'a Stream<Id, PageInput>,
}
// ----------------------------------------------------------------------------
/// 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<TagsPluginConfig>,
/// 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<String>,
}
// ----------------------------------------------------------------------------
/// 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::Mapping>,
/// Listing directives in source order.
listings: Vec<listing::Prepared>,
/// 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<Facts>,
}
// ----------------------------------------------------------------------------
/// 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<String>,
/// TOC after owned listing subtrees have been inserted.
pub toc: Option<Vec<Section>>,
/// Page-level template variables keyed by configured names.
pub variables: BTreeMap<String, Vec<TemplateTag>>,
/// Refreshed search facts when listing HTML changed.
pub search: Option<Arc<search::Facts>>,
}
// ----------------------------------------------------------------------------
/// 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::<Vec<_>>();
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<Id, Patch> {
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::<Vec<_>>()
});
// 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<Id>, 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<Id>, 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<bool> {
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<usize>,
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<String>) -> 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<Id> {
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<PageInput, render::Rendered>, tags: &Tags,
) -> anyhow::Result<Patch> {
let input = selection.configuration;
let mut rendered = selection
.members
.into_iter()
.map(|(_, rendered)| rendered)
.collect::<Vec<_>>();
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::<Vec<_>>();
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<BTreeMap<String, Vec<TemplateTag>>> {
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::<Vec<_>>();
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::<Vec<_>>();
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<Section>, 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| &section.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::<usize>();
common.saturating_sub(1)
}
@@ -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<String>,
/// Excluded tag names.
pub exclude: BTreeSet<String>,
}
// ----------------------------------------------------------------------------
/// 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<String>,
/// 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<String>,
/// 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<Tag>,
}
// ----------------------------------------------------------------------------
/// 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<Mapping>,
}
// ----------------------------------------------------------------------------
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<TemplateMapping>,
/// Child tag nodes.
pub children: Vec<Tree>,
/// 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<TemplateTagNode>,
}
// ----------------------------------------------------------------------------
/// 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<String, TemplateMapping>,
/// Child nodes keyed by cumulative tag name.
children: BTreeMap<String, Node>,
}
// ----------------------------------------------------------------------------
// 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<Item = PageMapping>,
plugin: &TagsPluginConfig,
) -> Result<Vec<Tree>> {
let mut roots = BTreeMap::<String, Node>::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<String, Node>, 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<String, Node>, config: &TagsPluginConfig,
) -> Result<Vec<Tree>> {
let mut output = nodes
.into_values()
.map(|node| {
let mut mappings = node.mappings.into_values().collect::<Vec<_>>();
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::<Result<Vec<_>>>()?;
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()]));
}
}
@@ -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<Instance>,
/// Per-instance mappings derived from page metadata.
mappings: Vec<Mapping>,
/// Listing directives discovered in source order.
listings: Vec<Prepared>,
/// Open heading ancestry.
headings: Vec<Heading>,
/// Start tag currently receiving heading attributes.
pending: Option<PendingHeading>,
/// Listings awaiting their first following heading.
waiting: Vec<usize>,
/// First failure deferred until the shared HTML pass completes.
error: Option<anyhow::Error>,
}
// ----------------------------------------------------------------------------
/// Heading currently receiving attributes.
struct PendingHeading {
/// Parsed heading level.
level: u8,
/// Decoded heading identifier, when present.
id: Option<String>,
/// 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<String, Dynamic>,
) -> Result<Self> {
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<Facts> {
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<usize>,
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<usize>, 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!(
"<!-- zensical:tags:{}:{ordinal}:{nonce} -->",
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<usize>,
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<u8> {
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<ListingConfig> {
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::<Vec<_>>()
.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<bool> {
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<String> {
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<Vec<String>> {
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
);
}
}
@@ -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 -%}
<span class="{{ class }}">{{ tag.name }}</span>"#;
/// Native fallback for Material's default recursive listing fragment.
const LISTING_TEMPLATE: &str = r#"{% macro render(listing) %}
{{ listing.content }}
<ul>
{% for mapping in listing.mappings %}
<li>
<a href="{{ mapping.item.url | url }}">
{{ mapping.item.title }}
</a>
</li>
{% endfor %}
{% for child in listing %}
<li style="list-style-type:none">{{ render(child) }}</li>
{% endfor %}
</ul>
{% 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<Section>,
/// Public tag anchors emitted by the rendered tree.
pub targets: Vec<Target>,
}
// ----------------------------------------------------------------------------
/// 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<Self>) -> ObjectRepr {
ObjectRepr::Iterable
}
fn get_value_by_str(self: &Arc<Self>, key: &str) -> Option<Value> {
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<Self>) -> 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<Listing, PageMapping>, tags: &Tags,
template: &Template<'_>,
) -> Result<Rendered> {
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::<Vec<_>>();
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<Project>,
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!("<h{level} id=\"{}\">{tag}</h{level}>", 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<u8> {
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<Target>) {
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("<h3 id=\"tag:x\">X</h3>"), Some(3));
}
}
@@ -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<N, T> {
/// Selector configuration paired with the completed member snapshot.
pub configuration: N,
/// Selected source values in deterministic key order.
pub members: Vec<(Key<Id>, T)>,
}
@@ -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<TagNode>,
}
// ----------------------------------------------------------------------------
/// 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<Arc<TagNode>>,
/// 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<String>) -> 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<Vec<Tag>> {
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::<BTreeSet<_>>();
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<Tag> {
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::<Vec<_>>()
} else {
vec![name.as_str()]
};
let shadows = config.shadow_tags.iter().cloned().collect::<BTreeSet<_>>();
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<String> {
let parts = if config.tags_hierarchy {
name.split(&config.tags_hierarchy_separator)
.collect::<Vec<_>>()
} else {
vec![name]
};
let slug = parts
.into_iter()
.map(|part| {
slug_part(
part,
&config.tags_slugify_separator,
&config.tags_slugify,
)
})
.collect::<Result<Vec<_>>>()?
.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<String> {
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::<String>();
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<Item = (String, Vec<TemplateTag>)>,
) -> BTreeMap<String, Vec<TemplateTag>> {
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::<Vec<_>>(),
["", "/", "//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("<b>A Straße</b>", "-", "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<b", "-", "pymdownx:lower").unwrap(), "ab");
assert_eq!(
slug_part("-Edge-", "-", "markdown:slugify").unwrap(),
"-edge-"
);
assert_eq!(slug_part("a---b", "-", "markdown:slugify").unwrap(), "a-b");
assert_eq!(slug_part("ΣΣ", "-", "pymdownx:lower").unwrap(), "σς");
assert_eq!(slug_part("!!!", "-", "pymdownx:lower").unwrap(), "");
}
#[test]
fn empty_references_do_not_claim_a_shared_template_variable() {
let populated = TemplateTag {
name: "Visible".into(),
parent: None,
url: None,
hidden: false,
links: Vec::new(),
};
let variables = variables([
("tags".into(), Vec::new()),
("tags".into(), vec![populated.clone()]),
]);
assert_eq!(variables["tags"], [populated]);
}
}
@@ -82,7 +82,7 @@ struct Classifier {
impl Resources {
/// Resolves the private settings owned by this module instance.
pub fn new(config: &Config, meta: &meta::Settings) -> 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(),
}
}
+25 -4
View File
@@ -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::<Vec<String>>()?;
// 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::<TagsPlugin>()?;
let project = config.extract::<Project>()?;
// Return configuration and theme directory
@@ -196,10 +205,7 @@ impl Config {
where
P: AsRef<Path>,
{
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<P>(path: P) -> String
where
P: AsRef<Path>,
{
PathBuf::from(".")
.relative_to(path)
.to_string_lossy()
.replace('\\', "/")
}
// ----------------------------------------------------------------------------
// Trait implementations
// ----------------------------------------------------------------------------
+9 -2
View File
@@ -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)]
+879
View File
@@ -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<TagsPluginInstance>,
}
// ----------------------------------------------------------------------------
/// 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<String>,
/// Exclusion patterns.
pub exclude: Vec<String>,
}
// ----------------------------------------------------------------------------
/// Per-listing tags configuration.
#[derive(Clone, Debug, Default, Hash, Serialize)]
pub struct TagsListingConfig {
/// Whether membership is restricted to the listing directory.
pub scope: Option<bool>,
/// Whether shadow tags are rendered.
pub shadow: Option<bool>,
/// Fragment layout name.
pub layout: Option<String>,
/// Whether listing anchors are added to the page table of contents.
pub toc: Option<bool>,
/// Included tag names.
pub include: Option<Vec<String>>,
/// Excluded tag names.
pub exclude: Option<Vec<String>>,
}
// ----------------------------------------------------------------------------
/// 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<String>,
/// Whether listing directives are rendered.
pub listings: bool,
/// Named listing configurations.
pub listings_map: BTreeMap<String, TagsListingConfig>,
/// 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<String>,
/// 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<String, Dynamic>,
}
// ----------------------------------------------------------------------------
// Implementations
// ----------------------------------------------------------------------------
impl TagsPluginConfig {
/// Normalizes and validates one raw plugin mapping.
fn from_python(value: &Bound<'_, PyAny>, path: String) -> PyResult<Self> {
let value = value.cast::<PyDict>().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<Self> {
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::<BTreeSet<_>>();
for (key, _) in self.value.iter() {
let key = key.extract::<String>().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<Option<Bound<'py, PyAny>>> {
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<bool> {
self.optional_bool(name)
.map(|value| value.unwrap_or(default))
}
/// Reads an optional Boolean.
fn optional_bool(&self, name: &str) -> PyResult<Option<bool>> {
self.get(name)?
.map(|value| {
value
.extract::<bool>()
.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<String> {
self.optional_string(name)
.map(|value| value.unwrap_or_else(|| default.into()))
}
/// Reads an optional string.
fn optional_string(&self, name: &str) -> PyResult<Option<String>> {
self.get(name)?
.map(|value| {
value
.extract::<String>()
.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<Vec<String>> {
let Some(value) = self.get(name)? else {
return Ok(Vec::new());
};
let values = value
.cast::<PyList>()
.map_err(|_| self.error(name, "must be a list"))?;
values
.iter()
.enumerate()
.map(|(index, value)| {
let value = value.extract::<Dynamic>().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<String> {
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<String, String> {
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<Self> {
let root = obj.cast::<PyDict>().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::<PyList>().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::<PyDict>().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::<String>()?;
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<TagsFilterConfig> {
let Some(value) = reader.get("filters")? else {
return Ok(TagsFilterConfig {
include: Vec::new(),
exclude: Vec::new(),
});
};
let value = value
.cast::<PyDict>()
.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<BTreeMap<String, TagsListingConfig>> {
let Some(value) = reader.get("listings_map")? else {
return Ok(BTreeMap::new());
};
let value = value
.cast::<PyDict>()
.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::<String>().map_err(|_| {
reader.error("listings_map", "listing names must be strings")
})?;
let value = value.cast::<PyDict>().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<Callable, String> {
if let Ok(name) = value.extract::<String>() {
return Ok(Callable {
name,
keywords: BTreeMap::new(),
});
}
if let Ok(value) = value.cast::<PyDict>() {
let name = value
.get_item("object")
.map_err(|error| error.to_string())?
.ok_or_else(|| "callable descriptor requires 'object'".to_string())?
.extract::<String>()
.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::<BTreeMap<String, Dynamic>>())
.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::<BTreeMap<String, Dynamic>>())
.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::<String>())
.map_err(|_| "unsupported callable without a module".to_string())?;
let name = target
.getattr("__name__")
.and_then(|value| value.extract::<String>())
.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<String, String> {
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<String, String> {
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<String, String> {
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<bool, String> {
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<String> {
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")
);
}
}
+21 -19
View File
@@ -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<bool> {
};
// 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<bool> {
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<bool> {
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.
+7 -7
View File
@@ -330,13 +330,13 @@ impl<'a> Issues<'a> {
// introduced by a template, fall back to a page-level issue.
let mut spans = HashMap::<_, Vec<Span>>::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() {
+8 -8
View File
@@ -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"<script type=\"module\">");
res.body.extend(CLIENT.as_bytes());
res.body.extend(b"</script>");
if let Some(value) = res.headers.get(Header::ContentType)
&& value.contains("text/html")
{
res.body.extend(b"<script type=\"module\">");
res.body.extend(CLIENT.as_bytes());
res.body.extend(b"</script>");
// 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
@@ -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
// ----------------------------------------------------------------------------
+19 -5
View File
@@ -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<String, Dynamic>,
@@ -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<String>, toc: Option<Vec<Section>>,
) {
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;
}
}
}
+12 -13
View File
@@ -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,
});
}
}
+37 -3
View File
@@ -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<NavigationItem>,
/// Next page.
pub next_page: Option<NavigationItem>,
/// Dynamic page-level template variables supplied by compatibility modules.
#[serde(skip)]
template_variables: Option<BTreeMap<String, Vec<Tag>>>,
}
// ----------------------------------------------------------------------------
@@ -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<H: Hasher>(&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<String>,
toc: Option<Vec<crate::structure::toc::Section>>,
variables: BTreeMap<String, Vec<Tag>>,
) {
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,
}
}
+43 -2
View File
@@ -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<TagNode>,
/// Primary listing URL, if any.
pub url: Option<String>,
/// Whether presentation classifies the tag as hidden.
pub hidden: bool,
/// Every matching listing URL in preference order.
pub links: Vec<TagLink>,
}
/// 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<Box<TagNode>>,
/// 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<Option<Box<TagNode>>> {
if value.is_none() {
Ok(None)
} else {
value.extract().map(Box::new).map(Some)
}
}
+17 -1
View File
@@ -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<C>(
&self, name: &str, fallback: &str, context: C,
) -> Result<String, Error>
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,
+8 -8
View File
@@ -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
+125 -105
View File
@@ -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<meta::Settings>,
/// 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<Vec<(Key<Id>, 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<Id>) {
let files = ctx.input::<Input>();
let configuration = ctx.input::<Configuration>();
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<Id, RoutedMarkdown>,
config: &Config, plugins: &plugin::Settings,
routed: &Stream<Id, RoutedMarkdown>,
) -> Stream<Id, RenderedMarkdown> {
// 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<Id, RenderedPage>,
) -> Stream<Id, RenderedPage> {
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<RenderedMarkdown> {
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<Id, RenderedPage>,
) -> Signal<Id, Site> {
) -> Signal<Id, Navigation> {
let config = config.clone();
pages.reduce(move |pages: &dyn Collection<Key<Id>, 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<Id, Site>) -> Signal<Id, Navigation> {
site.map(|site: &Site| site.nav.clone())
}
/// Render static and extra templates.
fn render_templates(
config: &Config, files: &Stream<Id, Input>, nav: &Signal<Id, Navigation>,
assets: &Signal<Id, minify::asset::Manifest>, minify: &minify::Settings,
assets: &Signal<Id, minify::Manifest>, minify: &minify::Minify,
) -> Stream<Id, ()> {
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<SitePath, PathError> {
/// Render pages.
fn render_pages(
config: &Config, site: &Signal<Id, Site>,
assets: &Signal<Id, minify::asset::Manifest>, minify: &minify::Settings,
config: &Config, pages: &Stream<Id, SitePage>,
nav: &Signal<Id, Navigation>, autorefs: &Signal<Id, autorefs::Registry>,
assets: &Signal<Id, minify::Manifest>, minify: &minify::Minify,
) -> Stream<Id, UnresolvedAutorefs> {
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::<Vec<_>>()
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<meta::Settings>,
config: &Config, strict: bool, serve: bool, meta: meta::Meta,
) -> Workflow<Id> {
Workflow::build(|workflow| {
Main {
config: config.clone(),
strict,
serve,
meta,
}
.setup(workflow);
+673
View File
@@ -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
<!-- material/tags -->
## After
Trailing content.
<div data-search-exclude>Secret listing-page text.</div>
""",
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 }}
<tags>{% for tag in tags %}
<tag name="{{ tag.name }}" url="{{ tag.url or '' }}"
hidden="{{ tag.hidden }}">{% for link in tag.links %}
<link title="{{ link.title }}" url="{{ link.url }}" />{% endfor %}
</tag>{% endfor %}
</tags>
<toc>{{ page.toc | tojson }}</toc>
""",
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 '<h2 id="tag:guide">' in listing
assert '<h3 id="tag:guide/rust">' 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
<!-- material/tags { include: [Public], toc: false } -->
```html
<!-- material/tags -->
```
""",
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 "&lt;!-- material/tags --&gt;" 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<!-- material/tags cards -->\n",
encoding="utf-8",
)
fragments = tmp_path / "overrides" / "fragments" / "tags" / "cards"
fragments.mkdir(parents=True)
(fragments / "tag.html").write_text(
'<x-tag data-name="{{ tag.name }}">{{ tag.name }}</x-tag>',
encoding="utf-8",
)
(fragments / "listing.html").write_text(
"""\
<x-listing name="{{ listing.tag.name }}">{{ listing.content }}
{% for mapping in listing.mappings %}
<x-page href="{{ mapping.item.url }}">{{ mapping.item.title }}</x-page>
{% endfor %}</x-listing>
""",
encoding="utf-8",
)
zensical.build(str(config), _BUILD_OPTIONS)
output = (tmp_path / "site" / "index.html").read_text()
assert '<x-listing name="Public">' in output
assert '<x-tag data-name="Public">Public</x-tag>' in output
assert '<x-page href="guide/rust/">Rust page</x-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 %}<ref name="{{ tag.name }}"
parent="{{ tag.parent.name if tag.parent else '' }}" />{% endfor %}
""",
encoding="utf-8",
)
fragments = tmp_path / "overrides" / "fragments" / "tags" / "default"
fragments.mkdir(parents=True)
(fragments / "tag.html").write_text(
'<tag name="{{ tag.name }}" '
'parent="{{ tag.parent.name if tag.parent else "" }}" />',
encoding="utf-8",
)
(fragments / "listing.html").write_text(
"""\
{% macro render(tree) %}
<listing name="{{ tree.tag.name }}">
{{ tree.content }}
{% for mapping in tree.mappings %}<mapping
title="{{ mapping.item.title }}"
audience="{{ mapping.item.meta.audience }}"
tags="{% for tag in mapping.tags %}{{ tag.name }};{% endfor %}" />
{% endfor %}
{% for child in tree %}{{ render(child) }}{% endfor %}
</listing>
{% 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 '<tag name="Guide/Rust" parent="Guide" />' in listing
assert 'audience="developers"' in listing
assert 'tags="Guide/Rust;Public;"' in listing
assert '<ref name="Guide/Rust"\n parent="Guide" />' 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(
"<!-- material/tags -->\n\n# Catalog\n",
encoding="utf-8",
)
zensical.build(str(config), _BUILD_OPTIONS)
output = (tmp_path / "site" / "index.html").read_text()
toc = output.partition("<toc>")[2].partition("</toc>")[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<!-- public/tags -->\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<!-- private/tags -->\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 }}
<tags>{% for tag in tags %}{{ tag.name }}={{ tag.url or '' }};
{% endfor %}</tags>
<labels>{% for tag in labels %}{{ tag.name }}={{ tag.url or '' }};
{% endfor %}</labels>
""",
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 '<h3 id="tag:/child">' 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<!-- material/tags { include: [true, 1.0] } -->\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
<!-- zensical:tags:0:0:0 -->
<!-- material/tags -->
""",
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 "<!-- zensical:tags:0:0:0 -->" 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<!-- material/tags -->\n", encoding="utf-8"
)
fragments = tmp_path / "overrides" / "fragments" / "tags" / "default"
fragments.mkdir(parents=True)
(fragments / "tag.html").write_text(
"<tag-base>{{ base_url }}</tag-base>", encoding="utf-8"
)
zensical.build(str(config), _BUILD_OPTIONS)
output = (tmp_path / "site" / "guide" / "index.html").read_text()
assert "<tag-base>..</tag-base>" 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<!-- material/tags -->\n", encoding="utf-8"
)
zensical.build(str(config), _BUILD_OPTIONS)
output = (tmp_path / "site" / "index.html").read_text()
assert "<h7" not in output
assert '<h6 id="tag:public">' in output
+27
View File
@@ -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:
+22 -4
View File
@@ -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)