refactor: reconcile MkDocs plugin infrastructure

Signed-off-by: squidfunk <martin.donath@squidfunk.com>
This commit is contained in:
squidfunk
2026-09-01 18:36:01 +02:00
parent cbdd6ce217
commit 86646a4aaf
77 changed files with 6297 additions and 3512 deletions
+21
View File
@@ -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
# ---------------------------------------------------------------------------
+193
View File
@@ -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()
+116
View File
@@ -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)
+90
View File
@@ -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()
+70
View File
@@ -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"
)
+9 -1
View File
@@ -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
+2
View File
@@ -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(