mirror of
https://github.com/zensical/zensical.git
synced 2026-08-23 07:36:52 +00:00
feature: support macros plugin
Signed-off-by: Timothée Mazzucotelli <dev@pawamoy.fr>
This commit is contained in:
@@ -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 }}")
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user