feature: support macros plugin

Signed-off-by: Timothée Mazzucotelli <dev@pawamoy.fr>
This commit is contained in:
Timothée Mazzucotelli
2026-05-04 14:40:04 +00:00
committed by GitHub
parent 91090e9917
commit f7fe088a37
7 changed files with 1262 additions and 46 deletions
+407
View File
@@ -0,0 +1,407 @@
# 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 TYPE_CHECKING, Any
import pytest
from jinja2.exceptions import TemplateSyntaxError, UndefinedError
from markdown import Markdown
from zensical.extensions.context import (
ContextExtension,
ContextPreprocessor,
Page,
)
from zensical.extensions.emoji import to_svg, twemoji
from zensical.extensions.macros import (
MacroEnv,
MacrosExtension,
_fix_url,
_format_value,
_load_module,
_load_one_yaml,
_make_table,
_merge_include_yaml,
_pretty,
)
if TYPE_CHECKING:
from pathlib import Path
MINIMAL_EXTENSIONS = {}
RECOMMENDED_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": {},
}
def _page(meta: dict[str, Any] | None = None) -> dict[str, Any]:
return {
"url": "/",
"path": "index.md",
"meta": dict(meta or {}),
}
@pytest.fixture(
name="project_config",
params=[MINIMAL_EXTENSIONS, RECOMMENDED_EXTENSIONS],
ids=["minimal_markdown", "recommended_markdown"],
)
def _fixture_project_config(request: pytest.FixtureRequest) -> dict[str, Any]:
active_markdown = dict(request.param)
return {"site_name": "Demo", "markdown_extensions": active_markdown}
@pytest.fixture(name="md")
def _fixture_md(
project_config: dict[str, Any],
request: pytest.FixtureRequest,
tmp_path: Path,
) -> Markdown:
"""Return a Markdown instance with MacrosExtension registered."""
fixture_param = dict(getattr(request, "param", {}))
allowed_keys = {"macros", "page", "project_config"}
unexpected_keys = set(fixture_param) - allowed_keys
if unexpected_keys:
raise ValueError(
f"Unsupported md fixture params: {sorted(unexpected_keys)}. "
"Use only 'macros', 'page', and 'project_config'."
)
macro_config = dict(fixture_param.get("macros", {}))
page = fixture_param.get("page", Page(url="/", path="index.md"))
if isinstance(page, dict):
page = Page(**page)
project_overrides = dict(fixture_param.get("project_config", {}))
effective_project_config = {**project_config, **project_overrides}
if "root_dir" not in effective_project_config:
effective_project_config["root_dir"] = str(tmp_path)
markdown_extensions = dict(
effective_project_config.get("markdown_extensions", {})
)
md = Markdown(
extensions=list(markdown_extensions.keys()),
extension_configs=markdown_extensions,
)
ContextExtension(page=page, config=effective_project_config).extendMarkdown(
md
)
MacrosExtension(**macro_config).extendMarkdown(md)
return md
@pytest.mark.parametrize(
("url", "expected"),
[
("page.html", "../page.html"),
("assets/img.png", "../assets/img.png"),
("https://example.org", "https://example.org"),
("mailto:test@example.org", "mailto:test@example.org"),
],
)
def test_fix_url(url: str, expected: str) -> None:
assert _fix_url(url) == expected
def test_macro_env_registers_macros_and_filters() -> None:
env = MacroEnv()
@env.macro
def twice(value: int) -> int:
return value * 2
@env.filter(name="rev")
def reverse(value: str) -> str:
return value[::-1]
assert env.macros["twice"](4) == 8
assert env.filters["rev"]("abc") == "cba"
@pytest.mark.parametrize(
("payload", "expected"),
[
([("alpha", "str", "hello")], "**alpha** | *str* | hello"),
([], ""),
],
)
def test_pretty(payload: list[tuple[str, str, str]], expected: str) -> None:
output = _pretty(payload)
if expected:
assert expected in output
else:
assert output == ""
def test_make_table_escapes_pipe() -> None:
table = _make_table(
rows=[("left|right", "type", "a|b")],
header=("Variable", "Type", "Content"),
)
assert "left\\|right" in table
assert "a\\|b" in table
def test_format_value_for_callable_and_dict() -> None:
def sample(name: str) -> str:
"""Doc first line.\nIgnored."""
return name
class Obj:
pass
value = {
"count": 1,
"name": "hello",
"obj": Obj(),
}
rendered_callable = _format_value(sample)
rendered_dict = _format_value(value)
assert "(*name*)" in rendered_callable
assert "Doc first line." in rendered_callable
assert "**count** = 1" in rendered_dict
assert "**obj** [*Obj*]" in rendered_dict
@pytest.mark.parametrize(
("relative_path", "content", "expected"),
[
("ok.yaml", "a: 1\nb: x\n", {"a": 1, "b": "x"}),
("not_dict.yaml", "- 1\n- 2\n", None),
],
)
def test_load_one_yaml_with_relative_paths(
tmp_path: Path,
relative_path: str,
content: str,
expected: dict | None,
) -> None:
(tmp_path / relative_path).write_text(content, encoding="utf-8")
loaded = _load_one_yaml(relative_path, tmp_path)
assert loaded == expected
def test_load_one_yaml_blocks_outside_project_root(tmp_path: Path) -> None:
outside = tmp_path.parent / "outside.yaml"
outside.write_text("x: 1\n", encoding="utf-8")
loaded = _load_one_yaml(str(outside), tmp_path)
assert loaded is None
@pytest.mark.parametrize(
"include_yaml",
[
["a.yaml", "b.yaml"],
{"left": "a.yaml", "right": "b.yaml"},
],
)
def test_merge_include_yaml_list_and_dict(
tmp_path: Path,
include_yaml: list[str] | dict[str, str],
) -> None:
(tmp_path / "a.yaml").write_text("x: 1\n", encoding="utf-8")
(tmp_path / "b.yaml").write_text("y: 2\n", encoding="utf-8")
variables: dict = {}
_merge_include_yaml(include_yaml, tmp_path, variables)
if isinstance(include_yaml, list):
assert variables == {"x": 1, "y": 2}
else:
assert variables == {"left": {"x": 1}, "right": {"y": 2}}
def test_load_module_from_local_file(tmp_path: Path) -> None:
(tmp_path / "main.py").write_text(
"def define_env(env):\n"
" env.variables['site_name'] = 'Demo'\n"
" @env.macro\n"
" def twice(x):\n"
" return x * 2\n"
" @env.filter\n"
" def shout(s):\n"
" return s.upper()\n",
encoding="utf-8",
)
variables, macros, filters = _load_module("main", tmp_path)
assert variables["site_name"] == "Demo"
assert macros["twice"](3) == 6
assert filters["shout"]("hi") == "HI"
@pytest.mark.parametrize("module_name", ["../evil", "foo/bar", r"foo\\bar"])
def test_load_module_rejects_non_package_like_module_names(
module_name: str,
) -> None:
assert _load_module(module_name) == ({}, {}, {})
@pytest.mark.parametrize(
("md", "expected"),
[
({"macros": {"render_by_default": False}}, "<p>Value: {{ 1 + 1 }}</p>"),
({"macros": {"render_by_default": True}}, "<p>Value: 2\n</p>"),
],
indirect=["md"],
)
def test_preprocessor_respects_render_by_default(
md: Markdown, expected: str
) -> None:
source = "Value: {{ 1 + 1 }}"
assert md.convert(source) == expected
@pytest.mark.parametrize(
"md",
[
{
"macros": {"render_by_default": False},
"page": _page({"render_macros": True}),
},
],
indirect=True,
)
def test_preprocessor_renders_when_opted_in_by_page_meta(md: Markdown) -> None:
assert md.convert("Value: {{ 1 + 1 }}") == "<p>Value: 2\n</p>"
@pytest.mark.parametrize(
"md",
[
{
"macros": {
"render_by_default": True,
"include_yaml": ["vars.yaml"],
"module_name": "main",
},
"page": _page({"render_macros": True}),
"project_config": {"extra": {"who": "world"}},
},
],
indirect=True,
)
def test_preprocessor_renders_with_include_yaml_and_module(
md: Markdown,
tmp_path: Path,
) -> None:
(tmp_path / "vars.yaml").write_text("name: Ada\n", encoding="utf-8")
(tmp_path / "main.py").write_text(
"def define_env(env):\n"
" @env.macro\n"
" def greet(name):\n"
" return f'Hello {name}!'\n",
encoding="utf-8",
)
rendered = md.convert("{{ greet(name) }}\n\n{{ who }}")
assert "Hello Ada!" in rendered
assert "world" in rendered
@pytest.mark.parametrize(
"md",
[{"macros": {"render_by_default": True}}],
indirect=True,
)
def test_preprocessor_error_handling_keep_text_by_default(md: Markdown) -> None:
assert "{{ not_closed" in md.convert("{{ not_closed")
@pytest.mark.parametrize(
"md",
[{"macros": {"render_by_default": True, "on_error_fail": True}}],
indirect=True,
)
def test_preprocessor_error_handling_raises_when_enabled(md: Markdown) -> None:
with pytest.raises(TemplateSyntaxError):
md.convert("{{ not_closed")
@pytest.mark.parametrize(
"md",
[
{
"macros": {"render_by_default": True},
"page": _page({"render_macros": True, "title": "Doc {{ 2 + 3 }}"}),
},
],
indirect=True,
)
def test_preprocessor_renders_jinja_in_title_meta(md: Markdown) -> None:
md.convert("# {{ title }}")
context = ContextPreprocessor.from_markdown(md)
assert context is not None
assert context.page.meta["title"] == "Doc 5"
@pytest.mark.parametrize(
"md",
[
{
"macros": {
"render_by_default": True,
"on_undefined": "strict",
"on_error_fail": True,
},
}
],
indirect=True,
)
def test_preprocessor_strict_undefined_raises(md: Markdown) -> None:
with pytest.raises(UndefinedError):
md.convert("{{ missing_variable }}")
+57 -5
View File
@@ -43,7 +43,7 @@ from yaml.constructor import ConstructorError
from zensical.compat.autorefs import get_autorefs_extension
from zensical.compat.mkdocstrings import get_mkdocstrings_extension
from zensical.extensions import glightbox
from zensical.extensions import glightbox, macros
from zensical.extensions.emoji import to_svg, twemoji
if TYPE_CHECKING:
@@ -593,10 +593,17 @@ def _apply_defaults(config: dict, path: str) -> dict:
)
)
# Map macros plugin configuration to the extension configuration
if "macros" in config["plugins"]:
plugin = config["plugins"]["macros"]["config"]
config["markdown_extensions"].append(macros.MacrosExtension.name)
config["mdx_configs"][macros.MacrosExtension.name] = plugin
# List files along with their hashes, so we can rebuild when they change
config["watched_files"] = sorted(
_list_sources(config, path) # mkdocstrings
| _list_snippet_files(config, path) # pymdownx.snippets
| _list_macros_files(config, path) # macros
)
# Hash all templates, so we rebuild if something changes
@@ -681,15 +688,13 @@ def _list_sources(config: dict, config_file: str) -> set[tuple[str, int]]:
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( # noqa: PERF401
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]]:
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", [])
@@ -708,6 +713,53 @@ def _list_snippet_files(
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(macros.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_templates(config: dict) -> list[tuple[str, int]]:
"""List all template files in the theme directories."""
# Collect file paths and their mtimes
+654
View File
@@ -0,0 +1,654 @@
# 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 importlib.util
import inspect
import platform
import subprocess
import traceback
from collections.abc import Callable, Iterable
from dataclasses import asdict, dataclass, field
from datetime import datetime
from functools import cache
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, TypeAlias
from urllib.parse import urlparse
import jinja2
import yaml
from jinja2.exceptions import UndefinedError
from markdown import Extension
from markdown.preprocessors import Preprocessor
from zensical.extensions.context import ContextPreprocessor
if TYPE_CHECKING:
from jinja2 import Environment
from markdown import Markdown
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
VariablesType: TypeAlias = dict[str, Any]
MacrosType: TypeAlias = dict[str, Callable[..., Any]]
FiltersType: TypeAlias = dict[str, Callable[..., Any]]
VariablesMacrosFiltersType: TypeAlias = tuple[
VariablesType, MacrosType, FiltersType
]
_MACROS_INFO = """
{#
Template for the macro_info() command
(C) Laurent Franceschetti 2019
#}
## Macros Plugin Environment
### General List
All available variables and filters within the macros plugin:
{{ context() | pretty }}
### Config Information
Standard configuration information. Do not try to modify.
e.g. {{ "`{{ config.docs_dir }}`" }}
See also the [MkDocs documentation on the config object](https://www.MkDocs.org/user-guide/custom-themes/#config).
{{ context(config)| pretty }}
### Macros
These macros have been defined programmatically for this environment
(module or pluglets).
{{ context(macros)| pretty }}
### Git Information
Information available on the last commit and the git repository containing the
documentation project:
e.g. {{ "`{{ git.message }}`" }}
{{ context(git)| pretty }}
### Page Attributes
Provided by MkDocs. These attributes change for every page
(the attributes shown are for this page).
e.g. {{ "`{{ page.title }}`" }}
See also the [MkDocs documentation on the page object](https://www.MkDocs.org/user-guide/custom-themes/#page).
{{ context(page)| pretty }}
To have all titles of all pages, use:
{% raw %}
{% for page in navigation.pages %}
- {{ page.title }}
{% endfor %}
{% endraw %}
### Plugin Filters
These filters are provided as a standard by the macros plugin.
{{ context(filters)| pretty }}
### Builtin Jinja2 Filters
These filters are provided by Jinja2 as a standard.
See also the [Jinja2 documentation on builtin filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#builtin-filters).
{{ context(filters_builtin) | pretty }}
"""
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class MacroEnv:
"""Minimal env object for compatibility with MkDocs Macros."""
def __init__(self) -> None:
self.variables: VariablesType = {}
self.macros: MacrosType = {}
self.filters: FiltersType = {}
def macro(
self, fn: Callable[..., Any] | None = None, name: str | None = None
) -> Any:
"""Register a macro.
Use as `@env.macro` or `env.macro(func)` or `env.macro(func, 'name')`.
"""
if fn is None:
return lambda f: self.macro(f, name)
self.macros[name or fn.__name__] = fn # ty:ignore[unresolved-attribute]
return fn
def filter(
self, fn: Callable[..., Any] | None = None, name: str | None = None
) -> Any:
"""Register a filter.
Use as `@env.filter` or `env.filter(func)`.
"""
if fn is None:
return lambda f: self.filter(f, name)
self.filters[name or fn.__name__] = fn # ty:ignore[unresolved-attribute]
return fn
@dataclass
class MacrosConfig:
"""Configuration for the macros Markdown extension."""
module_name: str = "main"
modules: list[str] = field(default_factory=list)
include_yaml: list[str] | dict[str, str] = field(default_factory=list)
include_dir: str = ""
render_by_default: bool = True
on_error_fail: bool = False
on_undefined: Literal["keep", "strict"] = "keep"
verbose: bool = False
j2_block_start_string: str = "{%"
j2_block_end_string: str = "%}"
j2_variable_start_string: str = "{{"
j2_variable_end_string: str = "}}"
j2_comment_start_string: str = "{#"
j2_comment_end_string: str = "#}"
j2_extensions: list[str] = field(default_factory=list)
class MacrosPreprocessor(Preprocessor):
"""Build Jinja2 context, render body."""
name = "macros"
def __init__(
self,
md: Markdown,
*,
config: MacrosConfig,
) -> None:
self.md: Markdown = md
self.config = config
def run(self, lines: list[str]) -> list[str]:
"""Render body as Jinja2 template with built context."""
# Fetch rendering context from our context preprocessor
context = ContextPreprocessor.from_markdown(self.md)
page = context.page if context else None
project_config = context.config if context else {}
project_root = Path(project_config.get("root_dir", ".")).resolve()
# Don't render if not enabled by default and no page-level override
if (
not self.config.render_by_default
and (not page or (page and not page.meta.get("render_macros")))
) or (page and page.meta.get("render_macros") is False):
return lines
text = "\n".join(lines)
variables = {}
macros = {}
filters: dict[str, Callable] = {
"pretty": _pretty,
"fix_url": _fix_url,
}
# Merge extra into variables
if extra := project_config.get("extra"):
variables["extra"] = extra
variables.update(extra)
# Load YAML from configuration
_merge_include_yaml(
self.config.include_yaml,
project_root,
variables,
)
# Load YAML data from page metadata
if page:
_merge_include_yaml(
page.meta.get("include_yaml", []),
project_root,
variables,
)
# Load module.
# Relative path (without extension) or importable module name
if self.config.module_name:
mod_vars, mod_macros, mod_filters = _load_module(
self.config.module_name,
project_root,
)
variables.update(mod_vars)
macros.update(mod_macros)
filters.update(mod_filters)
# Load pluglets (preinstalled modules)
# Importable module names only
for plug in self.config.modules:
plug_vars, plug_macros, plug_filters = _load_module(plug)
variables.update(plug_vars)
macros.update(plug_macros)
filters.update(plug_filters)
# Merge page metadata
if page:
variables.update(page.meta)
# Build Jinja2 environment
env_kw: dict[str, Any] = {
"block_start_string": self.config.j2_block_start_string,
"block_end_string": self.config.j2_block_end_string,
"variable_start_string": self.config.j2_variable_start_string,
"variable_end_string": self.config.j2_variable_end_string,
}
if self.config.j2_comment_start_string is not None:
env_kw["comment_start_string"] = self.config.j2_comment_start_string
if self.config.j2_comment_end_string is not None:
env_kw["comment_end_string"] = self.config.j2_comment_end_string
if self.config.on_undefined == "strict":
env_kw["undefined"] = jinja2.StrictUndefined
if self.config.j2_extensions:
env_kw["extensions"] = self.config.j2_extensions
if (
self.config.include_dir
and (
include_dir_path := project_root / self.config.include_dir
).exists()
):
env_kw["loader"] = jinja2.FileSystemLoader(include_dir_path)
env = jinja2.Environment(**env_kw) # noqa: S701
# Store builtin Jinja filters before adding our own
builtin_filters = env.filters.copy()
# Add user macros and filters to the environment
env.filters.update(filters)
env.globals.update(macros)
# Add our own macros and variables to the environment
env_globals: dict[str, Any] = {
"config": project_config,
"context": _context_closure(variables),
"environment": _get_env_info(),
"files": [],
"filters": filters,
"filters_builtin": builtin_filters,
"git": _get_git_info(),
"macros": macros,
"navigation": [],
"now": _now,
"plugin": asdict(self.config),
}
if page:
env_globals["page"] = page
env.globals.update(env_globals)
# This copies the environment filters and globals
# into a new environment so this call must be last
env.globals["macros_info"] = _macros_info_closure(env) # ty:ignore[invalid-assignment]
# Make global variables accessible to `context()` macro
variables.update(env.globals)
# Render title if it contains Jinja2 syntax
title = variables.get("title")
if isinstance(title, str) and (
self.config.j2_variable_start_string in title
or self.config.j2_block_start_string in title
):
try:
title_template = env.from_string(title)
new_title = title_template.render(**variables)
except Exception: # noqa: BLE001
pass
else:
variables["title"] = new_title
if page:
page.meta["title"] = new_title
# Render body and return it
try:
template = env.from_string(text)
rendered = template.render(**variables)
except Exception:
if self.config.on_error_fail:
raise
rendered = text
return rendered.split("\n")
class MacrosExtension(Extension):
"""Jinja2 templating with variables, macros, and filters."""
name = "zensical.extensions.macros"
def __init__(self, **kwargs: Any) -> None:
self._kwargs: dict[str, Any] = kwargs
def extendMarkdown(self, md: Markdown) -> None:
md.registerExtension(self)
config = MacrosConfig(**self._kwargs)
md.preprocessors.register(
MacrosPreprocessor(md, config=config),
MacrosPreprocessor.name,
priority=20,
)
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
def makeExtension(**kwargs: Any) -> MacrosExtension:
"""Register Markdown extension."""
return MacrosExtension(**kwargs)
def _now() -> datetime:
"""Return current datetime (`datetime.now()`)."""
return datetime.now() # noqa: DTZ005
def _macros_info_closure(env: Environment) -> Callable[[], str]:
new_env = jinja2.Environment() # noqa: S701
new_env.filters.update(env.filters)
new_env.globals.update(env.globals)
def macros_info() -> str:
"""Display info about the macros environment, for debugging purposes."""
return new_env.from_string(_MACROS_INFO).render()
return macros_info
def _fix_url(url: str) -> str:
parsed = urlparse(url)
if (not parsed.scheme) and parsed.path:
return "../" + url
return url
def _list_items(obj: Any) -> Iterable[tuple[str | int, Any]]:
try:
return sorted(obj.items())
except AttributeError:
return sorted(obj.__dict__.items())
except TypeError:
return enumerate(list(obj))
def _format_value(value: Any) -> str:
if callable(value):
if doc := value.__doc__:
doc = doc.strip().split("\n", 1)[0]
else:
return ""
try:
param_names = ", ".join(inspect.signature(value).parameters)
except ValueError:
return doc
else:
return f"(*{param_names}*)<br>{doc}" if param_names else doc
elif isinstance(value, dict):
r_list = []
for key, val in _list_items(value):
if isinstance(val, (int, float, str, list, dict)) or val is None:
r_list.append(f"**{key}** = {val!r}")
else:
r_list.append(f"**{key}** [*{type(val).__name__}*]")
return ", ".join(r_list)
else:
return repr(value)
def _context_closure(
variables: dict[str, Any],
) -> Callable[[Any], list[tuple[Any, Any, str]]]:
def context(obj: Any = None) -> list[tuple[Any, Any, str]]:
"""Display macros context (single object or all context)."""
if obj is None:
obj = variables
try:
return [
(var, type(value).__name__, _format_value(value))
for var, value in _list_items(obj)
]
except UndefinedError as e:
return [("*Error!*", type(e).__name__, str(e))]
except AttributeError:
# Not an object or dictionary (int, str, etc.)
return [(obj, type(obj).__name__, repr(obj))]
return context
def _make_table(
rows: list[tuple[str, str, str]],
header: tuple[str, str, str],
) -> str:
def _escape_cell(value: str) -> str:
return value.replace("|", r"\|")
header_line = " | ".join(_escape_cell(item) for item in header)
separator_line = " | ".join(["---"] * len(header))
body_lines = [
" | ".join(_escape_cell(str(item)) for item in row) for row in rows
]
return "\n".join([header_line, separator_line, *body_lines])
def _pretty(var_list: list[Any]) -> str:
if not var_list:
return ""
rows = [
(
f"**{var}**",
f"*{var_type}*",
content.replace("\n", "<br>"),
)
for var, var_type, content in var_list
]
try:
return _make_table(rows, ("Variable", "Type", "Content"))
except Exception as error: # noqa: BLE001
return f"#{type(error).__name__}: {error}\n{traceback.format_exc()}"
def _load_module(
module_name: str, project_root: Path | None = None
) -> VariablesMacrosFiltersType:
"""Load a module by name (e.g. 'main')."""
if project_root:
for candidate in [
project_root / f"{module_name}.py",
project_root / module_name / "__init__.py",
]:
if not candidate.exists() or not candidate.is_relative_to(
project_root
):
continue
spec = importlib.util.spec_from_file_location(
module_name, candidate
)
if spec and spec.loader:
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
if hasattr(mod, "define_env"):
env = MacroEnv()
mod.define_env(env)
return env.variables, env.macros, env.filters
break
# Only try import for package-like names (no path separators or "..").
if "/" in module_name or "\\" in module_name or ".." in module_name:
return {}, {}, {}
try:
mod = importlib.import_module(module_name)
except ImportError:
pass
else:
if hasattr(mod, "define_env"):
env = MacroEnv()
mod.define_env(env)
return env.variables, env.macros, env.filters
return {}, {}, {}
def _load_one_yaml(
path: str,
project_root: Path,
) -> VariablesType | None:
"""Load a single YAML file. Path must be relative to project root."""
p = (
(project_root / path).resolve()
if not Path(path).is_absolute()
else Path(path).resolve()
)
if not p.exists() or not p.is_relative_to(project_root):
return None
try:
with open(p, encoding="utf-8") as f:
data = yaml.safe_load(f)
except (OSError, ValueError):
return None
else:
return data if isinstance(data, dict) else None
def _merge_include_yaml(
include_yaml: list[str] | dict[str, str],
project_root: Path,
variables: VariablesType,
) -> None:
"""Merge external data into variables."""
if not include_yaml:
return
if isinstance(include_yaml, dict):
for key, path in include_yaml.items():
if data := _load_one_yaml(path, project_root):
variables[key] = data
else:
for path in include_yaml:
if data := _load_one_yaml(path, project_root):
variables.update(data)
@cache
def _get_git_info() -> dict[str, Any]:
"""Return Git metadata for the current repository."""
commands: dict[str, tuple[str, ...]] = {
"short_commit": ("git", "rev-parse", "--short", "HEAD"),
"commit": ("git", "rev-parse", "HEAD"),
"tag": ("git", "describe", "--tags"),
# With --abbrev set to 0, git finds the nearest tag name without suffix
"short_tag": ("git", "describe", "--tags", "--abbrev=0"),
"author": ("git", "log", "-1", "--pretty=format:%an"),
"author_email": ("git", "log", "-1", "--pretty=format:%ae"),
"committer": ("git", "log", "-1", "--pretty=format:%cn"),
"committer_email": (
"git",
"log",
"-1",
"--pretty=format:%ce",
),
# %cI is strict ISO 8601 commit date
"date_ISO": ("git", "log", "-1", "--pretty=format:%cI"),
"message": ("git", "log", "-1", "--pretty=format:%B"),
"raw": ("git", "log", "-1"),
"root_dir": ("git", "rev-parse", "--show-toplevel"),
}
result: dict[str, Any] = {"status": False, "date": None}
for field_name, git_command in commands.items():
try:
output = subprocess.check_output( # noqa: S603
git_command,
text=True,
stderr=subprocess.DEVNULL,
).strip()
except FileNotFoundError as error: # noqa: PERF203
# Git executable not found, abort early.
return {
"status": False,
"diagnosis": "Git command not found",
"error": str(error),
"date": None,
}
except subprocess.CalledProcessError as error:
if error.returncode == 128: # noqa: PLR2004
# Usually means no git repo or no tags.
result[field_name] = ""
else:
result[field_name] = (
f"# Cannot execute '{git_command}': {error}"
)
except Exception as error: # noqa: BLE001
result[field_name] = f"# Unexpected error '{git_command}': {error}"
else:
result[field_name] = output
if field_name == "date_ISO":
result["date"] = datetime.fromisoformat(
output.replace("Z", "+00:00")
)
result["status"] = True
return result
@cache
def _get_env_info() -> dict[str, str]:
sys_name = platform.system() or "<UNKNOWN>"
sys_name = {"Darwin": "MacOs"}.get(sys_name, sys_name)
return {
"system": sys_name,
"system_version": platform.release(),
"python_version": platform.python_version(),
"mkdocs_version": "1.6.1",
"macros_plugin_version": "1.3.7",
"jinja2_version": jinja2.__version__ if jinja2 else "0.0.0",
}
+1 -1
View File
@@ -127,7 +127,7 @@ def render(content: str, path: str, url: str) -> dict:
if meta.get("search", {}).get("exclude", False):
search_processor.data = []
# Sanitize metadata before passing it to Rust.
# Sanitize metadata before passing it to Rust
meta = {k: _sanitize(v) for k, v in meta.items()}
# Return Markdown with metadata