Files
zensical/python/zensical/config.py
T

1313 lines
49 KiB
Python

# 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
import functools
import hashlib
import importlib
import os
import pickle
from importlib.metadata import EntryPoint, entry_points
from importlib.util import find_spec
from pathlib import Path
from typing import IO, TYPE_CHECKING, Any
from urllib.parse import urljoin, urlparse
import yaml
from click import ClickException
from deepmerge import always_merger
from tomli import load as toml_load
from yaml import Loader, YAMLError
from yaml.constructor import ConstructorError
from zensical.extensions.autorefs import AutorefsExtension
from zensical.extensions.emoji import to_svg, twemoji
from zensical.extensions.glightbox import GlightboxExtension
from zensical.extensions.macros import MacrosExtension
from zensical.extensions.mkdocstrings import MkdocstringsExtension
if TYPE_CHECKING:
from collections.abc import Iterator
# ----------------------------------------------------------------------------
# Globals
# ----------------------------------------------------------------------------
_CONFIG: dict[str, Any] | None = None
"""
Global configuration to pick up later for parsing Markdown.
Since MkDocs uses YAML as a configuration format, the configuration can contain
references to functions or other Python objects, for which we don't have any
representation in Rust. Thus, we just keep the configuration on the Python
side, and use it directly when needed. It's a hack but will do for now.
"""
# ----------------------------------------------------------------------------
# Constants
# ----------------------------------------------------------------------------
DEFAULT_MARKDOWN_EXTENSIONS = {
"abbr": {},
"admonition": {},
"attr_list": {},
"def_list": {},
"footnotes": {},
"md_in_html": {},
"toc": {"permalink": True},
"pymdownx.arithmatex": {"generic": True},
"pymdownx.betterem": {},
"pymdownx.caret": {},
"pymdownx.details": {},
"pymdownx.emoji": {
"emoji_generator": to_svg,
"emoji_index": twemoji,
},
"pymdownx.highlight": {
"anchor_linenums": True,
"line_spans": "__span",
"pygments_lang_class": True,
},
"pymdownx.inlinehilite": {},
"pymdownx.keys": {},
"pymdownx.magiclink": {},
"pymdownx.mark": {},
"pymdownx.smartsymbols": {},
"pymdownx.superfences": {
"custom_fences": [{"name": "mermaid", "class": "mermaid"}]
},
"pymdownx.tabbed": {
"alternate_style": True,
"combine_header_slug": True,
},
"pymdownx.tasklist": {"custom_checkbox": True},
"pymdownx.tilde": {},
}
# ----------------------------------------------------------------------------
# Classes
# ----------------------------------------------------------------------------
class ConfigurationError(ClickException):
"""Configuration resolution or validation failed."""
# ----------------------------------------------------------------------------
# Functions
# ----------------------------------------------------------------------------
def parse_config(path: str) -> dict:
"""Parse configuration file."""
# Decide by extension; no need to convert to Path
_, ext = os.path.splitext(path)
if ext.lower() == ".toml":
return parse_zensical_config(path)
return parse_mkdocs_config(path)
def parse_zensical_config(path: str) -> dict:
"""Parse zensical.toml configuration file."""
global _CONFIG # noqa: PLW0603
with open(path, "rb") as f:
config = toml_load(f)
if "project" in config:
config = config["project"]
# Apply defaults and return parsed configuration
_CONFIG = _apply_defaults(config, path)
return _CONFIG
def parse_mkdocs_config(path: str) -> dict:
"""Parse mkdocs.yml configuration file."""
global _CONFIG # noqa: PLW0603
with open(path, encoding="utf-8") as f:
config = _yaml_load(f)
# Apply defaults and return parsed configuration
_CONFIG = _apply_defaults(config, path)
return _CONFIG
def get_config() -> dict:
"""Return configuration."""
# We assume this function is only called after populating `_CONFIG`.
return _CONFIG # ty:ignore[invalid-return-type]
def _yaml_load(source: IO) -> dict[str, Any]:
"""Load configuration file, resolve environment variables and parent files.
Note that INHERIT is only a bandaid that was introduced to allow for some
degree of modularity, but with serious shortcomings. Zensical will use a
different approach in the future, which will allow for composable and
environment-specific configuration.
"""
Loader.add_constructor("!ENV", _construct_env_tag)
try:
config = yaml.load(
# Compatibility shim: we remap Material's extension namespace to
# Zensical's, and the now deprecated materialx namespace as well
source.read()
.replace("material.extensions", "zensical.extensions")
.replace("materialx", "zensical.extensions"),
Loader=Loader, # noqa: S506
)
except YAMLError as e:
raise ConfigurationError(
f"Encountered an error parsing the configuration file: {e}"
) from e
if config is None:
return {}
# Try to resolve inherited configuration file
if "INHERIT" in config and not isinstance(source, str):
relpath = config.pop("INHERIT")
abspath = os.path.normpath(
os.path.join(os.path.dirname(source.name), relpath)
)
if not os.path.exists(abspath):
raise ConfigurationError(
f"Inherited config file '{relpath}' "
f"doesn't exist at '{abspath}'."
)
with open(abspath, encoding="utf-8") as fd:
parent = _yaml_load(fd)
config = always_merger.merge(parent, config)
# Return resulting configuration
return config
def _construct_env_tag(
loader: yaml.Loader,
node: yaml.ScalarNode | yaml.SequenceNode | yaml.MappingNode,
) -> Any:
"""Assign value of ENV variable referenced at node.
MkDocs supports the use of !ENV to reference environment variables in YAML
configuration files. We won't likely support this in Zensical, but for now
we need it to build MkDocs projects. Zensical will use a different approach
to create environment-specific configuration in the future.
Licensed under MIT
Copyright (c) 2020 Waylan Limberg
Taken and adapted from
https://github.com/waylan/pyyaml-env-tag/blob/master/yaml_env_tag.py
"""
default = None
# Handle !ENV <name>
if isinstance(node, yaml.nodes.ScalarNode):
vars = [loader.construct_scalar(node)]
# Handle !ENV [<name>, <fallback>]
elif isinstance(node, yaml.nodes.SequenceNode):
child_nodes = node.value
if len(child_nodes) > 1:
default = loader.construct_object(child_nodes[-1])
child_nodes = child_nodes[:-1]
# Env Vars are resolved as string values, ignoring (implicit) types.
vars = [loader.construct_scalar(child) for child in child_nodes]
else:
raise ConstructorError(
context=f"expected a scalar or sequence node, but found {node.id}",
context_mark=node.start_mark,
)
# Resolve environment variable
for var in vars:
if var in os.environ:
value = os.environ[var]
# Resolve value to Python type using YAML's implicit resolvers
tag = loader.resolve(yaml.nodes.ScalarNode, value, (True, False))
return loader.construct_object(yaml.nodes.ScalarNode(tag, value))
# Otherwise return default
return default
# ----------------------------------------------------------------------------
@functools.cache
def get_themes() -> dict[str, EntryPoint]:
"""Return available themes.
This function adds support for using MkDocs themes with Zensical, including
derived themes. There's currently no plan to add `zensical.*` entrypoints,
since the theming system will undergo a major revision with the addition
of the component system. This will make theming simpler and modular.
"""
themes: dict[str, EntryPoint] = {}
for entry_point in entry_points(group="mkdocs.themes"):
themes[entry_point.name] = entry_point
# Return themes
return themes
def get_builtin_theme_dir() -> str:
"""Return the built-in theme directory."""
path = os.path.dirname(os.path.abspath(__file__))
return os.path.join(path, "templates")
def get_theme_dir(name: str) -> str:
"""Return the theme directory."""
# Zensical's default theme is the replacement for the `material` theme, so
# make sure to use the built-in theme when `material` is specified.
# We also reserve the `zensical` theme name to the same effect.
if name in ("material", "zensical"):
return get_builtin_theme_dir()
# Otherwise, check for installed themes - note that because of minijinja,
# theme might need to be adjusted. This functionality is primarily intended
# for extending the `material` theme, as some users did, and allow to move
# overrides into an installable python package.
themes = get_themes()
if name not in themes:
raise ConfigurationError(
f"Theme '{name}' is not installed. "
f"Available themes are: {', '.join(themes.keys())}"
)
return os.path.dirname(os.path.abspath(themes[name].load().__file__))
def get_custom_theme_dir(path: str, config_path: str) -> str:
"""Return the custom theme directory."""
theme_dir = os.path.join(os.path.dirname(config_path), path)
# Validate that custom theme directory exists
if not os.path.isdir(theme_dir):
raise ConfigurationError(
f"Custom theme directory does not exist: {theme_dir}"
)
return theme_dir
def _yaml_load_theme_config(source: IO) -> dict[str, Any]:
Loader.add_constructor("!ENV", _construct_env_tag)
try:
config = yaml.load(source, Loader=Loader) # noqa: S506
except YAMLError as e:
raise ConfigurationError(
f"Encountered an error parsing the theme configuration file: {e}"
) from e
if config is None:
return {}
return config
def _load_theme_config(theme_dir: str) -> tuple[dict[str, Any], list[str]]:
# Keep track of the theme directories,
# so that we can pass them up to Minijinja.
theme_dirs = [theme_dir]
if (theme_config_file := Path(theme_dir, "mkdocs_theme.yml")).is_file():
# We found a theme configuration file (mkdocs_theme.yml).
with theme_config_file.open(encoding="utf-8") as file:
theme_config = _yaml_load_theme_config(file)
if extends := theme_config.pop("extends", None):
# This theme extends another theme,
# so we load this parent theme's configuration (recursively)
# and merge the current theme's configuration into it.
parent_theme_dir = get_theme_dir(extends)
parent_config, parent_dirs = _load_theme_config(
parent_theme_dir
)
parent_config.update(theme_config)
theme_dirs.extend(parent_dirs)
return parent_config, theme_dirs
# Otherwise we just return the current theme configuration.
return theme_config, theme_dirs
# No theme configuration.
return {}, theme_dirs
# ----------------------------------------------------------------------------
def _apply_defaults(config: dict, path: str) -> dict:
"""Apply default settings in configuration.
Note that this is loosely based on the defaults that MkDocs sets in its own
configuration system, which we won't port for compatibility right now, as
well as the defaults that are set in Material for MkDocs for theme- and
extra-level settings.
We must set all properties, as well as nested properties to `None`, or PyO3
will refuse to convert them, as the key must definitely exist.
"""
config["root_dir"] = os.path.dirname(path)
project_root = Path(config["root_dir"]).resolve()
if "site_name" not in config:
raise ConfigurationError("Missing required setting: site_name")
# Set site directory
set_default(config, "site_dir", "site", str)
if config["site_dir"] == "":
raise ConfigurationError("site_dir must not be empty")
site_dir = Path(config["site_dir"])
if site_dir.is_absolute():
site_dir = site_dir.resolve()
else:
site_dir = project_root.joinpath(site_dir).resolve()
if not site_dir.is_relative_to(project_root):
raise ConfigurationError("site_dir must be within project root")
# Set docs directory
set_default(config, "docs_dir", "docs", str)
if config["docs_dir"] == "":
raise ConfigurationError("docs_dir must not be empty")
docs_dir = Path(config["docs_dir"])
if docs_dir.is_absolute():
docs_dir = docs_dir.resolve()
else:
docs_dir = project_root.joinpath(docs_dir).resolve()
if not docs_dir.is_relative_to(project_root):
raise ConfigurationError("docs_dir must be within project root")
# Validate that site_dir is not the same as docs_dir
if site_dir == docs_dir:
raise ConfigurationError("site_dir and docs_dir must be different")
# Validate that docs directory exists
if not docs_dir.is_dir():
raise ConfigurationError(f"Docs directory does not exist: {docs_dir}")
# Set defaults for core settings
set_default(config, "site_url", None, str)
set_default(config, "site_description", None, str)
set_default(config, "site_author", None, str)
set_default(config, "use_directory_urls", True, bool)
set_default(config, "dev_addr", "localhost:8000", str)
set_default(config, "copyright", None, str)
set_default(config, "watch", [], list)
# Validate watch setting
if not all(isinstance(path, str) for path in config["watch"]):
raise ConfigurationError("'watch' entries must be strings.")
# Set defaults for versioning with mike
set_default(config, "remote_branch", "gh-pages", str)
set_default(config, "remote_name", "origin", str)
# Set defaults for repository settings
set_default(config, "repo_url", None, str)
set_default(config, "repo_name", None, str)
set_default(config, "edit_uri_template", None, str)
set_default(config, "edit_uri", None, str)
# Set defaults for repository name settings
repo_names = {
"github.com": "GitHub",
"gitlab.com": "Gitlab",
"bitbucket.org": "Bitbucket",
}
rel_docs_dir = docs_dir.relative_to(project_root)
edit_uris = {
"github.com": f"edit/master/{rel_docs_dir}",
"gitlab.com": f"edit/master/{rel_docs_dir}",
"bitbucket.org": f"src/default/{rel_docs_dir}",
}
repo_url = config.get("repo_url")
if repo_url:
host = urlparse(repo_url).hostname or ""
if not config.get("repo_name"):
if host in repo_names:
set_default(config, "repo_name", repo_names[host], str)
elif host:
config["repo_name"] = host.split(".")[0].title()
if host in edit_uris:
set_default(config, "edit_uri", edit_uris[host], str)
# Remove trailing slash from edit_uri if present
edit_uri = config.get("edit_uri")
if isinstance(edit_uri, str) and edit_uri.endswith("/"):
config["edit_uri"] = edit_uri.rstrip("/")
# Set defaults for theme font settings
if "theme" in config and isinstance(config["theme"], str):
config["theme"] = {"name": config["theme"]}
elif "theme" not in config:
config["theme"] = {}
# Set defaults for custom theme directory
set_default(config["theme"], "custom_dir", None, str)
# Load theme configuration
if config["theme"].get("custom_dir"):
theme_dir = get_custom_theme_dir(config["theme"]["custom_dir"], path)
theme_config, theme_dirs = _load_theme_config(theme_dir)
if (
len(theme_dirs) == 1
and (theme_name := config["theme"].get("name", "material"))
is not None
):
# Custom theme doesn't extend another theme,
# but user specified a theme name in configuration,
# so we use it as base theme.
theme_dir = get_theme_dir(theme_name)
base_theme_config, base_theme_dirs = _load_theme_config(theme_dir)
theme_config = {**base_theme_config, **theme_config}
theme_dirs = theme_dirs + base_theme_dirs
else:
# No custom theme directory
theme_dir = get_theme_dir(config["theme"].get("name") or "material")
theme_config, theme_dirs = _load_theme_config(theme_dir)
# Store theme directories for Minijinja and merge theme configuration
config["theme_dirs"] = theme_dirs
config["theme"] = {**theme_config, **config["theme"]}
theme = config["theme"]
# Set defaults for theme name
# (we do this after loading the theme configuration
# so that explicitly setting the name to null
# tells the system not to extend the Material theme)
set_default(theme, "name", None, str)
# Set variant and fonts for variant
set_default(theme, "variant", "modern", str)
if theme.get("variant") == "modern":
font = {"text": "Inter", "code": "JetBrains Mono"}
else:
font = {"text": "Roboto", "code": "Roboto Mono"}
# Ensure presence of static templates
theme["static_templates"] = ["404.html", "sitemap.xml"]
# Set defaults for theme settings
set_default(theme, "language", "en", str)
set_default(theme, "direction", None, str)
set_default(theme, "features", [], list)
set_default(theme, "favicon", "assets/images/favicon.png", str)
set_default(theme, "logo", None, str)
# Set defaults for theme font settings
theme.setdefault("font", {})
if isinstance(theme["font"], dict):
set_default(theme["font"], "text", font["text"], str)
set_default(theme["font"], "code", font["code"], str)
# Set defaults for theme icons
icon = set_default(theme, "icon", {}, dict)
set_default(icon, "repo", None, str)
set_default(icon, "annotation", None, str)
set_default(icon, "tag", {}, dict)
if theme.get("variant") == "modern":
set_default(icon, "logo", "lucide/book-open", str)
set_default(icon, "edit", "lucide/file-pen", str)
set_default(icon, "view", "lucide/file-code-2", str)
set_default(icon, "top", "lucide/circle-arrow-up", str)
set_default(icon, "share", "lucide/share-2", str)
set_default(icon, "menu", "lucide/menu", str)
set_default(icon, "alternate", "lucide/languages", str)
set_default(icon, "search", "lucide/search", str)
set_default(icon, "close", "lucide/x", str)
set_default(icon, "previous", "lucide/arrow-left", str)
set_default(icon, "next", "lucide/arrow-right", str)
else:
set_default(icon, "logo", None, str)
set_default(icon, "edit", None, str)
set_default(icon, "view", None, str)
set_default(icon, "top", None, str)
set_default(icon, "share", None, str)
set_default(icon, "menu", None, str)
set_default(icon, "alternate", None, str)
set_default(icon, "search", None, str)
set_default(icon, "close", None, str)
set_default(icon, "previous", None, str)
set_default(icon, "next", None, str)
# Set defaults for theme admonition icons
admonition = set_default(icon, "admonition", {}, dict)
if isinstance(admonition, dict):
icon["admonition"] = {
str(key): str(value)
for key, value in admonition.items()
if value is not None
}
# Set defaults for theme palette settings and normalize to list
palette = theme.setdefault("palette", [])
if isinstance(palette, dict):
palette = [palette]
theme["palette"] = palette
# Set defaults for each palette entry
for entry in palette:
set_default(entry, "media", None, str)
set_default(entry, "scheme", None, str)
set_default(entry, "primary", None, str)
set_default(entry, "accent", None, str)
set_default(entry, "toggle", None, dict)
# Set defaults for palette toggle
toggle = entry.get("toggle")
if toggle:
set_default(toggle, "icon", None, str)
set_default(toggle, "name", None, str)
# Set defaults for extra settings
if "extra" in config and not isinstance(config["extra"], dict):
raise ConfigurationError(
"The 'extra' setting must be a mapping/dictionary."
)
extra = set_default(config, "extra", {}, dict)
if "polyfills" in extra and not isinstance(extra["polyfills"], list):
raise ConfigurationError(
"The 'extra.polyfills' setting must be a list."
)
set_default(extra, "polyfills", [], list)
# Ensure all non-existent values are all empty strings (for now)
config["extra"] = _convert_extra(extra)
# Set defaults for extra files
set_default(config, "extra_css", [], list)
set_default(config, "extra_templates", [], list)
# Generate navigation if not defined, and convert
config["nav"] = _convert_nav(config.setdefault("nav", []))
config["extra_javascript"] = _convert_extra_javascript(
config.setdefault("extra_javascript", [])
)
# Initialize defaults for validation
validation = {
"unresolved_references": False,
"unresolved_footnotes": False,
"unused_definitions": False,
"unused_footnotes": False,
"shadowed_definitions": False,
"shadowed_footnotes": False,
"invalid_links": True,
"invalid_link_anchors": True,
}
# Map MkDocs validation configuration to ours - note that we only support
# validation of links right now, as navigation will change significantly
if "validation" in config:
if isinstance(config["validation"], bool):
config["validation"] = {
"invalid_links": config["validation"],
"invalid_link_anchors": config["validation"],
}
# Hoist links configuration to the top level, if present
input = config["validation"]
if "links" in input:
input.update(input.pop("links"))
# We only support a subset of MkDocs' validation settings, so we ignore
# the ones we don't support. We also map info to warn for simplicity.
if "not_found" in input:
validation["invalid_links"] = input["not_found"] != "ignore"
if "anchors" in input:
validation["invalid_link_anchors"] = input["anchors"] != "ignore"
# Our own keys override the ones we map from MkDocs, so we apply them
# after mapping the MkDocs keys
for key in validation:
if key in input:
validation[key] = bool(input[key])
# Set validation
config["validation"] = validation
# MkDocs will also set fenced_code, which is incompatible with SuperFences,
# the extension that Material for MkDocs generally recommends. Note that we
# decided to set defaults that make it easy to get started with sensible
# Markdown support, but users can override this as needed.
markdown_extensions, mdx_configs = _convert_markdown_extensions(
config.get("markdown_extensions", DEFAULT_MARKDOWN_EXTENSIONS)
)
config["markdown_extensions"] = markdown_extensions
config["mdx_configs"] = mdx_configs
# Now, since YAML supports using Python tags to resolve functions, we need
# to support the same for when we load TOML. This is a bandaid, and we will
# find a better solution, once we work on configuration management, but for
# now this should be sufficient.
_resolve_pymdownx_emoji(config)
_resolve_pymdownx_superfences(config)
_resolve_pymdownx_tabbed(config)
_resolve_pymdownx_blocks_tab(config)
_resolve_toc(config)
# Ensure the table of contents title is initialized, as it's used inside
# the template, and the table of contents extension is always defined
config["mdx_configs"]["toc"].setdefault("title", None)
config["mdx_configs_hash"] = _hash(mdx_configs)
# Convert plugins configuration
config["plugins"] = _convert_plugins(config.get("plugins", []), config)
# Map plugins configuration to Markdown extensions
_shim_autorefs(config)
_shim_markdown_exec(config)
_shim_mkdocstrings(config)
_shim_glightbox(config)
_shim_macros(config)
# List files along with their hashes, so we can rebuild when they change
watched_files = (
_list_sources(config, path) # mkdocstrings
| _list_snippet_files(config, path) # pymdownx.snippets
| _list_macros_files(config, path) # macros
| _list_watch_files(config, path) # watch
)
# We watch theme directories by default on the Rust side,
# so we need to prevent duplicates from here, in case
# users add theme directories to the watch option
theme_files = _list_templates(config)
watched_files -= set(theme_files)
config["watched_files"] = sorted(watched_files)
# Hash all templates, so we rebuild if something changes
config["template_hash"] = _hash(theme_files)
# Hash the entire plugins configuration.
# This is a special case for plugins because we currently only source
# the plugin configuration that we support in Rust,
# which means config on other plugins doesn't contribute to the hash,
# in turn not triggering full rebuilds.
config["plugins_hash"] = _hash(config["plugins"])
return config
def set_default(
entry: dict, key: str, default: Any, data_type: type | None = None
) -> Any:
"""Set a key to a default value if it isn't set.
Optionally cast it to the specified data type.
"""
if key in entry and entry[key] is None:
del entry[key]
# Set the default value if the key is not present
entry.setdefault(key, default)
# Optionally cast the value to the specified data type
if data_type is not None and entry[key] is not None:
try:
entry[key] = data_type(entry[key])
except (ValueError, TypeError) as e:
raise ValueError(
f"Failed to cast key '{key}' to {data_type}: {e}"
) from e
# Return the resulting value
return entry[key]
def _hash(data: Any) -> int:
"""Compute a hash for the given data."""
hash = hashlib.sha1(pickle.dumps(data)) # noqa: S324
return int(hash.hexdigest(), 16) % (2**64)
# ----------------------------------------------------------------------------
def _resolve(symbol: str) -> Any:
"""Resolve a symbol to its corresponding Python object."""
module_path, func_name = symbol.rsplit(".", 1)
module = importlib.import_module(module_path)
return getattr(module, func_name)
def _resolve_pymdownx_emoji(config: dict[str, Any]) -> None:
# Emoji extension: resolve emoji generator and index functions
emoji = config["mdx_configs"].get("pymdownx.emoji", {})
if isinstance(emoji.get("emoji_generator"), str):
emoji["emoji_generator"] = _resolve(emoji.get("emoji_generator"))
if isinstance(emoji.get("emoji_index"), str):
emoji["emoji_index"] = _resolve(emoji.get("emoji_index"))
def _resolve_pymdownx_tabbed(config: dict[str, Any]) -> None:
# Tabbed extension: resolve slugification function
tabbed = config["mdx_configs"].get("pymdownx.tabbed", {})
if isinstance(tabbed.get("slugify"), dict):
object = tabbed["slugify"].get("object", "pymdownx.slugs.slugify")
tabbed["slugify"] = _resolve(object)(
**tabbed["slugify"].get("kwds", {})
)
def _resolve_pymdownx_blocks_tab(config: dict[str, Any]) -> None:
# Blocks-tab extension: resolve slugification function
blocks_tab = config["mdx_configs"].get("pymdownx.blocks.tab", {})
if isinstance(blocks_tab.get("slugify"), dict):
object = blocks_tab["slugify"].get("object", "pymdownx.slugs.slugify")
blocks_tab["slugify"] = _resolve(object)(
**blocks_tab["slugify"].get("kwds", {})
)
def _resolve_pymdownx_superfences(config: dict[str, Any]) -> None:
# Superfences extension: resolve format and validator functions
superfences = config["mdx_configs"].get("pymdownx.superfences", {})
for fence in superfences.get("custom_fences", []):
if isinstance(fence.get("format"), str):
fence["format"] = _resolve(fence.get("format"))
elif isinstance(fence.get("format"), dict):
object = fence["format"].get(
"object", "pymdownx.superfences.fence_code_format"
)
fence["format"] = _resolve(object)(
**fence["format"].get("kwds", {})
)
if isinstance(fence.get("validator"), str):
fence["validator"] = _resolve(fence.get("validator"))
elif isinstance(fence.get("validator"), dict):
object = fence["validator"].get("object")
callable_object = (
_resolve(object) if object else lambda *args, **kwargs: True
)
fence["validator"] = callable_object(
**fence["validator"].get("kwds", {})
)
def _resolve_toc(config: dict[str, Any]) -> None:
# Table of contents extension: resolve slugification function
toc = config["mdx_configs"]["toc"]
if isinstance(toc.get("slugify"), dict):
object = toc["slugify"].get("object", "pymdownx.slugs.slugify")
toc["slugify"] = _resolve(object)(**toc["slugify"].get("kwds", {}))
# ----------------------------------------------------------------------------
def _shim_autorefs(config: dict[str, Any]) -> None:
# The Markdown extension is already enabled
if AutorefsExtension.name in config["markdown_extensions"]:
return
# Map autorefs plugin configuration to the extension configuration
if "autorefs" in config["plugins"]:
plugin = config["plugins"]["autorefs"]["config"]
if plugin.get("enabled", True):
config["markdown_extensions"].append(AutorefsExtension.name)
elif "mkdocstrings" in config["plugins"]:
# mkdocstrings enables autorefs itself
plugin = config["plugins"]["mkdocstrings"]["config"]
if plugin.get("enabled", True):
config["markdown_extensions"].append(AutorefsExtension.name)
elif "zensical.extensions.mkdocstrings" in config["markdown_extensions"]:
# same when mkdocstrings is enabled as a Markdown extension
config["markdown_extensions"].append(AutorefsExtension.name)
def _shim_markdown_exec(config: dict[str, Any]) -> None:
# Map markdown-exec plugin configuration to superfences configuration
if "markdown-exec" in config["plugins"]:
markdown_exec_config = config["plugins"]["markdown-exec"]["config"]
enabled = markdown_exec_config.pop("enabled", True)
languages = markdown_exec_config.get("languages", None)
if enabled and (languages or languages is None):
trueish = ("auto", "required", True)
ansi = markdown_exec_config.get("ansi", False) in trueish
if ansi and not find_spec("pygments_ansi_color"):
raise ConfigurationError(
"ANSI colors are required, but pygments-ansi-color is not "
"installed. Please install pygments-ansi-color or turn off "
"ANSI requirement in markdown-exec configuration."
)
if "pymdownx.superfences" not in config["markdown_extensions"]:
config["markdown_extensions"].append("pymdownx.superfences")
if "pymdownx.superfences" not in config["mdx_configs"]:
config["mdx_configs"]["pymdownx.superfences"] = {}
superfences = config["mdx_configs"]["pymdownx.superfences"]
if "custom_fences" not in superfences:
superfences["custom_fences"] = []
superfences["custom_fences"].extend(
_get_markdown_exec_superfences(languages)
)
def _shim_mkdocstrings(config: dict[str, Any]) -> None:
# The Markdown extension is already enabled
if MkdocstringsExtension.name in config["markdown_extensions"]:
return
# Map mkdocstrings plugin configuration to the extension configuration
if "mkdocstrings" in config["plugins"]:
plugin = config["plugins"]["mkdocstrings"]["config"]
if plugin.get("enabled", True):
if not find_spec("mkdocstrings"):
raise ConfigurationError(
"mkdocstrings plugin is enabled, but mkdocstrings is not "
"installed. Please install mkdocstrings or disable the "
"plugin."
)
config["markdown_extensions"].append(MkdocstringsExtension.name)
config["mdx_configs"][MkdocstringsExtension.name] = plugin
def _shim_glightbox(config: dict[str, Any]) -> None:
# The Markdown extension is already enabled
if GlightboxExtension.name in config["markdown_extensions"]:
return
# Map glightbox plugin configuration to the extension configuration
if "glightbox" in config["plugins"]:
plugin = config["plugins"]["glightbox"]["config"]
if plugin.get("enabled", True):
config["markdown_extensions"].append(GlightboxExtension.name)
config["mdx_configs"][GlightboxExtension.name] = plugin
def _shim_macros(config: dict[str, Any]) -> None:
# The Markdown extension is already enabled
if MacrosExtension.name in config["markdown_extensions"]:
return
# Map macros plugin configuration to the extension configuration
if "macros" in config["plugins"]:
plugin = config["plugins"]["macros"]["config"]
if plugin.get("enabled", True):
config["markdown_extensions"].append(MacrosExtension.name)
config["mdx_configs"][MacrosExtension.name] = plugin
# ----------------------------------------------------------------------------
def _ignore_directory(dirpath: str) -> bool:
"""Determine whether to ignore a folder based on its name."""
return dirpath == "__pycache__" or dirpath.startswith(".venv")
def _get_markdown_exec_superfences(
languages: list[str] | None = None,
) -> list[dict[str, Any]]:
"""Get superfences configuration for markdown-exec."""
try:
import markdown_exec # noqa: PLC0415 # ty:ignore[unresolved-import]
except ImportError as error:
raise ConfigurationError(
"markdown-exec plugin is enabled, but markdown-exec is not "
"installed. Please install markdown-exec or disable the plugin."
) from error
return [
{
"name": language,
"class": language,
"validator": markdown_exec.validator,
"format": markdown_exec.formatter,
}
for language in languages or markdown_exec.formatters.keys()
]
def _list_py_modules(path: Path) -> Iterator[Path]:
"""List Python modules in a directory, recursively."""
for root, dirs, files in os.walk(path, topdown=True, followlinks=True):
dirs[:] = [dir for dir in dirs if not _ignore_directory(dir)]
for relfile in files:
if os.path.splitext(relfile)[1] in {
".py",
".pyc",
".pyo",
".pyd",
".pyi",
".so",
}:
yield Path(root, relfile)
def _list_sources(config: dict, config_file: str) -> set[tuple[str, int]]:
"""List all absolute links to source files for mkdocstrings."""
python_paths = (
config["plugins"]
.get("mkdocstrings", {})
.get("config", {})
.get("handlers", {})
.get("python", {})
.get("paths", ())
)
files_with_hash = set()
root = Path(config_file).parent.resolve()
for python_path in python_paths:
path = root.joinpath(python_path).resolve()
if path.is_dir() and path.is_relative_to(root) and path != root:
for py_module in _list_py_modules(path):
files_with_hash.add(
(str(py_module), int(os.path.getmtime(py_module)))
)
return files_with_hash
def _list_snippet_files(config: dict, config_file: str) -> set[tuple[str, int]]:
"""List files referenced in pymdownx.snippets auto_append configuration."""
snippets_config = config["mdx_configs"].get("pymdownx.snippets", {})
auto_append = snippets_config.get("auto_append", [])
base_paths = snippets_config.get("base_path", ["."])
root = Path(config_file).parent.resolve()
files_with_mtime = set()
for file_name in auto_append:
for base_path in base_paths:
candidate = root.joinpath(base_path, file_name).resolve()
if candidate.is_file():
mtime = int(os.path.getmtime(candidate))
files_with_mtime.add((str(candidate), mtime))
break
return files_with_mtime
def _list_macros_files(config: dict, config_file: str) -> set[tuple[str, int]]:
"""List files referenced in macros plugin/extension."""
root = Path(config_file).parent.resolve()
macros_config = config["mdx_configs"].get(MacrosExtension.name, {})
macros_files = []
files_with_mtime = set()
module = macros_config.get("module", "main")
if (module_path := root.joinpath(module + ".py").resolve()).is_file():
macros_files.append(module_path)
pluglets = macros_config.get("modules", [])
for pluglet in pluglets:
try:
pluglet_module = importlib.import_module(pluglet)
except ImportError: # noqa: PERF203
continue
else:
macros_files.append(pluglet_module.__file__)
include_yaml: list[str] | dict[str, str] = macros_config.get(
"include_yaml", []
)
if isinstance(include_yaml, dict):
include_yaml = list(include_yaml.values())
for yaml_file in include_yaml:
candidate = root.joinpath(yaml_file).resolve()
if candidate.is_file():
macros_files.append(candidate)
for file_path in macros_files:
mtime = int(os.path.getmtime(file_path))
files_with_mtime.add((str(file_path), mtime))
include_dir = macros_config.get("include_dir", None)
if include_dir:
candidate_dir = root.joinpath(include_dir).resolve()
if candidate_dir.is_dir():
for root, _, files in os.walk(candidate_dir):
for file in files:
file_path = os.path.join(root, file)
mtime = int(os.path.getmtime(file_path))
files_with_mtime.add((file_path, mtime))
return files_with_mtime
def _list_watch_files(config: dict, config_file: str) -> set[tuple[str, int]]:
"""List files for user-defined watch paths."""
root = Path(config_file).parent.resolve()
files_with_mtime: set[tuple[str, int]] = set()
for watch_path in config.get("watch", []):
path = root.joinpath(watch_path).resolve()
if path.is_file():
mtime = int(os.path.getmtime(path))
files_with_mtime.add((str(path), mtime))
elif path.is_dir():
for dirpath, _, files in os.walk(path):
for file in files:
file_path = os.path.join(dirpath, file)
mtime = int(os.path.getmtime(file_path))
files_with_mtime.add((file_path, mtime))
return files_with_mtime
def _list_templates(config: dict) -> list[tuple[str, int]]:
"""List all template files in the theme directories."""
# Collect file paths and their mtimes
files_with_mtime = []
for directory in config["theme_dirs"]:
for root, _, files in os.walk(directory):
if ".icons" in root:
continue
for file in files:
file_path = os.path.join(root, file)
mtime = int(os.path.getmtime(file_path))
files_with_mtime.append((file_path, mtime))
# Sort by file path for deterministic order
return sorted(files_with_mtime)
# -----------------------------------------------------------------------------
def _is_index(path: str) -> bool:
"""Returns, whether the given path points to a section index."""
return os.path.basename(path) in ("index.md", "README.md")
def _convert_nav(nav: list) -> list:
"""Convert MkDocs navigation."""
return [_convert_nav_item(entry) for entry in nav]
def _convert_nav_item(item: str | dict | list) -> dict | list:
"""Convert MkDocs shorthand navigation structure into something manageable.
We need to annotate each item with a title, URL, icon, and children.
"""
if isinstance(item, str):
return {
"title": None,
"url": item,
"canonical_url": None,
"meta": None,
"children": [],
"is_index": _is_index(item),
"active": False,
}
# Handle Title: URL
if isinstance(item, dict):
for title, value in item.items():
if isinstance(value, str):
return {
"title": str(title),
"url": value.strip(),
"canonical_url": None,
"meta": None,
"children": [],
"is_index": _is_index(value.strip()),
"active": False,
}
if isinstance(value, list):
return {
"title": str(title),
"url": None,
"canonical_url": None,
"meta": None,
"children": [_convert_nav_item(child) for child in value],
"is_index": False,
"active": False,
}
raise TypeError(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)}")
def _convert_extra(data: dict | list) -> dict | list:
"""Recursively convert None values in a dictionary/list to empty strings."""
if isinstance(data, dict):
# Process each key-value pair in the dictionary
return {
key: _convert_extra(value)
if isinstance(value, (dict, list))
else ("" if value is None else value)
for key, value in data.items()
}
if isinstance(data, list):
# Process each item in the list
return [
_convert_extra(item)
if isinstance(item, (dict, list))
else ("" if item is None else item)
for item in data
]
return data
def _convert_extra_javascript(value: list) -> list:
"""Ensure extra_javascript uses a structured format."""
for i, item in enumerate(value):
if isinstance(item, str):
value[i] = {
"path": item,
"type": None,
"async": False,
"defer": False,
}
elif isinstance(item, dict):
item.setdefault("path", "")
item.setdefault("type", None)
item.setdefault("async", False)
item.setdefault("defer", False)
else:
raise TypeError(f"Unknown extra_javascript item type: {type(item)}")
# Return resulting value
return value
def _convert_markdown_extensions(value: Any) -> tuple[list[str], dict]:
"""Convert Markdown extensions to what Python Markdown expects."""
markdown_extensions = ["toc", "tables"]
mdx_configs: dict[str, dict[str, Any]] = {"toc": {}, "tables": {}}
# 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:
pymdownx = value.pop("pymdownx")
for ext, conf in pymdownx.items():
# Special case for blocks extension, which has another level of
# nesting. This is the only extension that requires this.
if ext == "blocks":
for block, config in conf.items():
value[f"pymdownx.{ext}.{block}"] = config
else:
value[f"pymdownx.{ext}"] = conf
# Same as for Python Markdown extensions, see above
if "zensical" in value:
zensical = value.pop("zensical")
for ext, conf in zensical.items():
if ext == "extensions":
for key, config in conf.items():
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)
# Return extension list and configuration, after ensuring they're unique
return list(set(markdown_extensions)), mdx_configs
def _convert_plugins(value: Any, config: dict) -> dict:
"""Convert plugins configuration to something we can work with."""
plugins = {}
# Plugins can be defined as a dict
if isinstance(value, dict):
plugins.update(value)
# Plugins can also be defined as a list
else:
for item in value:
if isinstance(item, dict):
name, data = item.popitem()
plugins[name] = data
elif isinstance(item, str):
plugins[item] = {}
# Define defaults for search plugin
search = set_default(plugins, "search", {}, dict)
set_default(search, "enabled", True, bool)
set_default(
search, "separator", '[\\s\\-_,:!=\\[\\]()\\\\"`/]+|\\.(?!\\d)', str
)
# Define defaults for offline plugin
offline = set_default(plugins, "offline", {"enabled": False}, dict)
set_default(offline, "enabled", True, bool)
# 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
# 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"]
):
script = "https://unpkg.com/iframe-worker/shim"
config["extra"]["polyfills"].append(
{
"path": script,
"type": "text/javascript",
"async": False,
"defer": False,
}
)
# 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",
{
"alias_type": "symlink",
"redirect_template": None,
"deploy_prefix": "",
"canonical_version": None,
},
dict,
)
# 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