Coverage for src/lektor_ng/markdown/controller.py: 100%

90 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-03 21:08 +0000

1import threading 

2from abc import ABC, abstractmethod 

3from collections.abc import Callable, Hashable, Mapping, MutableMapping 

4from dataclasses import dataclass 

5from typing import TYPE_CHECKING, Any, NamedTuple, Optional 

6from weakref import WeakKeyDictionary 

7 

8from werkzeug.utils import cached_property 

9 

10from lektor_ng.context import Context, get_ctx 

11from lektor_ng.sourceobj import SourceObject 

12 

13if TYPE_CHECKING: # pragma: no cover 

14 from lektor_ng.environment import Environment 

15 

16 

17@dataclass 

18class _Threadlocal(threading.local): 

19 renderer_context: Optional["RendererContext"] = None 

20 

21 

22_threadlocal = _Threadlocal() 

23 

24Meta = dict[str, Any] 

25FieldOptions = Mapping[str, str] 

26 

27 

28def require_ctx() -> Context: 

29 """Get Lektor build context, raising error if there is no current context.""" 

30 ctx = get_ctx() 

31 if ctx is None: 

32 raise RuntimeError("Context is required for markdown rendering") 

33 return ctx 

34 

35 

36class RendererContext(NamedTuple): 

37 """Extra data used during Markdown rendering.""" 

38 

39 record: SourceObject | None 

40 meta: Meta 

41 field_options: FieldOptions 

42 

43 def __enter__(self) -> "RendererContext": 

44 assert _threadlocal.renderer_context is None 

45 _threadlocal.renderer_context = self 

46 return self 

47 

48 def __exit__(self, *__: object) -> None: 

49 _threadlocal.renderer_context = None 

50 

51 

52def get_renderer_context() -> RendererContext: 

53 if _threadlocal.renderer_context is None: 

54 raise RuntimeError("RendererContext is required for markdown rendering") 

55 return _threadlocal.renderer_context 

56 

57 

58class RendererHelper: 

59 """Various helpers used by our markdown renderer subclasses.""" 

60 

61 @property 

62 def record(self) -> SourceObject | None: 

63 """The record that owns the markdown field being rendered. 

64 

65 This is used as the base for resolving relative URLs in the Markdown text. 

66 """ 

67 return get_renderer_context().record 

68 

69 @property 

70 def meta(self) -> Meta: 

71 """The metadata for the current render. 

72 

73 This is a dict and is used to return metadata from the 

74 rendering process. Currently, Lektor itself never generates 

75 any metadata, but custom Lektor plugins can do so by updating 

76 this dict. 

77 

78 Values inserted into this dict during the rendering process 

79 may be accessed in jinja templates via the ``.meta`` attribute 

80 of the _markdown_ field. 

81 """ 

82 return get_renderer_context().meta 

83 

84 @property 

85 def field_options(self) -> FieldOptions: 

86 """Field options. 

87 

88 A mapping containing the options specified on the markdown field in 

89 the model.ini file. 

90 """ 

91 return get_renderer_context().field_options 

92 

93 @property 

94 def base_url(self) -> str: 

95 """Get current base_url from build context. 

96 

97 The base URL of the artifact being built. This should start 

98 with a "/", however note that it is interpreted relative to 

99 any base_path configured for the project. 

100 

101 """ 

102 return require_ctx().base_url 

103 

104 def resolve_url(self, url: str) -> str: 

105 """Resolve markdown link to a URL.""" 

106 resolve_links = self.field_options.get("resolve_links") 

107 # Default is to resolve links to Lektor source objects when possible 

108 # This is a change from previous versions where we never resolved 

109 # links in Markdown. 

110 record = self.record 

111 if record is None: 

112 if resolve_links == "always": 

113 raise RuntimeError("A source object is required to resolve URLs") 

114 return url 

115 

116 resolve = strict_resolve = None 

117 if resolve_links == "always": 

118 strict_resolve = True 

119 elif resolve_links == "never": 

120 # This is the old behavior, equivalent to '!' prefix 

121 resolve = False 

122 return self.record.url_to(url, base_url=self.base_url, resolve=resolve, strict_resolve=strict_resolve) 

123 

124 

125class UnknownPluginError(LookupError): 

126 """Exception raised when a mistune2 plugin name can not be resolved.""" 

127 

128 

129class RenderResult(NamedTuple): 

130 html: str 

131 meta: Meta 

132 

133 

134class MarkdownController(ABC): 

135 def __init__(self, env: "Environment") -> None: 

136 self.env = env 

137 

138 @abstractmethod 

139 def make_parser(self) -> Callable[[str], str]: # () -> mistune.Mistune 

140 """Construct a mistune parser""" 

141 

142 @cached_property 

143 def parser(self) -> Callable[[str], str]: # () -> mistune.Mistune 

144 return self.make_parser() 

145 

146 def get_cache_key(self) -> Hashable | None: 

147 """Get cache key. 

148 

149 Identical keys guarantee that the rendered result for a given string, 

150 record, and set of field options will be identical. 

151 

152 This method may return ``None`` to disable caching of results. 

153 """ 

154 # pylint: disable=no-self-use 

155 ctx = get_ctx() 

156 if ctx is None: 

157 return None 

158 return ctx.base_url 

159 

160 def render(self, source: str, record: SourceObject | None, field_options: FieldOptions) -> RenderResult: 

161 """Render markdown string""" 

162 meta: Meta = {} 

163 self.env.plugin_controller.emit("markdown-meta-init", meta=meta, record=record) 

164 with RendererContext(record, meta, field_options): 

165 html = self.parser(source) 

166 self.env.plugin_controller.emit("markdown-meta-postprocess", meta=meta, record=record) 

167 return RenderResult(html, meta) 

168 

169 

170class ControllerCache: 

171 """Helper for constructing MarkdownControllers thats ensures just one 

172 controller per Lektor Environment.""" 

173 

174 _cache: MutableMapping["Environment", MarkdownController] 

175 

176 def __init__(self, factory: Callable[["Environment"], MarkdownController]): 

177 self.controller_class = factory 

178 self._cache = WeakKeyDictionary() 

179 

180 def __call__(self, env: Optional["Environment"] = None) -> MarkdownController: 

181 """Get MarkdownController for environment. 

182 

183 If no value is passed for env, the env for the current Lektor build context 

184 will be used. 

185 """ 

186 if env is None: 

187 env = require_ctx().env 

188 try: 

189 return self._cache[env] 

190 except KeyError: 

191 return self._cache.setdefault(env, self.controller_class(env))