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

1"""Markdown extensions and helpers.""" 

2 

3from __future__ import annotations 

4 

5from contextlib import contextmanager 

6from functools import lru_cache 

7from textwrap import indent 

8from typing import TYPE_CHECKING, Any, Iterator 

9 

10from markdown import Markdown 

11from markupsafe import Markup 

12 

13from markdown_exec.processors import ( 

14 HeadingReportingTreeprocessor, 

15 IdPrependingTreeprocessor, 

16 InsertHeadings, 

17 RemoveHeadings, 

18) 

19 

20if TYPE_CHECKING: 

21 from xml.etree.ElementTree import Element 

22 

23 from markdown import Extension 

24 

25 

26def code_block(language: str, code: str, **options: str) -> str: 

27 """Format code as a code block. 

28 

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. 

33 

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

39 

40 

41def tabbed(*tabs: tuple[str, str]) -> str: 

42 """Format tabs using `pymdownx.tabbed` extension. 

43 

44 Parameters: 

45 *tabs: Tuples of strings: title and text. 

46 

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) 

57 

58 

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

61 

62 

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. 

74 

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. 

83 

84 Raises: 

85 ValueError: When the given location is not supported. 

86 

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) 

93 

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>' 

101 

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

107 

108 raise ValueError(f"unsupported location for sources: {location}") 

109 

110 

111class MarkdownConfig: 

112 """This class returns a singleton used to store Markdown extensions configuration. 

113 

114 You don't have to instantiate the singleton yourself: 

115 we provide it as [`markdown_config`][markdown_exec.rendering.markdown_config]. 

116 """ 

117 

118 _singleton: MarkdownConfig | None = None 

119 

120 def __new__(cls) -> MarkdownConfig: # noqa: D102 

121 if cls._singleton is None: 

122 cls._singleton = super().__new__(cls) 

123 return cls._singleton 

124 

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 

128 

129 def save(self, exts: list[str | Extension], exts_config: dict[str, dict[str, Any]]) -> None: 

130 """Save Markdown extensions and their configuration. 

131 

132 Parameters: 

133 exts: The Markdown extensions. 

134 exts_config: The extensions configuration. 

135 """ 

136 self.exts = exts 

137 self.exts_config = exts_config 

138 

139 def reset(self) -> None: 

140 """Reset Markdown extensions and their configuration.""" 

141 self.exts = None 

142 self.exts_config = None 

143 

144 

145markdown_config = MarkdownConfig() 

146"""This object can be used to save the configuration of your Markdown extensions. 

147 

148For example, since we provide a MkDocs plugin, we use it to store the configuration 

149that was read from `mkdocs.yml`: 

150 

151```python 

152from markdown_exec.rendering import markdown_config 

153 

154# ...in relevant events/hooks, access and modify extensions and their configs, then: 

155markdown_config.save(extensions, extensions_config) 

156``` 

157 

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. 

161 

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

170 

171# FIXME: When a heading contains an XML entity such as &mdash;, 

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. 

181 

182 

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 ) 

195 

196 

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] 

208 

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 ) 

216 

217 return new_md 

218 

219 

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

229 

230 

231class MarkdownConverter: 

232 """Helper class to avoid breaking the original Markdown instance state.""" 

233 

234 counter: int = 0 

235 

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 

240 

241 @property 

242 def _original_md(self) -> Markdown: 

243 return getattr(self._md_ref, "_original_md", self._md_ref) 

244 

245 def _report_headings(self, markup: Markup) -> None: 

246 self._original_md.treeprocessors[InsertHeadings.name].headings[markup] = self._headings 

247 self._headings = [] 

248 

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. 

251 

252 Parameters: 

253 text: Markdown text. 

254 stash: An HTML stash. 

255 

256 Returns: 

257 Safe HTML. 

258 """ 

259 md = _mimic(self._original_md, self._headings, update_toc=self._update_toc) 

260 

261 # convert markdown to html 

262 with _id_prefix(md, id_prefix): 

263 converted = md.convert(text) 

264 

265 # restore html from stash 

266 for placeholder, stashed in (stash or {}).items(): 

267 converted = converted.replace(placeholder, stashed) 

268 

269 markup = Markup(converted) 

270 

271 # pass headings to upstream conversion layer 

272 if self._update_toc: 

273 self._report_headings(markup) 

274 

275 return markup