mirror of
https://github.com/zensical/zensical.git
synced 2026-08-15 03:36:35 +00:00
830 lines
29 KiB
Python
830 lines
29 KiB
Python
# Copyright (c) 2025 Zensical and contributors
|
|
|
|
# SPDX-License-Identifier: MIT
|
|
# Third-party contributions licensed under 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 hashlib
|
|
import importlib
|
|
import os
|
|
import pickle
|
|
import yaml
|
|
|
|
try:
|
|
import tomllib
|
|
except ModuleNotFoundError:
|
|
import tomli as tomllib # type: ignore
|
|
|
|
from click import ClickException
|
|
from deepmerge import always_merger
|
|
from typing import Any, IO
|
|
from yaml import BaseLoader, Loader, YAMLError
|
|
from yaml.constructor import ConstructorError
|
|
from urllib.parse import urlparse
|
|
|
|
from .extensions.emoji import to_svg, twemoji
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Globals
|
|
# ----------------------------------------------------------------------------
|
|
|
|
|
|
_CONFIG = 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.
|
|
"""
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# 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)
|
|
else:
|
|
return parse_mkdocs_config(path)
|
|
|
|
|
|
def parse_zensical_config(path: str) -> dict:
|
|
"""
|
|
Parse zensical.toml configuration file.
|
|
"""
|
|
global _CONFIG
|
|
with open(path, "rb") as f:
|
|
config = tomllib.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
|
|
with open(path, "r") as f:
|
|
config = _yaml_load(f)
|
|
|
|
# Apply defaults and return parsed configuration
|
|
_CONFIG = _apply_defaults(config, path)
|
|
return _CONFIG
|
|
|
|
|
|
def get_config():
|
|
"""
|
|
Return configuration.
|
|
"""
|
|
return _CONFIG
|
|
|
|
|
|
def get_theme_dir() -> str:
|
|
"""
|
|
Return the theme directory.
|
|
"""
|
|
path = os.path.dirname(os.path.abspath(__file__))
|
|
return os.path.join(path, "templates")
|
|
|
|
|
|
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.
|
|
"""
|
|
if "site_name" not in config:
|
|
raise ConfigurationError("Missing required setting: site_name")
|
|
|
|
# Set site directory
|
|
set_default(config, "site_dir", "site", str)
|
|
if ".." in config.get("site_dir"):
|
|
raise ConfigurationError("site_dir must not contain '..'")
|
|
|
|
# Set docs directory
|
|
set_default(config, "docs_dir", "docs", str)
|
|
if ".." in config.get("docs_dir"):
|
|
raise ConfigurationError("docs_dir must not contain '..'")
|
|
|
|
# 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 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_url = config.get("repo_url")
|
|
if repo_url and not config.get("repo_name"):
|
|
docs_dir = config.get("docs_dir")
|
|
host = urlparse(repo_url).hostname or ""
|
|
if host == "github.com":
|
|
set_default(config, "repo_name", "GitHub", str)
|
|
set_default(config, "edit_uri", f"edit/master/{docs_dir}", str)
|
|
elif host == "gitlab.com":
|
|
set_default(config, "repo_name", "GitLab", str)
|
|
set_default(config, "edit_uri", f"edit/master/{docs_dir}", str)
|
|
elif host == "bitbucket.org":
|
|
set_default(config, "repo_name", "Bitbucket", str)
|
|
set_default(config, "edit_uri", f"src/default/{docs_dir}", str)
|
|
elif host:
|
|
config["repo_name"] = host.split(".")[0].title()
|
|
|
|
# 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
|
|
theme = set_default(config, "theme", {}, dict)
|
|
if isinstance(theme, str):
|
|
theme = {"name": theme}
|
|
config["theme"] = theme
|
|
|
|
# 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"}
|
|
|
|
# Resolve custom theme directory
|
|
set_default(theme, "custom_dir", None, str)
|
|
if theme.get("custom_dir"):
|
|
theme["custom_dir"] = os.path.join(
|
|
os.path.dirname(path), theme["custom_dir"]
|
|
)
|
|
|
|
# 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)
|
|
set_default(admonition, "note", None, str)
|
|
set_default(admonition, "abstract", None, str)
|
|
set_default(admonition, "info", None, str)
|
|
set_default(admonition, "tip", None, str)
|
|
set_default(admonition, "success", None, str)
|
|
set_default(admonition, "question", None, str)
|
|
set_default(admonition, "warning", None, str)
|
|
set_default(admonition, "failure", None, str)
|
|
set_default(admonition, "danger", None, str)
|
|
set_default(admonition, "bug", None, str)
|
|
set_default(admonition, "example", None, str)
|
|
set_default(admonition, "quote", None, str)
|
|
|
|
# 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
|
|
extra = set_default(config, "extra", {}, dict)
|
|
set_default(extra, "homepage", None, str)
|
|
set_default(extra, "scope", None, str)
|
|
set_default(extra, "annotate", {}, dict)
|
|
set_default(extra, "tags", {}, dict)
|
|
set_default(extra, "generator", True, bool)
|
|
set_default(extra, "polyfills", [], list)
|
|
set_default(extra, "analytics", None, dict)
|
|
|
|
# Set defaults for extra analytics settings
|
|
analytics = extra.get("analytics")
|
|
if analytics:
|
|
set_default(analytics, "provider", None, str)
|
|
set_default(analytics, "property", None, str)
|
|
set_default(analytics, "feedback", None, dict)
|
|
|
|
# Set defaults for extra analytics feedback settings
|
|
feedback = analytics.get("feedback")
|
|
if feedback:
|
|
set_default(feedback, "title", None, str)
|
|
set_default(feedback, "ratings", [], list)
|
|
|
|
# Set defaults for each rating entry
|
|
ratings = feedback.setdefault("ratings", [])
|
|
for entry in ratings:
|
|
set_default(entry, "icon", None, str)
|
|
set_default(entry, "name", None, str)
|
|
set_default(entry, "data", None, str)
|
|
set_default(entry, "note", None, str)
|
|
|
|
# Set defaults for extra consent settings
|
|
consent = extra.setdefault("consent", None)
|
|
if consent:
|
|
set_default(consent, "title", None, str)
|
|
set_default(consent, "description", None, str)
|
|
set_default(consent, "actions", [], list)
|
|
|
|
# Set defaults for extra consent cookie settings
|
|
cookies = consent.setdefault("cookies", {})
|
|
for key, value in cookies.items():
|
|
if isinstance(value, str):
|
|
cookies[key] = {"name": value, "checked": False}
|
|
|
|
# Set defaults for each cookie entry
|
|
set_default(cookies[key], "name", None, str)
|
|
set_default(cookies[key], "checked", False, bool)
|
|
|
|
# Set defaults for extra social settings
|
|
social = extra.setdefault("social", [])
|
|
for entry in social:
|
|
set_default(entry, "icon", None, str)
|
|
set_default(entry, "name", None, str)
|
|
set_default(entry, "link", None, str)
|
|
|
|
# Set defaults for extra alternate settings
|
|
alternate = extra.setdefault("alternate", [])
|
|
for entry in alternate:
|
|
set_default(entry, "name", None, str)
|
|
set_default(entry, "link", None, str)
|
|
set_default(entry, "lang", None, str)
|
|
|
|
# Set defaults for extra version settings
|
|
version = extra.setdefault("version", None)
|
|
if version:
|
|
set_default(version, "provider", None, str)
|
|
set_default(version, "default", None, str)
|
|
set_default(version, "alias", False, bool)
|
|
|
|
# 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", [])
|
|
)
|
|
|
|
# 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",
|
|
{
|
|
"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": {},
|
|
},
|
|
)
|
|
)
|
|
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.
|
|
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"))
|
|
|
|
# Tabbed extension configuration - 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"))
|
|
|
|
# Table of contents extension configuration - 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"))
|
|
|
|
# Superfences extension configuration - resolve format function
|
|
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"))
|
|
|
|
# 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)
|
|
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, and 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}")
|
|
|
|
# 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))
|
|
return int(hash.hexdigest(), 16) % (2**64)
|
|
|
|
|
|
def _convert_extra(data: dict | list) -> dict | list:
|
|
"""
|
|
Recursively convert all None values in a dictionary or 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()
|
|
}
|
|
elif 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
|
|
]
|
|
else:
|
|
return data
|
|
|
|
|
|
def _resolve(symbol: str):
|
|
"""
|
|
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 _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 more manageable
|
|
as 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
|
|
elif isinstance(item, dict):
|
|
for title, value in item.items():
|
|
if isinstance(value, str):
|
|
return {
|
|
"title": str(title),
|
|
"url": value,
|
|
"canonical_url": None,
|
|
"meta": None,
|
|
"children": [],
|
|
"is_index": _is_index(value),
|
|
"active": False,
|
|
}
|
|
elif 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,
|
|
}
|
|
|
|
# Handle a list of items
|
|
elif isinstance(item, list):
|
|
return [_convert_nav_item(child) for child in item]
|
|
else:
|
|
raise ValueError(f"Unknown nav item type: {type(item)}")
|
|
|
|
|
|
def _is_index(path: str) -> bool:
|
|
"""
|
|
Returns, whether the given path points to a section index.
|
|
"""
|
|
return path.endswith(("index.md", "README.md"))
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def _convert_extra_javascript(value: list[Any]) -> 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 ValueError(
|
|
f"Unknown extra_javascript item type: {type(item)}"
|
|
)
|
|
|
|
# Return resulting value
|
|
return value
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def _convert_markdown_extensions(value: Any):
|
|
"""
|
|
Convert Markdown extensions configuration to what Python Markdown expects.
|
|
"""
|
|
markdown_extensions = ["toc", "tables"]
|
|
mdx_configs = {"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, config 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 config.items():
|
|
value[f"pymdownx.{ext}.{block}"] = config
|
|
else:
|
|
value[f"pymdownx.{ext}"] = config
|
|
|
|
# Same as for Python Markdown extensions, see above
|
|
if "zensical" in value:
|
|
zensical = value.pop("zensical")
|
|
for ext, config in zensical.items():
|
|
if ext == "extensions":
|
|
for key, config in config.items():
|
|
value[f"zensical.{ext}.{key}"] = config
|
|
else:
|
|
value[f"zensical.{ext}"] = config
|
|
|
|
# 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):
|
|
for name, data in value.items():
|
|
plugins[name] = data
|
|
|
|
# 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"):
|
|
config["use_directory_urls"] = False
|
|
|
|
# Append iframe-worker to polyfills/shims
|
|
if not any(
|
|
"iframe-worker" in 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,
|
|
}
|
|
)
|
|
|
|
# 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
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
|
|
|
|
def _yaml_load(
|
|
source: IO, loader: type[BaseLoader] | None = None
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Load configuration file and 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 = loader or 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,
|
|
)
|
|
except YAMLError as e:
|
|
raise ConfigurationError(
|
|
f"Encountered an error parsing the configuration file: {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}' doesn't exist at '{abspath}'."
|
|
)
|
|
with open(abspath, "r") as fd:
|
|
parent = _yaml_load(fd, loader)
|
|
config = always_merger.merge(parent, config)
|
|
|
|
# Return resulting configuration
|
|
return config
|
|
|
|
|
|
def _construct_env_tag(loader: yaml.Loader, node: yaml.Node):
|
|
"""
|
|
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}",
|
|
start_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
|