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,343 @@
1
+ """Core classes used to load and wrap LaTeX templates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping
6
+ from dataclasses import dataclass
7
+ import importlib.util
8
+ import inspect
9
+ from pathlib import Path
10
+ import shutil
11
+ import sys
12
+ from typing import Any, cast
13
+
14
+ from jinja2 import Environment, FileSystemLoader, TemplateNotFound
15
+
16
+ from texsmith.adapters.latex.utils import escape_latex_chars
17
+ from texsmith.adapters.latex.pyxindy import is_available as pyxindy_available
18
+ from .manifest import TemplateError, TemplateManifest, TemplateSlot
19
+
20
+
21
+ def _detect_index_engine() -> str:
22
+ """Return the preferred index engine based on available executables."""
23
+ if pyxindy_available():
24
+ return "pyxindy"
25
+ return "texindy" if shutil.which("texindy") else "makeindex"
26
+
27
+
28
+ def _resolve_manifest_path(root: Path) -> Path:
29
+ candidates = (root / "manifest.toml", root / "template" / "manifest.toml")
30
+ for candidate in candidates:
31
+ if candidate.exists():
32
+ return candidate
33
+ raise TemplateError(
34
+ f"Unable to locate template manifest under '{root}'. "
35
+ "Expected 'manifest.toml' at the root or inside a 'template' directory."
36
+ )
37
+
38
+
39
+ def _build_environment(template_root: Path) -> Environment:
40
+ search_paths = [str(template_root)]
41
+ common_dir = template_root.parent / "common"
42
+ if common_dir.exists():
43
+ search_paths.append(str(common_dir))
44
+ loader = FileSystemLoader(search_paths)
45
+ environment = Environment(
46
+ loader=loader,
47
+ autoescape=False,
48
+ trim_blocks=True,
49
+ lstrip_blocks=True,
50
+ keep_trailing_newline=True,
51
+ block_start_string=r"\BLOCK{",
52
+ block_end_string="}",
53
+ variable_start_string=r"\VAR{",
54
+ variable_end_string="}",
55
+ comment_start_string=r"\#{",
56
+ comment_end_string="}",
57
+ )
58
+ environment.filters.setdefault("latex_escape", escape_latex_chars)
59
+ environment.filters.setdefault("escape_latex", escape_latex_chars)
60
+ return environment
61
+
62
+
63
+ class BaseTemplate:
64
+ """Base class shared by template implementations."""
65
+
66
+ def __init__(self, root: Path) -> None:
67
+ self.root = root.resolve()
68
+ if not self.root.exists():
69
+ raise TemplateError(f"Template root does not exist: {self.root}")
70
+
71
+ manifest_path = _resolve_manifest_path(self.root)
72
+ self.manifest = TemplateManifest.load(manifest_path)
73
+ self.info = self.manifest.latex.template
74
+ self.environment = _build_environment(self.root)
75
+
76
+ def default_context(self) -> dict[str, Any]:
77
+ """Return a shallow copy of the manifest default attributes."""
78
+ defaults = self.info.attribute_defaults()
79
+ defaults.update(self.info.emit_defaults())
80
+ return defaults
81
+
82
+ def render_template(self, template_name: str, **context: Any) -> str:
83
+ """Render a template using the configured Jinja environment."""
84
+ try:
85
+ template = self.environment.get_template(template_name)
86
+ except TemplateNotFound as exc:
87
+ raise TemplateError(
88
+ f"Template entry '{template_name}' is missing in {self.root}"
89
+ ) from exc
90
+ return template.render(context)
91
+
92
+
93
+ class WrappableTemplate(BaseTemplate):
94
+ """Template capable of wrapping a generated LaTeX fragment."""
95
+
96
+ def prepare_context(
97
+ self,
98
+ latex_body: str,
99
+ *,
100
+ overrides: Mapping[str, Any] | None = None,
101
+ ) -> dict[str, Any]:
102
+ """Build the rendering context shared by the template and its assets."""
103
+ attribute_context = self.info.resolve_attributes(overrides)
104
+ context = dict(attribute_context)
105
+ for key, value in self.info.emit_defaults().items():
106
+ context.setdefault(key, value)
107
+ if overrides:
108
+ for key, value in overrides.items():
109
+ if key in context and key not in self.info.emit:
110
+ continue
111
+ context[key] = value
112
+
113
+ context.setdefault("frontmatter", "")
114
+ context.setdefault("backmatter", "")
115
+ context.setdefault("index_entries", False)
116
+ context.setdefault("has_index", False)
117
+ context.setdefault("index_terms", [])
118
+ context.setdefault("index_registry", [])
119
+ context.setdefault("index_engine", "auto")
120
+ context.setdefault("acronyms", {})
121
+ context.setdefault("citations", [])
122
+ context.setdefault("bibliography_entries", {})
123
+ context.setdefault("bibliography_resource", None)
124
+
125
+ slots, default_slot = self.info.resolve_slots()
126
+ for name in slots:
127
+ context.setdefault(name, "")
128
+
129
+ if default_slot == "mainmatter":
130
+ context["mainmatter"] = latex_body
131
+ else:
132
+ context.setdefault("mainmatter", "")
133
+ context[default_slot] = latex_body
134
+
135
+ return context
136
+
137
+ def wrap_document(
138
+ self,
139
+ latex_body: str,
140
+ *,
141
+ overrides: Mapping[str, Any] | None = None,
142
+ context: Mapping[str, Any] | None = None,
143
+ ) -> str:
144
+ """Render the template entry point using the provided LaTeX payload."""
145
+ if context is None:
146
+ context = self.prepare_context(latex_body, overrides=overrides)
147
+ else:
148
+ context = dict(context)
149
+ context.setdefault("frontmatter", "")
150
+ context.setdefault("backmatter", "")
151
+ slots, default_slot = self.info.resolve_slots()
152
+ for name in slots:
153
+ context.setdefault(name, "")
154
+ if default_slot == "mainmatter":
155
+ context["mainmatter"] = latex_body
156
+ else:
157
+ context.setdefault("mainmatter", "")
158
+ context[default_slot] = latex_body
159
+
160
+ engine = str(context.get("index_engine") or "").strip().lower()
161
+ if not engine or engine == "auto":
162
+ context["index_engine"] = _detect_index_engine()
163
+ else:
164
+ context["index_engine"] = engine
165
+
166
+ return self.render_template(self.info.entrypoint, **context)
167
+
168
+ def iter_assets(self) -> Iterable["ResolvedAsset"]:
169
+ """Yield declared template assets."""
170
+ for destination, asset in self.info.assets.items():
171
+ dest_path = Path(destination)
172
+ if dest_path.is_absolute():
173
+ raise TemplateError(
174
+ f"Template asset destination must be relative, got '{destination}'."
175
+ )
176
+
177
+ source_path = Path(asset.source)
178
+ if not source_path.is_absolute():
179
+ source_path = (self.root / source_path).resolve()
180
+ if not source_path.exists():
181
+ raise TemplateError(
182
+ f"Declared template asset '{asset.source}' is missing under {self.root}."
183
+ )
184
+
185
+ template_name: str | None = None
186
+ if asset.template:
187
+ if source_path.is_dir():
188
+ raise TemplateError(
189
+ f"Templated assets must reference files, got directory '{asset.source}'."
190
+ )
191
+ try:
192
+ relative = source_path.relative_to(self.root)
193
+ except ValueError as exc: # pragma: no cover - defensive
194
+ common_dir = self.root.parent / "common"
195
+ try:
196
+ relative = source_path.relative_to(common_dir)
197
+ except ValueError as nested_exc:
198
+ raise TemplateError(
199
+ f"Templated asset '{asset.source}' must live inside the template root "
200
+ "or the shared 'common' directory."
201
+ ) from nested_exc
202
+ template_name = relative.as_posix()
203
+
204
+ yield ResolvedAsset(
205
+ source=source_path,
206
+ destination=dest_path,
207
+ template=asset.template,
208
+ encoding=asset.encoding,
209
+ template_name=template_name,
210
+ )
211
+
212
+ def iter_formatter_overrides(self) -> Iterable[tuple[str, Path]]:
213
+ """Yield formatter override templates declared by the manifest."""
214
+ if not self.info.override:
215
+ return ()
216
+
217
+ search_roots = [
218
+ self.root / "overrides",
219
+ self.root / "template" / "overrides",
220
+ self.root,
221
+ self.root.parent / "overrides",
222
+ ]
223
+
224
+ seen: set[str] = set()
225
+ overrides: list[tuple[str, Path]] = []
226
+
227
+ for entry in self.info.override:
228
+ if not isinstance(entry, str):
229
+ raise TemplateError("Formatter override entries must be provided as string paths.")
230
+ candidate = entry.strip()
231
+ if not candidate:
232
+ continue
233
+
234
+ relative_path = Path(candidate)
235
+ if relative_path.is_absolute() or any(part == ".." for part in relative_path.parts):
236
+ raise TemplateError(
237
+ f"Formatter override '{entry}' must be a relative path without '..'."
238
+ )
239
+
240
+ resolved_path: Path | None = None
241
+ for root in search_roots:
242
+ if not root.exists():
243
+ continue
244
+ probe = (root / relative_path).resolve()
245
+ if probe.exists():
246
+ resolved_path = probe
247
+ break
248
+
249
+ if resolved_path is None:
250
+ raise TemplateError(f"Formatter override '{entry}' is missing under '{self.root}'.")
251
+
252
+ key = relative_path.with_suffix("").as_posix().replace("/", "_")
253
+ if key in seen:
254
+ continue
255
+ seen.add(key)
256
+ overrides.append((key, resolved_path))
257
+
258
+ return overrides
259
+
260
+
261
+ @dataclass(slots=True)
262
+ class ResolvedAsset:
263
+ """Resolved template asset ready to be materialised."""
264
+
265
+ source: Path
266
+ destination: Path
267
+ template: bool = False
268
+ encoding: str | None = None
269
+ template_name: str | None = None
270
+
271
+
272
+ def _coerce_template(value: Any) -> WrappableTemplate | None:
273
+ """Coerce an entry point payload into a ``WrappableTemplate`` instance."""
274
+ if isinstance(value, WrappableTemplate):
275
+ return value
276
+
277
+ if inspect.isclass(value) and issubclass(value, WrappableTemplate):
278
+ template_cls = cast(type[Any], value)
279
+ try:
280
+ instance = template_cls()
281
+ except TypeError as exc: # pragma: no cover - defensive
282
+ raise TemplateError(
283
+ "Specialised template classes exported via entry points must be ``WrappableTemplate`` "
284
+ "instances or be instantiable without arguments."
285
+ ) from exc
286
+ return cast(WrappableTemplate, instance)
287
+
288
+ if callable(value):
289
+ produced = value()
290
+ return _coerce_template(produced)
291
+
292
+ if isinstance(value, (str, Path)):
293
+ path = Path(value)
294
+ if path.exists():
295
+ return WrappableTemplate(path)
296
+
297
+ return None
298
+
299
+
300
+ def load_specialised_template(path: Path) -> WrappableTemplate | None:
301
+ """Import a template-specific module to retrieve a specialised implementation."""
302
+ init_path = path / "__init__.py"
303
+ if not init_path.exists():
304
+ return None
305
+
306
+ resolved_init = init_path.resolve()
307
+ module_name = f"_texsmith_template_{hash(resolved_init) & 0xFFFFFFFF:x}"
308
+ spec = importlib.util.spec_from_file_location(
309
+ module_name,
310
+ resolved_init,
311
+ submodule_search_locations=[str(path.resolve())],
312
+ )
313
+ if spec is None or spec.loader is None: # pragma: no cover - defensive
314
+ return None
315
+
316
+ module = importlib.util.module_from_spec(spec)
317
+ sys.modules[module_name] = module
318
+ try:
319
+ spec.loader.exec_module(module)
320
+ except Exception as exc: # pragma: no cover - surface import errors
321
+ sys.modules.pop(module_name, None)
322
+ raise TemplateError(f"Failed to import template module at '{path}': {exc}") from exc
323
+
324
+ for attribute in ("Template", "template", "load_template", "get_template"):
325
+ candidate = getattr(module, attribute, None)
326
+ if candidate is None:
327
+ continue
328
+ specialised = _coerce_template(candidate)
329
+ if specialised is not None:
330
+ return specialised
331
+
332
+ return None
333
+
334
+
335
+ __all__ = [
336
+ "BaseTemplate",
337
+ "ResolvedAsset",
338
+ "TemplateError",
339
+ "WrappableTemplate",
340
+ "_build_environment",
341
+ "_resolve_manifest_path",
342
+ "load_specialised_template",
343
+ ]
@@ -0,0 +1,68 @@
1
+ """Registry helpers for templates built directly into TeXSmith."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import import_module
6
+
7
+ from .base import WrappableTemplate
8
+
9
+
10
+ _BUILTIN_FACTORIES: dict[str, str] = {
11
+ "article": "texsmith.templates.article:Template",
12
+ "book": "texsmith.templates.book:Template",
13
+ "letter": "texsmith.templates.letter:Template",
14
+ "snippet": "texsmith.templates.snippet:Template",
15
+ }
16
+
17
+ _ALIASES: dict[str, str] = {
18
+ "formal-letter": "letter",
19
+ }
20
+
21
+ _PREFIXES = ("texsmith:", "texsmith.", "texsmith/", "builtin:", "builtin.", "builtin/")
22
+
23
+
24
+ def _normalise_identifier(identifier: str | None) -> str | None:
25
+ if identifier is None:
26
+ return None
27
+
28
+ candidate = identifier.strip().lower()
29
+ if not candidate:
30
+ return None
31
+
32
+ for prefix in _PREFIXES:
33
+ if candidate.startswith(prefix):
34
+ candidate = candidate[len(prefix) :]
35
+ break
36
+
37
+ candidate = candidate.strip("./")
38
+ candidate = candidate.replace("_", "-")
39
+ return candidate or None
40
+
41
+
42
+ def load_builtin_template(identifier: str) -> WrappableTemplate | None:
43
+ """Return a built-in template instance matching ``identifier`` when possible."""
44
+ slug = _normalise_identifier(identifier)
45
+ if slug is None:
46
+ return None
47
+
48
+ resolved = _ALIASES.get(slug, slug)
49
+ factory_path = _BUILTIN_FACTORIES.get(resolved)
50
+ if factory_path is None:
51
+ return None
52
+
53
+ module_name, _, attr_name = factory_path.partition(":")
54
+ if not module_name:
55
+ return None
56
+
57
+ module = import_module(module_name)
58
+ factory = getattr(module, attr_name or "Template")
59
+ template = factory()
60
+ return template
61
+
62
+
63
+ def iter_builtin_templates() -> tuple[str, ...]:
64
+ """Return the available built-in template slugs."""
65
+ return tuple(sorted(_BUILTIN_FACTORIES))
66
+
67
+
68
+ __all__ = ["iter_builtin_templates", "load_builtin_template"]
@@ -0,0 +1,137 @@
1
+ """Helpers to introspect template context emitters and consumers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from collections.abc import Mapping, Sequence
7
+ from typing import Any
8
+
9
+ from jinja2 import meta
10
+
11
+ from texsmith.core.fragments import FRAGMENT_REGISTRY, FragmentDefinition
12
+ from texsmith.core.templates.base import WrappableTemplate, _build_environment
13
+
14
+
15
+ def _format_value_preview(value: Any, *, limit: int = 60) -> str:
16
+ """Return a compact string representation suitable for tables."""
17
+
18
+ try:
19
+ rendered = repr(value)
20
+ except Exception: # pragma: no cover - defensive
21
+ rendered = "<unrenderable>"
22
+
23
+ if len(rendered) > limit:
24
+ return rendered[: limit - 1] + "\u2026"
25
+ return rendered
26
+
27
+
28
+ def _discover_template_variables(template: WrappableTemplate) -> set[str]:
29
+ """Return undeclared variables referenced by the template entrypoint."""
30
+
31
+ env = template.environment
32
+ try:
33
+ source, _, _ = env.loader.get_source(env, template.info.entrypoint)
34
+ except Exception:
35
+ return set()
36
+ parsed = env.parse(source)
37
+ return set(meta.find_undeclared_variables(parsed))
38
+
39
+
40
+ def _discover_fragment_variables(fragment: Any) -> set[str]:
41
+ """Return undeclared variables referenced by a fragment's pieces."""
42
+
43
+ variables: set[str] = set()
44
+ pieces = getattr(fragment, "pieces", None) or []
45
+ for piece in pieces:
46
+ env = _build_environment(piece.template_path.parent)
47
+ try:
48
+ source, _, _ = env.loader.get_source(env, piece.template_path.name)
49
+ except Exception:
50
+ continue
51
+ parsed = env.parse(source)
52
+ variables.update(meta.find_undeclared_variables(parsed))
53
+ return variables
54
+
55
+
56
+ def summarise_context_usage(
57
+ template: WrappableTemplate,
58
+ template_context: Mapping[str, Any],
59
+ *,
60
+ fragment_names: Sequence[str] | None = None,
61
+ overrides: Mapping[str, Any] | None = None,
62
+ ) -> list[dict[str, Any]]:
63
+ """Return a summary of context attributes with emitters/consumers."""
64
+
65
+ fragment_names = fragment_names or []
66
+ emitter_map: dict[str, set[str]] = defaultdict(set)
67
+ consumer_map: dict[str, set[str]] = defaultdict(set)
68
+
69
+ # Template-declared attributes and emitted helpers.
70
+ template_name = template.info.name
71
+ for name, spec in getattr(template.info, "attributes", {}).items():
72
+ owner = getattr(spec, "owner", None) or template_name
73
+ emitter_map[name].add(f"template:{owner} (attribute)")
74
+ for key in getattr(template.info, "emit", {}) or {}:
75
+ emitter_map[key].add(f"template:{template_name} (emit)")
76
+
77
+ # Fragment-provided attributes and context defaults.
78
+ for fragment_name in fragment_names:
79
+ try:
80
+ fragment = FRAGMENT_REGISTRY.resolve(fragment_name)
81
+ except Exception:
82
+ continue
83
+
84
+ if isinstance(fragment, FragmentDefinition):
85
+ attributes = fragment.attributes
86
+ context_defaults = fragment.context_defaults
87
+ else:
88
+ attributes = FRAGMENT_REGISTRY.attributes_for(fragment.name)
89
+ context_defaults = getattr(fragment, "context_defaults", {}) or {}
90
+
91
+ for attr_name, spec in attributes.items():
92
+ owner = getattr(spec, "owner", None) or fragment.name
93
+ emitter_map[attr_name].add(f"fragment:{owner} (attribute)")
94
+ for key in context_defaults:
95
+ emitter_map[key].add(f"fragment:{fragment.name} (context)")
96
+
97
+ for variable in _discover_fragment_variables(fragment):
98
+ if variable:
99
+ consumer_map[variable].add(f"fragment:{fragment.name}")
100
+
101
+ # Track which attributes were supplied via overrides.
102
+ if overrides:
103
+ press_section = overrides.get("press") if isinstance(overrides, Mapping) else None
104
+ for key in template_context.keys():
105
+ direct_override = key in overrides if isinstance(overrides, Mapping) else False
106
+ press_override = bool(isinstance(press_section, Mapping) and key in press_section)
107
+ if direct_override or press_override:
108
+ emitter_map[key].add("override")
109
+
110
+ # Anything injected by the pipeline without a declared owner.
111
+ for key in template_context.keys():
112
+ if key not in emitter_map:
113
+ emitter_map[key].add("pipeline")
114
+
115
+ for variable in _discover_template_variables(template):
116
+ if variable:
117
+ consumer_map[variable].add(f"template:{template_name}")
118
+
119
+ keys = sorted(set(template_context.keys()) | set(emitter_map) | set(consumer_map))
120
+
121
+ summary: list[dict[str, Any]] = []
122
+ for key in keys:
123
+ value = template_context.get(key)
124
+ summary.append(
125
+ {
126
+ "name": key,
127
+ "type": type(value).__name__ if value is not None else "None",
128
+ "value": _format_value_preview(value),
129
+ "emitters": sorted(emitter_map.get(key, {"-"})),
130
+ "consumers": sorted(consumer_map.get(key, {"-"})),
131
+ }
132
+ )
133
+
134
+ return summary
135
+
136
+
137
+ __all__ = ["summarise_context_usage"]