Coverage for src/lektor_ng/markdown/controller.py: 100%
90 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-08 23:29 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-08 23:29 +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
8from werkzeug.utils import cached_property
10from lektor_ng.context import Context, get_ctx
11from lektor_ng.sourceobj import SourceObject
13if TYPE_CHECKING: # pragma: no cover
14 from lektor_ng.environment import Environment
17@dataclass
18class _Threadlocal(threading.local):
19 renderer_context: Optional["RendererContext"] = None
22_threadlocal = _Threadlocal()
24Meta = dict[str, Any]
25FieldOptions = Mapping[str, str]
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
36class RendererContext(NamedTuple):
37 """Extra data used during Markdown rendering."""
39 record: SourceObject | None
40 meta: Meta
41 field_options: FieldOptions
43 def __enter__(self) -> "RendererContext":
44 assert _threadlocal.renderer_context is None
45 _threadlocal.renderer_context = self
46 return self
48 def __exit__(self, *__: object) -> None:
49 _threadlocal.renderer_context = None
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
58class RendererHelper:
59 """Various helpers used by our markdown renderer subclasses."""
61 @property
62 def record(self) -> SourceObject | None:
63 """The record that owns the markdown field being rendered.
65 This is used as the base for resolving relative URLs in the Markdown text.
66 """
67 return get_renderer_context().record
69 @property
70 def meta(self) -> Meta:
71 """The metadata for the current render.
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.
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
84 @property
85 def field_options(self) -> FieldOptions:
86 """Field options.
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
93 @property
94 def base_url(self) -> str:
95 """Get current base_url from build context.
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.
101 """
102 return require_ctx().base_url
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
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)
125class UnknownPluginError(LookupError):
126 """Exception raised when a mistune2 plugin name can not be resolved."""
129class RenderResult(NamedTuple):
130 html: str
131 meta: Meta
134class MarkdownController(ABC):
135 def __init__(self, env: "Environment") -> None:
136 self.env = env
138 @abstractmethod
139 def make_parser(self) -> Callable[[str], str]: # () -> mistune.Mistune
140 """Construct a mistune parser"""
142 @cached_property
143 def parser(self) -> Callable[[str], str]: # () -> mistune.Mistune
144 return self.make_parser()
146 def get_cache_key(self) -> Hashable | None:
147 """Get cache key.
149 Identical keys guarantee that the rendered result for a given string,
150 record, and set of field options will be identical.
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
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)
170class ControllerCache:
171 """Helper for constructing MarkdownControllers thats ensures just one
172 controller per Lektor Environment."""
174 _cache: MutableMapping["Environment", MarkdownController]
176 def __init__(self, factory: Callable[["Environment"], MarkdownController]):
177 self.controller_class = factory
178 self._cache = WeakKeyDictionary()
180 def __call__(self, env: Optional["Environment"] = None) -> MarkdownController:
181 """Get MarkdownController for environment.
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))