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,87 @@
1
+ """Debug and diagnostic helpers used throughout the conversion pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from ..diagnostics import DiagnosticEmitter, LoggingEmitter, NullEmitter
10
+ from ..exceptions import LatexRenderingError, exception_hint
11
+
12
+
13
+ class ConversionError(Exception):
14
+ """Raised when a conversion fails and cannot recover."""
15
+
16
+
17
+ def ensure_emitter(emitter: DiagnosticEmitter | None) -> DiagnosticEmitter:
18
+ """Return a usable emitter, defaulting to the null implementation."""
19
+ return emitter if emitter is not None else NullEmitter()
20
+
21
+
22
+ def debug_enabled(emitter: DiagnosticEmitter | None) -> bool:
23
+ """Return whether debug mode is active for the given emitter."""
24
+ return bool(emitter and getattr(emitter, "debug_enabled", False))
25
+
26
+
27
+ def raise_conversion_error(
28
+ emitter: DiagnosticEmitter | None,
29
+ message: str,
30
+ exc: Exception,
31
+ ) -> None:
32
+ """Emit an error diagnostic before raising a conversion failure."""
33
+ ensure_emitter(emitter).error(message, exc)
34
+ error = ConversionError(message)
35
+ error._texsmith_logged = True # noqa: SLF001
36
+ raise error from exc
37
+
38
+
39
+ def record_event(
40
+ emitter: DiagnosticEmitter | None,
41
+ event: str,
42
+ payload: Mapping[str, Any],
43
+ ) -> None:
44
+ """Forward a structured diagnostic event."""
45
+ ensure_emitter(emitter).event(event, payload)
46
+
47
+
48
+ def persist_debug_artifacts(output_dir: Path, source: Path, html: str) -> None:
49
+ """Persist intermediate HTML snapshots to aid debugging."""
50
+ output_dir.mkdir(parents=True, exist_ok=True)
51
+ debug_path = output_dir / f"{source.stem}.debug.html"
52
+ debug_path.write_text(html, encoding="utf-8")
53
+
54
+
55
+ def format_rendering_error(error: LatexRenderingError) -> str:
56
+ """Format a human-readable rendering failure summary."""
57
+ cause = error.__cause__
58
+ if cause is None:
59
+ return str(error)
60
+ return f"LaTeX rendering failed: {cause}"
61
+
62
+
63
+ def format_user_friendly_render_error(error: LatexRenderingError) -> str:
64
+ """Return a concise rendering failure summary suitable for end users."""
65
+ summary = "LaTeX rendering failed"
66
+ hint_source = error.__cause__ or error
67
+ hint = exception_hint(hint_source)
68
+ if hint:
69
+ summary = f"{summary}: {hint}"
70
+ if summary.endswith("."):
71
+ summary = summary.rstrip(".")
72
+ return f"{summary}. Re-run with --debug for technical details."
73
+
74
+
75
+ __all__ = [
76
+ "ConversionError",
77
+ "DiagnosticEmitter",
78
+ "LoggingEmitter",
79
+ "NullEmitter",
80
+ "debug_enabled",
81
+ "ensure_emitter",
82
+ "format_rendering_error",
83
+ "format_user_friendly_render_error",
84
+ "persist_debug_artifacts",
85
+ "raise_conversion_error",
86
+ "record_event",
87
+ ]
@@ -0,0 +1,474 @@
1
+ """Input parsing utilities shared across the conversion pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping, Sequence
6
+ from dataclasses import dataclass, field
7
+ from datetime import date, datetime
8
+ from enum import Enum
9
+ from pathlib import Path
10
+ import re
11
+ from typing import Any
12
+
13
+ from bs4 import BeautifulSoup, FeatureNotFound
14
+
15
+ from ..conversion.debug import ConversionError
16
+ from ..conversion_contexts import DocumentContext
17
+ from ..metadata import PressMetadataError, normalise_press_metadata
18
+
19
+
20
+ DOCUMENT_SELECTOR_SENTINEL = "@document"
21
+
22
+
23
+ class UnsupportedInputError(Exception):
24
+ """Raised when a CLI input argument cannot be processed."""
25
+
26
+
27
+ class InputKind(Enum):
28
+ """Supported input modalities handled by the conversion pipeline."""
29
+
30
+ MARKDOWN = "markdown"
31
+ HTML = "html"
32
+
33
+
34
+ class InlineBibliographyValidationError(ValueError):
35
+ """Raised when inline bibliography entries contain invalid data."""
36
+
37
+
38
+ @dataclass(slots=True)
39
+ class InlineBibliographyEntry:
40
+ """Validated representation of a front-matter bibliography entry."""
41
+
42
+ key: str
43
+ doi: str | None = None
44
+ entry_type: str | None = None
45
+ fields: dict[str, str] = field(default_factory=dict)
46
+ persons: dict[str, list[str]] = field(default_factory=dict)
47
+
48
+ @property
49
+ def is_manual(self) -> bool:
50
+ """Return True when the entry embeds explicit bibliographic fields."""
51
+ return self.entry_type is not None
52
+
53
+
54
+ def build_document_context(
55
+ *,
56
+ name: str,
57
+ source_path: Path,
58
+ html: str,
59
+ front_matter: Mapping[str, Any] | None,
60
+ base_level: int,
61
+ drop_title: bool,
62
+ numbered: bool,
63
+ title_from_heading: bool = False,
64
+ extracted_title: str | None = None,
65
+ ) -> DocumentContext:
66
+ """Construct a document context enriched with metadata and slot requests."""
67
+ metadata = dict(front_matter or {})
68
+ try:
69
+ press_payload = normalise_press_metadata(metadata)
70
+ except PressMetadataError as exc:
71
+ raise ConversionError(str(exc)) from exc
72
+ metadata.setdefault("_source_dir", str(source_path.parent))
73
+ metadata.setdefault("_source_path", str(source_path))
74
+ if press_payload:
75
+ press_payload.setdefault("_source_dir", str(source_path.parent))
76
+ press_payload.setdefault("_source_path", str(source_path))
77
+ slot_requests = extract_front_matter_slots(metadata)
78
+
79
+ return DocumentContext(
80
+ name=name,
81
+ source_path=source_path,
82
+ html=html,
83
+ base_level=base_level,
84
+ numbered=numbered,
85
+ drop_title=drop_title,
86
+ title_from_heading=title_from_heading,
87
+ extracted_title=extracted_title,
88
+ front_matter=metadata,
89
+ slot_requests=slot_requests,
90
+ )
91
+
92
+
93
+ def coerce_slot_selector(payload: Any) -> str | None:
94
+ """Normalise a selector definition coming from front matter."""
95
+ if isinstance(payload, str):
96
+ candidate = payload.strip()
97
+ return candidate or None
98
+ if isinstance(payload, Mapping):
99
+ for key in ("label", "title", "section"):
100
+ value = payload.get(key)
101
+ if isinstance(value, str) and value.strip():
102
+ return value.strip()
103
+ return None
104
+
105
+
106
+ def parse_slot_mapping(raw: Any) -> dict[str, str]:
107
+ """Parse slot mappings declared in front matter structures."""
108
+ overrides: dict[str, str] = {}
109
+ if not raw:
110
+ return overrides
111
+
112
+ if isinstance(raw, Mapping):
113
+ for slot_name, payload in raw.items():
114
+ if not isinstance(slot_name, str):
115
+ continue
116
+ selector = coerce_slot_selector(payload)
117
+ if selector:
118
+ key = slot_name.strip()
119
+ if key:
120
+ overrides[key] = selector
121
+ return overrides
122
+
123
+ if isinstance(raw, Iterable) and not isinstance(raw, str | bytes):
124
+ for entry in raw:
125
+ if not isinstance(entry, Mapping):
126
+ continue
127
+ slot_name = entry.get("target") or entry.get("slot")
128
+ if not isinstance(slot_name, str):
129
+ continue
130
+ selector = entry.get("label") or entry.get("title") or entry.get("section")
131
+ selector_value = coerce_slot_selector(selector)
132
+ if not selector_value:
133
+ selector_value = coerce_slot_selector(entry)
134
+ slot_key = slot_name.strip()
135
+ if slot_key and selector_value:
136
+ overrides[slot_key] = selector_value
137
+ return overrides
138
+
139
+ if isinstance(raw, str):
140
+ entry = raw.strip()
141
+ if entry and ":" in entry:
142
+ name, selector = entry.split(":", 1)
143
+ name = name.strip()
144
+ selector = selector.strip()
145
+ if name and selector:
146
+ overrides[name] = selector
147
+ return overrides
148
+
149
+ return overrides
150
+
151
+
152
+ def extract_front_matter_slots(front_matter: Mapping[str, Any]) -> dict[str, str]:
153
+ """Collect slot overrides defined in document front matter."""
154
+ overrides: dict[str, str] = {}
155
+
156
+ root_slots = front_matter.get("slots") or front_matter.get("entrypoints")
157
+ overrides.update(parse_slot_mapping(root_slots))
158
+
159
+ return overrides
160
+
161
+
162
+ _ISO_YEAR_RE = re.compile(r"^(?P<year>\d{4})$")
163
+ _ISO_YEAR_MONTH_RE = re.compile(r"^(?P<year>\d{4})-(?P<month>\d{2})$")
164
+ _ISO_DATE_RE = re.compile(r"^(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})$")
165
+
166
+ _COMMON_ALLOWED_FIELDS = {
167
+ "title",
168
+ "subtitle",
169
+ "date",
170
+ "year",
171
+ "month",
172
+ "day",
173
+ "note",
174
+ "url",
175
+ "doi",
176
+ }
177
+ _MISC_ALLOWED_FIELDS = _COMMON_ALLOWED_FIELDS | {"howpublished", "publisher", "address"}
178
+ _ARTICLE_ALLOWED_FIELDS = _COMMON_ALLOWED_FIELDS | {
179
+ "journal",
180
+ "volume",
181
+ "number",
182
+ "pages",
183
+ "publisher",
184
+ "address",
185
+ "issn",
186
+ }
187
+ _BOOK_ALLOWED_FIELDS = _COMMON_ALLOWED_FIELDS | {
188
+ "publisher",
189
+ "address",
190
+ "edition",
191
+ "series",
192
+ "volume",
193
+ "number",
194
+ "pages",
195
+ "isbn",
196
+ }
197
+ _INLINE_BIBLIOGRAPHY_SCHEMAS: dict[str, dict[str, set[str]]] = {
198
+ "misc": {
199
+ "required": {"title"},
200
+ "allowed": _MISC_ALLOWED_FIELDS,
201
+ },
202
+ "article": {
203
+ "required": {"title", "journal"},
204
+ "allowed": _ARTICLE_ALLOWED_FIELDS,
205
+ },
206
+ "book": {
207
+ "required": {"title", "publisher"},
208
+ "allowed": _BOOK_ALLOWED_FIELDS,
209
+ },
210
+ }
211
+ _PERSON_KEYS = {"author", "authors"}
212
+ _RESERVED_KEYS = {"type", *(_PERSON_KEYS)}
213
+
214
+
215
+ def extract_front_matter_bibliography(
216
+ front_matter: Mapping[str, Any] | None,
217
+ ) -> dict[str, InlineBibliographyEntry]:
218
+ """Return inline bibliography entries declared in the document front matter."""
219
+ if not isinstance(front_matter, Mapping):
220
+ return {}
221
+
222
+ bibliography: dict[str, InlineBibliographyEntry] = {}
223
+ container = front_matter.get("bibliography")
224
+ if isinstance(container, Mapping):
225
+ for key, value in container.items():
226
+ if not isinstance(key, str):
227
+ continue
228
+ entry = _parse_inline_bibliography_entry(key, value)
229
+ bibliography[key] = entry
230
+
231
+ return bibliography
232
+
233
+
234
+ def _parse_inline_bibliography_entry(key: str, value: Any) -> InlineBibliographyEntry:
235
+ if isinstance(value, str):
236
+ doi = _coerce_bibliography_doi(value)
237
+ if not doi:
238
+ raise InlineBibliographyValidationError(
239
+ f"Bibliography entry '{key}' must not be empty."
240
+ )
241
+ return InlineBibliographyEntry(key=key, doi=doi)
242
+
243
+ if isinstance(value, Mapping):
244
+ if "type" in value:
245
+ return _parse_manual_bibliography_mapping(key, value)
246
+ doi = _coerce_bibliography_doi(value)
247
+ if not doi:
248
+ raise InlineBibliographyValidationError(
249
+ f"Bibliography entry '{key}' must define a DOI or a 'type'."
250
+ )
251
+ return InlineBibliographyEntry(key=key, doi=doi)
252
+
253
+ raise InlineBibliographyValidationError(
254
+ f"Bibliography entry '{key}' must be a string DOI or a mapping of fields."
255
+ )
256
+
257
+
258
+ def _parse_manual_bibliography_mapping(
259
+ key: str,
260
+ payload: Mapping[str, Any],
261
+ ) -> InlineBibliographyEntry:
262
+ raw_type = payload.get("type")
263
+ if not isinstance(raw_type, str) or not raw_type.strip():
264
+ raise InlineBibliographyValidationError(
265
+ f"Bibliography entry '{key}' must define a textual 'type'."
266
+ )
267
+
268
+ entry_type = raw_type.strip().lower()
269
+ schema = _INLINE_BIBLIOGRAPHY_SCHEMAS.get(entry_type)
270
+ if schema is None:
271
+ allowed = ", ".join(sorted(_INLINE_BIBLIOGRAPHY_SCHEMAS))
272
+ raise InlineBibliographyValidationError(
273
+ f"Bibliography entry '{key}' declares unsupported type '{entry_type}'. "
274
+ f"Allowed types: {allowed}."
275
+ )
276
+
277
+ allowed_fields = schema["allowed"]
278
+ required_fields = schema["required"]
279
+
280
+ invalid_fields = sorted(
281
+ field_name
282
+ for field_name in payload
283
+ if field_name not in allowed_fields and field_name not in _RESERVED_KEYS
284
+ )
285
+ if invalid_fields:
286
+ raise InlineBibliographyValidationError(
287
+ f"Bibliography entry '{key}' ({entry_type}) contains unsupported field(s): "
288
+ + ", ".join(invalid_fields)
289
+ + "."
290
+ )
291
+
292
+ persons: dict[str, list[str]] = {}
293
+ author_values: list[str] = []
294
+ if "author" in payload:
295
+ author_values.extend(_coerce_person_list(key, "author", payload.get("author")))
296
+ if "authors" in payload:
297
+ author_values.extend(_coerce_person_list(key, "authors", payload.get("authors")))
298
+ if author_values:
299
+ persons["author"] = author_values
300
+
301
+ fields: dict[str, str] = {}
302
+ for field_name, raw_value in payload.items():
303
+ if field_name in _RESERVED_KEYS:
304
+ continue
305
+ if field_name == "date":
306
+ date_value = _coerce_bibliography_field_value(key, field_name, raw_value)
307
+ if date_value:
308
+ fields["date"] = date_value
309
+ derived = _derive_date_components(key, date_value)
310
+ for derived_name, derived_value in derived.items():
311
+ fields.setdefault(derived_name, derived_value)
312
+ continue
313
+
314
+ field_value = _coerce_bibliography_field_value(key, field_name, raw_value)
315
+ if field_value is not None:
316
+ fields[field_name] = field_value
317
+
318
+ for required in required_fields:
319
+ if required not in fields or not fields[required]:
320
+ raise InlineBibliographyValidationError(
321
+ f"Bibliography entry '{key}' ({entry_type}) is missing required field '{required}'."
322
+ )
323
+
324
+ return InlineBibliographyEntry(
325
+ key=key,
326
+ entry_type=entry_type,
327
+ fields=fields,
328
+ persons=persons,
329
+ )
330
+
331
+
332
+ def _coerce_bibliography_doi(value: Any) -> str | None:
333
+ if isinstance(value, str):
334
+ stripped = value.strip()
335
+ return stripped or None
336
+ if isinstance(value, Mapping):
337
+ candidate = value.get("doi")
338
+ if isinstance(candidate, str):
339
+ stripped = candidate.strip()
340
+ if stripped:
341
+ return stripped
342
+ return None
343
+
344
+
345
+ def _coerce_person_list(
346
+ key: str,
347
+ field: str,
348
+ value: Any,
349
+ ) -> list[str]:
350
+ if value is None:
351
+ return []
352
+ if isinstance(value, str):
353
+ candidate = value.strip()
354
+ if not candidate:
355
+ raise InlineBibliographyValidationError(
356
+ f"Bibliography entry '{key}' field '{field}' must not be empty."
357
+ )
358
+ return [candidate]
359
+ if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray, str)):
360
+ result: list[str] = []
361
+ for item in value:
362
+ if not isinstance(item, str):
363
+ raise InlineBibliographyValidationError(
364
+ f"Bibliography entry '{key}' field '{field}' must contain only strings."
365
+ )
366
+ candidate = item.strip()
367
+ if not candidate:
368
+ raise InlineBibliographyValidationError(
369
+ f"Bibliography entry '{key}' field '{field}' contains an empty value."
370
+ )
371
+ result.append(candidate)
372
+ if not result:
373
+ raise InlineBibliographyValidationError(
374
+ f"Bibliography entry '{key}' field '{field}' must define at least one value."
375
+ )
376
+ return result
377
+ raise InlineBibliographyValidationError(
378
+ f"Bibliography entry '{key}' field '{field}' must be a string or list of strings."
379
+ )
380
+
381
+
382
+ def _coerce_bibliography_field_value(key: str, field: str, value: Any) -> str | None:
383
+ if value is None:
384
+ return None
385
+ if isinstance(value, str):
386
+ candidate = value.strip()
387
+ return candidate or None
388
+ if isinstance(value, datetime):
389
+ return value.date().isoformat()
390
+ if isinstance(value, date):
391
+ return value.isoformat()
392
+ if isinstance(value, (int, float)):
393
+ if isinstance(value, float) and not value.is_integer():
394
+ raise InlineBibliographyValidationError(
395
+ f"Bibliography entry '{key}' field '{field}' must be an integer when numeric."
396
+ )
397
+ return str(int(value))
398
+ raise InlineBibliographyValidationError(
399
+ f"Bibliography entry '{key}' field '{field}' must be a string or integer."
400
+ )
401
+
402
+
403
+ def _derive_date_components(key: str, value: str) -> dict[str, str]:
404
+ candidate = value.strip()
405
+ if not candidate:
406
+ return {}
407
+
408
+ match = _ISO_DATE_RE.match(candidate)
409
+ if match:
410
+ year = match.group("year")
411
+ month = match.group("month")
412
+ day = match.group("day")
413
+ _validate_month(key, month)
414
+ _validate_day(key, day)
415
+ return {"year": year, "month": month, "day": day}
416
+
417
+ match = _ISO_YEAR_MONTH_RE.match(candidate)
418
+ if match:
419
+ year = match.group("year")
420
+ month = match.group("month")
421
+ _validate_month(key, month)
422
+ return {"year": year, "month": month}
423
+
424
+ match = _ISO_YEAR_RE.match(candidate)
425
+ if match:
426
+ return {"year": match.group("year")}
427
+
428
+ raise InlineBibliographyValidationError(
429
+ f"Bibliography entry '{key}' field 'date' must follow ISO formats YYYY, YYYY-MM, or YYYY-MM-DD."
430
+ )
431
+
432
+
433
+ def _validate_month(key: str, value: str) -> None:
434
+ month_int = int(value)
435
+ if not 1 <= month_int <= 12:
436
+ raise InlineBibliographyValidationError(
437
+ f"Bibliography entry '{key}' field 'date' contains an invalid month '{value}'."
438
+ )
439
+
440
+
441
+ def _validate_day(key: str, value: str) -> None:
442
+ day_int = int(value)
443
+ if not 1 <= day_int <= 31:
444
+ raise InlineBibliographyValidationError(
445
+ f"Bibliography entry '{key}' field 'date' contains an invalid day '{value}'."
446
+ )
447
+
448
+
449
+ def extract_content(html: str, selector: str) -> str:
450
+ """Extract and return the inner HTML for the first element matching selector."""
451
+ try:
452
+ soup = BeautifulSoup(html, "lxml")
453
+ except FeatureNotFound:
454
+ soup = BeautifulSoup(html, "html.parser")
455
+
456
+ element = soup.select_one(selector)
457
+ if element is None:
458
+ raise ValueError(f"Unable to locate content using selector '{selector}'.")
459
+ return element.decode_contents()
460
+
461
+
462
+ __all__ = [
463
+ "DOCUMENT_SELECTOR_SENTINEL",
464
+ "InlineBibliographyEntry",
465
+ "InlineBibliographyValidationError",
466
+ "InputKind",
467
+ "UnsupportedInputError",
468
+ "build_document_context",
469
+ "coerce_slot_selector",
470
+ "extract_content",
471
+ "extract_front_matter_bibliography",
472
+ "extract_front_matter_slots",
473
+ "parse_slot_mapping",
474
+ ]