feature: add minify 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 afe1ecb259
commit cbdd6ce217
18 changed files with 3656 additions and 128 deletions
+319
View File
@@ -0,0 +1,319 @@
# Copyright (c) 2025-2026 Zensical and contributors
# SPDX-License-Identifier: MIT
# All contributions are certified under the DCO
"""Integration tests for MkDocs-compatible HTML minification."""
from __future__ import annotations
import hashlib
import re
import subprocess
import sys
import time
from typing import TYPE_CHECKING
import pytest
import zensical
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
def _project(root: Path, *, minify_html: bool = True) -> Path:
"""Create a project exercising HTML and inline language minification."""
docs = root / "docs"
docs.mkdir()
(docs / "index.md").write_text(
"""\
# Minify
<!-- remove this comment -->
<div pre> preserved text </div>
<script>
const value = { nested: { answer: 42 } };
window.result = value?.nested?.answer ?? 0;
</script>
<script type="application/ld+json">
{ "preserved": true }
</script>
<style>
@media screen and (min-width: 45em) {
.probe { color: rgb(255, 0, 0); }
}
</style>
""",
encoding="utf-8",
)
config = root / "mkdocs.yml"
config.write_text(
f"""\
site_name: Minify
plugins:
- minify:
minify_html: {str(minify_html).lower()}
minify_inline_js: true
minify_inline_css: true
htmlmin_opts:
remove_comments: true
""",
encoding="utf-8",
)
return config
def test_minifies_final_html_and_inline_languages(tmp_path: Path) -> None:
"""HTML runs last while script/style use parser-backed minifiers."""
zensical.build(
str(_project(tmp_path)), {"clean": False, "strict": False}
)
output = (tmp_path / "site" / "index.html").read_text()
assert "remove this comment" not in output
assert "<div> preserved text </div>" in output
assert "const value={nested:{answer:42}};" in output
assert "value?.nested?.answer??0" in output
assert "screen and (min-width:45em)" in output
assert "screen and(" not in output
assert '{ "preserved": true }' in output
def test_inline_minification_does_not_require_html_minification(
tmp_path: Path,
) -> None:
"""Inline-only mode retains the surrounding rendered HTML byte shape."""
zensical.build(
str(_project(tmp_path, minify_html=False)),
{"clean": False, "strict": False},
)
output = (tmp_path / "site" / "index.html").read_text()
assert "<!-- remove this comment -->" in output
assert '<script type="application/ld+json">' in output
assert "const value={nested:{answer:42}};" in output
assert "screen and (min-width:45em)" in output
def _asset_project(
root: Path, *, minify: bool, cache_safe: bool
) -> Path:
"""Create a project covering exact, glob, and configured assets."""
docs = root / "docs"
(docs / "assets" / "nested").mkdir(parents=True)
(docs / "styles").mkdir()
(docs / "index.md").write_text("# Assets\n", encoding="utf-8")
(docs / "assets" / "app.js").write_text(
"const value = { answer: 42 };\nconsole.log(value.answer);\n",
encoding="utf-8",
)
(docs / "assets" / "nested" / "worker.js").write_text(
"self.addEventListener('message', (event) => { "
"postMessage(event.data); });\n",
encoding="utf-8",
)
(docs / "styles" / "site.css").write_text(
".card { color: rgb(255, 0, 0); padding: 0px 0px 0px 0px; }\n",
encoding="utf-8",
)
(docs / "assets" / "unchanged.txt").write_text(
"copied unchanged\n", encoding="utf-8"
)
config = root / "mkdocs.yml"
config.write_text(
f"""\
site_name: Asset minify
extra_javascript:
- path: assets/app.js
type: module
defer: true
extra_css:
- styles/site.css
plugins:
- minify:
minify_js: {str(minify).lower()}
minify_css: {str(minify).lower()}
cache_safe: {str(cache_safe).lower()}
js_files:
- assets/app.js
- assets/**/*.js
css_files: styles/*.css
""",
encoding="utf-8",
)
return config
def _single_asset(root: Path, pattern: str) -> Path:
"""Return the single generated asset matching a glob."""
paths = list((root / "site").glob(pattern))
assert len(paths) == 1, paths
return paths[0]
def test_minifies_hashes_and_rewrites_external_assets(tmp_path: Path) -> None:
"""Claimed assets are emitted once and projected into templates."""
zensical.build(
str(_asset_project(tmp_path, minify=True, cache_safe=True)),
{"clean": False, "strict": False},
)
script = _single_asset(tmp_path, "assets/app.*.min.js")
stylesheet = _single_asset(tmp_path, "styles/site.*.min.css")
worker = _single_asset(tmp_path, "assets/nested/worker.*.min.js")
assert not (tmp_path / "site" / "assets" / "app.js").exists()
assert not (tmp_path / "site" / "styles" / "site.css").exists()
assert "const value={answer:42};" in script.read_text()
assert "color:red" in stylesheet.read_text()
assert "postMessage(event.data)" in worker.read_text()
for path in (script, stylesheet, worker):
digest = hashlib.sha384(path.read_bytes()).hexdigest()[:6]
assert f".{digest}.min." in path.name
html = (tmp_path / "site" / "index.html").read_text()
assert f'src="./{script.relative_to(tmp_path / "site")}"' in html
assert f'href="./{stylesheet.relative_to(tmp_path / "site")}"' in html
assert 'type="module"' in html
assert re.search(r"<script[^>]+ defer(?:=| |>)", html)
assert (tmp_path / "site" / "assets" / "unchanged.txt").read_text() == (
"copied unchanged\n"
)
def test_cache_safe_can_rename_without_minifying(tmp_path: Path) -> None:
"""Cache-safe naming is independent from content minification."""
zensical.build(
str(_asset_project(tmp_path, minify=False, cache_safe=True)),
{"clean": False, "strict": False},
)
script = _single_asset(tmp_path, "assets/app.*.js")
assert ".min.js" not in script.name
assert "const value = { answer: 42 };" in script.read_text()
digest = hashlib.sha384(script.read_bytes()).hexdigest()[:6]
assert script.name == f"app.{digest}.js"
def test_external_minification_can_keep_original_names(tmp_path: Path) -> None:
"""Minification without cache-safe naming emits the upstream .min form."""
zensical.build(
str(_asset_project(tmp_path, minify=True, cache_safe=False)),
{"clean": False, "strict": False},
)
script = tmp_path / "site" / "assets" / "app.min.js"
assert script.is_file()
assert "const value={answer:42};" in script.read_text()
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"
docs.mkdir()
(docs / "index.md").write_text("# Missing asset\n", encoding="utf-8")
config = tmp_path / "mkdocs.yml"
config.write_text(
"""\
site_name: Missing asset
plugins:
- minify:
minify_js: true
js_files: assets/missing.js
""",
encoding="utf-8",
)
with pytest.raises(
RuntimeError,
match=r"selected asset does not exist: assets/missing\.js",
):
zensical.build(str(config), {"clean": False, "strict": False})
def test_files_for_disabled_asset_kind_are_ignored(tmp_path: Path) -> None:
"""A selector is only active with its minifier or cache-safe mode."""
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# CSS only\n", encoding="utf-8")
(docs / "site.css").write_text(
".card { color: rgb(255, 0, 0); }\n", encoding="utf-8"
)
config = tmp_path / "mkdocs.yml"
config.write_text(
"""\
site_name: CSS only
plugins:
- minify:
minify_css: true
js_files: assets/missing.js
css_files: site.css
""",
encoding="utf-8",
)
zensical.build(str(config), {"clean": False, "strict": False})
assert (tmp_path / "site" / "site.min.css").read_text() == (
".card{color:red}"
)
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)
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,
)
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)
raise AssertionError("serve did not produce the expected asset state")
try:
outputs = tmp_path / "site" / "assets"
wait_for(lambda: len(list(outputs.glob("app.*.min.js"))) == 1)
previous = next(outputs.glob("app.*.min.js"))
source = tmp_path / "docs" / "assets" / "app.js"
source.write_text(
"const value = { answer: 43 };\nconsole.log(value.answer);\n",
encoding="utf-8",
)
wait_for(
lambda: (
len(list(outputs.glob("app.*.min.js"))) == 1
and not previous.exists()
and previous.name
not in (tmp_path / "site" / "index.html").read_text()
)
)
finally:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
+49
View File
@@ -200,6 +200,55 @@ class TestPluginShimming:
"redirect_maps": {},
}
def test_minify_plugin_is_normalized(self, tmp_path: Path) -> None:
config = self._parse_yaml(
tmp_path,
plugins={
"minify": {
"minify_html": True,
"minify_inline_js": True,
"js_files": "assets/app.js",
"css_files": ["assets/app.css"],
"htmlmin_opts": {
"remove_comments": True,
"pre_tags": ["pre", "textarea", "code"],
},
}
},
)
assert config["plugins"]["minify"]["config"] == {
"enabled": True,
"minify_html": True,
"minify_js": False,
"minify_css": False,
"minify_inline_js": True,
"minify_inline_css": False,
"js_files": ["assets/app.js"],
"css_files": ["assets/app.css"],
"htmlmin_opts": {
"remove_comments": True,
"remove_empty_space": False,
"remove_all_empty_space": False,
"reduce_empty_attributes": True,
"reduce_boolean_attributes": False,
"remove_optional_attribute_quotes": True,
"convert_charrefs": True,
"keep_pre": False,
"pre_tags": ["pre", "textarea", "code"],
"pre_attr": "pre",
},
"cache_safe": False,
}
def test_minify_plugin_is_disabled_by_default(
self, tmp_path: Path
) -> None:
config = self._parse_yaml(tmp_path, plugins=[])
plugin = config["plugins"]["minify"]["config"]
assert plugin["enabled"] is False
assert plugin["minify_html"] is False
assert plugin["htmlmin_opts"]["pre_tags"] == ["pre", "textarea"]
def test_mike_plugin_defaults_with_versioned_build(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
+44
View File
@@ -1292,6 +1292,50 @@ def _convert_plugins(value: Any, config: dict) -> dict:
set_default(redirects, "redirect_maps", {}, dict)
plugins["redirects"] = redirects
# Normalize the complete mkdocs-minify-plugin configuration surface. Asset
# settings are retained for the dedicated copy/output stage; the inline
# switches are Zensical extensions handled by the final HTML pass.
if "minify" not in plugins:
minify = {"enabled": False}
else:
minify = dict(plugins["minify"] or {})
set_default(minify, "enabled", True, bool)
set_default(minify, "minify_html", False, bool)
set_default(minify, "minify_js", False, bool)
set_default(minify, "minify_css", False, bool)
set_default(minify, "minify_inline_js", False, bool)
set_default(minify, "minify_inline_css", False, bool)
set_default(minify, "cache_safe", False, bool)
for name in ("js_files", "css_files"):
files = minify.get(name)
if files is None:
minify[name] = []
elif isinstance(files, str):
minify[name] = [files]
elif isinstance(files, list):
minify[name] = files
else:
minify[name] = []
htmlmin_opts = minify.get("htmlmin_opts")
if not isinstance(htmlmin_opts, dict):
htmlmin_opts = {}
htmlmin_opts = dict(htmlmin_opts)
set_default(htmlmin_opts, "remove_comments", False, bool)
set_default(htmlmin_opts, "remove_empty_space", False, bool)
set_default(htmlmin_opts, "remove_all_empty_space", False, bool)
set_default(htmlmin_opts, "reduce_empty_attributes", True, bool)
set_default(htmlmin_opts, "reduce_boolean_attributes", False, bool)
set_default(htmlmin_opts, "remove_optional_attribute_quotes", True, bool)
set_default(htmlmin_opts, "convert_charrefs", True, bool)
set_default(htmlmin_opts, "keep_pre", False, bool)
set_default(htmlmin_opts, "pre_tags", ["pre", "textarea"], list)
set_default(htmlmin_opts, "pre_attr", "pre", str)
minify["htmlmin_opts"] = htmlmin_opts
plugins["minify"] = minify
# Define defaults for offline plugin
offline = set_default(plugins, "offline", {"enabled": False}, dict)
set_default(offline, "enabled", True, bool)