From 86646a4aaf98b5824417ee5924a5772bc0ebb6b4 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Tue, 1 Sep 2026 18:20:02 +0200 Subject: [PATCH] refactor: reconcile MkDocs plugin infrastructure Signed-off-by: squidfunk --- Cargo.toml | 4 + crates/zensical-serve/src/handler/matcher.rs | 2 +- .../src/handler/matcher/params.rs | 23 +- crates/zensical-serve/src/handler/scope.rs | 25 +- .../zensical-serve/src/middleware/convert.rs | 3 +- .../src/middleware/path/base.rs | 13 +- .../src/middleware/websocket.rs | 3 +- crates/zensical-serve/src/router.rs | 20 +- crates/zensical/src/compat.rs | 20 + crates/zensical/src/compat/mkdocs.rs | 23 +- crates/zensical/src/compat/mkdocs/html.rs | 39 +- crates/zensical/src/compat/mkdocs/plugin.rs | 33 +- .../src/compat/mkdocs/plugin/autorefs.rs | 337 ++----- .../mkdocs/plugin/autorefs/inventory.rs | 81 ++ .../compat/mkdocs/plugin/autorefs/parser.rs | 53 +- .../src/compat/mkdocs/plugin/autorefs/url.rs | 191 ++++ .../zensical/src/compat/mkdocs/plugin/meta.rs | 309 ++++--- .../compat/mkdocs/plugin/meta/admission.rs | 304 +++++++ .../src/compat/mkdocs/plugin/meta/parser.rs | 64 +- .../src/compat/mkdocs/plugin/minify.rs | 49 +- .../src/compat/mkdocs/plugin/minify/asset.rs | 455 ++++------ .../mkdocs/plugin/minify/asset/selector.rs | 160 ++++ .../mkdocs/plugin/minify/asset/writer.rs | 130 +++ .../src/compat/mkdocs/plugin/minify/html.rs | 826 +----------------- .../mkdocs/plugin/minify/html/inline.rs | 183 ++++ .../mkdocs/plugin/minify/html/serializer.rs | 468 ++++++++++ .../mkdocs/plugin/minify/html/syntax.rs | 360 ++++++++ .../src/compat/mkdocs/plugin/minify/script.rs | 32 +- .../src/compat/mkdocs/plugin/minify/style.rs | 32 +- .../src/compat/mkdocs/plugin/mkdocstrings.rs | 29 +- .../src/compat/mkdocs/plugin/redirects.rs | 573 +++--------- .../compat/mkdocs/plugin/redirects/output.rs | 146 ++++ .../compat/mkdocs/plugin/redirects/plan.rs | 490 +++++++++++ .../src/compat/mkdocs/plugin/search.rs | 61 +- .../src/compat/mkdocs/plugin/search/item.rs | 2 +- .../src/compat/mkdocs/plugin/search/parser.rs | 788 +++++++++-------- crates/zensical/src/compat/mkdocs/resource.rs | 320 +++++++ crates/zensical/src/config.rs | 112 +-- crates/zensical/src/config/theme.rs | 26 +- crates/zensical/src/lib.rs | 240 ++--- crates/zensical/src/path.rs | 71 ++ crates/zensical/src/path/relative.rs | 259 ++++++ crates/zensical/src/path/root.rs | 199 +++++ crates/zensical/src/path/site.rs | 175 ++++ crates/zensical/src/path/source.rs | 167 ++++ .../zensical/src/python/collector/anchor.rs | 4 +- .../src/python/collector/reference.rs | 4 +- .../python/collector/reference/footnote.rs | 2 +- .../src/python/collector/reference/link.rs | 3 +- .../zensical/src/python/collector/snippet.rs | 4 +- .../src/python/collector/snippet/file.rs | 2 +- .../src/python/collector/snippet/range.rs | 2 +- crates/zensical/src/python/issues.rs | 119 ++- crates/zensical/src/python/span.rs | 2 +- crates/zensical/src/server.rs | 3 +- crates/zensical/src/server/client.rs | 2 +- crates/zensical/src/structure/dynamic.rs | 27 +- crates/zensical/src/structure/markdown.rs | 28 +- crates/zensical/src/structure/nav.rs | 100 +-- crates/zensical/src/structure/nav/view.rs | 60 +- crates/zensical/src/structure/page.rs | 195 +++-- crates/zensical/src/template/filter.rs | 2 +- crates/zensical/src/template/output.rs | 2 +- crates/zensical/src/watcher.rs | 148 +++- crates/zensical/src/watcher/source.rs | 45 +- crates/zensical/src/workflow.rs | 385 +++----- crates/zensical/src/workflow/aggregate.rs | 254 ------ crates/zensical/src/workflow/cached.rs | 1 + python/tests/integration/test_config.py | 21 + python/tests/integration/test_meta.py | 193 ++++ python/tests/integration/test_minify.py | 116 +++ python/tests/integration/test_redirects.py | 90 ++ python/tests/unit/extensions/test_links.py | 70 ++ python/tests/unit/extensions/test_macros.py | 10 +- python/tests/unit/test_config.py | 2 + python/zensical/config.py | 8 +- python/zensical/extensions/macros.py | 5 +- 77 files changed, 6297 insertions(+), 3512 deletions(-) create mode 100644 crates/zensical/src/compat/mkdocs/plugin/autorefs/inventory.rs create mode 100644 crates/zensical/src/compat/mkdocs/plugin/autorefs/url.rs create mode 100644 crates/zensical/src/compat/mkdocs/plugin/meta/admission.rs create mode 100644 crates/zensical/src/compat/mkdocs/plugin/minify/asset/selector.rs create mode 100644 crates/zensical/src/compat/mkdocs/plugin/minify/asset/writer.rs create mode 100644 crates/zensical/src/compat/mkdocs/plugin/minify/html/inline.rs create mode 100644 crates/zensical/src/compat/mkdocs/plugin/minify/html/serializer.rs create mode 100644 crates/zensical/src/compat/mkdocs/plugin/minify/html/syntax.rs create mode 100644 crates/zensical/src/compat/mkdocs/plugin/redirects/output.rs create mode 100644 crates/zensical/src/compat/mkdocs/plugin/redirects/plan.rs create mode 100644 crates/zensical/src/compat/mkdocs/resource.rs create mode 100644 crates/zensical/src/path.rs create mode 100644 crates/zensical/src/path/relative.rs create mode 100644 crates/zensical/src/path/root.rs create mode 100644 crates/zensical/src/path/site.rs create mode 100644 crates/zensical/src/path/source.rs delete mode 100644 crates/zensical/src/workflow/aggregate.rs create mode 100644 python/tests/integration/test_meta.py create mode 100644 python/tests/unit/extensions/test_links.py diff --git a/Cargo.toml b/Cargo.toml index a63e4f5..3b90a2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,10 @@ resolver = "3" members = ["crates/*"] +[profile.release] +codegen-units = 1 +strip = "symbols" + [workspace.package] edition = "2024" rust-version = "1.86" diff --git a/crates/zensical-serve/src/handler/matcher.rs b/crates/zensical-serve/src/handler/matcher.rs index e85ccf4..f458a87 100644 --- a/crates/zensical-serve/src/handler/matcher.rs +++ b/crates/zensical-serve/src/handler/matcher.rs @@ -130,7 +130,7 @@ impl Matcher { /// ``` pub fn resolve<'v>(&self, path: &'v str) -> Option> { self.inner.at(path).ok().map(|route| Match { - params: Params::new(route.params), + params: params::new(route.params), data: route.value, }) } diff --git a/crates/zensical-serve/src/handler/matcher/params.rs b/crates/zensical-serve/src/handler/matcher/params.rs index bbd23bd..6b6fea6 100644 --- a/crates/zensical-serve/src/handler/matcher/params.rs +++ b/crates/zensical-serve/src/handler/matcher/params.rs @@ -53,19 +53,6 @@ pub struct Params<'k, 'v> { // Implementations // ---------------------------------------------------------------------------- -impl<'k, 'v> Params<'k, 'v> { - /// Creates matcher parameters. - /// - /// This method is used by the [`Matcher`][] to create matcher parameters - /// from the [`matchit::Params`] as returned by [`matchit`]. - /// - /// [`Matcher`]: crate::handler::Matcher - #[inline] - pub(crate) fn new(inner: matchit::Params<'k, 'v>) -> Self { - Params { inner } - } -} - impl<'k, 'v> Params<'k, 'v> { /// Returns the value for the given key. /// @@ -189,3 +176,13 @@ impl<'a, 'k, 'v> IntoIterator for &'a Params<'k, 'v> { self.iter() } } + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Wraps implementation parameters without exposing the implementation type. +#[inline] +pub fn new<'k, 'v>(inner: matchit::Params<'k, 'v>) -> Params<'k, 'v> { + Params { inner } +} diff --git a/crates/zensical-serve/src/handler/scope.rs b/crates/zensical-serve/src/handler/scope.rs index 10a6494..ae39a6b 100644 --- a/crates/zensical-serve/src/handler/scope.rs +++ b/crates/zensical-serve/src/handler/scope.rs @@ -34,7 +34,7 @@ use super::matcher::Route; /// Scope. #[derive(Clone, Debug, Default)] pub struct Scope { - // Base path for routes, optional. + /// Optional base path for routes. pub route: Option, } @@ -57,31 +57,10 @@ impl Scope { pub fn new() -> Self { Self { route: None } } - - /// Joins the scope with another scope. - #[must_use] - pub(crate) fn join(&self, scope: S) -> Self - where - S: Into, - { - let scope = scope.into(); - - // If both scopes define a route, append the route of the given scope - // to the route of the current scope. Otherwise, select the route. - let route = match (self.route.as_ref(), scope.route) { - (Some(head), Some(tail)) => Some(head.append(tail)), - (Some(head), None) => Some(head.clone()), - (None, Some(tail)) => Some(tail), - (None, None) => None, - }; - - // Return scope - Scope { route } - } } // ---------------------------------------------------------------------------- -// Implementations +// Trait implementations // ---------------------------------------------------------------------------- impl From for Scope { diff --git a/crates/zensical-serve/src/middleware/convert.rs b/crates/zensical-serve/src/middleware/convert.rs index 38d6cad..7af10c1 100644 --- a/crates/zensical-serve/src/middleware/convert.rs +++ b/crates/zensical-serve/src/middleware/convert.rs @@ -25,9 +25,10 @@ //! Middleware. -use super::Middleware; use crate::handler::{Result, Scope}; +use super::Middleware; + // ---------------------------------------------------------------------------- // Traits // ---------------------------------------------------------------------------- diff --git a/crates/zensical-serve/src/middleware/path/base.rs b/crates/zensical-serve/src/middleware/path/base.rs index e52db14..d3a2a9a 100644 --- a/crates/zensical-serve/src/middleware/path/base.rs +++ b/crates/zensical-serve/src/middleware/path/base.rs @@ -23,7 +23,7 @@ // ---------------------------------------------------------------------------- -//! tbd +//! Middleware for serving a site below a base path. use std::borrow::Cow; use std::str::FromStr; @@ -38,9 +38,9 @@ use crate::middleware::Middleware; // Structs // ---------------------------------------------------------------------------- -/// tbd +/// Middleware that redirects and removes a configured request base path. pub struct BasePath { - // Base path. + /// Base path removed from matching requests. base: Route, } @@ -88,7 +88,7 @@ impl Middleware for BasePath { } // ---------------------------------------------------------------------------- -// Helpers +// Functions // ---------------------------------------------------------------------------- fn strip_base_path(path: &str, base: &str) -> Option { @@ -107,9 +107,10 @@ fn strip_base_path(path: &str, base: &str) -> Option { #[cfg(test)] mod tests { - use super::*; - use crate::http::{Request, Response}; + use crate::middleware::Middleware; + + use super::BasePath; #[test] fn strips_base_path_once() { diff --git a/crates/zensical-serve/src/middleware/websocket.rs b/crates/zensical-serve/src/middleware/websocket.rs index fd06b7a..b2589b9 100644 --- a/crates/zensical-serve/src/middleware/websocket.rs +++ b/crates/zensical-serve/src/middleware/websocket.rs @@ -25,7 +25,8 @@ //! Middleware for WebSocket handshakes. -use base64::prelude::*; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::Engine; use sha1_smol::Sha1; use crate::handler::Handler; diff --git a/crates/zensical-serve/src/router.rs b/crates/zensical-serve/src/router.rs index ae2af3f..4584021 100644 --- a/crates/zensical-serve/src/router.rs +++ b/crates/zensical-serve/src/router.rs @@ -33,12 +33,10 @@ use super::handler::{Error, Result, Scope, TryIntoHandler}; use super::http::Method; use super::middleware::{Middleware, TryIntoMiddleware}; -// Re-export for convenient usage with routers -pub use super::handler::matcher::Params; - mod action; mod routes; +pub use super::handler::matcher::Params; pub use action::Action; use routes::Routes; @@ -406,7 +404,7 @@ impl TryIntoMiddleware for Router { // Join the parent scope with the scope derived from the router's base // path, which is then used for constructing routes and stacks - let scope = scope.join(path); + let scope = join_scope(scope, path); // Transform builders into middlewares - routers can host builders for // stacks and routes, both of which are converted into middlewares, and @@ -487,3 +485,17 @@ impl Default for Router { } } } + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Joins a router base path with its parent scope. +fn join_scope(scope: &Scope, route: Route) -> Scope { + // Preserve the parent prefix before appending the router-local base path. + let route = match &scope.route { + Some(parent) => parent.append(route), + None => route, + }; + Scope { route: Some(route) } +} diff --git a/crates/zensical/src/compat.rs b/crates/zensical/src/compat.rs index d600ffa..ddc80b5 100644 --- a/crates/zensical/src/compat.rs +++ b/crates/zensical/src/compat.rs @@ -3,6 +3,26 @@ // 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. + +// ---------------------------------------------------------------------------- + //! Compatibility implementations for established ecosystems. pub mod mkdocs; diff --git a/crates/zensical/src/compat/mkdocs.rs b/crates/zensical/src/compat/mkdocs.rs index 1f2c012..2390871 100644 --- a/crates/zensical/src/compat/mkdocs.rs +++ b/crates/zensical/src/compat/mkdocs.rs @@ -3,7 +3,28 @@ // 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 compatibility modules. -pub(crate) mod html; +pub mod html; pub mod plugin; +pub mod resource; diff --git a/crates/zensical/src/compat/mkdocs/html.rs b/crates/zensical/src/compat/mkdocs/html.rs index 697fe62..0b5bddd 100644 --- a/crates/zensical/src/compat/mkdocs/html.rs +++ b/crates/zensical/src/compat/mkdocs/html.rs @@ -3,6 +3,26 @@ // 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. + +// ---------------------------------------------------------------------------- + //! Shared HTML processing for MkDocs-compatible plugins. use html5gum::emitters::callback::{CallbackEmitter, CallbackEvent}; @@ -15,7 +35,7 @@ use std::ops::Range; // ---------------------------------------------------------------------------- /// Page-local observer participating in the shared HTML pass. -pub(crate) trait Visitor { +pub trait Visitor { /// Observes one tokenizer event and optionally records an output edit. fn visit( &mut self, event: &CallbackEvent<'_>, span: Span, @@ -28,7 +48,7 @@ pub(crate) trait Visitor { // ---------------------------------------------------------------------------- /// Deferred edits to the HTML currently being scanned. -pub(crate) struct Editor<'a> { +pub struct Editor<'a> { /// Original HTML input. input: &'a str, /// Edits recorded by visitors. @@ -55,12 +75,12 @@ impl<'a> Editor<'a> { } /// Returns original HTML covered by a tokenizer span. - pub(crate) fn text(&self, range: Range) -> &str { + pub fn text(&self, range: Range) -> &str { &self.input[range] } /// Replaces a byte range after all visitors have observed the input. - pub(crate) fn replace( + pub fn replace( &mut self, range: Range, replacement: impl Into>, ) { assert!(range.start <= range.end && range.end <= self.input.len()); @@ -71,7 +91,7 @@ impl<'a> Editor<'a> { } /// Removes the complete attribute whose name occupies `span`. - pub(crate) fn remove_attribute(&mut self, name: &[u8], span: Span) { + pub fn remove_attribute(&mut self, name: &[u8], span: Span) { let bytes = self.input.as_bytes(); assert!(span.start <= span.end && span.end <= bytes.len()); @@ -185,9 +205,7 @@ impl<'a> Editor<'a> { /// /// Returns modified HTML only when a visitor recorded an edit, allowing the /// caller to retain the original allocation for observational passes. -pub(crate) fn scan( - input: &str, visitors: &mut [&mut dyn Visitor], -) -> Option { +pub fn scan(input: &str, visitors: &mut [&mut dyn Visitor]) -> Option { let mut editor = Editor::new(input); { let mut emitter = CallbackEmitter::new( @@ -225,7 +243,10 @@ fn skip_whitespace(bytes: &[u8], offset: &mut usize) { #[cfg(test)] mod tests { - use super::*; + use html5gum::emitters::callback::CallbackEvent; + use html5gum::Span; + + use super::{scan, Editor, Visitor}; #[derive(Default)] struct RemoveDataAttribute; diff --git a/crates/zensical/src/compat/mkdocs/plugin.rs b/crates/zensical/src/compat/mkdocs/plugin.rs index 95da046..937d214 100644 --- a/crates/zensical/src/compat/mkdocs/plugin.rs +++ b/crates/zensical/src/compat/mkdocs/plugin.rs @@ -3,15 +3,36 @@ // 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-compatible plugins. use serde::{Deserialize, Serialize}; use std::sync::Arc; -use super::html::{self, Visitor}; use crate::config::Config; use crate::structure::markdown::Markdown; +use super::html::{self, Visitor}; + pub mod autorefs; pub mod meta; pub mod minify; @@ -25,7 +46,7 @@ pub mod search; /// Cached facts produced by the shared Markdown HTML pass. #[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] -pub(crate) struct HtmlFacts { +pub struct HtmlFacts { /// Page-local autoref placeholders replaced with stable slots. pub autorefs: Arc, /// Page-local search sections. @@ -36,7 +57,7 @@ pub(crate) struct HtmlFacts { /// Enabled MkDocs-compatible participants in the shared Markdown HTML pass. #[derive(Clone, Copy, Debug)] -pub(crate) struct Settings { +pub struct Settings { /// Whether autorefs extraction and settlement are active. pub autorefs: bool, /// Whether search extraction is active. @@ -49,7 +70,7 @@ pub(crate) struct Settings { impl Settings { /// Derives active compatibility participants from resolved configuration. - pub(crate) fn new(config: &Config) -> Self { + pub fn new(config: &Config) -> Self { Self { autorefs: autorefs::is_enabled(config), search: config.project.plugins.search.config.enabled, @@ -62,9 +83,7 @@ impl Settings { // ---------------------------------------------------------------------------- /// Runs enabled MkDocs-compatible visitors in one page-local HTML pass. -pub(crate) fn prepare( - markdown: &mut Markdown, settings: Settings, -) -> HtmlFacts { +pub fn prepare(markdown: &mut Markdown, settings: Settings) -> HtmlFacts { let mut autorefs = autorefs::Parser::default(); let mut search = search::parser(&markdown.meta); diff --git a/crates/zensical/src/compat/mkdocs/plugin/autorefs.rs b/crates/zensical/src/compat/mkdocs/plugin/autorefs.rs index e4fa2ce..01b79c9 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/autorefs.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/autorefs.rs @@ -1,7 +1,7 @@ -// Copyright (c) 2025 Zensical and contributors +// Copyright (c) 2025-2026 Zensical and contributors // SPDX-License-Identifier: MIT -// Third-party contributions licensed under DCO +// 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 @@ -29,26 +29,23 @@ use ahash::HashMap; use pyo3::types::PyAnyMethods; use pyo3::{FromPyObject, Python}; use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::Path; use std::string::ToString; use std::sync::Arc; -use zrx::id::Id; -use zrx::path::PathExt; -use zrx::stream::{Key, Value}; + +use zrx::stream::Value; use crate::compat::mkdocs::html; use crate::config::Config; -use crate::structure::nav::file_sort_key; +use crate::path::SourcePath; +use crate::structure::nav::source_sort_key; +mod inventory; mod parser; +mod url; -pub(crate) use parser::{Parser, References}; +pub use parser::{Parser, References}; use parser::{Reference, SLOT_PREFIX, SLOT_SUFFIX}; - -// ---------------------------------------------------------------------------- -// Constants -// ---------------------------------------------------------------------------- +use url::{closest, is_relative, relative}; /// Handled autoref attributes that should not be passed through to the output link. const HANDLED_ATTRS: &[&str] = &[ @@ -69,144 +66,6 @@ const HANDLED_ATTRS: &[&str] = &[ /// Python Markdown extension that produces autorefs compatibility facts. const EXTENSION_NAME: &str = "zensical.extensions.autorefs"; -// ---------------------------------------------------------------------------- -// Helper Functions -// ---------------------------------------------------------------------------- - -/// Escapes HTML special characters. -fn html_escape(text: &str) -> String { - text.replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") -} - -/// Helper to check if a URL is relative to a base URL. -fn is_relative_to(url: &str, base: &str) -> bool { - // Remove fragments and query strings for directory comparison - let url_path = url - .split('#') - .next() - .unwrap_or(url) - .split('?') - .next() - .unwrap_or(url); - let base_path = base - .split('#') - .next() - .unwrap_or(base) - .split('?') - .next() - .unwrap_or(base); - - // Use Path::starts_with for proper path comparison - Path::new(url_path).starts_with(Path::new(base_path)) -} - -/// Gets the parent path of a URL. -fn parent_path(url: &str) -> Option { - Path::new(url) - .parent() - .and_then(|p| p.to_str()) - .map(ToString::to_string) -} - -/// Resolves the closest URL from a list relative to from_url. -/// -/// We do that when multiple URLs are found for an identifier. -/// -/// By closest, we mean a combination of "relative to the current page" and "shortest distance from the current page". -/// -/// For example, if you link to identifier `hello` from page `foo/bar/`, -/// and the identifier is found in `foo/`, `foo/baz/` and `foo/bar/baz/qux/` pages, -/// autorefs will resolve to `foo/bar/baz/qux`, which is the only URL relative to `foo/bar/`. -/// -/// If multiple URLs are equally close, autorefs will resolve to the first of these equally close URLs. -/// If autorefs cannot find any URL that is close to the current page, it will log a warning and resolve to the first URL found. -/// -/// When false and multiple URLs are found for an identifier, autorefs will log a warning and resolve to the first URL. -fn resolve_closest_url( - from_url: &str, urls: &[String], _qualifier: &str, -) -> String { - let mut base_url = from_url.to_string(); - let candidates; - - loop { - let found: Vec = urls - .iter() - .filter(|url| is_relative_to(url, &base_url)) - .cloned() - .collect(); - - if !found.is_empty() { - candidates = found; - break; - } - - match parent_path(&base_url) { - Some(parent) if !parent.is_empty() => { - base_url = parent; - } - _ => { - // @todo Log warning using qualifier - return urls[0].clone(); - } - } - } - - if candidates.len() == 1 { - candidates[0].clone() - } else { - // Find the URL with the fewest slashes - candidates - .into_iter() - .min_by_key(|url| url.matches('/').count()) - .unwrap() - } -} - -/// Computes a relative URL from from_url to to_url. -fn relative_url(from_url: &str, to_url: &str) -> String { - let from_path = Path::new(from_url); - - // Split URL and fragment for relative computation - let (to_path, to_fragment) = to_url - .split_once('#') - .map_or((Path::new(to_url), None), |(path, f)| { - (Path::new(path), Some(f)) - }); - - // Make target URL relative to page - let mut rel_path = to_path - .relative_to(from_path) - .to_string_lossy() - .replace('\\', "/"); - - // Add fragment back if present - if let Some(frag) = to_fragment { - // If the relative path is "." and we have a fragment, - // just return the fragment - if rel_path == "." { - return format!("#{frag}"); - } - // If `to_path` was empty (URL was just a fragment), - // add "/" before the fragment - if to_path.as_os_str().is_empty() { - rel_path.push('/'); - } - rel_path.push('#'); - rel_path.push_str(frag); - } - - rel_path -} - -/// Checks if a URL is relative (no scheme). -fn is_relative_url(url: &str) -> bool { - !(url.starts_with("http://") || url.starts_with("https://")) -} - // ---------------------------------------------------------------------------- // Structs // ---------------------------------------------------------------------------- @@ -222,7 +81,7 @@ pub struct UnresolvedAutorefs { /// Shared immutable registry used to resolve page-local autorefs. #[derive(Clone, Debug)] -pub(crate) struct Registry(Option>); +pub struct Registry(Option>); // ---------------------------------------------------------------------------- @@ -231,7 +90,7 @@ pub(crate) struct Registry(Option>); Clone, Debug, Default, FromPyObject, Serialize, Deserialize, PartialEq, Eq, )] #[pyo3(from_item_all)] -pub(crate) struct Facts { +pub struct Facts { /// Primary page-local URLs. primary: HashMap>, /// Secondary page-local URLs. @@ -242,15 +101,6 @@ pub(crate) struct Facts { // ---------------------------------------------------------------------------- -/// Cached global inventory URLs supplied by mkdocstrings handlers. -#[derive(Debug, Default, Serialize, Deserialize)] -struct InventoryCache { - /// Absolute inventory URLs. - inventory: HashMap, -} - -// ---------------------------------------------------------------------------- - /// Autorefs (mkdocstrings). /// /// We use three URL maps, one for "primary" URLs, one for "secondary" URLs, @@ -325,7 +175,7 @@ impl Autorefs { // is re-exported, it should have a secondary URL instead. if let Some(urls) = self.primary.get(identifier) { if urls.len() > 1 && resolve_closest { - return Ok(resolve_closest_url(from_url, urls, "primary")); + return Ok(closest(from_url, urls, "primary")); // @todo Log warning about multiple URLs in production } return Ok(urls[0].clone()); @@ -354,7 +204,7 @@ impl Autorefs { // not have control over this. It means we shouldn't log // warnings when multiple secondary URLs are found, and // always resolve to closest. - return Ok(resolve_closest_url(from_url, urls, "secondary")); + return Ok(closest(from_url, urls, "secondary")); } return Ok(urls[0].clone()); } @@ -377,8 +227,8 @@ impl Autorefs { let title = self.titles.get(&url).cloned(); // If from_url is provided and URL is relative, compute relative URL - if is_relative_url(&url) { - url = relative_url(from_url, &url); + if is_relative(&url) { + url = relative(from_url, &url); } Ok((url, title)) @@ -418,7 +268,7 @@ impl Autorefs { match self.get_url_and_title_from_ids(&identifiers, from_url) { Ok((url, original_title)) => { - let external = !is_relative_url(&url); + let external = !is_relative(&url); let mut classes = vec![ "autorefs".to_string(), if external { @@ -558,12 +408,9 @@ impl Autorefs { } // ---------------------------------------------------------------------------- -// Trait implementations -// ---------------------------------------------------------------------------- - impl Registry { /// Replace autoref placeholders using this immutable registry. - pub(crate) fn replace_in( + pub fn replace_in( &self, content: S, references: &References, from_url: &str, ) -> (String, UnresolvedAutorefs) where @@ -579,16 +426,6 @@ impl Registry { // ---------------------------------------------------------------------------- -impl Value for Registry {} - -// ---------------------------------------------------------------------------- - -impl Value for UnresolvedAutorefs {} - -// ---------------------------------------------------------------------------- -// Implementations -// ---------------------------------------------------------------------------- - impl UnresolvedAutorefs { /// Records an identifier that failed to resolve. fn insert(&mut self, identifier: &str) { @@ -603,57 +440,45 @@ impl UnresolvedAutorefs { } } +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Value for Registry {} + +// ---------------------------------------------------------------------------- + +impl Value for UnresolvedAutorefs {} + // ---------------------------------------------------------------------------- // Functions // ---------------------------------------------------------------------------- /// Assemble a complete immutable registry from settled page-local facts. -pub(crate) fn assemble( - config: &Config, mut facts: Vec<(Key, Arc)>, +pub fn assemble( + config: &Config, mut facts: Vec<(SourcePath, Arc)>, ) -> Registry { if !is_enabled(config) { return Registry(None); } - facts.sort_by_key(|(key, _)| file_sort_key(&key[0])); + facts.sort_by_key(|(source, _)| source_sort_key(source)); let mut registry = Autorefs::new(); for (_, facts) in facts { registry.merge(&facts); } - registry.inventory = inventory(&config.get_cache_dir()); + registry.inventory = inventory::load(&config.get_cache_dir()); Registry(Some(Arc::new(registry))) } /// Returns whether autorefs is active after configuration shims are applied. -pub(super) fn is_enabled(config: &Config) -> bool { +pub fn is_enabled(config: &Config) -> bool { config.has_markdown_extension(EXTENSION_NAME) } -/// Collect and cache global inventory URLs supplied by mkdocstrings. -fn inventory(cache_dir: &Path) -> HashMap { - let path = cache_dir.join("autorefs.json"); - let mut cache = fs::read(&path) - .ok() - .and_then(|data| serde_json::from_slice::(&data).ok()) - .unwrap_or_default(); - - // An absent value means all pages came from the Markdown cache and Python - // never loaded mkdocstrings handlers. An empty map means rendering ran and - // no external inventory is configured, so it deliberately clears cache. - if let Some(inventory) = collect_inventory() { - cache.inventory = inventory; - } - - if let Ok(data) = serde_json::to_vec_pretty(&cache) { - let _ = fs::create_dir_all(cache_dir); - let _ = fs::write(path, data); - } - cache.inventory -} - /// Take registrations produced by the most recently rendered page. -pub(crate) fn take_page(url: &str) -> Arc { +pub fn take_page(url: &str) -> Arc { Arc::new( Python::attach(|py| { let module = py.import("zensical.extensions.autorefs")?; @@ -665,17 +490,6 @@ pub(crate) fn take_page(url: &str) -> Arc { ) } -/// Collect global inventory URLs if Python rendered at least one page. -fn collect_inventory() -> Option> { - Python::attach(|py| { - let module = py.import("zensical.extensions.autorefs")?; - module - .call_method0("get_autorefs_inventory_data")? - .extract::>>() - }) - .unwrap_or_default() -} - /// Merge URL lists while preserving registration order and uniqueness. fn merge_url_map( target: &mut HashMap>, @@ -691,9 +505,26 @@ fn merge_url_map( } } +/// Escapes text for use in generated HTML attributes and content. +fn html_escape(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + #[cfg(test)] mod tests { - use super::*; + use ahash::HashMap; + + use crate::compat::mkdocs::html; + + use super::{Autorefs, Facts, Parser, References}; fn prepare(input: &str) -> (String, References) { let mut parser = Parser::default(); @@ -723,70 +554,6 @@ mod tests { assert_eq!(autorefs.primary["shared"], ["one/#shared", "two/#shared"]); } - #[test] - fn test_resolve_closest_url() { - let test_cases = vec![ - ("", vec!["x/#b", "#b"], "#b"), - ("a/b", vec!["x/#e", "a/c/#e", "a/d/#e"], "a/c/#e"), - ("a/b/", vec!["x/#e", "a/d/#e", "a/c/#e"], "a/d/#e"), - ("a/b", vec!["x/#e", "a/c/#e", "a/c/d/#e"], "a/c/#e"), - ("a/b/", vec!["x/#e", "a/c/d/#e", "a/c/#e"], "a/c/#e"), - ( - "a/b/c", - vec!["x/#e", "a/#e", "a/b/#e", "a/b/c/#e", "a/b/c/d/#e"], - "a/b/c/#e", - ), - ( - "a/b/c/", - vec!["x/#e", "a/#e", "a/b/#e", "a/b/c/d/#e", "a/b/c/#e"], - "a/b/c/#e", - ), - ("a", vec!["b/c/#d", "c/#d"], "b/c/#d"), - ("a/", vec!["c/#d", "b/c/#d"], "c/#d"), - ]; - - for (base, urls, expected) in test_cases { - let urls: Vec = - urls.into_iter().map(String::from).collect(); - let result = resolve_closest_url(base, &urls, "test"); - assert_eq!(result, expected, "Failed for base: {base}"); - } - } - - #[test] - fn test_relative_url() { - let test_cases = vec![ - ("a/", "a#b", "#b"), - ("a/", "a/b#c", "b#c"), - ("a/b/", "a/b#c", "#c"), - ("a/b/", "a/c#d", "../c#d"), - ("a/b/", "a#c", "..#c"), - ("a/b/c/", "d#e", "../../../d#e"), - ("a/b/", "c/d/#e", "../../c/d/#e"), - ("a/index.html", "a/index.html#b", "#b"), - ("a/index.html", "a/b.html#c", "b.html#c"), - ("a/b.html", "a/b.html#c", "#c"), - ("a/b.html", "a/c.html#d", "c.html#d"), - ("a/b.html", "a/index.html#c", "index.html#c"), - ("a/b/c.html", "d.html#e", "../../d.html#e"), - ("a/b.html", "c/d.html#e", "../c/d.html#e"), - ("a/b/index.html", "a/b/c/d.html#e", "c/d.html#e"), - ("", "#x", "#x"), - ("a/", "#x", "../#x"), - ("a/b.html", "#x", "../#x"), - ("", "a/#x", "a/#x"), - ("", "a/b.html#x", "a/b.html#x"), - ]; - - for (current_url, to_url, expected_href) in test_cases { - let result = relative_url(current_url, to_url); - assert_eq!( - result, expected_href, - "Failed for relative_url('{current_url}', '{to_url}'), expected '{expected_href}' but got '{result}'" - ); - } - } - #[test] fn unresolved_autorefs_are_collected_while_replacing() { let mut autorefs = Autorefs::new(); diff --git a/crates/zensical/src/compat/mkdocs/plugin/autorefs/inventory.rs b/crates/zensical/src/compat/mkdocs/plugin/autorefs/inventory.rs new file mode 100644 index 0000000..b65f80a --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/autorefs/inventory.rs @@ -0,0 +1,81 @@ +// 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. + +// ---------------------------------------------------------------------------- + +//! Autorefs inventory. + +use ahash::HashMap; +use pyo3::types::PyAnyMethods; +use pyo3::Python; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Cached global inventory URLs supplied by mkdocstrings handlers. +#[derive(Debug, Default, Serialize, Deserialize)] +struct Cache { + /// Absolute inventory URLs. + inventory: HashMap, +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Collects and caches global inventory URLs supplied by mkdocstrings. +pub fn load(directory: &Path) -> HashMap { + let path = directory.join("autorefs.json"); + let mut cache = fs::read(&path) + .ok() + .and_then(|data| serde_json::from_slice::(&data).ok()) + .unwrap_or_default(); + + // An absent value means all pages came from the Markdown cache and Python + // never loaded mkdocstrings handlers. An empty map means rendering ran and + // no external inventory is configured, so it deliberately clears cache. + if let Some(inventory) = collect() { + cache.inventory = inventory; + } + + if let Ok(data) = serde_json::to_vec_pretty(&cache) { + let _ = fs::create_dir_all(directory); + let _ = fs::write(path, data); + } + cache.inventory +} + +/// Collects global inventory URLs if Python rendered at least one page. +fn collect() -> Option> { + Python::attach(|py| { + let module = py.import("zensical.extensions.autorefs")?; + module + .call_method0("get_autorefs_inventory_data")? + .extract::>>() + }) + .unwrap_or_default() +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/autorefs/parser.rs b/crates/zensical/src/compat/mkdocs/plugin/autorefs/parser.rs index a757d84..e26cb21 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/autorefs/parser.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/autorefs/parser.rs @@ -3,6 +3,26 @@ // 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 extraction of MkDocs-compatible autoref placeholders. use html5gum::emitters::callback::CallbackEvent; @@ -11,15 +31,11 @@ use serde::{Deserialize, Serialize}; use crate::compat::mkdocs::html::{Editor, Visitor}; -// ---------------------------------------------------------------------------- -// Constants -// ---------------------------------------------------------------------------- - /// Prefix of an internal page-local autoref slot. -pub(super) const SLOT_PREFIX: &str = ""; +pub const SLOT_SUFFIX: &str = " -->"; // ---------------------------------------------------------------------------- // Structs @@ -29,14 +45,14 @@ pub(super) const SLOT_SUFFIX: &str = " -->"; #[derive( Clone, Debug, Default, Deserialize, Hash, PartialEq, Eq, Serialize, )] -pub(crate) struct References { +pub struct References { /// References in document order; their positions are stable slot IDs. references: Vec, } /// One unresolved autoref placeholder. #[derive(Clone, Debug, Deserialize, Hash, PartialEq, Eq, Serialize)] -pub(super) struct Reference { +pub struct Reference { /// Attributes in their source order. attributes: Vec, /// Raw inner HTML used as link content. @@ -45,7 +61,7 @@ pub(super) struct Reference { /// One parsed HTML attribute. #[derive(Clone, Debug, Deserialize, Hash, PartialEq, Eq, Serialize)] -pub(super) struct Attribute { +struct Attribute { /// Decoded attribute name. name: String, /// Decoded attribute value, or an empty string for boolean attributes. @@ -54,7 +70,7 @@ pub(super) struct Attribute { /// Page-local autoref visitor. #[derive(Default)] -pub(crate) struct Parser { +pub struct Parser { /// Autoref start tag currently being assembled. pending: Option, /// Completed page-local references. @@ -81,12 +97,12 @@ struct Pending { impl References { /// Returns the reference at a page-local slot index. - pub(super) fn get(&self, index: usize) -> Option<&Reference> { + pub fn get(&self, index: usize) -> Option<&Reference> { self.references.get(index) } /// Returns whether no page-local autorefs were extracted. - pub(crate) fn is_empty(&self) -> bool { + pub fn is_empty(&self) -> bool { self.references.is_empty() } } @@ -95,7 +111,7 @@ impl References { impl Reference { /// Returns the last value for an attribute, matching the old map parser. - pub(super) fn get(&self, name: &str) -> Option<&str> { + pub fn get(&self, name: &str) -> Option<&str> { self.attributes .iter() .rev() @@ -104,21 +120,21 @@ impl Reference { } /// Returns whether an attribute is present. - pub(super) fn contains(&self, name: &str) -> bool { + pub fn contains(&self, name: &str) -> bool { self.attributes .iter() .any(|attribute| attribute.name == name) } /// Iterates over parsed attributes in source order. - pub(super) fn attributes(&self) -> impl Iterator { + pub fn attributes(&self) -> impl Iterator { self.attributes.iter().map(|attribute| { (attribute.name.as_str(), attribute.value.as_str()) }) } /// Returns raw inner HTML. - pub(super) fn title(&self) -> &str { + pub fn title(&self) -> &str { &self.title } } @@ -127,7 +143,7 @@ impl Reference { impl Parser { /// Converts the visitor into cached page-local references. - pub(crate) fn finish(self) -> References { + pub fn finish(self) -> References { References { references: self.references } } @@ -234,9 +250,10 @@ fn slot(index: usize) -> String { #[cfg(test)] mod tests { - use super::*; use crate::compat::mkdocs::html; + use super::{Parser, SLOT_PREFIX, SLOT_SUFFIX}; + #[test] fn extracts_attributes_and_raw_inner_html() { let input = concat!( diff --git a/crates/zensical/src/compat/mkdocs/plugin/autorefs/url.rs b/crates/zensical/src/compat/mkdocs/plugin/autorefs/url.rs new file mode 100644 index 0000000..d2b84bf --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/autorefs/url.rs @@ -0,0 +1,191 @@ +// 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. + +// ---------------------------------------------------------------------------- + +//! Autorefs URLs. + +use std::path::Path; +use std::string::ToString; + +use zrx::path::PathExt; + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Resolves the closest URL from a list relative to a source URL. +pub fn closest(from: &str, urls: &[String], _qualifier: &str) -> String { + let mut base = from.to_string(); + let candidates; + + loop { + let found = urls + .iter() + .filter(|url| is_relative_to(url, &base)) + .cloned() + .collect::>(); + + if !found.is_empty() { + candidates = found; + break; + } + + match parent(&base) { + Some(parent) if !parent.is_empty() => base = parent, + _ => return urls[0].clone(), + } + } + + if candidates.len() == 1 { + candidates[0].clone() + } else { + candidates + .into_iter() + .min_by_key(|url| url.matches('/').count()) + .expect("candidate list is nonempty") + } +} + +/// Computes a relative URL from one page URL to another. +pub fn relative(from: &str, to: &str) -> String { + let from = Path::new(from); + let (to, fragment) = to + .split_once('#') + .map_or((Path::new(to), None), |(path, fragment)| { + (Path::new(path), Some(fragment)) + }); + let mut relative = + to.relative_to(from).to_string_lossy().replace('\\', "/"); + + if let Some(fragment) = fragment { + if relative == "." { + return format!("#{fragment}"); + } + if to.as_os_str().is_empty() { + relative.push('/'); + } + relative.push('#'); + relative.push_str(fragment); + } + relative +} + +/// Returns whether a URL has no HTTP(S) scheme. +pub fn is_relative(url: &str) -> bool { + !(url.starts_with("http://") || url.starts_with("https://")) +} + +/// Returns whether one URL path begins with another at a component boundary. +fn is_relative_to(url: &str, base: &str) -> bool { + let url = url + .split('#') + .next() + .unwrap_or(url) + .split('?') + .next() + .unwrap_or(url); + let base = base + .split('#') + .next() + .unwrap_or(base) + .split('?') + .next() + .unwrap_or(base); + Path::new(url).starts_with(Path::new(base)) +} + +/// Returns the parent path of a URL. +fn parent(url: &str) -> Option { + Path::new(url) + .parent() + .and_then(Path::to_str) + .map(ToString::to_string) +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::{closest, relative}; + + #[test] + fn resolves_the_closest_url() { + let cases = [ + ("", vec!["x/#b", "#b"], "#b"), + ("a/b", vec!["x/#e", "a/c/#e", "a/d/#e"], "a/c/#e"), + ("a/b/", vec!["x/#e", "a/d/#e", "a/c/#e"], "a/d/#e"), + ("a/b", vec!["x/#e", "a/c/#e", "a/c/d/#e"], "a/c/#e"), + ("a/b/", vec!["x/#e", "a/c/d/#e", "a/c/#e"], "a/c/#e"), + ( + "a/b/c", + vec!["x/#e", "a/#e", "a/b/#e", "a/b/c/#e", "a/b/c/d/#e"], + "a/b/c/#e", + ), + ( + "a/b/c/", + vec!["x/#e", "a/#e", "a/b/#e", "a/b/c/d/#e", "a/b/c/#e"], + "a/b/c/#e", + ), + ("a", vec!["b/c/#d", "c/#d"], "b/c/#d"), + ("a/", vec!["c/#d", "b/c/#d"], "c/#d"), + ]; + + for (base, urls, expected) in cases { + let urls = urls.into_iter().map(String::from).collect::>(); + assert_eq!(closest(base, &urls, "test"), expected, "base: {base}"); + } + } + + #[test] + fn computes_relative_urls() { + let cases = [ + ("a/", "a#b", "#b"), + ("a/", "a/b#c", "b#c"), + ("a/b/", "a/b#c", "#c"), + ("a/b/", "a/c#d", "../c#d"), + ("a/b/", "a#c", "..#c"), + ("a/b/c/", "d#e", "../../../d#e"), + ("a/b/", "c/d/#e", "../../c/d/#e"), + ("a/index.html", "a/index.html#b", "#b"), + ("a/index.html", "a/b.html#c", "b.html#c"), + ("a/b.html", "a/b.html#c", "#c"), + ("a/b.html", "a/c.html#d", "c.html#d"), + ("a/b.html", "a/index.html#c", "index.html#c"), + ("a/b/c.html", "d.html#e", "../../d.html#e"), + ("a/b.html", "c/d.html#e", "../c/d.html#e"), + ("a/b/index.html", "a/b/c/d.html#e", "c/d.html#e"), + ("", "#x", "#x"), + ("a/", "#x", "../#x"), + ("a/b.html", "#x", "../#x"), + ("", "a/#x", "a/#x"), + ("", "a/b.html#x", "a/b.html#x"), + ]; + + for (from, to, expected) in cases { + assert_eq!(relative(from, to), expected, "from: {from}, to: {to}"); + } + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/meta.rs b/crates/zensical/src/compat/mkdocs/plugin/meta.rs index 0b2264f..9714b67 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/meta.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/meta.rs @@ -3,6 +3,26 @@ // 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 metadata inheritance. use anyhow::{bail, Context, Result}; @@ -13,49 +33,28 @@ use std::ops::Range; use std::path::Path; use crate::config::Config; +use crate::path::{SourcePath, SourceRoot}; use crate::structure::dynamic::Dynamic; +mod admission; mod parser; -// ---------------------------------------------------------------------------- -// Structs -// ---------------------------------------------------------------------------- +pub use admission::{Admission, Prepared}; -/// Metadata plugin settings used by the workflow. -#[derive(Clone, Debug)] -pub(crate) struct Settings { - /// Whether inheritance is enabled. - pub enabled: bool, - /// Exact basename of metadata files. - pub meta_file: String, -} - -/// A source range expressed as UTF-8 byte offsets. -#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct SourceSpan { - /// Source identifier. - pub source: String, - /// Half-open byte range within the complete source. - pub range: Range, -} +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- /// Origin of one metadata value. #[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) enum Origin { +pub enum Origin { /// Value read from a source document. Source(SourceSpan), /// Value created or changed during rendering. Runtime, } -/// A source-aware metadata value. -#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct Node { - /// Origin of this node. - origin: Origin, - /// Value and source-aware children. - value: Value, -} +// ---------------------------------------------------------------------------- /// Recursive metadata value. #[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] @@ -68,25 +67,66 @@ enum Value { Map(BTreeMap), } +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Metadata plugin settings used by the workflow. +#[derive(Clone, Debug)] +pub struct Settings { + /// Whether inheritance is enabled. + pub enabled: bool, + /// Exact basename of metadata files. + pub meta_file: String, +} + +// ---------------------------------------------------------------------------- + +/// A source range expressed as UTF-8 byte offsets. +#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceSpan { + /// Source identifier. + pub source: SourcePath, + /// Half-open byte range within the complete source. + pub range: Range, +} + +// ---------------------------------------------------------------------------- + +/// A source-aware metadata value. +#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct Node { + /// Origin of this node. + origin: Origin, + /// Value and source-aware children. + value: Value, +} + +// ---------------------------------------------------------------------------- + /// One parsed YAML metadata document. #[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct Document { +pub struct Document { /// Source-relative path. - path: String, + path: SourcePath, /// Source-aware root mapping. root: Node, } +// ---------------------------------------------------------------------------- + /// Metadata resolved for one Markdown page. #[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct Resolved { +pub struct Resolved { /// Source-aware root mapping. root: Node, } +// ---------------------------------------------------------------------------- + /// Immutable metadata documents available to one workflow revision. #[derive(Clone, Debug, Default, PartialEq, Eq)] -pub(crate) struct Index { +pub struct Index { /// Parsed metadata files, shared by every page in the revision. documents: Vec, } @@ -97,7 +137,7 @@ pub(crate) struct Index { impl Settings { /// Extracts native meta settings from resolved configuration. - pub(crate) fn new(config: &Config) -> Self { + pub fn new(config: &Config) -> Self { let config = &config.project.plugins.meta.config; Self { enabled: config.enabled, @@ -106,24 +146,9 @@ impl Settings { } } -impl Node { - /// Creates a runtime-owned tree from a plain dynamic value. - fn runtime(value: Dynamic) -> Self { - let value = match value { - Dynamic::List(values) => { - Value::List(values.into_iter().map(Self::runtime).collect()) - } - Dynamic::Map(values) => Value::Map( - values - .into_iter() - .map(|(key, value)| (key, Self::runtime(value))) - .collect(), - ), - value => Value::Scalar(value), - }; - Self { origin: Origin::Runtime, value } - } +// ---------------------------------------------------------------------------- +impl Node { /// Projects a source-aware tree into template-visible metadata. fn dynamic(&self) -> Dynamic { match &self.value { @@ -139,55 +164,13 @@ impl Node { ), } } - - /// Retains origins for values unchanged by Python extensions. - fn reconcile(&self, value: Dynamic) -> Self { - if self.dynamic() == value { - return self.clone(); - } - match (&self.value, value) { - (Value::Map(previous), Dynamic::Map(current)) => { - let values = current - .into_iter() - .map(|(key, value)| { - let value = if let Some(previous) = previous.get(&key) { - previous.reconcile(value) - } else { - Self::runtime(value) - }; - (key, value) - }) - .collect(); - Self { - origin: Origin::Runtime, - value: Value::Map(values), - } - } - (Value::List(previous), Dynamic::List(current)) => { - let values = current - .into_iter() - .enumerate() - .map(|(index, value)| { - if let Some(previous) = previous.get(index) { - previous.reconcile(value) - } else { - Self::runtime(value) - } - }) - .collect(); - Self { - origin: Origin::Runtime, - value: Value::List(values), - } - } - (_, value) => Self::runtime(value), - } - } } +// ---------------------------------------------------------------------------- + impl Resolved { /// Returns plain values for the Python Markdown boundary. - pub(crate) fn values(&self) -> BTreeMap { + pub fn values(&self) -> BTreeMap { let Value::Map(values) = &self.root.value else { unreachable!("metadata root is always a mapping") }; @@ -196,34 +179,30 @@ impl Resolved { .map(|(key, value)| (key.clone(), value.dynamic())) .collect() } - - /// Reconciles source origins with metadata returned from Python. - pub(crate) fn reconcile(&self, values: BTreeMap) -> Self { - let root = self.root.reconcile(Dynamic::Map(values)); - Self { root } - } } +// ---------------------------------------------------------------------------- + impl Index { /// Loads and parses every configured metadata file exactly once. - pub(crate) fn load(docs: &Path, settings: &Settings) -> Result { + pub fn load(docs: &SourceRoot, settings: &Settings) -> Result { if !settings.enabled { return Ok(Self::default()); } let mut documents = Vec::new(); - collect(docs, docs, settings, &mut documents)?; + collect(docs, docs.as_path(), settings, &mut documents)?; + sort_documents(&mut documents); Ok(Self { documents }) } /// Resolves the metadata chain applicable to one page. - pub(crate) fn resolve( - &self, page: &str, front_matter: Option, + pub fn resolve( + &self, page: &SourcePath, front_matter: Option, ) -> Result { - resolve( + resolve_ordered( self.documents .iter() - .filter(|document| applies(&document.path, page)) - .cloned(), + .filter(|document| applies(&document.path, page)), front_matter, ) } @@ -234,29 +213,26 @@ impl Index { // ---------------------------------------------------------------------------- /// Returns whether a source is claimed as a metadata file. -pub(crate) fn claims(path: &str, settings: &Settings) -> bool { +pub fn claims(path: &str, settings: &Settings) -> bool { settings.enabled && path.rsplit('/').next() == Some(settings.meta_file.as_str()) } /// Returns whether a metadata file applies to a Markdown page. -pub(crate) fn applies(meta: &str, page: &str) -> bool { - let parent = meta.rsplit_once('/').map_or("", |(parent, _)| parent); - parent.is_empty() - || page - .strip_prefix(parent) - .is_some_and(|suffix| suffix.starts_with('/')) +pub fn applies(meta: &SourcePath, page: &SourcePath) -> bool { + meta.parent() + .is_none_or(|parent| page.is_descendant_of(&parent)) } /// Parses one standalone metadata file. -pub(crate) fn parse(path: &str, source: &str) -> Result { - parser::parse(path, source, 0) +pub fn parse(path: SourcePath, source: &str) -> Result { + parser::parse(path.clone(), source, 0) .with_context(|| format!("error reading meta file '{path}'")) } /// Extracts and parses YAML front matter from a Markdown source. -pub(crate) fn front_matter( - path: &str, source: &str, +pub fn front_matter( + path: &SourcePath, source: &str, ) -> Result<(String, Option)> { parser::front_matter(path, source) .with_context(|| format!("error reading page metadata '{path}'")) @@ -264,7 +240,7 @@ pub(crate) fn front_matter( /// Recursively loads metadata documents from the docs tree. fn collect( - root: &Path, directory: &Path, settings: &Settings, + root: &SourceRoot, directory: &Path, settings: &Settings, documents: &mut Vec, ) -> Result<()> { for entry in std::fs::read_dir(directory)? { @@ -275,28 +251,30 @@ fn collect( .file_name() .is_some_and(|name| name == settings.meta_file.as_str()) { - let relative = path.strip_prefix(root)?; - let location = relative.to_string_lossy().replace('\\', "/"); + let relative = path.strip_prefix(root.as_path())?; + let location = SourcePath::from_path(relative)?; let source = std::fs::read_to_string(&path)?; - documents.push(parse(&location, &source)?); + documents.push(parse(location, &source)?); } } Ok(()) } -/// Resolves applicable meta files and page front matter. -pub(crate) fn resolve( - documents: impl IntoIterator, page: Option, -) -> Result { - let mut documents = documents.into_iter().collect::>(); +/// Sorts metadata from broad ancestors to specific descendants once. +fn sort_documents(documents: &mut [Document]) { documents.sort_by(|left, right| { - let left_depth = left.path.matches('/').count(); - let right_depth = right.path.matches('/').count(); + let left_depth = left.path.depth(); + let right_depth = right.path.depth(); left_depth .cmp(&right_depth) .then(left.path.cmp(&right.path)) }); +} +/// Resolves metadata whose source order is already deterministic. +fn resolve_ordered<'a>( + documents: impl IntoIterator, page: Option, +) -> Result { let mut root = Node { origin: Origin::Runtime, value: Value::Map(BTreeMap::new()), @@ -367,20 +345,60 @@ fn scalar_kind(value: &Dynamic) -> u8 { } } +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + #[cfg(test)] mod tests { - use super::*; + use std::collections::BTreeMap; + + use anyhow::Result; + + use crate::path::{SourcePath, SourceRoot}; + use crate::structure::dynamic::Dynamic; + + use super::{ + applies, parse, resolve_ordered, sort_documents, Document, Index, + Origin, Resolved, Settings, Value, + }; + + fn resolve( + documents: impl IntoIterator, page: Option, + ) -> Result { + let mut documents = documents.into_iter().collect::>(); + sort_documents(&mut documents); + resolve_ordered(&documents, page) + } fn values(source: &str) -> BTreeMap { - resolve([], Some(parse("docs/page.md", source).unwrap())) + resolve([], Some(parse(path("docs/page.md"), source).unwrap())) .unwrap() .values() } + fn path(value: &str) -> SourcePath { + value.parse().unwrap() + } + #[test] fn matches_path_components() { - assert!(applies("docs/guide/.meta.yml", "docs/guide/page.md")); - assert!(!applies("docs/guide/.meta.yml", "docs/guidelines/page.md")); + assert!(applies( + &path("docs/guide/.meta.yml"), + &path("docs/guide/page.md") + )); + assert!(!applies( + &path("docs/guide/.meta.yml"), + &path("docs/guidelines/page.md") + )); + assert!(applies( + &path("docs/café/defaults.yml"), + &path("docs/café/nested/page.md") + )); + assert!(!applies( + &path("docs/café/defaults.yml"), + &path("docs/café-other/page.md") + )); } #[test] @@ -390,9 +408,11 @@ mod tests { #[test] fn merges_maps_and_appends_lists() { - let root = parse("docs/.meta.yml", "x:\n a: 1\nitems: [a]\n").unwrap(); + let root = + parse(path("docs/.meta.yml"), "x:\n a: 1\nitems: [a]\n").unwrap(); let nested = - parse("docs/guide/.meta.yml", "x:\n b: 2\nitems: [b]\n").unwrap(); + parse(path("docs/guide/.meta.yml"), "x:\n b: 2\nitems: [b]\n") + .unwrap(); let values = resolve([nested, root], None).unwrap().values(); assert_eq!( values["items"], @@ -412,8 +432,8 @@ mod tests { #[test] fn rejects_type_mismatch() { - let root = parse("docs/.meta.yml", "value: text\n").unwrap(); - let page = parse("docs/page.md", "value: [text]\n").unwrap(); + let root = parse(path("docs/.meta.yml"), "value: text\n").unwrap(); + let page = parse(path("docs/page.md"), "value: [text]\n").unwrap(); let error = format!("{:#}", resolve([root], Some(page)).unwrap_err()); assert!(error.contains(".meta.yml")); assert!(error.contains("page.md")); @@ -421,8 +441,8 @@ mod tests { #[test] fn scalar_override_keeps_winning_source() { - let root = parse(".meta.yml", "title: Default\n").unwrap(); - let page = parse("page.md", "title: Page\n").unwrap(); + let root = parse(path(".meta.yml"), "title: Default\n").unwrap(); + let page = parse(path("page.md"), "title: Page\n").unwrap(); let resolved = resolve([root], Some(page)).unwrap(); let Value::Map(values) = &resolved.root.value else { panic!("mapping") @@ -430,7 +450,7 @@ mod tests { let Origin::Source(span) = &values["title"].origin else { panic!("source") }; - assert_eq!(span.source, "page.md"); + assert_eq!(span.source.as_str(), "page.md"); } #[test] @@ -451,9 +471,10 @@ mod tests { enabled: true, meta_file: ".meta.yml".into(), }; - let values = Index::load(docs, &settings) + let root = SourceRoot::open(docs).unwrap(); + let values = Index::load(&root, &settings) .unwrap() - .resolve("guide/page.md", None) + .resolve(&path("guide/page.md"), None) .unwrap() .values(); assert_eq!( diff --git a/crates/zensical/src/compat/mkdocs/plugin/meta/admission.rs b/crates/zensical/src/compat/mkdocs/plugin/meta/admission.rs new file mode 100644 index 0000000..31332e6 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/meta/admission.rs @@ -0,0 +1,304 @@ +// 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. + +// ---------------------------------------------------------------------------- + +//! Provider admission workaround for revision-local metadata facts. + +use anyhow::Result; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::{fs, io}; + +use zrx::id::Id; +use zrx::stream::{Change, Key}; + +use crate::path::{SourcePath, SourceRoot}; +use crate::watcher::Source; + +use super::{claims, Index, Settings}; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Provider-side metadata state retained while one workflow is serving. +/// +/// This is the narrow admission workaround for the pinned runtime. It keeps +/// the immutable parsed index alive across unrelated source revisions and +/// rebuilds it only when the documentation metadata relation changes. +pub struct Admission { + /// Documentation root used to resolve metadata descendants. + docs: SourceRoot, + /// Provider context whose metadata changes are admitted. + context: String, + /// Immutable settings shared with the metadata pipeline. + settings: Arc, + /// Most recently prepared metadata index. + index: Option>, +} + +// ---------------------------------------------------------------------------- + +/// Metadata facts prepared for one provider revision. +pub struct Prepared { + /// Immutable metadata index for the admitted revision. + pub index: Arc, + /// Descendant Markdown sources invalidated by metadata changes. + pub dependents: Vec<(Key, Source)>, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Admission { + /// Creates metadata admission state for one workflow lifetime. + pub fn new( + docs: SourceRoot, context: String, settings: Arc, + ) -> Self { + Self { + docs, + context, + settings, + index: None, + } + } + + /// Refreshes metadata when needed and returns dependent page insertions. + pub fn prepare( + &mut self, changes: &[Change], + ) -> Result { + if self.index.is_none() + || changes.iter().any(|change| self.claims(change_key(change))) + { + self.index = + Some(Arc::new(Index::load(&self.docs, &self.settings)?)); + } + let dependents = self.dependents(changes)?; + Ok(Prepared { + index: self + .index + .as_ref() + .expect("metadata index initialized above") + .clone(), + dependents, + }) + } + + /// Returns whether this source is a documentation metadata file. + fn claims(&self, key: &Key) -> bool { + key[0].context() == self.context + && claims(&key[0].location(), &self.settings) + } + + /// Expands metadata changes into descendant Markdown insertions. + fn dependents( + &self, changes: &[Change], + ) -> Result, Source)>> { + if !self.settings.enabled { + return Ok(Vec::new()); + } + let mut dependents = BTreeMap::new(); + for change in changes { + let key = change_key(change); + if !self.claims(key) { + continue; + } + let location = key[0].location().parse::()?; + let directory = location.parent().map_or_else( + || self.docs.as_path().to_owned(), + |parent| self.docs.join(&parent), + ); + let mut paths = Vec::new(); + collect_markdown(&directory, &mut paths)?; + for path in paths { + let relative = path.strip_prefix(self.docs.as_path())?; + let location = SourcePath::from_path(relative)?; + let id = key[0] + .to_builder() + .location(location.as_str()) + .build() + .expect("invariant"); + dependents.insert(Key::from(id), Source::from(path)); + } + } + + // A provider update for the page itself is authoritative. In + // particular, the initial snapshot contains both metadata files and + // every Markdown page, so synthesized inserts must not admit each page + // twice. + for change in changes { + dependents.remove(change_key(change)); + } + Ok(dependents.into_iter().collect()) + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Returns the resource key carried by one provider change. +fn change_key(change: &Change) -> &Key { + match change { + Change::Insert(key, _) | Change::Remove(key) => key, + } +} + +/// Collects descendant Markdown source paths below one metadata directory. +fn collect_markdown( + directory: &Path, paths: &mut Vec, +) -> io::Result<()> { + let Ok(entries) = fs::read_dir(directory) else { + return Ok(()); + }; + for entry in entries { + let path = entry?.path(); + if path.is_dir() { + collect_markdown(&path, paths)?; + } else if path.extension().is_some_and(|extension| extension == "md") { + paths.push(path); + } + } + Ok(()) +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::{Path, PathBuf}; + use std::sync::Arc; + + use tempfile::tempdir; + use zrx::id::Id; + use zrx::stream::{Change, Key}; + + use crate::path::SourceRoot; + use crate::watcher::Source; + + use super::Admission; + use super::Settings; + + #[test] + fn selects_only_descendant_markdown() { + let dir = tempdir().unwrap(); + let docs = dir.path(); + fs::create_dir_all(docs.join("guide/nested")).unwrap(); + fs::create_dir_all(docs.join("guidelines")).unwrap(); + fs::write(docs.join("guide/page.md"), "# Page").unwrap(); + fs::write(docs.join("guide/nested/page.md"), "# Nested").unwrap(); + fs::write(docs.join("guidelines/page.md"), "# Other").unwrap(); + let changes = vec![source_insert( + "docs", + "guide/.meta.yml", + docs.join("guide/.meta.yml"), + )]; + let mut metadata = admission(docs); + + let locations = metadata + .prepare(&changes) + .unwrap() + .dependents + .iter() + .map(|(key, _)| key[0].location().into_owned()) + .collect::>(); + assert_eq!(locations, vec!["guide/nested/page.md", "guide/page.md"]); + } + + #[test] + fn provider_page_change_supersedes_metadata_dependent() { + let dir = tempdir().unwrap(); + let docs = dir.path(); + fs::create_dir_all(docs.join("guide")).unwrap(); + let page = docs.join("guide/page.md"); + fs::write(&page, "# Page").unwrap(); + let changes = vec![ + source_insert( + "docs", + "guide/.meta.yml", + docs.join("guide/.meta.yml"), + ), + source_insert("docs", "guide/page.md", page), + ]; + let mut metadata = admission(docs); + + assert!(metadata.prepare(&changes).unwrap().dependents.is_empty()); + } + + #[test] + fn reuses_index_until_a_docs_metadata_change() { + let dir = tempdir().unwrap(); + let docs = dir.path(); + let path = docs.join(".meta.yml"); + fs::write(&path, "value: first\n").unwrap(); + let mut metadata = admission(docs); + + let initial = metadata.prepare(&[]).unwrap().index; + let unrelated = + vec![source_insert("docs", "asset.txt", docs.join("asset.txt"))]; + let reused = metadata.prepare(&unrelated).unwrap().index; + assert!(Arc::ptr_eq(&initial, &reused)); + + let theme = vec![source_insert( + "templates/0", + ".meta.yml", + dir.path().join("theme/.meta.yml"), + )]; + let still_reused = metadata.prepare(&theme).unwrap().index; + assert!(Arc::ptr_eq(&initial, &still_reused)); + + fs::write(&path, "value: second\n").unwrap(); + let changed = vec![source_insert("docs", ".meta.yml", path)]; + let refreshed = metadata.prepare(&changed).unwrap().index; + assert!(!Arc::ptr_eq(&initial, &refreshed)); + } + + fn admission(docs: &Path) -> Admission { + Admission::new( + SourceRoot::open(docs).unwrap(), + "docs".into(), + Arc::new(Settings { + enabled: true, + meta_file: ".meta.yml".into(), + }), + ) + } + + fn source_insert( + context: &str, location: &str, path: PathBuf, + ) -> Change { + let id = Id::builder() + .provider("file") + .context(context) + .location(location) + .build() + .unwrap(); + Change::Insert(Key::from(id), Source::from(path)) + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/meta/parser.rs b/crates/zensical/src/compat/mkdocs/plugin/meta/parser.rs index 4197f37..02506cb 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/meta/parser.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/meta/parser.rs @@ -3,18 +3,44 @@ // 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. + +// ---------------------------------------------------------------------------- + //! Source-aware YAML parsing for metadata. use anyhow::{bail, Result}; use saphyr::{LoadableYamlNode, MarkedYaml, Scalar, YamlData}; use std::collections::BTreeMap; -use super::{Document, Node, Origin, SourceSpan, Value}; +use crate::path::SourcePath; use crate::structure::dynamic::Dynamic; +use super::{Document, Node, Origin, SourceSpan, Value}; + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + /// Parses one YAML mapping and retains source ranges on every value. -pub(super) fn parse( - path: &str, source: &str, offset: usize, +pub fn parse( + path: SourcePath, source: &str, offset: usize, ) -> Result { let (source, offset) = source .strip_prefix('\u{FEFF}') @@ -23,12 +49,12 @@ pub(super) fn parse( if documents.len() != 1 { bail!("metadata must contain exactly one YAML document") } - let root = convert(path, source, offset, &documents[0])?; + let root = convert(&path, source, offset, &documents[0])?; match root.value { - Value::Map(_) => Ok(Document { path: path.into(), root }), + Value::Map(_) => Ok(Document { path, root }), Value::Scalar(Dynamic::Null) if source.trim().is_empty() => { Ok(Document { - path: path.into(), + path, root: Node { origin: root.origin, value: Value::Map(BTreeMap::new()), @@ -40,8 +66,8 @@ pub(super) fn parse( } /// Extracts front matter using the same delimiters as Python Markdown. -pub(super) fn front_matter( - path: &str, source: &str, +pub fn front_matter( + path: &SourcePath, source: &str, ) -> Result<(String, Option)> { let (source, source_offset) = source .strip_prefix('\u{FEFF}') @@ -62,7 +88,7 @@ pub(super) fn front_matter( let body = source[cursor + line.len()..] .trim_start_matches('\n') .to_owned(); - return parse(path, yaml, yaml_start + source_offset) + return parse(path.clone(), yaml, yaml_start + source_offset) .map(|document| (body, Some(document))); } cursor += line.len(); @@ -80,10 +106,10 @@ fn is_delimiter(line: &str, delimiter: &str) -> bool { /// Converts one Saphyr node into an owned source-aware node. fn convert( - path: &str, source: &str, offset: usize, node: &MarkedYaml<'_>, + path: &SourcePath, source: &str, offset: usize, node: &MarkedYaml<'_>, ) -> Result { let origin = Origin::Source(SourceSpan { - source: path.into(), + source: path.clone(), range: marker_to_byte(source, node.span.start.index()) + offset ..marker_to_byte(source, node.span.end.index()) + offset, }); @@ -177,13 +203,19 @@ fn marker_to_byte(source: &str, index: usize) -> usize { } } +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + #[cfg(test)] mod tests { - use super::*; + use super::{front_matter, parse, Origin, Value}; #[test] fn retains_unicode_byte_ranges() { - let document = parse("docs/.meta.yml", "title: Héllo\n", 0).unwrap(); + let document = + parse("docs/.meta.yml".parse().unwrap(), "title: Héllo\n", 0) + .unwrap(); let Value::Map(values) = document.root.value else { panic!("mapping") }; @@ -196,7 +228,8 @@ mod tests { #[test] fn extracts_front_matter_and_offsets_ranges() { let source = "---\ntitle: Home\n---\n\n# Home\n"; - let (body, document) = front_matter("docs/index.md", source).unwrap(); + let path = "docs/index.md".parse().unwrap(); + let (body, document) = front_matter(&path, source).unwrap(); assert_eq!(body, "# Home\n"); let document = document.unwrap(); let Value::Map(values) = document.root.value else { @@ -211,7 +244,8 @@ mod tests { #[test] fn expands_alias_merge_keys() { let source = "base: &base\n one: 1\nvalue:\n <<: *base\n two: 2\n"; - let document = parse("docs/.meta.yml", source, 0).unwrap(); + let document = + parse("docs/.meta.yml".parse().unwrap(), source, 0).unwrap(); let Value::Map(root) = document.root.value else { panic!("mapping") }; diff --git a/crates/zensical/src/compat/mkdocs/plugin/minify.rs b/crates/zensical/src/compat/mkdocs/plugin/minify.rs index fae4622..39f564e 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/minify.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/minify.rs @@ -3,36 +3,71 @@ // 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-compatible minify plugin. +use std::path::Path; + use crate::config::plugins::MinifyPluginConfig; use crate::config::Config; -use std::path::Path; -pub(crate) mod asset; +pub mod asset; mod html; mod script; mod style; +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + /// Resolved minification settings shared by page render tasks. #[derive(Clone, Debug)] -pub(crate) struct Settings { +pub struct Settings { + /// Normalized MkDocs-compatible minification configuration. config: MinifyPluginConfig, } +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + impl Settings { /// Resolves minification settings from project configuration. - pub(crate) fn new(config: &Config) -> Self { + pub fn new(config: &Config) -> Self { Self { config: config.project.plugins.minify.config.clone(), } } + /// Returns the normalized plugin configuration for the asset stage. + pub fn config(&self) -> &MinifyPluginConfig { + &self.config + } + /// Processes one final HTML document after all compatibility mutations. /// /// External asset options stay in the resolved plugin configuration for /// the asset output stage and do not affect this document-local pass. - pub(crate) fn html(&self, content: impl Into) -> String { + pub fn html(&self, content: impl Into) -> String { let content = content.into(); if !self.config.enabled { return content; @@ -57,9 +92,7 @@ impl Settings { } /// Processes a rendered static template when it produces HTML. - pub(crate) fn template( - &self, name: &str, content: impl Into, - ) -> String { + pub fn template(&self, name: &str, content: impl Into) -> String { let content = content.into(); if Path::new(name) .extension() diff --git a/crates/zensical/src/compat/mkdocs/plugin/minify/asset.rs b/crates/zensical/src/compat/mkdocs/plugin/minify/asset.rs index 6dd99ef..c9a55de 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/minify/asset.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/minify/asset.rs @@ -3,120 +3,135 @@ // 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. + +// ---------------------------------------------------------------------------- + //! External asset processing for the MkDocs-compatible minify plugin. use anyhow::{anyhow, Context as _}; -use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; use sha2::{Digest, Sha384}; use std::collections::{BTreeMap, BTreeSet}; +use std::fs; use std::hash::{DefaultHasher, Hash, Hasher}; -use std::path::{Component, Path, PathBuf}; use std::sync::Arc; -use std::{fs, io}; -use zrx::id::Id; -use zrx::scheduler::action::{Action, Concurrency, Context}; -use zrx::stream::function::Collection; -use zrx::stream::operator::Operator; -use zrx::stream::{Change, Key, Signal, Stream, Value}; +use zrx::id::Id; +use zrx::stream::function::Collection; +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::path::SitePath; +use crate::watcher::Source; +use super::Settings as PluginSettings; use super::{script, style}; -// ----------------------------------------------------------------------------- -// Structs -// ----------------------------------------------------------------------------- +mod selector; +mod writer; -/// One source that may become a site asset. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct Resource { - /// Logical output path relative to the site directory. - pub(crate) path: String, - /// Physical source path. - pub(crate) source: PathBuf, - /// Override priority, with lower values taking precedence. - pub(crate) priority: usize, +use selector::{normalize, Selector}; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// Supported external asset kinds. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Kind { + JavaScript, + Stylesheet, } -impl Value for Resource {} - /// Final asset bytes or a source file that can be copied without reading it. #[derive(Clone, Debug)] enum Contents { - Copy(PathBuf), + Copy(Source), Bytes(Arc<[u8]>), } +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + /// One fully resolved output asset. #[derive(Clone, Debug)] struct Emission { - source_path: String, - output_path: String, + /// Original site-relative resource path. + source_path: SitePath, + /// Final site-relative path after hashing and minification. + output_path: SitePath, + /// Bytes to write or source file to copy. contents: Contents, + /// Whether this plugin transformed and owns the resource. claimed: bool, } -impl Value for Emission {} - /// Path rewrite produced by one claimed asset. #[derive(Clone, Debug, PartialEq, Eq)] struct Mapping { - source_path: String, - output_path: String, + /// Original configured asset path. + source_path: SitePath, + /// Final emitted asset path. + output_path: SitePath, } -impl Value for Mapping {} - /// Asset-derived project view consumed by template rendering. #[derive(Clone, Debug)] -pub(crate) struct Manifest { +pub struct Manifest { /// Project with configured asset paths rewritten to their emitted names. - pub(crate) project: Arc, + pub project: Arc, /// Stable hash of the current path mapping. - pub(crate) hash: u64, + pub hash: u64, } -impl Value for Manifest {} - /// Compiled settings for external asset selection and transformation. #[derive(Clone, Debug)] struct Settings { + /// Whether the compatibility plugin is enabled. enabled: bool, - javascript: Selectors, - stylesheet: Selectors, - minify: BTreeSet, + /// JavaScript resources claimed by the plugin. + javascript: Selector, + /// Stylesheet resources claimed by the plugin. + stylesheet: Selector, + /// Asset kinds whose contents should be minified. + minify: BTreeSet, + /// Whether transformed names include a content digest. cache_safe: bool, } -/// Exact and glob selectors for one kind of asset. -#[derive(Clone, Debug)] -struct Selectors { - exact: BTreeSet, - globs: GlobSet, - error: Option, -} - -/// Writes insertions and removes retracted output paths. -#[derive(Clone)] -struct Writer { - root_dir: PathBuf, - site_dir: String, -} - -// ----------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- // Implementations -// ----------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- impl Settings { + /// Compiles asset settings once for the workflow. fn new(config: &MinifyPluginConfig) -> Self { Self { enabled: config.enabled, - javascript: Selectors::new(&config.js_files), - stylesheet: Selectors::new(&config.css_files), + javascript: Selector::new(&config.js_files), + stylesheet: Selector::new(&config.css_files), minify: [ - config.minify_js.then_some(AssetKind::JavaScript), - config.minify_css.then_some(AssetKind::Stylesheet), + config.minify_js.then_some(Kind::JavaScript), + config.minify_css.then_some(Kind::Stylesheet), ] .into_iter() .flatten() @@ -125,30 +140,32 @@ impl Settings { } } - fn claim(&self, path: &str) -> anyhow::Result> { + /// Classifies one resource, rejecting conflicting selectors. + fn claim(&self, path: &SitePath) -> anyhow::Result> { if !self.active() { return Ok(None); } - let javascript = self.active_kind(AssetKind::JavaScript) - && self.javascript.matches(path)?; - let stylesheet = self.active_kind(AssetKind::Stylesheet) - && self.stylesheet.matches(path)?; + let javascript = self.active_kind(Kind::JavaScript) + && self.javascript.matches(path.as_str())?; + let stylesheet = self.active_kind(Kind::Stylesheet) + && self.stylesheet.matches(path.as_str())?; match (javascript, stylesheet) { (true, true) => Err(anyhow!( "asset is selected as both JavaScript and CSS: {path}" )), (true, false) => { ensure_extension(path, "js")?; - Ok(Some(AssetKind::JavaScript)) + Ok(Some(Kind::JavaScript)) } (false, true) => { ensure_extension(path, "css")?; - Ok(Some(AssetKind::Stylesheet)) + Ok(Some(Kind::Stylesheet)) } (false, false) => Ok(None), } } + /// Produces the effective output for one resource. fn transform(&self, resource: &Resource) -> anyhow::Result { let Some(kind) = self.claim(&resource.path)? else { return Ok(Emission { @@ -166,8 +183,8 @@ impl Settings { let minify = self.minify.contains(&kind); let transformed = if minify { match kind { - AssetKind::JavaScript => script::minify(&source, false), - AssetKind::Stylesheet => style::minify(&source), + Kind::JavaScript => script::minify(&source, false), + Kind::Stylesheet => style::minify(&source), } .filter(|output| output.len() < source.len()) .unwrap_or(source) @@ -188,6 +205,7 @@ impl Settings { }) } + /// Validates exact selectors against the settled claimed relation. fn validate<'a>( &self, mappings: impl Iterator, ) -> anyhow::Result<()> { @@ -195,13 +213,13 @@ impl Settings { return Ok(()); } for (kind, selectors) in [ - (AssetKind::JavaScript, &self.javascript), - (AssetKind::Stylesheet, &self.stylesheet), + (Kind::JavaScript, &self.javascript), + (Kind::Stylesheet, &self.stylesheet), ] { if !self.active_kind(kind) { continue; } - if let Some(error) = &selectors.error { + if let Some(error) = selectors.error() { return Err(anyhow!("invalid minify asset selector: {error}")); } } @@ -209,12 +227,12 @@ impl Settings { .map(|mapping| mapping.source_path.as_str()) .collect::>(); let missing = [ - (AssetKind::JavaScript, &self.javascript), - (AssetKind::Stylesheet, &self.stylesheet), + (Kind::JavaScript, &self.javascript), + (Kind::Stylesheet, &self.stylesheet), ] .into_iter() .filter(|(kind, _)| self.active_kind(*kind)) - .flat_map(|(_, selectors)| &selectors.exact) + .flat_map(|(_, selector)| selector.exact()) .filter(|path| !found.contains(path.as_str())) .collect::>(); if missing.is_empty() { @@ -231,81 +249,34 @@ impl Settings { } } + /// Returns whether any transformation mode is active. fn active(&self) -> bool { - self.active_kind(AssetKind::JavaScript) - || self.active_kind(AssetKind::Stylesheet) + self.active_kind(Kind::JavaScript) || self.active_kind(Kind::Stylesheet) } - fn active_kind(&self, kind: AssetKind) -> bool { + /// Returns whether one asset kind participates in this build. + fn active_kind(&self, kind: Kind) -> bool { if !self.enabled || (!self.minify.contains(&kind) && !self.cache_safe) { return false; } match kind { - AssetKind::JavaScript => !self.javascript.is_empty(), - AssetKind::Stylesheet => !self.stylesheet.is_empty(), + Kind::JavaScript => !self.javascript.is_empty(), + Kind::Stylesheet => !self.stylesheet.is_empty(), } } } -impl Selectors { - fn new(patterns: &[String]) -> Self { - let mut exact = BTreeSet::new(); - let mut builder = GlobSetBuilder::new(); - let mut error = None; - for pattern in patterns { - let pattern = normalize(pattern); - if let Err(reason) = validate_relative(&pattern) { - error.get_or_insert_with(|| reason.to_string()); - continue; - } - if contains_glob(&pattern) { - match GlobBuilder::new(&pattern) - .literal_separator(true) - .backslash_escape(false) - .build() - { - Ok(pattern) => { - builder.add(pattern); - } - Err(reason) => { - error.get_or_insert_with(|| reason.to_string()); - } - } - } else { - exact.insert(pattern); - } - } - let globs = builder.build().unwrap_or_else(|reason| { - error.get_or_insert_with(|| reason.to_string()); - GlobSetBuilder::new().build().expect("empty glob set") - }); - Self { exact, globs, error } - } - - fn matches(&self, path: &str) -> anyhow::Result { - if let Some(error) = &self.error { - return Err(anyhow!("invalid minify asset selector: {error}")); - } - let path = normalize(path); - Ok(self.exact.contains(&path) || self.globs.is_match(path)) - } - - fn is_empty(&self) -> bool { - self.error.is_none() && self.exact.is_empty() && self.globs.is_empty() - } -} - impl Manifest { - pub(crate) fn base(project: Arc) -> Self { - Self { project, hash: 0 } - } - + /// Projects emitted asset names into template-visible project settings. fn new<'a>( project: Arc, mappings: impl Iterator, ) -> Self { let paths = mappings .map(|mapping| { - (mapping.source_path.clone(), mapping.output_path.clone()) + ( + mapping.source_path.to_string(), + mapping.output_path.to_string(), + ) }) .collect::>(); if paths.is_empty() { @@ -346,93 +317,39 @@ impl Manifest { } } -impl Writer { - fn output_path(&self, key: &Key) -> anyhow::Result { - let id = key.try_as_id()?; - if id.context() != self.site_dir { - return Err(anyhow!("asset output escaped the site directory")); - } - let location = id.location(); - validate_relative(&location)?; - Ok(self.root_dir.join(id.to_path())) - } +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- - fn insert(&self, key: &Key, emission: &Emission) -> anyhow::Result<()> { - let path = self.output_path(key)?; - fs::create_dir_all(path.parent().expect("site asset has parent"))?; - match &emission.contents { - Contents::Copy(source) => copy_file(source, path)?, - Contents::Bytes(bytes) => fs::write(path, bytes)?, - } - Ok(()) - } +impl Value for Emission {} - fn remove(&self, key: &Key) -> anyhow::Result<()> { - let path = self.output_path(key)?; - match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error.into()), - } - } -} +// ---------------------------------------------------------------------------- -impl Action> for Writer { - type Inputs = (Emission,); - type Output = (); +impl Value for Mapping {} - fn concurrency(&self) -> Concurrency { - Concurrency::adaptive() - } +// ---------------------------------------------------------------------------- - fn execute(&mut self, context: Context<'_, Key, Self>) { - let Context { inputs: input, output, .. } = context; - input.for_each(output, |change, emit| { - match change { - Change::Insert(key, emission) => { - self.insert(&key, emission.as_ref())?; - emit.insert(key, ()); - } - Change::Remove(key) => { - self.remove(&key)?; - emit.remove(key); - } - } - Ok(()) - }); - } -} +impl Value for Manifest {} -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -enum AssetKind { - JavaScript, - Stylesheet, -} - -// ----------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- // Functions -// ----------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- /// Transforms selected resources, writes every effective asset, and publishes /// the project view whose configured asset paths name the emitted files. -pub(crate) fn attach( - config: &Config, resources: &Stream, +pub fn attach( + config: &Config, plugin: &PluginSettings, resources: &Stream, ) -> Signal { - let settings = Settings::new(&config.project.plugins.minify.config); + let settings = Settings::new(plugin.config()); let settings_for_transform = settings.clone(); let emissions = resources.map(move |resource: &Resource| { settings_for_transform.transform(resource) }); - let site_dir = config.project.site_dir.clone(); - let site_dir_for_key = site_dir.clone(); let outputs = emissions.unique_by_key(move |emission: &Emission| { - output_key(&site_dir_for_key, &emission.output_path) - }); - let _ = outputs.subscribe(Writer { - root_dir: config.get_root_dir(), - site_dir, + output_key(&emission.output_path) }); + writer::attach(config.output_root().clone(), &outputs); let project = config.project.clone(); let settings_for_manifest = settings; @@ -452,50 +369,22 @@ pub(crate) fn attach( }) } -/// Returns whether configuration claims any external asset resources. -pub(crate) fn is_enabled(config: &Config) -> bool { - Settings::new(&config.project.plugins.minify.config).active() -} - -/// Creates the group key used to resolve theme and project overrides before -/// any asset is transformed or written. -pub(crate) fn resource_key(path: &str) -> anyhow::Result> { - validate_relative(path)?; - let id = Id::builder() - .provider("asset") - .context(".") - .location(path) - .build()?; - Ok(Key::from(id)) -} - -fn output_key(site_dir: &str, path: &str) -> anyhow::Result> { - validate_relative(path)?; +fn output_key(path: &SitePath) -> anyhow::Result> { let id = Id::builder() .provider("file") - .context(site_dir) - .location(path) + .context(".") + .location(path.as_str()) .build()?; Ok(Key::from(id)) } fn output_path( - path: &str, minify: bool, digest: Option, -) -> anyhow::Result { - let path = Path::new(path); + path: &SitePath, minify: bool, digest: Option, +) -> anyhow::Result { let extension = path .extension() - .and_then(|value| value.to_str()) - .ok_or_else(|| { - anyhow!("selected asset has no extension: {}", path.display()) - })?; - let stem = path - .file_stem() - .and_then(|value| value.to_str()) - .ok_or_else(|| { - anyhow!("selected asset has no file name: {}", path.display()) - })?; - let mut name = stem.to_owned(); + .ok_or_else(|| anyhow!("selected asset has no extension: {path}"))?; + let mut name = path.file_stem().to_owned(); if let Some(digest) = digest { name.push('.'); name.push_str(&digest[..6]); @@ -505,10 +394,7 @@ fn output_path( } name.push('.'); name.push_str(extension); - Ok(path - .with_file_name(name) - .to_string_lossy() - .replace('\\', "/")) + Ok(path.with_file_name(&name)?) } fn digest(bytes: &[u8]) -> String { @@ -523,35 +409,9 @@ fn rewrite(path: &str, mappings: &BTreeMap) -> String { .unwrap_or_else(|| path.to_owned()) } -fn normalize(path: &str) -> String { - path.replace('\\', "/") - .trim_start_matches("./") - .trim_start_matches('/') - .to_owned() -} - -fn contains_glob(pattern: &str) -> bool { - pattern - .bytes() - .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b']')) -} - -fn validate_relative(path: &str) -> anyhow::Result<()> { - if path.is_empty() - || Path::new(path).is_absolute() - || Path::new(path) - .components() - .any(|component| matches!(component, Component::ParentDir)) - { - return Err(anyhow!("asset path must be relative to the site: {path}")); - } - Ok(()) -} - -fn ensure_extension(path: &str, expected: &str) -> anyhow::Result<()> { - if Path::new(path) +fn ensure_extension(path: &SitePath, expected: &str) -> anyhow::Result<()> { + if path .extension() - .and_then(|extension| extension.to_str()) .is_some_and(|extension| extension.eq_ignore_ascii_case(expected)) { Ok(()) @@ -562,47 +422,54 @@ fn ensure_extension(path: &str, expected: &str) -> anyhow::Result<()> { } } -fn copy_file(from: impl AsRef, to: impl AsRef) -> io::Result<()> { - let mut from = fs::File::open(from)?; - let mut to = fs::File::create(to)?; - io::copy(&mut from, &mut to).map(|_| ()) -} - -// ----------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- // Tests -// ----------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- #[cfg(test)] mod tests { - use super::*; + use std::collections::BTreeMap; - #[test] - fn selectors_support_exact_paths_and_recursive_globs() { - let selectors = - Selectors::new(&["scripts/app.js".into(), "vendor/**/*.js".into()]); - assert!(selectors.matches("./scripts/app.js").unwrap()); - assert!(selectors.matches("vendor/lib/tool.js").unwrap()); - assert!(!selectors.matches("scripts/other.js").unwrap()); - } + use crate::path::SitePath; + + use super::{digest, output_key, output_path, rewrite}; #[test] fn output_names_follow_minify_and_cache_safe_modes() { assert_eq!( - output_path("assets/app.js", true, None).unwrap(), - "assets/app.min.js" + output_path(&"assets/app.js".parse().unwrap(), true, None).unwrap(), + "assets/app.min.js".parse::().unwrap() ); assert_eq!( - output_path("assets/app.js", false, Some("abcdef12".into())) - .unwrap(), - "assets/app.abcdef.js" + output_path( + &"assets/app.js".parse().unwrap(), + false, + Some("abcdef12".into()) + ) + .unwrap(), + "assets/app.abcdef.js".parse::().unwrap() ); assert_eq!( - output_path("assets/app.js", true, Some("abcdef12".into())) - .unwrap(), - "assets/app.abcdef.min.js" + output_path( + &"assets/app.js".parse().unwrap(), + true, + Some("abcdef12".into()) + ) + .unwrap(), + "assets/app.abcdef.min.js".parse::().unwrap() ); } + #[test] + fn output_keys_contain_only_site_relative_identity() { + let key = output_key(&"assets/café.js".parse().unwrap()).unwrap(); + let id = key.try_as_id().unwrap(); + + assert_eq!(id.context(), "."); + assert_eq!(id.location(), "assets/café.js"); + assert!("../outside.js".parse::().is_err()); + } + #[test] fn digest_uses_sha384_and_emitted_bytes() { assert_eq!(&digest(b"const value=1;")[..6], "11c998"); diff --git a/crates/zensical/src/compat/mkdocs/plugin/minify/asset/selector.rs b/crates/zensical/src/compat/mkdocs/plugin/minify/asset/selector.rs new file mode 100644 index 0000000..ea16915 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/minify/asset/selector.rs @@ -0,0 +1,160 @@ +// 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. + +// ---------------------------------------------------------------------------- + +//! Minify asset selector. + +use anyhow::anyhow; +use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; +use std::collections::BTreeSet; + +use crate::path::SitePath; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Compiled exact and glob asset selectors. +#[derive(Clone, Debug)] +pub struct Selector { + exact: BTreeSet, + globs: GlobSet, + error: Option, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Selector { + /// Compiles configured asset selectors. + pub fn new(patterns: &[String]) -> Self { + let mut exact = BTreeSet::new(); + let mut builder = GlobSetBuilder::new(); + let mut error = None; + for pattern in patterns { + let pattern = normalize(pattern); + if let Err(reason) = pattern.parse::() { + error.get_or_insert_with(|| reason.to_string()); + continue; + } + if contains_glob(&pattern) { + match GlobBuilder::new(&pattern) + .literal_separator(true) + .backslash_escape(false) + .build() + { + Ok(pattern) => { + builder.add(pattern); + } + Err(reason) => { + error.get_or_insert_with(|| reason.to_string()); + } + } + } else { + exact.insert(pattern); + } + } + let globs = builder.build().unwrap_or_else(|reason| { + error.get_or_insert_with(|| reason.to_string()); + GlobSetBuilder::new().build().expect("empty glob set") + }); + Self { exact, globs, error } + } + + /// Returns whether the selector matches a path. + pub fn matches(&self, path: &str) -> anyhow::Result { + if let Some(error) = &self.error { + return Err(anyhow!("invalid minify asset selector: {error}")); + } + let path = normalize(path); + Ok(self.exact.contains(&path) || self.globs.is_match(path)) + } + + /// Returns configured exact paths. + pub fn exact(&self) -> impl Iterator { + self.exact.iter() + } + + /// Returns a compilation error, if any. + pub fn error(&self) -> Option<&str> { + self.error.as_deref() + } + + /// Returns whether no valid selector was configured. + pub fn is_empty(&self) -> bool { + self.error.is_none() && self.exact.is_empty() && self.globs.is_empty() + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Normalizes one MkDocs-compatible configured asset path. +pub fn normalize(path: &str) -> String { + path.replace('\\', "/") + .trim_start_matches("./") + .trim_start_matches('/') + .to_owned() +} + +/// Returns whether a selector contains glob syntax. +fn contains_glob(pattern: &str) -> bool { + pattern + .bytes() + .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b']')) +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::Selector; + + #[test] + fn supports_exact_paths_and_recursive_globs() { + let selector = Selector::new(&[ + "scripts/app.js".into(), + "vendor/**/*.js".into(), + "scripts/café.js".into(), + ]); + assert!(selector.matches("./scripts/app.js").unwrap()); + assert!(selector.matches("/scripts/app.js").unwrap()); + assert!(selector.matches("scripts\\app.js").unwrap()); + assert!(selector.matches("vendor/lib/tool.js").unwrap()); + assert!(selector.matches("scripts/café.js").unwrap()); + assert!(!selector.matches("scripts/other.js").unwrap()); + } + + #[test] + fn rejects_parent_traversal_and_empty_paths() { + for path in ["", "../scripts/app.js", "scripts/../app.js"] { + let selector = Selector::new(&[path.into()]); + assert!(selector.matches("scripts/app.js").is_err(), "{path}"); + } + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/minify/asset/writer.rs b/crates/zensical/src/compat/mkdocs/plugin/minify/asset/writer.rs new file mode 100644 index 0000000..c754c49 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/minify/asset/writer.rs @@ -0,0 +1,130 @@ +// 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. + +// ---------------------------------------------------------------------------- + +//! Minify asset writer. + +use anyhow::anyhow; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use zrx::id::Id; +use zrx::scheduler::action::{Action, Concurrency, Context}; +use zrx::stream::operator::Operator; +use zrx::stream::{Change, Key, Stream}; + +use crate::path::{OutputRoot, SitePath}; + +use super::{Contents, Emission}; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Writes insertions and removes retracted output paths. +#[derive(Clone)] +struct Writer { + output: OutputRoot, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Writer { + fn path(&self, key: &Key) -> anyhow::Result { + let id = key.try_as_id()?; + if id.context() != "." { + return Err(anyhow!("asset output escaped the site directory")); + } + let path = id.location().parse::()?; + Ok(self.output.join(&path)) + } + + fn insert(&self, key: &Key, emission: &Emission) -> anyhow::Result<()> { + let path = self.path(key)?; + fs::create_dir_all(path.parent().expect("site asset has parent"))?; + match &emission.contents { + Contents::Copy(source) => copy(source, path)?, + Contents::Bytes(bytes) => fs::write(path, bytes)?, + } + Ok(()) + } + + fn remove(&self, key: &Key) -> anyhow::Result<()> { + let path = self.path(key)?; + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Action> for Writer { + type Inputs = (Emission,); + type Output = (); + + fn concurrency(&self) -> Concurrency { + Concurrency::adaptive() + } + + fn execute(&mut self, context: Context<'_, Key, Self>) { + let Context { inputs: input, output, .. } = context; + input.for_each(output, |change, emit| { + match change { + Change::Insert(key, emission) => { + self.insert(&key, emission.as_ref())?; + emit.insert(key, ()); + } + Change::Remove(key) => { + self.remove(&key)?; + emit.remove(key); + } + } + Ok(()) + }); + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Attaches a removal-aware writer to the emission relation. +pub fn attach(output: OutputRoot, emissions: &Stream) { + let _ = emissions.subscribe(Writer { output }); +} + +/// Copies one physical source into its output file. +fn copy(from: impl AsRef, to: impl AsRef) -> io::Result<()> { + let mut from = fs::File::open(from)?; + let mut to = fs::File::create(to)?; + io::copy(&mut from, &mut to).map(|_| ()) +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/minify/html.rs b/crates/zensical/src/compat/mkdocs/plugin/minify/html.rs index 641ee15..8aec48b 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/minify/html.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/minify/html.rs @@ -3,19 +3,47 @@ // 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. + +// ---------------------------------------------------------------------------- + //! HTML minification over the shared HTML tokenizer. use html5gum::emitters::callback::{CallbackEmitter, CallbackEvent}; use html5gum::{Span, Tokenizer}; use std::convert::Infallible; -use std::ops::Range; use crate::config::plugins::HtmlMinOptions; -use super::{script, style}; +mod inline; +mod serializer; +mod syntax; + +use inline::InlineEditor; +use serializer::Serializer; + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- /// Minifies a complete rendered HTML document. -pub(super) fn minify( +pub fn minify( input: &str, options: &HtmlMinOptions, inline_script: bool, inline_style: bool, ) -> String { @@ -37,7 +65,7 @@ pub(super) fn minify( } /// Minifies only inline language bodies, retaining all surrounding HTML. -pub(super) fn minify_inline( +pub fn minify_inline( input: String, inline_script: bool, inline_style: bool, ) -> String { let edits = { @@ -73,793 +101,15 @@ pub(super) fn minify_inline( output } -/// Span editor used when only inline language minification is enabled. -struct InlineEditor<'a> { - input: &'a str, - inline_script: bool, - inline_style: bool, - tag: Option, - active: Option<(InlineKind, usize)>, - edits: Vec<(Range, String)>, -} - -impl<'a> InlineEditor<'a> { - fn new(input: &'a str, inline_script: bool, inline_style: bool) -> Self { - Self { - input, - inline_script, - inline_style, - tag: None, - active: None, - edits: Vec::new(), - } - } - - fn event(&mut self, event: CallbackEvent<'_>, span: Span) { - match event { - CallbackEvent::OpenStartTag { name } => { - self.tag = Some(StartTag { - name: String::from_utf8_lossy(name).into_owned(), - output_name: source_name(self.input, span, 1, name.len()), - attributes: Vec::new(), - attribute: None, - }); - } - CallbackEvent::AttributeName { name } => { - self.attribute_name(name, span); - } - CallbackEvent::AttributeValue { value } => { - self.attribute_value(value, span); - } - CallbackEvent::CloseStartTag { self_closing } => { - self.close_start_tag(self_closing, span.end); - } - CallbackEvent::EndTag { name } => self.end_tag(name, span.start), - _ => {} - } - } - - fn attribute_name(&mut self, name: &[u8], span: Span) { - let Some(tag) = self.tag.as_mut() else { - return; - }; - if let Some(attribute) = tag.attribute.take() { - tag.attributes.push(attribute); - } - tag.attribute = Some(Attribute { - name: String::from_utf8_lossy(name).into_owned(), - output_name: source_name(self.input, span, 0, name.len()), - value: None, - }); - } - - fn attribute_value(&mut self, value: &[u8], span: Span) { - if let Some(attribute) = - self.tag.as_mut().and_then(|tag| tag.attribute.as_mut()) - { - attribute.value = Some(Value { - decoded: String::from_utf8_lossy(value).into_owned(), - raw: self.input[span.start..span.end].into(), - }); - } - } - - fn close_start_tag(&mut self, self_closing: bool, end: usize) { - let Some(mut tag) = self.tag.take() else { - return; - }; - if let Some(attribute) = tag.attribute.take() { - tag.attributes.push(attribute); - } - if self_closing { - return; - } - self.active = match tag.name.as_str() { - "script" if self.inline_script && is_javascript(&tag) => { - Some((InlineKind::Script { module: is_module(&tag) }, end)) - } - "style" if self.inline_style && is_css(&tag) => { - Some((InlineKind::Style, end)) - } - _ => None, - }; - } - - fn end_tag(&mut self, name: &[u8], end: usize) { - let expected = match self.active.as_ref().map(|item| item.0) { - Some(InlineKind::Script { .. }) => b"script".as_slice(), - Some(InlineKind::Style) => b"style".as_slice(), - None => return, - }; - if name != expected { - return; - } - - let (kind, start) = self.active.take().expect("active"); - let source = &self.input[start..end]; - let output = match kind { - InlineKind::Script { module } => script::minify(source, module), - InlineKind::Style => style::minify(source), - }; - if let Some(output) = output - && output.len() < source.len() - { - self.edits.push((start..end, output)); - } - } - - fn finish(self) -> Vec<(Range, String)> { - self.edits - } -} - -/// One parsed attribute waiting for serialization. -#[derive(Debug)] -struct Attribute { - name: String, - output_name: String, - value: Option, -} - -/// One parsed attribute value in decoded and source forms. -#[derive(Debug)] -struct Value { - decoded: String, - raw: String, -} - -/// One start tag being assembled from tokenizer events. -#[derive(Debug)] -struct StartTag { - name: String, - output_name: String, - attributes: Vec, - attribute: Option, -} - -/// One open element relevant to minification state. -#[derive(Debug)] -struct Element { - name: String, - preserve: bool, - language: Option, -} - -/// Inline language content buffered until its closing tag. -#[derive(Debug)] -struct Inline { - kind: InlineKind, - source: String, -} - -/// Inline language selected from a script or style element. -#[derive(Clone, Copy, Debug)] -enum InlineKind { - Script { module: bool }, - Style, -} - -/// Stateful serializer consuming html5gum callback events. -struct Serializer<'a> { - input: &'a str, - options: &'a HtmlMinOptions, - inline_script: bool, - inline_style: bool, - output: String, - start_tag: Option, - elements: Vec, - inline: Option, - after_doctype: bool, -} - -impl<'a> Serializer<'a> { - fn new( - input: &'a str, options: &'a HtmlMinOptions, inline_script: bool, - inline_style: bool, - ) -> Self { - Self { - input, - options, - inline_script, - inline_style, - output: String::with_capacity(input.len()), - start_tag: None, - elements: Vec::new(), - inline: None, - after_doctype: false, - } - } - - fn event(&mut self, event: CallbackEvent<'_>, span: Span) { - match event { - CallbackEvent::OpenStartTag { name } => { - self.open_start_tag(name, span); - } - CallbackEvent::AttributeName { name } => { - self.attribute_name(name, span); - } - CallbackEvent::AttributeValue { value } => { - self.attribute_value(value, span); - } - CallbackEvent::CloseStartTag { self_closing } => { - self.close_start_tag(self_closing); - } - CallbackEvent::EndTag { name } => self.end_tag(name, span), - CallbackEvent::String { value } => self.text(value, span), - CallbackEvent::Comment { value } => self.comment(value), - CallbackEvent::Doctype { - name, - public_identifier, - system_identifier, - .. - } => self.doctype(name, public_identifier, system_identifier), - CallbackEvent::Error(_) => {} - } - } - - fn open_start_tag(&mut self, name: &[u8], span: Span) { - let name = String::from_utf8_lossy(name).into_owned(); - self.close_optional_element(&name); - self.start_tag = Some(StartTag { - output_name: source_name(self.input, span, 1, name.len()), - name, - attributes: Vec::new(), - attribute: None, - }); - self.after_doctype = false; - } - - fn attribute_name(&mut self, name: &[u8], span: Span) { - let Some(tag) = self.start_tag.as_mut() else { - return; - }; - if let Some(attribute) = tag.attribute.take() { - tag.attributes.push(attribute); - } - tag.attribute = Some(Attribute { - name: String::from_utf8_lossy(name).into_owned(), - output_name: source_name(self.input, span, 0, name.len()), - value: None, - }); - } - - fn attribute_value(&mut self, value: &[u8], span: Span) { - let Some(attribute) = self - .start_tag - .as_mut() - .and_then(|tag| tag.attribute.as_mut()) - else { - return; - }; - attribute.value = Some(Value { - decoded: String::from_utf8_lossy(value).into_owned(), - raw: self.input[span.start..span.end].into(), - }); - } - - fn close_start_tag(&mut self, self_closing: bool) { - let Some(mut tag) = self.start_tag.take() else { - return; - }; - if let Some(attribute) = tag.attribute.take() { - tag.attributes.push(attribute); - } - - let parent_language = self - .elements - .last() - .and_then(|element| element.language.clone()); - let language = tag - .attributes - .iter() - .find(|attribute| attribute.name == "lang") - .and_then(|attribute| attribute.value.as_ref()) - .map(|value| value.decoded.clone()) - .or_else(|| parent_language.clone()); - let preserve = self.serialize_start_tag( - &tag, - self_closing, - parent_language.as_deref(), - ); - - if is_void(&tag.name) || self_closing { - return; - } - - let preserve = preserve - || self.is_preserved() - || tag.name == "script" - || tag.name == "style" - || self - .options - .pre_tags - .iter() - .any(|name| name.eq_ignore_ascii_case(&tag.name)); - self.elements.push(Element { - name: tag.name.clone(), - preserve, - language, - }); - - let kind = match tag.name.as_str() { - "script" if self.inline_script && is_javascript(&tag) => { - Some(InlineKind::Script { module: is_module(&tag) }) - } - "style" if self.inline_style && is_css(&tag) => { - Some(InlineKind::Style) - } - _ => None, - }; - if let Some(kind) = kind { - self.inline = Some(Inline { kind, source: String::new() }); - } - } - - fn serialize_start_tag( - &mut self, tag: &StartTag, self_closing: bool, - parent_language: Option<&str>, - ) -> bool { - self.output.push('<'); - self.output.push_str(&tag.output_name); - - let mut preserve = false; - for attribute in &tag.attributes { - let prefixed = attribute - .name - .strip_prefix(&format!("{}-", self.options.pre_attr)); - let name = prefixed.unwrap_or(&attribute.name); - let protected = prefixed.is_some(); - let output_name = if protected { - attribute - .output_name - .get(self.options.pre_attr.len() + 1..) - .unwrap_or(&attribute.output_name) - } else { - &attribute.output_name - }; - - if name == self.options.pre_attr { - preserve = true; - if !self.options.keep_pre && !protected { - continue; - } - } - if name == "lang" - && attribute.value.as_ref().is_some_and(|value| { - parent_language == Some(value.decoded.as_str()) - }) - { - continue; - } - - self.output.push(' '); - self.output.push_str(output_name); - let Some(value) = &attribute.value else { - continue; - }; - if (self.options.reduce_empty_attributes - && value.decoded.is_empty()) - || (self.options.reduce_boolean_attributes - && is_boolean_attribute(&tag.name, name)) - { - continue; - } - - self.output.push('='); - let value = if protected || !self.options.convert_charrefs { - value.raw.as_str() - } else { - value.decoded.as_str() - }; - serialize_attribute_value( - &mut self.output, - value, - self.options.remove_optional_attribute_quotes, - protected || !self.options.convert_charrefs, - ); - } - - if self_closing && !is_void(&tag.name) { - self.output.push_str("/>"); - } else { - self.output.push('>'); - } - preserve - } - - fn end_tag(&mut self, name: &[u8], span: Span) { - let name = String::from_utf8_lossy(name); - if matches!(name.as_ref(), "script" | "style") { - self.finish_inline(); - } - if name == "title" { - while self.output.ends_with(char::is_whitespace) { - self.output.pop(); - } - } - if !is_void(&name) { - self.output.push_str("'); - } - if let Some(index) = self - .elements - .iter() - .rposition(|element| element.name == name) - { - self.elements.truncate(index); - } - } - - fn text(&mut self, value: &[u8], span: Span) { - if let Some(inline) = self.inline.as_mut() { - inline.source.push_str(&self.input[span.start..span.end]); - return; - } - if self.is_preserved() { - self.output.push_str(&self.input[span.start..span.end]); - return; - } - - let value = String::from_utf8_lossy(value); - let only_whitespace = value.chars().all(is_html_whitespace); - if only_whitespace - && (self.options.remove_all_empty_space - || self.in_head() - || self.after_doctype - || (self.options.remove_empty_space - && value.contains(['\n', '\r']))) - { - return; - } - - let mut value = collapse_whitespace(&value); - if self.in_title() && self.output.ends_with("") { - value = value.trim_start().into(); - } - if self.output.ends_with(' ') && value.starts_with(' ') { - value.remove(0); - } - escape_text(&mut self.output, &value); - } - - fn comment(&mut self, value: &[u8]) { - let value = String::from_utf8_lossy(value); - if self.options.remove_comments { - if let Some(value) = value.strip_prefix('!') { - self.output.push_str("<!--"); - self.output.push_str(value); - self.output.push_str("-->"); - } else if value.trim_start().starts_with("[if ") { - self.output.push_str("<!--"); - self.output.push_str(&value); - self.output.push_str("-->"); - } - } else { - self.output.push_str("<!--"); - self.output.push_str(&value); - self.output.push_str("-->"); - } - } - - fn doctype( - &mut self, name: &[u8], public: Option<&[u8]>, system: Option<&[u8]>, - ) { - self.output.push_str("<!doctype "); - self.output.push_str(&String::from_utf8_lossy(name)); - if let Some(public) = public { - self.output.push_str(" public \""); - self.output.push_str(&String::from_utf8_lossy(public)); - self.output.push('"'); - } - if let Some(system) = system { - if public.is_none() { - self.output.push_str(" system"); - } - self.output.push_str(" \""); - self.output.push_str(&String::from_utf8_lossy(system)); - self.output.push('"'); - } - self.output.push('>'); - self.after_doctype = true; - } - - fn finish_inline(&mut self) { - let Some(inline) = self.inline.take() else { - return; - }; - let output = match inline.kind { - InlineKind::Script { module } => { - script::minify(&inline.source, module) - } - InlineKind::Style => style::minify(&inline.source), - }; - if let Some(output) = - output.filter(|output| output.len() < inline.source.len()) - { - self.output.push_str(&output); - } else { - self.output.push_str(&inline.source); - } - } - - fn is_preserved(&self) -> bool { - self.elements.last().is_some_and(|element| element.preserve) - } - - fn in_head(&self) -> bool { - self.elements.iter().any(|element| element.name == "head") - } - - fn in_title(&self) -> bool { - self.elements - .last() - .is_some_and(|element| element.name == "title") - } - - fn close_optional_element(&mut self, next: &str) { - let Some(current) = self.elements.last() else { - return; - }; - if closes_on_start(¤t.name, next) { - self.elements.pop(); - } - } - - fn finish(mut self) -> String { - self.finish_inline(); - self.output - } -} - -fn collapse_whitespace(value: &str) -> String { - let mut output = String::with_capacity(value.len()); - let mut whitespace = false; - for character in value.chars() { - if is_html_whitespace(character) { - whitespace = true; - } else { - if whitespace { - output.push(' '); - whitespace = false; - } - output.push(character); - } - } - if whitespace { - output.push(' '); - } - output -} - -fn source_name( - input: &str, span: Span<usize>, prefix: usize, length: usize, -) -> String { - let start = span.start.saturating_add(prefix); - let end = (start + length).min(input.len()); - input.get(start..end).unwrap_or_default().into() -} - -fn is_html_whitespace(character: char) -> bool { - matches!(character, '\t' | '\n' | '\u{c}' | '\r' | ' ') -} - -fn escape_text(output: &mut String, value: &str) { - for character in value.chars() { - match character { - '&' => output.push_str("&"), - '<' => output.push_str("<"), - _ => output.push(character), - } - } -} - -fn serialize_attribute_value( - output: &mut String, value: &str, remove_quotes: bool, - preserve_references: bool, -) { - if remove_quotes && !value.is_empty() && value.chars().all(is_unquoted) { - if preserve_references { - output.push_str(value); - } else { - escape_ampersands(output, value); - } - return; - } - - let single = value.matches('\'').count(); - let double = value.matches('"').count(); - let quote = if double > single { '\'' } else { '"' }; - output.push(quote); - for character in value.chars() { - match character { - '&' if !preserve_references => output.push_str("&"), - '\'' if quote == '\'' => output.push_str("'"), - '"' if quote == '"' => output.push_str("""), - _ => output.push(character), - } - } - output.push(quote); -} - -fn escape_ampersands(output: &mut String, value: &str) { - for character in value.chars() { - if character == '&' { - output.push_str("&"); - } else { - output.push(character); - } - } -} - -fn is_unquoted(character: char) -> bool { - !is_html_whitespace(character) - && !matches!(character, '"' | '\'' | '`' | '=' | '<' | '>') -} - -fn is_void(name: &str) -> bool { - matches!( - name, - "area" - | "base" - | "br" - | "col" - | "command" - | "embed" - | "hr" - | "img" - | "input" - | "keygen" - | "link" - | "meta" - | "param" - | "source" - | "track" - | "wbr" - ) -} - -fn is_boolean_attribute(tag: &str, name: &str) -> bool { - if name == "hidden" { - return true; - } - match tag { - "audio" | "video" => { - matches!(name, "autoplay" | "controls" | "loop" | "muted") - } - "button" => matches!(name, "autofocus" | "disabled" | "formnovalidate"), - "dialog" => name == "open", - "fieldset" | "optgroup" => name == "disabled", - "form" => name == "novalidate", - "iframe" => name == "seamless", - "img" => name == "ismap", - "input" => matches!( - name, - "autofocus" - | "checked" - | "disabled" - | "formnovalidate" - | "multiple" - | "readonly" - | "required" - ), - "keygen" => matches!(name, "autofocus" | "disabled"), - "object" => name == "typemustmatch", - "ol" => name == "reversed", - "option" => matches!(name, "disabled" | "selected"), - "script" => matches!(name, "async" | "defer"), - "select" => { - matches!(name, "autofocus" | "disabled" | "multiple" | "required") - } - "style" => name == "scoped", - "textarea" => { - matches!(name, "autofocus" | "disabled" | "readonly" | "required") - } - "track" => name == "default", - _ => false, - } -} - -fn closes_on_start(current: &str, next: &str) -> bool { - match current { - "li" => next == "li", - "dd" | "dt" => matches!(next, "dd" | "dt"), - "rp" | "rt" => matches!(next, "rp" | "rt"), - "p" => matches!( - next, - "address" - | "article" - | "aside" - | "blockquote" - | "dir" - | "div" - | "dl" - | "fieldset" - | "footer" - | "form" - | "h1" - | "h2" - | "h3" - | "h4" - | "h5" - | "h6" - | "header" - | "hgroup" - | "hr" - | "menu" - | "nav" - | "ol" - | "p" - | "pre" - | "section" - | "table" - | "ul" - ), - "option" => matches!(next, "option" | "optgroup"), - "optgroup" => next == "optgroup", - "colgroup" => true, - "thead" | "tbody" => matches!(next, "tbody" | "tfoot"), - "tfoot" => next == "tbody", - "tr" => next == "tr", - "td" | "th" => matches!(next, "td" | "th"), - _ => false, - } -} - -fn is_javascript(tag: &StartTag) -> bool { - let kind = attribute(tag, "type") - .map(str::trim) - .map(str::to_ascii_lowercase); - let language = attribute(tag, "language") - .map(str::trim) - .map(str::to_ascii_lowercase); - match kind.as_deref() { - None | Some("" | "module") => {} - Some(kind) - if matches!( - kind.split(';').next().map(str::trim), - Some( - "text/javascript" - | "application/javascript" - | "text/ecmascript" - | "application/ecmascript" - ) - ) => {} - _ => return false, - } - language.as_deref().is_none_or(|language| { - matches!(language, "javascript" | "ecmascript" | "jscript") - }) -} - -fn is_css(tag: &StartTag) -> bool { - attribute(tag, "type").is_none_or(|kind| { - let kind = kind.trim().to_ascii_lowercase(); - kind.is_empty() - || kind - .split(';') - .next() - .is_some_and(|kind| kind.trim() == "text/css") - }) -} - -fn is_module(tag: &StartTag) -> bool { - attribute(tag, "type") - .is_some_and(|kind| kind.trim().eq_ignore_ascii_case("module")) -} - -fn attribute<'a>(tag: &'a StartTag, name: &str) -> Option<&'a str> { - tag.attributes - .iter() - .find(|attribute| attribute.name == name) - .and_then(|attribute| attribute.value.as_ref()) - .map(|value| value.decoded.as_str()) -} +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- #[cfg(test)] mod tests { - use super::*; + use crate::config::plugins::HtmlMinOptions; + + use super::{minify, minify_inline}; fn options() -> HtmlMinOptions { HtmlMinOptions { diff --git a/crates/zensical/src/compat/mkdocs/plugin/minify/html/inline.rs b/crates/zensical/src/compat/mkdocs/plugin/minify/html/inline.rs new file mode 100644 index 0000000..647b043 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/minify/html/inline.rs @@ -0,0 +1,183 @@ +// 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. + +// ---------------------------------------------------------------------------- + +//! Inline script and style editing. + +use html5gum::emitters::callback::CallbackEvent; +use html5gum::Span; +use std::ops::Range; + +use crate::compat::mkdocs::plugin::minify::{script, style}; + +use super::syntax::{ + is_css, is_javascript, is_module, source_name, Attribute, InlineKind, + StartTag, Value, +}; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Collects size-reducing replacements for inline scripts and styles. +pub struct InlineEditor<'a> { + /// Original rendered HTML. + input: &'a str, + /// Whether inline JavaScript is minified. + inline_script: bool, + /// Whether inline CSS is minified. + inline_style: bool, + /// Start tag currently being assembled. + tag: Option<StartTag>, + /// Active inline body and its source start. + active: Option<(InlineKind, usize)>, + /// Non-overlapping replacements collected in source order. + edits: Vec<(Range<usize>, String)>, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl<'a> InlineEditor<'a> { + /// Creates an inline editor. + pub fn new( + input: &'a str, inline_script: bool, inline_style: bool, + ) -> Self { + Self { + input, + inline_script, + inline_style, + tag: None, + active: None, + edits: Vec::new(), + } + } + + /// Consumes one tokenizer event. + pub fn event(&mut self, event: CallbackEvent<'_>, span: Span<usize>) { + match event { + CallbackEvent::OpenStartTag { name } => { + self.tag = Some(StartTag { + name: String::from_utf8_lossy(name).into_owned(), + output_name: source_name(self.input, span, 1, name.len()), + attributes: Vec::new(), + attribute: None, + }); + } + CallbackEvent::AttributeName { name } => { + self.attribute_name(name, span); + } + CallbackEvent::AttributeValue { value } => { + self.attribute_value(value, span); + } + CallbackEvent::CloseStartTag { self_closing } => { + self.close_start_tag(self_closing, span.end); + } + CallbackEvent::EndTag { name } => self.end_tag(name, span.start), + _ => {} + } + } + + fn attribute_name(&mut self, name: &[u8], span: Span<usize>) { + let Some(tag) = self.tag.as_mut() else { + return; + }; + if let Some(attribute) = tag.attribute.take() { + tag.attributes.push(attribute); + } + tag.attribute = Some(Attribute { + name: String::from_utf8_lossy(name).into_owned(), + output_name: source_name(self.input, span, 0, name.len()), + value: None, + }); + } + + fn attribute_value(&mut self, value: &[u8], span: Span<usize>) { + if let Some(attribute) = + self.tag.as_mut().and_then(|tag| tag.attribute.as_mut()) + { + attribute.value = Some(Value { + decoded: String::from_utf8_lossy(value).into_owned(), + raw: self.input[span.start..span.end].into(), + }); + } + } + + fn close_start_tag(&mut self, self_closing: bool, end: usize) { + let Some(mut tag) = self.tag.take() else { + return; + }; + if let Some(attribute) = tag.attribute.take() { + tag.attributes.push(attribute); + } + if self_closing { + return; + } + + // Record the body start only for enabled, supported inline languages. + // Unsupported types are left entirely untouched. + self.active = match tag.name.as_str() { + "script" if self.inline_script && is_javascript(&tag) => { + Some((InlineKind::Script { module: is_module(&tag) }, end)) + } + "style" if self.inline_style && is_css(&tag) => { + Some((InlineKind::Style, end)) + } + _ => None, + }; + } + + fn end_tag(&mut self, name: &[u8], end: usize) { + // Ignore unrelated end tags until the active inline body closes. + let expected = match self.active.as_ref().map(|item| item.0) { + Some(InlineKind::Script { .. }) => b"script".as_slice(), + Some(InlineKind::Style) => b"style".as_slice(), + None => return, + }; + if name != expected { + return; + } + + let (kind, start) = self.active.take().expect("active"); + let source = &self.input[start..end]; + let output = match kind { + InlineKind::Script { module } => script::minify(source, module), + InlineKind::Style => style::minify(source), + }; + + // Applying only shrinking edits guarantees that inline-only mode never + // expands the surrounding document. + if let Some(output) = output + && output.len() < source.len() + { + self.edits.push((start..end, output)); + } + } + + /// Returns the collected replacements. + pub fn finish(self) -> Vec<(Range<usize>, String)> { + self.edits + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/minify/html/serializer.rs b/crates/zensical/src/compat/mkdocs/plugin/minify/html/serializer.rs new file mode 100644 index 0000000..33ed55f --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/minify/html/serializer.rs @@ -0,0 +1,468 @@ +// 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 HTML serializer. + +use html5gum::emitters::callback::CallbackEvent; +use html5gum::Span; + +use crate::compat::mkdocs::plugin::minify::{script, style}; +use crate::config::plugins::HtmlMinOptions; + +use super::syntax::{ + closes_on_start, collapse_whitespace, escape_text, is_boolean_attribute, + is_css, is_html_whitespace, is_javascript, is_module, is_void, + serialize_attribute_value, source_name, Attribute, Element, Inline, + InlineKind, StartTag, Value, +}; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Stateful serializer consuming html5gum callback events. +pub struct Serializer<'a> { + /// Original rendered HTML. + input: &'a str, + /// HTML minification options. + options: &'a HtmlMinOptions, + /// Whether inline JavaScript is minified. + inline_script: bool, + /// Whether inline CSS is minified. + inline_style: bool, + /// Serialized output. + output: String, + /// Start tag currently being assembled. + start_tag: Option<StartTag>, + /// Open elements relevant to whitespace and language state. + elements: Vec<Element>, + /// Inline body currently being buffered. + inline: Option<Inline>, + /// Whether the last emitted token was a doctype. + after_doctype: bool, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl<'a> Serializer<'a> { + /// Creates a serializer. + pub fn new( + input: &'a str, options: &'a HtmlMinOptions, inline_script: bool, + inline_style: bool, + ) -> Self { + Self { + input, + options, + inline_script, + inline_style, + output: String::with_capacity(input.len()), + start_tag: None, + elements: Vec::new(), + inline: None, + after_doctype: false, + } + } + + /// Consumes one tokenizer event. + pub fn event(&mut self, event: CallbackEvent<'_>, span: Span<usize>) { + match event { + CallbackEvent::OpenStartTag { name } => { + self.open_start_tag(name, span); + } + CallbackEvent::AttributeName { name } => { + self.attribute_name(name, span); + } + CallbackEvent::AttributeValue { value } => { + self.attribute_value(value, span); + } + CallbackEvent::CloseStartTag { self_closing } => { + self.close_start_tag(self_closing); + } + CallbackEvent::EndTag { name } => self.end_tag(name, span), + CallbackEvent::String { value } => self.text(value, span), + CallbackEvent::Comment { value } => self.comment(value), + CallbackEvent::Doctype { + name, + public_identifier, + system_identifier, + .. + } => self.doctype(name, public_identifier, system_identifier), + CallbackEvent::Error(_) => {} + } + } + + fn open_start_tag(&mut self, name: &[u8], span: Span<usize>) { + let name = String::from_utf8_lossy(name).into_owned(); + self.close_optional_element(&name); + self.start_tag = Some(StartTag { + output_name: source_name(self.input, span, 1, name.len()), + name, + attributes: Vec::new(), + attribute: None, + }); + self.after_doctype = false; + } + + fn attribute_name(&mut self, name: &[u8], span: Span<usize>) { + let Some(tag) = self.start_tag.as_mut() else { + return; + }; + if let Some(attribute) = tag.attribute.take() { + tag.attributes.push(attribute); + } + tag.attribute = Some(Attribute { + name: String::from_utf8_lossy(name).into_owned(), + output_name: source_name(self.input, span, 0, name.len()), + value: None, + }); + } + + fn attribute_value(&mut self, value: &[u8], span: Span<usize>) { + let Some(attribute) = self + .start_tag + .as_mut() + .and_then(|tag| tag.attribute.as_mut()) + else { + return; + }; + attribute.value = Some(Value { + decoded: String::from_utf8_lossy(value).into_owned(), + raw: self.input[span.start..span.end].into(), + }); + } + + fn close_start_tag(&mut self, self_closing: bool) { + let Some(mut tag) = self.start_tag.take() else { + return; + }; + if let Some(attribute) = tag.attribute.take() { + tag.attributes.push(attribute); + } + + // Resolve the effective language before serialization can remove a + // redundant lang attribute. + let parent_language = self + .elements + .last() + .and_then(|element| element.language.clone()); + let language = tag + .attributes + .iter() + .find(|attribute| attribute.name == "lang") + .and_then(|attribute| attribute.value.as_ref()) + .map(|value| value.decoded.clone()) + .or_else(|| parent_language.clone()); + let preserve = self.serialize_start_tag( + &tag, + self_closing, + parent_language.as_deref(), + ); + + // Void and explicitly self-closing tags do not affect descendant + // whitespace or language state. + if is_void(&tag.name) || self_closing { + return; + } + + // Preservation is inherited. Script and style bodies are always kept + // verbatim until their optional language minifier accepts them. + let preserve = preserve + || self.is_preserved() + || tag.name == "script" + || tag.name == "style" + || self + .options + .pre_tags + .iter() + .any(|name| name.eq_ignore_ascii_case(&tag.name)); + self.elements.push(Element { + name: tag.name.clone(), + preserve, + language, + }); + + // Buffer supported inline languages so parse failures can fall back to + // the exact original body. + let kind = match tag.name.as_str() { + "script" if self.inline_script && is_javascript(&tag) => { + Some(InlineKind::Script { module: is_module(&tag) }) + } + "style" if self.inline_style && is_css(&tag) => { + Some(InlineKind::Style) + } + _ => None, + }; + if let Some(kind) = kind { + self.inline = Some(Inline { kind, source: String::new() }); + } + } + + fn serialize_start_tag( + &mut self, tag: &StartTag, self_closing: bool, + parent_language: Option<&str>, + ) -> bool { + self.output.push('<'); + self.output.push_str(&tag.output_name); + + let mut preserve = false; + for attribute in &tag.attributes { + // A configured prefix protects an attribute from normalization; + // the prefix itself is omitted from rendered output. + let prefixed = attribute + .name + .strip_prefix(&format!("{}-", self.options.pre_attr)); + let name = prefixed.unwrap_or(&attribute.name); + let protected = prefixed.is_some(); + let output_name = if protected { + attribute + .output_name + .get(self.options.pre_attr.len() + 1..) + .unwrap_or(&attribute.output_name) + } else { + &attribute.output_name + }; + + if name == self.options.pre_attr { + preserve = true; + if !self.options.keep_pre && !protected { + continue; + } + } + + // An inherited language need not be repeated on the child. + if name == "lang" + && attribute.value.as_ref().is_some_and(|value| { + parent_language == Some(value.decoded.as_str()) + }) + { + continue; + } + + self.output.push(' '); + self.output.push_str(output_name); + let Some(value) = &attribute.value else { + continue; + }; + + // Empty and boolean values can be represented by the name alone. + if (self.options.reduce_empty_attributes + && value.decoded.is_empty()) + || (self.options.reduce_boolean_attributes + && is_boolean_attribute(&tag.name, name)) + { + continue; + } + + self.output.push('='); + // Protected values and disabled character-reference conversion use + // their source spelling; all other values use decoded text. + let value = if protected || !self.options.convert_charrefs { + value.raw.as_str() + } else { + value.decoded.as_str() + }; + serialize_attribute_value( + &mut self.output, + value, + self.options.remove_optional_attribute_quotes, + protected || !self.options.convert_charrefs, + ); + } + + if self_closing && !is_void(&tag.name) { + self.output.push_str("/>"); + } else { + self.output.push('>'); + } + preserve + } + + fn end_tag(&mut self, name: &[u8], span: Span<usize>) { + let name = String::from_utf8_lossy(name); + + // Flush a buffered language body before emitting its closing tag. + if matches!(name.as_ref(), "script" | "style") { + self.finish_inline(); + } + + // htmlmin removes trailing whitespace from titles specifically. + if name == "title" { + while self.output.ends_with(char::is_whitespace) { + self.output.pop(); + } + } + if !is_void(&name) { + self.output.push_str("</"); + self.output + .push_str(&source_name(self.input, span, 2, name.len())); + self.output.push('>'); + } + + // Search backwards to recover cleanly from imperfectly nested input. + if let Some(index) = self + .elements + .iter() + .rposition(|element| element.name == name) + { + self.elements.truncate(index); + } + } + + fn text(&mut self, value: &[u8], span: Span<usize>) { + // Buffered and preserved contexts retain source bytes, bypassing HTML + // whitespace and entity normalization. + if let Some(inline) = self.inline.as_mut() { + inline.source.push_str(&self.input[span.start..span.end]); + return; + } + if self.is_preserved() { + self.output.push_str(&self.input[span.start..span.end]); + return; + } + + let value = String::from_utf8_lossy(value); + let only_whitespace = value.chars().all(is_html_whitespace); + + // The three whitespace options differ only in where an all-whitespace + // token may be dropped completely. + if only_whitespace + && (self.options.remove_all_empty_space + || self.in_head() + || self.after_doctype + || (self.options.remove_empty_space + && value.contains(['\n', '\r']))) + { + return; + } + + let mut value = collapse_whitespace(&value); + + // Trim boundaries that would otherwise survive token-by-token + // processing and produce duplicate spaces. + if self.in_title() && self.output.ends_with("<title>") { + value = value.trim_start().into(); + } + if self.output.ends_with(' ') && value.starts_with(' ') { + value.remove(0); + } + escape_text(&mut self.output, &value); + } + + fn comment(&mut self, value: &[u8]) { + let value = String::from_utf8_lossy(value); + if self.options.remove_comments { + // Legal comments and conditional comments remain observable even + // when ordinary comments are removed. + if let Some(value) = value.strip_prefix('!') { + self.output.push_str("<!--"); + self.output.push_str(value); + self.output.push_str("-->"); + } else if value.trim_start().starts_with("[if ") { + self.output.push_str("<!--"); + self.output.push_str(&value); + self.output.push_str("-->"); + } + } else { + self.output.push_str("<!--"); + self.output.push_str(&value); + self.output.push_str("-->"); + } + } + + fn doctype( + &mut self, name: &[u8], public: Option<&[u8]>, system: Option<&[u8]>, + ) { + self.output.push_str("<!doctype "); + self.output.push_str(&String::from_utf8_lossy(name)); + if let Some(public) = public { + self.output.push_str(" public \""); + self.output.push_str(&String::from_utf8_lossy(public)); + self.output.push('"'); + } + if let Some(system) = system { + if public.is_none() { + self.output.push_str(" system"); + } + self.output.push_str(" \""); + self.output.push_str(&String::from_utf8_lossy(system)); + self.output.push('"'); + } + self.output.push('>'); + self.after_doctype = true; + } + + fn finish_inline(&mut self) { + let Some(inline) = self.inline.take() else { + return; + }; + let output = match inline.kind { + InlineKind::Script { module } => { + script::minify(&inline.source, module) + } + InlineKind::Style => style::minify(&inline.source), + }; + + // A minifier may reject valid-but-unsupported syntax or produce larger + // output. In either case, retain the original body byte-for-byte. + if let Some(output) = + output.filter(|output| output.len() < inline.source.len()) + { + self.output.push_str(&output); + } else { + self.output.push_str(&inline.source); + } + } + + fn is_preserved(&self) -> bool { + self.elements.last().is_some_and(|element| element.preserve) + } + + fn in_head(&self) -> bool { + self.elements.iter().any(|element| element.name == "head") + } + + fn in_title(&self) -> bool { + self.elements + .last() + .is_some_and(|element| element.name == "title") + } + + fn close_optional_element(&mut self, next: &str) { + let Some(current) = self.elements.last() else { + return; + }; + if closes_on_start(¤t.name, next) { + self.elements.pop(); + } + } + + /// Finishes buffered inline content and returns the output. + pub fn finish(mut self) -> String { + self.finish_inline(); + self.output + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/minify/html/syntax.rs b/crates/zensical/src/compat/mkdocs/plugin/minify/html/syntax.rs new file mode 100644 index 0000000..ec63c8c --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/minify/html/syntax.rs @@ -0,0 +1,360 @@ +// 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. + +// ---------------------------------------------------------------------------- + +//! HTML syntax facts and serialization helpers. + +use html5gum::Span; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// Inline language selected from a script or style element. +#[derive(Clone, Copy, Debug)] +pub enum InlineKind { + /// JavaScript, optionally parsed as a module. + Script { module: bool }, + /// CSS. + Style, +} + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// One parsed attribute waiting for serialization. +#[derive(Debug)] +pub struct Attribute { + /// Normalized attribute name. + pub name: String, + /// Attribute name as it appeared in the source. + pub output_name: String, + /// Optional attribute value. + pub value: Option<Value>, +} + +/// One parsed attribute value in decoded and source forms. +#[derive(Debug)] +pub struct Value { + /// Decoded attribute value. + pub decoded: String, + /// Attribute value as it appeared in the source. + pub raw: String, +} + +/// One start tag being assembled from tokenizer events. +#[derive(Debug)] +pub struct StartTag { + /// Normalized element name. + pub name: String, + /// Name as it appeared in the source. + pub output_name: String, + /// Completed attributes. + pub attributes: Vec<Attribute>, + /// Attribute currently being assembled. + pub attribute: Option<Attribute>, +} + +/// One open element relevant to minification state. +#[derive(Debug)] +pub struct Element { + /// Normalized element name. + pub name: String, + /// Whether descendant text must be preserved verbatim. + pub preserve: bool, + /// Effective inherited language. + pub language: Option<String>, +} + +/// Inline language content buffered until its closing tag. +#[derive(Debug)] +pub struct Inline { + /// Inline language kind. + pub kind: InlineKind, + /// Buffered inline source. + pub source: String, +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Collapses consecutive HTML whitespace. +pub fn collapse_whitespace(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + let mut whitespace = false; + for character in value.chars() { + if is_html_whitespace(character) { + whitespace = true; + } else { + if whitespace { + output.push(' '); + whitespace = false; + } + output.push(character); + } + } + if whitespace { + output.push(' '); + } + output +} + +/// Returns an element or attribute name with its source casing. +pub fn source_name( + input: &str, span: Span<usize>, prefix: usize, length: usize, +) -> String { + let start = span.start.saturating_add(prefix); + let end = (start + length).min(input.len()); + input.get(start..end).unwrap_or_default().into() +} + +/// Returns whether a character is HTML whitespace. +pub fn is_html_whitespace(character: char) -> bool { + matches!(character, '\t' | '\n' | '\u{c}' | '\r' | ' ') +} + +/// Escapes text for serialization. +pub fn escape_text(output: &mut String, value: &str) { + for character in value.chars() { + match character { + '&' => output.push_str("&"), + '<' => output.push_str("<"), + _ => output.push(character), + } + } +} + +/// Serializes an attribute value with minimal safe quoting. +pub fn serialize_attribute_value( + output: &mut String, value: &str, remove_quotes: bool, + preserve_references: bool, +) { + if remove_quotes && !value.is_empty() && value.chars().all(is_unquoted) { + if preserve_references { + output.push_str(value); + } else { + escape_ampersands(output, value); + } + return; + } + + let single = value.matches('\'').count(); + let double = value.matches('"').count(); + let quote = if double > single { '\'' } else { '"' }; + output.push(quote); + for character in value.chars() { + match character { + '&' if !preserve_references => output.push_str("&"), + '\'' if quote == '\'' => output.push_str("'"), + '"' if quote == '"' => output.push_str("""), + _ => output.push(character), + } + } + output.push(quote); +} + +/// Escapes ampersands in an attribute value. +fn escape_ampersands(output: &mut String, value: &str) { + for character in value.chars() { + if character == '&' { + output.push_str("&"); + } else { + output.push(character); + } + } +} + +/// Returns whether a character is allowed in an unquoted value. +fn is_unquoted(character: char) -> bool { + !is_html_whitespace(character) + && !matches!(character, '"' | '\'' | '`' | '=' | '<' | '>') +} + +/// Returns whether an element is void. +pub fn is_void(name: &str) -> bool { + matches!( + name, + "area" + | "base" + | "br" + | "col" + | "command" + | "embed" + | "hr" + | "img" + | "input" + | "keygen" + | "link" + | "meta" + | "param" + | "source" + | "track" + | "wbr" + ) +} + +/// Returns whether an attribute is boolean for an element. +pub fn is_boolean_attribute(tag: &str, name: &str) -> bool { + if name == "hidden" { + return true; + } + match tag { + "audio" | "video" => { + matches!(name, "autoplay" | "controls" | "loop" | "muted") + } + "button" => matches!(name, "autofocus" | "disabled" | "formnovalidate"), + "dialog" => name == "open", + "fieldset" | "optgroup" => name == "disabled", + "form" => name == "novalidate", + "iframe" => name == "seamless", + "img" => name == "ismap", + "input" => matches!( + name, + "autofocus" + | "checked" + | "disabled" + | "formnovalidate" + | "multiple" + | "readonly" + | "required" + ), + "keygen" => matches!(name, "autofocus" | "disabled"), + "object" => name == "typemustmatch", + "ol" => name == "reversed", + "option" => matches!(name, "disabled" | "selected"), + "script" => matches!(name, "async" | "defer"), + "select" => { + matches!(name, "autofocus" | "disabled" | "multiple" | "required") + } + "style" => name == "scoped", + "textarea" => { + matches!(name, "autofocus" | "disabled" | "readonly" | "required") + } + "track" => name == "default", + _ => false, + } +} + +/// Returns whether a start tag implicitly closes the current element. +pub fn closes_on_start(current: &str, next: &str) -> bool { + match current { + "li" => next == "li", + "dd" | "dt" => matches!(next, "dd" | "dt"), + "rp" | "rt" => matches!(next, "rp" | "rt"), + "p" => matches!( + next, + "address" + | "article" + | "aside" + | "blockquote" + | "dir" + | "div" + | "dl" + | "fieldset" + | "footer" + | "form" + | "h1" + | "h2" + | "h3" + | "h4" + | "h5" + | "h6" + | "header" + | "hgroup" + | "hr" + | "menu" + | "nav" + | "ol" + | "p" + | "pre" + | "section" + | "table" + | "ul" + ), + "option" => matches!(next, "option" | "optgroup"), + "optgroup" => next == "optgroup", + "colgroup" => true, + "thead" | "tbody" => matches!(next, "tbody" | "tfoot"), + "tfoot" => next == "tbody", + "tr" => next == "tr", + "td" | "th" => matches!(next, "td" | "th"), + _ => false, + } +} + +/// Returns whether a script tag contains JavaScript. +pub fn is_javascript(tag: &StartTag) -> bool { + let kind = attribute(tag, "type") + .map(str::trim) + .map(str::to_ascii_lowercase); + let language = attribute(tag, "language") + .map(str::trim) + .map(str::to_ascii_lowercase); + match kind.as_deref() { + None | Some("" | "module") => {} + Some(kind) + if matches!( + kind.split(';').next().map(str::trim), + Some( + "text/javascript" + | "application/javascript" + | "text/ecmascript" + | "application/ecmascript" + ) + ) => {} + _ => return false, + } + language.as_deref().is_none_or(|language| { + matches!(language, "javascript" | "ecmascript" | "jscript") + }) +} + +/// Returns whether a style tag contains CSS. +pub fn is_css(tag: &StartTag) -> bool { + attribute(tag, "type").is_none_or(|kind| { + let kind = kind.trim().to_ascii_lowercase(); + kind.is_empty() + || kind + .split(';') + .next() + .is_some_and(|kind| kind.trim() == "text/css") + }) +} + +/// Returns whether a script tag is a module. +pub fn is_module(tag: &StartTag) -> bool { + attribute(tag, "type") + .is_some_and(|kind| kind.trim().eq_ignore_ascii_case("module")) +} + +/// Returns a decoded attribute value by name. +fn attribute<'a>(tag: &'a StartTag, name: &str) -> Option<&'a str> { + tag.attributes + .iter() + .find(|attribute| attribute.name == name) + .and_then(|attribute| attribute.value.as_ref()) + .map(|value| value.decoded.as_str()) +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/minify/script.rs b/crates/zensical/src/compat/mkdocs/plugin/minify/script.rs index a3416d9..c5bc935 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/minify/script.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/minify/script.rs @@ -3,6 +3,26 @@ // 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. + +// ---------------------------------------------------------------------------- + //! JavaScript minification. use oxc_allocator::Allocator; @@ -10,8 +30,12 @@ use oxc_codegen::{Codegen, CodegenOptions, CommentOptions}; use oxc_parser::Parser; use oxc_span::SourceType; +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + /// Minifies JavaScript while retaining the original source on parse errors. -pub(super) fn minify(source: &str, module: bool) -> Option<String> { +pub fn minify(source: &str, module: bool) -> Option<String> { let allocator = Allocator::default(); let source_type = if module { SourceType::mjs() @@ -38,9 +62,13 @@ pub(super) fn minify(source: &str, module: bool) -> Option<String> { Some(output.code) } +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + #[cfg(test)] mod tests { - use super::*; + use super::minify; #[test] fn minifies_modern_javascript() { diff --git a/crates/zensical/src/compat/mkdocs/plugin/minify/style.rs b/crates/zensical/src/compat/mkdocs/plugin/minify/style.rs index c55894e..e3cf282 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/minify/style.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/minify/style.rs @@ -3,6 +3,26 @@ // 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. + +// ---------------------------------------------------------------------------- + //! CSS minification. use swc_common::input::StringInput; @@ -13,8 +33,12 @@ use swc_css::codegen::{CodeGenerator, CodegenConfig, Emit}; use swc_css::minifier::options::MinifyOptions; use swc_css::parser::parser::ParserConfig; +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + /// Minifies CSS while retaining the original source on parse errors. -pub(super) fn minify(source: &str) -> Option<String> { +pub fn minify(source: &str) -> Option<String> { GLOBALS.set(&Globals::default(), || { let legal = legal_comments(source); let end = u32::try_from(source.len()).ok()?; @@ -95,9 +119,13 @@ fn legal_comments(source: &str) -> Vec<&str> { comments } +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + #[cfg(test)] mod tests { - use super::*; + use super::{legal_comments, minify}; #[test] fn minifies_modern_css() { diff --git a/crates/zensical/src/compat/mkdocs/plugin/mkdocstrings.rs b/crates/zensical/src/compat/mkdocs/plugin/mkdocstrings.rs index c35d819..521f68e 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/mkdocstrings.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/mkdocstrings.rs @@ -3,15 +3,37 @@ // 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. + +// ---------------------------------------------------------------------------- + //! Mkdocstrings compatibility plugin. use pyo3::types::PyAnyMethods; use pyo3::Python; use std::fs; + use zrx::id::Id; use zrx::stream::Signal; use crate::config::Config; +use crate::path::SitePath; use crate::structure::nav::Navigation; // ---------------------------------------------------------------------------- @@ -19,7 +41,7 @@ use crate::structure::nav::Navigation; // ---------------------------------------------------------------------------- /// Attach object inventory generation to the settled navigation stream. -pub(crate) fn attach(config: &Config, nav: &Signal<Id, Navigation>) { +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(); @@ -34,8 +56,9 @@ pub(crate) fn attach(config: &Config, nav: &Signal<Id, Navigation>) { }); if let Ok(data) = data { - let site_dir = config.get_site_dir(); - let path = site_dir.join("objects.inv"); + 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); diff --git a/crates/zensical/src/compat/mkdocs/plugin/redirects.rs b/crates/zensical/src/compat/mkdocs/plugin/redirects.rs index 6710eb5..ae2a336 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/redirects.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/redirects.rs @@ -3,483 +3,146 @@ // SPDX-License-Identifier: MIT // All contributions are certified under the DCO -//! MkDocs-compatible redirects plugin. +// 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-compatible redirects pipeline. + +use anyhow::Result; +use std::sync::Arc; -use anyhow::{bail, Result}; -use std::collections::{BTreeMap, BTreeSet}; -use std::fs; -use std::path::{Component, Path}; use zrx::id::Id; -use zrx::stream::{Key, Signal, Value}; +use zrx::stream::function::Collection; +use zrx::stream::{Stream, Value}; -use crate::compat::mkdocs::plugin::meta; use crate::config::Config; +use crate::path::OutputRoot; use crate::structure::page::PageRoute; -// ---------------------------------------------------------------------------- -// Constants -// ---------------------------------------------------------------------------- +mod output; +mod plan; -/// Redirect document emitted by mkdocs-redirects 1.2.2. -const HTML_TEMPLATE: &str = r##" -<!doctype html> -<html lang="en"> -<head> - <meta charset="utf-8"> - <title>Redirecting... - - - - - -You're being redirected to a new destination. - - -"##; - -/// Source suffixes recognized by MkDocs as Markdown. -const MARKDOWN_SUFFIXES: &[&str] = - &[".markdown", ".mdown", ".mkdn", ".mkd", ".md"]; +use output::write; +use plan::{Plan, Snapshot}; // ---------------------------------------------------------------------------- // Structs // ---------------------------------------------------------------------------- -/// One resolved redirect output. -#[derive(Clone, Debug, PartialEq, Eq)] -struct Redirect { - /// Site-relative output path. - output: String, - /// Resolved redirect target, or `None` when the target is missing. - target: Option, +/// MkDocs-compatible redirects pipeline. +#[derive(Clone, Debug)] +pub struct Redirects; + +// ---------------------------------------------------------------------------- + +/// Streams consumed by the redirects pipeline. +pub struct Dependencies<'a> { + /// Module-local settings derived from the shared configuration stream. + pub settings: &'a Stream, + /// Routes derived before Markdown rendering. + pub routes: &'a Stream, } -/// One validated configuration entry awaiting target resolution. -struct Specification<'a> { - /// Site-relative output path. - output: String, - /// Configured internal or external target. - target: &'a str, +// ---------------------------------------------------------------------------- + +/// Configuration owned by the redirects pipeline. +#[derive(Clone, Debug)] +pub struct Settings { + /// Configuration work prepared once for the workflow lifetime. + plan: Arc, + /// Whether page URLs use directories. + use_directory_urls: bool, + /// Site output directory. + output: OutputRoot, + /// Whether warnings fail the build. + strict: bool, } -/// Revision-settled redirect outputs and warnings. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub(crate) struct Snapshot { - /// Redirects ordered by configured source URI. - redirects: Vec, - /// Compatibility warnings emitted for this snapshot. - warnings: Vec, -} +// ---------------------------------------------------------------------------- -impl Value for Snapshot {} +/// Compact revision-settled route facts consumed by redirects. +#[derive(Clone, Debug)] +struct Routes(Arc>); // ---------------------------------------------------------------------------- // Implementations // ---------------------------------------------------------------------------- -impl Snapshot { - /// Resolves configured redirects against the current page relation. - pub(crate) fn new<'a>( - config: &Config, - page_routes: impl Iterator, &'a PageRoute)>, - ) -> Result { - let settings = &config.project.plugins.redirects.config; - if !settings.enabled || settings.redirect_maps.is_empty() { - return Ok(Self::default()); - } - - let mut outputs = BTreeSet::new(); - let mut targets = BTreeSet::new(); - let mut specifications = - Vec::with_capacity(settings.redirect_maps.len()); - let mut warnings = Vec::new(); - for (source, configured_target) in &settings.redirect_maps { - let source = normalize_source(source)?; - let output = PageRoute::destination( - &source, - config.project.use_directory_urls, - ); - if !outputs.insert(output.clone()) { - bail!("redirect output '{output}' is configured more than once") - } - validate_output(config, &output)?; - - if !MARKDOWN_SUFFIXES - .iter() - .any(|suffix| source.ends_with(suffix)) - { - warnings.push(format!( - "redirects plugin: '{source}' is not a valid markdown file!" - )); - } - - if !is_external(configured_target) { - targets.insert(split_fragment(configured_target).0); - } - specifications.push(Specification { - output, - target: configured_target, - }); - } - - let mut routes = BTreeMap::new(); - for (_, route) in page_routes { - if targets.contains(route.source.as_str()) { - routes.insert(route.source.clone(), route.url.clone()); - } - if outputs.contains(&route.destination) { - bail!( - "redirect output '{}' collides with a page", - route.destination - ) - } - } - - let mut redirects = Vec::with_capacity(specifications.len()); - for specification in specifications { - let target = if is_external(specification.target) { - Some(specification.target.into()) - } else { - let (target_source, fragment) = - split_fragment(specification.target); - if let Some(url) = routes.get(target_source) { - Some(relative_target( - &specification.output, - url, - fragment, - config.project.use_directory_urls, - )) - } else { - warnings.push(format!( - "Redirect target '{}' does not exist!", - specification.target - )); - None - } - }; - redirects.push(Redirect { - output: specification.output, - target, - }); - } - Ok(Self { redirects, warnings }) - } -} - -// ---------------------------------------------------------------------------- -// Functions -// ---------------------------------------------------------------------------- - -/// Attaches redirect artifact generation to the settled site graph. -pub(crate) fn attach( - config: &Config, strict: bool, snapshot: &Signal, -) { - let settings = &config.project.plugins.redirects.config; - if !settings.enabled || settings.redirect_maps.is_empty() { - return; - } - let site_dir = config.get_site_dir(); - let _ = snapshot - .map(move |snapshot: &Snapshot| write(&site_dir, snapshot, strict)); -} - -/// Reconciles one redirect snapshot with the site directory. -fn write(site_dir: &Path, snapshot: &Snapshot, strict: bool) -> Result<()> { - for redirect in &snapshot.redirects { - let path = site_dir.join(&redirect.output); - if let Some(target) = &redirect.target { - fs::create_dir_all(path.parent().expect("redirect has parent"))?; - fs::write(path, redirect_html(target))?; - } else if path.is_file() { - fs::remove_file(path)?; - } - } - for warning in &snapshot.warnings { - eprintln!("WARNING - {warning}"); - } - if strict && !snapshot.warnings.is_empty() { - bail!("Aborted because --strict flag is set") - } - Ok(()) -} - -/// Rejects redirect sources that could escape the site directory. -fn normalize_source(source: &str) -> Result { - if source.is_empty() || source.contains('\\') { - bail!("redirect source '{source}' is not a safe relative path") - } - let mut parts = Vec::new(); - for component in Path::new(source).components() { - match component { - Component::Normal(part) => { - parts.push(part.to_string_lossy().into_owned()); - } - Component::CurDir => {} - Component::ParentDir - | Component::RootDir - | Component::Prefix(_) => { - bail!("redirect source '{source}' is not a safe relative path") - } - } - } - if parts.is_empty() { - bail!("redirect source '{source}' is not a safe relative path") - } - Ok(parts.join("/")) -} - -/// Rejects redirect paths already owned by another output producer. -fn validate_output(config: &Config, output: &str) -> Result<()> { - let extra_templates = &config.project.extra_templates; - let meta = meta::Settings::new(config); - let docs_asset = config.get_docs_dir().join(output); - if docs_asset.is_file() - && !meta::claims(output, &meta) - && !extra_templates.iter().any(|template| template == output) - { - bail!("redirect output '{output}' collides with a documentation asset") - } - - if config.theme_dirs.iter().any(|directory| { - let path = directory.join(output); - path.is_file() && path.extension().is_none_or(|ext| ext != "html") - }) { - bail!("redirect output '{output}' collides with a theme asset") - } - - let templates = config - .project - .theme - .static_templates - .iter() - .chain(extra_templates); - if templates - .filter_map(|template| Path::new(template).file_name()) - .any(|name| { - Path::new(output).file_name().is_some_and(|out| out == name) - && Path::new(output) - .parent() - .is_none_or(|parent| parent.as_os_str().is_empty()) - }) - { - bail!("redirect output '{output}' collides with a rendered template") - } - Ok(()) -} - -/// Returns whether a configured target is an external HTTP(S) URL. -fn is_external(target: &str) -> bool { - let target = target.to_ascii_lowercase(); - target.starts_with("http://") || target.starts_with("https://") -} - -/// Splits an internal target into source URI and hash fragment. -fn split_fragment(target: &str) -> (&str, &str) { - target - .find('#') - .map_or((target, ""), |index| (&target[..index], &target[index..])) -} - -/// Makes a final page URL relative to one redirect output. -fn relative_target( - output: &str, target: &str, fragment: &str, use_directory_urls: bool, -) -> String { - let parent = Path::new(output).parent().unwrap_or_else(|| Path::new("")); - let mut relative = relative_path(Path::new(target), parent); - if use_directory_urls { - relative.push('/'); - } - relative.push_str(fragment); - relative -} - -/// Computes a lexical POSIX-style relative path. -fn relative_path(target: &Path, base: &Path) -> String { - let target = target - .components() - .filter_map(|component| match component { - Component::Normal(part) => Some(part), - _ => None, - }) - .collect::>(); - let base = base - .components() - .filter_map(|component| match component { - Component::Normal(part) => Some(part), - _ => None, - }) - .collect::>(); - let common = target - .iter() - .zip(&base) - .take_while(|(left, right)| left == right) - .count(); - let mut parts = vec!["..".into(); base.len() - common]; - parts.extend( - target[common..] - .iter() - .map(|part| part.to_string_lossy().into_owned()), - ); - if parts.is_empty() { - ".".into() - } else { - parts.join("/") - } -} - -/// Renders the upstream redirect document. -fn redirect_html(target: &str) -> String { - HTML_TEMPLATE.replace("{url}", target) -} - -// ---------------------------------------------------------------------------- -// Tests -// ---------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn matches_upstream_relative_targets() { - let directory_cases = [ - ("old/index.html", "", "", "../"), - ("old/index.html", "new/", "", "../new/"), - ("foo/old/index.html", "foo/new/", "", "../new/"), - ( - "foo/fizz/old/index.html", - "foo/bar/new/", - "", - "../../bar/new/", - ), - ( - "fizz/old/index.html", - "foo/bar/new/", - "", - "../../foo/bar/new/", - ), - ("foo/index.html", "foo/", "", "./"), - ( - "foo/index.html", - "fake/destination/", - "", - "../fake/destination/", - ), - ("old/index.html", "new/", "#hash", "../new/#hash"), - ("foo/index.html", "foo/", "#hash", "./#hash"), - ("old/index.html", "100%25/", "", "../100%25/"), - ]; - for (output, target, fragment, expected) in directory_cases { - assert_eq!( - relative_target(output, target, fragment, true), - expected - ); - } - - let file_cases = [ - ("old.html", "index.html", "", "index.html"), - ("old.html", "new.html", "", "new.html"), - ("foo/old.html", "foo/new.html", "", "new.html"), - ( - "foo/fizz/old.html", - "foo/bar/new.html", - "", - "../bar/new.html", - ), - ( - "fizz/old.html", - "foo/bar/new.html", - "", - "../foo/bar/new.html", - ), - ("foo.html", "foo/index.html", "", "foo/index.html"), - ("old.html", "new.html", "#hash", "new.html#hash"), - ]; - for (output, target, fragment, expected) in file_cases { - assert_eq!( - relative_target(output, target, fragment, false), - expected - ); - } - } - - #[test] - fn matches_upstream_redirect_output_paths() { - let cases = [ - ("old.md", "old.html", "old/index.html"), - ("README.md", "index.html", "index.html"), - ("100%.md", "100%.html", "100%/index.html"), - ( - "foo/fizz/old.md", - "foo/fizz/old.html", - "foo/fizz/old/index.html", - ), - ( - "foo/fizz/index.md", - "foo/fizz/index.html", - "foo/fizz/index.html", - ), - ]; - for (source, file, directory) in cases { - assert_eq!(PageRoute::destination(source, false), file); - assert_eq!(PageRoute::destination(source, true), directory); - } - } - - #[test] - fn rejects_unsafe_sources() { - for source in ["", "../old.md", "/old.md", "old\\page.md"] { - assert!(normalize_source(source).is_err(), "{source}"); - } - assert_eq!(normalize_source("./old/page.md").unwrap(), "old/page.md"); - } - - #[test] - fn renders_upstream_document() { - let html = redirect_html("../new/"); - assert_eq!( - html, - r##" - - - - - Redirecting... - - - - - -You're being redirected to a new destination. - - -"## +impl Redirects { + /// Installs redirect resolution and artifact generation. + /// + /// Route updates are first reduced into one revision-settled value. This + /// prevents the writer from observing a partially updated navigation and + /// keeps every emitted redirect snapshot internally consistent. + #[allow( + clippy::unused_self, + reason = "setup is the module instance entry point" + )] + pub fn setup(&self, dependencies: Dependencies<'_>) { + let routes = dependencies.routes.reduce( + |routes: &dyn Collection, PageRoute>| { + Some(Routes(Arc::new(routes.values().cloned().collect()))) + }, ); - } - - #[test] - fn removes_a_stale_redirect_when_its_target_disappears() { - let directory = tempfile::tempdir().unwrap(); - let output = String::from("old/index.html"); - let valid = Snapshot { - redirects: vec![Redirect { - output: output.clone(), - target: Some("../new/".into()), - }], - warnings: Vec::new(), - }; - write(directory.path(), &valid, false).unwrap(); - assert!(directory.path().join(&output).is_file()); - - let missing = Snapshot { - redirects: vec![Redirect { - output: output.clone(), - target: None, - }], - warnings: vec!["missing".into()], - }; - write(directory.path(), &missing, false).unwrap(); - assert!(!directory.path().join(output).exists()); - assert!(write(directory.path(), &missing, true).is_err()); + let snapshots = routes.product(dependencies.settings).map( + |routes: &Routes, settings: &Settings| { + Ok::<_, anyhow::Error>(( + Snapshot::new( + &settings.plan, + routes.0.iter(), + settings.use_directory_urls, + )?, + settings.clone(), + )) + }, + ); + let _ = snapshots.map(|snapshot: &(Snapshot, Settings)| { + write(&snapshot.1.output, &snapshot.0, snapshot.1.strict) + }); } } + +// ---------------------------------------------------------------------------- + +impl Settings { + /// Extracts and prepares the configuration owned by redirects. + pub fn new(config: &Config, strict: bool) -> Result { + Ok(Self { + plan: Arc::new(Plan::new(config)?), + use_directory_urls: config.project.use_directory_urls, + output: config.output_root().clone(), + strict, + }) + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Value for Settings {} + +// ---------------------------------------------------------------------------- + +impl Value for Routes {} + +// ---------------------------------------------------------------------------- + +impl Value for Snapshot {} diff --git a/crates/zensical/src/compat/mkdocs/plugin/redirects/output.rs b/crates/zensical/src/compat/mkdocs/plugin/redirects/output.rs new file mode 100644 index 0000000..c4e1c54 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/redirects/output.rs @@ -0,0 +1,146 @@ +// 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. + +// ---------------------------------------------------------------------------- + +//! Redirect output. + +use anyhow::{bail, Result}; +use std::fs; + +use crate::path::OutputRoot; + +use super::plan::Snapshot; + +/// Redirect document emitted by mkdocs-redirects 1.2.2. +const HTML_TEMPLATE: &str = r##" + + + + + Redirecting... + + + + + +You're being redirected to a new destination. + + +"##; + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Reconciles one complete redirect snapshot with the site directory. +/// +/// Missing targets retract outputs created by earlier revisions. Warnings are +/// reported after all files have been reconciled so strict mode cannot leave a +/// stale redirect behind merely because its target disappeared. +pub fn write( + output: &OutputRoot, snapshot: &Snapshot, strict: bool, +) -> Result<()> { + for redirect in &snapshot.redirects { + let path = output.join(&redirect.output); + if let Some(target) = &redirect.target { + fs::create_dir_all(path.parent().expect("redirect has parent"))?; + fs::write(path, render(target))?; + } else if path.is_file() { + fs::remove_file(path)?; + } + } + for warning in &snapshot.warnings { + eprintln!("WARNING - {warning}"); + } + if strict && !snapshot.warnings.is_empty() { + bail!("Aborted because --strict flag is set") + } + Ok(()) +} + +/// Renders the upstream-compatible redirect document. +fn render(target: &str) -> String { + HTML_TEMPLATE.replace("{url}", target) +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::{render, write}; + use crate::compat::mkdocs::plugin::redirects::plan::{Redirect, Snapshot}; + use crate::path::{OutputRoot, SitePath}; + + #[test] + fn renders_upstream_document() { + let html = render("../new/"); + assert_eq!( + html, + r##" + + + + + Redirecting... + + + + + +You're being redirected to a new destination. + + +"## + ); + } + + #[test] + fn removes_a_stale_redirect_when_its_target_disappears() { + let directory = tempfile::tempdir().unwrap(); + let output = "old/index.html".parse::().unwrap(); + let valid = Snapshot { + redirects: vec![Redirect { + output: output.clone(), + target: Some("../new/".into()), + }], + warnings: Vec::new(), + }; + let root = OutputRoot::prepare(directory.path()).unwrap(); + write(&root, &valid, false).unwrap(); + assert!(directory.path().join(output.as_str()).is_file()); + + let missing = Snapshot { + redirects: vec![Redirect { + output: output.clone(), + target: None, + }], + warnings: vec!["missing".into()], + }; + write(&root, &missing, false).unwrap(); + assert!(!directory.path().join(output.as_str()).exists()); + assert!(write(&root, &missing, true).is_err()); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/redirects/plan.rs b/crates/zensical/src/compat/mkdocs/plugin/redirects/plan.rs new file mode 100644 index 0000000..92ffd3b --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/redirects/plan.rs @@ -0,0 +1,490 @@ +// 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. + +// ---------------------------------------------------------------------------- + +//! Redirect planning. + +use anyhow::{bail, Result}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Component, Path}; + +use crate::config::Config; +use crate::path::{SitePath, SourcePath}; +use crate::structure::page::PageRoute; + +/// Source suffixes recognized by MkDocs as Markdown. +const MARKDOWN_SUFFIXES: &[&str] = + &[".markdown", ".mdown", ".mkdn", ".mkd", ".md"]; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// Target classification prepared before route settlement. +#[derive(Clone, Debug)] +enum Target { + /// External target copied into the redirect document unchanged. + External(String), + /// Internal Markdown source resolved against the live route relation. + Internal { + /// Original configured value used in diagnostics. + configured: String, + /// Source path without a fragment. + source: String, + /// Fragment including its leading `#`, when present. + fragment: String, + }, +} + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Redirect configuration prepared independently of live page routes. +#[derive(Clone, Debug, Default)] +pub struct Plan { + /// Generated paths reserved by configured redirects. + outputs: BTreeSet, + /// Internal source paths needed from the live route relation. + targets: BTreeSet, + /// Validated redirect specifications in configuration order. + specifications: Vec, + /// Diagnostics that depend only on configuration. + warnings: Vec, +} + +// ---------------------------------------------------------------------------- + +/// One resolved redirect output. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Redirect { + /// Site-relative output path. + pub output: SitePath, + /// Resolved redirect target, or `None` when the target is missing. + pub target: Option, +} + +// ---------------------------------------------------------------------------- + +/// One validated configuration entry awaiting target resolution. +#[derive(Clone, Debug)] +struct Specification { + /// Site-relative output path. + output: SitePath, + /// Prepared internal or external target. + target: Target, +} + +// ---------------------------------------------------------------------------- + +/// Revision-settled redirect outputs and warnings. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Snapshot { + /// Redirects ordered by configured source URI. + pub redirects: Vec, + /// Compatibility warnings emitted for this snapshot. + pub warnings: Vec, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Plan { + /// Validates route-independent configuration once for the workflow. + pub fn new(config: &Config) -> Result { + let plugin = &config.project.plugins.redirects.config; + if !plugin.enabled || plugin.redirect_maps.is_empty() { + return Ok(Self::default()); + } + + let mut plan = Self { + specifications: Vec::with_capacity(plugin.redirect_maps.len()), + ..Self::default() + }; + for (source, configured_target) in &plugin.redirect_maps { + let source = normalize_source(source)?; + let output = PageRoute::destination( + &source, + config.project.use_directory_urls, + )?; + if !plan.outputs.insert(output.clone()) { + bail!("redirect output '{output}' is configured more than once") + } + validate_output(config, &output)?; + + if !MARKDOWN_SUFFIXES + .iter() + .any(|suffix| source.as_str().ends_with(suffix)) + { + plan.warnings.push(format!( + "redirects plugin: '{source}' is not a valid markdown file!" + )); + } + + let target = if is_external(configured_target) { + Target::External(configured_target.clone()) + } else { + let (source, fragment) = split_fragment(configured_target); + plan.targets.insert(source.into()); + Target::Internal { + configured: configured_target.clone(), + source: source.into(), + fragment: fragment.into(), + } + }; + plan.specifications.push(Specification { output, target }); + } + Ok(plan) + } +} + +// ---------------------------------------------------------------------------- + +impl Snapshot { + /// Resolves one prepared plan against a revision-settled page relation. + pub fn new<'a>( + plan: &Plan, page_routes: impl Iterator, + use_directory_urls: bool, + ) -> Result { + if plan.specifications.is_empty() { + return Ok(Self::default()); + } + + let mut routes = BTreeMap::new(); + for route in page_routes { + if plan.targets.contains(route.source.as_str()) { + routes.insert(route.source.to_string(), route.url.clone()); + } + if plan.outputs.contains(&route.destination) { + bail!( + "redirect output '{}' collides with a page", + route.destination + ) + } + } + + let mut redirects = Vec::with_capacity(plan.specifications.len()); + let mut warnings = plan.warnings.clone(); + for specification in &plan.specifications { + let target = match &specification.target { + Target::External(target) => Some(target.clone()), + Target::Internal { configured, source, fragment } => { + if let Some(url) = routes.get(source) { + Some(relative_target( + &specification.output, + url, + fragment, + use_directory_urls, + )) + } else { + warnings.push(format!( + "Redirect target '{configured}' does not exist!" + )); + None + } + } + }; + redirects.push(Redirect { + output: specification.output.clone(), + target, + }); + } + Ok(Self { redirects, warnings }) + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Rejects redirect sources that could escape the site directory. +fn normalize_source(source: &str) -> Result { + if source.is_empty() || source.contains('\\') { + bail!("redirect source '{source}' is not a safe relative path") + } + let mut parts = Vec::new(); + for component in Path::new(source).components() { + match component { + Component::Normal(part) => { + parts.push(part.to_str().ok_or_else(|| { + anyhow::anyhow!( + "redirect source '{source}' is not valid UTF-8" + ) + })?); + } + Component::CurDir => {} + Component::ParentDir + | Component::RootDir + | Component::Prefix(_) => { + bail!("redirect source '{source}' is not a safe relative path") + } + } + } + if parts.is_empty() { + bail!("redirect source '{source}' is not a safe relative path") + } + Ok(parts.join("/").parse()?) +} + +/// Rejects redirect paths already owned by another output producer. +fn validate_output(config: &Config, output: &SitePath) -> Result<()> { + let extra_templates = &config.project.extra_templates; + let docs_asset = config.docs_root().as_path().join(output.as_str()); + let metadata_file = config + .project + .plugins + .meta + .config + .enabled + .then_some(config.project.plugins.meta.config.meta_file.as_str()); + if docs_asset.is_file() + && metadata_file != Some(output.file_name()) + && !extra_templates + .iter() + .any(|template| template == output.as_str()) + { + bail!("redirect output '{output}' collides with a documentation asset") + } + + if config.theme_dirs.iter().any(|directory| { + let path = directory.join(output.as_str()); + path.is_file() && path.extension().is_none_or(|ext| ext != "html") + }) { + bail!("redirect output '{output}' collides with a theme asset") + } + + let templates = config + .project + .theme + .static_templates + .iter() + .chain(extra_templates); + let output_name = output.file_name(); + if templates + .filter_map(|template| Path::new(template).file_name()) + .any(|name| name == output_name && output.depth() == 1) + { + bail!("redirect output '{output}' collides with a rendered template") + } + Ok(()) +} + +/// Returns whether a configured target is an external HTTP(S) URL. +fn is_external(target: &str) -> bool { + let target = target.to_ascii_lowercase(); + target.starts_with("http://") || target.starts_with("https://") +} + +/// Splits an internal target into source URI and hash fragment. +fn split_fragment(target: &str) -> (&str, &str) { + target + .find('#') + .map_or((target, ""), |index| (&target[..index], &target[index..])) +} + +/// Makes a final page URL relative to one redirect output. +fn relative_target( + output: &SitePath, target: &str, fragment: &str, use_directory_urls: bool, +) -> String { + let parent = Path::new(output.as_str()) + .parent() + .unwrap_or_else(|| Path::new("")); + let mut relative = relative_path(Path::new(target), parent); + if use_directory_urls { + relative.push('/'); + } + relative.push_str(fragment); + relative +} + +/// Computes a lexical POSIX-style relative path. +fn relative_path(target: &Path, base: &Path) -> String { + let target = target + .components() + .filter_map(|component| match component { + Component::Normal(part) => Some(part), + _ => None, + }) + .collect::>(); + let base = base + .components() + .filter_map(|component| match component { + Component::Normal(part) => Some(part), + _ => None, + }) + .collect::>(); + let common = target + .iter() + .zip(&base) + .take_while(|(left, right)| left == right) + .count(); + let mut parts = vec!["..".into(); base.len() - common]; + parts.extend(target[common..].iter().map(|part| { + part.to_str() + .expect("redirect URL originated as UTF-8") + .to_owned() + })); + if parts.is_empty() { + ".".into() + } else { + parts.join("/") + } +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::{normalize_source, relative_target}; + use crate::path::{SitePath, SourcePath}; + use crate::structure::page::PageRoute; + + #[test] + fn matches_upstream_relative_targets() { + let directory_cases = [ + ("old/index.html", "", "", "../"), + ("old/index.html", "new/", "", "../new/"), + ("foo/old/index.html", "foo/new/", "", "../new/"), + ( + "foo/fizz/old/index.html", + "foo/bar/new/", + "", + "../../bar/new/", + ), + ( + "fizz/old/index.html", + "foo/bar/new/", + "", + "../../foo/bar/new/", + ), + ("foo/index.html", "foo/", "", "./"), + ( + "foo/index.html", + "fake/destination/", + "", + "../fake/destination/", + ), + ("old/index.html", "new/", "#hash", "../new/#hash"), + ("foo/index.html", "foo/", "#hash", "./#hash"), + ("old/index.html", "100%25/", "", "../100%25/"), + ]; + for (output, target, fragment, expected) in directory_cases { + assert_eq!( + relative_target( + &output.parse().unwrap(), + target, + fragment, + true + ), + expected + ); + } + + let file_cases = [ + ("old.html", "index.html", "", "index.html"), + ("old.html", "new.html", "", "new.html"), + ("foo/old.html", "foo/new.html", "", "new.html"), + ( + "foo/fizz/old.html", + "foo/bar/new.html", + "", + "../bar/new.html", + ), + ( + "fizz/old.html", + "foo/bar/new.html", + "", + "../foo/bar/new.html", + ), + ("foo.html", "foo/index.html", "", "foo/index.html"), + ("old.html", "new.html", "#hash", "new.html#hash"), + ]; + for (output, target, fragment, expected) in file_cases { + assert_eq!( + relative_target( + &output.parse().unwrap(), + target, + fragment, + false + ), + expected + ); + } + } + + #[test] + fn matches_upstream_redirect_output_paths() { + let cases = [ + ("old.md", "old.html", "old/index.html"), + ("README.md", "index.html", "index.html"), + ("100%.md", "100%.html", "100%/index.html"), + ( + "foo/fizz/old.md", + "foo/fizz/old.html", + "foo/fizz/old/index.html", + ), + ( + "foo/fizz/index.md", + "foo/fizz/index.html", + "foo/fizz/index.html", + ), + ]; + for (source, file, directory) in cases { + let source = source.parse::().unwrap(); + assert_eq!( + PageRoute::destination(&source, false).unwrap().as_str(), + file + ); + assert_eq!( + PageRoute::destination(&source, true).unwrap().as_str(), + directory + ); + } + } + + #[test] + fn rejects_unsafe_sources() { + for source in [ + "", + "../old.md", + "nested/../old.md", + "/old.md", + "old\\page.md", + ] { + assert!(normalize_source(source).is_err(), "{source}"); + } + assert_eq!( + normalize_source("./old/page.md").unwrap().as_str(), + "old/page.md" + ); + assert_eq!( + normalize_source("guides/café.md").unwrap().as_str(), + "guides/café.md" + ); + assert!("../outside.html".parse::().is_err()); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/search.rs b/crates/zensical/src/compat/mkdocs/plugin/search.rs index 35f6c41..94d8b2e 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/search.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/search.rs @@ -30,14 +30,16 @@ use std::collections::BTreeMap; use std::fs; use std::io::{BufWriter, Write}; use std::sync::Arc; + use zrx::id::Id; use zrx::scheduler::Value; -use zrx::stream::{Key, Signal}; +use zrx::stream::Signal; use crate::config::plugins::SearchPluginConfig; use crate::config::Config; +use crate::path::{SitePath, SourcePath}; use crate::structure::dynamic::Dynamic; -use crate::structure::nav::{file_sort_key, Navigation}; +use crate::structure::nav::{source_sort_key, Navigation}; use crate::structure::page::Page; mod item; @@ -70,14 +72,16 @@ struct SearchIndex { /// Search facts extracted while rendering one Markdown page. #[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] -pub(crate) struct Facts { +pub struct Facts { /// Page-local search sections. sections: Vec, } /// Compact page document retained by the search branch. #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct Document { +pub struct Document { + /// Documentation-relative source used for deterministic ordering. + source: SourcePath, /// Page target URL. url: String, /// Page title. @@ -90,9 +94,9 @@ pub(crate) struct Document { /// Revision-aligned search inputs from the site settlement boundary. #[derive(Clone, Debug)] -pub(crate) struct Snapshot { +pub struct Snapshot { /// Compact page documents. - documents: Arc, Document)>>, + documents: Arc>, /// Navigation from the same page revision. nav: Navigation, } @@ -115,8 +119,9 @@ impl SearchConfig { impl Document { /// Attaches page properties to previously extracted search facts. - pub(crate) fn new(page: &Page, facts: Arc) -> Self { + pub fn new(page: &Page, facts: Arc) -> Self { Self { + source: page.source().clone(), url: page.url.clone(), title: page.title.clone(), tags: page.tags().into_iter().map(|tag| tag.name).collect(), @@ -129,9 +134,7 @@ impl Document { impl Snapshot { /// Creates a search snapshot without another site-wide reduction. - pub(crate) fn new( - documents: Vec<(Key, Document)>, nav: Navigation, - ) -> Self { + pub fn new(documents: Vec, nav: Navigation) -> Self { Self { documents: Arc::new(documents), nav, @@ -143,7 +146,7 @@ impl Snapshot { impl Facts { /// Returns whether this page contributes anything to the search index. - pub(crate) fn is_empty(&self) -> bool { + pub fn is_empty(&self) -> bool { self.sections.is_empty() } } @@ -154,17 +157,19 @@ impl SearchIndex { /// Creates a search index from compact page facts. #[allow(clippy::assigning_clones)] fn new( - documents: Vec<(Key, Document)>, nav: &Navigation, - config: SearchPluginConfig, language: &str, + documents: Vec, nav: &Navigation, config: SearchPluginConfig, + language: &str, ) -> Self { let mut items: Vec = Vec::new(); - let mut documents = Vec::from_iter(documents); - documents.sort_by_key(|(id, _)| file_sort_key(&id[0])); + // Provider order is not stable, so establish MkDocs-compatible source + // order before emitting the site-wide index. + let mut documents = documents; + documents.sort_by_key(|document| source_sort_key(&document.source)); // Attach site-wide navigation facts only while assembling the final // artifact, keeping them out of each page-local stream value. - for (_id, document) in documents { + for document in documents { let iter = nav.ancestors_for_url(&document.url).into_iter().rev(); let mut path = iter .filter_map(|item| { @@ -178,6 +183,8 @@ impl SearchIndex { path.push(document.title.clone()); } + // Each heading section becomes an independently addressable search + // item while sharing the page path and tags. for section in &document.facts.sections { let location = match §ion.location { Some(id) => format!("{}#{}", document.url, id), @@ -219,7 +226,7 @@ impl Value for Snapshot {} // ---------------------------------------------------------------------------- /// Attach MkDocs-compatible search artifact generation to the build graph. -pub(crate) fn attach(config: &Config, snapshot: &Signal) { +pub fn attach(config: &Config, snapshot: &Signal) { let config = config.clone(); let _ = snapshot.map(move |snapshot: &Snapshot| { let documents = if config.project.plugins.search.config.enabled { @@ -238,7 +245,7 @@ pub(crate) fn attach(config: &Config, snapshot: &Signal) { } /// Creates the page-local search visitor. -pub(crate) fn parser(meta: &BTreeMap) -> Parser { +pub fn parser(meta: &BTreeMap) -> Parser { if is_search_excluded(meta) { Parser::discarding() } else { @@ -247,21 +254,25 @@ pub(crate) fn parser(meta: &BTreeMap) -> Parser { } /// Converts a completed visitor into cached page-local facts. -pub(crate) fn finish(parser: Parser) -> Arc { +pub fn finish(parser: Parser) -> Arc { Arc::new(Facts { sections: parser.finish() }) } /// Write search artifacts without retaining a second serialized copy. fn write(config: &Config, search: &SearchIndex) -> anyhow::Result<()> { - let site_dir = config.get_site_dir(); - let path = site_dir.join("search.json"); + let output_root = config.output_root(); + let path = output_root + .join(&"search.json".parse::().expect("static site path")); fs::create_dir_all(path.parent().expect("invariant"))?; let mut writer = BufWriter::new(fs::File::create(path)?); serde_json::to_writer(&mut writer, search)?; writer.flush()?; + // Offline mode embeds the same index in the JavaScript global expected by + // the theme instead of changing its schema. if config.project.plugins.offline.config.enabled { - let path = site_dir.join("search.js"); + let path = output_root + .join(&"search.js".parse::().expect("static site path")); fs::create_dir_all(path.parent().expect("invariant"))?; let mut writer = BufWriter::new(fs::File::create(path)?); writer.write_all(b"var __index = ")?; @@ -286,7 +297,11 @@ fn is_search_excluded(meta: &BTreeMap) -> bool { #[cfg(test)] mod tests { - use super::*; + use std::collections::BTreeMap; + + use crate::structure::dynamic::Dynamic; + + use super::is_search_excluded; #[test] fn search_exclusion_is_read_from_page_metadata() { diff --git a/crates/zensical/src/compat/mkdocs/plugin/search/item.rs b/crates/zensical/src/compat/mkdocs/plugin/search/item.rs index 996fd7a..b24252a 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/search/item.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/search/item.rs @@ -52,7 +52,7 @@ pub struct SearchItem { /// Page-local search section before site-wide facts are attached. #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] -pub(crate) struct SearchSection { +pub struct SearchSection { /// Heading fragment, if present. pub location: Option, /// Section level. diff --git a/crates/zensical/src/compat/mkdocs/plugin/search/parser.rs b/crates/zensical/src/compat/mkdocs/plugin/search/parser.rs index 8c5f06d..59e17e3 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/search/parser.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/search/parser.rs @@ -3,6 +3,26 @@ // 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-compatible search extraction from rendered HTML. use html5gum::emitters::callback::CallbackEvent; @@ -13,12 +33,60 @@ use crate::compat::mkdocs::html::{Editor, Visitor}; use super::SearchSection; // ---------------------------------------------------------------------------- -// Parser +// Enums +// ---------------------------------------------------------------------------- + +/// Attributes relevant to extraction. +#[derive(Clone, Copy, Default)] +enum Attribute { + Id, + Class, + SearchExclude, + #[default] + Other, +} + +/// HTML tags relevant to extraction. +#[derive(Clone, Debug, PartialEq, Eq)] +enum Tag { + A, + Area, + Base, + Br, + Code, + Col, + Embed, + H(u8), + Hr, + Img, + Input, + Li, + Link, + Meta, + Object, + Ol, + P, + Param, + Pre, + Script, + Small, + Source, + Style, + Sub, + Sup, + Track, + Ul, + Wbr, + Other(Box), +} + +// ---------------------------------------------------------------------------- +// Structs // ---------------------------------------------------------------------------- /// Streaming search parser. #[derive(Default)] -pub(crate) struct Parser { +pub struct Parser { /// Whether extraction is disabled for a page excluded through metadata. discard: bool, /// Open HTML elements. @@ -35,290 +103,6 @@ pub(crate) struct Parser { attribute: Attribute, } -impl Parser { - /// Creates a parser that only applies search-related HTML cleanup. - pub(crate) fn discarding() -> Self { - Self { - discard: true, - ..Self::default() - } - } - - /// Handles a tokenizer event. - fn handle( - &mut self, event: &CallbackEvent<'_>, span: Span, - editor: &mut Editor<'_>, - ) { - if let CallbackEvent::AttributeName { name } = event - && *name == b"data-search-exclude" - { - editor.remove_attribute(name, span); - } - if self.discard { - return; - } - - match event { - CallbackEvent::OpenStartTag { name } => { - self.start = Some(StartTag::new(Tag::from_bytes(name))); - self.attribute = Attribute::Other; - } - CallbackEvent::AttributeName { name } => { - self.attribute = Attribute::from_bytes(name); - if let Some(start) = &mut self.start { - start.observe_attribute(self.attribute, None); - } - } - CallbackEvent::AttributeValue { value } => { - if let Some(start) = &mut self.start { - start.observe_attribute(self.attribute, Some(value)); - } - } - CallbackEvent::CloseStartTag { self_closing } => { - if let Some(start) = self.start.take() { - let tag = start.tag.clone(); - self.start(start); - if *self_closing { - self.end(&tag); - } - } - } - CallbackEvent::EndTag { name } => { - self.end(&Tag::from_bytes(name)); - } - CallbackEvent::String { value } => { - self.text(String::from_utf8_lossy(value).as_ref()); - } - CallbackEvent::Comment { .. } - | CallbackEvent::Doctype { .. } - | CallbackEvent::Error(_) => {} - } - } - - /// Handles a complete start tag. - fn start(&mut self, start: StartTag) { - if start.tag.is_void() { - return; - } - - let heading = start.tag.heading_level(); - let skipped = start.tag.is_skipped() - || start.excluded - || start.class.as_deref() == Some(b"linenodiv"); - let headerlink = start.tag == Tag::A - && start.class.as_deref() == Some(b"headerlink"); - let tag = start.tag.clone(); - - self.context.push(Element { - tag: start.tag, - skipped, - headerlink, - kept: None, - }); - - if let (Some(level), true) = (heading, start.id_present) { - let depth = self.context.len(); - - if level != 1 && self.sections.is_empty() { - self.push_preface(); - } - - let location = if self.sections.is_empty() { - None - } else { - start.id - }; - let section = SectionState::new( - Some(level), - level.into(), - depth, - location, - start.excluded, - ); - self.sections.push(section); - self.current = Some(self.sections.len() - 1); - } - - self.ensure_section(); - - if skipped { - self.skip += 1; - return; - } - - if self.skip == 0 && tag.is_kept() { - let (section, title) = self.output_target(); - let data = self.sections[section].output_mut(title); - let start = data.value.len(); - let previous_whitespace = data.last_whitespace; - data.value.push('<'); - data.value.push_str(tag.name()); - data.value.push('>'); - data.last_whitespace = false; - - self.context.last_mut().expect("context").kept = - Some(KeptElement { - section, - title, - start, - previous_whitespace, - }); - } - } - - /// Handles an end tag. - fn end(&mut self, tag: &Tag) { - if self.context.last().is_none_or(|el| el.tag != *tag) { - return; - } - - let depth = self.context.len(); - if let Some(index) = self.current - && self.sections[index].exited_or_deeper_than(depth) - && let Some(parent) = self - .sections - .iter() - .rposition(|section| !section.exited && section.depth <= depth) - { - self.sections[index].exited = true; - self.current = Some(parent); - } - - let element = self.context.pop().expect("context"); - if element.skipped { - self.skip -= 1; - return; - } - - if self.skip == 0 && tag.is_kept() { - let (section, title) = self.output_target(); - let data = self.sections[section].output_mut(title); - let opening = format!("<{}>", tag.name()); - - if let Some(start) = data.value.find(&opening) { - let following = &data.value[start + opening.len()..]; - if following.chars().any(|char| !char.is_whitespace()) { - data.value.push_str("'); - data.last_whitespace = false; - } else { - data.value.truncate(start); - data.last_whitespace = element.kept.map_or_else( - || { - data.value - .chars() - .last() - .is_some_and(char::is_whitespace) - }, - |kept| { - debug_assert_eq!(kept.section, section); - debug_assert_eq!(kept.title, title); - debug_assert_eq!(kept.start, start); - kept.previous_whitespace - }, - ); - } - } - } - } - - /// Handles text content. - fn text(&mut self, value: &str) { - if self.skip > 0 { - return; - } - - let preformatted = self.context.iter().any(|el| el.tag == Tag::Pre); - let whitespace = value.chars().all(char::is_whitespace); - let text; - let value = if preformatted { - value - } else if whitespace { - " " - } else if value.contains('\n') { - text = value.replace('\n', " "); - &text - } else { - value - }; - - self.ensure_section(); - let (section, title) = self.output_target(); - - if title { - if self.context.iter().any(|el| el.headerlink) { - return; - } - escape(value, &mut self.sections[section].title.value); - self.sections[section].title.last_whitespace = whitespace; - } else { - let data = &mut self.sections[section].text; - if !whitespace || preformatted || !data.last_whitespace { - escape(value, &mut data.value); - data.last_whitespace = whitespace; - } - } - } - - /// Returns the current section and whether its title receives output. - fn output_target(&self) -> (usize, bool) { - let section = self.current.expect("section"); - let heading = self.sections[section].heading; - let title = heading.is_some_and(|level| { - self.context - .iter() - .any(|el| el.tag.heading_level() == Some(level)) - }); - (section, title) - } - - /// Ensures a section exists for preface content. - fn ensure_section(&mut self) { - if self.current.is_none() { - self.push_preface(); - } - } - - /// Adds the implicit top-level preface section. - fn push_preface(&mut self) { - self.sections - .push(SectionState::new(None, 1, 0, None, false)); - self.current = Some(self.sections.len() - 1); - } - - /// Converts parser state into page-local search sections. - pub(crate) fn finish(self) -> Vec { - self.sections - .into_iter() - .filter(|section| !section.excluded) - .map(|section| SearchSection { - location: section.location, - level: section.level, - title: trim(section.title.value), - text: trim(section.text.value), - }) - .collect() - } -} - -// ---------------------------------------------------------------------------- -// Trait implementations -// ---------------------------------------------------------------------------- - -impl Visitor for Parser { - fn visit( - &mut self, event: &CallbackEvent<'_>, span: Span, - editor: &mut Editor<'_>, - ) { - self.handle(event, span, editor); - } -} - -// ---------------------------------------------------------------------------- -// State -// ---------------------------------------------------------------------------- - /// Section being assembled. struct SectionState { heading: Option, @@ -331,36 +115,6 @@ struct SectionState { text: Output, } -impl SectionState { - fn new( - heading: Option, level: u32, depth: usize, - location: Option, excluded: bool, - ) -> Self { - Self { - heading, - level, - depth, - exited: false, - excluded, - location, - title: Output::default(), - text: Output::default(), - } - } - - fn exited_or_deeper_than(&self, depth: usize) -> bool { - self.exited || self.depth > depth - } - - fn output_mut(&mut self, title: bool) -> &mut Output { - if title { - &mut self.title - } else { - &mut self.text - } - } -} - /// Output buffer and whitespace state. #[derive(Default)] struct Output { @@ -394,6 +148,342 @@ struct StartTag { class: Option>, } +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Parser { + /// Creates a parser that only applies search-related HTML cleanup. + pub fn discarding() -> Self { + Self { + discard: true, + ..Self::default() + } + } + + /// Handles a tokenizer event. + fn handle( + &mut self, event: &CallbackEvent<'_>, span: Span, + editor: &mut Editor<'_>, + ) { + // The attribute is an extraction directive, not rendered output. This + // cleanup still runs for pages excluded through front matter. + if let CallbackEvent::AttributeName { name } = event + && *name == b"data-search-exclude" + { + editor.remove_attribute(name, span); + } + + // Page-level exclusion disables fact collection, but not HTML cleanup. + if self.discard { + return; + } + + // html5gum emits a start tag and each of its attributes separately, so + // assemble the tag before applying section and exclusion semantics. + match event { + CallbackEvent::OpenStartTag { name } => { + self.start = Some(StartTag::new(Tag::from_bytes(name))); + self.attribute = Attribute::Other; + } + CallbackEvent::AttributeName { name } => { + self.attribute = Attribute::from_bytes(name); + if let Some(start) = &mut self.start { + start.observe_attribute(self.attribute, None); + } + } + CallbackEvent::AttributeValue { value } => { + if let Some(start) = &mut self.start { + start.observe_attribute(self.attribute, Some(value)); + } + } + CallbackEvent::CloseStartTag { self_closing } => { + if let Some(start) = self.start.take() { + let tag = start.tag.clone(); + self.start(start); + + // Self-closing non-void elements enter and leave the + // context in the same tokenizer event. + if *self_closing { + self.end(&tag); + } + } + } + CallbackEvent::EndTag { name } => { + self.end(&Tag::from_bytes(name)); + } + CallbackEvent::String { value } => { + self.text(String::from_utf8_lossy(value).as_ref()); + } + CallbackEvent::Comment { .. } + | CallbackEvent::Doctype { .. } + | CallbackEvent::Error(_) => {} + } + } + + /// Handles a complete start tag. + fn start(&mut self, start: StartTag) { + // Void elements never contribute nesting state, even without an + // explicit self-closing slash in HTML. + if start.tag.is_void() { + return; + } + + // Search directives suppress complete subtrees. Header links are + // tracked separately because their visible glyph is not title text. + let heading = start.tag.heading_level(); + let skipped = start.tag.is_skipped() + || start.excluded + || start.class.as_deref() == Some(b"linenodiv"); + let headerlink = start.tag == Tag::A + && start.class.as_deref() == Some(b"headerlink"); + let tag = start.tag.clone(); + + self.context.push(Element { + tag: start.tag, + skipped, + headerlink, + kept: None, + }); + + // Only headings with an ID start sections, matching MkDocs search. + // Content before the first non-h1 heading belongs to an implicit + // top-level preface section. + if let (Some(level), true) = (heading, start.id_present) { + let depth = self.context.len(); + + if level != 1 && self.sections.is_empty() { + self.push_preface(); + } + + let location = if self.sections.is_empty() { + None + } else { + start.id + }; + let section = SectionState::new( + Some(level), + level.into(), + depth, + location, + start.excluded, + ); + self.sections.push(section); + self.current = Some(self.sections.len() - 1); + } + + // Ordinary content before the first indexed heading also needs the + // implicit preface section. + self.ensure_section(); + + // Count skipped ancestors so descendants can be rejected in O(1). + if skipped { + self.skip += 1; + return; + } + + // Retain only the small markup allowlist used by MkDocs search. Record + // the insertion point so an empty element can be removed on close. + if self.skip == 0 && tag.is_kept() { + let (section, title) = self.output_target(); + let data = self.sections[section].output_mut(title); + let start = data.value.len(); + let previous_whitespace = data.last_whitespace; + data.value.push('<'); + data.value.push_str(tag.name()); + data.value.push('>'); + data.last_whitespace = false; + + self.context.last_mut().expect("context").kept = + Some(KeptElement { + section, + title, + start, + previous_whitespace, + }); + } + } + + /// Handles an end tag. + fn end(&mut self, tag: &Tag) { + // Ignore mismatched tags rather than letting malformed HTML corrupt + // the element and exclusion stacks. + if self.context.last().is_none_or(|el| el.tag != *tag) { + return; + } + + // A heading can be nested inside a container. When that container + // closes, resume the nearest still-open parent section and permanently + // retire the nested section. + let depth = self.context.len(); + if let Some(index) = self.current + && self.sections[index].exited_or_deeper_than(depth) + && let Some(parent) = self + .sections + .iter() + .rposition(|section| !section.exited && section.depth <= depth) + { + self.sections[index].exited = true; + self.current = Some(parent); + } + + // Closing the outermost skipped element re-enables extraction. + let element = self.context.pop().expect("context"); + if element.skipped { + self.skip -= 1; + return; + } + + // Keep a closing tag only when its opening tag encloses non-whitespace + // content. Otherwise remove the opening tag and restore whitespace + // state from before it was inserted. + if self.skip == 0 && tag.is_kept() { + let (section, title) = self.output_target(); + let data = self.sections[section].output_mut(title); + let opening = format!("<{}>", tag.name()); + + if let Some(start) = data.value.find(&opening) { + let following = &data.value[start + opening.len()..]; + if following.chars().any(|char| !char.is_whitespace()) { + data.value.push_str("'); + data.last_whitespace = false; + } else { + data.value.truncate(start); + data.last_whitespace = element.kept.map_or_else( + || { + data.value + .chars() + .last() + .is_some_and(char::is_whitespace) + }, + |kept| { + debug_assert_eq!(kept.section, section); + debug_assert_eq!(kept.title, title); + debug_assert_eq!(kept.start, start); + kept.previous_whitespace + }, + ); + } + } + } + } + + /// Handles text content. + fn text(&mut self, value: &str) { + // Excluded subtrees contribute neither title nor body text. + if self.skip > 0 { + return; + } + + // Preserve preformatted input exactly. Elsewhere, mirror the Python + // implementation by converting line breaks and whitespace runs. + let preformatted = self.context.iter().any(|el| el.tag == Tag::Pre); + let whitespace = value.chars().all(char::is_whitespace); + let text; + let value = if preformatted { + value + } else if whitespace { + " " + } else if value.contains('\n') { + text = value.replace('\n', " "); + &text + } else { + value + }; + + self.ensure_section(); + let (section, title) = self.output_target(); + + if title { + // Permalink anchors are presentation affordances, not title text. + if self.context.iter().any(|el| el.headerlink) { + return; + } + escape(value, &mut self.sections[section].title.value); + self.sections[section].title.last_whitespace = whitespace; + } else { + // Collapse adjacent whitespace outside preformatted content. + let data = &mut self.sections[section].text; + if !whitespace || preformatted || !data.last_whitespace { + escape(value, &mut data.value); + data.last_whitespace = whitespace; + } + } + } + + /// Returns the current section and whether its title receives output. + fn output_target(&self) -> (usize, bool) { + let section = self.current.expect("section"); + let heading = self.sections[section].heading; + let title = heading.is_some_and(|level| { + self.context + .iter() + .any(|el| el.tag.heading_level() == Some(level)) + }); + (section, title) + } + + /// Ensures a section exists for preface content. + fn ensure_section(&mut self) { + if self.current.is_none() { + self.push_preface(); + } + } + + /// Adds the implicit top-level preface section. + fn push_preface(&mut self) { + self.sections + .push(SectionState::new(None, 1, 0, None, false)); + self.current = Some(self.sections.len() - 1); + } + + /// Converts parser state into page-local search sections. + pub fn finish(self) -> Vec { + self.sections + .into_iter() + .filter(|section| !section.excluded) + .map(|section| SearchSection { + location: section.location, + level: section.level, + title: trim(section.title.value), + text: trim(section.text.value), + }) + .collect() + } +} + +impl SectionState { + fn new( + heading: Option, level: u32, depth: usize, + location: Option, excluded: bool, + ) -> Self { + Self { + heading, + level, + depth, + exited: false, + excluded, + location, + title: Output::default(), + text: Output::default(), + } + } + + fn exited_or_deeper_than(&self, depth: usize) -> bool { + self.exited || self.depth > depth + } + + fn output_mut(&mut self, title: bool) -> &mut Output { + if title { + &mut self.title + } else { + &mut self.text + } + } +} + impl StartTag { fn new(tag: Tag) -> Self { Self { @@ -423,16 +513,6 @@ impl StartTag { } } -/// Attributes relevant to extraction. -#[derive(Clone, Copy, Default)] -enum Attribute { - Id, - Class, - SearchExclude, - #[default] - Other, -} - impl Attribute { fn from_bytes(value: &[u8]) -> Self { match value { @@ -444,40 +524,6 @@ impl Attribute { } } -/// HTML tags relevant to extraction. -#[derive(Clone, Debug, PartialEq, Eq)] -enum Tag { - A, - Area, - Base, - Br, - Code, - Col, - Embed, - H(u8), - Hr, - Img, - Input, - Li, - Link, - Meta, - Object, - Ol, - P, - Param, - Pre, - Script, - Small, - Source, - Style, - Sub, - Sup, - Track, - Ul, - Wbr, - Other(Box), -} - impl Tag { fn from_bytes(value: &[u8]) -> Self { match value { @@ -609,7 +655,20 @@ impl Tag { } // ---------------------------------------------------------------------------- -// Helpers +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Visitor for Parser { + fn visit( + &mut self, event: &CallbackEvent<'_>, span: Span, + editor: &mut Editor<'_>, + ) { + self.handle(event, span, editor); + } +} + +// ---------------------------------------------------------------------------- +// Functions // ---------------------------------------------------------------------------- /// Escapes text like `html.escape(..., quote=False)`. @@ -633,11 +692,16 @@ fn trim(mut value: String) -> String { value } +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + #[cfg(test)] mod tests { - use super::*; use crate::compat::mkdocs::html::scan; + use super::{Parser, SearchSection}; + fn extract(html: &str) -> Vec { let mut parser = Parser::default(); let _ = scan(html, &mut [&mut parser]); diff --git a/crates/zensical/src/compat/mkdocs/resource.rs b/crates/zensical/src/compat/mkdocs/resource.rs new file mode 100644 index 0000000..104df5d --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/resource.rs @@ -0,0 +1,320 @@ +// 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. + +// ---------------------------------------------------------------------------- + +//! Effective MkDocs resources. + +use anyhow::Result; + +use zrx::id::Id; +use zrx::stream::function::Collection; +use zrx::stream::{Key, Stream, Value}; + +use crate::config::Config; +use crate::path::SitePath; +use crate::watcher::Source; + +use super::plugin::meta; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Installs the MkDocs resource pipeline. +#[derive(Debug)] +pub struct Resources { + /// Immutable classification rules for one workflow lifetime. + classifier: Classifier, +} + +/// Inputs required to derive effective resources. +pub struct Dependencies<'a> { + /// Physical sources published by the provider. + pub sources: &'a Stream, +} + +/// One resource after MkDocs source precedence has been resolved. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Resource { + /// Logical output path relative to the site directory. + pub path: SitePath, + /// Physical source path. + pub source: Source, + /// Override priority, with lower values taking precedence. + priority: usize, +} + +impl Value for Resource {} + +/// Immutable classification rules for one workflow lifetime. +#[derive(Clone, Debug)] +struct Classifier { + docs: String, + extra_templates: Vec, + static_templates: Vec, + meta: meta::Settings, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Resources { + /// Resolves the private settings owned by this module instance. + pub fn new(config: &Config, meta: &meta::Settings) -> Self { + Self { + classifier: Classifier::new(config, meta), + } + } + + /// Classifies sources and resolves docs-over-theme precedence. + pub fn setup(&self, deps: Dependencies<'_>) -> Stream { + let classifier = self.classifier.clone(); + let resources = + deps.sources.filter_map(move |id: &Id, source: &Source| { + classifier.classify(id, source) + }); + + // Settle precedence before consumers transform or write a resource. + // Removing a docs override therefore reveals its theme fallback in + // the same revision without transiently deleting the logical output. + resources.reduce_by_key( + |resource: &Resource| resource_key(&resource.path), + |resources: &dyn Collection, Resource>| { + Ok::<_, anyhow::Error>(preferred( + resources.iter().map(|(_, resource)| resource), + )) + }, + ) + } +} + +impl Classifier { + /// Resolves classification settings once for the workflow lifetime. + fn new(config: &Config, meta: &meta::Settings) -> 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(), + } + } + + /// Classifies one provider source as an MkDocs resource candidate. + fn classify(&self, id: &Id, source: &Source) -> Result> { + let context = id.context(); + let is_docs = context == self.docs; + let priority = if is_docs { + 0 + } else { + let Some(index) = context.strip_prefix("templates/") else { + return Ok(None); + }; + index + .parse::() + .ok() + .and_then(|index| index.checked_add(1)) + .unwrap_or(usize::MAX) + }; + let path = id.location().parse::()?; + if is_docs { + if has_extension(&path, "md") + || meta::claims(path.as_str(), &self.meta) + || self + .extra_templates + .iter() + .any(|item| item == path.as_str()) + { + return Ok(None); + } + } else if has_extension(&path, "html") + || self + .static_templates + .iter() + .any(|item| item == path.as_str()) + { + return Ok(None); + } + Ok(Some(Resource { + path, + source: source.clone(), + priority, + })) + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Creates the group key used to resolve equivalent resource sources. +fn resource_key(path: &SitePath) -> Result> { + let id = Id::builder() + .provider("asset") + .context(".") + .location(path.as_str()) + .build()?; + Ok(Key::from(id)) +} + +/// Selects the authoritative source for one logical resource path. +fn preferred<'a>( + resources: impl Iterator, +) -> Option { + resources + .min_by(|left, right| { + left.priority + .cmp(&right.priority) + .then_with(|| left.source.cmp(&right.source)) + }) + .cloned() +} + +/// Returns whether a path has the requested extension, case-insensitively. +fn has_extension(path: &SitePath, expected: &str) -> bool { + path.extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case(expected)) +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use zrx::id::Id; + + use crate::path::SitePath; + use crate::watcher::Source; + + use super::{meta, preferred, Classifier, Resource}; + + #[test] + fn classifies_docs_and_ordered_theme_resources() { + let classifier = classifier(); + let docs = classifier + .classify(&id("docs", "assets/app.js"), &source("docs/app.js")) + .unwrap() + .unwrap(); + let first = classifier + .classify( + &id("templates/0", "assets/app.js"), + &source("theme-0/app.js"), + ) + .unwrap() + .unwrap(); + let second = classifier + .classify( + &id("templates/1", "assets/app.js"), + &source("theme-1/app.js"), + ) + .unwrap() + .unwrap(); + + assert_eq!(docs.priority, 0); + assert_eq!(first.priority, 1); + assert_eq!(second.priority, 2); + assert_eq!(preferred([&second, &first, &docs].into_iter()), Some(docs)); + } + + #[test] + fn excludes_sources_owned_by_other_mkdocs_stages() { + let classifier = classifier(); + for path in ["index.md", "GUIDE.MD", ".meta.yml", "extra.txt"] { + assert!( + classifier + .classify(&id("docs", path), &source(path)) + .unwrap() + .is_none(), + "{path}" + ); + } + for path in ["main.html", "MAIN.HTML", "static.html"] { + assert!( + classifier + .classify(&id("templates/0", path), &source(path)) + .unwrap() + .is_none(), + "{path}" + ); + } + } + + #[test] + fn ignores_sources_outside_docs_and_themes() { + assert!(classifier() + .classify(&id("site", "asset.js"), &source("site/asset.js")) + .unwrap() + .is_none()); + } + + #[test] + fn uses_source_path_as_a_deterministic_tie_breaker() { + let left = Resource { + path: "asset.js".parse().unwrap(), + source: source("b/asset.js"), + priority: 1, + }; + let right = Resource { + path: "asset.js".parse().unwrap(), + source: source("a/asset.js"), + priority: 1, + }; + assert_eq!(preferred([&left, &right].into_iter()), Some(right)); + } + + #[test] + fn rejects_unsafe_resource_keys() { + for path in ["", "/asset.js", "../asset.js", "a/../asset.js"] { + assert!(path.parse::().is_err(), "{path}"); + } + } + + fn classifier() -> Classifier { + Classifier { + docs: "docs".into(), + extra_templates: vec!["extra.txt".into()], + static_templates: vec!["static.html".into()], + meta: meta::Settings { + enabled: true, + meta_file: ".meta.yml".into(), + }, + } + } + + fn id(context: &str, location: &str) -> Id { + Id::builder() + .provider("file") + .context(context) + .location(location) + .build() + .unwrap() + } + + fn source(path: &str) -> Source { + Source::from(PathBuf::from(path)) + } +} diff --git a/crates/zensical/src/config.rs b/crates/zensical/src/config.rs index f8a92e6..bbe41c9 100644 --- a/crates/zensical/src/config.rs +++ b/crates/zensical/src/config.rs @@ -32,8 +32,11 @@ use std::fs; use std::hash::{DefaultHasher, Hash, Hasher}; use std::path::{Path, PathBuf}; use std::sync::Arc; + use zrx::path::PathExt; +use crate::path::{OutputRoot, SourceRoot}; + mod error; pub mod extra; pub mod mdx; @@ -63,6 +66,10 @@ pub struct Config { pub project: Arc, /// Theme directories. pub theme_dirs: Vec, + /// Canonical documentation source root. + docs_root: SourceRoot, + /// Canonical site output root. + output_root: OutputRoot, /// Resolved Python Markdown extensions after compatibility shims. markdown_extensions: Arc<[String]>, /// Configuration hash. @@ -82,8 +89,17 @@ impl Config { where P: AsRef, { - let path = path.as_ref(); - Python::attach(|py| { + // Resolve the configuration itself before Python interprets relative + // paths. This keeps Python configuration loading and Rust filesystem + // roots anchored to the same directory when the file is a symlink. + let path = path.as_ref().canonicalize()?; + let value = path.to_str().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "configuration path must be valid UTF-8", + ) + })?; + let (project, markdown_extensions) = Python::attach(|py| { // Reset global data in compatibility modules py.import("zensical.extensions.autorefs")? .call_method0("reset")?; @@ -95,8 +111,7 @@ impl Config { // in configuration. For TOML, this is technically not necessary, // but we'll move it through the same pipeline for consistency. let module = py.import("zensical.config")?; - let config = module - .call_method1("parse_config", (path.to_string_lossy(),))?; + let config = module.call_method1("parse_config", (value,))?; let markdown_extensions = config .get_item("markdown_extensions")? .extract::>()?; @@ -104,70 +119,57 @@ impl Config { // Return configuration and theme directory Ok::<_, PyErr>((project, markdown_extensions)) - }) - .map_err(Into::into) - .and_then(|(project, markdown_extensions)| { - // Merge theme directories, giving precedence to custom directory - // over the main theme directory to allow for overrides - let iter = project.theme_dirs.clone().into_iter(); - let theme_dirs = iter - .map(|path| path.canonicalize().expect("invariant")) - .collect(); + })?; - // Precompute hash - let hash = { - let mut hasher = DefaultHasher::default(); - project.hash(&mut hasher); - hasher.finish() - }; + // Merge theme directories, giving precedence to custom directory over + // the main theme directory to allow for overrides. + let iter = project.theme_dirs.clone().into_iter(); + let theme_dirs = iter + .map(|path| path.canonicalize().expect("invariant")) + .collect(); - // Return configuration - Ok(Config { - path: path.canonicalize()?, - project: Arc::new(project), - theme_dirs, - markdown_extensions: markdown_extensions.into(), - hash, - }) + // Precompute hash + let hash = { + let mut hasher = DefaultHasher::default(); + project.hash(&mut hasher); + hasher.finish() + }; + + // Resolve physical roots once. Logical source and output paths are + // joined to these canonical roots at filesystem boundaries. + let root = path.parent().expect("configuration has parent"); + let docs_dir = root.join(&project.docs_dir); + fs::create_dir_all(&docs_dir)?; + let docs_root = SourceRoot::open(docs_dir)?; + let output_root = OutputRoot::prepare(root.join(&project.site_dir))?; + + // Return configuration + Ok(Config { + path, + project: Arc::new(project), + theme_dirs, + docs_root, + output_root, + markdown_extensions: markdown_extensions.into(), + hash, }) } /// Returns whether a resolved Python Markdown extension is active. - pub(crate) fn has_markdown_extension(&self, name: &str) -> bool { + pub fn has_markdown_extension(&self, name: &str) -> bool { self.markdown_extensions .iter() .any(|extension| extension == name) } - /// Returns the directory the configuration file is located in. - pub fn get_root_dir(&self) -> PathBuf { - let mut path = self.path.clone(); - path.pop(); - path + /// Returns the canonical documentation source root. + pub fn docs_root(&self) -> &SourceRoot { + &self.docs_root } - /// Returns the docs directory, resolved relative to the configuration file. - pub fn get_docs_dir(&self) -> PathBuf { - let mut path = self.path.clone(); - path.pop(); - - // Ensure directory exists - let path = path.join(&self.project.docs_dir); - fs::create_dir_all(&path) - .and_then(|()| path.canonicalize()) - .expect("invariant") - } - - /// Returns the site directory, resolved relative to the configuration file. - pub fn get_site_dir(&self) -> PathBuf { - let mut path = self.path.clone(); - path.pop(); - - // Ensure directory exists - let path = path.join(&self.project.site_dir); - fs::create_dir_all(&path) - .and_then(|()| path.canonicalize()) - .expect("invariant") + /// Returns the canonical site output root. + pub fn output_root(&self) -> &OutputRoot { + &self.output_root } /// Returns the cache directory, resolved relative to the configuration file. diff --git a/crates/zensical/src/config/theme.rs b/crates/zensical/src/config/theme.rs index 93eb14a..d2c09cf 100644 --- a/crates/zensical/src/config/theme.rs +++ b/crates/zensical/src/config/theme.rs @@ -30,6 +30,21 @@ use serde::Serialize; use std::collections::BTreeMap; use std::path::PathBuf; +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// Font settings. +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] +#[serde(untagged)] +#[pyo3(from_item_all)] +pub enum Font { + /// Use custom fonts. + Custom(CustomFont), + /// Use system fonts. + System(bool), +} + // ---------------------------------------------------------------------------- // Structs // ---------------------------------------------------------------------------- @@ -66,17 +81,6 @@ pub struct Theme { // ---------------------------------------------------------------------------- -/// Font settings. -#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] -#[serde(untagged)] -#[pyo3(from_item_all)] -pub enum Font { - /// Use custom fonts. - Custom(CustomFont), - /// Use system fonts. - System(bool), -} - /// Custom fonts. #[derive(Clone, Debug, Hash, FromPyObject, Serialize)] #[pyo3(from_item_all)] diff --git a/crates/zensical/src/lib.rs b/crates/zensical/src/lib.rs index f73d1fc..7dbe142 100644 --- a/crates/zensical/src/lib.rs +++ b/crates/zensical/src/lib.rs @@ -32,19 +32,23 @@ use crossbeam::channel::{unbounded, RecvTimeoutError}; use pyo3::exceptions::PyRuntimeError; -use pyo3::prelude::*; -use pyo3::Python; -use std::collections::BTreeMap; +use pyo3::types::{PyModule, PyModuleMethods}; +use pyo3::{ + pyfunction, pymodule, wrap_pyfunction, Bound, FromPyObject, PyResult, + Python, +}; use std::path::{Path, PathBuf}; use std::process; use std::sync::Arc; use std::time::{Duration, Instant}; use std::{fs, io, thread}; + use zrx::id::Id; use zrx::stream::{Change, Key}; mod compat; mod config; +pub mod path; mod python; mod server; mod structure; @@ -55,23 +59,13 @@ mod workflow; use compat::mkdocs::plugin::meta; use config::Config; use server::{create_server, ServeOptions}; -use watcher::{Source, Watcher}; -use workflow::{create_workflow, Input}; +use watcher::Watcher; +use workflow::{create_workflow, Configuration, Input}; // ---------------------------------------------------------------------------- // Enums // ---------------------------------------------------------------------------- -/// Serve options. -#[derive(Clone, Debug, FromPyObject, PartialEq, Eq)] -#[pyo3(from_item_all)] -pub struct BuildOptions { - /// Whether to clean the cache directory before building. - pub clean: Option, - /// Whether to enable strict mode - abort the build on any warnings. - pub strict: Option, -} - /// Build mode. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Mode { @@ -81,6 +75,20 @@ pub enum Mode { Serve(ServeOptions, u64), } +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Build options. +#[derive(Clone, Debug, FromPyObject, PartialEq, Eq)] +#[pyo3(from_item_all)] +pub struct BuildOptions { + /// Whether to clean the cache directory before building. + pub clean: Option, + /// Whether to enable strict mode and abort on warnings. + pub strict: Option, +} + // ---------------------------------------------------------------------------- // Functions // ---------------------------------------------------------------------------- @@ -183,9 +191,9 @@ fn run(config_file: &PathBuf, mode: Mode) -> PyResult { // true differential builds, which will also include cleaning up old files // that are not needed anymore but for now, we just remove everything, like // MkDocs does it, but not the directory itself, see https://t.ly/Lrjdx - let site_dir = config.get_site_dir(); + let site_dir = config.output_root().as_path(); if site_dir.exists() { - clear_dir(&site_dir).expect("site directory could not be cleaned"); + clear_dir(site_dir).expect("site directory could not be cleaned"); } // Determine if strict mode is enabled @@ -196,16 +204,39 @@ fn run(config_file: &PathBuf, mode: Mode) -> PyResult { Mode::Serve(_, _) => false, }; + // Resolve metadata settings once for the workflow and provider boundary. + // The provider still needs them while metadata remains a revision fact + // workaround, so share the module-owned value rather than reprojecting + // configuration independently on both sides. + let meta_settings = Arc::new(meta::Settings::new(&config)); + // Create workflow runner and acquire its source input - let workflow = create_workflow(&config, strict); + let workflow = create_workflow(&config, strict, meta_settings.clone()); let mut runner = workflow .runner() .map_err(|err| PyRuntimeError::new_err(err.to_string()))?; let mut input = runner .input::() .map_err(|err| PyRuntimeError::new_err(err.to_string()))?; - let meta_settings = meta::Settings::new(&config); - + let configuration = runner + .input::() + .map_err(|err| PyRuntimeError::new_err(err.to_string()))?; + let mut revision = configuration + .begin() + .map_err(|err| PyRuntimeError::new_err(err.to_string()))?; + revision + .insert( + Key::::from_iter(std::iter::empty()), + Configuration::new(config.clone(), strict), + ) + .map_err(|err| PyRuntimeError::new_err(err.to_string()))?; + let _configuration = revision + .seal() + .map_err(|err| PyRuntimeError::new_err(err.to_string()))?; + let run = runner + .settle() + .map_err(|err| PyRuntimeError::new_err(err.to_string()))?; + report_failures(&run)?; // Create channel for reload notifications let (sender, receiver) = unbounded(); @@ -234,6 +265,11 @@ fn run(config_file: &PathBuf, mode: Mode) -> PyResult { let serve = matches!(mode, Mode::Serve(_, _)); let watcher = Watcher::new(&config, serve, sender, waker.clone())?; + let mut metadata = meta::Admission::new( + config.docs_root().clone(), + config.project.docs_dir.clone(), + meta_settings, + ); // Start the event loop. Each debounced watcher batch is admitted as one // source revision and fully settled before the next batch is accepted. @@ -242,23 +278,16 @@ fn run(config_file: &PathBuf, mode: Mode) -> PyResult { loop { match watcher.receive(Duration::from_millis(100)) { Ok(changes) => { - let metadata = Arc::new( - meta::Index::load(&config.get_docs_dir(), &meta_settings) - .map_err(|error| { + let meta::Prepared { index, dependents } = + metadata.prepare(&changes).map_err(|error| { PyRuntimeError::new_err(format!("{error:#}")) - })?, - ); - let dependents = metadata_dependents( - &changes, - &config.get_docs_dir(), - &meta_settings, - )?; + })?; let mut revision = input .begin() .map_err(|err| PyRuntimeError::new_err(err.to_string()))?; for (key, source) in dependents { revision - .insert(key, Input::new(source, metadata.clone())) + .insert(key, Input::new(source, index.clone())) .map_err(|err| { PyRuntimeError::new_err(err.to_string()) })?; @@ -266,7 +295,7 @@ fn run(config_file: &PathBuf, mode: Mode) -> PyResult { for change in changes { match change { Change::Insert(key, source) => revision - .insert(key, Input::new(source, metadata.clone())) + .insert(key, Input::new(source, index.clone())) .map_err(|err| { PyRuntimeError::new_err(err.to_string()) })?, @@ -314,75 +343,6 @@ fn run(config_file: &PathBuf, mode: Mode) -> PyResult { Ok(false) } -/// Expands metadata-file changes into descendant Markdown updates. -fn metadata_dependents( - changes: &[Change], docs: &Path, settings: &meta::Settings, -) -> PyResult, Source)>> { - if !settings.enabled { - return Ok(Vec::new()); - } - let mut dependents = BTreeMap::new(); - for change in changes { - let key = match change { - Change::Insert(key, _) | Change::Remove(key) => key, - }; - let location = key[0].location(); - if !meta::claims(&location, settings) { - continue; - } - let parent = Path::new(location.as_ref()) - .parent() - .unwrap_or_else(|| Path::new("")); - let mut paths = Vec::new(); - collect_markdown(&docs.join(parent), &mut paths) - .map_err(|error| PyRuntimeError::new_err(error.to_string()))?; - for path in paths { - let relative = path - .strip_prefix(docs) - .map_err(|error| PyRuntimeError::new_err(error.to_string()))?; - let location = relative.to_string_lossy().replace('\\', "/"); - let id = key[0] - .to_builder() - .location(location) - .build() - .expect("invariant"); - dependents.insert( - Key::from(id), - Source::from(path.to_string_lossy().into_owned()), - ); - } - } - - // A provider update for the page itself is authoritative. In particular, - // the initial snapshot contains both metadata files and every Markdown - // page, so retaining synthesized inserts here would admit each page twice. - for change in changes { - let key = match change { - Change::Insert(key, _) | Change::Remove(key) => key, - }; - dependents.remove(key); - } - Ok(dependents.into_iter().collect()) -} - -/// Recursively collects Markdown files below one metadata directory. -fn collect_markdown( - directory: &Path, paths: &mut Vec, -) -> io::Result<()> { - let Ok(entries) = fs::read_dir(directory) else { - return Ok(()); - }; - for entry in entries { - let path = entry?.path(); - if path.is_dir() { - collect_markdown(&path, paths)?; - } else if path.extension().is_some_and(|extension| extension == "md") { - paths.push(path); - } - } - Ok(()) -} - /// Returns the first action failure reported by one settled run. fn report_failures(run: &zrx::stream::Run) -> PyResult<()> { for invocation in run.report().invocations() { @@ -446,10 +406,11 @@ fn zensical(m: &Bound<'_, PyModule>) -> PyResult<()> { #[cfg(test)] mod tests { - use super::*; use std::fs; use tempfile::tempdir; + use super::clear_dir; + #[test] fn clear_dir_removes_non_hidden_file() { let dir = tempdir().unwrap(); @@ -542,79 +503,4 @@ mod tests { assert!(dir.path().exists()); } - - #[test] - fn metadata_change_selects_only_descendant_markdown() { - let dir = tempdir().unwrap(); - let docs = dir.path(); - fs::create_dir_all(docs.join("guide/nested")).unwrap(); - fs::create_dir_all(docs.join("guidelines")).unwrap(); - fs::write(docs.join("guide/page.md"), "# Page").unwrap(); - fs::write(docs.join("guide/nested/page.md"), "# Nested").unwrap(); - fs::write(docs.join("guidelines/page.md"), "# Other").unwrap(); - - let id = Id::builder() - .provider("file") - .context("docs") - .location("guide/.meta.yml") - .build() - .unwrap(); - let changes = vec![Change::Insert( - Key::from(id), - Source::from(docs.join("guide/.meta.yml").display().to_string()), - )]; - let settings = meta::Settings { - enabled: true, - meta_file: ".meta.yml".into(), - }; - let dependents = - metadata_dependents(&changes, docs, &settings).unwrap(); - let locations = dependents - .iter() - .map(|(key, _)| key[0].location().into_owned()) - .collect::>(); - assert_eq!(locations, vec!["guide/nested/page.md", "guide/page.md"]); - } - - #[test] - fn provider_page_change_supersedes_metadata_dependent() { - let dir = tempdir().unwrap(); - let docs = dir.path(); - fs::create_dir_all(docs.join("guide")).unwrap(); - let page = docs.join("guide/page.md"); - fs::write(&page, "# Page").unwrap(); - - let meta_id = Id::builder() - .provider("file") - .context("docs") - .location("guide/.meta.yml") - .build() - .unwrap(); - let page_id = Id::builder() - .provider("file") - .context("docs") - .location("guide/page.md") - .build() - .unwrap(); - let changes = vec![ - Change::Insert( - Key::from(meta_id), - Source::from( - docs.join("guide/.meta.yml").display().to_string(), - ), - ), - Change::Insert( - Key::from(page_id), - Source::from(page.display().to_string()), - ), - ]; - let settings = meta::Settings { - enabled: true, - meta_file: ".meta.yml".into(), - }; - - assert!(metadata_dependents(&changes, docs, &settings) - .unwrap() - .is_empty()); - } } diff --git a/crates/zensical/src/path.rs b/crates/zensical/src/path.rs new file mode 100644 index 0000000..7077303 --- /dev/null +++ b/crates/zensical/src/path.rs @@ -0,0 +1,71 @@ +// 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. + +// ---------------------------------------------------------------------------- + +//! Paths with explicit source, site, and filesystem-root domains. +//! +//! Logical paths use a canonical, platform-independent `/` representation. +//! They deliberately exclude URL semantics such as queries, fragments, +//! schemes, percent encoding, empty routes, and trailing slashes. + +use thiserror::Error; + +mod relative; +mod root; +mod site; +mod source; + +pub use root::{OutputRoot, RootError, SourceRoot}; +pub use site::SitePath; +pub use source::SourcePath; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// Error returned when a logical path is not canonical and relative. +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum PathError { + /// The path contains no components. + #[error("logical path must not be empty")] + Empty, + /// The path starts at a filesystem root or platform prefix. + #[error("logical path must be relative: {0}")] + Absolute(String), + /// The path uses a platform-dependent backslash separator. + #[error("logical path must use forward slashes: {0}")] + Backslash(String), + /// The path contains an empty or current-directory component. + #[error("logical path must use its canonical spelling: {0}")] + NonCanonical(String), + /// The path contains a parent-directory component. + #[error("logical path must not contain parent traversal: {0}")] + Parent(String), + /// The physical path contains a component that is not valid UTF-8. + #[error("logical path must be valid UTF-8")] + NonUtf8, + /// The path contains a NUL byte and cannot name a filesystem entry. + #[error("logical path must not contain NUL bytes")] + Nul, +} diff --git a/crates/zensical/src/path/relative.rs b/crates/zensical/src/path/relative.rs new file mode 100644 index 0000000..4a85e5c --- /dev/null +++ b/crates/zensical/src/path/relative.rs @@ -0,0 +1,259 @@ +// 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. + +// ---------------------------------------------------------------------------- + +//! Shared representation for typed logical paths. + +use std::path::{Component, Path}; +use std::sync::Arc; + +use super::PathError; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Canonical platform-independent relative path. +#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub struct RelativePath(Arc); + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl RelativePath { + /// Parses a canonical `/`-separated logical path. + pub fn parse(path: &str) -> Result { + if path.is_empty() { + return Err(PathError::Empty); + } + if path.contains('\0') { + return Err(PathError::Nul); + } + if path.contains('\\') { + return Err(PathError::Backslash(path.into())); + } + if path.starts_with('/') || has_windows_prefix(path) { + return Err(PathError::Absolute(path.into())); + } + for component in path.split('/') { + match component { + "" | "." => { + return Err(PathError::NonCanonical(path.into())); + } + ".." => return Err(PathError::Parent(path.into())), + _ => {} + } + } + Ok(Self(Arc::from(path))) + } + + /// Converts a relative native path without lossy string conversion. + pub fn from_path(path: &Path) -> Result { + if path.is_absolute() { + return Err(PathError::Absolute(path.display().to_string())); + } + let mut components = Vec::new(); + for component in path.components() { + match component { + Component::Normal(component) => components.push( + component.to_str().ok_or(PathError::NonUtf8)?.to_owned(), + ), + Component::CurDir => { + return Err(PathError::NonCanonical( + path.display().to_string(), + )); + } + Component::ParentDir => { + return Err(PathError::Parent(path.display().to_string())); + } + Component::RootDir | Component::Prefix(_) => { + return Err(PathError::Absolute( + path.display().to_string(), + )); + } + } + } + Self::parse(&components.join("/")) + } + + /// Returns the canonical string representation. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Iterates over path components. + pub fn components(&self) -> impl DoubleEndedIterator { + self.0.split('/') + } + + /// Returns the final component. + pub fn file_name(&self) -> &str { + self.components().next_back().expect("nonempty path") + } + + /// Returns the final component's extension. + pub fn extension(&self) -> Option<&str> { + let name = self.file_name(); + name.rsplit_once('.') + .filter(|(stem, _)| !stem.is_empty()) + .map(|(_, extension)| extension) + } + + /// Returns the final component without its extension. + pub fn file_stem(&self) -> &str { + let name = self.file_name(); + name.rsplit_once('.') + .filter(|(stem, _)| !stem.is_empty()) + .map_or(name, |(stem, _)| stem) + } + + /// Returns the number of components. + pub fn depth(&self) -> usize { + self.components().count() + } + + /// Returns the parent path, if this path has more than one component. + pub fn parent(&self) -> Option { + self.0 + .rfind('/') + .map(|index| Self(Arc::from(&self.0[..index]))) + } + + /// Returns whether this path is a strict component descendant of `base`. + pub fn is_descendant_of(&self, base: &Self) -> bool { + self.0 + .strip_prefix(base.as_str()) + .is_some_and(|suffix| suffix.starts_with('/')) + } + + /// Appends one or more canonical relative components. + pub fn join(&self, path: &str) -> Result { + let path = Self::parse(path)?; + Self::parse(&format!("{}/{}", self.as_str(), path.as_str())) + } + + /// Replaces the final component with one canonical file name. + pub fn with_file_name(&self, name: &str) -> Result { + let name = Self::parse(name)?; + if name.depth() != 1 { + return Err(PathError::NonCanonical(name.as_str().into())); + } + match self.parent() { + Some(parent) => parent.join(name.as_str()), + None => Ok(name), + } + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Detects Windows drive-relative and drive-absolute prefixes on every host. +fn has_windows_prefix(path: &str) -> bool { + let bytes = path.as_bytes(); + bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::{PathError, RelativePath}; + + #[test] + fn accepts_canonical_unicode_and_percent_paths() { + for path in [ + "index.md", + "guide/page.md", + "café/Überblick.md", + "100%/page.md", + ".meta.yml", + ] { + assert_eq!(RelativePath::parse(path).unwrap().as_str(), path); + } + } + + #[test] + fn rejects_noncanonical_and_unsafe_spellings() { + let cases = [ + "", + "/page.md", + "./page.md", + "guide/./page.md", + "guide//page.md", + "guide/page.md/", + "../page.md", + "guide/../page.md", + "guide\\page.md", + "C:page.md", + "C:/page.md", + "page\0.md", + ]; + for path in cases { + assert!(RelativePath::parse(path).is_err(), "{path:?}"); + } + } + + #[test] + fn component_operations_are_lexical_and_platform_independent() { + let path = RelativePath::parse("guide/nested/page.md").unwrap(); + assert_eq!(path.file_name(), "page.md"); + assert_eq!(path.extension(), Some("md")); + assert_eq!(path.file_stem(), "page"); + assert_eq!(path.depth(), 3); + assert_eq!(path.parent().unwrap().as_str(), "guide/nested"); + assert!(path.is_descendant_of(&RelativePath::parse("guide").unwrap())); + assert!(!RelativePath::parse("guidelines/page.md") + .unwrap() + .is_descendant_of(&RelativePath::parse("guide").unwrap())); + assert_eq!( + RelativePath::parse("guide") + .unwrap() + .join("nested/page.md") + .unwrap() + .as_str(), + path.as_str() + ); + assert_eq!( + path.with_file_name("other.html").unwrap().as_str(), + "guide/nested/other.html" + ); + assert!(path.with_file_name("other/name.html").is_err()); + } + + #[cfg(unix)] + #[test] + fn rejects_non_utf8_native_components() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt as _; + use std::path::PathBuf; + + let path = PathBuf::from(OsString::from_vec(b"bad-\xff.md".to_vec())); + assert_eq!(RelativePath::from_path(&path), Err(PathError::NonUtf8)); + } +} diff --git a/crates/zensical/src/path/root.rs b/crates/zensical/src/path/root.rs new file mode 100644 index 0000000..9afdd12 --- /dev/null +++ b/crates/zensical/src/path/root.rs @@ -0,0 +1,199 @@ +// 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. + +// ---------------------------------------------------------------------------- + +//! Canonical physical roots for logical paths. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use thiserror::Error; + +use super::{PathError, SitePath, SourcePath}; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// Error converting an existing physical source below its configured root. +#[derive(Debug, Error)] +pub enum RootError { + /// The path could not be resolved physically. + #[error(transparent)] + Io(#[from] io::Error), + /// The physical source does not belong to this root. + #[error("source path '{}' is outside root '{}'", path.display(), root.display())] + Outside { root: PathBuf, path: PathBuf }, + /// The relative source cannot be represented as a logical path. + #[error(transparent)] + Logical(#[from] PathError), +} + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Canonical physical root of one provider source relation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SourceRoot(Arc); + +// ---------------------------------------------------------------------------- + +/// Canonical physical root of the generated site. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OutputRoot(Arc); + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl SourceRoot { + /// Canonicalizes an existing source directory once. + pub fn open(path: impl AsRef) -> io::Result { + canonical_directory(path).map(|path| Self(Arc::from(path))) + } + + /// Returns the canonical physical root. + #[must_use] + pub fn as_path(&self) -> &Path { + &self.0 + } + + /// Resolves a logical source below this root. + #[must_use] + pub fn join(&self, path: &SourcePath) -> PathBuf { + self.0.join(path.as_str()) + } + + /// Converts an existing physical source below this root. + /// + /// Watcher removals need provider-bound logical identities because a + /// deleted path can no longer be canonicalized. This method is therefore + /// intentionally limited to existing paths. + pub fn relative_existing( + &self, path: impl AsRef, + ) -> Result { + let path = fs::canonicalize(path)?; + let relative = path.strip_prefix(self.as_path()).map_err(|_| { + RootError::Outside { + root: self.as_path().to_owned(), + path: path.clone(), + } + })?; + Ok(SourcePath::from_path(relative)?) + } +} + +// ---------------------------------------------------------------------------- + +impl OutputRoot { + /// Creates and canonicalizes the output directory once. + pub fn prepare(path: impl AsRef) -> io::Result { + fs::create_dir_all(path.as_ref())?; + canonical_directory(path).map(|path| Self(Arc::from(path))) + } + + /// Returns the canonical physical root. + #[must_use] + pub fn as_path(&self) -> &Path { + &self.0 + } + + /// Resolves one validated site path below this root. + /// + /// This guarantees lexical containment. Preventing an existing symlinked + /// parent from escaping the root remains an output-reconciler concern. + #[must_use] + pub fn join(&self, path: &SitePath) -> PathBuf { + self.0.join(path.as_str()) + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Canonicalizes an existing directory. +fn canonical_directory(path: impl AsRef) -> io::Result { + let path = fs::canonicalize(path)?; + if path.is_dir() { + Ok(path) + } else { + Err(io::Error::new( + io::ErrorKind::NotADirectory, + format!("path is not a directory: {}", path.display()), + )) + } +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use std::fs; + use tempfile::tempdir; + + use super::{OutputRoot, RootError, SitePath, SourceRoot}; + + #[test] + fn converts_existing_sources_without_lossy_round_trips() { + let directory = tempdir().unwrap(); + let root = SourceRoot::open(directory.path()).unwrap(); + let path = directory.path().join("guide/café.md"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "# Page").unwrap(); + + let source = root.relative_existing(&path).unwrap(); + assert_eq!(source.as_str(), "guide/café.md"); + assert_eq!(root.join(&source), fs::canonicalize(path).unwrap()); + } + + #[test] + fn rejects_sources_outside_their_root() { + let root_dir = tempdir().unwrap(); + let outside = tempdir().unwrap(); + let path = outside.path().join("page.md"); + fs::write(&path, "# Page").unwrap(); + let root = SourceRoot::open(root_dir.path()).unwrap(); + + assert!(matches!( + root.relative_existing(path), + Err(RootError::Outside { .. }) + )); + } + + #[test] + fn prepares_one_output_root_for_site_paths() { + let directory = tempdir().unwrap(); + let site = directory.path().join("nested/site"); + let root = OutputRoot::prepare(&site).unwrap(); + let path = "assets/app.js".parse::().unwrap(); + + assert_eq!(root.as_path(), fs::canonicalize(&site).unwrap()); + assert_eq!(root.join(&path), root.as_path().join("assets/app.js")); + } +} diff --git a/crates/zensical/src/path/site.rs b/crates/zensical/src/path/site.rs new file mode 100644 index 0000000..b2969d1 --- /dev/null +++ b/crates/zensical/src/path/site.rs @@ -0,0 +1,175 @@ +// 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. + +// ---------------------------------------------------------------------------- + +//! Site-relative output paths. + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::fmt; +use std::path::Path; +use std::str::FromStr; + +use super::relative::RelativePath; +use super::PathError; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Canonical path relative to the site output root. +#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub struct SitePath(RelativePath); + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl SitePath { + /// Converts a relative native path without lossy string conversion. + pub fn from_path(path: &Path) -> Result { + RelativePath::from_path(path).map(Self) + } + + /// Returns the canonical string representation. + #[must_use] + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + /// Iterates over site path components. + #[must_use] + pub fn components(&self) -> impl DoubleEndedIterator { + self.0.components() + } + + /// Returns the output file name. + #[must_use] + pub fn file_name(&self) -> &str { + self.0.file_name() + } + + /// Returns the output file extension. + #[must_use] + pub fn extension(&self) -> Option<&str> { + self.0.extension() + } + + /// Returns the output file name without its extension. + #[must_use] + pub fn file_stem(&self) -> &str { + self.0.file_stem() + } + + /// Returns the number of path components. + #[must_use] + pub fn depth(&self) -> usize { + self.0.depth() + } + + /// Returns the parent site path. + pub fn parent(&self) -> Option { + self.0.parent().map(Self) + } + + /// Returns whether this output is a strict descendant of `base`. + #[must_use] + pub fn is_descendant_of(&self, base: &Self) -> bool { + self.0.is_descendant_of(&base.0) + } + + /// Appends a canonical site-relative path. + pub fn join(&self, path: &str) -> Result { + self.0.join(path).map(Self) + } + + /// Replaces the output file name. + pub fn with_file_name(&self, name: &str) -> Result { + self.0.with_file_name(name).map(Self) + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl FromStr for SitePath { + type Err = PathError; + + fn from_str(path: &str) -> Result { + RelativePath::parse(path).map(Self) + } +} + +impl AsRef for SitePath { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for SitePath { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl Serialize for SitePath { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for SitePath { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let path = String::deserialize(deserializer)?; + path.parse().map_err(serde::de::Error::custom) + } +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::SitePath; + + #[test] + fn retains_a_distinct_site_path_domain() { + let path = "assets/app.min.js".parse::().unwrap(); + assert_eq!(path.file_name(), "app.min.js"); + assert_eq!(path.extension(), Some("js")); + assert_eq!(path.file_stem(), "app.min"); + assert_eq!(path.parent().unwrap().as_str(), "assets"); + assert_eq!( + path.with_file_name("vendor.js").unwrap().as_str(), + "assets/vendor.js" + ); + } +} diff --git a/crates/zensical/src/path/source.rs b/crates/zensical/src/path/source.rs new file mode 100644 index 0000000..19dad94 --- /dev/null +++ b/crates/zensical/src/path/source.rs @@ -0,0 +1,167 @@ +// 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. + +// ---------------------------------------------------------------------------- + +//! Provider-relative source paths. + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::fmt; +use std::path::Path; +use std::str::FromStr; + +use super::relative::RelativePath; +use super::PathError; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Canonical path relative to one provider source root. +#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub struct SourcePath(RelativePath); + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl SourcePath { + /// Converts a relative native path without lossy string conversion. + pub fn from_path(path: &Path) -> Result { + RelativePath::from_path(path).map(Self) + } + + /// Returns the canonical string representation. + #[must_use] + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + /// Iterates over source path components. + #[must_use] + pub fn components(&self) -> impl DoubleEndedIterator { + self.0.components() + } + + /// Returns the source file name. + #[must_use] + pub fn file_name(&self) -> &str { + self.0.file_name() + } + + /// Returns the source file extension. + #[must_use] + pub fn extension(&self) -> Option<&str> { + self.0.extension() + } + + /// Returns the source file name without its extension. + #[must_use] + pub fn file_stem(&self) -> &str { + self.0.file_stem() + } + + /// Returns the number of path components. + #[must_use] + pub fn depth(&self) -> usize { + self.0.depth() + } + + /// Returns the parent source path. + pub fn parent(&self) -> Option { + self.0.parent().map(Self) + } + + /// Returns whether this source is a strict descendant of `base`. + #[must_use] + pub fn is_descendant_of(&self, base: &Self) -> bool { + self.0.is_descendant_of(&base.0) + } + + /// Appends a canonical source-relative path. + pub fn join(&self, path: &str) -> Result { + self.0.join(path).map(Self) + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl FromStr for SourcePath { + type Err = PathError; + + fn from_str(path: &str) -> Result { + RelativePath::parse(path).map(Self) + } +} + +impl AsRef for SourcePath { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for SourcePath { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl Serialize for SourcePath { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for SourcePath { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let path = String::deserialize(deserializer)?; + path.parse().map_err(serde::de::Error::custom) + } +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::SourcePath; + + #[test] + fn serializes_as_a_validated_string() { + let path = "guide/café.md".parse::().unwrap(); + assert_eq!(path.file_stem(), "café"); + let data = serde_json::to_string(&path).unwrap(); + assert_eq!(data, "\"guide/café.md\""); + assert_eq!(serde_json::from_str::(&data).unwrap(), path); + assert!(serde_json::from_str::("\"../page.md\"").is_err()); + } +} diff --git a/crates/zensical/src/python/collector/anchor.rs b/crates/zensical/src/python/collector/anchor.rs index e611c6d..e1d85b4 100644 --- a/crates/zensical/src/python/collector/anchor.rs +++ b/crates/zensical/src/python/collector/anchor.rs @@ -25,9 +25,11 @@ //! Anchor. -use pyo3::prelude::*; +use pyo3::types::PyAnyMethods; +use pyo3::{PyErr, PyResult, Python}; use std::slice::Iter; use std::str::FromStr; + use zrx::stream::Value; // ---------------------------------------------------------------------------- diff --git a/crates/zensical/src/python/collector/reference.rs b/crates/zensical/src/python/collector/reference.rs index 3936c1d..4624135 100644 --- a/crates/zensical/src/python/collector/reference.rs +++ b/crates/zensical/src/python/collector/reference.rs @@ -25,12 +25,14 @@ //! Reference. -use pyo3::prelude::*; +use pyo3::types::PyAnyMethods; +use pyo3::{FromPyObject, PyErr, PyResult, Python}; use std::fmt::{self, Debug}; use std::ops::Deref; use std::slice::Iter; use std::str::FromStr; use std::sync::Arc; + use zrx::stream::Value; mod footnote; diff --git a/crates/zensical/src/python/collector/reference/footnote.rs b/crates/zensical/src/python/collector/reference/footnote.rs index 80bad81..d0a0c7b 100644 --- a/crates/zensical/src/python/collector/reference/footnote.rs +++ b/crates/zensical/src/python/collector/reference/footnote.rs @@ -25,7 +25,7 @@ //! Footnote reference. -use pyo3::prelude::*; +use pyo3::FromPyObject; use std::ops::Range; use crate::python::Span; diff --git a/crates/zensical/src/python/collector/reference/link.rs b/crates/zensical/src/python/collector/reference/link.rs index 168c354..4f06a74 100644 --- a/crates/zensical/src/python/collector/reference/link.rs +++ b/crates/zensical/src/python/collector/reference/link.rs @@ -26,7 +26,8 @@ //! Link reference. use pyo3::exceptions::PyValueError; -use pyo3::prelude::*; +use pyo3::types::PyAny; +use pyo3::{Borrowed, FromPyObject, PyErr, PyResult}; use std::ops::Range; use crate::python::Span; diff --git a/crates/zensical/src/python/collector/snippet.rs b/crates/zensical/src/python/collector/snippet.rs index cd4d372..389b336 100644 --- a/crates/zensical/src/python/collector/snippet.rs +++ b/crates/zensical/src/python/collector/snippet.rs @@ -25,9 +25,11 @@ //! Snippet. -use pyo3::prelude::*; +use pyo3::types::PyAnyMethods; +use pyo3::{FromPyObject, PyErr, PyResult, Python}; use std::slice::Iter; use std::str::FromStr; + use zrx::stream::Value; mod file; diff --git a/crates/zensical/src/python/collector/snippet/file.rs b/crates/zensical/src/python/collector/snippet/file.rs index 6b16191..1d4a221 100644 --- a/crates/zensical/src/python/collector/snippet/file.rs +++ b/crates/zensical/src/python/collector/snippet/file.rs @@ -25,7 +25,7 @@ //! Snippet file. -use pyo3::prelude::*; +use pyo3::FromPyObject; use crate::python::Span; diff --git a/crates/zensical/src/python/collector/snippet/range.rs b/crates/zensical/src/python/collector/snippet/range.rs index e302b15..f4e5d59 100644 --- a/crates/zensical/src/python/collector/snippet/range.rs +++ b/crates/zensical/src/python/collector/snippet/range.rs @@ -25,7 +25,7 @@ //! Snippet range. -use pyo3::prelude::*; +use pyo3::FromPyObject; // ---------------------------------------------------------------------------- // Structs diff --git a/crates/zensical/src/python/issues.rs b/crates/zensical/src/python/issues.rs index 10d736b..022806e 100644 --- a/crates/zensical/src/python/issues.rs +++ b/crates/zensical/src/python/issues.rs @@ -31,11 +31,13 @@ use percent_encoding::percent_decode_str; use std::ops::Range; use std::path::{Component, Path, PathBuf}; use std::slice::Iter; + use zrx::id::Id; use zrx::stream::Key; use crate::compat::mkdocs::plugin::autorefs::UnresolvedAutorefs; use crate::config::validation::Validation; +use crate::path::SourcePath; use super::collector::reference::{ LinkReference, LinkReferenceKind, Reference, @@ -59,55 +61,55 @@ pub enum Issue { /// The span is optional, since autorefs might be introduced by templates /// or transformations, in which case they cannot be located in the source. UnresolvedAutoref { - path: PathBuf, + path: SourcePath, span: Option, id: String, }, /// Link or image reference with no matching definition. UnresolvedReference { - path: PathBuf, + path: SourcePath, span: Span, id: String, }, /// Footnote reference with no matching definition. UnresolvedFootnote { - path: PathBuf, + path: SourcePath, span: Span, id: String, }, /// Link definition that is never referenced. UnusedDefinition { - path: PathBuf, + path: SourcePath, span: Span, id: String, }, /// Footnote definition that is never referenced. UnusedFootnote { - path: PathBuf, + path: SourcePath, span: Span, id: String, }, /// Shadowed link definition. ShadowedDefinition { - path: PathBuf, + path: SourcePath, span: Span, id: String, }, /// Shadowed footnote definition. ShadowedFootnote { - path: PathBuf, + path: SourcePath, span: Span, id: String, }, /// Invalid link. InvalidLink { - path: PathBuf, + path: SourcePath, span: Span, href: String, }, /// Invalid link anchor InvalidLinkAnchor { - path: PathBuf, + path: SourcePath, span: Span, href: String, anchor: String, @@ -122,7 +124,7 @@ pub enum Issue { #[derive(Clone, Debug, PartialEq, Eq)] pub struct Issues<'a> { /// Markdown contents for printing errors. - contents: HashMap, + contents: HashMap, /// Inner set of issues. inner: Vec, } @@ -131,7 +133,7 @@ pub struct Issues<'a> { impl Issue { /// Returns the path of the issue. - pub fn path(&self) -> &Path { + pub fn path(&self) -> &SourcePath { match self { Issue::UnresolvedAutoref { path, .. } | Issue::UnresolvedReference { path, .. } @@ -190,12 +192,15 @@ impl<'a> Issues<'a> { let mut anchor_map = HashMap::default(); for (key, (references, anchors, autorefs)) in iter { let id = key.try_as_id().expect("invariant"); - let path = id.location().into_owned(); + let path = id + .location() + .parse::() + .expect("provider identity must be a canonical source path"); // Associate anchors with their location for lookup contents.insert(path.clone(), references.markdown()); anchor_map.insert( - to_slash(&path), + path.as_str().to_string(), anchors .into_iter() .map(String::as_str) @@ -225,7 +230,7 @@ impl<'a> Issues<'a> { } } } - link_map.insert(to_slash(id.location().as_ref()), mappings); + link_map.insert(path.clone(), mappings); // Initialize link and footnote definitions let mut link_defs = HashMap::default(); @@ -239,7 +244,7 @@ impl<'a> Issues<'a> { let id = &markdown[link.id.start..link.id.end]; if let Some(prev) = link_defs.insert(to_id(id), link) { issues.push(Issue::ShadowedDefinition { - path: path.clone().into(), + path: path.clone(), span: (prev.id.start..prev.id.end).into(), id: id.to_string(), }); @@ -249,7 +254,7 @@ impl<'a> Issues<'a> { let id = &markdown[note.id.start..note.id.end]; if let Some(prev) = note_defs.insert(to_id(id), note) { issues.push(Issue::ShadowedFootnote { - path: path.clone().into(), + path: path.clone(), span: (prev.id.start..prev.id.end).into(), id: id.to_string(), }); @@ -272,7 +277,7 @@ impl<'a> Issues<'a> { used_link_defs.insert(to_id(id)); } else { issues.push(Issue::UnresolvedReference { - path: path.clone().into(), + path: path.clone(), span: (link.id.start..link.id.end).into(), id: id.to_string(), }); @@ -284,7 +289,7 @@ impl<'a> Issues<'a> { used_note_defs.insert(to_id(id)); } else { issues.push(Issue::UnresolvedFootnote { - path: path.clone().into(), + path: path.clone(), span: (note.id.start..note.id.end).into(), id: id.to_string(), }); @@ -299,7 +304,7 @@ impl<'a> Issues<'a> { let id = &markdown[link.id.start..link.id.end]; if !used_link_defs.contains(&to_id(id)) { issues.push(Issue::UnusedDefinition { - path: path.clone().into(), + path: path.clone(), span: (link.id.start..link.id.end).into(), id: id.to_string(), }); @@ -311,7 +316,7 @@ impl<'a> Issues<'a> { let id = &markdown[note.id.start..note.id.end]; if !used_note_defs.contains(&to_id(id)) { issues.push(Issue::UnusedFootnote { - path: path.clone().into(), + path: path.clone(), span: (note.id.start..note.id.end).into(), id: id.to_string(), }); @@ -338,14 +343,14 @@ impl<'a> Issues<'a> { if let Some(spans) = spans.get(id.as_str()) { for span in spans { issues.push(Issue::UnresolvedAutoref { - path: path.clone().into(), + path: path.clone(), span: Some(*span), id: id.clone(), }); } } else { issues.push(Issue::UnresolvedAutoref { - path: path.clone().into(), + path: path.clone(), span: None, id: id.clone(), }); @@ -355,8 +360,7 @@ impl<'a> Issues<'a> { // Check links across pages for issues for (base, mappings) in link_map { - let base_str = to_slash(&base); - let base = Path::new(&base_str); + let base_path = Path::new(base.as_str()); for (span, href) in mappings { if let Some((path, anchor)) = href.split_once('#') { let offset = path.len(); @@ -381,28 +385,23 @@ impl<'a> Issues<'a> { if !path.is_empty() && !is_markdown_path(&path) { if is_invalid_markdown_path(&path) { issues.push(Issue::InvalidLink { - path: base.into(), + path: base.clone(), span, - href: to_slash( - &resolve_relative(base, &path) - .to_string_lossy(), - ), + href: resolve_link(base_path, &path), }); } continue; } // Resolve the link against the base path - let link = to_slash( - &resolve_relative(base, &path).to_string_lossy(), - ); + let link = resolve_link(base_path, &path); // Check if the link exists, and if it does, whether the // anchor exists on the target page if let Some(anchors) = anchor_map.get(&link) { if !anchors.contains(anchor.as_str()) { issues.push(Issue::InvalidLinkAnchor { - path: base.into(), + path: base.clone(), span: Span::from( (span.start + offset + 1)..span.end - len, ), @@ -412,7 +411,7 @@ impl<'a> Issues<'a> { } } else { issues.push(Issue::InvalidLink { - path: base.into(), + path: base.clone(), span, href: link, }); @@ -422,24 +421,19 @@ impl<'a> Issues<'a> { if !is_markdown_path(&href) { if is_invalid_markdown_path(&href) { issues.push(Issue::InvalidLink { - path: base.into(), + path: base.clone(), span, - href: to_slash( - &resolve_relative(base, &href) - .to_string_lossy(), - ), + href: resolve_link(base_path, &href), }); } continue; } - let link = to_slash( - &resolve_relative(base, &href).to_string_lossy(), - ); + let link = resolve_link(base_path, &href); if !anchor_map.contains_key(&link) { issues.push(Issue::InvalidLink { - path: base.into(), + path: base.clone(), span, href: link, }); @@ -468,7 +462,7 @@ impl<'a> Issues<'a> { let mut count = 0; for issue in &self.inner { // Determine the path and kind of report - let path = issue.path().to_string_lossy(); + let path = issue.path().as_str(); let kind = match issue { Issue::UnresolvedAutoref { .. } => { if !validation.invalid_links { @@ -563,36 +557,32 @@ impl<'a> Issues<'a> { let Issue::UnresolvedAutoref { id, .. } = issue else { unreachable!("only autorefs may lack spans"); }; - Report::build(kind, (path.as_ref(), 0..0)) + Report::build(kind, (path, 0..0)) .with_message(format!("{message} `{id}` in {path}")) .finish() - .eprint((path.as_ref(), Source::from("")))?; + .eprint((path, Source::from("")))?; count += 1; continue; }; // Create report - let builder = - Report::build(kind, (path.as_ref(), Range::from(*span))) - .with_message(message) - .with_label( - Label::new((path.as_ref(), Range::from(*span))) - .with_message(message) - .with_color(color), - ); + let builder = Report::build(kind, (path, Range::from(*span))) + .with_message(message) + .with_label( + Label::new((path, Range::from(*span))) + .with_message(message) + .with_color(color), + ); // Obtain Markdown source - let source = self - .contents - .get(path.as_ref()) - .copied() - .unwrap_or_default(); + let source = + self.contents.get(issue.path()).copied().unwrap_or_default(); // Create and print report builder .with_config(Config::default().with_index_type(IndexType::Byte)) .finish() - .eprint((path.as_ref(), Source::from(source)))?; + .eprint((path, Source::from(source)))?; count += 1; } @@ -724,6 +714,15 @@ where normalize(base_dir.join(href)) } +/// Resolves a UTF-8 authored URL path into its normalized lookup form. +fn resolve_link(base: &Path, href: &str) -> String { + let path = resolve_relative(base, href); + let path = path + .to_str() + .expect("a path constructed from UTF-8 link text remains UTF-8"); + to_slash(path) +} + /// Returns whether a URL path points directly to a Markdown file. fn is_markdown_path(path: &str) -> bool { path.rsplit('/').next().is_some_and(|name| { diff --git a/crates/zensical/src/python/span.rs b/crates/zensical/src/python/span.rs index 7f57ed2..9f34ad7 100644 --- a/crates/zensical/src/python/span.rs +++ b/crates/zensical/src/python/span.rs @@ -25,7 +25,7 @@ //! Span of bytes. -use pyo3::prelude::*; +use pyo3::FromPyObject; use std::ops::Range; // ---------------------------------------------------------------------------- diff --git a/crates/zensical/src/server.rs b/crates/zensical/src/server.rs index c416bea..a1fb374 100644 --- a/crates/zensical/src/server.rs +++ b/crates/zensical/src/server.rs @@ -30,6 +30,7 @@ use mio::Waker; use pyo3::FromPyObject; use std::sync::Arc; use std::{fs, thread}; + use zensical_serve::handler::Stack; use zensical_serve::middleware; use zensical_serve::server::{Result, Server}; @@ -62,7 +63,7 @@ pub struct ServeOptions { pub fn create_server( config: &Config, receiver: Receiver, options: ServeOptions, ) -> Arc { - let site_dir = config.get_site_dir(); + let site_dir = config.output_root().as_path().to_owned(); fs::create_dir_all(&site_dir).expect("site directory could not be created"); // Create a one shot channel to extract waker - this is currently necessary, diff --git a/crates/zensical/src/server/client.rs b/crates/zensical/src/server/client.rs index 523fa0f..50c3829 100644 --- a/crates/zensical/src/server/client.rs +++ b/crates/zensical/src/server/client.rs @@ -30,7 +30,7 @@ use zensical_serve::http::{Header, Request, Response, Status}; use zensical_serve::middleware::Middleware; // ---------------------------------------------------------------------------- -// Structs +// Constants // ---------------------------------------------------------------------------- /// Livereload client script. diff --git a/crates/zensical/src/structure/dynamic.rs b/crates/zensical/src/structure/dynamic.rs index 7404c6a..2b131d7 100644 --- a/crates/zensical/src/structure/dynamic.rs +++ b/crates/zensical/src/structure/dynamic.rs @@ -39,7 +39,7 @@ mod float; use float::Float; // ---------------------------------------------------------------------------- -// Structs +// Enums // ---------------------------------------------------------------------------- /// Dynamic value. @@ -47,8 +47,7 @@ use float::Float; /// This data type represents any valid value that can be used as part of the /// metadata of a page and the extra data of configuration, supporting strings, /// nulls, booleans, integers, floating point numbers, lists, and maps, so -/// basically -/// everything supported in YAML and TOML. +/// basically everything supported in YAML and TOML. /// #[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] #[serde(untagged)] @@ -69,6 +68,17 @@ pub enum Dynamic { Map(BTreeMap), } +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Dynamic { + /// Creates a dynamic floating-point value. + pub fn from_float(value: f64) -> Self { + Self::Float(Float(value)) + } +} + // ---------------------------------------------------------------------------- // Trait implementations // ---------------------------------------------------------------------------- @@ -123,19 +133,12 @@ impl<'a, 'py> FromPyObject<'a, 'py> for Dynamic { } // ---------------------------------------------------------------------------- - -impl Dynamic { - /// Creates a dynamic floating-point value. - pub(crate) fn from_float(value: f64) -> Self { - Self::Float(Float(value)) - } -} - +// Tests // ---------------------------------------------------------------------------- #[cfg(test)] mod tests { - use super::*; + use super::Dynamic; #[test] fn null_round_trips_through_json() { diff --git a/crates/zensical/src/structure/markdown.rs b/crates/zensical/src/structure/markdown.rs index e971c3f..96e3796 100644 --- a/crates/zensical/src/structure/markdown.rs +++ b/crates/zensical/src/structure/markdown.rs @@ -32,9 +32,11 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::ops::Deref; use std::sync::Arc; + use zrx::id::Id; use zrx::stream::Value; +use crate::path::SourcePath; use crate::structure::dynamic::Dynamic; use crate::structure::nav::to_title; use crate::structure::toc::Section; @@ -123,7 +125,7 @@ impl Markdown { } /// Replaces rendered HTML before the Markdown value enters the workflow. - pub(crate) fn replace_content(&mut self, content: String) { + pub fn replace_content(&mut self, content: String) { Arc::get_mut(&mut self.data) .expect("rendered Markdown is not shared yet") .content = content; @@ -183,26 +185,24 @@ fn extract_title(id: &Id, markdown: &MarkdownData) -> String { return item.title.clone(); } - // As a last resort, use the file name - let location = id.location(); - - // Split location into components at slashes - let mut components = location - .split('/') - .map(ToString::to_string) - .collect::>(); - - // Extract file, and return title - let file = components.pop().expect("invariant"); - to_title(&file) + // As a last resort, use the provider-relative file name. + let source = id + .location() + .parse::() + .expect("Markdown source identity is canonical"); + to_title(source.file_name()) } +// ---------------------------------------------------------------------------- // Tests // ---------------------------------------------------------------------------- #[cfg(test)] mod tests { - use super::*; + use std::collections::BTreeMap; + use std::sync::Arc; + + use super::{Markdown, MarkdownData}; fn markdown() -> Markdown { Markdown { diff --git a/crates/zensical/src/structure/nav.rs b/crates/zensical/src/structure/nav.rs index 84ace2d..c1718e7 100644 --- a/crates/zensical/src/structure/nav.rs +++ b/crates/zensical/src/structure/nav.rs @@ -25,16 +25,16 @@ //! Navigation. -use std::hash::{DefaultHasher, Hash, Hasher}; -use std::sync::Arc; - use ahash::HashMap; use pyo3::types::{PyAny, PyAnyMethods}; use pyo3::{Bound, FromPyObject, PyResult}; use serde::Serialize; -use zrx::id::Id; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::sync::Arc; + use zrx::scheduler::Value; -use zrx::stream::Key; + +use crate::path::SourcePath; use super::page::Page; @@ -44,7 +44,7 @@ mod view; pub use item::NavigationItem; use iter::Iter; -pub(crate) use view::NavigationView; +pub use view::NavigationView; // ---------------------------------------------------------------------------- // Structs @@ -76,9 +76,7 @@ pub struct Navigation { impl Navigation { /// Creates a navigation from the given items. - pub fn new( - mut items: Vec, pages: Vec<(Key, Page)>, - ) -> Self { + pub fn new(mut items: Vec, pages: Vec) -> Self { if items.is_empty() { return Self::from(pages); } @@ -87,10 +85,7 @@ impl Navigation { // icons from the file location of the respective page. let pages = pages .into_iter() - .map(|(id, page)| { - let id = id[0].location().to_string(); - (id, page) - }) + .map(|page| (page.source().to_string(), page)) .collect::>(); // Since a navigation structure is given, we just need to add titles and @@ -176,7 +171,7 @@ impl Navigation { } /// Returns ancestors of a page URL without requiring the complete page. - pub(crate) fn ancestors_for_url(&self, url: &str) -> Vec { + pub fn ancestors_for_url(&self, url: &str) -> Vec { // Recursively find ancestors of the page with the given URL. fn recurse<'a>( items: &'a [NavigationItem], url: &str, @@ -254,40 +249,33 @@ impl Value for Navigation {} // ---------------------------------------------------------------------------- -impl From, Page)>> for Navigation { +impl From> for Navigation { /// Creates a navigation from pages. /// /// This mirrors the functionality of auto-populated navigation that MkDocs /// provides. In the future, we intend to refactor this into a more flexible /// system that allows for custom and modular navigation structures, but for /// now, compatibility is key. - fn from(pages: Vec<(Key, Page)>) -> Self { + fn from(pages: Vec) -> Self { let mut items: Vec = Vec::new(); - // Convert chunk into a vector for easier processing, and sort pages by - // the exact same method that MkDocs uses - let mut pages = Vec::from_iter(pages); - pages.sort_by_key(|(id, _)| file_sort_key(&id[0])); + // Sort pages by the exact same method that MkDocs uses. + let mut pages = pages; + pages.sort_by_key(|page| source_sort_key(page.source())); // There can only be pages, no URLs, since we're auto-populating the // navigation from the files in the docs directory - for (id, page) in pages { - let location = id[0].location(); - - // Split location into components at slashes - let mut components = location - .split('/') - .map(ToString::to_string) - .collect::>(); - - // Extract file, and check, whether it's an index file - let file = components.pop().expect("invariant"); + for page in pages { + let source = page.source(); + let file = source.file_name(); // Now, first obtain the subsection in which we need to insert the // page. If there are no parents, we insert it at the top level. let mut section = &mut items; - for component in components { - let title = to_title(&component); + for component in + source.parent().iter().flat_map(SourcePath::components) + { + let title = to_title(component); // Next, we try to find an existing section with the same title. // If we find one, we descend into it, otherwise, we create. @@ -320,7 +308,7 @@ impl From, Page)>> for Navigation { canonical_url: page.canonical_url.clone(), meta: Some(page.meta.clone()), children: Vec::new(), - is_index: is_index(&file), + is_index: is_index(file), active: false, }); } @@ -354,19 +342,15 @@ impl<'a> IntoIterator for &'a Navigation { // Functions // ---------------------------------------------------------------------------- -// Returns a key that replicates MkDocs' navigation sorting behavior, ordering -// by parents, then putting the index page first, then sorting by name -pub(crate) fn file_sort_key(id: &Id) -> (Vec, bool, String) { - let location = id.location(); - - // Split location into components at slashes - let mut components = location - .split('/') - .map(ToString::to_string) - .collect::>(); - - // Extract file, and check, whether it's an index file - let file = components.pop().expect("invariant"); +/// Returns the MkDocs navigation sort key for one validated source path. +pub fn source_sort_key(source: &SourcePath) -> (Vec, bool, String) { + let file = source.file_name().to_owned(); + let components = source + .parent() + .iter() + .flat_map(SourcePath::components) + .map(ToOwned::to_owned) + .collect(); (components, !is_index(&file), file) } @@ -383,7 +367,7 @@ fn navigation_hash(items: &[NavigationItem]) -> u64 { } /// Computes a page title from a file name, replicating MkDocs' behavior. -pub(crate) fn to_title(component: &str) -> String { +pub fn to_title(component: &str) -> String { let title = component.trim_end_matches(".md").replace(['-', '_'], " "); let first = title.chars().next().unwrap_or_default(); @@ -408,7 +392,11 @@ fn extract_shared_items( #[cfg(test)] mod tests { - use super::*; + use std::sync::Arc; + + use crate::path::SourcePath; + + use super::{navigation_hash, source_sort_key, to_title, Navigation}; #[test] fn test_clone_shares_immutable_data() { @@ -444,4 +432,18 @@ mod tests { assert_eq!(to_title("hello-world"), "Hello world"); assert_eq!(to_title("编译器笔记"), "编译器笔记"); } + + #[test] + fn source_sorting_places_index_before_siblings() { + let mut sources = [ + "guide/zebra.md".parse::().unwrap(), + "guide/index.md".parse().unwrap(), + "guide/café.md".parse().unwrap(), + ]; + sources.sort_by_key(source_sort_key); + + assert_eq!(sources[0].as_str(), "guide/index.md"); + assert_eq!(sources[1].as_str(), "guide/café.md"); + assert_eq!(sources[2].as_str(), "guide/zebra.md"); + } } diff --git a/crates/zensical/src/structure/nav/view.rs b/crates/zensical/src/structure/nav/view.rs index 4e2a56a..b8055d3 100644 --- a/crates/zensical/src/structure/nav/view.rs +++ b/crates/zensical/src/structure/nav/view.rs @@ -25,9 +25,8 @@ //! Lazy MiniJinja navigation views. -use std::sync::Arc; - use minijinja::value::{Enumerator, Object, Value}; +use std::sync::Arc; use super::{Navigation, NavigationItem}; @@ -66,7 +65,7 @@ struct Overlay { /// Lazy navigation object exposed to MiniJinja. #[derive(Clone, Debug)] -pub(crate) struct NavigationView { +pub struct NavigationView { /// Shared overlay. overlay: Arc, } @@ -103,7 +102,7 @@ impl Overlay { impl NavigationView { /// Creates a navigation view for an optional active page. - pub(crate) fn new(navigation: Navigation, active: Option<&str>) -> Self { + pub fn new(navigation: Navigation, active: Option<&str>) -> Self { fn find( items: &[NavigationItem], url: &str, path: &mut Vec, ) -> bool { @@ -129,7 +128,7 @@ impl NavigationView { } /// Creates the flattened page sequence used by static templates. - pub(crate) fn pages(&self) -> Value { + pub fn pages(&self) -> Value { fn collect( items: &[NavigationItem], parent: &mut Vec, overlay: &Arc, values: &mut Vec, @@ -170,28 +169,6 @@ impl NavigationView { // ---------------------------------------------------------------------------- -/// Creates one lazy sequence of navigation item views. -fn items(overlay: &Arc, parent: &[usize]) -> Value { - let children = if parent.is_empty() { - &*overlay.navigation.items - } else { - &overlay.item(parent).children - }; - let values = (0..children.len()) - .map(|index| { - let mut path = parent.to_vec(); - path.push(index); - Value::from_object(ItemView { - overlay: Arc::clone(overlay), - path, - }) - }) - .collect::>(); - Value::from_object(values) -} - -// ---------------------------------------------------------------------------- - impl ItemView { /// Resolves one template-visible field. fn field(&self, field: &str) -> Option { @@ -246,15 +223,40 @@ impl Object for ItemView { } } +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Creates one lazy sequence of navigation item views. +fn items(overlay: &Arc, parent: &[usize]) -> Value { + let children = if parent.is_empty() { + &*overlay.navigation.items + } else { + &overlay.item(parent).children + }; + let values = (0..children.len()) + .map(|index| { + let mut path = parent.to_vec(); + path.push(index); + Value::from_object(ItemView { + overlay: Arc::clone(overlay), + path, + }) + }) + .collect::>(); + Value::from_object(values) +} + // ---------------------------------------------------------------------------- // Tests // ---------------------------------------------------------------------------- #[cfg(test)] mod tests { - use minijinja::{context, Environment}; + use minijinja::{context, Environment, Value}; + use std::sync::Arc; - use super::*; + use super::{Navigation, NavigationItem, NavigationView}; /// Creates the same tree in immutable and page-active forms. fn navigation(active: bool) -> Navigation { diff --git a/crates/zensical/src/structure/page.rs b/crates/zensical/src/structure/page.rs index 3159d6e..03d1c76 100644 --- a/crates/zensical/src/structure/page.rs +++ b/crates/zensical/src/structure/page.rs @@ -29,13 +29,14 @@ use minijinja::{context, Error, Value as TemplateValue}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::ops::Deref; -use std::path::PathBuf; use std::sync::Arc; + use zensical_serve::http::Uri; use zrx::id::Id; use zrx::scheduler::Value; use crate::config::{Config, Project}; +use crate::path::{PathError, SitePath, SourcePath}; use crate::template::{Output, Template, GENERATOR}; use super::dynamic::Dynamic; @@ -49,15 +50,13 @@ use super::tag::Tag; /// Stable route facts derived from one Markdown source. #[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct PageRoute { +pub struct PageRoute { /// Documentation-relative source URI. - pub source: String, + pub source: SourcePath, /// Site-relative destination URI. - pub destination: String, + pub destination: SitePath, /// Encoded page URL. pub url: String, - /// Absolute destination path. - pub path: String, } impl Value for PageRoute {} @@ -71,6 +70,12 @@ impl Value for PageRoute {} /// behind an [`Arc`] makes those clones constant-sized. #[derive(Debug, Serialize)] pub struct PageData { + /// Validated documentation-relative source used by internal consumers. + #[serde(skip)] + source: SourcePath, + /// Validated site-relative output used by the writer. + #[serde(skip)] + destination: SitePath, /// Page target URL. pub url: String, /// Page canonical URL. @@ -110,28 +115,24 @@ pub struct Page { impl PageRoute { /// Computes route facts for a source identifier. - pub(crate) fn new(config: &Config, id: &Id) -> Self { - Self::from_source(config, &id.location()) + pub fn new(config: &Config, id: &Id) -> Result { + Self::from_source(config, id.location().parse()?) } /// Computes route facts for a documentation-relative source URI. - pub(crate) fn from_source(config: &Config, source: &str) -> Self { + pub fn from_source( + config: &Config, source: SourcePath, + ) -> Result { let destination = - Self::destination(source, config.project.use_directory_urls); + Self::destination(&source, config.project.use_directory_urls)?; let url = route_url(&destination, config.project.use_directory_urls); - let path = config.get_site_dir().join(&destination); - Self { - source: source.into(), - destination, - url, - path: path.to_string_lossy().into_owned(), - } + Ok(Self { source, destination, url }) } /// Computes the site-relative destination for a Markdown source. - pub(crate) fn destination( - source: &str, use_directory_urls: bool, - ) -> String { + pub fn destination( + source: &SourcePath, use_directory_urls: bool, + ) -> Result { destination(source, use_directory_urls) } } @@ -141,9 +142,10 @@ impl PageRoute { impl Page { /// Creates a page. #[allow(clippy::similar_names)] - pub(crate) fn new( - config: &Config, route: PageRoute, markdown: Markdown, - ) -> Page { + pub fn new(config: &Config, route: PageRoute, markdown: Markdown) -> Page { + let path = config.output_root().join(&route.destination); + let source = route.source; + let destination = route.destination; // Retrieve site URL let site_url = config.project.site_url.clone(); @@ -163,9 +165,9 @@ impl Page { let edit_url = repo_url.clone().and_then(|repo_url| { edit_uri.clone().map(|uri| { if uri.starts_with("https://") { - format!("{uri}/{}", route.source) + format!("{uri}/{source}") } else { - format!("{repo_url}/{uri}/{}", route.source) + format!("{repo_url}/{uri}/{source}") } }) }); @@ -176,10 +178,15 @@ impl Page { // single struct, but to split up the page as necessary later on. Page { data: Arc::new(PageData { + source, + destination, url, canonical_url, edit_url, - path: route.path, + path: path + .to_str() + .expect("configured output path is valid UTF-8") + .into(), markdown, }), ancestors: Vec::new(), @@ -236,35 +243,16 @@ impl Page { } tags } -} -// ---------------------------------------------------------------------------- - -/// Computes the site-relative destination for a Markdown source. -fn destination(source: &str, use_directory_urls: bool) -> String { - let mut path = PathBuf::from(source); - let is_index = path.ends_with("index.md") || path.ends_with("README.md"); - if path.ends_with("README.md") { - path.pop(); - path.push("index.md"); + /// Returns the validated site-relative page output. + pub fn destination(&self) -> &SitePath { + &self.destination } - if !use_directory_urls || is_index { - path.set_extension("html"); - } else { - path.set_extension(""); - path.push("index.html"); - } - path.to_string_lossy().replace('\\', "/") -} -/// Computes the encoded URL for a site-relative destination. -fn route_url(destination: &str, use_directory_urls: bool) -> String { - let url = if use_directory_urls { - destination.trim_end_matches("index.html") - } else { - destination - }; - Uri::from(url).to_string() + /// Returns the validated documentation-relative page source. + pub fn source(&self) -> &SourcePath { + &self.source + } } // ---------------------------------------------------------------------------- @@ -278,6 +266,8 @@ impl Value for Page {} impl PartialEq for PageData { fn eq(&self, other: &Self) -> bool { self.url == other.url + && self.source == other.source + && self.destination == other.destination && self.canonical_url == other.canonical_url && self.edit_url == other.edit_url && self.title == other.title @@ -315,20 +305,60 @@ impl Deref for Page { } // ---------------------------------------------------------------------------- -// Type alises +// Type aliases // ---------------------------------------------------------------------------- /// Page metadata. pub type PageMeta = BTreeMap; +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Computes the site-relative destination for a Markdown source. +fn destination( + source: &SourcePath, use_directory_urls: bool, +) -> Result { + let parent = source.parent(); + let name = source.file_name(); + let is_index = matches!(name, "index.md" | "README.md"); + let stem = if name == "README.md" { + "index" + } else { + source.file_stem() + }; + let output = if use_directory_urls && !is_index { + format!("{stem}/index.html") + } else { + format!("{stem}.html") + }; + let destination = match parent { + Some(parent) => format!("{parent}/{output}"), + None => output, + }; + destination.parse() +} + +/// Computes the encoded URL for a site-relative destination. +fn route_url(destination: &SitePath, use_directory_urls: bool) -> String { + let url = if use_directory_urls { + destination.as_str().trim_end_matches("index.html") + } else { + destination.as_str() + }; + Uri::from(url).to_string() +} + // ---------------------------------------------------------------------------- // Tests // ---------------------------------------------------------------------------- #[cfg(test)] mod tests { - use super::*; use serde_json::json; + use std::sync::Arc; + + use super::{destination, route_url, Page, PageData, PageRoute}; fn page() -> Page { let markdown = serde_json::from_value(json!({ @@ -341,6 +371,8 @@ mod tests { .unwrap(); Page { data: Arc::new(PageData { + source: "index.md".parse().unwrap(), + destination: "index.html".parse().unwrap(), url: String::from("/"), canonical_url: None, edit_url: None, @@ -363,17 +395,56 @@ mod tests { #[test] fn computes_mkdocs_destinations() { - assert_eq!(destination("index.md", true), "index.html"); - assert_eq!(destination("README.md", true), "index.html"); - assert_eq!(destination("guide/README.md", true), "guide/index.html"); - assert_eq!(destination("guide/page.md", true), "guide/page/index.html"); - assert_eq!(destination("guide/page.md", false), "guide/page.html"); + let cases = [ + ("index.md", true, "index.html"), + ("README.md", true, "index.html"), + ("guide/README.md", true, "guide/index.html"), + ("guide/index.md", true, "guide/index.html"), + ("guide/page.md", true, "guide/page/index.html"), + ("myindex.md", true, "myindex/index.html"), + ("guide/page.md", false, "guide/page.html"), + ("guide/README.md", false, "guide/index.html"), + ("café/100%.md", true, "café/100%/index.html"), + ]; + for (source, directory_urls, expected) in cases { + let source = source.parse().unwrap(); + assert_eq!( + destination(&source, directory_urls).unwrap().as_str(), + expected + ); + } } #[test] fn computes_encoded_urls() { - assert_eq!(route_url("100%/index.html", true), "100%25/"); - assert_eq!(route_url("100%.html", false), "100%25.html"); + let cases = [ + ("100%/index.html", true, "100%25/"), + ("100%.html", false, "100%25.html"), + ("café/index.html", true, "caf%C3%A9/"), + ("myindex/index.html", true, "myindex/"), + ]; + for (destination, directory_urls, expected) in cases { + assert_eq!( + route_url(&destination.parse().unwrap(), directory_urls), + expected + ); + } + } + + #[test] + fn route_serialization_contains_only_logical_facts() { + let route = PageRoute { + source: "guide/café.md".parse().unwrap(), + destination: "guide/café/index.html".parse().unwrap(), + url: "guide/caf%C3%A9/".into(), + }; + let value = serde_json::to_value(&route).unwrap(); + + assert_eq!(value["source"], "guide/café.md"); + assert_eq!(value["destination"], "guide/café/index.html"); + assert_eq!(value["url"], "guide/caf%C3%A9/"); + assert!(value.get("path").is_none()); + assert_eq!(serde_json::from_value::(value).unwrap(), route); } #[test] @@ -382,6 +453,8 @@ mod tests { assert_eq!(value["url"], "/"); assert_eq!(value["title"], "Home"); + assert!(value.get("source").is_none()); + assert!(value.get("destination").is_none()); assert!(value.get("data").is_none()); } } diff --git a/crates/zensical/src/template/filter.rs b/crates/zensical/src/template/filter.rs index b53eac8..687b2d8 100644 --- a/crates/zensical/src/template/filter.rs +++ b/crates/zensical/src/template/filter.rs @@ -28,9 +28,9 @@ use minijinja::{State, Value}; use std::fmt::Write; use std::path::Path; -use zrx::path::PathExt; use zensical_serve::http::Uri; +use zrx::path::PathExt; // ---------------------------------------------------------------------------- // Functions diff --git a/crates/zensical/src/template/output.rs b/crates/zensical/src/template/output.rs index fb10e57..92a1f72 100644 --- a/crates/zensical/src/template/output.rs +++ b/crates/zensical/src/template/output.rs @@ -25,9 +25,9 @@ //! MiniJinja template output. +use serde::{Deserialize, Serialize}; use std::ops::Deref; -use serde::{Deserialize, Serialize}; use zrx::stream::Value; // ---------------------------------------------------------------------------- diff --git a/crates/zensical/src/watcher.rs b/crates/zensical/src/watcher.rs index a125831..2c0d5b9 100644 --- a/crates/zensical/src/watcher.rs +++ b/crates/zensical/src/watcher.rs @@ -30,15 +30,18 @@ use mio::Waker; use std::collections::BTreeSet; use std::ffi::OsStr; use std::fs; +use std::io; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; + use zensical_watch::event::{Event, Kind}; use zensical_watch::{Agent, Error, Result}; use zrx::id::Id; use zrx::stream::Change; -use super::config::Config; +use crate::config::Config; +use crate::path::SourcePath; mod source; @@ -59,6 +62,12 @@ pub struct Watcher { changes: Receiver>>, } +/// One physical source root and its provider-relative identity context. +struct SourceMount { + root: PathBuf, + context: String, +} + // ---------------------------------------------------------------------------- // Implementations // ---------------------------------------------------------------------------- @@ -74,9 +83,15 @@ impl Watcher { let mut sources = Vec::default(); // Add docs directory and theme directories - sources.push((config.get_docs_dir(), config.project.docs_dir.clone())); + sources.push(SourceMount::new( + config.docs_root().as_path().to_owned(), + config.project.docs_dir.clone(), + )); for (i, theme_dir) in config.theme_dirs.iter().enumerate() { - sources.push((theme_dir.clone(), format!("templates/{i}"))); + sources.push(SourceMount::new( + theme_dir.clone(), + format!("templates/{i}"), + )); } // Add configuration file last, or we might run into overlapping paths. @@ -85,8 +100,11 @@ impl Watcher { // so we can make sure that there won't be any ambiguities. let mut path = config.path.clone(); path.pop(); - sources.push((config.get_site_dir(), config.project.site_dir.clone())); - sources.push((path, String::from("."))); + sources.push(SourceMount::new( + config.output_root().as_path().to_owned(), + String::from("."), + )); + sources.push(SourceMount::new(path, String::from("."))); // Track seen files to restart on config or template change let mut seen = BTreeSet::new(); @@ -170,12 +188,12 @@ impl Watcher { // that were generated and should not trigger a rebuild. We // forward them to the reload channel in the server instead, // so the browser can refresh the site. - let site_dir = config.get_site_dir(); - let site_dir = canonical_or_clone(&site_dir); - if event_path.starts_with(&site_dir) { + let site_dir = config.output_root().as_path(); + if event_path.starts_with(site_dir) { // Compute identifier, since we need the relative URL // so we only reload the page the client is on. - let id = to_id(event.path().clone(), &sources); + let event_path = event.path(); + let id = to_id(&event_path, &sources)?; // Compute path, and if directory URLs are enabled, // strip the `index.html` suffix, if present. @@ -210,29 +228,27 @@ impl Watcher { // File was created or modified Event::Create { path, .. } | Event::Modify { path, .. } => { - let data = path.to_string_lossy().into_owned(); batch.push(Change::Insert( - to_id(path, &sources).into(), - data.into(), + to_id(&path, &sources)?.into(), + Source::from(path), )); } // File was renamed Event::Rename { from, to, .. } => { - let data = to.to_string_lossy().into_owned(); batch.push(Change::Remove( - to_id(from, &sources).into(), + to_id(&from, &sources)?.into(), )); batch.push(Change::Insert( - to_id(to, &sources).into(), - data.into(), + to_id(&to, &sources)?.into(), + Source::from(to), )); } // File was removed Event::Remove { path, .. } => { batch.push(Change::Remove( - to_id(path, &sources).into(), + to_id(&path, &sources)?.into(), )); } } @@ -274,12 +290,12 @@ impl Watcher { } // Watch site directory, ensuring it exists - let site_dir = config.get_site_dir(); - fs::create_dir_all(&site_dir).unwrap(); - agent.watch(&site_dir)?; + let site_dir = config.output_root().as_path(); + fs::create_dir_all(site_dir).unwrap(); + agent.watch(site_dir)?; // Return file watcher - agent.watch(config.get_docs_dir())?; + agent.watch(config.docs_root().as_path())?; Ok(Self { _agent: agent, changes: receiver, @@ -294,6 +310,16 @@ impl Watcher { } } +impl SourceMount { + /// Creates a source mount with platform-independent context spelling. + fn new(root: PathBuf, context: String) -> Self { + Self { + root, + context: context.replace('\\', "/"), + } + } +} + // ---------------------------------------------------------------------------- // Functions // ---------------------------------------------------------------------------- @@ -302,29 +328,77 @@ impl Watcher { /// /// This will also be hoisted into the file provider, which will make sure that /// identifiers are platform independent by always ensuring forward slashes. -fn to_id(path: Arc, sources: &[(PathBuf, String)]) -> Id { - let option = sources.iter().find_map(|(prefix, context)| { - if let Ok(suffix) = path.strip_prefix(prefix) { - let location = suffix.to_str().unwrap_or(""); - Some( - Id::builder() - .provider("file") - .context(context.replace('\\', "/")) - .location(location.replace('\\', "/")) - .build() - .expect("invariant"), - ) - } else { - None - } +fn to_id(path: &Path, sources: &[SourceMount]) -> io::Result { + let option = sources.iter().find_map(|source| { + path.strip_prefix(&source.root) + .ok() + .map(|suffix| (source, suffix)) }); // Note that this cannot fail, since there must be a path in the source // mapping that matches the given path, at least the project root - option.expect("invariant") + let (source, suffix) = option.expect("invariant"); + let location = SourcePath::from_path(suffix).map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid source path '{}': {error}", path.display()), + ) + })?; + Ok(Id::builder() + .provider("file") + .context(&source.context) + .location(location.as_str()) + .build() + .expect("invariant")) } #[inline] fn canonical_or_clone(path: &Path) -> PathBuf { fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use std::{fs, io}; + use tempfile::tempdir; + + use super::{to_id, SourceMount}; + + #[test] + fn output_identifiers_do_not_contain_the_physical_root() { + let directory = tempdir().unwrap(); + let output = directory.path().join("absolute/site"); + fs::create_dir_all(&output).unwrap(); + let file = output.join("guide/index.html"); + let sources = [SourceMount::new(output, String::from("."))]; + + let id = to_id(&file, &sources).unwrap(); + + assert_eq!(id.context(), "."); + assert_eq!(id.location(), "guide/index.html"); + assert_eq!(id.as_uri().as_str(), "guide/index.html"); + } + + #[cfg(unix)] + #[test] + fn rejects_non_utf8_provider_identity_instead_of_collapsing_it() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt as _; + + let directory = tempdir().unwrap(); + let file = directory + .path() + .join(OsString::from_vec(b"bad-\xff.md".to_vec())); + let sources = [SourceMount::new( + directory.path().to_owned(), + String::from("docs"), + )]; + + let error = to_id(&file, &sources).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + } +} diff --git a/crates/zensical/src/watcher/source.rs b/crates/zensical/src/watcher/source.rs index d59e727..bb1be68 100644 --- a/crates/zensical/src/watcher/source.rs +++ b/crates/zensical/src/watcher/source.rs @@ -26,6 +26,8 @@ //! Source. use std::ops::Deref; +use std::path::{Path, PathBuf}; +use std::sync::Arc; use zrx::stream::Value; @@ -35,15 +37,11 @@ use zrx::stream::Value; /// Source. /// -/// From ZRX 0.0.17 on, all ata emitted in streams must explicitly implement the -/// `Value` trait. Right now, we just emit `String` representations of paths, -/// but as we develop the provider architecture, we'll switch to a structured -/// representation that includes the path as well as metadata. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Source { - /// Path as string. - pub path: String, -} +/// From ZRX 0.0.17 on, all data emitted in streams must explicitly implement +/// the `Value` trait. Physical paths stay physical throughout the data plane; +/// provider-relative identity is carried separately by the stream key. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct Source(Arc); // ---------------------------------------------------------------------------- // Trait implementations @@ -53,20 +51,35 @@ impl Value for Source {} // ---------------------------------------------------------------------------- -impl From for Source { - /// Creates a source from a string. +impl From for Source { + /// Creates a source from an owned physical path. #[inline] - fn from(path: String) -> Self { - Self { path } + fn from(path: PathBuf) -> Self { + Self(Arc::new(path)) + } +} + +impl From> for Source { + /// Creates a source while reusing a watcher-owned physical path. + #[inline] + fn from(path: Arc) -> Self { + Self(path) } } impl Deref for Source { - type Target = String; + type Target = Path; - /// Dereferences the source to a string. + /// Dereferences the source to its physical path. #[inline] fn deref(&self) -> &Self::Target { - &self.path + self.0.as_path() + } +} + +impl AsRef for Source { + #[inline] + fn as_ref(&self) -> &Path { + self } } diff --git a/crates/zensical/src/workflow.rs b/crates/zensical/src/workflow.rs index f287170..42e05a5 100644 --- a/crates/zensical/src/workflow.rs +++ b/crates/zensical/src/workflow.rs @@ -23,16 +23,16 @@ // ---------------------------------------------------------------------------- -//! Workflow definitions +//! Workflow definitions. use regex::Regex; use serde::{Deserialize, Serialize}; use std::fs; use std::hash::{DefaultHasher, Hash, Hasher}; use std::ops::Deref; -use std::path::Path; use std::str::FromStr; use std::sync::{Arc, LazyLock, OnceLock}; + use zrx::id::matcher::Matcher; use zrx::id::Id; use zrx::stream::function::Collection; @@ -41,24 +41,22 @@ use zrx::stream::{ concurrent, Key, Signal, Stream, StreamTupleExt, Value, Workflow, }; -use super::compat::mkdocs::plugin::{ - self, autorefs, meta, minify, mkdocstrings, redirects, search, +use crate::compat::mkdocs::plugin::autorefs::UnresolvedAutorefs; +use crate::compat::mkdocs::{ + plugin::{self, autorefs, meta, minify, mkdocstrings, redirects, search}, + resource, }; -use super::config::Config; -use super::structure::markdown::Markdown; -use super::structure::nav::Navigation; -use super::structure::page::{Page, PageRoute}; -use super::template::Template; -use super::watcher::Source; +use crate::config::Config; +use crate::path::{PathError, SitePath, SourcePath}; +use crate::python::{Anchors, Issues, References, SharedReferences}; +use crate::structure::markdown::Markdown; +use crate::structure::nav::Navigation; +use crate::structure::page::{Page, PageRoute}; +use crate::template::Template; +use crate::watcher::Source; -use super::compat::mkdocs::plugin::autorefs::UnresolvedAutorefs; -use super::python::{Anchors, Issues, References, SharedReferences}; - -// TODO: Migrate aggregation after the basic workflow runs on the new runtime. -// mod aggregate; mod cached; -// use aggregate::aggregate; use cached::cached; // ---------------------------------------------------------------------------- @@ -82,16 +80,18 @@ static SNIPPET_RE: LazyLock = /// ship the module system as fast as possible, allowing us to work on feature /// parity, while testing the module system in a real-world codebase. #[derive(Debug)] -pub struct Main { +struct Main { /// Configuration. config: Config, /// Strict mode. strict: bool, + /// Resolved metadata settings shared with source admission. + meta: Arc, } /// File input enriched with immutable facts for the current revision. #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct Input { +pub struct Input { /// Source supplied by the file provider. source: Source, /// Metadata files parsed once and shared by every page in the revision. @@ -100,9 +100,30 @@ pub(crate) struct Input { impl Value for Input {} +/// Immutable build configuration supplied through the workflow data plane. +#[derive(Clone, Debug)] +pub struct Configuration { + /// Fully resolved project configuration. + config: Arc, + /// Whether warnings fail this build. + strict: bool, +} + +impl Value for Configuration {} + +impl Configuration { + /// Creates the configuration fact for one workflow lifetime. + pub fn new(config: Config, strict: bool) -> Self { + Self { + config: Arc::new(config), + strict, + } + } +} + impl Input { /// Enriches one provider source with revision-local metadata facts. - pub(crate) fn new(source: Source, metadata: Arc) -> Self { + pub fn new(source: Source, metadata: Arc) -> Self { Self { source, metadata } } } @@ -188,8 +209,6 @@ struct RenderedMarkdown { registrations: Arc, /// Facts extracted by the shared MkDocs-compatible HTML pass. html: plugin::HtmlFacts, - /// Resolved metadata and source mappings for later compatibility modules. - pub(crate) meta: Arc, } impl Value for RenderedMarkdown {} @@ -205,14 +224,11 @@ struct RenderedPage { registrations: Arc, /// HTML compatibility facts revision-aligned with the page. html: plugin::HtmlFacts, - /// Resolved metadata and source mappings for later compatibility modules. - pub(crate) meta: Arc, } impl Value for RenderedPage {} // ---------------------------------------------------------------------------- - // Implementations // ---------------------------------------------------------------------------- @@ -220,18 +236,31 @@ impl Main { /// Initializes the module. fn setup(&self, ctx: &mut Builder) { let files = ctx.input::(); - let meta = meta::Settings::new(&self.config); + let configuration = ctx.input::(); + let minify = minify::Settings::new(&self.config); // Set up workflow to process static assets and Markdown files. - let assets = process_assets(&self.config, &files, &meta); + 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 markdown = route_markdown(&self.config, &files); // Redirects depend on routes, not rendered Markdown. Settle their // compact input independently so they can proceed concurrently with // the Python rendering branch. let routes = markdown.map(|input: &RoutedMarkdown| input.route.clone()); - let redirects = generate_redirects(&self.config, &routes); - redirects::attach(&self.config, self.strict, &redirects); + let redirect_settings = + configuration.map(|configuration: &Configuration| { + redirects::Settings::new( + &configuration.config, + configuration.strict, + ) + }); + redirects::Redirects.setup(redirects::Dependencies { + settings: &redirect_settings, + routes: &routes, + }); let rendered = process_markdown(&self.config, &markdown); @@ -245,8 +274,8 @@ impl Main { let search = site.map(|site: &Site| site.search.clone()); search::attach(&self.config, &search); mkdocstrings::attach(&self.config, &nav); - let _ = render_templates(&self.config, &files, &nav, &assets); - let unresolved = render_pages(&self.config, &site, &assets); + let _ = render_templates(&self.config, &files, &nav, &assets, &minify); + let unresolved = render_pages(&self.config, &site, &assets, &minify); validate(&self.config, self.strict, &files, &page, &unresolved); } } @@ -255,31 +284,8 @@ impl Main { // Functions // ---------------------------------------------------------------------------- -// TODO: Replace the legacy barrier with revision-settled aggregation. -// Return condition waiting for all Markdown files -#[cfg(any())] -pub fn wait_for_markdown(config: &Config) -> (Key, Barrier) { - let docs_dir = config.project.docs_dir.clone(); - let matcher = Matcher::from_str(&format!("zrs::::{docs_dir}:**/*.md:")) - .expect("invariant"); - - // Create barrier that waits for all Markdown files to be processed - let barrier = Barrier::new(move |id: &Key| { - matcher.is_match(&id[0]).expect("invariant") - }); - - // Create key for barrier - let id = - Key::from_iter([ - id!(provider = "file", context = ".", location = ".").unwrap() - ]); - - // Return both - (id, barrier) -} - /// Create a stream to collect references from all Markdown files. -pub fn collect_references( +fn collect_references( config: &Config, files: &Stream, ) -> Stream { let matcher = Arc::new( @@ -295,7 +301,7 @@ pub fn collect_references( .filter(move |id: &Id| matcher.is_match(id).expect("invariant")) .map(|source: &Input| { let references: References = - fs::read_to_string(&*source.path)?.parse()?; + fs::read_to_string(&*source.source)?.parse()?; Ok::<_, anyhow::Error>(SharedReferences::from(references)) }) } @@ -343,157 +349,6 @@ fn page_hash(page: &Page, autorefs: &autorefs::References) -> u64 { hasher.finish() } -/// Create a stream to process static assets. -pub fn process_assets( - config: &Config, files: &Stream, meta: &meta::Settings, -) -> Signal { - if !minify::asset::is_enabled(config) { - copy_assets(config, files, meta); - let project = config.project.clone(); - return files.reduce(move |_: &dyn Collection, Input>| { - Ok::<_, anyhow::Error>(Some(minify::asset::Manifest::base( - project.clone(), - ))) - }); - } - - let extra_templates = config.project.extra_templates.clone(); - let static_templates = config.project.theme.static_templates.clone(); - let docs_dir = config.project.docs_dir.clone(); - let docs = Arc::new( - Matcher::from_str(&format!("zrs::::{docs_dir}::")).expect("invariant"), - ); - let themes = - Arc::new(Matcher::from_str("zrs::::templates/*::").expect("invariant")); - let meta = meta.clone(); - let resources = files.filter_map(move |id: &Id, input: &Input| { - let location = id.location().into_owned(); - let priority = - if docs.is_match(id).expect("invariant") { - if Path::new(&location).extension().is_some_and(|extension| { - extension.eq_ignore_ascii_case("md") - }) || meta::claims(&location, &meta) - || extra_templates.contains(&location) - { - return None; - } - 0 - } else if themes.is_match(id).expect("invariant") { - if Path::new(&location).extension().is_some_and(|extension| { - extension.eq_ignore_ascii_case("html") - }) || static_templates.contains(&location) - { - return None; - } - id.context() - .strip_prefix("templates/") - .and_then(|index| index.parse::().ok()) - .map_or(usize::MAX, |index| index + 1) - } else { - return None; - }; - Some(minify::asset::Resource { - path: location, - source: input.path.clone().into(), - priority, - }) - }); - - // Resolve project/theme precedence before transformation. A removed - // project override therefore reveals the effective theme resource in the - // same revision instead of briefly deleting the logical output. - let effective = resources.reduce_by_key( - |resource: &minify::asset::Resource| { - minify::asset::resource_key(&resource.path) - }, - |resources: &dyn Collection, minify::asset::Resource>| { - Ok::<_, anyhow::Error>( - resources - .iter() - .map(|(_, resource)| resource) - .min_by(|left, right| { - left.priority - .cmp(&right.priority) - .then_with(|| left.source.cmp(&right.source)) - }) - .cloned(), - ) - }, - ); - minify::asset::attach(config, &effective) -} - -/// Copies assets directly when no compatibility module claims them. -fn copy_assets( - config: &Config, files: &Stream, meta: &meta::Settings, -) { - let docs_dir = config.project.docs_dir.clone(); - let docs = Arc::new( - Matcher::from_str(&format!("zrs::::{docs_dir}::")).expect("invariant"), - ); - let extra_templates = config.project.extra_templates.clone(); - let site_dir = config.project.site_dir.clone(); - let root_dir = config.get_root_dir(); - let meta = meta.clone(); - let _ = files.map(move |id: &Id, input: &Input| { - let location = id.location(); - if !docs.is_match(id).expect("invariant") - || Path::new(location.as_ref()) - .extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case("md")) - || meta::claims(&location, &meta) - || extra_templates - .iter() - .any(|template| template == location.as_ref()) - { - return Ok(()); - } - let output = id - .to_builder() - .context(&site_dir) - .build() - .expect("invariant"); - copy_asset(&input.path, root_dir.join(output.to_path())) - }); - - let themes = - Arc::new(Matcher::from_str("zrs::::templates/*::").expect("invariant")); - let static_templates = config.project.theme.static_templates.clone(); - let site_dir = config.project.site_dir.clone(); - let root_dir = config.get_root_dir(); - let _ = files.map(move |id: &Id, input: &Input| { - let location = id.location(); - if !themes.is_match(id).expect("invariant") - || Path::new(location.as_ref()) - .extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case("html")) - || static_templates - .iter() - .any(|template| template == location.as_ref()) - { - return Ok(()); - } - let output = id - .to_builder() - .context(&site_dir) - .build() - .expect("invariant"); - copy_asset(&input.path, root_dir.join(output.to_path())) - }); -} - -/// Copies one asset without preserving source permissions. -fn copy_asset( - from: impl AsRef, to: impl AsRef, -) -> anyhow::Result<()> { - let to = to.as_ref(); - fs::create_dir_all(to.parent().expect("site asset has parent"))?; - let mut from = fs::File::open(from)?; - let mut to = fs::File::create(to)?; - std::io::copy(&mut from, &mut to)?; - Ok(()) -} - /// Select Markdown sources and derive their routes before rendering. fn route_markdown( config: &Config, files: &Stream, @@ -508,9 +363,11 @@ fn route_markdown( let config = config.clone(); files .filter(move |id: &Id| matcher.is_match(id).expect("invariant")) - .map(move |id: &Id, input: &Input| RoutedMarkdown { - input: input.clone(), - route: PageRoute::new(&config, id), + .map(move |id: &Id, input: &Input| { + Ok::<_, crate::path::PathError>(RoutedMarkdown { + input: input.clone(), + route: PageRoute::new(&config, id)?, + }) }) } @@ -527,13 +384,12 @@ fn process_markdown( // Note that we need to limit concurrency here, or we'll overwhelm the // Python interpreter with all tasks competing for the GIL. .map(concurrent(1, move |id: &Id, routed: &RoutedMarkdown| { - let location = id.location().into_owned(); - let data = fs::read_to_string(&*routed.input.path)?; + let data = fs::read_to_string(&*routed.input.source)?; let route = routed.route.clone(); - let (data, page_meta) = meta::front_matter(&location, &data)?; + let (data, page_meta) = meta::front_matter(&route.source, &data)?; let resolved = - routed.input.metadata.resolve(&location, page_meta)?; + routed.input.metadata.resolve(&route.source, page_meta)?; // Don't cache page if it inserts (pymdownx) snippets. // This is a hack while waiting for CommonMark (AST) and components, // as well as topic-based authoring functionality. @@ -571,13 +427,11 @@ fn render_markdown( } else { Arc::default() }; - let meta = Arc::new(meta.reconcile(markdown.meta.clone())); Ok(RenderedMarkdown { route, markdown, registrations, html, - meta, }) } @@ -594,7 +448,6 @@ fn generate_page( ), registrations: markdown.registrations.clone(), html: markdown.html.clone(), - meta: markdown.meta.clone(), }) } @@ -609,7 +462,7 @@ fn generate_site( let mut facts = Vec::new(); let mut documents = Vec::new(); for (key, rendered) in pages.iter() { - nav_pages.push((key.clone(), rendered.page.clone())); + nav_pages.push(rendered.page.clone()); site_pages.push(( key.clone(), SitePage { @@ -617,14 +470,14 @@ fn generate_site( autorefs: rendered.html.autorefs.clone(), }, )); - facts.push((key.clone(), rendered.registrations.clone())); + facts.push(( + rendered.page.source().clone(), + rendered.registrations.clone(), + )); if !rendered.html.search.is_empty() { - documents.push(( - key.clone(), - search::Document::new( - &rendered.page, - rendered.html.search.clone(), - ), + documents.push(search::Document::new( + &rendered.page, + rendered.html.search.clone(), )); } } @@ -641,25 +494,15 @@ fn generate_site( }) } -/// Resolve redirects from the compact route relation. -fn generate_redirects( - config: &Config, routes: &Stream, -) -> Signal { - let config = config.clone(); - routes.reduce(move |routes: &dyn Collection, PageRoute>| { - redirects::Snapshot::new(&config, routes.iter()).map(Some) - }) -} - /// Project navigation from the current site batch. fn generate_nav(site: &Signal) -> Signal { site.map(|site: &Site| site.nav.clone()) } /// Render static and extra templates. -pub fn render_templates( +fn render_templates( config: &Config, files: &Stream, nav: &Signal, - assets: &Signal, + assets: &Signal, minify: &minify::Settings, ) -> Stream { let docs_dir = config.project.docs_dir.clone(); @@ -686,24 +529,24 @@ pub fn render_templates( // Add docs directory to theme templates let mut theme_dirs = config.theme_dirs.clone(); - theme_dirs.push(config.get_docs_dir()); + theme_dirs.push(config.docs_root().as_path().to_owned()); // Create pipeline to render templates let renderer = Template::new(theme_dirs); - let minify = minify::Settings::new(config); + let minify = minify.clone(); let config = config.clone(); templates.product(nav).product(assets).map( - move |input: &(Input, Navigation), assets: &minify::asset::Manifest| { - let (template, nav) = input; - let name = - Path::new(&template.path).file_name().expect("invariant"); - let site_dir = config.get_site_dir(); + move |id: &Id, + input: &(Input, Navigation), + assets: &minify::asset::Manifest| { + let (_, nav) = input; + let output = template_output(id)?; + let name = output.as_str(); // Render template and write to disk - let name = name.to_string_lossy(); - let data = renderer.render(&name, &config, nav, &assets.project)?; - let data = minify.template(&name, data); - let path = site_dir.join(name.as_ref()); + let data = renderer.render(name, &config, nav, &assets.project)?; + let data = minify.template(name, data); + let path = config.output_root().join(&output); fs::create_dir_all(path.parent().expect("invariant"))?; fs::write(path, &data)?; Ok::<_, anyhow::Error>(()) @@ -711,10 +554,16 @@ pub fn render_templates( ) } +/// Maps a template provider identity to its MkDocs-compatible root output. +fn template_output(id: &Id) -> Result { + let source = id.location().parse::()?; + source.file_name().parse() +} + /// Render pages. fn render_pages( config: &Config, site: &Signal, - assets: &Signal, + assets: &Signal, minify: &minify::Settings, ) -> Stream { let pages = site.product(assets).flat_map( |site: &Site, assets: &minify::asset::Manifest| { @@ -738,7 +587,7 @@ fn render_pages( let template = OnceLock::new(); let theme_dirs = config.theme_dirs.clone(); - let minify = minify::Settings::new(config); + let minify = minify.clone(); let config = config.clone(); pages.map(move |input: &PageRender| { let mut page = input.input.page.clone(); @@ -772,16 +621,46 @@ fn render_pages( input.autorefs.replace_in(rendered, references, &page.url); let data = minify.html(data); - let path = Path::new(&page.path); + let path = config.output_root().join(page.destination()); fs::create_dir_all(path.parent().expect("invariant"))?; - fs::write(path, &data)?; + fs::write(&path, &data)?; Ok::<_, anyhow::Error>(unresolved) }) } /// Creates a workflow for the given config. -pub fn create_workflow(config: &Config, strict: bool) -> Workflow { +pub fn create_workflow( + config: &Config, strict: bool, meta: Arc, +) -> Workflow { Workflow::build(|workflow| { - Main { config: config.clone(), strict }.setup(workflow); + Main { + config: config.clone(), + strict, + meta, + } + .setup(workflow); }) } + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use zrx::id::Id; + + use super::template_output; + + #[test] + fn template_outputs_use_logical_provider_identity() { + let id = Id::builder() + .provider("file") + .context("templates/0") + .location("nested/café.html") + .build() + .unwrap(); + + assert_eq!(template_output(&id).unwrap().as_str(), "café.html"); + } +} diff --git a/crates/zensical/src/workflow/aggregate.rs b/crates/zensical/src/workflow/aggregate.rs deleted file mode 100644 index b76c51d..0000000 --- a/crates/zensical/src/workflow/aggregate.rs +++ /dev/null @@ -1,254 +0,0 @@ -// 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. - -// ---------------------------------------------------------------------------- - -//! Stream aggregation for rebuildable site-wide snapshots. - -use ahash::HashSet; -use std::marker::PhantomData; -use zrx::id::{id, Id}; -use zrx::scheduler::action::context::Binding; -use zrx::scheduler::action::options::{Event, Interest}; -use zrx::scheduler::action::{Action, Context, Options}; -use zrx::scheduler::schedule::Subscriber; -use zrx::scheduler::step::{IntoSteps, Scope}; -use zrx::scheduler::{Key, Value}; -use zrx::stream::operator::Operator; -use zrx::stream::{Barrier, Stream}; - -// ---------------------------------------------------------------------------- -// Structs -// ---------------------------------------------------------------------------- - -/// Aggregate all values matching a barrier into one site-wide snapshot. -struct Aggregate { - /// Base output scope. - output: Key, - /// Barrier selecting source scopes. - barrier: Barrier, - /// Source scopes that have not reached this stream yet. - pending: HashSet>, - /// Current output scope. - current: Option>, - /// Output generation. - generation: u64, - /// Capture value type. - marker: PhantomData, -} - -// ---------------------------------------------------------------------------- -// Implementations -// ---------------------------------------------------------------------------- - -impl Aggregate { - /// Create an aggregate for the given output scope and barrier. - fn new(output: Key, barrier: Barrier) -> Self { - Self { - output, - barrier, - pending: HashSet::default(), - current: None, - generation: 0, - marker: PhantomData, - } - } -} - -// ---------------------------------------------------------------------------- -// Trait implementations -// ---------------------------------------------------------------------------- - -impl Action for Aggregate -where - T: Value + Clone, -{ - type Inputs = (T,); - type Output<'a> = Vec<(Key, T)>; - - fn execute(&mut self, ctx: Context) -> impl IntoSteps { - let Binding { - events, - scopes, - inputs, - mut output, - .. - } = ctx.bind(); - // Track every submitted source scope, including repeated submissions - // of an existing scope during serve rebuilds. - for event in events { - match event { - Event::Insert(scope) if self.barrier.contains(&scope) => { - self.pending.insert(scope); - } - Event::Remove(scope) => { - self.pending.remove(&scope); - } - Event::Insert(_) => {} - } - } - - // A scope reaching this action is complete for this stream. Repeated - // scopes must still advance the aggregate, even if they were already - // present during a previous build. - let mut advanced = false; - for scope in scopes { - if self.barrier.contains(scope.key()) { - self.pending.remove(scope.key()); - advanced = true; - } - } - - let complete = advanced && self.pending.is_empty(); - let mut steps = Vec::new(); - if complete { - let mut values = inputs - .iter() - .map(|(key, value)| (key.clone(), value.clone())) - .collect::>(); - values.sort_unstable_by(|(left, _), (right, _)| left.cmp(right)); - - // A source submission can reach the aggregate before an updated - // asynchronous value does. Ignore that lifecycle-only advance; - // the changed value will arrive in a subsequent scope. - if self.current.as_ref().and_then(|key| output.get(key)) - == Some(&values) - { - return steps.into_iter(); - } - - // Repeated synthetic scopes are not propagated by the current - // runtime. Rotate the aggregate scope on every snapshot, removing - // the previous generation so downstream stores stay bounded. - self.generation = - self.generation.checked_add(1).expect("invariant"); - let id = id!( - self.output.try_as_id().expect("invariant"); - fragment = self.generation.to_string() - ) - .expect("invariant"); - let key = Key::from(id); - - if let Some(current) = self.current.replace(key.clone()) { - output.remove(¤t); - steps.push(Scope::from(current).done()); - } - output.insert(key.clone(), values); - steps.push(Scope::from(key).done()); - } - steps.into_iter() - } -} - -// ---------------------------------------------------------------------------- -// Functions -// ---------------------------------------------------------------------------- - -/// Aggregate a stream whenever all matching source scopes have completed. -pub fn aggregate( - stream: &Stream, (output, barrier): (Key, Barrier), -) -> Stream, T)>> -where - T: Value + Clone, -{ - let options = Options::default() - .interest(Interest::Enter) - .interest(Interest::Leave); - stream.subscribe( - Subscriber::new(Aggregate::new(output, barrier)).with_options(options), - ) -} - -// ---------------------------------------------------------------------------- -// Tests -// ---------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::{Arc, Mutex}; - use std::time::Duration; - use zrx::module::Context as ModuleContext; - use zrx::scheduler::Scheduler; - use zrx::stream::Workflow; - - #[derive(Clone, Debug, PartialEq, Eq)] - struct Number(u8); - - impl Value for Number {} - - fn tick_until( - scheduler: &mut Scheduler, condition: impl Fn() -> bool, - stage: &str, - ) { - for _ in 0..100 { - if condition() { - return; - } - scheduler.tick_timeout(Duration::from_millis(10)).unwrap(); - } - panic!("scheduler did not produce output after {stage}"); - } - - #[test] - fn test_aggregate_reemits_changed_repeated_scope() { - let context = ModuleContext::default(); - let input = context.add::(); - let root = Key::from( - id!(provider = "file", context = ".", location = ".").unwrap(), - ); - let barrier = - Barrier::new(|key: &Key| key[0].location().as_ref() == "item"); - let snapshots = aggregate(&input, (root, barrier)); - let seen = Arc::new(Mutex::new(Vec::new())); - let seen_by_stream = Arc::clone(&seen); - snapshots.map(move |values: Vec<(Key, Number)>| { - let (_, Number(value)) = values.first().expect("invariant"); - seen_by_stream.lock().expect("invariant").push(*value); - }); - drop(snapshots); - drop(input); - - let workflow: Workflow = context.into(); - let mut scheduler = Scheduler::::default(); - scheduler.attach(workflow); - let session = scheduler.session::(); - let item = - id!(provider = "file", context = ".", location = "item").unwrap(); - - session.insert(item.clone(), Number(1)).unwrap(); - tick_until( - &mut scheduler, - || seen.lock().expect("invariant").last() == Some(&1), - "first insertion", - ); - session.insert(item, Number(2)).unwrap(); - tick_until( - &mut scheduler, - || seen.lock().expect("invariant").last() == Some(&2), - "changed insertion", - ); - - assert_eq!(*seen.lock().expect("invariant"), [1, 2]); - } -} diff --git a/crates/zensical/src/workflow/cached.rs b/crates/zensical/src/workflow/cached.rs index f53e630..a52aab9 100644 --- a/crates/zensical/src/workflow/cached.rs +++ b/crates/zensical/src/workflow/cached.rs @@ -30,6 +30,7 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::hash::{DefaultHasher, Hash, Hasher}; use std::io::{BufWriter, Write}; + use zrx::scheduler::Value; use crate::config::Config; diff --git a/python/tests/integration/test_config.py b/python/tests/integration/test_config.py index 9f7be8c..b675277 100644 --- a/python/tests/integration/test_config.py +++ b/python/tests/integration/test_config.py @@ -108,6 +108,27 @@ def _make_custom_dir( return custom +def test_symlinked_config_anchors_relative_paths_to_its_target( + tmp_path: Path, +) -> None: + """Python and Rust resolve project roots from the same config path.""" + project = tmp_path / "project" + project.mkdir() + config = _make_yml_project(project) + alias_dir = tmp_path / "alias" + alias_dir.mkdir() + alias = alias_dir / "mkdocs.yml" + try: + alias.symlink_to(config) + except OSError as error: + pytest.skip(f"symbolic links unavailable: {error}") + + _build(alias) + + assert (project / "site" / "index.html").is_file() + assert not (alias_dir / "site").exists() + + # --------------------------------------------------------------------------- # Theme loading: both zensical.toml and mkdocs.yml # --------------------------------------------------------------------------- diff --git a/python/tests/integration/test_meta.py b/python/tests/integration/test_meta.py new file mode 100644 index 0000000..da7b5ae --- /dev/null +++ b/python/tests/integration/test_meta.py @@ -0,0 +1,193 @@ +# Copyright (c) 2025-2026 Zensical and contributors + +# SPDX-License-Identifier: MIT +# All contributions are certified under the DCO + +"""Integration tests for MkDocs Material metadata inheritance.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import time +from typing import TYPE_CHECKING, Any + +import pytest + +import zensical + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + +_BUILD_OPTIONS: dict[str, Any] = {"clean": False, "strict": False} + + +def test_nested_metadata_and_front_matter_render_with_custom_name( + tmp_path: Path, +) -> None: + """Ancestor maps and lists merge before page values take precedence.""" + docs = tmp_path / "docs" + guide = docs / "guide" + overrides = tmp_path / "overrides" + guide.mkdir(parents=True) + overrides.mkdir() + (docs / "defaults.yml").write_text( + "scope:\n root: root\nitems: [root]\ntitle: Root\n", + encoding="utf-8", + ) + (guide / "defaults.yml").write_text( + "scope:\n guide: guide\nitems: [guide]\ntitle: Guide\n", + encoding="utf-8", + ) + (guide / "page.md").write_text( + """\ +--- +scope: + page: page +items: [page] +title: Page +--- +# Content +""", + encoding="utf-8", + ) + (overrides / "main.html").write_text("{{ page.meta }}", encoding="utf-8") + config = tmp_path / "mkdocs.yml" + config.write_text( + """\ +site_name: Metadata +theme: + name: material + custom_dir: overrides +plugins: + - material/meta: + meta_file: defaults.yml +""", + encoding="utf-8", + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + output = (tmp_path / "site" / "guide" / "page" / "index.html") + assert json.loads(output.read_text()) == { + "items": ["root", "guide", "page"], + "scope": {"guide": "guide", "page": "page", "root": "root"}, + "title": "Page", + } + + +def test_reports_metadata_type_conflicts_with_both_sources( + tmp_path: Path, +) -> None: + """Incompatible inherited and page values retain useful source spans.""" + docs = tmp_path / "docs" + docs.mkdir() + (docs / ".meta.yml").write_text("value: inherited\n", encoding="utf-8") + (docs / "index.md").write_text( + "---\nvalue: [page]\n---\n# Page\n", encoding="utf-8" + ) + config = tmp_path / "mkdocs.yml" + config.write_text( + """\ +site_name: Metadata +theme: + name: material +plugins: + - material/meta +""", + encoding="utf-8", + ) + + with pytest.raises(RuntimeError) as caught: + zensical.build(str(config), _BUILD_OPTIONS) + + message = str(caught.value) + assert "metadata types do not match" in message + assert ".meta.yml" in message + assert "index.md" in message + + +def test_serve_rebuilds_descendants_after_metadata_edit( + tmp_path: Path, +) -> None: + """A retained workflow refreshes its index and dependent pages.""" + docs = tmp_path / "docs" + overrides = tmp_path / "overrides" + docs.mkdir() + overrides.mkdir() + metadata = docs / ".meta.yml" + metadata.write_text("value: first\n", encoding="utf-8") + (docs / "index.md").write_text("# Page\n", encoding="utf-8") + (overrides / "main.html").write_text("{{ page.meta }}", encoding="utf-8") + config = tmp_path / "mkdocs.yml" + config.write_text( + """\ +site_name: Metadata +dev_addr: 127.0.0.1:0 +theme: + name: material + custom_dir: overrides +plugins: + - material/meta +""", + encoding="utf-8", + ) + log = (tmp_path / "serve.log").open("w+", encoding="utf-8") + process = subprocess.Popen( # noqa: S603 + [ + sys.executable, + "-m", + "zensical", + "serve", + "--config-file", + str(config), + ], + cwd=tmp_path, + stdout=log, + stderr=subprocess.STDOUT, + ) + output = tmp_path / "site" / "index.html" + + def rendered_meta() -> dict[str, Any] | None: + try: + return json.loads(output.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + return None + + def wait_for(condition: Callable[[], bool], timeout: float = 10.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if condition(): + return + if process.poll() is not None: + log.flush() + log.seek(0) + raise AssertionError( + f"serve exited with status {process.returncode}: " + f"{log.read()}" + ) + time.sleep(0.02) + log.flush() + log.seek(0) + raise AssertionError( + f"serve did not rebuild metadata descendants: {log.read()}" + ) + + try: + wait_for(lambda: rendered_meta() == {"value": "first"}) + with metadata.open("r+", encoding="utf-8") as stream: + stream.write("value: other\n") + stream.truncate() + wait_for(lambda: rendered_meta() == {"value": "other"}) + assert process.poll() is None + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + log.close() diff --git a/python/tests/integration/test_minify.py b/python/tests/integration/test_minify.py index 4d38e02..d333c49 100644 --- a/python/tests/integration/test_minify.py +++ b/python/tests/integration/test_minify.py @@ -211,6 +211,32 @@ def test_external_minification_can_keep_original_names(tmp_path: Path) -> None: assert "const value={answer:42};" in script.read_text() +def test_assets_support_an_absolute_site_directory(tmp_path: Path) -> None: + """Physical output roots never enter logical asset identities.""" + docs = tmp_path / "docs" + docs.mkdir() + (docs / "index.md").write_text("# Absolute output\n", encoding="utf-8") + (docs / "app.js").write_text("const answer = 42;\n", encoding="utf-8") + output = tmp_path / "absolute-output" + config = tmp_path / "mkdocs.yml" + config.write_text( + f"""\ +site_name: Absolute output +site_dir: {output} +plugins: + - minify: + minify_js: true + js_files: app.js +""", + encoding="utf-8", + ) + + zensical.build(str(config), {"clean": False, "strict": False}) + + assert (output / "index.html").is_file() + assert (output / "app.min.js").read_text() == "const answer=42;" + + def test_missing_explicit_asset_is_reported(tmp_path: Path) -> None: """An exact configured path remains an error as it is upstream.""" docs = tmp_path / "docs" @@ -260,6 +286,96 @@ plugins: ) +def _unminified_asset_project(root: Path) -> Path: + """Create colliding project/theme assets without enabling minify.""" + docs = root / "docs" + overrides = root / "overrides" + (docs / "assets").mkdir(parents=True) + (overrides / "assets").mkdir(parents=True) + (docs / "index.md").write_text("# Assets\n", encoding="utf-8") + (docs / "assets" / "shared.txt").write_text( + "project\n", encoding="utf-8" + ) + (overrides / "assets" / "shared.txt").write_text( + "theme\n", encoding="utf-8" + ) + config = root / "mkdocs.yml" + config.write_text( + """\ +site_name: Unminified assets +theme: + name: material + custom_dir: overrides +""", + encoding="utf-8", + ) + return config + + +def test_disabled_minify_uses_project_over_theme_precedence( + tmp_path: Path, +) -> None: + """The copy path consumes the same effective-resource relation.""" + config = _unminified_asset_project(tmp_path) + zensical.build(str(config), {"clean": False, "strict": False}) + assert (tmp_path / "site" / "assets" / "shared.txt").read_text() == ( + "project\n" + ) + + +def test_disabled_minify_reconciles_asset_handoffs_and_removals( + tmp_path: Path, +) -> None: + """Serve reveals theme fallbacks and removes outputs without stale files.""" + config = _unminified_asset_project(tmp_path) + with config.open("a", encoding="utf-8") as stream: + stream.write("dev_addr: 127.0.0.1:0\n") + + process = subprocess.Popen( # noqa: S603 + [ + sys.executable, + "-m", + "zensical", + "serve", + "--config-file", + str(config), + ], + cwd=tmp_path, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + output = tmp_path / "site" / "assets" / "shared.txt" + + def wait_for(condition: Callable[[], bool], timeout: float = 10.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if condition(): + return + if process.poll() is not None: + raise AssertionError( + f"serve exited with status {process.returncode}" + ) + time.sleep(0.02) + current = output.read_text() if output.is_file() else None + raise AssertionError( + f"serve did not reconcile the expected asset: {current!r}" + ) + + try: + wait_for(lambda: output.is_file() and output.read_text() == "project\n") + (tmp_path / "docs" / "assets" / "shared.txt").unlink() + wait_for(lambda: output.is_file() and output.read_text() == "theme\n") + (tmp_path / "overrides" / "assets" / "shared.txt").unlink() + wait_for(lambda: not output.exists()) + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + def test_serve_retracts_superseded_cache_safe_assets(tmp_path: Path) -> None: """A changed asset removes its old hash and refreshes template paths.""" config = _asset_project(tmp_path, minify=True, cache_safe=True) diff --git a/python/tests/integration/test_redirects.py b/python/tests/integration/test_redirects.py index c6cd311..a851ebf 100644 --- a/python/tests/integration/test_redirects.py +++ b/python/tests/integration/test_redirects.py @@ -7,6 +7,9 @@ from __future__ import annotations +import subprocess +import sys +import time from typing import TYPE_CHECKING, Any import pytest @@ -14,6 +17,7 @@ import pytest import zensical if TYPE_CHECKING: + from collections.abc import Callable from pathlib import Path @@ -163,3 +167,89 @@ def test_redirect_output_cannot_replace_a_static_template( file.write("use_directory_urls: false\n") with pytest.raises(RuntimeError, match="rendered template"): zensical.build(str(config), _BUILD_OPTIONS) + + +def test_repeated_build_removes_and_restores_redirect_with_its_target( + tmp_path: Path, +) -> None: + """An internal target controls ownership across non-clean builds.""" + config = _write_project(tmp_path, " old.md: new.md\n") + target = tmp_path / "docs" / "new.md" + output = tmp_path / "site" / "old" / "index.html" + + zensical.build(str(config), _BUILD_OPTIONS) + assert output.is_file() + + target.unlink() + zensical.build(str(config), _BUILD_OPTIONS) + assert not output.exists() + + target.write_text("# New again\n", encoding="utf-8") + zensical.build(str(config), _BUILD_OPTIONS) + assert output.is_file() + + +def test_serve_removes_and_restores_redirect_with_its_target( + tmp_path: Path, +) -> None: + """One retained workflow reconciles a disappearing internal target.""" + config = _write_project(tmp_path, " old.md: new.md\n") + with config.open("a", encoding="utf-8") as file: + file.write("dev_addr: 127.0.0.1:0\n") + + log = (tmp_path / "serve.log").open("w+", encoding="utf-8") + process = subprocess.Popen( # noqa: S603 + [ + sys.executable, + "-m", + "zensical", + "serve", + "--config-file", + str(config), + ], + cwd=tmp_path, + stdout=log, + stderr=subprocess.STDOUT, + ) + target = tmp_path / "docs" / "new.md" + output = tmp_path / "site" / "old" / "index.html" + target_output = tmp_path / "site" / "new" / "index.html" + + def wait_for(condition: Callable[[], bool], timeout: float = 10.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if condition(): + return + if process.poll() is not None: + log.flush() + log.seek(0) + raise AssertionError( + f"serve exited with status {process.returncode}: " + f"{log.read()}" + ) + time.sleep(0.02) + log.flush() + log.seek(0) + raise AssertionError( + f"serve did not reconcile the redirect output: {log.read()}" + ) + + try: + wait_for(lambda: output.is_file() and target_output.is_file()) + target.unlink() + wait_for(lambda: not output.exists()) + target.write_text("# New again\n", encoding="utf-8") + wait_for( + lambda: output.is_file() + and target_output.is_file() + and "New again" in target_output.read_text(encoding="utf-8") + ) + assert process.poll() is None + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + log.close() diff --git a/python/tests/unit/extensions/test_links.py b/python/tests/unit/extensions/test_links.py new file mode 100644 index 0000000..6250667 --- /dev/null +++ b/python/tests/unit/extensions/test_links.py @@ -0,0 +1,70 @@ +# Copyright (c) 2025-2026 Zensical and contributors + +# SPDX-License-Identifier: MIT +# All contributions are certified under the DCO + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +import pytest + +from zensical.extensions.links import ( + _is_relative, + _md_path_to_html, + _rewrite_url, +) + + +@pytest.mark.parametrize( + ("path", "directory_urls", "expected"), + [ + ("index.md", True, ""), + ("README.md", True, ""), + ("guide/README.md", True, "guide/"), + ("guide/page.md", True, "guide/page/"), + ("myindex.md", True, "myindex/"), + ("guide/README.md", False, "guide/index.html"), + ("guide/page.md", False, "guide/page.html"), + ("assets/app.js", True, "assets/app.js"), + ], +) +def test_markdown_path_routing( + path: str, directory_urls: bool, expected: str +) -> None: + """Markdown links retain the current MkDocs-compatible route shape.""" + assert _md_path_to_html(path, directory_urls) == expected + + +@pytest.mark.parametrize( + "value", + ["https://example.com", "//example.com", "/root", "#section"], +) +def test_non_relative_references_are_not_rewritten(value: str) -> None: + """External, root-relative, and same-page references remain untouched.""" + assert not _is_relative(value) + assert _rewrite_url(value, "guide/page.md", True) is None + + +def test_rewrite_preserves_query_and_fragment() -> None: + """Only the path component changes when a Markdown URL is rewritten.""" + assert ( + _rewrite_url("other.md?view=full#details", "guide/page.md", True) + == "../other/?view=full#details" + ) diff --git a/python/tests/unit/extensions/test_macros.py b/python/tests/unit/extensions/test_macros.py index 2113082..c063fcc 100644 --- a/python/tests/unit/extensions/test_macros.py +++ b/python/tests/unit/extensions/test_macros.py @@ -23,6 +23,7 @@ from __future__ import annotations +from io import StringIO from typing import TYPE_CHECKING import pandas @@ -539,11 +540,18 @@ class TestTableHelpers: def test_convert_to_md_table_omits_index_by_default(self) -> None: # Custom index values must not leak into the output. # Verifies that the `index=False` default is applied. - df: DataFrame = pandas.DataFrame({"X": [1, 2]}, index=[100, 200]) + df: DataFrame = pandas.DataFrame( + {"X": [1, 2]}, index=pandas.Index([100, 200]) + ) result = _convert_to_md_table(df) assert "100" not in result assert "200" not in result + def test_convert_to_md_table_requires_string_output(self) -> None: + df: DataFrame = pandas.DataFrame({"X": [1, 2]}) + with pytest.raises(ValueError, match="produced no output"): + _convert_to_md_table(df, buf=StringIO()) + # --------------------------------------------------------------------------- # Table readers diff --git a/python/tests/unit/test_config.py b/python/tests/unit/test_config.py index 54276e6..9956b14 100644 --- a/python/tests/unit/test_config.py +++ b/python/tests/unit/test_config.py @@ -176,10 +176,12 @@ class TestPluginShimming: tmp_path, plugins={"material/meta": {"meta_file": "defaults.yml"}}, ) + assert "material/meta" not in config["plugins"] assert config["plugins"]["meta"]["config"] == { "enabled": True, "meta_file": "defaults.yml", } + assert config["plugins_hash"] == cfg_module._hash(config["plugins"]) def test_redirects_plugin_is_normalized(self, tmp_path: Path) -> None: config = self._parse_yaml( diff --git a/python/zensical/config.py b/python/zensical/config.py index 3d10a6b..1743224 100644 --- a/python/zensical/config.py +++ b/python/zensical/config.py @@ -1270,10 +1270,9 @@ def _convert_plugins(value: Any, config: dict) -> dict: search, "separator", '[\\s\\-_,:!=\\[\\]()\\\\"`/]+|\\.(?!\\d)', str ) - # Normalize Material's meta plugin to an identifier that can be extracted - # into the typed Rust configuration. Keep the original entry intact for - # compatibility with consumers of the MkDocs plugin mapping. - material_meta = plugins.get("material/meta") + # Consume Material's public plugin name and normalize it to the internal + # identifier extracted into typed Rust configuration. + material_meta = plugins.pop("material/meta", None) if material_meta is None: meta = {"enabled": False, "meta_file": ".meta.yml"} else: @@ -1295,6 +1294,7 @@ def _convert_plugins(value: Any, config: dict) -> dict: # Normalize the complete mkdocs-minify-plugin configuration surface. Asset # settings are retained for the dedicated copy/output stage; the inline # switches are Zensical extensions handled by the final HTML pass. + minify: dict[str, Any] if "minify" not in plugins: minify = {"enabled": False} else: diff --git a/python/zensical/extensions/macros.py b/python/zensical/extensions/macros.py index 526daf6..597cbf2 100644 --- a/python/zensical/extensions/macros.py +++ b/python/zensical/extensions/macros.py @@ -589,7 +589,10 @@ def _convert_to_md_table(df: DataFrame, **kwargs: Any) -> str: df = df.map(lambda s: escape_pipes(s) if isinstance(s, str) else s) kwargs.setdefault("index", False) kwargs.setdefault("tablefmt", "pipe") - return df.to_markdown(**kwargs) + result = df.to_markdown(**kwargs) + if result is None: + raise ValueError("Markdown table conversion produced no output") + return result def _param_names(func: Callable) -> list[str]: