feature: add redirects MkDocs plugin replacement

Signed-off-by: squidfunk <martin.donath@squidfunk.com>
This commit is contained in:
squidfunk
2026-09-01 18:36:00 +02:00
parent 8dc4a80a4e
commit afe1ecb259
8 changed files with 881 additions and 120 deletions
+165
View File
@@ -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)
+19
View File
@@ -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:
+10
View File
@@ -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)