mirror of
https://github.com/zensical/zensical.git
synced 2026-09-24 15:25:40 +00:00
feature: add redirects MkDocs plugin replacement
Signed-off-by: squidfunk <martin.donath@squidfunk.com>
This commit is contained in:
+165
@@ -0,0 +1,165 @@
|
||||
# Copyright (c) 2025-2026 Zensical and contributors
|
||||
|
||||
# SPDX-License-Identifier: MIT
|
||||
# All contributions are certified under the DCO
|
||||
|
||||
"""Integration tests for MkDocs-compatible redirect artifacts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
import zensical
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_BUILD_OPTIONS: dict[str, Any] = {"clean": False, "strict": False}
|
||||
|
||||
|
||||
def _write_project(root: Path, redirect_maps: str) -> Path:
|
||||
"""Create a small project with internal and external redirect targets."""
|
||||
docs = root / "docs"
|
||||
(docs / "guide").mkdir(parents=True)
|
||||
(docs / "index.md").write_text("# Home\n", encoding="utf-8")
|
||||
(docs / "new.md").write_text("# New\n", encoding="utf-8")
|
||||
(docs / "guide" / "topic.md").write_text(
|
||||
"# Topic\n\n## Details\n", encoding="utf-8"
|
||||
)
|
||||
config = root / "mkdocs.yml"
|
||||
config.write_text(
|
||||
f"""\
|
||||
site_name: Redirects
|
||||
plugins:
|
||||
- redirects:
|
||||
redirect_maps:
|
||||
{redirect_maps}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def test_redirects_generate_mkdocs_compatible_artifacts(tmp_path: Path) -> None:
|
||||
"""Internal, fragment, and external targets use the upstream paths."""
|
||||
config = _write_project(
|
||||
tmp_path,
|
||||
"""\
|
||||
old.md: new.md
|
||||
legacy/deep.md: guide/topic.md#details
|
||||
external.md: https://example.com/new?q=1
|
||||
""",
|
||||
)
|
||||
zensical.build(str(config), _BUILD_OPTIONS)
|
||||
|
||||
old = (tmp_path / "site" / "old" / "index.html").read_text()
|
||||
nested = (
|
||||
tmp_path / "site" / "legacy" / "deep" / "index.html"
|
||||
).read_text()
|
||||
external = (
|
||||
tmp_path / "site" / "external" / "index.html"
|
||||
).read_text()
|
||||
assert '<link rel="canonical" href="../new/">' in old
|
||||
assert (
|
||||
'<link rel="canonical" href="../../guide/topic/#details">' in nested
|
||||
)
|
||||
assert (
|
||||
'<link rel="canonical" href="https://example.com/new?q=1">'
|
||||
in external
|
||||
)
|
||||
assert "noindex" not in old
|
||||
|
||||
|
||||
def test_redirects_without_directory_urls_write_html_files(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""File-style URLs retain MkDocs' relative target calculation."""
|
||||
config = _write_project(tmp_path, " old.md: new.md\n")
|
||||
with config.open("a", encoding="utf-8") as file:
|
||||
file.write("use_directory_urls: false\n")
|
||||
zensical.build(str(config), _BUILD_OPTIONS)
|
||||
|
||||
old = (tmp_path / "site" / "old.html").read_text()
|
||||
assert '<link rel="canonical" href="new.html">' in old
|
||||
|
||||
|
||||
def test_missing_redirect_target_warns_and_strict_mode_fails(
|
||||
tmp_path: Path, capfd: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Missing targets are omitted and retain MkDocs strict semantics."""
|
||||
config = _write_project(tmp_path, " old.md: missing.md\n")
|
||||
zensical.build(str(config), _BUILD_OPTIONS)
|
||||
assert not (tmp_path / "site" / "old" / "index.html").exists()
|
||||
assert (
|
||||
"Redirect target 'missing.md' does not exist!"
|
||||
in capfd.readouterr().err
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="strict flag is set"):
|
||||
zensical.build(str(config), {"clean": False, "strict": True})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kind", "message"),
|
||||
[("page", "collides with a page"), ("asset", "documentation asset")],
|
||||
)
|
||||
def test_redirect_output_collisions_are_rejected(
|
||||
tmp_path: Path, kind: str, message: str
|
||||
) -> None:
|
||||
"""No concurrent producer may own a configured redirect output."""
|
||||
config = _write_project(tmp_path, " old.md: new.md\n")
|
||||
if kind == "page":
|
||||
(tmp_path / "docs" / "old.md").write_text("# Existing\n")
|
||||
else:
|
||||
asset = tmp_path / "docs" / "old" / "index.html"
|
||||
asset.parent.mkdir()
|
||||
asset.write_text("existing asset", encoding="utf-8")
|
||||
|
||||
with pytest.raises(RuntimeError, match=message):
|
||||
zensical.build(str(config), _BUILD_OPTIONS)
|
||||
|
||||
|
||||
def test_unsafe_redirect_source_is_rejected(tmp_path: Path) -> None:
|
||||
"""Redirect outputs cannot escape the site directory."""
|
||||
config = _write_project(tmp_path, " ../old.md: new.md\n")
|
||||
with pytest.raises(RuntimeError, match="not a safe relative path"):
|
||||
zensical.build(str(config), _BUILD_OPTIONS)
|
||||
|
||||
|
||||
def test_invalid_source_suffix_warns_but_still_generates(
|
||||
tmp_path: Path, capfd: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Upstream's source warning does not suppress a valid redirect."""
|
||||
config = _write_project(
|
||||
tmp_path, " old.txt: https://example.com/new\n"
|
||||
)
|
||||
zensical.build(str(config), _BUILD_OPTIONS)
|
||||
assert (tmp_path / "site" / "old" / "index.html").is_file()
|
||||
assert "'old.txt' is not a valid markdown file" in capfd.readouterr().err
|
||||
|
||||
|
||||
def test_duplicate_redirect_outputs_are_rejected(tmp_path: Path) -> None:
|
||||
"""Different source names cannot resolve to one generated file."""
|
||||
config = _write_project(
|
||||
tmp_path,
|
||||
"""\
|
||||
foo.md: new.md
|
||||
foo/index.md: new.md
|
||||
""",
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="configured more than once"):
|
||||
zensical.build(str(config), _BUILD_OPTIONS)
|
||||
|
||||
|
||||
def test_redirect_output_cannot_replace_a_static_template(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The upstream post-build overwrite becomes a deterministic error."""
|
||||
config = _write_project(tmp_path, " 404.md: new.md\n")
|
||||
with config.open("a", encoding="utf-8") as file:
|
||||
file.write("use_directory_urls: false\n")
|
||||
with pytest.raises(RuntimeError, match="rendered template"):
|
||||
zensical.build(str(config), _BUILD_OPTIONS)
|
||||
Vendored
+19
@@ -181,6 +181,25 @@ class TestPluginShimming:
|
||||
"meta_file": "defaults.yml",
|
||||
}
|
||||
|
||||
def test_redirects_plugin_is_normalized(self, tmp_path: Path) -> None:
|
||||
config = self._parse_yaml(
|
||||
tmp_path,
|
||||
plugins={"redirects": {"redirect_maps": {"old.md": "new.md"}}},
|
||||
)
|
||||
assert config["plugins"]["redirects"]["config"] == {
|
||||
"enabled": True,
|
||||
"redirect_maps": {"old.md": "new.md"},
|
||||
}
|
||||
|
||||
def test_redirects_plugin_is_disabled_by_default(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
config = self._parse_yaml(tmp_path, plugins=[])
|
||||
assert config["plugins"]["redirects"]["config"] == {
|
||||
"enabled": False,
|
||||
"redirect_maps": {},
|
||||
}
|
||||
|
||||
def test_mike_plugin_defaults_with_versioned_build(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
|
||||
@@ -1282,6 +1282,16 @@ def _convert_plugins(value: Any, config: dict) -> dict:
|
||||
set_default(meta, "meta_file", ".meta.yml", str)
|
||||
plugins["meta"] = meta
|
||||
|
||||
# Normalize redirects into typed native configuration. The enabled flag is
|
||||
# internal; plugin presence retains MkDocs' activation semantics.
|
||||
if "redirects" not in plugins:
|
||||
redirects = {"enabled": False, "redirect_maps": {}}
|
||||
else:
|
||||
redirects = dict(plugins["redirects"] or {})
|
||||
set_default(redirects, "enabled", True, bool)
|
||||
set_default(redirects, "redirect_maps", {}, dict)
|
||||
plugins["redirects"] = redirects
|
||||
|
||||
# Define defaults for offline plugin
|
||||
offline = set_default(plugins, "offline", {"enabled": False}, dict)
|
||||
set_default(offline, "enabled", True, bool)
|
||||
|
||||
Reference in New Issue
Block a user