Coverage for src/lektor_ng/markdown/mistune2.py: 0%
61 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-05 10:32 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-05 10:32 +0000
1"""MarkdownController implementation for mistune 2.x"""
3from __future__ import annotations
5from collections.abc import Callable, Sequence
6from importlib import import_module
7from typing import ClassVar, TypedDict
9import mistune.util
11from lektor_ng.markdown.controller import (
12 MarkdownController,
13 RendererHelper,
14 UnknownPluginError,
15)
16from lektor_ng.utils import unique_everseen
19def escape(text: str) -> str:
20 # This is only here to provide the implementation for the
21 # deprecated lektor.markdown.escape method.
22 #
23 # (We don't use it below and it can be deleted once access
24 # to lektor.markdown.escape is removed.)
25 return mistune.util.escape(text, quote=True)
28class ImprovedRenderer(mistune.HTMLRenderer): # type: ignore[misc]
29 lektor: ClassVar = RendererHelper()
31 # The deprecated .record and .meta attributes are not made available here, since old
32 # renderer mixins (written for Lektor<3.4 and mistune 0.x) are not going to work
33 # with mistune 2.x anyway.
35 def link(self, link: str, text: str | None = None, title: str | None = None) -> str:
36 link = self.lektor.resolve_url(link)
37 return super().link(link, text, title)
39 def image(self, src: str, alt: str = "", title: str | None = None) -> str:
40 src = self.lektor.resolve_url(src)
41 return super().image(src, alt, title)
44class ParserConfigDict(TypedDict, total=False):
45 block: mistune.BlockParser
46 inline: mistune.InlineParser
47 plugins: Sequence[Callable[[mistune.Markdown], None]]
50MistunePlugin = Callable[[mistune.Markdown], None]
53class MarkdownConfig:
54 # Enabling these plugins give us feature parity with mistune 0.8.4.
55 # We enable them by default for the sake of backwards-compatibility.
56 DEFAULT_PLUGINS = ("url", "strikethrough", "footnotes", "table")
58 def __init__(self) -> None:
59 self.renderer_options = {
60 "escape": False,
61 "allow_harmful_protocols": True,
62 }
63 self.renderer_base = ImprovedRenderer
64 self.renderer_mixins: list[type] = []
65 self.parser_options: ParserConfigDict = {
66 "plugins": list(self.DEFAULT_PLUGINS),
67 }
69 def make_renderer(self) -> ImprovedRenderer:
70 bases = tuple(self.renderer_mixins) + (self.renderer_base,)
71 renderer_cls = type("renderer_cls", bases, {})
72 return renderer_cls(**self.renderer_options)
75class MarkdownController2(MarkdownController):
76 def make_parser(self) -> mistune.Markdown:
77 env = self.env
78 cfg = MarkdownConfig()
79 # FIXME: call different hooks here for mistune 2?
80 env.plugin_controller.emit("markdown-config", config=cfg)
81 renderer = cfg.make_renderer()
82 env.plugin_controller.emit("markdown-lexer-config", config=cfg, renderer=renderer)
83 parser_options = cfg.parser_options
84 plugins = parser_options.get("plugins")
85 if plugins:
86 # Resolve and filter out duplicates
87 parser_options["plugins"] = tuple(unique_everseen(map(self.resolve_plugin, plugins)))
88 return mistune.Markdown(renderer, **cfg.parser_options)
90 PLUGINS = {**mistune.PLUGINS}
92 def resolve_plugin(self, plugin: str | MistunePlugin) -> MistunePlugin:
93 if callable(plugin):
94 return plugin
95 if not isinstance(plugin, str):
96 raise TypeError(f"Plugins should be specified as strings or callables, not {plugin!r}")
98 module_name, sep, attr = plugin.partition(":")
99 if sep:
100 try:
101 return getattr(import_module(module_name.strip()), attr.strip())
102 except (ImportError, AttributeError) as exc:
103 raise UnknownPluginError(f"Can not load plugin {plugin!r}") from exc
104 try:
105 return self.PLUGINS[plugin]
106 except KeyError as exc:
107 raise UnknownPluginError(f"Unknown plugin {plugin!r}") from exc