Coverage for src/lektor_ng/markdown/mistune2.py: 0%

61 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-05 14:56 +0000

1"""MarkdownController implementation for mistune 2.x""" 

2 

3from __future__ import annotations 

4 

5from collections.abc import Callable, Sequence 

6from importlib import import_module 

7from typing import ClassVar, TypedDict 

8 

9import mistune.util 

10 

11from lektor_ng.markdown.controller import ( 

12 MarkdownController, 

13 RendererHelper, 

14 UnknownPluginError, 

15) 

16from lektor_ng.utils import unique_everseen 

17 

18 

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) 

26 

27 

28class ImprovedRenderer(mistune.HTMLRenderer): # type: ignore[misc] 

29 lektor: ClassVar = RendererHelper() 

30 

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. 

34 

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) 

38 

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) 

42 

43 

44class ParserConfigDict(TypedDict, total=False): 

45 block: mistune.BlockParser 

46 inline: mistune.InlineParser 

47 plugins: Sequence[Callable[[mistune.Markdown], None]] 

48 

49 

50MistunePlugin = Callable[[mistune.Markdown], None] 

51 

52 

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") 

57 

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 } 

68 

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) 

73 

74 

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) 

89 

90 PLUGINS = {**mistune.PLUGINS} 

91 

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}") 

97 

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