mirror of
https://github.com/zensical/zensical.git
synced 2026-09-24 23:35:35 +00:00
feature: add tags MkDocs plugin replacement
Signed-off-by: squidfunk <martin.donath@squidfunk.com>
This commit is contained in:
Generated
+49
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
] }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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| §ion.id == following)
|
||||
})
|
||||
.unwrap_or(children.len());
|
||||
children.splice(index..index, rendered.toc.clone());
|
||||
}
|
||||
|
||||
/// Finds one section recursively by its stable heading ID.
|
||||
fn find_section_mut<'a>(
|
||||
sections: &'a mut [Section], id: &str,
|
||||
) -> Option<&'a mut Section> {
|
||||
for section in sections {
|
||||
if section.id == id {
|
||||
return Some(section);
|
||||
}
|
||||
if let Some(section) = find_section_mut(&mut section.children, id) {
|
||||
return Some(section);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Computes Material's path-closeness ordering without filesystem access.
|
||||
fn closeness(left: &str, right: &str) -> usize {
|
||||
let common = left
|
||||
.split('/')
|
||||
.zip(right.split('/'))
|
||||
.take_while(|(left, right)| left == right)
|
||||
.map(|(component, _)| component.len() + 1)
|
||||
.sum::<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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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 "<!-- material/tags -->" in output
|
||||
assert '"id":"tag:public"' not in output
|
||||
|
||||
|
||||
def test_invalid_tag_metadata_reports_the_page(tmp_path: Path) -> None:
|
||||
"""Mapping validation fails with the affected page in the diagnostic."""
|
||||
config = _write_project(tmp_path)
|
||||
(tmp_path / "docs" / "guide" / "rust.md").write_text(
|
||||
"---\ntags: scalar\n---\n# Rust\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match=r"guide/rust\.md"):
|
||||
zensical.build(str(config), _BUILD_OPTIONS)
|
||||
|
||||
|
||||
def test_named_layout_uses_project_fragment_overrides(tmp_path: Path) -> None:
|
||||
"""Named listing configuration retains Material's fragment contract."""
|
||||
config = _write_project(
|
||||
tmp_path,
|
||||
plugin="""\
|
||||
listings_map:
|
||||
cards:
|
||||
include: [Public]
|
||||
layout: cards
|
||||
toc: false
|
||||
""",
|
||||
)
|
||||
(tmp_path / "docs" / "index.md").write_text(
|
||||
"# Catalog\n\n<!-- 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
|
||||
Vendored
+27
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user