mirror of
https://github.com/zensical/zensical.git
synced 2026-10-08 05:41:24 +00:00
feature: support mkdocs-exclude plugin
Signed-off-by: Timothée Mazzucotelli <dev@pawamoy.fr>
This commit is contained in:
1 parent
b3241494be
commit
4c2b8e2a07
10 files changed
+767
-6
No files matched your search
+26
@@ -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
@@ -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
@@ -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}},
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user