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.
- texsmith/__init__.py +107 -0
- texsmith/_alias.py +59 -0
- texsmith/adapters/__init__.py +6 -0
- texsmith/adapters/docker.py +258 -0
- texsmith/adapters/handlers/__init__.py +3 -0
- texsmith/adapters/handlers/_assets.py +363 -0
- texsmith/adapters/handlers/_helpers.py +62 -0
- texsmith/adapters/handlers/_mermaid.py +122 -0
- texsmith/adapters/handlers/admonitions.py +267 -0
- texsmith/adapters/handlers/basic.py +228 -0
- texsmith/adapters/handlers/blocks.py +851 -0
- texsmith/adapters/handlers/code.py +309 -0
- texsmith/adapters/handlers/inline.py +937 -0
- texsmith/adapters/handlers/links.py +239 -0
- texsmith/adapters/handlers/media.py +380 -0
- texsmith/adapters/latex/__init__.py +10 -0
- texsmith/adapters/latex/engines/__init__.py +726 -0
- texsmith/adapters/latex/engines/latex/__init__.py +26 -0
- texsmith/adapters/latex/engines/latex/log.py +720 -0
- texsmith/adapters/latex/engines/latex/runner.py +28 -0
- texsmith/adapters/latex/engines/tectonic/__init__.py +38 -0
- texsmith/adapters/latex/formatter.py +297 -0
- texsmith/adapters/latex/latexmk.py +148 -0
- texsmith/adapters/latex/partials/acronym.tex +1 -0
- texsmith/adapters/latex/partials/add.tex +1 -0
- texsmith/adapters/latex/partials/addition.tex +1 -0
- texsmith/adapters/latex/partials/blockquote.tex +3 -0
- texsmith/adapters/latex/partials/callout.tex +3 -0
- texsmith/adapters/latex/partials/choices.tex +5 -0
- texsmith/adapters/latex/partials/citation.tex +1 -0
- texsmith/adapters/latex/partials/codeblock.tex +7 -0
- texsmith/adapters/latex/partials/codeblock_listings.tex +5 -0
- texsmith/adapters/latex/partials/codeblock_pygments.tex +5 -0
- texsmith/adapters/latex/partials/codeblock_verbatim.tex +12 -0
- texsmith/adapters/latex/partials/codeinline.tex +1 -0
- texsmith/adapters/latex/partials/codeinlinett.tex +1 -0
- texsmith/adapters/latex/partials/comment.tex +1 -0
- texsmith/adapters/latex/partials/del.tex +1 -0
- texsmith/adapters/latex/partials/deletion.tex +1 -0
- texsmith/adapters/latex/partials/description_list.tex +5 -0
- texsmith/adapters/latex/partials/enquote.tex +1 -0
- texsmith/adapters/latex/partials/epigraph.tex +1 -0
- texsmith/adapters/latex/partials/exercises_solutions.tex +7 -0
- texsmith/adapters/latex/partials/figure.tex +33 -0
- texsmith/adapters/latex/partials/figure_tcolorbox.tex +15 -0
- texsmith/adapters/latex/partials/footnote.tex +3 -0
- texsmith/adapters/latex/partials/glossary.tex +1 -0
- texsmith/adapters/latex/partials/heading.tex +14 -0
- texsmith/adapters/latex/partials/highlight.tex +25 -0
- texsmith/adapters/latex/partials/horizontal_rule.tex +1 -0
- texsmith/adapters/latex/partials/href.tex +1 -0
- texsmith/adapters/latex/partials/icon.tex +1 -0
- texsmith/adapters/latex/partials/include.tex +1 -0
- texsmith/adapters/latex/partials/index.tex +1 -0
- texsmith/adapters/latex/partials/italic.tex +1 -0
- texsmith/adapters/latex/partials/keystroke.tex +46 -0
- texsmith/adapters/latex/partials/label.tex +1 -0
- texsmith/adapters/latex/partials/list_acronyms.tex +5 -0
- texsmith/adapters/latex/partials/list_glossary.tex +8 -0
- texsmith/adapters/latex/partials/multicolumn.tex +3 -0
- texsmith/adapters/latex/partials/ordered_list.tex +5 -0
- texsmith/adapters/latex/partials/pagestyle.tex +1 -0
- texsmith/adapters/latex/partials/ref.tex +1 -0
- texsmith/adapters/latex/partials/regex.tex +1 -0
- texsmith/adapters/latex/partials/smallcaps.tex +1 -0
- texsmith/adapters/latex/partials/strikethrough.tex +1 -0
- texsmith/adapters/latex/partials/strong.tex +1 -0
- texsmith/adapters/latex/partials/subscript.tex +1 -0
- texsmith/adapters/latex/partials/substitution.tex +1 -0
- texsmith/adapters/latex/partials/superscript.tex +1 -0
- texsmith/adapters/latex/partials/tabbed.tex +3 -0
- texsmith/adapters/latex/partials/table.tex +48 -0
- texsmith/adapters/latex/partials/underline.tex +1 -0
- texsmith/adapters/latex/partials/unordered_list.tex +5 -0
- texsmith/adapters/latex/partials/url.tex +1 -0
- texsmith/adapters/latex/pygments.py +98 -0
- texsmith/adapters/latex/pyxindy.py +64 -0
- texsmith/adapters/latex/renderer.py +220 -0
- texsmith/adapters/latex/tectonic.py +366 -0
- texsmith/adapters/latex/utils.py +86 -0
- texsmith/adapters/markdown/__init__.py +342 -0
- texsmith/adapters/plugins/__init__.py +8 -0
- texsmith/adapters/plugins/material.py +174 -0
- texsmith/adapters/plugins/snippet.py +1734 -0
- texsmith/adapters/transformers/__init__.py +122 -0
- texsmith/adapters/transformers/base.py +140 -0
- texsmith/adapters/transformers/strategies.py +1456 -0
- texsmith/adapters/transformers/utils.py +59 -0
- texsmith/api/__init__.py +80 -0
- texsmith/api/_utils.py +56 -0
- texsmith/api/document.py +645 -0
- texsmith/api/pipeline.py +229 -0
- texsmith/api/service.py +648 -0
- texsmith/api/templates.py +287 -0
- texsmith/core/__init__.py +6 -0
- texsmith/core/bibliography/__init__.py +57 -0
- texsmith/core/bibliography/collection.py +354 -0
- texsmith/core/bibliography/doi.py +194 -0
- texsmith/core/bibliography/issues.py +15 -0
- texsmith/core/bibliography/parsing.py +45 -0
- texsmith/core/callouts.py +99 -0
- texsmith/core/config.py +191 -0
- texsmith/core/context.py +242 -0
- texsmith/core/conversion/__init__.py +103 -0
- texsmith/core/conversion/core.py +780 -0
- texsmith/core/conversion/debug.py +87 -0
- texsmith/core/conversion/inputs.py +474 -0
- texsmith/core/conversion/renderer.py +578 -0
- texsmith/core/conversion/templates.py +646 -0
- texsmith/core/conversion_contexts.py +95 -0
- texsmith/core/diagnostics.py +118 -0
- texsmith/core/exceptions.py +41 -0
- texsmith/core/fonts/__init__.py +3 -0
- texsmith/core/fragments/__init__.py +716 -0
- texsmith/core/fragments/base.py +117 -0
- texsmith/core/metadata.py +280 -0
- texsmith/core/mustache.py +86 -0
- texsmith/core/partials.py +20 -0
- texsmith/core/rules.py +394 -0
- texsmith/core/templates/__init__.py +58 -0
- texsmith/core/templates/base.py +343 -0
- texsmith/core/templates/builtins.py +68 -0
- texsmith/core/templates/context_usage.py +137 -0
- texsmith/core/templates/loader.py +303 -0
- texsmith/core/templates/manifest.py +861 -0
- texsmith/core/templates/runtime.py +347 -0
- texsmith/core/templates/text.py +14 -0
- texsmith/core/templates/wrapper.py +340 -0
- texsmith/core/user_dir.py +179 -0
- texsmith/devtools.py +28 -0
- texsmith/extensions/__init__.py +162 -0
- texsmith/extensions/index/__init__.py +21 -0
- texsmith/extensions/index/markdown.py +94 -0
- texsmith/extensions/index/mkdocs_plugin.py +136 -0
- texsmith/extensions/index/registry.py +57 -0
- texsmith/extensions/index/renderer.py +183 -0
- texsmith/extensions/index/templates/index.tex +1 -0
- texsmith/extensions/latex_raw.py +115 -0
- texsmith/extensions/latex_text.py +117 -0
- texsmith/extensions/mermaid.py +267 -0
- texsmith/extensions/missing_footnotes.py +125 -0
- texsmith/extensions/multi_citations.py +54 -0
- texsmith/extensions/progressbar/__init__.py +9 -0
- texsmith/extensions/progressbar/markdown.py +212 -0
- texsmith/extensions/progressbar/renderer.py +117 -0
- texsmith/extensions/smallcaps.py +48 -0
- texsmith/extensions/texlogos/__init__.py +10 -0
- texsmith/extensions/texlogos/markdown.py +236 -0
- texsmith/extensions/texlogos/renderer.py +81 -0
- texsmith/extensions/texlogos/specs.py +66 -0
- texsmith/fonts/__init__.py +57 -0
- texsmith/fonts/cache.py +46 -0
- texsmith/fonts/constants.py +60 -0
- texsmith/fonts/coverage.py +275 -0
- texsmith/fonts/downloader.py +103 -0
- texsmith/fonts/fallback.py +438 -0
- texsmith/fonts/html_scripts.py +168 -0
- texsmith/fonts/logging.py +127 -0
- texsmith/fonts/pipeline.py +338 -0
- texsmith/fonts/scripts.py +493 -0
- texsmith/fonts/ucharclasses.py +164 -0
- texsmith/fragments/__init__.py +11 -0
- texsmith/fragments/bibliography/__init__.py +65 -0
- texsmith/fragments/bibliography/fragment.toml +3 -0
- texsmith/fragments/bibliography/ts-bibliography-backmatter.jinja.tex +35 -0
- texsmith/fragments/bibliography/ts-bibliography.jinja.tex +10 -0
- texsmith/fragments/callouts/__init__.py +88 -0
- texsmith/fragments/callouts/fragment.toml +3 -0
- texsmith/fragments/callouts/ts-callouts.jinja.sty +129 -0
- texsmith/fragments/code/__init__.py +82 -0
- texsmith/fragments/code/fragment.toml +3 -0
- texsmith/fragments/code/ts-code.jinja.sty +140 -0
- texsmith/fragments/extra/__init__.py +180 -0
- texsmith/fragments/extra/fragment.toml +3 -0
- texsmith/fragments/extra/ts-extra.jinja.tex +13 -0
- texsmith/fragments/fonts/__init__.py +880 -0
- texsmith/fragments/fonts/fragment.toml +3 -0
- texsmith/fragments/fonts/ts-fonts.jinja.sty +292 -0
- texsmith/fragments/frame/__init__.py +170 -0
- texsmith/fragments/frame/fragment.toml +3 -0
- texsmith/fragments/frame/ts-frame.tex.jinja +49 -0
- texsmith/fragments/geometry/__init__.py +169 -0
- texsmith/fragments/geometry/fragment.toml +3 -0
- texsmith/fragments/geometry/paper.py +526 -0
- texsmith/fragments/geometry/ts_geometry.tex.jinja +53 -0
- texsmith/fragments/glossary/__init__.py +67 -0
- texsmith/fragments/glossary/fragment.toml +3 -0
- texsmith/fragments/glossary/ts-glossary-backmatter.jinja.tex +5 -0
- texsmith/fragments/glossary/ts-glossary.jinja.sty +28 -0
- texsmith/fragments/index/__init__.py +66 -0
- texsmith/fragments/index/fragment.toml +3 -0
- texsmith/fragments/index/ts-index-backmatter.jinja.tex +3 -0
- texsmith/fragments/index/ts-index.jinja.sty +12 -0
- texsmith/fragments/keystrokes/__init__.py +69 -0
- texsmith/fragments/keystrokes/fragment.toml +3 -0
- texsmith/fragments/keystrokes/ts-keystrokes.jinja.sty +20 -0
- texsmith/fragments/todolist/__init__.py +71 -0
- texsmith/fragments/todolist/fragment.toml +3 -0
- texsmith/fragments/todolist/ts-todolist.jinja.sty +21 -0
- texsmith/fragments/typesetting/__init__.py +214 -0
- texsmith/fragments/typesetting/fragment.toml +3 -0
- texsmith/fragments/typesetting/ts-typesetting.tex.jinja +81 -0
- texsmith/index.py +26 -0
- texsmith/plugins/__init__.py +14 -0
- texsmith/progressbar.py +8 -0
- texsmith/quotes.py +40 -0
- texsmith/smart_dashes.py +66 -0
- texsmith/templates/__init__.py +3 -0
- texsmith/templates/article/README.md +33 -0
- texsmith/templates/article/__init__.py +281 -0
- texsmith/templates/article/template/manifest.toml +125 -0
- texsmith/templates/article/template/mermaid-config.json +18 -0
- texsmith/templates/article/template/template.tex +74 -0
- texsmith/templates/book/README.md +26 -0
- texsmith/templates/book/__init__.py +75 -0
- texsmith/templates/book/overrides/codeblock.tex +7 -0
- texsmith/templates/book/overrides/codeinline.tex +1 -0
- texsmith/templates/book/template/fixtoc.sty +81 -0
- texsmith/templates/book/template/manifest.toml +198 -0
- texsmith/templates/book/template/template.tex +314 -0
- texsmith/templates/common/__init__.py +1 -0
- texsmith/templates/common/latexmkrc +46 -0
- texsmith/templates/letter/README.md +59 -0
- texsmith/templates/letter/__init__.py +495 -0
- texsmith/templates/letter/demo.md +31 -0
- texsmith/templates/letter/fonts/modernline bold.otf +0 -0
- texsmith/templates/letter/fonts/modernline.otf +0 -0
- texsmith/templates/letter/manifest.toml +197 -0
- texsmith/templates/letter/template/callouts.jinja.sty +290 -0
- texsmith/templates/letter/template/template.tex +123 -0
- texsmith/templates/snippet/README.md +21 -0
- texsmith/templates/snippet/__init__.py +80 -0
- texsmith/templates/snippet/template/manifest.toml +66 -0
- texsmith/templates/snippet/template/template.tex +47 -0
- texsmith/texlogos.py +15 -0
- texsmith/ui/__init__.py +6 -0
- texsmith/ui/cli/__init__.py +22 -0
- texsmith/ui/cli/_options.py +338 -0
- texsmith/ui/cli/app.py +65 -0
- texsmith/ui/cli/bibliography.py +300 -0
- texsmith/ui/cli/commands/__init__.py +14 -0
- texsmith/ui/cli/commands/render.py +1128 -0
- texsmith/ui/cli/commands/templates.py +397 -0
- texsmith/ui/cli/diagnostics.py +36 -0
- texsmith/ui/cli/presenter.py +663 -0
- texsmith/ui/cli/state.py +263 -0
- texsmith/ui/cli/utils.py +235 -0
- texsmith-0.0.2.dev0.dist-info/METADATA +187 -0
- texsmith-0.0.2.dev0.dist-info/RECORD +252 -0
- texsmith-0.0.2.dev0.dist-info/WHEEL +4 -0
- texsmith-0.0.2.dev0.dist-info/entry_points.txt +22 -0
- 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,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
|
+
]
|