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,179 @@
1
+ """Centralised resolution of the TeXSmith user and cache directories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from contextlib import contextmanager
7
+ from dataclasses import dataclass
8
+ import os
9
+ from pathlib import Path
10
+ import shutil
11
+ from threading import RLock
12
+
13
+
14
+ __all__ = [
15
+ "TexsmithUserDir",
16
+ "configure_user_dir",
17
+ "get_user_dir",
18
+ "set_user_dir",
19
+ "user_dir_context",
20
+ ]
21
+
22
+ _USER_DIR: TexsmithUserDir | None = None
23
+ _LOCK: RLock = RLock()
24
+
25
+
26
+ def _resolve_root(root: str | Path | None) -> tuple[Path, bool]:
27
+ if root is not None:
28
+ return Path(root).expanduser(), True
29
+ env_root = os.environ.get("TEXSMITH_HOME")
30
+ if env_root:
31
+ return Path(env_root).expanduser(), True
32
+ return Path.home() / ".texsmith", False
33
+
34
+
35
+ def _resolve_cache_root(
36
+ cache_root: str | Path | None,
37
+ *,
38
+ user_root: Path,
39
+ root_was_explicit: bool,
40
+ ) -> tuple[Path, bool]:
41
+ if cache_root is not None:
42
+ return Path(cache_root).expanduser(), True
43
+ env_cache = os.environ.get("TEXSMITH_CACHE_DIR")
44
+ if env_cache:
45
+ return Path(env_cache).expanduser(), True
46
+ xdg_cache = os.environ.get("XDG_CACHE_HOME")
47
+ if xdg_cache:
48
+ return Path(xdg_cache).expanduser() / "texsmith", True
49
+ if root_was_explicit:
50
+ return user_root / "cache", True
51
+ return Path.home() / ".cache" / "texsmith", False
52
+
53
+
54
+ @dataclass(slots=True)
55
+ class TexsmithUserDir:
56
+ """Resolved user and cache roots plus helpers to manage them."""
57
+
58
+ root: Path
59
+ cache_root: Path
60
+ root_is_explicit: bool = False
61
+ cache_is_explicit: bool = False
62
+
63
+ def data_dir(self, *parts: str | Path, create: bool = True) -> Path:
64
+ """Return a directory under the user root, creating it when requested."""
65
+ target = self.root.joinpath(*parts)
66
+ if create:
67
+ target.mkdir(parents=True, exist_ok=True)
68
+ return target
69
+
70
+ def cache_dir(self, *parts: str | Path, create: bool = True) -> Path:
71
+ """Return a directory under the cache root, creating it when requested."""
72
+ target = self.cache_root.joinpath(*parts)
73
+ if create:
74
+ target.mkdir(parents=True, exist_ok=True)
75
+ return target
76
+
77
+ def data_path(self, *parts: str | Path, create: bool = True) -> Path:
78
+ """Return a path under the user root, creating parent directories if needed."""
79
+ target = self.root.joinpath(*parts)
80
+ if create:
81
+ target.parent.mkdir(parents=True, exist_ok=True)
82
+ return target
83
+
84
+ def cache_path(self, *parts: str | Path, create: bool = True) -> Path:
85
+ """Return a path under the cache root, creating parent directories if needed."""
86
+ target = self.cache_root.joinpath(*parts)
87
+ if create:
88
+ target.parent.mkdir(parents=True, exist_ok=True)
89
+ return target
90
+
91
+ def clear_cache(self, namespaces: Iterable[str] | None = None) -> list[Path]:
92
+ """Clear cached namespaces and return the list of removed paths."""
93
+ targets: list[Path] = []
94
+ if namespaces is None:
95
+ targets.append(self.cache_root)
96
+ else:
97
+ for name in namespaces:
98
+ targets.append(self.cache_root / name)
99
+ targets.append(self.root / name)
100
+
101
+ cleared: list[Path] = []
102
+ for path in targets:
103
+ if not path.exists():
104
+ continue
105
+ try:
106
+ shutil.rmtree(path)
107
+ cleared.append(path)
108
+ except OSError:
109
+ continue
110
+ return cleared
111
+
112
+
113
+ def configure_user_dir(
114
+ *,
115
+ root: str | Path | None = None,
116
+ cache_root: str | Path | None = None,
117
+ ) -> TexsmithUserDir:
118
+ """Replace the global user dir singleton with a freshly resolved instance."""
119
+ user_root, root_was_explicit = _resolve_root(root)
120
+ resolved_cache_root, cache_was_explicit = _resolve_cache_root(
121
+ cache_root, user_root=user_root, root_was_explicit=root_was_explicit
122
+ )
123
+ return set_user_dir(
124
+ TexsmithUserDir(
125
+ root=user_root,
126
+ cache_root=resolved_cache_root,
127
+ root_is_explicit=root_was_explicit,
128
+ cache_is_explicit=cache_was_explicit,
129
+ )
130
+ )
131
+
132
+
133
+ def get_user_dir() -> TexsmithUserDir:
134
+ """Return the lazily created user dir singleton."""
135
+ global _USER_DIR
136
+ with _LOCK:
137
+ if _USER_DIR is None:
138
+ _USER_DIR = configure_user_dir()
139
+ return _USER_DIR
140
+ current_root, root_was_explicit = _resolve_root(None)
141
+ current_cache_root, cache_was_explicit = _resolve_cache_root(
142
+ None, user_root=current_root, root_was_explicit=root_was_explicit
143
+ )
144
+ if (not _USER_DIR.root_is_explicit and _USER_DIR.root != current_root) or (
145
+ not _USER_DIR.cache_is_explicit and _USER_DIR.cache_root != current_cache_root
146
+ ):
147
+ _USER_DIR = TexsmithUserDir(
148
+ root=current_root,
149
+ cache_root=current_cache_root,
150
+ root_is_explicit=root_was_explicit,
151
+ cache_is_explicit=cache_was_explicit,
152
+ )
153
+ return _USER_DIR
154
+
155
+
156
+ def set_user_dir(user_dir: TexsmithUserDir) -> TexsmithUserDir:
157
+ """Replace the current user dir singleton and return it."""
158
+ global _USER_DIR
159
+ with _LOCK:
160
+ _USER_DIR = user_dir
161
+ return _USER_DIR
162
+
163
+
164
+ @contextmanager
165
+ def user_dir_context(
166
+ *,
167
+ root: str | Path | None = None,
168
+ cache_root: str | Path | None = None,
169
+ ) -> Iterable[TexsmithUserDir]:
170
+ """Temporarily override the global user dir singleton."""
171
+ global _USER_DIR
172
+ with _LOCK:
173
+ previous = _USER_DIR
174
+ current = configure_user_dir(root=root, cache_root=cache_root)
175
+ try:
176
+ yield current
177
+ finally:
178
+ with _LOCK:
179
+ _USER_DIR = previous
texsmith/devtools.py ADDED
@@ -0,0 +1,28 @@
1
+ """Developer utilities for local workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ import subprocess
7
+ import sys
8
+
9
+
10
+ def main(argv: Sequence[str] | None = None) -> int:
11
+ """Run repository checks (currently proxies to ruff)."""
12
+ args = list(argv) if argv is not None else sys.argv[1:]
13
+ try:
14
+ pass
15
+ except Exception as exc: # pragma: no cover - defensive message
16
+ sys.stderr.write("ruff is required to run checks; install dev dependencies.\n")
17
+ sys.stderr.write(f"Details: {exc}\n")
18
+ return 1
19
+
20
+ result = subprocess.run(
21
+ ["ruff", "check", *args],
22
+ check=False,
23
+ )
24
+ return result.returncode
25
+
26
+
27
+ if __name__ == "__main__": # pragma: no cover
28
+ raise SystemExit(main())
@@ -0,0 +1,162 @@
1
+ """Central registry for TeXSmith's bundled Markdown extensions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterable
6
+ from dataclasses import dataclass
7
+ from importlib import import_module
8
+ from typing import Any
9
+
10
+
11
+ __all__ = [
12
+ "ExtensionSpec",
13
+ "available_extensions",
14
+ "get_extension_spec",
15
+ "load_markdown_extension",
16
+ "load_mkdocs_plugin",
17
+ "register_all_renderers",
18
+ ]
19
+
20
+
21
+ def _load_attribute(path: str) -> Any:
22
+ module_name, _, attribute = path.partition(":")
23
+ if not module_name or not attribute:
24
+ msg = f"Extension entry point '{path}' must use the 'module:attribute' format."
25
+ raise ValueError(msg)
26
+ module = import_module(module_name)
27
+ target: Any = module
28
+ for chunk in attribute.split("."):
29
+ target = getattr(target, chunk)
30
+ return target
31
+
32
+
33
+ def _normalise_slug(value: str) -> str:
34
+ slug = value.split(":", 1)[0].lower()
35
+ if slug.startswith("texsmith."):
36
+ return slug.removeprefix("texsmith.")
37
+ return slug
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class ExtensionSpec:
42
+ """Describe how to import Markdown and renderer hooks for an extension."""
43
+
44
+ slug: str
45
+ markdown_entry: str
46
+ renderer_entry: str | None = None
47
+ mkdocs_entry: str | None = None
48
+ description: str | None = None
49
+
50
+ @property
51
+ def package_name(self) -> str:
52
+ return f"texsmith.{self.slug}"
53
+
54
+ def iter_entry_points(self) -> Iterable[str]:
55
+ """Yield configured entry points for documentation/debugging."""
56
+ yield self.markdown_entry
57
+ if self.renderer_entry:
58
+ yield self.renderer_entry
59
+ if self.mkdocs_entry:
60
+ yield self.mkdocs_entry
61
+
62
+
63
+ _EXTENSIONS: dict[str, ExtensionSpec] = {
64
+ "index": ExtensionSpec(
65
+ slug="index",
66
+ markdown_entry="texsmith.index:TexsmithIndexExtension",
67
+ renderer_entry="texsmith.index:register_renderer",
68
+ mkdocs_entry="texsmith.index:IndexPlugin",
69
+ description="Inline hashtags converted into LaTeX index entries.",
70
+ ),
71
+ "texlogos": ExtensionSpec(
72
+ slug="texlogos",
73
+ markdown_entry="texsmith.texlogos:TexLogosExtension",
74
+ renderer_entry="texsmith.texlogos:register_renderer",
75
+ description="Semantic spans for TeX logos rendered in Markdown and LaTeX.",
76
+ ),
77
+ "smallcaps": ExtensionSpec(
78
+ slug="smallcaps",
79
+ markdown_entry="texsmith.smallcaps:SmallCapsExtension",
80
+ description="Maps '__text__' syntax to small caps spans.",
81
+ ),
82
+ "latex_raw": ExtensionSpec(
83
+ slug="latex_raw",
84
+ markdown_entry="texsmith.latex_raw:LatexRawExtension",
85
+ description="Fence supporting inline raw LaTeX payloads.",
86
+ ),
87
+ "rawlatex": ExtensionSpec(
88
+ slug="rawlatex",
89
+ markdown_entry="texsmith.rawlatex:RawLatexExtension",
90
+ description="Inline {latex}[...] markers and /// latex fences.",
91
+ ),
92
+ "latex_text": ExtensionSpec(
93
+ slug="latex_text",
94
+ markdown_entry="texsmith.latex_text:LatexTextExtension",
95
+ description="Styles the literal 'LaTeX' token with a dedicated span.",
96
+ ),
97
+ "smart_dashes": ExtensionSpec(
98
+ slug="smart_dashes",
99
+ markdown_entry="texsmith.smart_dashes:TexsmithSmartDashesExtension",
100
+ description="Converts '--' and '---' into typographic dashes outside code.",
101
+ ),
102
+ "missing_footnotes": ExtensionSpec(
103
+ slug="missing_footnotes",
104
+ markdown_entry="texsmith.missing_footnotes:MissingFootnotesExtension",
105
+ description="Warns about references to undefined footnotes.",
106
+ ),
107
+ "multi_citations": ExtensionSpec(
108
+ slug="multi_citations",
109
+ markdown_entry="texsmith.multi_citations:MultiCitationExtension",
110
+ description="Normalises '^[a,b]' inline citations before footnotes run.",
111
+ ),
112
+ "mermaid": ExtensionSpec(
113
+ slug="mermaid",
114
+ markdown_entry="texsmith.mermaid:MermaidExtension",
115
+ description="Inlines Mermaid diagrams referenced via Markdown images.",
116
+ ),
117
+ "progressbar": ExtensionSpec(
118
+ slug="progressbar",
119
+ markdown_entry="texsmith.progressbar:ProgressBarExtension",
120
+ renderer_entry="texsmith.progressbar:register_renderer",
121
+ description="Renders `[=50%]` progress blocks via the LaTeX progressbar package.",
122
+ ),
123
+ }
124
+
125
+
126
+ def available_extensions() -> list[ExtensionSpec]:
127
+ """Return the registered extension specs sorted by slug."""
128
+ return [_EXTENSIONS[key] for key in sorted(_EXTENSIONS)]
129
+
130
+
131
+ def get_extension_spec(name: str) -> ExtensionSpec:
132
+ """Look up the runtime spec for a given extension slug or qualified name."""
133
+ slug = _normalise_slug(name)
134
+ try:
135
+ return _EXTENSIONS[slug]
136
+ except KeyError as exc: # pragma: no cover - defensive
137
+ raise KeyError(f"No TeXSmith extension named '{name}'.") from exc
138
+
139
+
140
+ def load_markdown_extension(name: str, **config: Any) -> Any:
141
+ """Instantiate a Python-Markdown extension by slug or qualified name."""
142
+ spec = get_extension_spec(name)
143
+ factory: Callable[..., Any] = _load_attribute(spec.markdown_entry)
144
+ return factory(**config)
145
+
146
+
147
+ def register_all_renderers(renderer: object) -> None:
148
+ """Register every available renderer hook on the provided renderer."""
149
+ for spec in _EXTENSIONS.values():
150
+ if not spec.renderer_entry:
151
+ continue
152
+ register: Callable[[object], None] = _load_attribute(spec.renderer_entry)
153
+ register(renderer)
154
+
155
+
156
+ def load_mkdocs_plugin(name: str) -> type[Any] | None:
157
+ """Return the MkDocs plugin class for an extension, if any."""
158
+ spec = get_extension_spec(name)
159
+ if not spec.mkdocs_entry:
160
+ return None
161
+ plugin_cls: type[Any] = _load_attribute(spec.mkdocs_entry)
162
+ return plugin_cls
@@ -0,0 +1,21 @@
1
+ """Public entry points for TeXSmith's hashtag index extension."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .markdown import TexsmithIndexExtension, makeExtension
6
+ from .registry import (
7
+ IndexEntry,
8
+ IndexRegistry,
9
+ clear_registry,
10
+ get_registry,
11
+ )
12
+
13
+
14
+ __all__ = [
15
+ "IndexEntry",
16
+ "IndexRegistry",
17
+ "TexsmithIndexExtension",
18
+ "clear_registry",
19
+ "get_registry",
20
+ "makeExtension",
21
+ ]
@@ -0,0 +1,94 @@
1
+ """Markdown extension providing the ``#[term]`` hashtag syntax."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from xml.etree import ElementTree
7
+
8
+ from markdown import Markdown
9
+ from markdown.extensions import Extension
10
+ from markdown.inlinepatterns import InlineProcessor
11
+
12
+
13
+ TAG_PATTERN = re.compile(r"\[([^\]]+)\]")
14
+ STYLE_PATTERN = re.compile(r"\{([^}]+)\}")
15
+
16
+
17
+ class _IndexInlineProcessor(InlineProcessor):
18
+ """Replace index syntax with tagged spans."""
19
+
20
+ def handleMatch( # noqa: N802 - Markdown inline API requires camelCase
21
+ self,
22
+ match: re.Match[str],
23
+ data: str,
24
+ ) -> tuple[ElementTree.Element, int, int]: # type: ignore[override]
25
+ del data
26
+ payload = match.group("payload")
27
+ registry = match.group("registry1")
28
+ style_token = match.group("style")
29
+
30
+ tags = _extract_tags(payload)
31
+ if not tags:
32
+ return None, match.start(0), match.end(0)
33
+
34
+ element = ElementTree.Element("span")
35
+ element.set("class", "ts-hashtag")
36
+ for index, tag in enumerate(tags):
37
+ key = "data-tag" if index == 0 else f"data-tag{index}"
38
+ element.set(key, tag)
39
+
40
+ normalised_style = _normalise_style(style_token)
41
+ if normalised_style:
42
+ element.set("data-style", normalised_style)
43
+
44
+ if registry:
45
+ element.set("data-registry", registry)
46
+
47
+ # The index marker is invisible in the output text
48
+ element.text = ""
49
+ return element, match.start(0), match.end(0)
50
+
51
+
52
+ def _extract_tags(payload: str | None) -> list[str]:
53
+ if not payload:
54
+ return []
55
+ values = [part.strip() for part in TAG_PATTERN.findall(payload)]
56
+ return [value for value in values if value]
57
+
58
+
59
+ def _normalise_style(style_token: str | None) -> str | None:
60
+ if not style_token:
61
+ return None
62
+ match = STYLE_PATTERN.search(style_token)
63
+ if not match:
64
+ return None
65
+ style = match.group(1).strip().lower()
66
+ if not style:
67
+ return None
68
+ if style == "ib":
69
+ style = "bi"
70
+ if style not in {"b", "i", "bi"}:
71
+ return None
72
+ return style
73
+
74
+
75
+ class TexsmithIndexExtension(Extension):
76
+ """Register the inline index processor with Python-Markdown."""
77
+
78
+ def extendMarkdown(self, md: Markdown) -> None: # noqa: N802
79
+ """Match modern and legacy index syntaxes and register the inline processor."""
80
+ pattern = (
81
+ r"(?<!\\)"
82
+ r"(?P<prefix>#|\{index(?::(?P<registry1>[^\]\{\(\s]+))?\}|%)"
83
+ r"(?P<payload>(?:\[[^\]]+\])+)"
84
+ r"(?P<style>\{[^}]+\})?"
85
+ )
86
+ processor = _IndexInlineProcessor(pattern, md)
87
+ md.inlinePatterns.register(processor, "texsmith_index", 180)
88
+
89
+
90
+ def makeExtension(**kwargs: object) -> TexsmithIndexExtension: # noqa: N802 - Markdown API hook; pragma: no cover
91
+ return TexsmithIndexExtension(**kwargs)
92
+
93
+
94
+ __all__ = ["TexsmithIndexExtension", "makeExtension"]
@@ -0,0 +1,136 @@
1
+ """MkDocs plugin injecting TeXSmith hashtag spans into the lunr search index."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from collections.abc import Iterable
7
+ import json
8
+ from pathlib import Path
9
+ import re
10
+ from typing import Any
11
+
12
+ from mkdocs import plugins
13
+ from mkdocs.config import config_options
14
+ from mkdocs.config.defaults import MkDocsConfig
15
+ from mkdocs.plugins import BasePlugin
16
+ from mkdocs.structure.files import Files
17
+ from mkdocs.structure.pages import Page
18
+
19
+
20
+ RE_HEADERLINK = re.compile(r'<a\s+[^>]*headerlink[^>]*href="(#[^"]+)"[^>]*>')
21
+ RE_HASHTAG = re.compile(r"<span\s+[^>]*class=\"[^\"]*ts-(?:hashtag|index)[^\"]*\"[^>]*>")
22
+ RE_DATA_TAG = re.compile(r"data-tag\d*=\"([^\"]+)\"")
23
+
24
+
25
+ def _expand_search_terms(tags: Iterable[str]) -> list[str]:
26
+ """Return a list of search tokens derived from the hierarchy of tags."""
27
+ tokens: list[str] = []
28
+ collected: set[str] = set()
29
+ hierarchy: list[str] = []
30
+ for tag in tags:
31
+ hierarchy.append(tag)
32
+ direct = tag.strip()
33
+ if direct and direct not in collected:
34
+ collected.add(direct)
35
+ tokens.append(direct)
36
+ composite = "::".join(hierarchy)
37
+ if composite not in collected:
38
+ collected.add(composite)
39
+ tokens.append(composite)
40
+ return tokens
41
+
42
+
43
+ def _extract_tags(fragment: str) -> list[str]:
44
+ return [value.strip() for value in RE_DATA_TAG.findall(fragment) if value.strip()]
45
+
46
+
47
+ class IndexPlugin(BasePlugin):
48
+ """Collect hashtag spans and inject their tags into MkDocs search."""
49
+
50
+ config_scheme = (("inject_markdown_extension", config_options.Type(bool, default=True)),)
51
+
52
+ def __init__(self) -> None:
53
+ self._collected: dict[str, set[tuple[str, ...]]] = defaultdict(set)
54
+
55
+ def on_config(self, config: MkDocsConfig) -> MkDocsConfig:
56
+ """Optionally enable the markdown extension automatically."""
57
+ self._collected.clear()
58
+ if self.config.get("inject_markdown_extension", True):
59
+ extension = "texsmith.index:TexsmithIndexExtension"
60
+ extensions = list(config.markdown_extensions or [])
61
+ if extension not in extensions:
62
+ extensions.append(extension)
63
+ config.markdown_extensions = extensions
64
+ return config
65
+
66
+ def on_page_content(
67
+ self,
68
+ html: str,
69
+ page: Page,
70
+ config: MkDocsConfig,
71
+ files: Files,
72
+ ) -> str:
73
+ """Collect tags per page (and optionally per section heading)."""
74
+ del config, files
75
+ base = page.url or ""
76
+ anchor = ""
77
+ heading_count = 0
78
+
79
+ for line in html.split("\n"):
80
+ if header := RE_HEADERLINK.search(line):
81
+ anchor = header.group(1)
82
+ heading_count += 1
83
+
84
+ for match in RE_HASHTAG.findall(line):
85
+ tags = _extract_tags(match)
86
+ if not tags:
87
+ continue
88
+ location = f"{base}{anchor}" if anchor and heading_count > 1 else base
89
+ self._collected[location].add(tuple(tags))
90
+
91
+ return html
92
+
93
+ @plugins.event_priority(-100)
94
+ def on_post_build(self, config: MkDocsConfig) -> None:
95
+ """Inject gathered tags into the lunr search index."""
96
+ if not self._collected:
97
+ return
98
+
99
+ search_dir = Path(config.site_dir) / "search"
100
+ index_path = search_dir / "search_index.json"
101
+ if not index_path.exists():
102
+ return
103
+
104
+ data: dict[str, Any]
105
+ with index_path.open("r", encoding="utf-8") as handle:
106
+ data = json.load(handle)
107
+
108
+ docs = data.get("docs")
109
+ if not isinstance(docs, list):
110
+ return
111
+
112
+ for entry in docs:
113
+ location = entry.get("location")
114
+ if not isinstance(location, str):
115
+ continue
116
+ tag_sets = self._collected.get(location)
117
+ if not tag_sets:
118
+ continue
119
+ existing = entry.setdefault("tags", [])
120
+ if not isinstance(existing, list):
121
+ continue
122
+ payload: list[str] = []
123
+ seen: set[str] = set(map(str, existing))
124
+ for tags in sorted(tag_sets):
125
+ for token in _expand_search_terms(tags):
126
+ if token not in seen:
127
+ seen.add(token)
128
+ payload.append(token)
129
+ if payload:
130
+ existing.extend(payload)
131
+
132
+ with index_path.open("w", encoding="utf-8") as handle:
133
+ json.dump(data, handle)
134
+
135
+
136
+ __all__ = ["IndexPlugin"]
@@ -0,0 +1,57 @@
1
+ """Global registry tracking hashtag-index entries across documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Iterator
6
+ from dataclasses import dataclass, field
7
+ from threading import Lock
8
+
9
+
10
+ IndexEntry = tuple[str, ...]
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class IndexRegistry:
15
+ """Thread-safe container gathering index entries encountered during rendering."""
16
+
17
+ _entries: set[IndexEntry] = field(default_factory=set)
18
+ _lock: Lock = field(default_factory=Lock)
19
+
20
+ def add(self, entry: Iterable[str]) -> None:
21
+ """Record a tuple describing an index entry."""
22
+ values = tuple(part for part in entry if part)
23
+ if not values:
24
+ return
25
+ with self._lock:
26
+ self._entries.add(values)
27
+
28
+ def clear(self) -> None:
29
+ """Reset the registry to its initial empty state."""
30
+ with self._lock:
31
+ self._entries.clear()
32
+
33
+ def __len__(self) -> int: # pragma: no cover - trivial
34
+ with self._lock:
35
+ return len(self._entries)
36
+
37
+ def __iter__(self) -> Iterator[IndexEntry]: # pragma: no cover - simple proxy
38
+ with self._lock:
39
+ yield from sorted(self._entries)
40
+
41
+ def snapshot(self) -> set[IndexEntry]:
42
+ """Return a shallow copy of the registered entries."""
43
+ with self._lock:
44
+ return set(self._entries)
45
+
46
+
47
+ _REGISTRY = IndexRegistry()
48
+
49
+
50
+ def get_registry() -> IndexRegistry:
51
+ """Return the global index registry."""
52
+ return _REGISTRY
53
+
54
+
55
+ def clear_registry() -> None:
56
+ """Convenience helper to wipe the global registry."""
57
+ _REGISTRY.clear()