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,117 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import Mapping, Sequence
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any, ClassVar, Generic, Literal, TypeVar
8
+
9
+ from texsmith.core.templates.manifest import TemplateAttributeSpec, TemplateError
10
+
11
+
12
+ FragmentKind = Literal["package", "input", "inline"]
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class FragmentPiece:
17
+ """One renderable fragment component."""
18
+
19
+ template_path: Path
20
+ kind: FragmentKind = "package"
21
+ slot: str = "extra_packages"
22
+ output_name: str | None = None
23
+
24
+ @classmethod
25
+ def from_mapping(cls, payload: Mapping[str, Any], *, base_dir: Path) -> FragmentPiece:
26
+ """Build a piece from a TOML entry or Python mapping."""
27
+ if not isinstance(payload, Mapping):
28
+ raise TemplateError("Fragment file entries must be mappings.")
29
+
30
+ path_value = payload.get("path") or payload.get("template")
31
+ if not path_value or not isinstance(path_value, str):
32
+ raise TemplateError("Fragment file entries require a 'path' or 'template' string.")
33
+
34
+ candidate_path = Path(path_value)
35
+ resolved_path = (
36
+ candidate_path
37
+ if candidate_path.is_absolute()
38
+ else (base_dir / candidate_path).resolve()
39
+ )
40
+
41
+ if not resolved_path.exists():
42
+ raise TemplateError(f"Fragment file is missing: {resolved_path}")
43
+
44
+ kind_raw = payload.get("type", "package")
45
+ if kind_raw not in ("package", "input", "inline"):
46
+ raise TemplateError(
47
+ f"Unknown fragment file type '{kind_raw}'. Expected one of: package, input, inline."
48
+ )
49
+
50
+ slot = str(payload.get("slot", "extra_packages"))
51
+ output_name = payload.get("output") if isinstance(payload.get("output"), str) else None
52
+
53
+ return cls(
54
+ template_path=resolved_path,
55
+ kind=kind_raw, # type: ignore[arg-type]
56
+ slot=slot,
57
+ output_name=output_name,
58
+ )
59
+
60
+ def _ensure_suffix(self, name: str) -> str:
61
+ suffix = ".sty" if self.kind == "package" else ".tex"
62
+ return name if name.endswith(suffix) else f"{name}{suffix}"
63
+
64
+ def output_filename(self, fragment_name: str) -> str | None:
65
+ """Return the rendered filename, if applicable."""
66
+ if self.kind == "inline":
67
+ return None
68
+ base = self.output_name or fragment_name
69
+ normalised = Path(base).name
70
+ return self._ensure_suffix(normalised)
71
+
72
+
73
+ C = TypeVar("C")
74
+
75
+
76
+ class BaseFragment(ABC, Generic[C]):
77
+ """Base contract for object-oriented fragments."""
78
+
79
+ name: ClassVar[str]
80
+ description: ClassVar[str]
81
+ pieces: ClassVar[list[FragmentPiece]]
82
+ attributes: ClassVar[dict[str, TemplateAttributeSpec]]
83
+ config_cls: ClassVar[type[C]]
84
+ context_defaults: ClassVar[dict[str, Any]] = {}
85
+ partials: ClassVar[Mapping[str, Path | str] | Sequence[Path | str]] = ()
86
+ required_partials: ClassVar[Sequence[str]] = ()
87
+ source: ClassVar[Path | None] = None
88
+
89
+ @abstractmethod
90
+ def build_config(
91
+ self, context: Mapping[str, Any], overrides: Mapping[str, Any] | None = None
92
+ ) -> C:
93
+ """Return a normalized config instance from the provided context."""
94
+ raise NotImplementedError
95
+
96
+ @abstractmethod
97
+ def inject(
98
+ self, config: C, context: dict[str, Any], overrides: Mapping[str, Any] | None = None
99
+ ) -> None:
100
+ """Inject Jinja/templating context variables derived from the config."""
101
+ raise NotImplementedError
102
+
103
+ @abstractmethod
104
+ def should_render(self, config: C) -> bool:
105
+ """Return whether this fragment should render given the config."""
106
+ raise NotImplementedError
107
+
108
+ def render_context(
109
+ self, context: dict[str, Any], overrides: Mapping[str, Any] | None = None
110
+ ) -> C:
111
+ """Build config and inject into a mutable context; return the config."""
112
+ config = self.build_config(context, overrides=overrides)
113
+ self.inject(config, context, overrides=overrides)
114
+ return config
115
+
116
+
117
+ __all__ = ["BaseFragment", "FragmentKind", "FragmentPiece"]
@@ -0,0 +1,280 @@
1
+ """Helpers for normalising common press metadata across inputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, MutableMapping, Sequence
6
+ import copy
7
+ from datetime import date, datetime
8
+ from typing import Any
9
+ import warnings
10
+
11
+ from pydantic import BaseModel, ConfigDict, ValidationError, field_validator
12
+
13
+
14
+ __all__ = ["PressMetadataError", "normalise_press_metadata"]
15
+
16
+
17
+ class PressMetadataError(ValueError):
18
+ """Raised when press metadata fields contain invalid values."""
19
+
20
+
21
+ class _AuthorEntry(BaseModel):
22
+ """Validated representation of a press author entry."""
23
+
24
+ model_config = ConfigDict(extra="ignore")
25
+
26
+ name: str
27
+ affiliation: str | None = None
28
+
29
+ @field_validator("name", mode="before")
30
+ @classmethod
31
+ def _coerce_name(cls, value: Any) -> str:
32
+ if value is None:
33
+ raise ValueError("Author name cannot be empty.")
34
+ candidate = value if isinstance(value, str) else str(value)
35
+ stripped = candidate.strip()
36
+ if not stripped:
37
+ raise ValueError("Author name cannot be empty.")
38
+ return stripped
39
+
40
+ @field_validator("affiliation", mode="before")
41
+ @classmethod
42
+ def _coerce_affiliation(cls, value: Any) -> str | None:
43
+ if value is None:
44
+ return None
45
+ candidate = value if isinstance(value, str) else str(value)
46
+ stripped = candidate.strip()
47
+ return stripped or None
48
+
49
+
50
+ _SPECIAL_FIELDS = ("title", "subtitle", "date")
51
+ _ROOT_SKIP_KEYS = {"press", "slots", "entrypoints"}
52
+ _NESTED_ALIAS_MAP: dict[tuple[str, str], str] = {
53
+ ("glossary", "style"): "glossary_style",
54
+ ("cover", "color"): "covercolor",
55
+ ("cover", "logo"): "logo",
56
+ ("imprint", "thanks"): "imprint_thanks",
57
+ ("imprint", "license"): "imprint_license",
58
+ ("imprint", "copyright"): "imprint_copyright",
59
+ ("override", "preamble"): "preamble",
60
+ ("snippet", "width"): "width",
61
+ ("snippet", "margin"): "margin",
62
+ ("snippet", "dogear"): "dogear",
63
+ ("snippet", "border"): "border",
64
+ ("from", "name"): "from_name",
65
+ ("from", "address"): "from_address",
66
+ ("from", "city"): "from_location",
67
+ ("from", "location"): "from_location",
68
+ ("to", "name"): "to_name",
69
+ ("to", "address"): "to_address",
70
+ ("signature", "align"): "signature_align",
71
+ }
72
+ _DIRECT_ALIAS_MAP = {
73
+ "ps": "postscript",
74
+ "postscript": "postscript",
75
+ "myref": "reference",
76
+ "my_ref": "reference",
77
+ }
78
+
79
+
80
+ def normalise_press_metadata(metadata: MutableMapping[str, Any]) -> MutableMapping[str, Any]:
81
+ """Flatten press metadata into the root mapping while preserving compatibility."""
82
+ press_section = metadata.pop("press", None)
83
+ press_payload: dict[str, Any] = (
84
+ dict(press_section) if isinstance(press_section, Mapping) else {}
85
+ )
86
+
87
+ root_payload = dict(metadata)
88
+ _hoist_dotted_press_keys(root_payload, press_payload)
89
+
90
+ merged = _merge_press_overrides(press_payload, root_payload)
91
+ _coerce_common_strings(merged, press_payload)
92
+ _normalise_press_authors(merged)
93
+ _flatten_press_aliases(merged)
94
+
95
+ metadata.clear()
96
+ metadata.update(merged)
97
+
98
+ press_view = _build_press_view(merged)
99
+ if press_view:
100
+ metadata["press"] = press_view
101
+
102
+ return press_view
103
+
104
+
105
+ def _merge_press_overrides(
106
+ press_payload: Mapping[str, Any], root_payload: Mapping[str, Any]
107
+ ) -> dict[str, Any]:
108
+ merged: dict[str, Any] = copy.deepcopy(press_payload)
109
+
110
+ def _merge(target: MutableMapping[str, Any], source: Mapping[str, Any]) -> None:
111
+ for key, value in source.items():
112
+ existing = target.get(key)
113
+ if isinstance(existing, MutableMapping) and isinstance(value, Mapping):
114
+ nested: dict[str, Any] = dict(existing)
115
+ _merge(nested, value)
116
+ target[key] = nested
117
+ continue
118
+ target[key] = copy.deepcopy(value)
119
+
120
+ _merge(merged, root_payload)
121
+ return merged
122
+
123
+
124
+ def _coerce_common_strings(
125
+ merged: MutableMapping[str, Any], press_payload: Mapping[str, Any]
126
+ ) -> None:
127
+ for field in _SPECIAL_FIELDS:
128
+ existing = _coerce_metadata_string(merged.get(field))
129
+ source = _coerce_metadata_string(press_payload.get(field))
130
+ if existing is None and source is not None:
131
+ merged[field] = source
132
+ elif existing is not None:
133
+ if source is not None and source != existing:
134
+ warnings.warn(
135
+ f"Overriding press.{field} with root front matter value '{existing}'.",
136
+ stacklevel=2,
137
+ )
138
+ merged[field] = existing
139
+
140
+
141
+ def _coerce_metadata_string(value: Any) -> str | None:
142
+ if value is None:
143
+ return None
144
+ if isinstance(value, str):
145
+ stripped = value.strip()
146
+ return stripped or None
147
+ if isinstance(value, (datetime, date)):
148
+ return value.isoformat()
149
+ candidate = str(value).strip()
150
+ return candidate or None
151
+
152
+
153
+ def _normalise_press_authors(metadata: MutableMapping[str, Any]) -> None:
154
+ sources: list[Any] = []
155
+
156
+ authors_present = "authors" in metadata
157
+ author_present = "author" in metadata
158
+
159
+ if authors_present:
160
+ sources.append(metadata.get("authors"))
161
+ if author_present:
162
+ sources.append(metadata.get("author"))
163
+
164
+ if not sources:
165
+ if authors_present:
166
+ metadata["authors"] = []
167
+ if author_present:
168
+ metadata.pop("author", None)
169
+ return
170
+
171
+ entries: list[dict[str, str | None]] = []
172
+ for source in sources:
173
+ entries.extend(_flatten_author_entries(source))
174
+
175
+ if not entries:
176
+ metadata.setdefault("authors", [])
177
+ metadata.pop("author", None)
178
+ return
179
+
180
+ # Remove stale scalar author values to keep press metadata canonical.
181
+ metadata.pop("author", None)
182
+
183
+ normalized: list[dict[str, str | None]] = []
184
+ seen: set[tuple[str | None, str | None]] = set()
185
+ for entry in entries:
186
+ try:
187
+ candidate = _AuthorEntry.model_validate(entry).model_dump()
188
+ except ValidationError as exc:
189
+ raise PressMetadataError(
190
+ "Invalid author metadata. Provide names and optional affiliations."
191
+ ) from exc
192
+ key = (candidate.get("name"), candidate.get("affiliation"))
193
+ if key in seen:
194
+ continue
195
+ seen.add(key)
196
+ normalized.append(candidate)
197
+
198
+ metadata["authors"] = normalized
199
+
200
+
201
+ def _flatten_press_aliases(press_payload: MutableMapping[str, Any]) -> None:
202
+ for (section, key), target in _NESTED_ALIAS_MAP.items():
203
+ nested = press_payload.get(section)
204
+ if not isinstance(nested, Mapping):
205
+ continue
206
+ if key not in nested:
207
+ continue
208
+ value = nested[key]
209
+ press_payload[target] = value
210
+
211
+ for source, target in _DIRECT_ALIAS_MAP.items():
212
+ if source not in press_payload:
213
+ continue
214
+ press_payload[target] = press_payload[source]
215
+
216
+
217
+ def _hoist_dotted_press_keys(
218
+ root_payload: MutableMapping[str, Any], press_payload: MutableMapping[str, Any]
219
+ ) -> None:
220
+ dotted_keys = [
221
+ key
222
+ for key in list(root_payload.keys())
223
+ if isinstance(key, str) and key.startswith("press.")
224
+ ]
225
+ for dotted in dotted_keys:
226
+ value = root_payload.pop(dotted)
227
+ segments = [segment for segment in dotted.split(".")[1:] if segment]
228
+ if not segments:
229
+ continue
230
+ target = press_payload
231
+ for segment in segments[:-1]:
232
+ existing = target.get(segment)
233
+ nested = dict(existing) if isinstance(existing, MutableMapping) else {}
234
+ target[segment] = nested
235
+ target = nested
236
+ target[segments[-1]] = value
237
+
238
+ slash_keys = [
239
+ key
240
+ for key in list(root_payload.keys())
241
+ if isinstance(key, str) and key.startswith("press/")
242
+ ]
243
+ for entry in slash_keys:
244
+ value = root_payload.pop(entry)
245
+ suffix = entry.split("/", 1)[1]
246
+ if suffix:
247
+ press_payload[suffix] = value
248
+
249
+
250
+ def _build_press_view(merged: Mapping[str, Any]) -> dict[str, Any]:
251
+ return {
252
+ key: copy.deepcopy(value)
253
+ for key, value in merged.items()
254
+ if key not in _ROOT_SKIP_KEYS and not str(key).startswith("_")
255
+ }
256
+
257
+
258
+ def _flatten_author_entries(payload: Any) -> list[dict[str, Any]]:
259
+ if payload is None:
260
+ return []
261
+ if isinstance(payload, Mapping):
262
+ return _validate_mapping_entry(payload)
263
+ if isinstance(payload, str):
264
+ return [{"name": payload}]
265
+ if isinstance(payload, Sequence) and not isinstance(payload, (str, bytes)):
266
+ flattened: list[dict[str, Any]] = []
267
+ for item in payload:
268
+ flattened.extend(_flatten_author_entries(item))
269
+ return flattened
270
+ return [{"name": payload}]
271
+
272
+
273
+ def _validate_mapping_entry(payload: Mapping[str, Any]) -> list[dict[str, Any]]:
274
+ if "name" in payload or "affiliation" in payload:
275
+ return [dict(payload)]
276
+ if "author" in payload:
277
+ normalised = dict(payload)
278
+ normalised.setdefault("name", normalised.get("author"))
279
+ return [normalised]
280
+ raise PressMetadataError("Author objects must declare at least a 'name' field.")
@@ -0,0 +1,86 @@
1
+ """Utility helpers for resolving simple mustache-style placeholders."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ import re
7
+ from typing import Any
8
+
9
+ from .diagnostics import DiagnosticEmitter
10
+
11
+
12
+ _MUSTACHE_RE = re.compile(r"\{\{\s*([^\}\s][^\}]*)\s*\}\}")
13
+ _MISSING = object()
14
+
15
+
16
+ def _lookup(context: Mapping[str, Any] | None, path: str) -> Any:
17
+ current: Any = context
18
+ for part in path.split("."):
19
+ if not isinstance(current, Mapping) or part not in current:
20
+ return _MISSING
21
+ current = current.get(part)
22
+ return current
23
+
24
+
25
+ def _resolve_value(path: str, contexts: Sequence[Mapping[str, Any] | None]) -> Any:
26
+ for context in contexts:
27
+ value = _lookup(context, path)
28
+ if value is not _MISSING:
29
+ return value
30
+ return _MISSING
31
+
32
+
33
+ def replace_mustaches(
34
+ text: str,
35
+ contexts: Sequence[Mapping[str, Any] | None],
36
+ *,
37
+ emitter: DiagnosticEmitter | None = None,
38
+ source: str | None = None,
39
+ ) -> str:
40
+ """Replace ``{{path.to.value}}`` placeholders in ``text`` using ``contexts``."""
41
+
42
+ def _warn(message: str) -> None:
43
+ if emitter:
44
+ emitter.warning(message)
45
+
46
+ def _replacement(match: re.Match[str]) -> str:
47
+ raw_path = match.group(1).strip()
48
+ value = _resolve_value(raw_path, contexts)
49
+ if value is _MISSING or value is None or (isinstance(value, str) and not value.strip()):
50
+ location = f" in {source}" if source else ""
51
+ _warn(f"Unresolved mustache '{{{{{raw_path}}}}}'{location}; leaving placeholder as-is.")
52
+ return match.group(0)
53
+ return str(value)
54
+
55
+ return _MUSTACHE_RE.sub(_replacement, text)
56
+
57
+
58
+ def replace_mustaches_in_structure(
59
+ payload: Any,
60
+ contexts: Sequence[Mapping[str, Any] | None],
61
+ *,
62
+ emitter: DiagnosticEmitter | None = None,
63
+ source: str | None = None,
64
+ ) -> Any:
65
+ """Recursively resolve mustache placeholders inside mappings/sequences/strings."""
66
+ if isinstance(payload, str):
67
+ return replace_mustaches(payload, contexts, emitter=emitter, source=source)
68
+ if isinstance(payload, Mapping):
69
+ return {
70
+ key: replace_mustaches_in_structure(value, contexts, emitter=emitter, source=source)
71
+ for key, value in payload.items()
72
+ }
73
+ if isinstance(payload, list):
74
+ return [
75
+ replace_mustaches_in_structure(item, contexts, emitter=emitter, source=source)
76
+ for item in payload
77
+ ]
78
+ if isinstance(payload, tuple):
79
+ return tuple(
80
+ replace_mustaches_in_structure(item, contexts, emitter=emitter, source=source)
81
+ for item in payload
82
+ )
83
+ return payload
84
+
85
+
86
+ __all__ = ["replace_mustaches", "replace_mustaches_in_structure"]
@@ -0,0 +1,20 @@
1
+ """Shared helpers for managing LaTeX partial identifiers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from texsmith.adapters.latex.formatter import LaTeXFormatter
8
+
9
+
10
+ def normalise_partial_key(value: str) -> str:
11
+ """Return a formatter-friendly identifier for a partial entry."""
12
+ candidate = str(value or "").strip()
13
+ if not candidate:
14
+ return ""
15
+ path = Path(candidate)
16
+ base = path.with_suffix("").as_posix()
17
+ return LaTeXFormatter.normalise_key(base)
18
+
19
+
20
+ __all__ = ["normalise_partial_key"]