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,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
|
+
]
|