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