chore: validate more configuration settings

Signed-off-by: Timothée Mazzucotelli <dev@pawamoy.fr>
This commit is contained in:
Timothée Mazzucotelli
2026-09-08 10:22:44 +00:00
committed by GitHub
parent 206b6b3f2b
commit 765473c572
3 changed files with 959 additions and 189 deletions
+62 -2
View File
@@ -145,6 +145,66 @@ def test_site_dir_docs_dir_cant_be_equal(tmp_path: Path) -> None:
parse_config(str(config_file))
# ---------------------------------------------------------------------------
# Markdown extensions
# ---------------------------------------------------------------------------
def test_markdown_extensions_are_normalized_without_mutating_input() -> None:
value = {
"abbr": None,
"pymdownx": {
"highlight": {"linenums": True},
"blocks": {"tab": None},
},
"zensical": {"extensions": {"preview": {}}},
}
extensions, configs = cfg_module._convert_markdown_extensions(value)
assert extensions == [
"toc",
"tables",
"abbr",
"pymdownx.highlight",
"pymdownx.blocks.tab",
"zensical.extensions.preview",
]
assert configs["abbr"] == {}
assert configs["pymdownx.highlight"] == {"linenums": True}
assert value["pymdownx"]["blocks"] == {"tab": None}
@pytest.mark.parametrize(
("value", "message"),
[
(42, "Markdown extensions must be a list or mapping"),
([42], "Markdown extensions must be strings or mappings"),
(
[{"abbr": {}, "toc": {}}],
"Markdown extension mappings must contain one entry",
),
({42: {}}, "Markdown extension names must be strings"),
({"abbr": []}, "Markdown extension configurations must be mappings"),
({"pymdownx": []}, "pymdownx Markdown extensions must be a mapping"),
(
{"pymdownx": {"blocks": []}},
"pymdownx.blocks Markdown extensions must be a mapping",
),
({"zensical": []}, "zensical Markdown extensions must be a mapping"),
(
{"zensical": {"extensions": []}},
"zensical.extensions Markdown extensions must be a mapping",
),
],
)
def test_markdown_extensions_reject_invalid_configuration(
value: Any, message: str
) -> None:
with pytest.raises(ConfigurationError, match=message):
cfg_module._convert_markdown_extensions(value)
# ---------------------------------------------------------------------------
# Plugins to Markdown extensions
# ---------------------------------------------------------------------------
@@ -328,8 +388,8 @@ class TestPluginShimming:
("plugin", "message"),
[
({"unknown": True}, "unknown awesome-nav option"),
({"filename": 42}, "filename must be a string"),
({"filename": ""}, "filename must not be empty"),
({"filename": 42}, "filename must be a non-empty string"),
({"filename": ""}, "filename must be a non-empty string"),
({"logs": "warning"}, "logs must be a mapping"),
({"logs": {"unknown": "info"}}, "unknown awesome-nav log"),
(
+302
View File
@@ -0,0 +1,302 @@
# 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 NON-INFRINGEMENT. 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
from typing import Any
import pytest
import zensical.config as config_module
from zensical.config import ConfigurationError
PYTHON_PLUGINS = (
"search",
"meta",
"redirects",
"minify",
"literate-nav",
"awesome-nav",
"offline",
"mike",
"autorefs",
"markdown-exec",
"mkdocstrings",
"glightbox",
"macros",
)
SHIM_PLUGINS = (
"autorefs",
"markdown-exec",
"mkdocstrings",
"glightbox",
"macros",
)
def _convert_plugins(value: Any) -> dict[str, dict[str, Any]]:
config = {"extra": {"polyfills": []}}
return config_module._convert_plugins(value, config)
def test_normalizes_defaults_and_complex_options() -> None:
plugins = _convert_plugins(
{
"minify": {
"js_files": "assets/app.js",
"htmlmin_opts": {"remove_comments": True},
},
"literate-nav": {
"markdown_extensions": [
"abbr",
{"toc": {"permalink": False}},
]
},
"awesome-nav": {"logs": {"no_matches": "error"}},
}
)
minify = plugins["minify"]["config"]
assert minify["enabled"] is True
assert minify["js_files"] == ["assets/app.js"]
assert minify["htmlmin_opts"]["remove_comments"] is True
assert minify["htmlmin_opts"]["pre_tags"] == ["pre", "textarea"]
literate_nav = plugins["literate_nav"]["config"]
assert literate_nav["markdown_extensions"] == ["abbr", "toc"]
assert literate_nav["mdx_configs"] == {
"abbr": {},
"toc": {"permalink": False},
}
awesome_nav = plugins["awesome_nav"]["config"]
assert awesome_nav["logs"] == {
"nav_override": None,
"root_title": None,
"root_hide": None,
"no_matches": "error",
}
def test_preserves_plugin_presence_semantics() -> None:
plugins = _convert_plugins([])
assert plugins["search"]["config"]["enabled"] is True
for name in (
"meta",
"redirects",
"minify",
"literate_nav",
"awesome_nav",
"offline",
):
assert plugins[name]["config"]["enabled"] is False
assert plugins["tags"]["config"] == []
assert "mike" not in plugins
assert not set(SHIM_PLUGINS) & set(plugins)
def test_normalizes_mike_defaults() -> None:
plugins = _convert_plugins({"mike": {}})
assert plugins["mike"]["config"] == {
"alias_type": "symlink",
"redirect_template": None,
"deploy_prefix": "",
"canonical_version": None,
}
@pytest.mark.parametrize("name", [*PYTHON_PLUGINS, "tags", "external"])
def test_plugin_configuration_must_be_a_mapping(name: str) -> None:
with pytest.raises(
ConfigurationError,
match=rf"{name} configuration must be a mapping",
):
_convert_plugins({name: []})
@pytest.mark.parametrize("name", PYTHON_PLUGINS)
def test_rejects_unknown_python_plugin_options(name: str) -> None:
with pytest.raises(
ConfigurationError,
match=rf"unknown {name} option: unknown",
):
_convert_plugins({name: {"unknown": True}})
@pytest.mark.parametrize("name", SHIM_PLUGINS)
def test_normalizes_null_shim_configuration(name: str) -> None:
plugins = _convert_plugins({name: None})
assert plugins[name]["config"] == {}
@pytest.mark.parametrize(
("name", "config"),
[
pytest.param("autorefs", {"enabled": False}, id="autorefs"),
pytest.param(
"markdown-exec",
{"enabled": False, "ansi": "off", "languages": ["python"]},
id="markdown-exec",
),
pytest.param(
"mkdocstrings",
{
"enabled": False,
"handlers": {"python": {"options": {}}},
"custom_templates": None,
"enable_inventory": None,
"default_handler": "python",
"locale": "fr",
},
id="mkdocstrings",
),
pytest.param(
"glightbox",
{
"enabled": True,
"width": "80%",
"height": "auto",
"skip_classes": ["no-lightbox"],
"auto": False,
"auto_themed": True,
"auto_caption": True,
"caption_position": "top",
"touchNavigation": False,
"loop": True,
"effect": "fade",
"zoomable": False,
"draggable": False,
"background": "black",
"shadow": False,
"manual": None,
},
id="glightbox",
),
pytest.param(
"macros",
{
"enabled": True,
"module_name": "hooks",
"modules": ["plugin.macros"],
"include_yaml": {"data": "data.yml"},
"include_dir": "includes",
"render_by_default": False,
"on_error_fail": True,
"on_undefined": "strict",
"verbose": True,
"j2_block_start_string": "<%",
"j2_block_end_string": "%>",
"j2_variable_start_string": "<@",
"j2_variable_end_string": "@>",
"j2_comment_start_string": "<#",
"j2_comment_end_string": "#>",
"j2_extensions": ["jinja2.ext.do"],
},
id="macros",
),
],
)
def test_accepts_supported_shim_options(
name: str, config: dict[str, Any]
) -> None:
plugins = _convert_plugins({name: config})
assert plugins[name]["config"] == config
@pytest.mark.parametrize(
("name", "config", "message"),
[
("search", {"enabled": "yes"}, "enabled must be a boolean"),
("search", {"separator": 42}, "separator must be a string"),
("meta", {"meta_file": 42}, "meta_file must be a string"),
(
"redirects",
{"redirect_maps": {"old.md": 42}},
"redirect_maps must be a mapping of strings",
),
("minify", {"js_files": 42}, "js_files must be a string or a list"),
(
"minify",
{"js_files": [42]},
"js_files must be a string or a list",
),
(
"minify",
{"htmlmin_opts": {"unknown": True}},
"unknown minify htmlmin_opts option",
),
(
"minify",
{"htmlmin_opts": {"pre_tags": [42]}},
"pre_tags must be a list of strings",
),
(
"literate-nav",
{"tab_length": 0},
"tab_length must be a positive integer",
),
(
"literate-nav",
{"markdown_extensions": [42]},
"Markdown extensions must be strings or mappings",
),
(
"awesome-nav",
{"filename": ""},
"filename must be a non-empty string",
),
(
"awesome-nav",
{"logs": {"no_matches": "debug"}},
"no_matches must be info, warning or error",
),
("offline", {"enabled": "yes"}, "enabled must be a boolean"),
("mike", {"canonical_version": 42}, "must be a string or null"),
("autorefs", {"enabled": "yes"}, "enabled must be a boolean"),
("markdown-exec", {"ansi": "sometimes"}, "ansi must be"),
(
"markdown-exec",
{"languages": ["ruby"]},
"languages must be a list of supported language names",
),
("mkdocstrings", {"handlers": []}, "handlers must be a mapping"),
("glightbox", {"effect": "slide"}, "effect must be"),
("macros", {"include_yaml": [42]}, "include_yaml must be a list"),
("macros", {"on_undefined": "silent"}, "on_undefined must be"),
],
)
def test_rejects_invalid_plugin_options(
name: str, config: dict[str, Any], message: str
) -> None:
with pytest.raises(ConfigurationError, match=message):
_convert_plugins({name: config})
@pytest.mark.parametrize(
"value",
[True, "search", [42], [{}], [{"search": {}, "offline": {}}]],
)
def test_rejects_invalid_plugin_collections(value: Any) -> None:
with pytest.raises(ConfigurationError):
_convert_plugins(value)
+595 -187
View File
@@ -48,7 +48,7 @@ from zensical.extensions.macros import MacrosExtension
from zensical.extensions.mkdocstrings import MkdocstringsExtension
if TYPE_CHECKING:
from collections.abc import Iterator
from collections.abc import Iterable, Iterator
# ----------------------------------------------------------------------------
@@ -1091,6 +1091,10 @@ def _list_templates(config: dict) -> list[tuple[str, int]]:
# -----------------------------------------------------------------------------
# Note: we do a lot of validation on configuration settings in the following
# helpers (presence, shape, type, value). This is transitional: configuration
# parsing and validation will move to Rust at some point. Doing this in Python
# is an easy win until we start working on configuration in Rust.
def _is_index(path: str) -> bool:
@@ -1142,13 +1146,15 @@ def _convert_nav_item(item: str | dict | list) -> dict | list:
"is_index": False,
"active": False,
}
raise TypeError(f"Unknown nav item value type: {type(value)}")
raise ConfigurationError(
f"Unknown nav item value type: {type(value)}"
)
# Handle a list of items
elif isinstance(item, list):
return [_convert_nav_item(child) for child in item]
raise TypeError(f"Unknown nav item type: {type(item)}")
raise ConfigurationError(f"Unknown nav item type: {type(item)}")
def _convert_extra(data: dict | list) -> dict | list:
@@ -1188,57 +1194,92 @@ def _convert_extra_javascript(value: list) -> list:
item.setdefault("async", False)
item.setdefault("defer", False)
else:
raise TypeError(f"Unknown extra_javascript item type: {type(item)}")
raise ConfigurationError(
f"Unknown extra_javascript item type: {type(item)}"
)
# Return resulting value
return value
def _convert_markdown_extensions(value: Any) -> tuple[list[str], dict]:
def _convert_markdown_extensions(
value: Any,
) -> tuple[list[str], dict[str, dict[str, Any]]]:
"""Convert Markdown extensions to what Python Markdown expects."""
markdown_extensions = ["toc", "tables"]
mdx_configs: dict[str, dict[str, Any]] = {"toc": {}, "tables": {}}
if value is None:
return markdown_extensions, mdx_configs
if not isinstance(value, (dict, list)):
raise ConfigurationError(
"Markdown extensions must be a list or mapping"
)
# In case of Python Markdown Extensions, we allow to omit the necessary
# quotes around the extension names, so we need to hoist the extensions
# configuration one level up. This is a pre-processing step before we
# actually parse the configuration.
if "pymdownx" in value:
if isinstance(value, dict):
value = dict(value)
if isinstance(value, dict) and "pymdownx" in value:
pymdownx = value.pop("pymdownx")
if not isinstance(pymdownx, dict):
raise ConfigurationError(
"pymdownx Markdown extensions must be a mapping"
)
for ext, conf in pymdownx.items():
if not isinstance(ext, str):
raise ConfigurationError(
"Markdown extension names must be strings"
)
# Special case for blocks extension, which has another level of
# nesting. This is the only extension that requires this.
if ext == "blocks":
if not isinstance(conf, dict):
raise ConfigurationError(
"pymdownx.blocks Markdown extensions must be a mapping"
)
for block, config in conf.items():
if not isinstance(block, str):
raise ConfigurationError(
"Markdown extension names must be strings"
)
value[f"pymdownx.{ext}.{block}"] = config
else:
value[f"pymdownx.{ext}"] = conf
# Same as for Python Markdown extensions, see above
if "zensical" in value:
if isinstance(value, dict) and "zensical" in value:
zensical = value.pop("zensical")
if not isinstance(zensical, dict):
raise ConfigurationError(
"zensical Markdown extensions must be a mapping"
)
for ext, conf in zensical.items():
if not isinstance(ext, str):
raise ConfigurationError(
"Markdown extension names must be strings"
)
if ext == "extensions":
if not isinstance(conf, dict):
raise ConfigurationError(
"zensical.extensions Markdown extensions must be a "
"mapping"
)
for key, config in conf.items():
if not isinstance(key, str):
raise ConfigurationError(
"Markdown extension names must be strings"
)
value[f"zensical.{ext}.{key}"] = config
else:
value[f"zensical.{ext}"] = conf
# Extensions can be defined as a dict
if isinstance(value, dict):
for ext, config in value.items():
markdown_extensions.append(ext)
mdx_configs[ext] = config or {}
# Extensions can also be defined as a list
else:
for item in value:
if isinstance(item, dict):
ext, config = item.popitem()
markdown_extensions.append(ext)
mdx_configs[ext] = config or {}
elif isinstance(item, str):
markdown_extensions.append(item)
extensions, configs = _convert_plugin_markdown_extensions(value)
markdown_extensions.extend(extensions)
mdx_configs.update(configs)
# Return extension list and configuration, after ensuring they're unique
seen = set()
@@ -1260,24 +1301,8 @@ def _convert_plugin_markdown_extensions(
"""
markdown_extensions: list[str] = []
mdx_configs: dict[str, dict[str, Any]] = {}
if value is None:
return markdown_extensions, mdx_configs
items: Any = value.items() if isinstance(value, dict) else value
for item in items:
if isinstance(item, tuple):
extension, extension_config = item
elif isinstance(item, dict):
if len(item) != 1:
raise ConfigurationError(
"Markdown extension mappings must contain one entry"
)
extension, extension_config = next(iter(item.items()))
elif isinstance(item, str):
extension, extension_config = item, {}
else:
raise ConfigurationError(
"Markdown extensions must be strings or mappings"
)
def add(extension: Any, extension_config: Any) -> None:
if not isinstance(extension, str):
raise ConfigurationError("Markdown extension names must be strings")
if extension_config is None:
@@ -1286,117 +1311,270 @@ def _convert_plugin_markdown_extensions(
raise ConfigurationError(
"Markdown extension configurations must be mappings"
)
normalized_config = cast("dict[str, Any]", extension_config)
markdown_extensions.append(extension)
mdx_configs[extension] = normalized_config
mdx_configs[extension] = cast("dict[str, Any]", extension_config)
if value is None:
return markdown_extensions, mdx_configs
if not isinstance(value, (dict, list)):
raise ConfigurationError(
"Plugin Markdown extensions must be a list or mapping"
)
if isinstance(value, dict):
for extension, extension_config in value.items():
add(extension, extension_config)
else:
for item in value:
if isinstance(item, str):
add(item, {})
continue
if not isinstance(item, dict):
raise ConfigurationError(
"Markdown extensions must be strings or mappings"
)
if len(item) != 1:
raise ConfigurationError(
"Markdown extension mappings must contain one entry"
)
extension, extension_config = next(iter(item.items()))
add(extension, extension_config)
return markdown_extensions, mdx_configs
def _reject_unknown_options(
label: str, plugin: dict[str, Any], supported: set[str]
) -> None:
"""Reject unknown options in a supported plugin configuration."""
for name in plugin:
if not isinstance(name, str):
raise ConfigurationError(f"{label} option names must be strings")
if unknown := set(plugin) - supported:
name = sorted(unknown)[0]
raise ConfigurationError(f"unknown {label} option: {name}")
def _validate_boolean_options(
label: str, plugin: dict[str, Any], options: Iterable[str]
) -> None:
"""Validate boolean options in a supported plugin configuration."""
for name in options:
if name in plugin and not isinstance(plugin[name], bool):
raise ConfigurationError(f"{label} {name} must be a boolean")
def _validate_string_options(
label: str, plugin: dict[str, Any], options: Iterable[str]
) -> None:
"""Validate string options in a supported plugin configuration."""
for name in options:
if name in plugin and not isinstance(plugin[name], str):
raise ConfigurationError(f"{label} {name} must be a string")
def _convert_plugins(value: Any, config: dict) -> dict:
"""Convert plugins configuration to something we can work with."""
plugins: dict[str, Any] = {}
tags: list[dict[str, Any]] = []
def add(name: str, data: Any) -> None:
def add(name: Any, data: Any) -> None:
"""Canonicalize Material aliases while preserving tag instances."""
if not isinstance(name, str):
raise ConfigurationError("Plugin names must be strings")
name = name.removeprefix("material/")
if data is None:
data = {}
elif not isinstance(data, dict):
raise ConfigurationError(f"{name} configuration must be a mapping")
else:
data = dict(data)
if name == "tags":
tags.append({"name": name, "config": dict(data or {})})
tags.append({"name": name, "config": data})
else:
plugins[name] = data
if value is None:
pass
# Plugins can be defined as a dict
if isinstance(value, dict):
elif isinstance(value, dict):
for name, data in value.items():
add(name, data)
# Plugins can also be defined as a list
else:
elif isinstance(value, list):
for item in value:
if isinstance(item, dict):
name, data = next(iter(item.items()))
if not isinstance(name, str):
raise ConfigurationError("Plugin names must be strings")
add(name, data)
elif isinstance(item, str):
if isinstance(item, str):
add(item, {})
elif isinstance(item, dict) and len(item) == 1:
name, data = next(iter(item.items()))
add(name, data)
else:
raise ConfigurationError(
"Plugin entries must be names or single-entry mappings"
)
else:
raise ConfigurationError("plugins must be a list or mapping")
# Rust owns all tags defaults, validation, scalar coercion and callable
# lowering. Python only preserves ordered plugin instances and their raw
# configuration, as it does for future native compatibility modules.
plugins["tags"] = tags
# Define defaults for search plugin
search = set_default(plugins, "search", {}, dict)
set_default(search, "enabled", True, bool)
set_default(
search, "separator", '[\\s\\-_,:!=\\[\\]()\\\\"`/]+|\\.(?!\\d)', str
)
# Search is enabled by default, even when it isn't explicitly configured.
search = plugins.pop("search", {})
_reject_unknown_options("search", search, {"enabled", "separator"})
set_default(search, "enabled", True)
set_default(search, "separator", '[\\s\\-_,:!=\\[\\]()\\\\"`/]+|\\.(?!\\d)')
_validate_boolean_options("search", search, ("enabled",))
_validate_string_options("search", search, ("separator",))
plugins["search"] = search
# Normalize metadata into the typed Rust configuration. The Material
# namespace is removed at admission, so both names share this code path.
present, meta = _pop_plugin_config(plugins, "meta")
set_default(meta, "enabled", present, bool)
set_default(meta, "meta_file", ".meta.yml", str)
# The configuration is always materialized and enabled by plugin presence.
present = "meta" in plugins
meta = plugins.pop("meta", {})
_reject_unknown_options("meta", meta, {"enabled", "meta_file"})
set_default(meta, "enabled", present)
set_default(meta, "meta_file", ".meta.yml")
_validate_boolean_options("meta", meta, ("enabled",))
_validate_string_options("meta", meta, ("meta_file",))
plugins["meta"] = meta
# Normalize redirects into typed native configuration. The enabled flag is
# internal; plugin presence retains MkDocs' activation semantics.
present, redirects = _pop_plugin_config(plugins, "redirects")
set_default(redirects, "enabled", present, bool)
set_default(redirects, "redirect_maps", {}, dict)
# internal; plugin presence retains MkDocs' activation semantics. The
# configuration is always materialized for Rust.
present = "redirects" in plugins
redirects = plugins.pop("redirects", {})
_reject_unknown_options(
"redirects", redirects, {"enabled", "redirect_maps"}
)
set_default(redirects, "enabled", present)
_validate_boolean_options("redirects", redirects, ("enabled",))
set_default(redirects, "redirect_maps", {})
redirect_maps = redirects["redirect_maps"]
if not isinstance(redirect_maps, dict) or not all(
isinstance(source, str) and isinstance(target, str)
for source, target in redirect_maps.items()
):
raise ConfigurationError(
"redirects redirect_maps must be a mapping of strings to strings"
)
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.
present, minify = _pop_plugin_config(plugins, "minify")
set_default(minify, "enabled", present, 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)
# switches are Zensical extensions handled by the final HTML pass. The
# configuration is always materialized and enabled by plugin presence.
present = "minify" in plugins
minify = plugins.pop("minify", {})
boolean_defaults = {
"enabled": present,
"minify_html": False,
"minify_js": False,
"minify_css": False,
"minify_inline_js": False,
"minify_inline_css": False,
"cache_safe": False,
}
_reject_unknown_options(
"minify",
minify,
set(boolean_defaults)
| {
"js_files",
"css_files",
"htmlmin_opts",
},
)
for name, default in boolean_defaults.items():
set_default(minify, name, default)
_validate_boolean_options("minify", minify, boolean_defaults)
for name in ("js_files", "css_files"):
files = minify.get(name)
if files is None:
minify[name] = []
elif isinstance(files, str):
set_default(minify, name, [])
files = minify[name]
if isinstance(files, str):
minify[name] = [files]
elif isinstance(files, list):
minify[name] = files
else:
minify[name] = []
elif not isinstance(files, list) or not all(
isinstance(file, str) for file in files
):
raise ConfigurationError(
f"minify {name} must be a string or a list of strings"
)
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)
set_default(minify, "htmlmin_opts", {})
if not isinstance(minify["htmlmin_opts"], dict):
raise ConfigurationError("minify htmlmin_opts must be a mapping")
htmlmin_opts = dict(minify["htmlmin_opts"])
boolean_defaults = {
"remove_comments": False,
"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,
}
_reject_unknown_options(
"minify htmlmin_opts",
htmlmin_opts,
set(boolean_defaults) | {"pre_tags", "pre_attr"},
)
for name, default in boolean_defaults.items():
set_default(htmlmin_opts, name, default)
_validate_boolean_options(
"minify htmlmin_opts", htmlmin_opts, boolean_defaults
)
set_default(htmlmin_opts, "pre_tags", ["pre", "textarea"])
if not isinstance(htmlmin_opts["pre_tags"], list) or not all(
isinstance(tag, str) for tag in htmlmin_opts["pre_tags"]
):
raise ConfigurationError(
"minify htmlmin_opts pre_tags must be a list of strings"
)
set_default(htmlmin_opts, "pre_attr", "pre")
_validate_string_options("minify htmlmin_opts", htmlmin_opts, ("pre_attr",))
minify["htmlmin_opts"] = htmlmin_opts
plugins["minify"] = minify
# Normalize mkdocs-literate-nav without importing or executing the plugin.
# Python retains extension objects and callables for the narrow Markdown
# rendering boundary; Rust owns discovery and navigation resolution.
present, literate_nav = _pop_plugin_config(plugins, "literate-nav")
set_default(literate_nav, "enabled", present, bool)
set_default(literate_nav, "nav_file", "SUMMARY.md", str)
set_default(literate_nav, "implicit_index", False, bool)
set_default(literate_nav, "tab_length", 4, int)
# rendering boundary; Rust owns discovery and navigation resolution. The
# configuration is always materialized and enabled by plugin presence, and
# its Markdown settings belong to its private parser.
present = "literate-nav" in plugins
literate_nav = plugins.pop("literate-nav", {})
_reject_unknown_options(
"literate-nav",
literate_nav,
{
"enabled",
"nav_file",
"implicit_index",
"tab_length",
"markdown_extensions",
},
)
set_default(literate_nav, "enabled", present)
set_default(literate_nav, "nav_file", "SUMMARY.md")
set_default(literate_nav, "implicit_index", False)
set_default(literate_nav, "tab_length", 4)
_validate_boolean_options(
"literate-nav", literate_nav, ("enabled", "implicit_index")
)
_validate_string_options("literate-nav", literate_nav, ("nav_file",))
if (
not isinstance(literate_nav["tab_length"], int)
or isinstance(literate_nav["tab_length"], bool)
or literate_nav["tab_length"] <= 0
):
raise ConfigurationError(
"literate-nav tab_length must be a positive integer"
)
set_default(literate_nav, "markdown_extensions", [])
extensions, extension_configs = _convert_plugin_markdown_extensions(
literate_nav.get("markdown_extensions", [])
literate_nav["markdown_extensions"]
)
literate_nav["markdown_extensions"] = extensions
literate_nav["mdx_configs"] = extension_configs
@@ -1404,102 +1582,332 @@ def _convert_plugins(value: Any, config: dict) -> dict:
# Normalize mkdocs-awesome-nav without importing or executing the plugin.
# Rust owns discovery, YAML parsing, matching and navigation resolution.
present, awesome_nav = _pop_plugin_config(plugins, "awesome-nav")
set_default(awesome_nav, "enabled", present, bool)
unknown = set(awesome_nav) - {"enabled", "filename", "logs"}
if unknown:
option = sorted(unknown)[0]
raise ConfigurationError(f"unknown awesome-nav option: {option}")
# The configuration is always materialized and enabled by plugin presence.
present = "awesome-nav" in plugins
awesome_nav = plugins.pop("awesome-nav", {})
_reject_unknown_options(
"awesome-nav", awesome_nav, {"enabled", "filename", "logs"}
)
set_default(awesome_nav, "enabled", present)
_validate_boolean_options("awesome-nav", awesome_nav, ("enabled",))
set_default(awesome_nav, "filename", ".nav.yml")
if not isinstance(awesome_nav["filename"], str):
raise ConfigurationError("awesome-nav filename must be a string")
if not awesome_nav["filename"]:
raise ConfigurationError("awesome-nav filename must not be empty")
logs = awesome_nav.get("logs")
if logs is None:
logs = {}
elif not isinstance(logs, dict):
if (
not isinstance(awesome_nav["filename"], str)
or not awesome_nav["filename"]
):
raise ConfigurationError(
"awesome-nav filename must be a non-empty string"
)
set_default(awesome_nav, "logs", {})
if not isinstance(awesome_nav["logs"], dict):
raise ConfigurationError("awesome-nav logs must be a mapping")
logs = dict(logs)
unknown = set(logs) - {
"nav_override",
"root_title",
"root_hide",
"no_matches",
}
if unknown:
option = sorted(unknown)[0]
raise ConfigurationError(f"unknown awesome-nav log option: {option}")
for name in ("nav_override", "root_title", "root_hide", "no_matches"):
logs = dict(awesome_nav["logs"])
log_names = ("nav_override", "root_title", "root_hide", "no_matches")
_reject_unknown_options("awesome-nav log", logs, set(log_names))
for name in log_names:
set_default(logs, name, None)
if logs[name] not in (None, "info", "warning", "error"):
raise ConfigurationError(
f"awesome-nav log level '{name}' must be info, warning or error"
f"awesome-nav log {name} must be info, warning or error "
"(or null)"
)
awesome_nav["logs"] = logs
plugins["awesome_nav"] = awesome_nav
# Define defaults for offline plugin
offline = set_default(plugins, "offline", {"enabled": False}, dict)
set_default(offline, "enabled", True, bool)
# Offline is always materialized for Rust and enabled by plugin presence.
present = "offline" in plugins
offline = plugins.pop("offline", {})
_reject_unknown_options("offline", offline, {"enabled"})
set_default(offline, "enabled", present)
_validate_boolean_options("offline", offline, ("enabled",))
plugins["offline"] = offline
# Ensure correct resolution of links when viewing the site from the
# file system by disabling directory URLs
if offline.get("enabled", True):
config["use_directory_urls"] = False
# Mike signals versioned builds through an environment variable. During
# one, we read its plugin configuration and mirror Mike's site URL
# adjustment; support is currently limited to our theme. Explicit plugin
# configurations are also normalized outside a versioned build.
version = os.environ.get("MIKE_DOCS_VERSION")
if "mike" in plugins or (version and config.get("site_url")):
mike = plugins.pop("mike", {})
string_defaults = {
"alias_type": "symlink",
"deploy_prefix": "",
}
nullable_strings = ("redirect_template", "canonical_version")
_reject_unknown_options(
"mike",
mike,
{"enabled", *string_defaults, *nullable_strings},
)
_validate_boolean_options("mike", mike, ("enabled",))
for name, default in string_defaults.items():
set_default(mike, name, default)
_validate_string_options("mike", mike, string_defaults)
for name in nullable_strings:
set_default(mike, name, None)
if mike[name] is not None and not isinstance(mike[name], str):
raise ConfigurationError(
f"mike {name} must be a string or null"
)
plugins["mike"] = mike
# Append iframe-worker to polyfills/shims
if not any(
"iframe-worker"
in (url.get("path", "") if isinstance(url, dict) else url)
for url in config["extra"]["polyfills"]
# Validate settings forwarded by the plugin-to-extension shims.
if "autorefs" in plugins:
autorefs = plugins["autorefs"]
_reject_unknown_options("autorefs", autorefs, {"enabled"})
_validate_boolean_options("autorefs", autorefs, ("enabled",))
if "markdown-exec" in plugins:
markdown_exec = plugins["markdown-exec"]
_reject_unknown_options(
"markdown-exec", markdown_exec, {"enabled", "ansi", "languages"}
)
_validate_boolean_options("markdown-exec", markdown_exec, ("enabled",))
if "ansi" in markdown_exec:
ansi = markdown_exec["ansi"]
if not isinstance(ansi, bool) and ansi not in {
"auto",
"off",
"required",
}:
raise ConfigurationError(
"markdown-exec ansi must be 'auto', 'off', 'required', "
"true or false"
)
if "languages" in markdown_exec:
languages = markdown_exec["languages"]
supported = {
"bash",
"console",
"md",
"markdown",
"py",
"python",
"pycon",
"pyodide",
"sh",
"tree",
}
if languages is not None and (
not isinstance(languages, list)
or not all(
isinstance(language, str) and language in supported
for language in languages
)
):
raise ConfigurationError(
"markdown-exec languages must be a list of supported "
"language names or null"
)
if "mkdocstrings" in plugins:
mkdocstrings = plugins["mkdocstrings"]
string_options = {"default_handler", "locale"}
_reject_unknown_options(
"mkdocstrings",
mkdocstrings,
string_options
| {
"enabled",
"handlers",
"custom_templates",
"enable_inventory",
},
)
_validate_boolean_options("mkdocstrings", mkdocstrings, ("enabled",))
if (
"handlers" in mkdocstrings
and mkdocstrings["handlers"] is not None
and not isinstance(mkdocstrings["handlers"], dict)
):
script = "https://unpkg.com/iframe-worker/shim"
config["extra"]["polyfills"].append(
{
"path": script,
"type": "text/javascript",
"async": False,
"defer": False,
}
raise ConfigurationError(
"mkdocstrings handlers must be a mapping or null"
)
if (
"custom_templates" in mkdocstrings
and mkdocstrings["custom_templates"] is not None
and not isinstance(mkdocstrings["custom_templates"], str)
):
raise ConfigurationError(
"mkdocstrings custom_templates must be a string or null"
)
if (
"enable_inventory" in mkdocstrings
and mkdocstrings["enable_inventory"] is not None
and not isinstance(mkdocstrings["enable_inventory"], bool)
):
raise ConfigurationError(
"mkdocstrings enable_inventory must be a boolean or null"
)
_validate_string_options("mkdocstrings", mkdocstrings, string_options)
if "glightbox" in plugins:
glightbox = plugins["glightbox"]
string_options = {"width", "height", "background"}
boolean_options = {
"enabled",
"auto",
"auto_themed",
"auto_caption",
"touchNavigation",
"loop",
"zoomable",
"draggable",
"shadow",
}
_reject_unknown_options(
"glightbox",
glightbox,
string_options
| boolean_options
| {
"skip_classes",
"caption_position",
"effect",
"manual",
},
)
_validate_string_options("glightbox", glightbox, string_options)
if "skip_classes" in glightbox and (
not isinstance(glightbox["skip_classes"], list)
or not all(
isinstance(name, str) for name in glightbox["skip_classes"]
)
):
raise ConfigurationError(
"glightbox skip_classes must be a list of strings"
)
_validate_boolean_options("glightbox", glightbox, boolean_options)
if "caption_position" in glightbox and glightbox[
"caption_position"
] not in {"bottom", "top", "left", "right"}:
raise ConfigurationError(
"glightbox caption_position must be 'bottom', 'top', 'left' "
"or 'right'"
)
if "effect" in glightbox and glightbox["effect"] not in {
"zoom",
"fade",
"none",
}:
raise ConfigurationError(
"glightbox effect must be 'zoom', 'fade' or 'none'"
)
if (
"manual" in glightbox
and glightbox["manual"] is not None
and not isinstance(glightbox["manual"], bool)
):
raise ConfigurationError(
"glightbox manual must be a boolean or null"
)
# Mike sets an environment variable for us to determine whether the build
# is triggered from mike. If it is, we read the plugin configuration, if
# any, and mirror mike's functionality in adjusting the configuration when
# building the site, while only allowing for our theme at the moment.
version = os.environ.get("MIKE_DOCS_VERSION")
if version and config.get("site_url"):
mike = set_default(plugins, "mike", {}, dict)
set_default(mike, "alias_type", "symlink", str)
set_default(mike, "redirect_template", None)
set_default(mike, "deploy_prefix", "", str)
set_default(mike, "canonical_version", None)
if "macros" in plugins:
macros = plugins["macros"]
string_options = {
"module_name",
"include_dir",
"j2_block_start_string",
"j2_block_end_string",
"j2_variable_start_string",
"j2_variable_end_string",
"j2_comment_start_string",
"j2_comment_end_string",
}
list_options = {"modules", "j2_extensions"}
boolean_options = {
"enabled",
"render_by_default",
"on_error_fail",
"verbose",
}
_reject_unknown_options(
"macros",
macros,
string_options
| list_options
| boolean_options
| {
"include_yaml",
"on_undefined",
},
)
_validate_string_options("macros", macros, string_options)
for name in list_options:
if name in macros and (
not isinstance(macros[name], list)
or not all(isinstance(item, str) for item in macros[name])
):
raise ConfigurationError(
f"macros {name} must be a list of strings"
)
_validate_boolean_options("macros", macros, boolean_options)
if "include_yaml" in macros:
include_yaml = macros["include_yaml"]
valid_list = isinstance(include_yaml, list) and all(
isinstance(path, str) for path in include_yaml
)
valid_mapping = isinstance(include_yaml, dict) and all(
isinstance(name, str) and isinstance(path, str)
for name, path in include_yaml.items()
)
if not valid_list and not valid_mapping:
raise ConfigurationError(
"macros include_yaml must be a list of strings or a "
"mapping of strings to strings"
)
if "on_undefined" in macros and macros["on_undefined"] not in {
"keep",
"strict",
}:
raise ConfigurationError(
"macros on_undefined must be 'keep' or 'strict'"
)
# Copied and adapted from mike's plugin implementation
if mike["canonical_version"] is not None:
version = mike["canonical_version"]
config["site_url"] = urljoin(config["site_url"], version)
# Now, add another level of indirection, by moving all plugin configuration
# into a `config` property, making it compatible with Material for MkDocs.
for name, data in plugins.items():
if not isinstance(data, dict) or "config" not in data:
plugins[name] = {"config": data}
# Return plugins
return plugins
# Add one level of indirection by moving every plugin configuration into a
# `config` property, matching Material's template-facing representation.
wrapped = {name: {"config": plugin} for name, plugin in plugins.items()}
_apply_offline_plugin(config, wrapped)
_apply_mike_plugin(config, wrapped, version)
return wrapped
def _pop_plugin_config(
plugins: dict[str, Any], name: str
) -> tuple[bool, dict[str, Any]]:
"""Consume one optional mapping while preserving presence semantics."""
if name not in plugins:
return False, {}
value = plugins.pop(name)
if value is None:
return True, {}
if not isinstance(value, dict):
raise ConfigurationError(f"{name} configuration must be a mapping")
return True, dict(value)
def _apply_offline_plugin(config: dict, plugins: dict[str, Any]) -> None:
"""Apply project-wide effects of the offline plugin."""
offline = plugins["offline"]["config"]
if not offline["enabled"]:
return
# Ensure correct resolution of links when viewing the site from the file
# system by disabling directory URLs.
config["use_directory_urls"] = False
# Append iframe-worker to polyfills/shims.
if not any(
"iframe-worker"
in (url.get("path", "") if isinstance(url, dict) else url)
for url in config["extra"]["polyfills"]
):
config["extra"]["polyfills"].append(
{
"path": "https://unpkg.com/iframe-worker/shim",
"type": "text/javascript",
"async": False,
"defer": False,
}
)
def _apply_mike_plugin(
config: dict,
plugins: dict[str, Any],
version: str | None,
) -> None:
"""Apply project-wide effects of a mike versioned build."""
if not version or not config.get("site_url"):
return
mike = plugins["mike"]["config"]
# Copied and adapted from Mike's plugin implementation.
version = mike["canonical_version"] or version
config["site_url"] = urljoin(config["site_url"], version)