feature: support mkdocs-exclude plugin

Signed-off-by: Timothée Mazzucotelli <dev@pawamoy.fr>
This commit is contained in:
Timothée Mazzucotelli authored and GitHub committed 2026-09-29 08:46:32 +00:00
1 parent b3241494be
commit 4c2b8e2a07
10 files changed
+767 -6

No files matched your search

+26
View File
@@ -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:
+349
View File
@@ -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)
+68
View File
@@ -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<bad", "(?<=a*)b"])
def test_exclude_rejects_invalid_regular_expressions(pattern: str) -> 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}},
+29
View File
@@ -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.