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,716 @@
1
+ """Pluggable fragment helpers (templated .sty packages)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterable, Mapping, Sequence
6
+ from dataclasses import dataclass, field
7
+ import importlib
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ try: # Python >=3.11
13
+ import tomllib # type: ignore[attr-defined]
14
+ except ModuleNotFoundError: # Python 3.10
15
+ import tomli as tomllib
16
+
17
+ from texsmith.core.fragments.base import BaseFragment, FragmentKind, FragmentPiece
18
+ from texsmith.core.partials import normalise_partial_key
19
+ from texsmith.core.templates.base import _build_environment
20
+ from texsmith.core.templates.manifest import (
21
+ TemplateAttributeResolver,
22
+ TemplateAttributeSpec,
23
+ TemplateError,
24
+ )
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class FragmentDefinition:
29
+ """Resolved fragment template ready to render."""
30
+
31
+ name: str
32
+ pieces: list[FragmentPiece]
33
+ description: str | None = None
34
+ source: Path | None = None
35
+ context_defaults: dict[str, Any] = field(default_factory=dict)
36
+ context_injector: Callable[[dict[str, Any], Mapping[str, Any] | None], None] | None = None
37
+ should_render: Callable[[Mapping[str, Any]], bool] | None = None
38
+ attributes: dict[str, TemplateAttributeSpec] = field(default_factory=dict)
39
+ partials: Mapping[str, Path] | Sequence[Path | str] = field(default_factory=dict)
40
+ required_partials: set[str] = field(default_factory=set)
41
+ _attribute_resolver: TemplateAttributeResolver | None = field(
42
+ init=False, repr=False, default=None
43
+ )
44
+
45
+ def __post_init__(self) -> None:
46
+ if self.attributes:
47
+ normalised: dict[str, TemplateAttributeSpec] = {}
48
+ for name, spec in self.attributes.items():
49
+ if isinstance(spec, TemplateAttributeSpec):
50
+ candidate = spec
51
+ elif isinstance(spec, Mapping):
52
+ candidate = TemplateAttributeSpec.model_validate(spec)
53
+ else:
54
+ candidate = TemplateAttributeSpec.model_validate({"default": spec})
55
+ candidate.name = name
56
+ if candidate.owner is None:
57
+ candidate.owner = self.name
58
+ normalised[name] = candidate
59
+
60
+ object.__setattr__(self, "attributes", normalised)
61
+ object.__setattr__(self, "_attribute_resolver", TemplateAttributeResolver(normalised))
62
+
63
+ base_dir = self._resolve_base_dir()
64
+ resolved_partials = self._normalise_partials(base_dir)
65
+ object.__setattr__(self, "partials", resolved_partials)
66
+ object.__setattr__(self, "required_partials", self._normalise_required_partials())
67
+
68
+ def attribute_defaults(self) -> dict[str, Any]:
69
+ """Return defaults for fragment-managed attributes."""
70
+ if self._attribute_resolver is None:
71
+ return {}
72
+ return self._attribute_resolver.defaults()
73
+
74
+ def resolve_attributes(self, overrides: Mapping[str, Any] | None = None) -> dict[str, Any]:
75
+ """Return resolved fragment attributes merged with overrides."""
76
+ if self._attribute_resolver is None:
77
+ return {}
78
+ return self._attribute_resolver.merge(overrides)
79
+
80
+ def iter_partials(self) -> Iterable[tuple[str, Path]]:
81
+ """Yield resolved partial overrides."""
82
+ return self.partials.items()
83
+
84
+ def _resolve_base_dir(self) -> Path:
85
+ if self.source is not None:
86
+ return (self.source if self.source.is_dir() else self.source.parent).resolve()
87
+ if self.pieces:
88
+ return self.pieces[0].template_path.parent
89
+ return Path().resolve()
90
+
91
+ def _normalise_partials(self, base_dir: Path) -> dict[str, Path]:
92
+ raw_partials = self.partials or {}
93
+ resolved: dict[str, Path] = {}
94
+ entries: Iterable[tuple[str | None, Path | str]]
95
+
96
+ if isinstance(raw_partials, Mapping):
97
+ entries = [(key, value) for key, value in raw_partials.items()]
98
+ elif isinstance(raw_partials, Sequence) and not isinstance(raw_partials, (str, bytes)):
99
+ entries = [(None, entry) for entry in raw_partials]
100
+ else:
101
+ return resolved
102
+
103
+ for name_hint, payload in entries:
104
+ path_value = Path(payload)
105
+ resolved_path = (
106
+ path_value if path_value.is_absolute() else (base_dir / path_value).resolve()
107
+ )
108
+ if not resolved_path.exists():
109
+ raise TemplateError(
110
+ f"Partial '{payload}' declared by fragment '{self.name}' is missing: {resolved_path}"
111
+ )
112
+
113
+ candidate_name = (
114
+ str(name_hint) if name_hint is not None else path_value.with_suffix("").as_posix()
115
+ )
116
+ normalised = normalise_partial_key(candidate_name)
117
+ if not normalised:
118
+ raise TemplateError(
119
+ f"Fragment '{self.name}' declared a partial with an empty name."
120
+ )
121
+ if normalised in resolved:
122
+ raise TemplateError(
123
+ f"Fragment '{self.name}' declares partial '{normalised}' more than once."
124
+ )
125
+ resolved[normalised] = resolved_path
126
+ return resolved
127
+
128
+ def _normalise_required_partials(self) -> set[str]:
129
+ required: set[str] = set()
130
+ for entry in self.required_partials or set():
131
+ key = normalise_partial_key(str(entry))
132
+ if key:
133
+ required.add(key)
134
+ return required
135
+
136
+ @classmethod
137
+ def from_manifest(cls, manifest_path: Path) -> BaseFragment[Any] | FragmentDefinition:
138
+ """Load a fragment definition from a ``fragment.toml`` file."""
139
+ try:
140
+ payload = tomllib.loads(manifest_path.read_text(encoding="utf-8"))
141
+ except OSError as exc: # pragma: no cover - IO edge cases
142
+ raise TemplateError(f"Failed to read fragment manifest {manifest_path}: {exc}") from exc
143
+ except tomllib.TOMLDecodeError as exc:
144
+ raise TemplateError(f"Invalid fragment manifest {manifest_path}: {exc}") from exc
145
+
146
+ base_dir = manifest_path.parent
147
+
148
+ entrypoint = payload.get("entrypoint")
149
+ if isinstance(entrypoint, str):
150
+ return _load_entrypoint(entrypoint)
151
+
152
+ name = payload.get("name") if isinstance(payload.get("name"), str) else None
153
+ description = (
154
+ payload.get("description") if isinstance(payload.get("description"), str) else None
155
+ )
156
+ files = payload.get("files") or []
157
+ if not name:
158
+ name = base_dir.name
159
+ if not isinstance(files, list) or not files:
160
+ raise TemplateError(
161
+ f"Fragment manifest {manifest_path} must declare at least one file."
162
+ )
163
+
164
+ pieces = [FragmentPiece.from_mapping(entry, base_dir=base_dir) for entry in files]
165
+ attributes: dict[str, TemplateAttributeSpec] = {}
166
+ declared_attrs = payload.get("attributes")
167
+ if isinstance(declared_attrs, Mapping):
168
+ for key, value in declared_attrs.items():
169
+ attributes[key] = (
170
+ value
171
+ if isinstance(value, TemplateAttributeSpec)
172
+ else TemplateAttributeSpec.model_validate(value)
173
+ if isinstance(value, Mapping)
174
+ else TemplateAttributeSpec.model_validate({"default": value})
175
+ )
176
+ partials: Mapping[str, Path | str] | Sequence[Path | str] = {}
177
+ partial_entries = payload.get("partials")
178
+ if isinstance(partial_entries, (Mapping, list, tuple)):
179
+ partials = partial_entries
180
+ elif partial_entries is not None:
181
+ raise TemplateError("Fragment manifest 'partials' must be a list or mapping.")
182
+
183
+ required_partials: set[str] = set()
184
+ required_entries = payload.get("required_partials") or ()
185
+ if isinstance(required_entries, Sequence) and not isinstance(
186
+ required_entries, (str, bytes)
187
+ ):
188
+ for entry in required_entries:
189
+ if not isinstance(entry, str):
190
+ raise TemplateError("Fragment 'required_partials' entries must be strings.")
191
+ required_partials.add(entry)
192
+ elif required_entries:
193
+ raise TemplateError("Fragment manifest 'required_partials' must be a list of strings.")
194
+
195
+ return cls(
196
+ name=name,
197
+ pieces=pieces,
198
+ description=description,
199
+ source=manifest_path,
200
+ context_defaults={},
201
+ attributes=attributes,
202
+ partials=partials,
203
+ required_partials=required_partials,
204
+ )
205
+
206
+ @classmethod
207
+ def from_path(cls, path: Path, *, name: str | None = None) -> FragmentDefinition:
208
+ """Build a fragment definition from a single template path."""
209
+ resolved = path.resolve()
210
+ if not resolved.exists():
211
+ raise TemplateError(f"Fragment path does not exist: {resolved}")
212
+
213
+ fragment_name = name or _package_name(resolved.name)
214
+ piece = FragmentPiece(template_path=resolved, kind="package", slot="extra_packages")
215
+ return cls(name=fragment_name, pieces=[piece], source=resolved, context_defaults={})
216
+
217
+
218
+ def _package_name(identifier: str) -> str:
219
+ candidate = Path(identifier)
220
+ name = candidate.name
221
+ for suffix in (".jinja.sty", ".jinja.tex", ".sty", ".tex"):
222
+ if name.endswith(suffix):
223
+ name = name[: -len(suffix)]
224
+ return Path(name).stem
225
+
226
+
227
+ def _load_entrypoint(entrypoint: str) -> BaseFragment[Any] | FragmentDefinition:
228
+ """Load a fragment definition from a Python callable."""
229
+ module_name, _, attr = entrypoint.partition(":")
230
+ if not module_name or not attr:
231
+ raise TemplateError("Fragment entrypoint must be in the form 'module:attribute'.")
232
+
233
+ module = importlib.import_module(module_name)
234
+ target = getattr(module, attr)
235
+ candidate = target() if callable(target) else target
236
+
237
+ if isinstance(candidate, (BaseFragment, FragmentDefinition)):
238
+ return candidate
239
+
240
+ raise TemplateError(
241
+ f"Fragment entrypoint '{entrypoint}' must return a BaseFragment or FragmentDefinition."
242
+ )
243
+
244
+
245
+ def _fragment_base_dir(fragment: BaseFragment[Any]) -> Path:
246
+ if fragment.source is not None:
247
+ return (fragment.source if fragment.source.is_dir() else fragment.source.parent).resolve()
248
+ if fragment.pieces:
249
+ return fragment.pieces[0].template_path.parent
250
+ return Path().resolve()
251
+
252
+
253
+ def _normalise_fragment_attributes(
254
+ fragment: BaseFragment[Any],
255
+ ) -> tuple[dict[str, TemplateAttributeSpec], TemplateAttributeResolver | None]:
256
+ raw = getattr(fragment, "attributes", {}) or {}
257
+ if not raw:
258
+ return {}, None
259
+
260
+ normalised: dict[str, TemplateAttributeSpec] = {}
261
+ for name, spec in raw.items():
262
+ if isinstance(spec, TemplateAttributeSpec):
263
+ candidate = spec
264
+ elif isinstance(spec, Mapping):
265
+ candidate = TemplateAttributeSpec.model_validate(spec)
266
+ else:
267
+ candidate = TemplateAttributeSpec.model_validate({"default": spec})
268
+ candidate.name = name
269
+ if candidate.owner is None:
270
+ candidate.owner = fragment.name
271
+ normalised[name] = candidate
272
+
273
+ resolver = TemplateAttributeResolver(normalised)
274
+ return normalised, resolver
275
+
276
+
277
+ def _normalise_partials_from_fragment(
278
+ fragment: BaseFragment[Any],
279
+ ) -> tuple[dict[str, Path], set[str]]:
280
+ base_dir = _fragment_base_dir(fragment)
281
+ resolved: dict[str, Path] = {}
282
+ raw_partials = getattr(fragment, "partials", {}) or {}
283
+ entries: Iterable[tuple[str | None, Path | str]]
284
+
285
+ if isinstance(raw_partials, Mapping):
286
+ entries = [(key, value) for key, value in raw_partials.items()]
287
+ elif isinstance(raw_partials, Sequence) and not isinstance(raw_partials, (str, bytes)):
288
+ entries = [(None, entry) for entry in raw_partials]
289
+ else:
290
+ entries = []
291
+
292
+ for name_hint, payload in entries:
293
+ path_value = Path(payload)
294
+ resolved_path = (
295
+ path_value if path_value.is_absolute() else (base_dir / path_value).resolve()
296
+ )
297
+ if not resolved_path.exists():
298
+ raise TemplateError(
299
+ f"Partial '{payload}' declared by fragment '{fragment.name}' is missing: {resolved_path}"
300
+ )
301
+
302
+ candidate_name = (
303
+ str(name_hint) if name_hint is not None else path_value.with_suffix("").as_posix()
304
+ )
305
+ normalised = normalise_partial_key(candidate_name)
306
+ if not normalised:
307
+ raise TemplateError(
308
+ f"Fragment '{fragment.name}' declared a partial with an empty name."
309
+ )
310
+ if normalised in resolved:
311
+ raise TemplateError(
312
+ f"Fragment '{fragment.name}' declares partial '{normalised}' more than once."
313
+ )
314
+ resolved[normalised] = resolved_path
315
+
316
+ required: set[str] = set()
317
+ for entry in getattr(fragment, "required_partials", ()) or set():
318
+ key = normalise_partial_key(str(entry))
319
+ if key:
320
+ required.add(key)
321
+ return resolved, required
322
+
323
+
324
+ class FragmentRegistry:
325
+ """Central registry resolving fragment templates."""
326
+
327
+ def __init__(self, *, root: Path, default_order: Sequence[str]) -> None:
328
+ self._root = root
329
+ self._default_order = list(default_order)
330
+ self._fragments: dict[str, BaseFragment[Any] | FragmentDefinition] = {}
331
+ self._attributes: dict[str, dict[str, TemplateAttributeSpec]] = {}
332
+ self._attribute_resolvers: dict[str, TemplateAttributeResolver] = {}
333
+ self._partials: dict[str, dict[str, Path]] = {}
334
+ self._required_partials: dict[str, set[str]] = {}
335
+ self._discover_builtins()
336
+
337
+ @property
338
+ def default_fragment_names(self) -> list[str]:
339
+ return [name for name in self._default_order if name in self._fragments]
340
+
341
+ def attributes_for(self, name: str) -> dict[str, TemplateAttributeSpec]:
342
+ return self._attributes.get(name, {})
343
+
344
+ def attribute_resolver_for(self, name: str) -> TemplateAttributeResolver | None:
345
+ return self._attribute_resolvers.get(name)
346
+
347
+ def partials_for(self, name: str) -> dict[str, Path]:
348
+ return self._partials.get(name, {})
349
+
350
+ def required_partials_for(self, name: str) -> set[str]:
351
+ return self._required_partials.get(name, set())
352
+
353
+ def register_fragment(
354
+ self,
355
+ fragment: BaseFragment[Any] | FragmentDefinition | Path | str,
356
+ *,
357
+ name: str | None = None,
358
+ ) -> None:
359
+ """Register a custom fragment definition (path or definition instance)."""
360
+ if isinstance(fragment, (BaseFragment, FragmentDefinition)):
361
+ self._register_fragment_object(fragment)
362
+ return
363
+
364
+ definition = FragmentDefinition.from_path(Path(fragment), name=name)
365
+ self._register_fragment_object(definition)
366
+
367
+ def resolve(
368
+ self, name: str, *, source_dir: Path | None = None
369
+ ) -> BaseFragment[Any] | FragmentDefinition:
370
+ if name in self._fragments:
371
+ return self._fragments[name]
372
+
373
+ candidate = Path(name)
374
+ if not candidate.is_absolute() and source_dir is not None:
375
+ candidate = (source_dir / candidate).resolve()
376
+ if candidate.is_dir():
377
+ manifest_path = candidate / "fragment.toml"
378
+ definition = FragmentDefinition.from_manifest(manifest_path)
379
+ elif candidate.is_file() and candidate.name == "fragment.toml":
380
+ definition = FragmentDefinition.from_manifest(candidate)
381
+ elif candidate.exists():
382
+ definition = FragmentDefinition.from_path(candidate, name=name)
383
+ else:
384
+ raise TemplateError(f"Fragment '{name}' could not be resolved.")
385
+
386
+ self._register_fragment_object(definition)
387
+ return self._fragments[definition.name]
388
+
389
+ def _discover_builtins(self) -> None:
390
+ if not self._root.exists():
391
+ return
392
+
393
+ for manifest in sorted(self._root.rglob("fragment.toml")):
394
+ definition = FragmentDefinition.from_manifest(manifest)
395
+ self._register_fragment_object(definition)
396
+
397
+ def _register_fragment_object(self, fragment: BaseFragment[Any] | FragmentDefinition) -> None:
398
+ if isinstance(fragment, BaseFragment):
399
+ self._fragments[fragment.name] = fragment
400
+ normalised_attributes, resolver = _normalise_fragment_attributes(fragment)
401
+ if normalised_attributes:
402
+ self._attributes[fragment.name] = normalised_attributes
403
+ if resolver:
404
+ self._attribute_resolvers[fragment.name] = resolver
405
+ partials, required_partials = _normalise_partials_from_fragment(fragment)
406
+ if partials:
407
+ self._partials[fragment.name] = partials
408
+ if required_partials:
409
+ self._required_partials[fragment.name] = required_partials
410
+ return
411
+
412
+ self._fragments.setdefault(fragment.name, fragment)
413
+
414
+
415
+ @dataclass(slots=True)
416
+ class FragmentRenderResult:
417
+ """Rendered artefacts for selected fragments."""
418
+
419
+ packages: list[str]
420
+ variable_injections: dict[str, list[str]]
421
+ providers: dict[str, list[str]]
422
+ written: list[Path]
423
+
424
+
425
+ BUILTIN_FRAGMENT_ORDER = [
426
+ "ts-geometry",
427
+ "ts-typesetting",
428
+ "ts-frame",
429
+ "ts-fonts",
430
+ "ts-extra",
431
+ "ts-keystrokes",
432
+ "ts-callouts",
433
+ "ts-code",
434
+ "ts-glossary",
435
+ "ts-index",
436
+ "ts-bibliography",
437
+ "ts-todolist",
438
+ ]
439
+
440
+ FRAGMENT_ROOT = Path(__file__).resolve().parent.parent.parent / "fragments"
441
+ FRAGMENT_REGISTRY = FragmentRegistry(root=FRAGMENT_ROOT, default_order=BUILTIN_FRAGMENT_ORDER)
442
+
443
+
444
+ def register_fragment(
445
+ fragment: BaseFragment[Any] | FragmentDefinition | Path | str,
446
+ path: Path | None = None,
447
+ *,
448
+ name: str | None = None,
449
+ ) -> None:
450
+ """Register a custom fragment path (mainly for extensions/tests)."""
451
+ if path is not None:
452
+ derived_name = name or (fragment if isinstance(fragment, str) else None)
453
+ definition = FragmentDefinition.from_path(Path(path), name=derived_name)
454
+ FRAGMENT_REGISTRY.register_fragment(definition)
455
+ return
456
+ FRAGMENT_REGISTRY.register_fragment(fragment, name=name)
457
+
458
+
459
+ def _resolve_fragments(
460
+ names: Iterable[str], *, source_dir: Path | None = None
461
+ ) -> list[BaseFragment[Any] | FragmentDefinition]:
462
+ return [FRAGMENT_REGISTRY.resolve(name, source_dir=source_dir) for name in names]
463
+
464
+
465
+ def collect_fragment_attribute_defaults(
466
+ names: Iterable[str], *, source_dir: Path | None = None
467
+ ) -> dict[str, Any]:
468
+ """Return default attributes declared by the provided fragments."""
469
+ defaults: dict[str, Any] = {}
470
+ for fragment in _resolve_fragments(names, source_dir=source_dir):
471
+ if isinstance(fragment, FragmentDefinition):
472
+ for key, value in fragment.attribute_defaults().items():
473
+ defaults.setdefault(key, value)
474
+ continue
475
+
476
+ attributes = FRAGMENT_REGISTRY.attributes_for(fragment.name)
477
+ resolver = FRAGMENT_REGISTRY.attribute_resolver_for(fragment.name)
478
+ if attributes and resolver:
479
+ for key, value in resolver.defaults().items():
480
+ defaults.setdefault(key, value)
481
+ return defaults
482
+
483
+
484
+ def inject_fragment_attributes(
485
+ names: Iterable[str],
486
+ *,
487
+ context: dict[str, Any],
488
+ overrides: Mapping[str, Any] | None = None,
489
+ source_dir: Path | None = None,
490
+ declared_attribute_owners: Mapping[str, str] | None = None,
491
+ ) -> dict[str, Any]:
492
+ """Populate ``context`` with fragment-owned attributes."""
493
+ if not isinstance(context, dict):
494
+ raise TemplateError("Fragment attribute injection requires a mutable context dictionary.")
495
+
496
+ owners: dict[str, str] = dict(declared_attribute_owners or {})
497
+ injected: dict[str, Any] = {}
498
+ for fragment in _resolve_fragments(names, source_dir=source_dir):
499
+ if isinstance(fragment, FragmentDefinition):
500
+ resolved = fragment.resolve_attributes(overrides)
501
+ for attr_name in fragment.attributes:
502
+ owner = fragment.attributes[attr_name].owner or fragment.name
503
+ existing = owners.get(attr_name)
504
+ if existing and existing != owner:
505
+ raise TemplateError(
506
+ f"Attribute '{attr_name}' already owned by '{existing}', conflict with '{owner}'."
507
+ )
508
+ owners[attr_name] = owner
509
+ for key, value in resolved.items():
510
+ context[key] = value
511
+ injected[key] = value
512
+ continue
513
+
514
+ attributes = FRAGMENT_REGISTRY.attributes_for(fragment.name)
515
+ resolver = FRAGMENT_REGISTRY.attribute_resolver_for(fragment.name)
516
+ resolved = resolver.merge(overrides) if resolver else {}
517
+ for attr_name, spec in attributes.items():
518
+ owner = spec.owner or fragment.name
519
+ existing = owners.get(attr_name)
520
+ if existing and existing != owner:
521
+ raise TemplateError(
522
+ f"Attribute '{attr_name}' already owned by '{existing}', conflict with '{owner}'."
523
+ )
524
+ owners[attr_name] = owner
525
+ for key, value in resolved.items():
526
+ context[key] = value
527
+ injected[key] = value
528
+ return injected
529
+
530
+
531
+ def collect_fragment_partials(
532
+ names: Iterable[str],
533
+ *,
534
+ source_dir: Path | None = None,
535
+ ) -> tuple[dict[str, Path], dict[str, set[str]], dict[str, str]]:
536
+ """Return partial overrides and requirements declared by the selected fragments."""
537
+ overrides: dict[str, Path] = {}
538
+ required_by: dict[str, set[str]] = {}
539
+ providers: dict[str, str] = {}
540
+
541
+ for fragment in _resolve_fragments(names, source_dir=source_dir):
542
+ if isinstance(fragment, FragmentDefinition):
543
+ for partial_name, partial_path in fragment.iter_partials():
544
+ if partial_name in overrides:
545
+ existing = providers.get(partial_name, "unknown fragment")
546
+ raise TemplateError(
547
+ f"Partial '{partial_name}' provided by fragment '{fragment.name}' "
548
+ f"conflicts with '{existing}'."
549
+ )
550
+ overrides[partial_name] = partial_path
551
+ providers[partial_name] = fragment.name
552
+
553
+ for required in fragment.required_partials:
554
+ required_by.setdefault(required, set()).add(fragment.name)
555
+ continue
556
+
557
+ partial_map = FRAGMENT_REGISTRY.partials_for(fragment.name)
558
+ for partial_name, partial_path in partial_map.items():
559
+ if partial_name in overrides:
560
+ existing = providers.get(partial_name, "unknown fragment")
561
+ raise TemplateError(
562
+ f"Partial '{partial_name}' provided by fragment '{fragment.name}' "
563
+ f"conflicts with '{existing}'."
564
+ )
565
+ overrides[partial_name] = partial_path
566
+ providers[partial_name] = fragment.name
567
+
568
+ for required in FRAGMENT_REGISTRY.required_partials_for(fragment.name):
569
+ required_by.setdefault(required, set()).add(fragment.name)
570
+
571
+ return overrides, required_by, providers
572
+
573
+
574
+ def render_fragments(
575
+ names: Iterable[str],
576
+ *,
577
+ context: Mapping[str, Any],
578
+ output_dir: Path,
579
+ source_dir: Path | None = None,
580
+ overrides: Mapping[str, Any] | None = None,
581
+ declared_slots: set[str] | None = None,
582
+ declared_variables: set[str] | None = None,
583
+ template_name: str | None = None,
584
+ declared_attribute_owners: Mapping[str, str] | None = None,
585
+ ) -> FragmentRenderResult:
586
+ """
587
+ Render the selected fragments into ``output_dir`` and return the injected variables.
588
+ """
589
+ rendered_packages: list[str] = []
590
+ written: list[Path] = []
591
+ variable_injections: dict[str, list[str]] = {}
592
+ providers: dict[str, list[str]] = {}
593
+ owners: dict[str, str] = dict(declared_attribute_owners or {})
594
+
595
+ if not isinstance(context, dict):
596
+ raise TemplateError("Fragment rendering requires a mutable context dictionary.")
597
+
598
+ fragments = _resolve_fragments(names, source_dir=source_dir)
599
+
600
+ for fragment in fragments:
601
+ if declared_slots is not None:
602
+ for piece in fragment.pieces:
603
+ target_slot = piece.slot
604
+ if target_slot in declared_slots:
605
+ raise TemplateError(
606
+ f"Fragments cannot target slot '{target_slot}' in template "
607
+ f"'{template_name or 'unknown'}'."
608
+ )
609
+ if declared_variables is not None and target_slot not in declared_variables:
610
+ raise TemplateError(
611
+ f"Template '{template_name or 'unknown'}' doesn't declare variable "
612
+ f"'{target_slot}' required by fragment '{fragment.name}'."
613
+ )
614
+
615
+ if isinstance(fragment, FragmentDefinition):
616
+ if fragment.attributes:
617
+ resolved_attributes = fragment.resolve_attributes(overrides)
618
+ for attr_name in fragment.attributes:
619
+ owner = fragment.attributes[attr_name].owner or fragment.name
620
+ existing = owners.get(attr_name)
621
+ if existing and existing != owner:
622
+ raise TemplateError(
623
+ f"Attribute '{attr_name}' already owned by '{existing}', conflict with '{owner}'."
624
+ )
625
+ owners[attr_name] = owner
626
+ context.update(resolved_attributes)
627
+
628
+ for key, value in fragment.context_defaults.items():
629
+ context.setdefault(key, value)
630
+
631
+ if fragment.context_injector is not None:
632
+ fragment.context_injector(context, overrides)
633
+
634
+ if fragment.should_render is not None:
635
+ try:
636
+ if not fragment.should_render(context):
637
+ continue
638
+ except Exception:
639
+ pass
640
+ else:
641
+ attributes = FRAGMENT_REGISTRY.attributes_for(fragment.name)
642
+ resolver = FRAGMENT_REGISTRY.attribute_resolver_for(fragment.name)
643
+ resolved_attributes = resolver.merge(overrides) if resolver else {}
644
+ for attr_name, spec in attributes.items():
645
+ owner = spec.owner or fragment.name
646
+ existing = owners.get(attr_name)
647
+ if existing and existing != owner:
648
+ raise TemplateError(
649
+ f"Attribute '{attr_name}' already owned by '{existing}', conflict with '{owner}'."
650
+ )
651
+ owners[attr_name] = owner
652
+ context.update(resolved_attributes)
653
+
654
+ for key, value in getattr(fragment, "context_defaults", {}).items():
655
+ context.setdefault(key, value)
656
+
657
+ config = fragment.build_config(context, overrides=overrides)
658
+ fragment.inject(config, context, overrides=overrides)
659
+ try:
660
+ if not fragment.should_render(config):
661
+ continue
662
+ except Exception:
663
+ pass
664
+
665
+ for piece in fragment.pieces:
666
+ env = _build_environment(piece.template_path.parent)
667
+ template = env.get_template(piece.template_path.name)
668
+ payload = template.render(**context)
669
+
670
+ target_slot = piece.slot
671
+ if piece.kind == "inline":
672
+ injection = payload.strip()
673
+ if injection:
674
+ variable_injections.setdefault(target_slot, []).append(injection)
675
+ providers.setdefault(target_slot, []).append(fragment.name)
676
+ continue
677
+
678
+ output_name = piece.output_filename(fragment.name)
679
+ if output_name is None:
680
+ continue
681
+
682
+ output_path = output_dir / output_name
683
+ output_path.parent.mkdir(parents=True, exist_ok=True)
684
+ output_path.write_text(payload, encoding="utf-8")
685
+ written.append(output_path)
686
+
687
+ if piece.kind == "package":
688
+ rendered_packages.append(Path(output_name).stem)
689
+ injection = f"\\usepackage{{{Path(output_name).stem}}}"
690
+ else:
691
+ injection = f"\\input{{{Path(output_name).name}}}"
692
+
693
+ variable_injections.setdefault(target_slot, []).append(injection)
694
+ providers.setdefault(target_slot, []).append(fragment.name)
695
+
696
+ return FragmentRenderResult(
697
+ packages=rendered_packages,
698
+ variable_injections=variable_injections,
699
+ providers=providers,
700
+ written=written,
701
+ )
702
+
703
+
704
+ __all__ = [
705
+ "FRAGMENT_REGISTRY",
706
+ "BaseFragment",
707
+ "FragmentDefinition",
708
+ "FragmentPiece",
709
+ "FragmentRegistry",
710
+ "FragmentRenderResult",
711
+ "collect_fragment_attribute_defaults",
712
+ "collect_fragment_partials",
713
+ "inject_fragment_attributes",
714
+ "register_fragment",
715
+ "render_fragments",
716
+ ]