refactor: pass rendering context into preprocessor

Signed-off-by: Timothée Mazzucotelli <dev@pawamoy.fr>
This commit is contained in:
Timothée Mazzucotelli
2026-05-04 09:55:15 +00:00
committed by GitHub
parent c23fb92789
commit b2fdc788d4
12 changed files with 205 additions and 196 deletions
+2
View File
@@ -46,6 +46,8 @@ use super::validation::Validation;
#[derive(Debug, Hash, FromPyObject, Serialize)]
#[pyo3(from_item_all)]
pub struct Project {
/// Project root directory.
pub root_dir: PathBuf,
/// Site name.
pub site_name: String,
/// Site URL.
+6 -12
View File
@@ -30,6 +30,8 @@ if TYPE_CHECKING:
AutorefsExtension,
)
from zensical.extensions.context import Page
# ----------------------------------------------------------------------------
# Global variables
@@ -40,19 +42,11 @@ AUTOREFS: AutorefsPlugin | None = None
# ----------------------------------------------------------------------------
# Classes
# ----------------------------------------------------------------------------
class AutorefsPage:
"""Mock MkDocs pages."""
def __init__(self, url: str, path: str):
self.url = url
self.path = path
class AutorefsPlugin:
"""Mock the autorefs plugin (data store)."""
def __init__(self) -> None:
self.current_page: AutorefsPage | None = None
self.current_page: Page | None = None
self.scan_toc: bool = True
self.record_backlinks: bool = False
@@ -63,7 +57,7 @@ class AutorefsPlugin:
def register_anchor(
self,
page: AutorefsPage,
page: Page,
identifier: str,
anchor: str | None = None,
*,
@@ -106,10 +100,10 @@ def get_autorefs_extension() -> AutorefsExtension | None:
return AutorefsExtension(get_autorefs_plugin())
def set_autorefs_page(url: str, path: str) -> None:
def set_autorefs_page(page: Page) -> None:
"""Set the current page for autorefs."""
plugin = get_autorefs_plugin()
plugin.current_page = AutorefsPage(url=url, path=path)
plugin.current_page = page
def get_autorefs_data() -> dict[str, Any]:
+3 -1
View File
@@ -183,6 +183,8 @@ def _apply_defaults(config: dict, path: str) -> dict:
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.
"""
project_root = config["root_dir"] = os.path.dirname(path)
if "site_name" not in config:
raise ConfigurationError("Missing required setting: site_name")
@@ -197,7 +199,7 @@ def _apply_defaults(config: dict, path: str) -> dict:
raise ConfigurationError("docs_dir must not contain '..'")
# Validate that docs directory exists
docs_dir_path = os.path.join(os.path.dirname(path), config["docs_dir"])
docs_dir_path = os.path.join(project_root, config["docs_dir"])
if not os.path.isdir(docs_dir_path):
raise ConfigurationError(
f"Docs directory does not exist: {docs_dir_path}"
+106
View File
@@ -0,0 +1,106 @@
# 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
from markdown import Extension
from markdown.preprocessors import Preprocessor
if TYPE_CHECKING:
from markdown import Markdown
# ----------------------------------------------------------------------------
# Classes
# ----------------------------------------------------------------------------
class Page:
"""A class representing a page being rendered."""
def __init__(
self,
url: str,
path: str,
title: str | None = None,
meta: dict | None = None,
):
self.url = url
self.path = path
self.title: str | None = title
self.meta: dict = meta if meta is not None else {}
class ContextPreprocessor(Preprocessor):
"""Preprocessor to store rendering context."""
name = "rendering_context"
def __init__(
self,
md: Markdown,
page: Page,
config: dict[str, Any],
):
super().__init__(md)
self.page = page
self.config = config
def run(self, lines: list[str]) -> list[str]:
return lines
@classmethod
def from_markdown(cls, md: Markdown) -> ContextPreprocessor | None:
"""Lookup rendering context preprocessor from Markdown instance."""
for processor in md.preprocessors:
if isinstance(processor, cls):
return processor
return None
class ContextExtension(Extension):
"""Markdown extension to register rendering context."""
name = "zensical.extensions.context"
def __init__(self, **kwargs: Any):
super().__init__()
self._kwargs = kwargs
def extendMarkdown(self, md: Markdown) -> None:
"""Register rendering context preprocessor."""
# We must register the extension to ensure markdown-exec
# is able to forward it to its inner Markdown instances
md.registerExtension(self)
md.preprocessors.register(
ContextPreprocessor(md=md, **self._kwargs),
ContextPreprocessor.name,
0,
)
def makeExtension(**kwargs: Any) -> ContextExtension:
"""Register Markdown extension."""
return ContextExtension(**kwargs)
+3 -3
View File
@@ -32,14 +32,14 @@ from xml.etree.ElementTree import Element
from pymdownx import emoji, twemoji_db
if TYPE_CHECKING:
from zensical.markdown.extensions import MarkdownExt
from markdown import Markdown
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
def twemoji(options: dict, md: MarkdownExt) -> dict: # noqa: ARG001
def twemoji(options: dict, md: Markdown) -> dict: # noqa: ARG001
"""Create twemoji index."""
paths = options.get("custom_icons", [])[:]
return _load_twemoji_index(tuple(paths))
@@ -54,7 +54,7 @@ def to_svg(
title: str,
category: str,
options: dict,
md: MarkdownExt,
md: Markdown,
) -> Element[str]:
"""Load icon."""
if not uc:
+11 -10
View File
@@ -28,11 +28,12 @@ from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from xml.etree.ElementTree import Element, ParseError, fromstring, tostring
from zensical.markdown.extensions import ExtensionExt, MarkdownExt
from zensical.markdown.processors import PostprocessorExt, TreeprocessorExt
from markdown import Extension, Markdown
from markdown.postprocessors import Postprocessor
from markdown.treeprocessors import Treeprocessor
if TYPE_CHECKING:
from zensical.markdown.extensions import MarkdownExt
from markdown import Markdown
# -----------------------------------------------------------------------------
# Constants
@@ -62,14 +63,14 @@ class GlightboxConfig:
# -----------------------------------------------------------------------------
class GlightboxTreeprocessor(TreeprocessorExt):
class GlightboxTreeprocessor(Treeprocessor):
"""Wraps image elements in anchor tags to integrate with GLightbox."""
SKIP_CLASSES: frozenset[str] = frozenset(
{"emojione", "twemoji", "gemoji", "off-glb"}
)
def __init__(self, md: MarkdownExt, config: GlightboxConfig):
def __init__(self, md: Markdown, config: GlightboxConfig):
super().__init__(md)
self.config = config
@@ -190,7 +191,7 @@ class GlightboxTreeprocessor(TreeprocessorExt):
)
class GlightboxPostprocessor(PostprocessorExt):
class GlightboxPostprocessor(Postprocessor):
"""Wraps stashed images in anchors, delegating to the treeprocessor.
This postprocessor uses a regular expression to find image tags in stashed
@@ -199,8 +200,8 @@ class GlightboxPostprocessor(PostprocessorExt):
parse and modify the HTML with an actual parser.
"""
def __init__(self, md: MarkdownExt, processor: GlightboxTreeprocessor):
super().__init__(md)
def __init__(self, md: Markdown, processor: GlightboxTreeprocessor):
self.md: Markdown = md
self._processor = processor
self._processed: set[int] = set()
@@ -244,7 +245,7 @@ class GlightboxPostprocessor(PostprocessorExt):
# -----------------------------------------------------------------------------
class GlightboxExtension(ExtensionExt):
class GlightboxExtension(Extension):
"""Markdown extension that wraps images in GLightbox anchor tags.
This extension provides both a treeprocessor to wrap images in the normal
@@ -281,7 +282,7 @@ class GlightboxExtension(ExtensionExt):
}
super().__init__(**kwargs)
def extendMarkdown(self, md: MarkdownExt) -> None:
def extendMarkdown(self, md: Markdown) -> None:
"""Register Markdown extension."""
md.registerExtension(self)
config = GlightboxConfig(**self.getConfigs())
+11 -11
View File
@@ -28,15 +28,15 @@ from pathlib import PurePosixPath
from typing import TYPE_CHECKING
from urllib.parse import urlparse
from markdown import Extension
from markdown.postprocessors import Postprocessor
from markdown.treeprocessors import Treeprocessor
from markdown.util import AMP_SUBSTITUTE
from zensical.markdown.extensions import ExtensionExt
from zensical.markdown.processors import PostprocessorExt, TreeprocessorExt
if TYPE_CHECKING:
from xml.etree.ElementTree import Element
from zensical.markdown.extensions import MarkdownExt
from markdown import Markdown
# -----------------------------------------------------------------------------
# Constants
@@ -53,10 +53,10 @@ _RE = re.compile(
# -----------------------------------------------------------------------------
class LinksTreeprocessor(TreeprocessorExt):
class LinksTreeprocessor(Treeprocessor):
"""Rewrites relative links."""
def __init__(self, md: MarkdownExt, path: str, use_directory_urls: bool):
def __init__(self, md: Markdown, path: str, use_directory_urls: bool):
super().__init__(md)
self.path = path
self.use_directory_urls = use_directory_urls
@@ -77,7 +77,7 @@ class LinksTreeprocessor(TreeprocessorExt):
el.set(key, url)
class LinksPostprocessor(PostprocessorExt):
class LinksPostprocessor(Postprocessor):
"""Rewrites relative links in stashed raw HTML blocks.
This postprocessor complements the :class:`LinksTreeprocessor` by applying
@@ -86,8 +86,8 @@ class LinksPostprocessor(PostprocessorExt):
inside raw HTML are handled consistently as well.
"""
def __init__(self, md: MarkdownExt, path: str, use_directory_urls: bool):
super().__init__(md)
def __init__(self, md: Markdown, path: str, use_directory_urls: bool):
self.md: Markdown = md
self._path = path
self._use_directory_urls = use_directory_urls
self._processed: set[int] = set()
@@ -123,7 +123,7 @@ class LinksPostprocessor(PostprocessorExt):
# -----------------------------------------------------------------------------
class LinksExtension(ExtensionExt):
class LinksExtension(Extension):
"""Markdown extension to rewrite relative links to other files.
Registers both a treeprocessor for links in the normal Markdown flow and
@@ -137,7 +137,7 @@ class LinksExtension(ExtensionExt):
self.path = path
self.use_directory_urls = use_directory_urls
def extendMarkdown(self, md: MarkdownExt) -> None:
def extendMarkdown(self, md: Markdown) -> None:
"""Register Markdown extension."""
md.registerExtension(self)
+10 -7
View File
@@ -27,29 +27,32 @@ import posixpath
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from markdown import Extension
from markdown.treeprocessors import Treeprocessor
from zensical.extensions.links import LinksTreeprocessor
from zensical.extensions.utilities.filter import Filter
from zensical.markdown.extensions import ExtensionExt, MarkdownExt
from zensical.markdown.processors import TreeprocessorExt
if TYPE_CHECKING:
from xml.etree.ElementTree import Element
from markdown import Markdown
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class PreviewProcessor(TreeprocessorExt):
class PreviewProcessor(Treeprocessor):
"""A Markdown treeprocessor to enable instant previews on links.
Note that this treeprocessor is dependent on the `links` treeprocessor
registered programmatically before rendering a page.
"""
def __init__(self, md: MarkdownExt, config: dict):
def __init__(self, md: Markdown, config: dict):
"""Initialize the treeprocessor."""
super().__init__(md)
self.md: Markdown = md
self.config = config
def run(self, root: Element) -> None:
@@ -125,7 +128,7 @@ class PreviewProcessor(TreeprocessorExt):
# -----------------------------------------------------------------------------
class PreviewExtension(ExtensionExt):
class PreviewExtension(Extension):
"""Markdown extension to enable instant previews on links.
This extensions allows to automatically add the `data-preview` attribute to
@@ -143,7 +146,7 @@ class PreviewExtension(ExtensionExt):
}
super().__init__(*args, **kwargs)
def extendMarkdown(self, md: MarkdownExt) -> None:
def extendMarkdown(self, md: Markdown) -> None:
"""Register Markdown extension."""
md.registerExtension(self)
+13 -7
View File
@@ -21,22 +21,28 @@
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
from html import escape
from html.parser import HTMLParser
from typing import Any
from typing import TYPE_CHECKING, Any
from markdown import Extension
from markdown.postprocessors import Postprocessor
if TYPE_CHECKING:
from markdown import Markdown
from zensical.markdown.extensions import ExtensionExt, MarkdownExt
from zensical.markdown.processors import PostprocessorExt
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class SearchProcessor(PostprocessorExt):
class SearchProcessor(Postprocessor):
"""Post processor to extract searchable content from the rendered HTML."""
def __init__(self, md: MarkdownExt) -> None:
def __init__(self, md: Markdown) -> None:
super().__init__(md)
self.data: list[dict[str, Any]] = []
@@ -71,14 +77,14 @@ class SearchProcessor(PostprocessorExt):
return text
class SearchExtension(ExtensionExt):
class SearchExtension(Extension):
"""Markdown extension for search indexing."""
def __init__(self, **kwargs: Any) -> None:
self.config = {"keep": [set(), "Set of HTML tags to keep in output"]}
super().__init__(**kwargs)
def extendMarkdown(self, md: MarkdownExt) -> None:
def extendMarkdown(self, md: Markdown) -> None:
"""Register the PostProcessor with Markdown."""
processor = SearchProcessor(md)
md.postprocessors.register(processor, "search", 0)
-52
View File
@@ -1,52 +0,0 @@
# 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 markdown import Extension, Markdown
# ----------------------------------------------------------------------------
# Classes
# ----------------------------------------------------------------------------
class MarkdownExt(Markdown):
"""Subclass of `Markdown`.
We need to subclass the `Markdown` class to provide additional data to the
processors, such as page information and configuration, someting that isn't
supported by the original Markdown `Markdown` class. It allows to implement
several features that previously required MkDocs plugins more efficiently.
"""
class ExtensionExt(Extension):
"""Subclass of `Extension`.
We need to subclass the `Extension` to allow access to our modified
`MarkdownExt` instance, which includes the page and configuration.
"""
def extendMarkdown(self, md: MarkdownExt) -> None: # ty:ignore[invalid-method-override]
"""Register Markdown extension."""
super().extendMarkdown(md)
-73
View File
@@ -1,73 +0,0 @@
# 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
from markdown.postprocessors import Postprocessor
from markdown.preprocessors import Preprocessor
from markdown.treeprocessors import Treeprocessor
if TYPE_CHECKING:
from zensical.markdown.extensions import MarkdownExt
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class PreprocessorExt(Preprocessor):
"""Subclass of `Preprocessor`.
We need to subclass the `Preprocessor` to allow access to our modified
`MarkdownExt` instance, which includes the page and configuration.
"""
def __init__(self, md: MarkdownExt):
"""Initialize processor."""
self.md: MarkdownExt = md
class PostprocessorExt(Postprocessor):
"""Subclass of `Postprocessor`.
We need to subclass the `Postprocessor` to allow access to our modified
`MarkdownExt` instance, which includes the page and configuration.
"""
def __init__(self, md: MarkdownExt):
"""Initialize processor."""
self.md: MarkdownExt = md
class TreeprocessorExt(Treeprocessor):
"""Subclass of `Treeprocessor`.
We need to subclass the `Treeprocessor` to allow access to our modified
`MarkdownExt` instance, which includes the page and configuration.
"""
def __init__(self, md: MarkdownExt):
"""Initialize processor."""
self.md: MarkdownExt = md
+40 -20
View File
@@ -28,13 +28,14 @@ from datetime import date, datetime
from typing import TYPE_CHECKING, Any
import yaml
from markdown import Markdown
from yaml import SafeLoader
from zensical.compat.autorefs import set_autorefs_page
from zensical.config import get_config
from zensical.extensions.context import ContextExtension, Page
from zensical.extensions.links import LinksExtension
from zensical.extensions.search import SearchExtension
from zensical.markdown.extensions import MarkdownExt
if TYPE_CHECKING:
from zensical.extensions.search import SearchProcessor
@@ -66,12 +67,43 @@ def render(content: str, path: str, url: str) -> dict:
in order to support the specific syntax of Python Markdown. We're working
on moving the entire rendering chain to Rust.
"""
config = get_config()
# First, extract metadata - the Python Markdown parser brings a metadata
# extension, but the implementation is broken, as it does not support full
# YAML syntax, e.g. lists. Thus, we just parse the metadata with YAML.
meta: dict = {}
if match := FRONT_MATTER_RE.match(content):
try:
meta = yaml.load(match.group(1), SafeLoader)
if isinstance(meta, dict):
content = content[match.end() :].lstrip("\n")
else:
meta = {}
except Exception: # noqa: BLE001
pass
set_autorefs_page(url, path)
# Create page context and set it for autorefs
page = Page(url=url, path=path, meta=meta)
set_autorefs_page(page)
# Update configuration to include context extension
# It's important we mutate the global configuration here,
# to allow mkdocstrings to forward the extension
# to its inner Markdown instances
config = get_config()
for extension in config["markdown_extensions"]:
if isinstance(extension, ContextExtension):
extension._kwargs["page"] = page
break
else:
config["markdown_extensions"].append(
ContextExtension(
page=page,
config=config,
)
)
# Initialize Markdown parser
md = MarkdownExt(
md = Markdown(
extensions=config["markdown_extensions"],
extension_configs=config["mdx_configs"],
)
@@ -87,29 +119,17 @@ def render(content: str, path: str, url: str) -> dict:
search_extension = SearchExtension()
search_extension.extendMarkdown(md)
# First, extract metadata - the Python Markdown parser brings a metadata
# extension, but the implementation is broken, as it does not support full
# YAML syntax, e.g. lists. Thus, we just parse the metadata with YAML.
meta: dict = {}
if match := FRONT_MATTER_RE.match(content):
try:
meta = yaml.load(match.group(1), SafeLoader)
if isinstance(meta, dict):
content = content[match.end() :].lstrip("\n")
else:
meta = {}
except Exception: # noqa: BLE001
pass
# Convert Markdown and sanitize metadata before sending back to Rust
# Convert content to HTML
content = md.convert(content)
meta = {k: _sanitize(v) for k, v in meta.items()}
# Obtain search index data, unless page is excluded
search_processor: SearchProcessor = md.postprocessors["search"] # ty:ignore[invalid-assignment]
if meta.get("search", {}).get("exclude", False):
search_processor.data = []
# Sanitize metadata before passing it to Rust.
meta = {k: _sanitize(v) for k, v in meta.items()}
# Return Markdown with metadata
return {
"meta": meta,