mirror of
https://github.com/zensical/zensical.git
synced 2026-09-26 08:15:34 +00:00
chore: validate more configuration settings
Signed-off-by: Timothée Mazzucotelli <dev@pawamoy.fr>
This commit is contained in:
Vendored
+62
-2
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user