mirror of
https://github.com/zensical/zensical.git
synced 2026-09-24 15:25:40 +00:00
refactor: reconcile MkDocs plugin infrastructure
Signed-off-by: squidfunk <martin.donath@squidfunk.com>
This commit is contained in:
+21
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Vendored
+2
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user