Coverage for src/markdown_exec/rendering.py: 90.16%
96 statements
« prev ^ index » next coverage.py v7.2.3, created at 2023-04-26 22:45 +0200
« prev ^ index » next coverage.py v7.2.3, created at 2023-04-26 22:45 +0200
1"""Markdown extensions and helpers."""
3from __future__ import annotations
5from contextlib import contextmanager
6from functools import lru_cache
7from textwrap import indent
8from typing import TYPE_CHECKING, Any, Iterator
10from markdown import Markdown
11from markupsafe import Markup
13from markdown_exec.processors import (
14 HeadingReportingTreeprocessor,
15 IdPrependingTreeprocessor,
16 InsertHeadings,
17 RemoveHeadings,
18)
20if TYPE_CHECKING:
21 from xml.etree.ElementTree import Element
23 from markdown import Extension
26def code_block(language: str, code: str, **options: str) -> str:
27 """Format code as a code block.
29 Parameters:
30 language: The code block language.
31 code: The source code to format.
32 **options: Additional options passed from the source, to add back to the generated code block.
34 Returns:
35 The formatted code block.
36 """
37 opts = " ".join(f'{opt_name}="{opt_value}"' for opt_name, opt_value in options.items())
38 return f"````````{language} {opts}\n{code}\n````````"
41def tabbed(*tabs: tuple[str, str]) -> str:
42 """Format tabs using `pymdownx.tabbed` extension.
44 Parameters:
45 *tabs: Tuples of strings: title and text.
47 Returns:
48 The formatted tabs.
49 """
50 parts = []
51 for title, text in tabs:
52 title = title.replace(r"\|", "|").strip() # noqa: PLW2901
53 parts.append(f'=== "{title}"')
54 parts.append(indent(text, prefix=" " * 4))
55 parts.append("")
56 return "\n".join(parts)
59def _hide_lines(source: str) -> str:
60 return "\n".join(line for line in source.split("\n") if "markdown-exec: hide" not in line).strip()
63def add_source(
64 *,
65 source: str,
66 location: str,
67 output: str,
68 language: str,
69 tabs: tuple[str, str],
70 result: str = "",
71 **extra: str,
72) -> str:
73 """Add source code block to the output.
75 Parameters:
76 source: The source code block.
77 location: Where to add the source (above, below, tabbed-left, tabbed-right, console).
78 output: The current output.
79 language: The code language.
80 tabs: Tabs titles (if used).
81 result: Syntax to use when concatenating source and result with "console" location.
82 **extra: Extra options added back to source code block.
84 Raises:
85 ValueError: When the given location is not supported.
87 Returns:
88 The updated output.
89 """
90 source = _hide_lines(source)
91 if location == "console":
92 return code_block(result or language, source + "\n" + output, **extra)
94 source_block = code_block(language, source, **extra)
95 if location == "above": 95 ↛ 96line 95 didn't jump to line 96, because the condition on line 95 was never true
96 return source_block + "\n\n" + output
97 if location == "below": 97 ↛ 98line 97 didn't jump to line 98, because the condition on line 97 was never true
98 return output + "\n\n" + source_block
99 if location == "material-block": 99 ↛ 100line 99 didn't jump to line 100, because the condition on line 99 was never true
100 return source_block + f'\n\n<div class="result" markdown="1" >\n\n{output}\n\n</div>'
102 source_tab_title, result_tab_title = tabs
103 if location == "tabbed-left": 103 ↛ 105line 103 didn't jump to line 105, because the condition on line 103 was never false
104 return tabbed((source_tab_title, source_block), (result_tab_title, output))
105 if location == "tabbed-right":
106 return tabbed((result_tab_title, output), (source_tab_title, source_block))
108 raise ValueError(f"unsupported location for sources: {location}")
111class MarkdownConfig:
112 """This class returns a singleton used to store Markdown extensions configuration.
114 You don't have to instantiate the singleton yourself:
115 we provide it as [`markdown_config`][markdown_exec.rendering.markdown_config].
116 """
118 _singleton: MarkdownConfig | None = None
120 def __new__(cls) -> MarkdownConfig: # noqa: D102
121 if cls._singleton is None:
122 cls._singleton = super().__new__(cls)
123 return cls._singleton
125 def __init__(self) -> None: # noqa: D107
126 self.exts: list[str | Extension] | None = None
127 self.exts_config: dict[str, dict[str, Any]] | None = None
129 def save(self, exts: list[str | Extension], exts_config: dict[str, dict[str, Any]]) -> None:
130 """Save Markdown extensions and their configuration.
132 Parameters:
133 exts: The Markdown extensions.
134 exts_config: The extensions configuration.
135 """
136 self.exts = exts
137 self.exts_config = exts_config
139 def reset(self) -> None:
140 """Reset Markdown extensions and their configuration."""
141 self.exts = None
142 self.exts_config = None
145markdown_config = MarkdownConfig()
146"""This object can be used to save the configuration of your Markdown extensions.
148For example, since we provide a MkDocs plugin, we use it to store the configuration
149that was read from `mkdocs.yml`:
151```python
152from markdown_exec.rendering import markdown_config
154# ...in relevant events/hooks, access and modify extensions and their configs, then:
155markdown_config.save(extensions, extensions_config)
156```
158See the actual event hook: [`on_config`][markdown_exec.mkdocs_plugin.MarkdownExecPlugin.on_config].
159See the [`save`][markdown_exec.rendering.MarkdownConfig.save]
160and [`reset`][markdown_exec.rendering.MarkdownConfig.reset] methods.
162Without it, Markdown Exec will rely on the `registeredExtensions` attribute
163of the original Markdown instance, which does not forward everything
164that was configured, notably extensions like `tables`. Other extensions
165such as `attr_list` are visible, but fail to register properly when
166reusing their instances. It means that the rendered HTML might differ
167from what you expect (tables not rendered, attribute lists not injected,
168emojis not working, etc.).
169"""
171# FIXME: When a heading contains an XML entity such as —,
172# the entity is stashed and replaced with a placeholder.
173# The heading therefore contains this placeholder.
174# When reporting the heading to the upper conversion layer (for the ToC),
175# the placeholder gets unstashed using the upper Markdown instance
176# instead of the neste one. If the upper instance doesn't know the placeholder,
177# nothing happens. But if it knows it, we then get a heading with garbabe/previous
178# contents within it, messing up the ToC.
179# We should fix this somehow. In the meantime, the workaround is to avoid
180# XML entities that get stashed in headings.
183@lru_cache(maxsize=None)
184def _register_headings_processors(md: Markdown) -> None:
185 md.treeprocessors.register(
186 InsertHeadings(md),
187 InsertHeadings.name,
188 priority=75, # right before markdown.blockprocessors.HashHeaderProcessor
189 )
190 md.treeprocessors.register(
191 RemoveHeadings(md),
192 RemoveHeadings.name,
193 priority=4, # right after toc
194 )
197def _mimic(md: Markdown, headings: list[Element], *, update_toc: bool = True) -> Markdown:
198 new_md = Markdown()
199 extensions: list[Extension | str] = markdown_config.exts or md.registeredExtensions # type: ignore[assignment]
200 extensions_config: dict[str, dict[str, Any]] = markdown_config.exts_config or {}
201 new_md.registerExtensions(extensions, extensions_config)
202 new_md.treeprocessors.register(
203 IdPrependingTreeprocessor(md, ""),
204 IdPrependingTreeprocessor.name,
205 priority=4, # right after 'toc' (needed because that extension adds ids to headings)
206 )
207 new_md._original_md = md # type: ignore[attr-defined]
209 if update_toc:
210 _register_headings_processors(md)
211 new_md.treeprocessors.register(
212 HeadingReportingTreeprocessor(new_md, headings),
213 HeadingReportingTreeprocessor.name,
214 priority=1, # Close to the end.
215 )
217 return new_md
220@contextmanager
221def _id_prefix(md: Markdown, prefix: str | None) -> Iterator[None]:
222 MarkdownConverter.counter += 1
223 id_prepending_processor = md.treeprocessors[IdPrependingTreeprocessor.name]
224 id_prepending_processor.id_prefix = prefix if prefix is not None else f"exec-{MarkdownConverter.counter}--"
225 try:
226 yield
227 finally:
228 id_prepending_processor.id_prefix = ""
231class MarkdownConverter:
232 """Helper class to avoid breaking the original Markdown instance state."""
234 counter: int = 0
236 def __init__(self, md: Markdown, *, update_toc: bool = True) -> None: # noqa: D107
237 self._md_ref: Markdown = md
238 self._headings: list[Element] = []
239 self._update_toc = update_toc
241 @property
242 def _original_md(self) -> Markdown:
243 return getattr(self._md_ref, "_original_md", self._md_ref)
245 def _report_headings(self, markup: Markup) -> None:
246 self._original_md.treeprocessors[InsertHeadings.name].headings[markup] = self._headings
247 self._headings = []
249 def convert(self, text: str, stash: dict[str, str] | None = None, id_prefix: str | None = None) -> Markup:
250 """Convert Markdown text to safe HTML.
252 Parameters:
253 text: Markdown text.
254 stash: An HTML stash.
256 Returns:
257 Safe HTML.
258 """
259 md = _mimic(self._original_md, self._headings, update_toc=self._update_toc)
261 # convert markdown to html
262 with _id_prefix(md, id_prefix):
263 converted = md.convert(text)
265 # restore html from stash
266 for placeholder, stashed in (stash or {}).items():
267 converted = converted.replace(placeholder, stashed)
269 markup = Markup(converted)
271 # pass headings to upstream conversion layer
272 if self._update_toc:
273 self._report_headings(markup)
275 return markup