diff --git a/crates/zensical/src/compat/mkdocs/plugin.rs b/crates/zensical/src/compat/mkdocs/plugin.rs index 20a0c1b..1aebf38 100644 --- a/crates/zensical/src/compat/mkdocs/plugin.rs +++ b/crates/zensical/src/compat/mkdocs/plugin.rs @@ -39,6 +39,7 @@ pub mod autoapi; pub mod autorefs; pub mod awesome_nav; pub mod blog; +pub mod exclude; pub mod literate_nav; pub mod meta; pub mod minify; diff --git a/crates/zensical/src/compat/mkdocs/plugin/exclude.rs b/crates/zensical/src/compat/mkdocs/plugin/exclude.rs new file mode 100644 index 0000000..10ac891 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/exclude.rs @@ -0,0 +1,149 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Native file exclusion for mkdocs-exclude. + +use anyhow::{Context, Result}; +use pyo3::prelude::*; +use std::sync::Arc; + +use zrx::id::Id; +use zrx::stream::{Stream, Value}; + +use crate::compat::mkdocs::resource::Resource; +use crate::config::Config; +use crate::path::SourcePath; + +mod pattern; + +use pattern::Pattern; + +/// File exclusion shared by documentation and resource streams. +#[derive(Clone, Debug)] +pub struct Exclude { + settings: Arc, +} + +#[derive(Debug)] +struct Settings { + enabled: bool, + docs: String, + globs: Vec, + regexes: Vec>, +} + +impl Exclude { + /// Compiles configured patterns once for the workflow. + pub fn new(config: &Config) -> Result { + let plugin = &config.project.plugins.exclude.config; + let mut settings = Settings { + enabled: plugin.enabled, + docs: config.project.docs_dir.clone(), + globs: Vec::new(), + regexes: Vec::new(), + }; + if plugin.enabled { + settings.globs = plugin + .glob + .iter() + .map(|pattern| Pattern::compile(pattern)) + .collect::>()?; + if !plugin.regex.is_empty() { + // Preserve Python lookarounds, backreferences and flags. + settings.regexes = Python::attach(|py| { + let re = py.import("re")?; + plugin + .regex + .iter() + .map(|pattern| { + re.call_method1("compile", (pattern,)) + .map(Bound::unbind) + .with_context(|| { + format!("invalid exclude regex {pattern:?}") + }) + }) + .collect::>() + })?; + } + } + Ok(Self { settings: Arc::new(settings) }) + } + + /// Removes matching documentation sources before reading their contents. + pub fn sources(&self, sources: &Stream) -> Stream { + if !self.settings.enabled { + return sources.clone(); + } + let settings = self.settings.clone(); + sources.filter_map(move |id: &Id, value: &T| { + if id.context() == settings.docs { + let path = id.location().parse::()?; + // Hidden files can configure other plugins, but MkDocs does + // not include them in the Files collection being filtered. + if !path.is_hidden() && !settings.includes(path.as_str())? { + return Ok(None); + } + } + Ok::<_, anyhow::Error>(Some(value.clone())) + }) + } + + /// Filters effective assets, including files supplied by the theme. + pub fn resources( + &self, resources: &Stream, + ) -> Stream { + if !self.settings.enabled { + return resources.clone(); + } + let settings = self.settings.clone(); + resources.filter_map(move |resource: &Resource| { + Ok::<_, anyhow::Error>( + settings + .includes(resource.source_path.as_str())? + .then(|| resource.clone()), + ) + }) + } +} + +impl Settings { + /// Returns whether no configured pattern excludes the source path. + fn includes(&self, path: &str) -> Result { + if self.globs.iter().any(|pattern| pattern.matches(path)) { + return Ok(false); + } + if self.regexes.is_empty() { + return Ok(true); + } + Python::attach(|py| { + for pattern in &self.regexes { + if !pattern.bind(py).call_method1("match", (path,))?.is_none() { + return Ok(false); + } + } + Ok(true) + }) + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/exclude/pattern.rs b/crates/zensical/src/compat/mkdocs/plugin/exclude/pattern.rs new file mode 100644 index 0000000..3db4e1c --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/exclude/pattern.rs @@ -0,0 +1,109 @@ +// 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. + +// ---------------------------------------------------------------------------- + +//! Glob matching for source file exclusion. + +use anyhow::{Context, Result}; +use globset::{GlobBuilder, GlobMatcher}; + +/// One compiled, case-sensitive source path pattern. +#[derive(Debug)] +pub struct Pattern { + matcher: GlobMatcher, +} + +impl Pattern { + /// Compiles a glob whose wildcards can cross directory separators. + pub fn compile(source: &str) -> Result { + let matcher = GlobBuilder::new(source) + .literal_separator(false) + .backslash_escape(true) + .build() + .with_context(|| format!("invalid exclude glob {source:?}"))? + .compile_matcher(); + Ok(Self { matcher }) + } + + /// Matches the complete source path relative to its documentation root. + pub fn matches(&self, path: &str) -> bool { + self.matcher.is_match(path) + } +} + +#[cfg(test)] +mod tests { + use super::Pattern; + + #[test] + fn wildcards_cross_directories_and_preserve_case() { + let pattern = Pattern::compile("*.tmp").unwrap(); + + assert!(pattern.matches("file.tmp")); + assert!(pattern.matches("guide/assets/file.tmp")); + assert!(!pattern.matches("guide/file.TMP")); + assert!(!pattern.matches("guide/file.tmp.md")); + + // Patterns remain rooted even though wildcards can span separators. + let pattern = Pattern::compile("drafts/*").unwrap(); + + assert!(pattern.matches("drafts/page.md")); + assert!(pattern.matches("drafts/nested/page.md")); + assert!(!pattern.matches("guide/drafts/page.md")); + } + + #[test] + fn supports_character_classes_and_single_character_wildcards() { + let pattern = Pattern::compile("page[!0-3]?.md").unwrap(); + + assert!(pattern.matches("page4a.md")); + assert!(!pattern.matches("page2a.md")); + assert!(!pattern.matches("page4ab.md")); + } + + #[test] + fn uses_globset_recursive_patterns_and_alternatives() { + let pattern = Pattern::compile("drafts/**/{old,new}.md").unwrap(); + + assert!(pattern.matches("drafts/old.md")); + assert!(pattern.matches("drafts/nested/new.md")); + assert!(!pattern.matches("drafts/current.md")); + } + + #[test] + fn backslashes_escape_glob_characters() { + let pattern = Pattern::compile(r"file\[draft\].md").unwrap(); + + assert!(pattern.matches("file[draft].md")); + assert!(!pattern.matches("filed.md")); + } + + #[test] + fn invalid_patterns_identify_the_option_and_expression() { + let error = Pattern::compile("[bad").unwrap_err().to_string(); + + assert!(error.contains("invalid exclude glob")); + assert!(error.contains("[bad")); + } +} diff --git a/crates/zensical/src/config/plugins.rs b/crates/zensical/src/config/plugins.rs index 42cf5df..78b6473 100644 --- a/crates/zensical/src/config/plugins.rs +++ b/crates/zensical/src/config/plugins.rs @@ -76,6 +76,8 @@ pub struct Plugins { pub search: SearchPlugin, /// Material meta plugin. pub meta: MetaPlugin, + /// File exclusion plugin. + pub exclude: ExcludePlugin, /// Redirects plugin. pub redirects: RedirectsPlugin, /// Minify plugin. @@ -232,6 +234,28 @@ pub struct MetaPluginConfig { // ---------------------------------------------------------------------------- +/// File exclusion plugin. +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct ExcludePlugin { + /// Plugin configuration. + pub config: ExcludePluginConfig, +} + +/// File exclusion plugin configuration. +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct ExcludePluginConfig { + /// Whether file exclusion is enabled. + pub enabled: bool, + /// Glob patterns matched against source paths. + pub glob: Vec, + /// Python regular expressions matched at the start of source paths. + pub regex: Vec, +} + +// ---------------------------------------------------------------------------- + /// Redirects plugin. #[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 563701d..948c601 100644 --- a/crates/zensical/src/lib.rs +++ b/crates/zensical/src/lib.rs @@ -230,7 +230,8 @@ fn run(config_file: &PathBuf, mode: Mode) -> PyResult { strict, matches!(&mode, Mode::Serve(_, _)), meta.clone(), - ); + ) + .map_err(|error| PyRuntimeError::new_err(format!("{error:#}")))?; let mut runner = workflow .runner() .map_err(|err| PyRuntimeError::new_err(err.to_string()))?; diff --git a/crates/zensical/src/workflow.rs b/crates/zensical/src/workflow.rs index 9468e12..b9e9efd 100644 --- a/crates/zensical/src/workflow.rs +++ b/crates/zensical/src/workflow.rs @@ -48,7 +48,7 @@ use crate::compat::mkdocs::plugin::autorefs::UnresolvedAutorefs; use crate::compat::mkdocs::{ html, plugin::{ - self, autorefs, awesome_nav, blog, literate_nav, meta, minify, + self, autorefs, awesome_nav, blog, exclude, literate_nav, meta, minify, mkdocstrings, redirects, rss, search, tags, }, resource, @@ -99,6 +99,8 @@ struct Main { serve: bool, /// Metadata pipeline shared with source admission. meta: meta::Meta, + /// File exclusion shared by documentation and resource processing. + exclude: exclude::Exclude, } /// File input enriched with immutable facts for the current revision. @@ -256,7 +258,7 @@ impl Main { /// Initializes the module. #[allow(clippy::too_many_lines)] fn setup(&self, ctx: &mut Builder) { - let files = ctx.input::(); + let files = self.exclude.sources(&ctx.input::()); let configuration = ctx.input::(); let minify = minify::Minify::new(&self.config); @@ -264,6 +266,7 @@ impl Main { let sources = files.map(|input: &Input| input.source.clone()); let resources = resource::Resources::new(&self.config, &self.meta) .setup(resource::Dependencies { sources: &sources }); + let resources = self.exclude.resources(&resources); let assets = minify.setup(minify::Dependencies { resources: &resources }); let documents = read_documents(&self.config, &files); @@ -998,16 +1001,18 @@ fn render_pages( /// Creates a workflow for the given config. pub fn create_workflow( config: &Config, strict: bool, serve: bool, meta: meta::Meta, -) -> Workflow { - Workflow::build(|workflow| { +) -> anyhow::Result> { + let exclude = exclude::Exclude::new(config)?; + Ok(Workflow::build(|workflow| { Main { config: config.clone(), strict, serve, meta, + exclude, } .setup(workflow); - }) + })) } // ---------------------------------------------------------------------------- diff --git a/python/tests/integration/test_api_generators.py b/python/tests/integration/test_api_generators.py index 259b033..9d3ffa0 100644 --- a/python/tests/integration/test_api_generators.py +++ b/python/tests/integration/test_api_generators.py @@ -140,6 +140,32 @@ def test_generates_api_pages_and_navigation( ) +@pytest.mark.parametrize( + ("plugin", "options", "prefix"), + [ + ("mkdocs-autoapi", {"autoapi_dir": "src"}, "autoapi"), + ("api-autonav", {"modules": ["src/sample"]}, "reference"), + ], +) +def test_exclude_filters_generated_api_pages( + tmp_path: Path, plugin: str, options: dict, prefix: str +) -> None: + config = project( + tmp_path, + plugin, + options, + extra_plugins=[{"exclude": {"glob": f"{prefix}/sample/public.md"}}], + ) + + build(config, strict=False) + + # Generated Markdown follows the same exclusion rules as physical pages. + assert not (tmp_path / f"site/{prefix}/sample/public/index.html").exists() + assert (tmp_path / f"site/{prefix}/sample/index.html").exists() + search = (tmp_path / "site/search.json").read_text() + assert "Public module documentation" not in search + + def test_autoapi_patterns_stubs_keep_and_manual_navigation( tmp_path: Path, ) -> None: diff --git a/python/tests/integration/test_exclude.py b/python/tests/integration/test_exclude.py new file mode 100644 index 0000000..242de2c --- /dev/null +++ b/python/tests/integration/test_exclude.py @@ -0,0 +1,349 @@ +# 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. + +"""Integration tests for native mkdocs-exclude compatibility.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import time +from typing import TYPE_CHECKING, Any + +import pytest +import yaml + +import zensical + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + +def _project( + root: Path, options: dict[str, Any], *, toml: bool = False +) -> Path: + docs = root / "docs" + docs.mkdir() + (docs / "index.md").write_text("# Home\n", encoding="utf-8") + overrides = root / "overrides" + overrides.mkdir() + if toml: + config = root / "zensical.toml" + config.write_text( + "[project]\n" + 'site_name = "Exclude"\n' + 'site_url = "https://example.com/"\n' + 'extra_templates = ["export.html"]\n' + "[project.theme]\n" + 'custom_dir = "overrides"\n' + "[project.plugins.exclude]\n" + + "\n".join( + f"{key} = {json.dumps(value)}" for key, value in options.items() + ), + encoding="utf-8", + ) + else: + config = root / "mkdocs.yml" + config.write_text( + yaml.safe_dump( + { + "site_name": "Exclude", + "site_url": "https://example.com/", + "dev_addr": "127.0.0.1:0", + "theme": {"custom_dir": "overrides"}, + "extra_templates": ["export.html"], + "plugins": [{"exclude": options}], + } + ), + encoding="utf-8", + ) + return config + + +@pytest.mark.parametrize("toml", [False, True]) +def test_excludes_pages_resources_and_extra_templates( + tmp_path: Path, toml: bool +) -> None: + config = _project( + tmp_path, + { + "glob": ["drafts/*", "*/draft.md", "*.tmp", "export.html"], + "regex": [r".*\.bin$", "private-"], + }, + toml=toml, + ) + docs = tmp_path / "docs" + for name, content in { + "guide/keep.md": "# Kept guide\n", + "guide/private-note.md": "# Kept nested note\n", + "guide/draft.md": "---\ninvalid: [\n---\n", + "drafts/nested/page.md": "# Excluded subtree\n", + "private-note.md": "# Excluded root note\n", + "root.tmp": "temporary", + "files/archive.tmp": "temporary", + "files/archive.TMP": "case matters", + "files/archive.bin": "binary", + "export.html": "{{ must_not_render() }}", + }.items(): + path = docs / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + # Exclusion covers theme assets and prevents an excluded docs override + # from exposing the theme's version of the same file. + (tmp_path / "overrides/root.tmp").write_text( + "theme fallback", encoding="utf-8" + ) + (tmp_path / "overrides/theme.tmp").write_text( + "theme temporary", encoding="utf-8" + ) + (tmp_path / "overrides/theme.txt").write_text( + "theme asset", encoding="utf-8" + ) + + zensical.build(str(config), {"clean": False, "strict": True}) + + site = tmp_path / "site" + for name in ( + "guide/draft/index.html", + "drafts/nested/page/index.html", + "private-note/index.html", + "root.tmp", + "files/archive.tmp", + "files/archive.bin", + "export.html", + "theme.tmp", + ): + assert not (site / name).exists(), name + for name in ( + "index.html", + "guide/keep/index.html", + "guide/private-note/index.html", + "files/archive.TMP", + "theme.txt", + ): + assert (site / name).exists(), name + + # Excluded pages do not reach navigation, the search index or the sitemap. + home = (site / "index.html").read_text() + search = (site / "search.json").read_text() + sitemap = (site / "sitemap.xml").read_text() + for content in (home, search, sitemap): + assert "drafts/nested/page" not in content + assert "guide/draft" not in content + assert "guide/keep/" in content + assert "Excluded root note" not in home + assert "Excluded root note" not in search + + +def test_regexes_preserve_python_match_semantics(tmp_path: Path) -> None: + config = _project( + tmp_path, + { + "regex": [ + r"(?i)draft\.md$", + r"guide/(?!public\.)", + r"([^/]+)/\1\.md$", + ], + }, + ) + docs = tmp_path / "docs" + for name in ( + "DRAFT.md", + "guide/private.md", + "repeat/repeat.md", + "guide/public.md", + "nested/draft.md", + ): + path = docs / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("# Page\n", encoding="utf-8") + + zensical.build(str(config), {"clean": False, "strict": True}) + + site = tmp_path / "site" + for name in ("DRAFT", "guide/private", "repeat/repeat"): + assert not (site / name / "index.html").exists() + for name in ("guide/public", "nested/draft"): + assert (site / name / "index.html").exists() + + +@pytest.mark.parametrize( + "options", + [{}, {"glob": None, "regex": None}, {"enabled": False, "glob": "*.md"}], +) +def test_empty_or_disabled_plugin_keeps_pages( + tmp_path: Path, options: dict[str, Any] +) -> None: + config = _project(tmp_path, options) + + zensical.build(str(config), {"clean": False, "strict": True}) + + assert (tmp_path / "site/index.html").is_file() + + +@pytest.mark.parametrize(("pattern", "included"), [("", True), ([""], False)]) +def test_empty_regex_scalar_and_list_have_different_meanings( + tmp_path: Path, pattern: Any, included: bool +) -> None: + config = _project(tmp_path, {"regex": pattern}) + + zensical.build(str(config), {"clean": False, "strict": True}) + + assert (tmp_path / "site/index.html").is_file() is included + + +def test_reports_invalid_glob(tmp_path: Path) -> None: + config = _project(tmp_path, {"glob": "[bad"}) + + with pytest.raises(RuntimeError, match=r"invalid exclude glob.*\[bad"): + zensical.build(str(config), {"clean": False, "strict": True}) + + +def test_preserves_hidden_awesome_nav_configuration(tmp_path: Path) -> None: + config = _project(tmp_path, {"glob": ["*.yml", "draft.md"]}) + data = yaml.safe_load(config.read_text()) + data["plugins"].append("awesome-nav") + config.write_text(yaml.safe_dump(data), encoding="utf-8") + (tmp_path / "docs/.nav.yml").write_text( + "nav:\n - Custom home: index.md\n - '*'\n", encoding="utf-8" + ) + (tmp_path / "docs/draft.md").write_text( + "# Excluded draft\n", encoding="utf-8" + ) + (tmp_path / "docs/keep.md").write_text("# Visible page\n", encoding="utf-8") + + zensical.build(str(config), {"clean": False, "strict": True}) + + home = (tmp_path / "site/index.html").read_text() + assert "Custom home" in home + assert "Visible page" in home + assert "Excluded draft" not in home + assert not (tmp_path / "site/draft/index.html").exists() + + +def test_serve_filters_new_files_and_removes_renamed_outputs( + tmp_path: Path, +) -> None: + config = _project(tmp_path, {"glob": ["*.excluded.md", "*.tmp"]}) + page = tmp_path / "docs/moving.md" + asset = tmp_path / "docs/moving.txt" + page.write_text("# Moving page\n", encoding="utf-8") + asset.write_text("moving asset", encoding="utf-8") + site = tmp_path / "site" + + with (tmp_path / "serve.log").open("w+", encoding="utf-8") as log: + process = subprocess.Popen( # noqa: S603 + [ + sys.executable, + "-m", + "zensical", + "serve", + "--config-file", + str(config), + ], + cwd=tmp_path, + stdout=log, + stderr=subprocess.STDOUT, + ) + + def wait_for(condition: Callable[[], bool]) -> None: + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + if condition(): + return + if process.poll() is not None: + break + time.sleep(0.02) + log.flush() + log.seek(0) + raise AssertionError( + f"serve did not update excluded files: {log.read()}" + ) + + try: + wait_for( + lambda: ( + (site / "moving/index.html").exists() + and (site / "moving.txt").exists() + ) + ) + + # Renaming into an excluded path retracts the existing outputs. + page.rename(tmp_path / "docs/moving.excluded.md") + asset.rename(tmp_path / "docs/moving.tmp") + wait_for( + lambda: ( + not (site / "moving/index.html").exists() + and not (site / "moving.txt").exists() + ) + ) + + # New excluded inputs must stay unpublished after another revision. + (tmp_path / "docs/new.excluded.md").write_text( + "# Excluded new page\n", encoding="utf-8" + ) + (tmp_path / "docs/new.tmp").write_text( + "excluded new asset", encoding="utf-8" + ) + (tmp_path / "docs/index.md").write_text( + "# Updated home\n", encoding="utf-8" + ) + wait_for( + lambda: "Updated home" in (site / "index.html").read_text() + ) + + for name in ( + "moving.excluded/index.html", + "moving.tmp", + "new.excluded/index.html", + "new.tmp", + ): + assert not (site / name).exists() + assert "Moving page" not in (site / "search.json").read_text() + + # Reloading plugin configuration makes the retained files visible. + data = yaml.safe_load(config.read_text()) + data["plugins"][0]["exclude"]["enabled"] = False + config.write_text(yaml.safe_dump(data), encoding="utf-8") + wait_for( + lambda: ( + (site / "moving.excluded/index.html").exists() + and (site / "new.excluded/index.html").exists() + ) + ) + wait_for( + lambda: ( + (site / "moving.tmp").exists() + and (site / "new.tmp").exists() + ) + ) + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) diff --git a/python/tests/unit/test_plugin_config.py b/python/tests/unit/test_plugin_config.py index 8f8001c..f5c3d41 100644 --- a/python/tests/unit/test_plugin_config.py +++ b/python/tests/unit/test_plugin_config.py @@ -33,6 +33,7 @@ from zensical.config import ConfigurationError PYTHON_PLUGINS = ( "search", "meta", + "exclude", "redirects", "mkdocs-autoapi", "api-autonav", @@ -112,6 +113,7 @@ def test_preserves_plugin_presence_semantics() -> None: assert plugins["search"]["config"]["enabled"] is True for name in ( "meta", + "exclude", "redirects", "minify", "literate_nav", @@ -125,6 +127,71 @@ def test_preserves_plugin_presence_semantics() -> None: assert not set(SHIM_PLUGINS) & set(plugins) +@pytest.mark.parametrize( + "entry", ["exclude", {"exclude": None}, {"exclude": {}}] +) +def test_exclude_presence_enables_empty_defaults(entry: Any) -> None: + plugins = _convert_plugins([entry]) + + assert plugins["exclude"]["config"] == { + "enabled": True, + "glob": [], + "regex": [], + } + + +@pytest.mark.parametrize("option", ["glob", "regex"]) +@pytest.mark.parametrize( + ("value", "expected"), + [ + (None, []), + ("", []), + ([], []), + ("drafts/.*", ["drafts/.*"]), + (["", "drafts/.*"], ["", "drafts/.*"]), + ], +) +def test_exclude_normalizes_pattern_options( + option: str, value: Any, expected: list[str] +) -> None: + plugins = _convert_plugins({"exclude": {option: value}}) + + # An empty scalar disables an option; an empty expression in a list stays. + assert plugins["exclude"]["config"][option] == expected + + +@pytest.mark.parametrize("option", ["glob", "regex"]) +@pytest.mark.parametrize("value", [False, 1, {}, [None], [42], [["*.md"]]]) +def test_exclude_rejects_invalid_pattern_types(option: str, value: Any) -> None: + with pytest.raises( + ConfigurationError, + match=rf"exclude {option} must be a string or a list of strings", + ): + _convert_plugins({"exclude": {option: value}}) + + +@pytest.mark.parametrize("pattern", ["[", "(?P None: + with pytest.raises( + ConfigurationError, match="exclude invalid regular expression" + ): + _convert_plugins({"exclude": {"regex": pattern}}) + + +def test_exclude_accepts_python_regular_expressions_and_disabling() -> None: + patterns = [r"(?i)drafts/", r"(?!public/).*\.md$", r"(.+)/\1\.md$"] + + plugins = _convert_plugins( + {"exclude": {"enabled": False, "regex": patterns}} + ) + + assert plugins["exclude"]["config"] == { + "enabled": False, + "glob": [], + "regex": patterns, + } + + def test_rss_instances_keep_defaults_and_validate_output_names() -> None: plugins = _convert_plugins( [ @@ -594,6 +661,7 @@ def test_normalizes_null_autorefs_settings() -> None: ("search", {"enabled": "yes"}, "enabled must be a boolean"), ("search", {"separator": 42}, "separator must be a string"), ("meta", {"meta_file": 42}, "meta_file must be a string"), + ("exclude", {"enabled": "yes"}, "enabled must be a boolean"), ( "redirects", {"redirect_maps": {"old.md": 42}}, diff --git a/python/zensical/config.py b/python/zensical/config.py index a266147..5ade825 100644 --- a/python/zensical/config.py +++ b/python/zensical/config.py @@ -126,6 +126,7 @@ _PLUGIN_UNSUPPORTED_OPTIONS = { "breakless_lists", "title_from_first_bold", ), + "exclude": (), "gh-admonitions": (), "glightbox": ( "touchNavigation", @@ -1811,6 +1812,34 @@ def _convert_plugins(value: Any, config: dict) -> dict: _validate_string_options("meta", meta, ("meta_file",)) plugins["meta"] = meta + # Normalize file exclusion without importing or executing the plugin. + present = "exclude" in plugins + exclude = plugins.pop("exclude", {}) + _reject_unknown_options("exclude", exclude, {"enabled", "glob", "regex"}) + set_default(exclude, "enabled", present) + _validate_boolean_options("exclude", exclude, ("enabled",)) + for name in ("glob", "regex"): + patterns = exclude.get(name) + if patterns is None or patterns == "": + patterns = [] + elif isinstance(patterns, str): + patterns = [patterns] + if not isinstance(patterns, list) or not all( + isinstance(pattern, str) for pattern in patterns + ): + raise ConfigurationError( + f"exclude {name} must be a string or a list of strings" + ) + exclude[name] = patterns + try: + for pattern in exclude["regex"]: + re.compile(pattern) + except re.error as error: + raise ConfigurationError( + f"exclude invalid regular expression {pattern!r}: {error}" + ) from error + plugins["exclude"] = exclude + # Normalize redirects into typed native configuration. The enabled flag is # internal; plugin presence retains MkDocs' activation semantics. The # configuration is always materialized for Rust.