texsmith 0.0.2.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. texsmith/__init__.py +107 -0
  2. texsmith/_alias.py +59 -0
  3. texsmith/adapters/__init__.py +6 -0
  4. texsmith/adapters/docker.py +258 -0
  5. texsmith/adapters/handlers/__init__.py +3 -0
  6. texsmith/adapters/handlers/_assets.py +363 -0
  7. texsmith/adapters/handlers/_helpers.py +62 -0
  8. texsmith/adapters/handlers/_mermaid.py +122 -0
  9. texsmith/adapters/handlers/admonitions.py +267 -0
  10. texsmith/adapters/handlers/basic.py +228 -0
  11. texsmith/adapters/handlers/blocks.py +851 -0
  12. texsmith/adapters/handlers/code.py +309 -0
  13. texsmith/adapters/handlers/inline.py +937 -0
  14. texsmith/adapters/handlers/links.py +239 -0
  15. texsmith/adapters/handlers/media.py +380 -0
  16. texsmith/adapters/latex/__init__.py +10 -0
  17. texsmith/adapters/latex/engines/__init__.py +726 -0
  18. texsmith/adapters/latex/engines/latex/__init__.py +26 -0
  19. texsmith/adapters/latex/engines/latex/log.py +720 -0
  20. texsmith/adapters/latex/engines/latex/runner.py +28 -0
  21. texsmith/adapters/latex/engines/tectonic/__init__.py +38 -0
  22. texsmith/adapters/latex/formatter.py +297 -0
  23. texsmith/adapters/latex/latexmk.py +148 -0
  24. texsmith/adapters/latex/partials/acronym.tex +1 -0
  25. texsmith/adapters/latex/partials/add.tex +1 -0
  26. texsmith/adapters/latex/partials/addition.tex +1 -0
  27. texsmith/adapters/latex/partials/blockquote.tex +3 -0
  28. texsmith/adapters/latex/partials/callout.tex +3 -0
  29. texsmith/adapters/latex/partials/choices.tex +5 -0
  30. texsmith/adapters/latex/partials/citation.tex +1 -0
  31. texsmith/adapters/latex/partials/codeblock.tex +7 -0
  32. texsmith/adapters/latex/partials/codeblock_listings.tex +5 -0
  33. texsmith/adapters/latex/partials/codeblock_pygments.tex +5 -0
  34. texsmith/adapters/latex/partials/codeblock_verbatim.tex +12 -0
  35. texsmith/adapters/latex/partials/codeinline.tex +1 -0
  36. texsmith/adapters/latex/partials/codeinlinett.tex +1 -0
  37. texsmith/adapters/latex/partials/comment.tex +1 -0
  38. texsmith/adapters/latex/partials/del.tex +1 -0
  39. texsmith/adapters/latex/partials/deletion.tex +1 -0
  40. texsmith/adapters/latex/partials/description_list.tex +5 -0
  41. texsmith/adapters/latex/partials/enquote.tex +1 -0
  42. texsmith/adapters/latex/partials/epigraph.tex +1 -0
  43. texsmith/adapters/latex/partials/exercises_solutions.tex +7 -0
  44. texsmith/adapters/latex/partials/figure.tex +33 -0
  45. texsmith/adapters/latex/partials/figure_tcolorbox.tex +15 -0
  46. texsmith/adapters/latex/partials/footnote.tex +3 -0
  47. texsmith/adapters/latex/partials/glossary.tex +1 -0
  48. texsmith/adapters/latex/partials/heading.tex +14 -0
  49. texsmith/adapters/latex/partials/highlight.tex +25 -0
  50. texsmith/adapters/latex/partials/horizontal_rule.tex +1 -0
  51. texsmith/adapters/latex/partials/href.tex +1 -0
  52. texsmith/adapters/latex/partials/icon.tex +1 -0
  53. texsmith/adapters/latex/partials/include.tex +1 -0
  54. texsmith/adapters/latex/partials/index.tex +1 -0
  55. texsmith/adapters/latex/partials/italic.tex +1 -0
  56. texsmith/adapters/latex/partials/keystroke.tex +46 -0
  57. texsmith/adapters/latex/partials/label.tex +1 -0
  58. texsmith/adapters/latex/partials/list_acronyms.tex +5 -0
  59. texsmith/adapters/latex/partials/list_glossary.tex +8 -0
  60. texsmith/adapters/latex/partials/multicolumn.tex +3 -0
  61. texsmith/adapters/latex/partials/ordered_list.tex +5 -0
  62. texsmith/adapters/latex/partials/pagestyle.tex +1 -0
  63. texsmith/adapters/latex/partials/ref.tex +1 -0
  64. texsmith/adapters/latex/partials/regex.tex +1 -0
  65. texsmith/adapters/latex/partials/smallcaps.tex +1 -0
  66. texsmith/adapters/latex/partials/strikethrough.tex +1 -0
  67. texsmith/adapters/latex/partials/strong.tex +1 -0
  68. texsmith/adapters/latex/partials/subscript.tex +1 -0
  69. texsmith/adapters/latex/partials/substitution.tex +1 -0
  70. texsmith/adapters/latex/partials/superscript.tex +1 -0
  71. texsmith/adapters/latex/partials/tabbed.tex +3 -0
  72. texsmith/adapters/latex/partials/table.tex +48 -0
  73. texsmith/adapters/latex/partials/underline.tex +1 -0
  74. texsmith/adapters/latex/partials/unordered_list.tex +5 -0
  75. texsmith/adapters/latex/partials/url.tex +1 -0
  76. texsmith/adapters/latex/pygments.py +98 -0
  77. texsmith/adapters/latex/pyxindy.py +64 -0
  78. texsmith/adapters/latex/renderer.py +220 -0
  79. texsmith/adapters/latex/tectonic.py +366 -0
  80. texsmith/adapters/latex/utils.py +86 -0
  81. texsmith/adapters/markdown/__init__.py +342 -0
  82. texsmith/adapters/plugins/__init__.py +8 -0
  83. texsmith/adapters/plugins/material.py +174 -0
  84. texsmith/adapters/plugins/snippet.py +1734 -0
  85. texsmith/adapters/transformers/__init__.py +122 -0
  86. texsmith/adapters/transformers/base.py +140 -0
  87. texsmith/adapters/transformers/strategies.py +1456 -0
  88. texsmith/adapters/transformers/utils.py +59 -0
  89. texsmith/api/__init__.py +80 -0
  90. texsmith/api/_utils.py +56 -0
  91. texsmith/api/document.py +645 -0
  92. texsmith/api/pipeline.py +229 -0
  93. texsmith/api/service.py +648 -0
  94. texsmith/api/templates.py +287 -0
  95. texsmith/core/__init__.py +6 -0
  96. texsmith/core/bibliography/__init__.py +57 -0
  97. texsmith/core/bibliography/collection.py +354 -0
  98. texsmith/core/bibliography/doi.py +194 -0
  99. texsmith/core/bibliography/issues.py +15 -0
  100. texsmith/core/bibliography/parsing.py +45 -0
  101. texsmith/core/callouts.py +99 -0
  102. texsmith/core/config.py +191 -0
  103. texsmith/core/context.py +242 -0
  104. texsmith/core/conversion/__init__.py +103 -0
  105. texsmith/core/conversion/core.py +780 -0
  106. texsmith/core/conversion/debug.py +87 -0
  107. texsmith/core/conversion/inputs.py +474 -0
  108. texsmith/core/conversion/renderer.py +578 -0
  109. texsmith/core/conversion/templates.py +646 -0
  110. texsmith/core/conversion_contexts.py +95 -0
  111. texsmith/core/diagnostics.py +118 -0
  112. texsmith/core/exceptions.py +41 -0
  113. texsmith/core/fonts/__init__.py +3 -0
  114. texsmith/core/fragments/__init__.py +716 -0
  115. texsmith/core/fragments/base.py +117 -0
  116. texsmith/core/metadata.py +280 -0
  117. texsmith/core/mustache.py +86 -0
  118. texsmith/core/partials.py +20 -0
  119. texsmith/core/rules.py +394 -0
  120. texsmith/core/templates/__init__.py +58 -0
  121. texsmith/core/templates/base.py +343 -0
  122. texsmith/core/templates/builtins.py +68 -0
  123. texsmith/core/templates/context_usage.py +137 -0
  124. texsmith/core/templates/loader.py +303 -0
  125. texsmith/core/templates/manifest.py +861 -0
  126. texsmith/core/templates/runtime.py +347 -0
  127. texsmith/core/templates/text.py +14 -0
  128. texsmith/core/templates/wrapper.py +340 -0
  129. texsmith/core/user_dir.py +179 -0
  130. texsmith/devtools.py +28 -0
  131. texsmith/extensions/__init__.py +162 -0
  132. texsmith/extensions/index/__init__.py +21 -0
  133. texsmith/extensions/index/markdown.py +94 -0
  134. texsmith/extensions/index/mkdocs_plugin.py +136 -0
  135. texsmith/extensions/index/registry.py +57 -0
  136. texsmith/extensions/index/renderer.py +183 -0
  137. texsmith/extensions/index/templates/index.tex +1 -0
  138. texsmith/extensions/latex_raw.py +115 -0
  139. texsmith/extensions/latex_text.py +117 -0
  140. texsmith/extensions/mermaid.py +267 -0
  141. texsmith/extensions/missing_footnotes.py +125 -0
  142. texsmith/extensions/multi_citations.py +54 -0
  143. texsmith/extensions/progressbar/__init__.py +9 -0
  144. texsmith/extensions/progressbar/markdown.py +212 -0
  145. texsmith/extensions/progressbar/renderer.py +117 -0
  146. texsmith/extensions/smallcaps.py +48 -0
  147. texsmith/extensions/texlogos/__init__.py +10 -0
  148. texsmith/extensions/texlogos/markdown.py +236 -0
  149. texsmith/extensions/texlogos/renderer.py +81 -0
  150. texsmith/extensions/texlogos/specs.py +66 -0
  151. texsmith/fonts/__init__.py +57 -0
  152. texsmith/fonts/cache.py +46 -0
  153. texsmith/fonts/constants.py +60 -0
  154. texsmith/fonts/coverage.py +275 -0
  155. texsmith/fonts/downloader.py +103 -0
  156. texsmith/fonts/fallback.py +438 -0
  157. texsmith/fonts/html_scripts.py +168 -0
  158. texsmith/fonts/logging.py +127 -0
  159. texsmith/fonts/pipeline.py +338 -0
  160. texsmith/fonts/scripts.py +493 -0
  161. texsmith/fonts/ucharclasses.py +164 -0
  162. texsmith/fragments/__init__.py +11 -0
  163. texsmith/fragments/bibliography/__init__.py +65 -0
  164. texsmith/fragments/bibliography/fragment.toml +3 -0
  165. texsmith/fragments/bibliography/ts-bibliography-backmatter.jinja.tex +35 -0
  166. texsmith/fragments/bibliography/ts-bibliography.jinja.tex +10 -0
  167. texsmith/fragments/callouts/__init__.py +88 -0
  168. texsmith/fragments/callouts/fragment.toml +3 -0
  169. texsmith/fragments/callouts/ts-callouts.jinja.sty +129 -0
  170. texsmith/fragments/code/__init__.py +82 -0
  171. texsmith/fragments/code/fragment.toml +3 -0
  172. texsmith/fragments/code/ts-code.jinja.sty +140 -0
  173. texsmith/fragments/extra/__init__.py +180 -0
  174. texsmith/fragments/extra/fragment.toml +3 -0
  175. texsmith/fragments/extra/ts-extra.jinja.tex +13 -0
  176. texsmith/fragments/fonts/__init__.py +880 -0
  177. texsmith/fragments/fonts/fragment.toml +3 -0
  178. texsmith/fragments/fonts/ts-fonts.jinja.sty +292 -0
  179. texsmith/fragments/frame/__init__.py +170 -0
  180. texsmith/fragments/frame/fragment.toml +3 -0
  181. texsmith/fragments/frame/ts-frame.tex.jinja +49 -0
  182. texsmith/fragments/geometry/__init__.py +169 -0
  183. texsmith/fragments/geometry/fragment.toml +3 -0
  184. texsmith/fragments/geometry/paper.py +526 -0
  185. texsmith/fragments/geometry/ts_geometry.tex.jinja +53 -0
  186. texsmith/fragments/glossary/__init__.py +67 -0
  187. texsmith/fragments/glossary/fragment.toml +3 -0
  188. texsmith/fragments/glossary/ts-glossary-backmatter.jinja.tex +5 -0
  189. texsmith/fragments/glossary/ts-glossary.jinja.sty +28 -0
  190. texsmith/fragments/index/__init__.py +66 -0
  191. texsmith/fragments/index/fragment.toml +3 -0
  192. texsmith/fragments/index/ts-index-backmatter.jinja.tex +3 -0
  193. texsmith/fragments/index/ts-index.jinja.sty +12 -0
  194. texsmith/fragments/keystrokes/__init__.py +69 -0
  195. texsmith/fragments/keystrokes/fragment.toml +3 -0
  196. texsmith/fragments/keystrokes/ts-keystrokes.jinja.sty +20 -0
  197. texsmith/fragments/todolist/__init__.py +71 -0
  198. texsmith/fragments/todolist/fragment.toml +3 -0
  199. texsmith/fragments/todolist/ts-todolist.jinja.sty +21 -0
  200. texsmith/fragments/typesetting/__init__.py +214 -0
  201. texsmith/fragments/typesetting/fragment.toml +3 -0
  202. texsmith/fragments/typesetting/ts-typesetting.tex.jinja +81 -0
  203. texsmith/index.py +26 -0
  204. texsmith/plugins/__init__.py +14 -0
  205. texsmith/progressbar.py +8 -0
  206. texsmith/quotes.py +40 -0
  207. texsmith/smart_dashes.py +66 -0
  208. texsmith/templates/__init__.py +3 -0
  209. texsmith/templates/article/README.md +33 -0
  210. texsmith/templates/article/__init__.py +281 -0
  211. texsmith/templates/article/template/manifest.toml +125 -0
  212. texsmith/templates/article/template/mermaid-config.json +18 -0
  213. texsmith/templates/article/template/template.tex +74 -0
  214. texsmith/templates/book/README.md +26 -0
  215. texsmith/templates/book/__init__.py +75 -0
  216. texsmith/templates/book/overrides/codeblock.tex +7 -0
  217. texsmith/templates/book/overrides/codeinline.tex +1 -0
  218. texsmith/templates/book/template/fixtoc.sty +81 -0
  219. texsmith/templates/book/template/manifest.toml +198 -0
  220. texsmith/templates/book/template/template.tex +314 -0
  221. texsmith/templates/common/__init__.py +1 -0
  222. texsmith/templates/common/latexmkrc +46 -0
  223. texsmith/templates/letter/README.md +59 -0
  224. texsmith/templates/letter/__init__.py +495 -0
  225. texsmith/templates/letter/demo.md +31 -0
  226. texsmith/templates/letter/fonts/modernline bold.otf +0 -0
  227. texsmith/templates/letter/fonts/modernline.otf +0 -0
  228. texsmith/templates/letter/manifest.toml +197 -0
  229. texsmith/templates/letter/template/callouts.jinja.sty +290 -0
  230. texsmith/templates/letter/template/template.tex +123 -0
  231. texsmith/templates/snippet/README.md +21 -0
  232. texsmith/templates/snippet/__init__.py +80 -0
  233. texsmith/templates/snippet/template/manifest.toml +66 -0
  234. texsmith/templates/snippet/template/template.tex +47 -0
  235. texsmith/texlogos.py +15 -0
  236. texsmith/ui/__init__.py +6 -0
  237. texsmith/ui/cli/__init__.py +22 -0
  238. texsmith/ui/cli/_options.py +338 -0
  239. texsmith/ui/cli/app.py +65 -0
  240. texsmith/ui/cli/bibliography.py +300 -0
  241. texsmith/ui/cli/commands/__init__.py +14 -0
  242. texsmith/ui/cli/commands/render.py +1128 -0
  243. texsmith/ui/cli/commands/templates.py +397 -0
  244. texsmith/ui/cli/diagnostics.py +36 -0
  245. texsmith/ui/cli/presenter.py +663 -0
  246. texsmith/ui/cli/state.py +263 -0
  247. texsmith/ui/cli/utils.py +235 -0
  248. texsmith-0.0.2.dev0.dist-info/METADATA +187 -0
  249. texsmith-0.0.2.dev0.dist-info/RECORD +252 -0
  250. texsmith-0.0.2.dev0.dist-info/WHEEL +4 -0
  251. texsmith-0.0.2.dev0.dist-info/entry_points.txt +22 -0
  252. texsmith-0.0.2.dev0.dist-info/licenses/LICENSE.md +21 -0
@@ -0,0 +1,342 @@
1
+ """Markdown conversion utilities for TeXSmith."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Sequence
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ import re
9
+ from threading import Lock
10
+ from typing import Any
11
+
12
+ import yaml
13
+
14
+
15
+ try: # Optional dependency guard in case pymdownx is missing in constrained envs
16
+ import pymdownx.superfences as _superfences
17
+ from pymdownx.superfences import fence_code_format as _fence_code_format
18
+ except Exception: # pragma: no cover - fallback when extension unavailable
19
+ _fence_code_format = None
20
+ _superfences = None
21
+
22
+
23
+ __all__ = [
24
+ "DEFAULT_MARKDOWN_EXTENSIONS",
25
+ "MarkdownConversionError",
26
+ "MarkdownDocument",
27
+ "deduplicate_markdown_extensions",
28
+ "normalize_markdown_extensions",
29
+ "render_markdown",
30
+ "resolve_markdown_extensions",
31
+ "split_front_matter",
32
+ ]
33
+
34
+
35
+ DEFAULT_MARKDOWN_EXTENSIONS = [
36
+ "pymdownx.highlight",
37
+ "pymdownx.superfences",
38
+ "abbr",
39
+ "admonition",
40
+ "attr_list",
41
+ "def_list",
42
+ "footnotes",
43
+ "texsmith.index:TexsmithIndexExtension",
44
+ "texsmith.multi_citations:MultiCitationExtension",
45
+ "texsmith.latex_raw:LatexRawExtension",
46
+ "texsmith.missing_footnotes:MissingFootnotesExtension",
47
+ "texsmith.latex_text:LatexTextExtension",
48
+ "texsmith.smart_dashes:TexsmithSmartDashesExtension",
49
+ "texsmith.smallcaps:SmallCapsExtension",
50
+ "texsmith.mermaid:MermaidExtension",
51
+ "texsmith.progressbar:ProgressBarExtension",
52
+ "texsmith.quotes:TexsmithQuotesExtension",
53
+ "md_in_html",
54
+ "mdx_math",
55
+ "pymdownx.betterem",
56
+ "pymdownx.blocks.caption",
57
+ "pymdownx.blocks.html",
58
+ "pymdownx.caret",
59
+ "pymdownx.critic",
60
+ "pymdownx.details",
61
+ "pymdownx.emoji",
62
+ "pymdownx.fancylists",
63
+ "pymdownx.inlinehilite",
64
+ "pymdownx.keys",
65
+ "pymdownx.magiclink",
66
+ "pymdownx.mark",
67
+ "pymdownx.saneheaders",
68
+ "pymdownx.smartsymbols",
69
+ "pymdownx.snippets",
70
+ "pymdownx.tabbed",
71
+ "pymdownx.tasklist",
72
+ "pymdownx.tilde",
73
+ "tables",
74
+ ]
75
+
76
+
77
+ DEFAULT_EXTENSION_CONFIGS: dict[str, dict[str, object]] = {
78
+ "pymdownx.keys": {
79
+ "camel_case": True,
80
+ },
81
+ "pymdownx.highlight": {
82
+ "anchor_linenums": True,
83
+ "line_spans": "__span",
84
+ "pygments_lang_class": True,
85
+ },
86
+ "pymdownx.superfences": {
87
+ "custom_fences": (
88
+ []
89
+ if _fence_code_format is None
90
+ else [
91
+ {
92
+ "name": "mermaid",
93
+ "class": "mermaid",
94
+ "format": _fence_code_format,
95
+ }
96
+ ]
97
+ )
98
+ },
99
+ }
100
+
101
+
102
+ class MarkdownConversionError(Exception):
103
+ """Raised when Markdown cannot be converted into HTML."""
104
+
105
+
106
+ @dataclass(slots=True)
107
+ class MarkdownDocument:
108
+ """Result of converting Markdown into HTML."""
109
+
110
+ html: str
111
+ front_matter: dict[str, Any]
112
+
113
+
114
+ class _MarkdownCacheEntry:
115
+ __slots__ = ("lock", "processor")
116
+
117
+ def __init__(self, processor: Any) -> None:
118
+ self.processor = processor
119
+ self.lock = Lock()
120
+
121
+
122
+ _MARKDOWN_CACHE: dict[
123
+ tuple[tuple[str, ...], tuple[str, ...]],
124
+ _MarkdownCacheEntry,
125
+ ] = {}
126
+ _MARKDOWN_CACHE_GUARD = Lock()
127
+
128
+
129
+ def resolve_markdown_extensions(
130
+ requested: Iterable[str] | None,
131
+ disabled: Iterable[str] | None,
132
+ ) -> list[str]:
133
+ """Return the active Markdown extension list after applying overrides."""
134
+ enabled = normalize_markdown_extensions(requested)
135
+ disabled_normalized = {
136
+ extension.lower() for extension in normalize_markdown_extensions(disabled)
137
+ }
138
+
139
+ combined = deduplicate_markdown_extensions(list(DEFAULT_MARKDOWN_EXTENSIONS) + enabled)
140
+
141
+ if not disabled_normalized:
142
+ return combined
143
+
144
+ return [extension for extension in combined if extension.lower() not in disabled_normalized]
145
+
146
+
147
+ def deduplicate_markdown_extensions(values: Iterable[str]) -> list[str]:
148
+ """Remove duplicate extensions while preserving order and case."""
149
+ seen: set[str] = set()
150
+ result: list[str] = []
151
+ for value in values:
152
+ if not isinstance(value, str):
153
+ continue
154
+ key = value.lower()
155
+ if key in seen:
156
+ continue
157
+ seen.add(key)
158
+ result.append(value)
159
+ return result
160
+
161
+
162
+ def normalize_markdown_extensions(
163
+ values: Iterable[str] | str | None,
164
+ ) -> list[str]:
165
+ """Normalise extension names from CLI-friendly strings into a flat list."""
166
+ if values is None:
167
+ return []
168
+
169
+ if isinstance(values, str):
170
+ candidates: Iterable[str] = [values]
171
+ else:
172
+ candidates = values
173
+
174
+ normalized: list[str] = []
175
+ for value in candidates:
176
+ if not isinstance(value, str):
177
+ continue
178
+ chunks = re.split(r"[,\s\x00]+", value)
179
+ normalized.extend(chunk for chunk in chunks if chunk)
180
+ return normalized
181
+
182
+
183
+ def render_markdown(
184
+ source: str,
185
+ extensions: Sequence[str] | None = None,
186
+ *,
187
+ base_path: str | Path | None = None,
188
+ ) -> MarkdownDocument:
189
+ """Convert Markdown source into HTML while collecting front matter."""
190
+ try:
191
+ import markdown
192
+ except ModuleNotFoundError as exc: # pragma: no cover - environment dependent
193
+ raise MarkdownConversionError(
194
+ "Python Markdown is required to process Markdown inputs; "
195
+ "install the 'markdown' package."
196
+ ) from exc
197
+
198
+ metadata, markdown_body = split_front_matter(source)
199
+
200
+ active_extensions = list(extensions or ())
201
+ extensions_key = tuple(active_extensions)
202
+
203
+ snippet_enabled = any(
204
+ _normalise_extension_name(extension) == "pymdownx.snippets"
205
+ for extension in active_extensions
206
+ )
207
+ snippet_paths: tuple[str, ...] = ()
208
+ if snippet_enabled and base_path is not None:
209
+ snippet_paths = (str(Path(base_path).resolve()),)
210
+
211
+ entry = _resolve_markdown_entry(markdown, extensions_key, snippet_paths)
212
+
213
+ resolved_base: Path | None = None
214
+ if base_path is not None:
215
+ try:
216
+ resolved_base = Path(base_path).resolve()
217
+ except OSError:
218
+ resolved_base = Path(base_path)
219
+
220
+ try:
221
+ with entry.lock:
222
+ processor = entry.processor
223
+ reset_callback = getattr(processor, "reset", None)
224
+ if callable(reset_callback):
225
+ reset_callback()
226
+ processor.texsmith_mermaid_base_path = (
227
+ str(resolved_base) if resolved_base is not None else None
228
+ )
229
+ html = processor.convert(markdown_body)
230
+ except MarkdownConversionError:
231
+ raise
232
+ except Exception as exc: # pragma: no cover - library-controlled
233
+ raise MarkdownConversionError(f"Failed to convert Markdown source: {exc}") from exc
234
+
235
+ return MarkdownDocument(html=html, front_matter=metadata)
236
+
237
+
238
+ def split_front_matter(source: str) -> tuple[dict[str, Any], str]:
239
+ """Split YAML front matter from Markdown content, returning metadata and body."""
240
+ candidate = source.lstrip("\ufeff")
241
+ prefix_len = len(source) - len(candidate)
242
+ lines = candidate.splitlines()
243
+ if not lines or lines[0].strip() != "---":
244
+ return {}, source
245
+
246
+ front_matter_lines: list[str] = []
247
+ closing_index: int | None = None
248
+ for idx, line in enumerate(lines[1:], start=1):
249
+ stripped = line.strip()
250
+ if stripped in {"---", "..."}:
251
+ closing_index = idx
252
+ break
253
+ front_matter_lines.append(line)
254
+
255
+ if closing_index is None:
256
+ return {}, source
257
+
258
+ raw_block = "\n".join(front_matter_lines)
259
+ try:
260
+ metadata = yaml.safe_load(raw_block) or {}
261
+ except yaml.YAMLError:
262
+ return {}, source
263
+
264
+ if not isinstance(metadata, dict):
265
+ metadata = {}
266
+
267
+ body_lines = lines[closing_index + 1 :]
268
+ body = "\n".join(body_lines)
269
+ if source.endswith("\n"):
270
+ body += "\n"
271
+
272
+ prefix = source[:prefix_len]
273
+ return metadata, prefix + body
274
+
275
+
276
+ def _resolve_markdown_entry(
277
+ markdown: Any,
278
+ extensions_key: tuple[str, ...],
279
+ snippet_paths: tuple[str, ...],
280
+ ) -> _MarkdownCacheEntry:
281
+ cache_key = (extensions_key, snippet_paths)
282
+ entry = _MARKDOWN_CACHE.get(cache_key)
283
+ if entry is not None:
284
+ return entry
285
+ with _MARKDOWN_CACHE_GUARD:
286
+ entry = _MARKDOWN_CACHE.get(cache_key)
287
+ if entry is None:
288
+ processor = _build_markdown_processor(markdown, extensions_key, snippet_paths)
289
+ entry = _MarkdownCacheEntry(processor)
290
+ _MARKDOWN_CACHE[cache_key] = entry
291
+ return entry
292
+
293
+
294
+ def _build_markdown_processor(
295
+ markdown: Any,
296
+ extensions_key: tuple[str, ...],
297
+ snippet_paths: tuple[str, ...],
298
+ ) -> Any:
299
+ active_extensions = list(extensions_key)
300
+ extension_configs = {
301
+ name: dict(DEFAULT_EXTENSION_CONFIGS[name])
302
+ for name in active_extensions
303
+ if name in DEFAULT_EXTENSION_CONFIGS
304
+ }
305
+ snippet_enabled = any(
306
+ _normalise_extension_name(extension) == "pymdownx.snippets"
307
+ for extension in active_extensions
308
+ )
309
+ if snippet_enabled and snippet_paths:
310
+ snippet_config = extension_configs.setdefault("pymdownx.snippets", {})
311
+ existing_paths = _normalise_snippet_paths(snippet_config.get("base_path"))
312
+ for path in snippet_paths:
313
+ if path not in existing_paths:
314
+ existing_paths.append(path)
315
+ snippet_config["base_path"] = existing_paths
316
+ snippet_config.setdefault("encoding", "utf-8")
317
+
318
+ try:
319
+ processor = markdown.Markdown(
320
+ extensions=active_extensions, extension_configs=extension_configs
321
+ )
322
+ except Exception as exc: # pragma: no cover - library-controlled
323
+ raise MarkdownConversionError(f"Failed to initialize Markdown processor: {exc}") from exc
324
+
325
+ return processor
326
+
327
+
328
+ def _normalise_extension_name(value: str | object) -> str:
329
+ if not isinstance(value, str):
330
+ return ""
331
+ return value.split(":", 1)[0].lower()
332
+
333
+
334
+ def _normalise_snippet_paths(value: Any) -> list[str]:
335
+ if value is None:
336
+ return []
337
+ if isinstance(value, str):
338
+ return [value]
339
+ try:
340
+ return [str(item) for item in value] # type: ignore[arg-type]
341
+ except TypeError:
342
+ return [str(value)]
@@ -0,0 +1,8 @@
1
+ """Optional plugins that extend TeXSmith with non-core features."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from . import material, snippet
6
+
7
+
8
+ __all__ = ["material", "snippet"]
@@ -0,0 +1,174 @@
1
+ """Optional handlers for MkDocs Material specific constructs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from bs4.element import NavigableString, Tag
8
+
9
+ from texsmith.core.context import RenderContext
10
+ from texsmith.core.rules import RenderPhase, renders
11
+
12
+ from ..handlers import admonitions as base_admonitions
13
+ from ..handlers._helpers import coerce_attribute, gather_classes, mark_processed
14
+
15
+
16
+ EXERCISE_IGNORED_CLASSES = {
17
+ "admonition",
18
+ "annotate",
19
+ "inline",
20
+ "end",
21
+ "left",
22
+ "right",
23
+ "checkbox",
24
+ "fill-in-the-blank",
25
+ }
26
+
27
+
28
+ def _gather_solutions(container: Tag) -> list[str]:
29
+ solutions: list[str] = []
30
+ for details in container.find_all("details", class_="solution"):
31
+ if summary := details.find("summary"):
32
+ summary.decompose()
33
+ solutions.append(details.get_text(strip=False).strip())
34
+ details.decompose()
35
+ return solutions
36
+
37
+
38
+ def _render_exercise(
39
+ element: Tag, context: RenderContext, *, classes: list[str], title: str
40
+ ) -> None:
41
+ callout_classes = [cls for cls in classes if cls not in EXERCISE_IGNORED_CLASSES]
42
+ callout_type = callout_classes[0] if callout_classes else "exercise"
43
+
44
+ solutions = _gather_solutions(element)
45
+ answers: list[str] = []
46
+
47
+ for gap in element.find_all("input", class_="text-with-gap"):
48
+ correct_value = (
49
+ coerce_attribute(gap.get("answer")) or coerce_attribute(gap.get("value")) or ""
50
+ )
51
+ size_hint = coerce_attribute(gap.get("size"))
52
+ try:
53
+ width = max(int(size_hint), 3) if size_hint is not None else max(len(correct_value), 3)
54
+ except ValueError:
55
+ width = max(len(correct_value), 3)
56
+ gap.replace_with(mark_processed(NavigableString(f"\\rule{{{width}ex}}{{0.4pt}}")))
57
+ if correct_value:
58
+ answers.append(correct_value)
59
+
60
+ if note := element.find("p", class_="align--right"):
61
+ note_text = note.get_text(strip=True)
62
+ if note_text:
63
+ answers.append(note_text)
64
+ note.decompose()
65
+
66
+ with base_admonitions._use_tcolorbox_figures(context): # noqa: SLF001
67
+ content = element.get_text(strip=False).strip()
68
+
69
+ if solutions or answers:
70
+ exercise_index = context.state.next_exercise()
71
+ exercise_label = f"ex:{exercise_index}"
72
+ solution_label = f"sol:{exercise_index}"
73
+
74
+ solution_parts = [f"\\label{{{solution_label}}}"]
75
+ solution_parts.extend(solutions)
76
+ if answers:
77
+ label = "Réponse" if len(answers) == 1 else "Réponses"
78
+ solution_parts.append(f"{label}: {', '.join(answers)}")
79
+
80
+ context.state.add_solution(
81
+ {
82
+ "index": exercise_index,
83
+ "title": title,
84
+ "label": exercise_label,
85
+ "solution": "\n".join(part for part in solution_parts if part),
86
+ }
87
+ )
88
+
89
+ title = f"\\hyperref[{solution_label}]{{{title}}}"
90
+ content = f"\\label{{{exercise_label}}}\n{content}"
91
+
92
+ latex = context.formatter.callout(content, title=title, type=callout_type)
93
+ element.replace_with(mark_processed(NavigableString(latex)))
94
+
95
+
96
+ @renders(
97
+ "div",
98
+ phase=RenderPhase.POST,
99
+ priority=51,
100
+ name="material_exercise_admonition",
101
+ nestable=False,
102
+ )
103
+ def render_exercise_div(element: Tag, context: RenderContext) -> None:
104
+ """Render Material exercise admonitions into LaTeX callouts."""
105
+ classes = gather_classes(element.get("class"))
106
+ if "admonition" not in classes or "exercise" not in classes:
107
+ return
108
+
109
+ title = base_admonitions._extract_title( # noqa: SLF001
110
+ element.find("p", class_="admonition-title")
111
+ )
112
+ _render_exercise(element, context, classes=classes, title=title)
113
+
114
+
115
+ @renders(
116
+ "details",
117
+ phase=RenderPhase.POST,
118
+ priority=56,
119
+ name="material_exercise_details",
120
+ nestable=False,
121
+ )
122
+ def render_exercise_details(element: Tag, context: RenderContext) -> None:
123
+ """Render exercise blocks authored using <details> markup."""
124
+ classes = gather_classes(element.get("class"))
125
+ if "exercise" not in classes:
126
+ return
127
+
128
+ title = ""
129
+ if summary := element.find("summary"):
130
+ title = summary.get_text(strip=True)
131
+ summary.decompose()
132
+
133
+ _render_exercise(element, context, classes=classes, title=title or "")
134
+
135
+
136
+ @renders(
137
+ "blockquote",
138
+ phase=RenderPhase.POST,
139
+ priority=10,
140
+ name="material_epigraphs",
141
+ auto_mark=False,
142
+ )
143
+ def render_epigraph(element: Tag, context: RenderContext) -> None:
144
+ """Render Material epigraph blockquotes using the LaTeX epigraph macro."""
145
+ classes = gather_classes(element.get("class"))
146
+ if "epigraph" not in classes:
147
+ return
148
+
149
+ source = None
150
+ if footer := element.find("footer"):
151
+ source = footer.get_text(strip=True)
152
+ footer.decompose()
153
+
154
+ text = element.get_text(strip=False)
155
+ latex = context.formatter.epigraph(text=text, source=source)
156
+ node = mark_processed(NavigableString(latex))
157
+ context.mark_processed(element)
158
+ context.suppress_children(element)
159
+ element.replace_with(node)
160
+
161
+
162
+ def register(renderer: Any) -> None:
163
+ """Register Material-specific exercise and epigraph handlers."""
164
+ renderer.register(render_exercise_div)
165
+ renderer.register(render_exercise_details)
166
+ renderer.register(render_epigraph)
167
+
168
+
169
+ __all__ = [
170
+ "register",
171
+ "render_epigraph",
172
+ "render_exercise_details",
173
+ "render_exercise_div",
174
+ ]