mkdocs-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.
- mkdocs_plugin_texsmith/__init__.py +6 -0
- mkdocs_plugin_texsmith/plugin.py +1428 -0
- mkdocs_texsmith-0.0.2.dev0.dist-info/METADATA +157 -0
- mkdocs_texsmith-0.0.2.dev0.dist-info/RECORD +7 -0
- mkdocs_texsmith-0.0.2.dev0.dist-info/WHEEL +4 -0
- mkdocs_texsmith-0.0.2.dev0.dist-info/entry_points.txt +2 -0
- mkdocs_texsmith-0.0.2.dev0.dist-info/licenses/LICENSE.md +21 -0
|
@@ -0,0 +1,1428 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
import posixpath
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Any
|
|
11
|
+
import warnings
|
|
12
|
+
from warnings import WarningMessage
|
|
13
|
+
|
|
14
|
+
from mkdocs.config import config_options
|
|
15
|
+
from mkdocs.config.defaults import MkDocsConfig
|
|
16
|
+
from mkdocs.exceptions import PluginError
|
|
17
|
+
from mkdocs.plugins import BasePlugin
|
|
18
|
+
from mkdocs.structure import StructureItem
|
|
19
|
+
from mkdocs.structure.files import Files
|
|
20
|
+
from mkdocs.structure.nav import Navigation
|
|
21
|
+
from mkdocs.utils import log
|
|
22
|
+
from pybtex.exceptions import PybtexError
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
from slugify import slugify
|
|
25
|
+
from texsmith.adapters.latex import LaTeXFormatter, LaTeXRenderer
|
|
26
|
+
from texsmith.adapters.latex.engines import (
|
|
27
|
+
EngineFeatures,
|
|
28
|
+
LatexMessage,
|
|
29
|
+
LatexMessageSeverity,
|
|
30
|
+
build_engine_command,
|
|
31
|
+
build_tex_env,
|
|
32
|
+
compute_features,
|
|
33
|
+
ensure_command_paths,
|
|
34
|
+
missing_dependencies,
|
|
35
|
+
resolve_engine,
|
|
36
|
+
run_engine_command,
|
|
37
|
+
)
|
|
38
|
+
from texsmith.adapters.latex.latexmk import build_latexmkrc_content
|
|
39
|
+
from texsmith.adapters.latex.tectonic import (
|
|
40
|
+
BiberAcquisitionError,
|
|
41
|
+
MakeglossariesAcquisitionError,
|
|
42
|
+
TectonicAcquisitionError,
|
|
43
|
+
select_biber_binary,
|
|
44
|
+
select_makeglossaries,
|
|
45
|
+
select_tectonic_binary,
|
|
46
|
+
)
|
|
47
|
+
from texsmith.adapters.plugins import material, snippet
|
|
48
|
+
from texsmith.core.bibliography import (
|
|
49
|
+
BibliographyCollection,
|
|
50
|
+
DoiBibliographyFetcher,
|
|
51
|
+
DoiLookupError,
|
|
52
|
+
bibliography_data_from_inline_entry,
|
|
53
|
+
bibliography_data_from_string,
|
|
54
|
+
)
|
|
55
|
+
from texsmith.core.config import BookConfig, LaTeXConfig
|
|
56
|
+
from texsmith.core.context import DocumentState
|
|
57
|
+
from texsmith.core.conversion import (
|
|
58
|
+
ensure_fallback_converters,
|
|
59
|
+
extract_front_matter_bibliography,
|
|
60
|
+
render_with_fallback,
|
|
61
|
+
)
|
|
62
|
+
from texsmith.core.conversion.debug import format_rendering_error
|
|
63
|
+
from texsmith.core.conversion.inputs import (
|
|
64
|
+
InlineBibliographyEntry,
|
|
65
|
+
InlineBibliographyValidationError,
|
|
66
|
+
)
|
|
67
|
+
from texsmith.core.diagnostics import LoggingEmitter
|
|
68
|
+
from texsmith.core.exceptions import LatexRenderingError
|
|
69
|
+
from texsmith.core.templates import (
|
|
70
|
+
TemplateError,
|
|
71
|
+
TemplateSlot,
|
|
72
|
+
load_template_runtime,
|
|
73
|
+
normalise_template_language,
|
|
74
|
+
wrap_template_document,
|
|
75
|
+
)
|
|
76
|
+
import yaml
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
AUTO_BASE_LEVEL = -2
|
|
80
|
+
FULL_NAVIGATION_ROOT = "__texsmith_full_navigation__"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(slots=True)
|
|
84
|
+
class BookExtras:
|
|
85
|
+
"""Container for plugin-specific book options."""
|
|
86
|
+
|
|
87
|
+
template: str | None = None
|
|
88
|
+
template_overrides: dict[str, Any] = field(default_factory=dict)
|
|
89
|
+
bibliography: list[Path] = field(default_factory=list)
|
|
90
|
+
slots: dict[str, set[str]] = field(default_factory=dict)
|
|
91
|
+
press: dict[str, Any] = field(default_factory=dict)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(slots=True)
|
|
95
|
+
class NavEntry:
|
|
96
|
+
"""Flattened navigation entry with rendering metadata."""
|
|
97
|
+
|
|
98
|
+
title: str
|
|
99
|
+
level: int
|
|
100
|
+
numbered: bool
|
|
101
|
+
drop_title: bool
|
|
102
|
+
part: str
|
|
103
|
+
is_page: bool
|
|
104
|
+
slot: str | None = None
|
|
105
|
+
src_path: str | None = None
|
|
106
|
+
abs_src_path: Path | None = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(slots=True)
|
|
110
|
+
class BookRuntime:
|
|
111
|
+
"""Runtime representation of a configured book."""
|
|
112
|
+
|
|
113
|
+
config: BookConfig
|
|
114
|
+
extras: BookExtras
|
|
115
|
+
section: StructureItem | None = None
|
|
116
|
+
entries: list[NavEntry] = field(default_factory=list)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class _MkdocsEmitter(LoggingEmitter):
|
|
120
|
+
"""Emitter that surfaces diagnostics through MkDocs' logger."""
|
|
121
|
+
|
|
122
|
+
def event(self, name: str, payload: Mapping[str, Any]) -> None:
|
|
123
|
+
if name == "snippet_build":
|
|
124
|
+
digest = payload.get("digest") if isinstance(payload, Mapping) else None
|
|
125
|
+
source = payload.get("source") if isinstance(payload, Mapping) else None
|
|
126
|
+
source_hint = f" ({source})" if source else ""
|
|
127
|
+
self._logger.info(
|
|
128
|
+
"texsmith: building snippet %s%s", digest or "snippet", source_hint
|
|
129
|
+
)
|
|
130
|
+
return
|
|
131
|
+
super().event(name, payload)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class LatexPlugin(BasePlugin):
|
|
135
|
+
"""MkDocs plugin that exports documentation to LaTeX using TeXSmith."""
|
|
136
|
+
|
|
137
|
+
config_scheme = (
|
|
138
|
+
("enabled", config_options.Type(bool, default=True)),
|
|
139
|
+
("build_dir", config_options.Type(str, default="press")),
|
|
140
|
+
("template", config_options.Type(str, default="book")),
|
|
141
|
+
("parser", config_options.Type(str, default="lxml")),
|
|
142
|
+
("copy_assets", config_options.Type(bool, default=True)),
|
|
143
|
+
("clean_assets", config_options.Type(bool, default=True)),
|
|
144
|
+
("save_html", config_options.Type(bool, default=False)),
|
|
145
|
+
("embed_fragments", config_options.Type(bool, default=False)),
|
|
146
|
+
("language", config_options.Type((str, type(None)), default=None)),
|
|
147
|
+
("bibliography", config_options.Type(list, default=[])),
|
|
148
|
+
("books", config_options.Type(list, default=[])),
|
|
149
|
+
("template_overrides", config_options.Type(dict, default={})),
|
|
150
|
+
("register_material", config_options.Type(bool, default=True)),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def __init__(self) -> None:
|
|
154
|
+
self._enabled = True
|
|
155
|
+
self._is_serve = False
|
|
156
|
+
self._mkdocs_config: MkDocsConfig | None = None
|
|
157
|
+
self._latex_config: LaTeXConfig | None = None
|
|
158
|
+
self._books: list[BookRuntime] = []
|
|
159
|
+
self._book_extras: list[BookExtras] = []
|
|
160
|
+
self._build_root: Path | None = None
|
|
161
|
+
self._project_dir: Path | None = None
|
|
162
|
+
self._site_dir: Path | None = None
|
|
163
|
+
self._page_content: dict[str, str] = {}
|
|
164
|
+
self._page_meta: dict[str, dict[str, Any]] = {}
|
|
165
|
+
self._page_sources: dict[str, Path] = {}
|
|
166
|
+
self._global_bibliography: list[Path] = []
|
|
167
|
+
self._global_template_overrides: dict[str, Any] = {}
|
|
168
|
+
self._nav: Navigation | None = None
|
|
169
|
+
self._diagnostic_emitter: LoggingEmitter | None = None
|
|
170
|
+
self._auto_build = False
|
|
171
|
+
|
|
172
|
+
# -- MkDocs lifecycle -------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def on_startup(self, command: str, dirty: bool) -> None: # pragma: no cover - hook
|
|
175
|
+
self._is_serve = command == "serve"
|
|
176
|
+
|
|
177
|
+
def on_config(
|
|
178
|
+
self, config: MkDocsConfig
|
|
179
|
+
) -> MkDocsConfig: # pragma: no cover - hook
|
|
180
|
+
self._enabled = bool(self.config.get("enabled", True))
|
|
181
|
+
self._mkdocs_config = config
|
|
182
|
+
self._auto_build = self._env_flag_enabled(os.environ.get("TEXSMITH_BUILD"))
|
|
183
|
+
|
|
184
|
+
if not self._enabled:
|
|
185
|
+
return config
|
|
186
|
+
|
|
187
|
+
config_path = Path(config.config_file_path)
|
|
188
|
+
self._project_dir = config_path.parent.resolve()
|
|
189
|
+
self._site_dir = Path(config.site_dir).resolve()
|
|
190
|
+
|
|
191
|
+
build_dir_setting = Path(self.config.get("build_dir") or "press")
|
|
192
|
+
if not build_dir_setting.is_absolute():
|
|
193
|
+
base_dir = self._project_dir or Path.cwd()
|
|
194
|
+
self._build_root = (base_dir / build_dir_setting).resolve()
|
|
195
|
+
else:
|
|
196
|
+
self._build_root = build_dir_setting.resolve()
|
|
197
|
+
|
|
198
|
+
theme_language: str | None = None
|
|
199
|
+
theme_locale: str | None = None
|
|
200
|
+
theme = getattr(config, "theme", None)
|
|
201
|
+
if theme is not None:
|
|
202
|
+
theme_get = getattr(theme, "get", None)
|
|
203
|
+
if callable(theme_get):
|
|
204
|
+
theme_language = theme_get("language")
|
|
205
|
+
locale = theme_get("locale")
|
|
206
|
+
if locale is not None:
|
|
207
|
+
theme_locale = getattr(locale, "language", None) or str(locale)
|
|
208
|
+
else:
|
|
209
|
+
theme_language = getattr(theme, "language", None)
|
|
210
|
+
locale = getattr(theme, "locale", None)
|
|
211
|
+
if locale is not None:
|
|
212
|
+
theme_locale = getattr(locale, "language", None) or str(locale)
|
|
213
|
+
|
|
214
|
+
site_language: str | None = getattr(config, "site_language", None)
|
|
215
|
+
config_get = getattr(config, "get", None)
|
|
216
|
+
if not site_language and callable(config_get):
|
|
217
|
+
site_language = config_get("site_language")
|
|
218
|
+
|
|
219
|
+
language = (
|
|
220
|
+
self.config.get("language")
|
|
221
|
+
or theme_language
|
|
222
|
+
or theme_locale
|
|
223
|
+
or site_language
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
self._global_bibliography = self._coerce_paths(
|
|
227
|
+
self.config.get("bibliography") or []
|
|
228
|
+
)
|
|
229
|
+
self._global_template_overrides = dict(
|
|
230
|
+
self.config.get("template_overrides") or {}
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
self._latex_config = self._build_latex_config(language)
|
|
234
|
+
self._diagnostic_emitter = _MkdocsEmitter(
|
|
235
|
+
logger_obj=log,
|
|
236
|
+
debug_enabled=self._is_serve,
|
|
237
|
+
)
|
|
238
|
+
ensure_fallback_converters()
|
|
239
|
+
return config
|
|
240
|
+
|
|
241
|
+
def on_nav(
|
|
242
|
+
self,
|
|
243
|
+
nav: Navigation,
|
|
244
|
+
config: MkDocsConfig,
|
|
245
|
+
files: Files, # noqa: ARG002 - required by MkDocs
|
|
246
|
+
) -> Navigation: # pragma: no cover - hook
|
|
247
|
+
if not self._enabled:
|
|
248
|
+
return nav
|
|
249
|
+
|
|
250
|
+
self._nav = nav
|
|
251
|
+
return nav
|
|
252
|
+
|
|
253
|
+
def on_post_page(
|
|
254
|
+
self,
|
|
255
|
+
output: str,
|
|
256
|
+
page,
|
|
257
|
+
config: MkDocsConfig,
|
|
258
|
+
) -> str: # pragma: no cover - hook
|
|
259
|
+
if not self._enabled:
|
|
260
|
+
return output
|
|
261
|
+
|
|
262
|
+
src_path = page.file.src_path
|
|
263
|
+
self._page_content[src_path] = page.content
|
|
264
|
+
self._page_meta[src_path] = dict(page.meta or {})
|
|
265
|
+
self._page_sources[src_path] = Path(page.file.abs_src_path)
|
|
266
|
+
rewritten = snippet.rewrite_html_snippets(
|
|
267
|
+
output,
|
|
268
|
+
lambda block: self._build_snippet_urls(page, block),
|
|
269
|
+
source_path=page.file.abs_src_path,
|
|
270
|
+
)
|
|
271
|
+
return rewritten
|
|
272
|
+
|
|
273
|
+
def on_post_build(self, config: MkDocsConfig) -> None: # pragma: no cover - hook
|
|
274
|
+
if not self._enabled or self._is_serve:
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
if self._latex_config is None or self._build_root is None:
|
|
278
|
+
raise PluginError("TeXSmith plugin is not initialised correctly.")
|
|
279
|
+
|
|
280
|
+
self._prepare_books_if_needed()
|
|
281
|
+
|
|
282
|
+
for runtime in self._books:
|
|
283
|
+
self._render_book(runtime)
|
|
284
|
+
|
|
285
|
+
# -- Helpers ----------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
@staticmethod
|
|
288
|
+
def _env_flag_enabled(raw: str | None) -> bool:
|
|
289
|
+
if raw is None:
|
|
290
|
+
return False
|
|
291
|
+
normalised = raw.strip().lower()
|
|
292
|
+
return normalised not in {"", "0", "false", "no", "off"}
|
|
293
|
+
|
|
294
|
+
def _build_latex_config(self, language: str | None) -> LaTeXConfig:
|
|
295
|
+
project_dir = self._project_dir
|
|
296
|
+
build_root = self._build_root
|
|
297
|
+
|
|
298
|
+
if project_dir is None or build_root is None:
|
|
299
|
+
raise PluginError("Project directories are not prepared.")
|
|
300
|
+
|
|
301
|
+
book_specs = self.config.get("books") or []
|
|
302
|
+
if not isinstance(book_specs, list):
|
|
303
|
+
raise PluginError("The 'books' option must be a list.")
|
|
304
|
+
|
|
305
|
+
book_configs: list[BookConfig] = []
|
|
306
|
+
extras: list[BookExtras] = []
|
|
307
|
+
|
|
308
|
+
for idx, raw in enumerate(book_specs, start=1):
|
|
309
|
+
if not isinstance(raw, dict):
|
|
310
|
+
raise PluginError(f"Book definition #{idx} is not a mapping.")
|
|
311
|
+
|
|
312
|
+
data = dict(raw)
|
|
313
|
+
slot_requests = self._normalise_slot_requests(data.pop("slots", None))
|
|
314
|
+
paper_override = data.pop("paper", None)
|
|
315
|
+
press_overrides = self._normalise_press_overrides(
|
|
316
|
+
data.pop("press", None), paper_override
|
|
317
|
+
)
|
|
318
|
+
book_extra = BookExtras(
|
|
319
|
+
template=data.pop("template", None),
|
|
320
|
+
template_overrides=dict(data.pop("template_overrides", {}) or {}),
|
|
321
|
+
bibliography=self._coerce_paths(
|
|
322
|
+
data.pop("bibliography", []), relative_to=project_dir
|
|
323
|
+
),
|
|
324
|
+
slots=slot_requests,
|
|
325
|
+
press=press_overrides,
|
|
326
|
+
)
|
|
327
|
+
try:
|
|
328
|
+
book_config = BookConfig(**data)
|
|
329
|
+
except ValueError as exc:
|
|
330
|
+
raise PluginError(f"Invalid book configuration #{idx}: {exc}") from exc
|
|
331
|
+
|
|
332
|
+
book_configs.append(book_config)
|
|
333
|
+
extras.append(book_extra)
|
|
334
|
+
|
|
335
|
+
if not book_configs:
|
|
336
|
+
book_configs = [BookConfig()]
|
|
337
|
+
extras = [BookExtras()]
|
|
338
|
+
|
|
339
|
+
latex_config = LaTeXConfig(
|
|
340
|
+
build_dir=build_root,
|
|
341
|
+
save_html=bool(self.config.get("save_html", False)),
|
|
342
|
+
project_dir=project_dir,
|
|
343
|
+
language=language,
|
|
344
|
+
books=book_configs,
|
|
345
|
+
clean_assets=bool(self.config.get("clean_assets", True)),
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
self._book_extras = extras
|
|
349
|
+
return latex_config
|
|
350
|
+
|
|
351
|
+
def _prepare_books_if_needed(self) -> None:
|
|
352
|
+
if self._books:
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
if (
|
|
356
|
+
self._latex_config is None
|
|
357
|
+
or self._mkdocs_config is None
|
|
358
|
+
or self._nav is None
|
|
359
|
+
):
|
|
360
|
+
raise PluginError("Navigation is not available to prepare books.")
|
|
361
|
+
|
|
362
|
+
nav = self._nav
|
|
363
|
+
self._books.clear()
|
|
364
|
+
for book_config, book_extra in zip(
|
|
365
|
+
self._latex_config.books, self._book_extras, strict=False
|
|
366
|
+
):
|
|
367
|
+
self._prepare_book(book_config, book_extra, nav, self._mkdocs_config)
|
|
368
|
+
|
|
369
|
+
def _prepare_book(
|
|
370
|
+
self,
|
|
371
|
+
book_config: BookConfig,
|
|
372
|
+
extras: BookExtras,
|
|
373
|
+
nav: Navigation,
|
|
374
|
+
mkdocs_config: MkDocsConfig,
|
|
375
|
+
) -> None:
|
|
376
|
+
if book_config.root is None:
|
|
377
|
+
if nav.pages:
|
|
378
|
+
book_config.root = nav.pages[0].title
|
|
379
|
+
else:
|
|
380
|
+
raise PluginError(
|
|
381
|
+
"Unable to infer the root section for a book; "
|
|
382
|
+
"specify 'root' in the plugin configuration."
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
book_config.title = book_config.title or mkdocs_config.site_name
|
|
386
|
+
book_config.author = book_config.author or getattr(
|
|
387
|
+
mkdocs_config, "site_author", None
|
|
388
|
+
)
|
|
389
|
+
book_config.subtitle = book_config.subtitle or getattr(
|
|
390
|
+
mkdocs_config, "site_description", None
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
site_date = getattr(mkdocs_config, "site_date", None)
|
|
394
|
+
if book_config.year is None:
|
|
395
|
+
if hasattr(site_date, "year"):
|
|
396
|
+
book_config.year = site_date.year
|
|
397
|
+
book_config.email = book_config.email or getattr(
|
|
398
|
+
mkdocs_config, "site_email", None
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
entries: list[NavEntry]
|
|
402
|
+
root_item: StructureItem | None = None
|
|
403
|
+
|
|
404
|
+
if book_config.root == FULL_NAVIGATION_ROOT:
|
|
405
|
+
entries = self._flatten_full_navigation(nav, book_config, extras.slots)
|
|
406
|
+
else:
|
|
407
|
+
root_item = self._find_item_by_title(nav.items, book_config.root)
|
|
408
|
+
if root_item is None:
|
|
409
|
+
raise PluginError(
|
|
410
|
+
f"Root section '{book_config.root}' not found in navigation."
|
|
411
|
+
)
|
|
412
|
+
entries = self._flatten_navigation(root_item, book_config, extras.slots)
|
|
413
|
+
|
|
414
|
+
runtime = BookRuntime(config=book_config, extras=extras, section=root_item)
|
|
415
|
+
runtime.entries = entries
|
|
416
|
+
self._books.append(runtime)
|
|
417
|
+
|
|
418
|
+
def _render_book(self, runtime: BookRuntime) -> None:
|
|
419
|
+
output_root = self._resolve_output_root(runtime.config)
|
|
420
|
+
output_root.mkdir(parents=True, exist_ok=True)
|
|
421
|
+
emitter = self._diagnostic_emitter or _MkdocsEmitter(
|
|
422
|
+
logger_obj=log, debug_enabled=self._is_serve
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
template_name = runtime.extras.template or self.config.get("template")
|
|
426
|
+
try:
|
|
427
|
+
template_runtime = (
|
|
428
|
+
load_template_runtime(template_name)
|
|
429
|
+
if template_name
|
|
430
|
+
else load_template_runtime("book")
|
|
431
|
+
)
|
|
432
|
+
except TemplateError as exc:
|
|
433
|
+
raise PluginError(
|
|
434
|
+
f"Failed to load template '{template_name}': {exc}"
|
|
435
|
+
) from exc
|
|
436
|
+
|
|
437
|
+
original_base_level = runtime.config.base_level
|
|
438
|
+
resolved_base_level = original_base_level
|
|
439
|
+
if original_base_level == AUTO_BASE_LEVEL:
|
|
440
|
+
template_base_level = template_runtime.base_level
|
|
441
|
+
default_slot_name = template_runtime.default_slot
|
|
442
|
+
default_slot: TemplateSlot | None = (
|
|
443
|
+
template_runtime.slots.get(default_slot_name)
|
|
444
|
+
if default_slot_name in template_runtime.slots
|
|
445
|
+
else None
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
if template_base_level is not None:
|
|
449
|
+
resolved_base_level = template_base_level
|
|
450
|
+
elif default_slot is not None:
|
|
451
|
+
fallback_level = template_base_level or 0
|
|
452
|
+
resolved_base_level = default_slot.resolve_level(fallback_level)
|
|
453
|
+
else:
|
|
454
|
+
resolved_base_level = 1
|
|
455
|
+
|
|
456
|
+
if resolved_base_level != original_base_level:
|
|
457
|
+
level_shift = resolved_base_level - original_base_level
|
|
458
|
+
runtime.config.base_level = resolved_base_level
|
|
459
|
+
for entry in runtime.entries:
|
|
460
|
+
entry.level += level_shift
|
|
461
|
+
|
|
462
|
+
parser_backend = self.config.get("parser") or "lxml"
|
|
463
|
+
copy_assets = bool(self.config.get("copy_assets", True))
|
|
464
|
+
|
|
465
|
+
formatter_overrides = dict(template_runtime.formatter_overrides)
|
|
466
|
+
heading_formatter = LaTeXFormatter()
|
|
467
|
+
for name, override_path in formatter_overrides.items():
|
|
468
|
+
heading_formatter.override_template(name, override_path)
|
|
469
|
+
|
|
470
|
+
def renderer_factory() -> LaTeXRenderer:
|
|
471
|
+
formatter = LaTeXFormatter()
|
|
472
|
+
for name, override_path in formatter_overrides.items():
|
|
473
|
+
formatter.override_template(name, override_path)
|
|
474
|
+
renderer = LaTeXRenderer(
|
|
475
|
+
config=runtime.config,
|
|
476
|
+
formatter=formatter,
|
|
477
|
+
output_root=output_root,
|
|
478
|
+
parser=parser_backend,
|
|
479
|
+
copy_assets=copy_assets,
|
|
480
|
+
)
|
|
481
|
+
if self.config.get("register_material", True):
|
|
482
|
+
material.register(renderer)
|
|
483
|
+
snippet.register(renderer)
|
|
484
|
+
return renderer
|
|
485
|
+
|
|
486
|
+
inline_bibliography_specs: list[
|
|
487
|
+
tuple[str, str, dict[str, InlineBibliographyEntry]]
|
|
488
|
+
] = []
|
|
489
|
+
for entry in runtime.entries:
|
|
490
|
+
if not entry.is_page or not entry.src_path:
|
|
491
|
+
continue
|
|
492
|
+
page_meta = self._page_meta.get(entry.src_path) or {}
|
|
493
|
+
try:
|
|
494
|
+
inline_map = extract_front_matter_bibliography(page_meta)
|
|
495
|
+
except InlineBibliographyValidationError as exc:
|
|
496
|
+
log.warning(
|
|
497
|
+
"Inline bibliography on page '%s' is invalid: %s",
|
|
498
|
+
entry.title or entry.src_path,
|
|
499
|
+
exc,
|
|
500
|
+
)
|
|
501
|
+
inline_map = {}
|
|
502
|
+
if inline_map:
|
|
503
|
+
inline_bibliography_specs.append(
|
|
504
|
+
(entry.src_path, entry.title or entry.src_path, inline_map)
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
bibliography_files = [
|
|
508
|
+
*self._global_bibliography,
|
|
509
|
+
*runtime.extras.bibliography,
|
|
510
|
+
]
|
|
511
|
+
bibliography_collection: BibliographyCollection | None = None
|
|
512
|
+
bibliography_map: dict[str, dict[str, Any]] = {}
|
|
513
|
+
if bibliography_files or inline_bibliography_specs:
|
|
514
|
+
bibliography_collection = BibliographyCollection()
|
|
515
|
+
if bibliography_files:
|
|
516
|
+
bibliography_collection.load_files(bibliography_files)
|
|
517
|
+
if inline_bibliography_specs:
|
|
518
|
+
fetcher = DoiBibliographyFetcher()
|
|
519
|
+
for src_path, label, mapping in inline_bibliography_specs:
|
|
520
|
+
self._load_inline_bibliography(
|
|
521
|
+
bibliography_collection,
|
|
522
|
+
mapping,
|
|
523
|
+
source_label=label or src_path,
|
|
524
|
+
fetcher=fetcher,
|
|
525
|
+
)
|
|
526
|
+
bibliography_map = bibliography_collection.to_dict()
|
|
527
|
+
for issue in bibliography_collection.issues:
|
|
528
|
+
prefix = f"[{issue.key}] " if issue.key else ""
|
|
529
|
+
source = f" ({issue.source})" if issue.source else ""
|
|
530
|
+
log.warning("%s%s%s", prefix, issue.message, source)
|
|
531
|
+
|
|
532
|
+
document_state: DocumentState | None = None
|
|
533
|
+
assets_map: dict[str, Path] = {}
|
|
534
|
+
last_renderer: LaTeXRenderer | None = None
|
|
535
|
+
|
|
536
|
+
def track_renderer() -> LaTeXRenderer:
|
|
537
|
+
nonlocal last_renderer
|
|
538
|
+
last_renderer = renderer_factory()
|
|
539
|
+
return last_renderer
|
|
540
|
+
|
|
541
|
+
raw_language = runtime.config.language or self._latex_config.language
|
|
542
|
+
language = normalise_template_language(raw_language)
|
|
543
|
+
runtime_language = language or raw_language
|
|
544
|
+
|
|
545
|
+
overrides = dict(self._global_template_overrides)
|
|
546
|
+
overrides.update(runtime.extras.template_overrides)
|
|
547
|
+
press_section = overrides.get("press")
|
|
548
|
+
base_press = dict(press_section) if isinstance(press_section, Mapping) else {}
|
|
549
|
+
if runtime.extras.press:
|
|
550
|
+
base_press.update(runtime.extras.press)
|
|
551
|
+
if base_press:
|
|
552
|
+
overrides["press"] = base_press
|
|
553
|
+
|
|
554
|
+
def _set_override(key: str, value: Any) -> None:
|
|
555
|
+
if value is not None:
|
|
556
|
+
overrides.setdefault(key, value)
|
|
557
|
+
|
|
558
|
+
_set_override("title", runtime.config.title)
|
|
559
|
+
_set_override("subtitle", runtime.config.subtitle)
|
|
560
|
+
_set_override("author", runtime.config.author)
|
|
561
|
+
_set_override("email", runtime.config.email)
|
|
562
|
+
_set_override("year", runtime.config.year)
|
|
563
|
+
if language:
|
|
564
|
+
overrides.setdefault("language", language)
|
|
565
|
+
overrides.setdefault("cover", runtime.config.cover.name)
|
|
566
|
+
overrides.setdefault("covercolor", runtime.config.cover.color)
|
|
567
|
+
if runtime.config.cover.logo:
|
|
568
|
+
overrides.setdefault("logo", runtime.config.cover.logo)
|
|
569
|
+
|
|
570
|
+
embed_fragments = bool(self.config.get("embed_fragments", False))
|
|
571
|
+
|
|
572
|
+
slot_buffers_embed: dict[str, list[str]] = {
|
|
573
|
+
name: [] for name in template_runtime.slots
|
|
574
|
+
}
|
|
575
|
+
slot_buffers_embed.setdefault(template_runtime.default_slot, [])
|
|
576
|
+
slot_buffers_link: dict[str, list[str]] = {
|
|
577
|
+
name: [] for name in template_runtime.slots
|
|
578
|
+
}
|
|
579
|
+
slot_buffers_link.setdefault(template_runtime.default_slot, [])
|
|
580
|
+
default_base_level = runtime.config.base_level
|
|
581
|
+
if default_base_level is None:
|
|
582
|
+
default_base_level = 0
|
|
583
|
+
slot_base_levels = {
|
|
584
|
+
name: slot.resolve_level(default_base_level)
|
|
585
|
+
for name, slot in template_runtime.slots.items()
|
|
586
|
+
}
|
|
587
|
+
missing_slot_warnings: set[str] = set()
|
|
588
|
+
|
|
589
|
+
def select_slot(entry: NavEntry) -> str:
|
|
590
|
+
if entry.slot:
|
|
591
|
+
target = entry.slot
|
|
592
|
+
elif entry.part == "frontmatter" and "frontmatter" in slot_buffers_embed:
|
|
593
|
+
target = "frontmatter"
|
|
594
|
+
elif entry.part == "backmatter" and "backmatter" in slot_buffers_embed:
|
|
595
|
+
target = "backmatter"
|
|
596
|
+
else:
|
|
597
|
+
target = template_runtime.default_slot
|
|
598
|
+
|
|
599
|
+
if target not in slot_buffers_embed:
|
|
600
|
+
if target not in missing_slot_warnings:
|
|
601
|
+
missing_slot_warnings.add(target)
|
|
602
|
+
log.warning(
|
|
603
|
+
"Requested slot '%s' is not defined by template '%s'; "
|
|
604
|
+
"falling back to '%s'.",
|
|
605
|
+
target,
|
|
606
|
+
template_runtime.name,
|
|
607
|
+
template_runtime.default_slot,
|
|
608
|
+
)
|
|
609
|
+
return template_runtime.default_slot
|
|
610
|
+
return target
|
|
611
|
+
|
|
612
|
+
for page_index, entry in enumerate(runtime.entries):
|
|
613
|
+
target_slot = select_slot(entry)
|
|
614
|
+
slot_base = slot_base_levels.get(target_slot, default_base_level)
|
|
615
|
+
target_buffer_embed = slot_buffers_embed[target_slot]
|
|
616
|
+
target_buffer_link = slot_buffers_link[target_slot]
|
|
617
|
+
effective_level = entry.level
|
|
618
|
+
if slot_base is not None:
|
|
619
|
+
effective_level = slot_base + (entry.level - default_base_level)
|
|
620
|
+
|
|
621
|
+
if not entry.is_page:
|
|
622
|
+
if entry.title and effective_level >= slot_base:
|
|
623
|
+
fragment = heading_formatter.heading(
|
|
624
|
+
entry.title, level=effective_level, numbered=entry.numbered
|
|
625
|
+
)
|
|
626
|
+
target_buffer_embed.append(fragment)
|
|
627
|
+
target_buffer_link.append(fragment)
|
|
628
|
+
continue
|
|
629
|
+
|
|
630
|
+
if not entry.src_path or entry.src_path not in self._page_content:
|
|
631
|
+
log.warning(
|
|
632
|
+
"Skipping page '%s' because no rendered HTML was captured.",
|
|
633
|
+
entry.title,
|
|
634
|
+
)
|
|
635
|
+
continue
|
|
636
|
+
|
|
637
|
+
html = self._page_content[entry.src_path]
|
|
638
|
+
abs_src = entry.abs_src_path or self._page_sources.get(entry.src_path)
|
|
639
|
+
if abs_src is None:
|
|
640
|
+
log.warning(
|
|
641
|
+
"Cannot determine source path for page '%s'; skipping.",
|
|
642
|
+
entry.title,
|
|
643
|
+
)
|
|
644
|
+
continue
|
|
645
|
+
|
|
646
|
+
if runtime.config.save_html:
|
|
647
|
+
self._persist_html_snapshot(output_root, entry.src_path, html)
|
|
648
|
+
|
|
649
|
+
runtime_payload = {
|
|
650
|
+
"base_level": effective_level,
|
|
651
|
+
"numbered": entry.numbered,
|
|
652
|
+
"drop_title": entry.drop_title,
|
|
653
|
+
"source_dir": abs_src.parent,
|
|
654
|
+
"document_path": abs_src,
|
|
655
|
+
"language": runtime_language,
|
|
656
|
+
"template": template_runtime.name,
|
|
657
|
+
"copy_assets": copy_assets,
|
|
658
|
+
"emitter": emitter,
|
|
659
|
+
"snippet_frame_default": False,
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
try:
|
|
663
|
+
with warnings.catch_warnings(record=True) as captured_warnings:
|
|
664
|
+
warnings.simplefilter("always")
|
|
665
|
+
fragment, document_state = render_with_fallback(
|
|
666
|
+
track_renderer,
|
|
667
|
+
html,
|
|
668
|
+
runtime_payload,
|
|
669
|
+
bibliography_map,
|
|
670
|
+
state=document_state,
|
|
671
|
+
emitter=emitter,
|
|
672
|
+
)
|
|
673
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
674
|
+
log.exception("TeXSmith failed while rendering page '%s'.", entry.title)
|
|
675
|
+
detail = (
|
|
676
|
+
format_rendering_error(exc)
|
|
677
|
+
if isinstance(exc, LatexRenderingError)
|
|
678
|
+
else str(exc)
|
|
679
|
+
)
|
|
680
|
+
raise PluginError(
|
|
681
|
+
f"LaTeX rendering failed for page '{entry.title}': {detail}"
|
|
682
|
+
) from exc
|
|
683
|
+
|
|
684
|
+
for warning in captured_warnings:
|
|
685
|
+
self._log_render_warning(entry, warning)
|
|
686
|
+
|
|
687
|
+
page_rel_path = self._resolve_page_fragment_path(entry, page_index)
|
|
688
|
+
page_abs_path = output_root / page_rel_path
|
|
689
|
+
page_abs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
690
|
+
page_abs_path.write_text(fragment, encoding="utf-8")
|
|
691
|
+
target_buffer_embed.append(fragment)
|
|
692
|
+
target_buffer_link.append(f"\\input{{{page_rel_path.as_posix()}}}")
|
|
693
|
+
|
|
694
|
+
if last_renderer is not None:
|
|
695
|
+
for key, path in last_renderer.assets.items():
|
|
696
|
+
assets_map[key] = path
|
|
697
|
+
|
|
698
|
+
final_state = document_state or DocumentState(
|
|
699
|
+
bibliography=dict(bibliography_map)
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
slot_outputs_embed = {
|
|
703
|
+
name: "\n\n".join(parts) for name, parts in slot_buffers_embed.items()
|
|
704
|
+
}
|
|
705
|
+
slot_outputs_link = {
|
|
706
|
+
name: "\n\n".join(parts) for name, parts in slot_buffers_link.items()
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
bibliography_output: Path | None = None
|
|
710
|
+
if (
|
|
711
|
+
bibliography_collection is not None
|
|
712
|
+
and final_state.citations
|
|
713
|
+
and bibliography_map
|
|
714
|
+
):
|
|
715
|
+
bibliography_output = output_root / "texsmith-bibliography.bib"
|
|
716
|
+
bibliography_output.parent.mkdir(parents=True, exist_ok=True)
|
|
717
|
+
bibliography_collection.write_bibtex(
|
|
718
|
+
bibliography_output, keys=final_state.citations
|
|
719
|
+
)
|
|
720
|
+
overrides.setdefault("bibliography", bibliography_output.stem)
|
|
721
|
+
overrides.setdefault("bibliography_resource", bibliography_output.name)
|
|
722
|
+
|
|
723
|
+
fragment_names = (
|
|
724
|
+
overrides.get("fragments") or template_runtime.extras.get("fragments") or []
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
folder = runtime.config.folder
|
|
728
|
+
stem = (
|
|
729
|
+
folder.name if isinstance(folder, Path) else folder if folder else "index"
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
try:
|
|
733
|
+
wrap_result = wrap_template_document(
|
|
734
|
+
template=template_runtime.instance,
|
|
735
|
+
default_slot=template_runtime.default_slot,
|
|
736
|
+
slot_outputs=slot_outputs_embed,
|
|
737
|
+
slot_output_overrides=None if embed_fragments else slot_outputs_link,
|
|
738
|
+
document_state=final_state,
|
|
739
|
+
template_overrides=overrides,
|
|
740
|
+
output_dir=output_root,
|
|
741
|
+
copy_assets=copy_assets,
|
|
742
|
+
output_name=f"{stem}.tex",
|
|
743
|
+
bibliography_path=bibliography_output,
|
|
744
|
+
emitter=emitter,
|
|
745
|
+
fragments=fragment_names,
|
|
746
|
+
template_runtime=template_runtime,
|
|
747
|
+
)
|
|
748
|
+
except TemplateError as exc:
|
|
749
|
+
raise PluginError(f"Failed to wrap LaTeX document: {exc}") from exc
|
|
750
|
+
|
|
751
|
+
template_context = wrap_result.template_context or {}
|
|
752
|
+
tex_path = wrap_result.output_path or (output_root / f"{stem}.tex")
|
|
753
|
+
log.info("TeXSmith wrote '%s'.", tex_path.relative_to(self._build_root))
|
|
754
|
+
self._announce_latexmk_command(output_root, tex_path)
|
|
755
|
+
|
|
756
|
+
template_assets: list[Path] = list(wrap_result.asset_paths or [])
|
|
757
|
+
template_assets.extend(
|
|
758
|
+
Path(destination)
|
|
759
|
+
for _, destination in getattr(wrap_result, "asset_pairs", [])
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
if assets_map:
|
|
763
|
+
self._write_assets_manifest(output_root, assets_map)
|
|
764
|
+
|
|
765
|
+
if self._latex_config.clean_assets and copy_assets:
|
|
766
|
+
referenced_assets = [*assets_map.values(), *template_assets]
|
|
767
|
+
self._prune_unused_assets(output_root, referenced_assets)
|
|
768
|
+
|
|
769
|
+
self._copy_extra_files(runtime.config, output_root)
|
|
770
|
+
self._publish_snippet_assets(output_root)
|
|
771
|
+
if self._auto_build:
|
|
772
|
+
self._run_pdf_build(
|
|
773
|
+
output_root=output_root,
|
|
774
|
+
tex_path=tex_path,
|
|
775
|
+
template_context=template_context,
|
|
776
|
+
document_state=final_state,
|
|
777
|
+
bibliography_present=bool(bibliography_output),
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
def _flatten_navigation(
|
|
781
|
+
self,
|
|
782
|
+
root: StructureItem,
|
|
783
|
+
config: BookConfig,
|
|
784
|
+
slots: Mapping[str, set[str]] | None = None,
|
|
785
|
+
) -> list[NavEntry]:
|
|
786
|
+
entries: list[NavEntry] = []
|
|
787
|
+
slots_map = slots or {}
|
|
788
|
+
|
|
789
|
+
def walk(
|
|
790
|
+
node: StructureItem,
|
|
791
|
+
level: int,
|
|
792
|
+
numbered: bool,
|
|
793
|
+
part: str,
|
|
794
|
+
front_flag: bool,
|
|
795
|
+
back_flag: bool,
|
|
796
|
+
active_slot: str | None,
|
|
797
|
+
) -> None:
|
|
798
|
+
is_front = front_flag or (node.title in config.frontmatter)
|
|
799
|
+
is_back = back_flag or (node.title in config.backmatter)
|
|
800
|
+
segment = "frontmatter" if is_front else "backmatter" if is_back else part
|
|
801
|
+
resolved_slot = active_slot or self._match_slot(node.title, slots_map)
|
|
802
|
+
|
|
803
|
+
drop_title = False
|
|
804
|
+
node_numbered = numbered
|
|
805
|
+
if node.is_page and config.index_is_foreword:
|
|
806
|
+
filename = getattr(node.file, "name", "")
|
|
807
|
+
if filename == "index":
|
|
808
|
+
node_numbered = False
|
|
809
|
+
if config.drop_title_index:
|
|
810
|
+
drop_title = True
|
|
811
|
+
|
|
812
|
+
entry = NavEntry(
|
|
813
|
+
title=node.title or "",
|
|
814
|
+
level=level,
|
|
815
|
+
numbered=node_numbered,
|
|
816
|
+
drop_title=drop_title,
|
|
817
|
+
part=segment,
|
|
818
|
+
is_page=node.is_page,
|
|
819
|
+
slot=resolved_slot,
|
|
820
|
+
src_path=getattr(node.file, "src_path", None) if node.is_page else None,
|
|
821
|
+
abs_src_path=Path(node.file.abs_src_path)
|
|
822
|
+
if node.is_page and getattr(node.file, "abs_src_path", None)
|
|
823
|
+
else None,
|
|
824
|
+
)
|
|
825
|
+
entries.append(entry)
|
|
826
|
+
|
|
827
|
+
next_level = level + 1
|
|
828
|
+
for child in node.children or []:
|
|
829
|
+
walk(
|
|
830
|
+
child,
|
|
831
|
+
next_level,
|
|
832
|
+
node_numbered,
|
|
833
|
+
segment,
|
|
834
|
+
is_front,
|
|
835
|
+
is_back,
|
|
836
|
+
resolved_slot,
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
walk(root, config.base_level, True, "mainmatter", False, False, None)
|
|
840
|
+
return entries
|
|
841
|
+
|
|
842
|
+
def _flatten_full_navigation(
|
|
843
|
+
self,
|
|
844
|
+
nav: Navigation,
|
|
845
|
+
config: BookConfig,
|
|
846
|
+
slots: Mapping[str, set[str]] | None = None,
|
|
847
|
+
) -> list[NavEntry]:
|
|
848
|
+
entries: list[NavEntry] = []
|
|
849
|
+
for item in nav.items:
|
|
850
|
+
entries.extend(self._flatten_navigation(item, config, slots))
|
|
851
|
+
return entries
|
|
852
|
+
|
|
853
|
+
def _find_item_by_title(
|
|
854
|
+
self, items: Iterable[StructureItem], title: str
|
|
855
|
+
) -> StructureItem | None:
|
|
856
|
+
for item in items:
|
|
857
|
+
if item.title == title:
|
|
858
|
+
return item
|
|
859
|
+
match = self._find_item_by_title(item.children or [], title)
|
|
860
|
+
if match is not None:
|
|
861
|
+
return match
|
|
862
|
+
return None
|
|
863
|
+
|
|
864
|
+
def _resolve_output_root(self, config: BookConfig) -> Path:
|
|
865
|
+
base_dir = Path(config.build_dir or self._build_root)
|
|
866
|
+
folder = config.folder
|
|
867
|
+
candidate = base_dir if not folder else base_dir / folder
|
|
868
|
+
return candidate.resolve()
|
|
869
|
+
|
|
870
|
+
@staticmethod
|
|
871
|
+
def _normalise_label(value: str | None) -> str:
|
|
872
|
+
"""Return a case-insensitive label suitable for slot matching."""
|
|
873
|
+
if value is None:
|
|
874
|
+
return ""
|
|
875
|
+
return " ".join(str(value).split()).casefold()
|
|
876
|
+
|
|
877
|
+
def _normalise_slot_requests(self, payload: Any) -> dict[str, set[str]]:
|
|
878
|
+
"""Return normalised slot selectors keyed by slot name."""
|
|
879
|
+
if payload is None:
|
|
880
|
+
return {}
|
|
881
|
+
if not isinstance(payload, Mapping):
|
|
882
|
+
log.warning(
|
|
883
|
+
"Ignoring invalid 'slots' mapping: expected a mapping, got %r",
|
|
884
|
+
type(payload),
|
|
885
|
+
)
|
|
886
|
+
return {}
|
|
887
|
+
|
|
888
|
+
slots: dict[str, set[str]] = {}
|
|
889
|
+
for slot_name, selectors in payload.items():
|
|
890
|
+
name = str(slot_name).strip()
|
|
891
|
+
if not name:
|
|
892
|
+
continue
|
|
893
|
+
titles: set[str] = set()
|
|
894
|
+
if isinstance(selectors, str):
|
|
895
|
+
titles.add(self._normalise_label(selectors))
|
|
896
|
+
elif isinstance(selectors, Iterable) and not isinstance(
|
|
897
|
+
selectors, (bytes, Mapping, str)
|
|
898
|
+
):
|
|
899
|
+
for selector in selectors:
|
|
900
|
+
if isinstance(selector, str) and selector.strip():
|
|
901
|
+
titles.add(self._normalise_label(selector))
|
|
902
|
+
elif selectors is not None:
|
|
903
|
+
log.warning(
|
|
904
|
+
(
|
|
905
|
+
"Slot '%s' selectors must be strings or lists of strings; "
|
|
906
|
+
"ignoring %r."
|
|
907
|
+
),
|
|
908
|
+
name,
|
|
909
|
+
type(selectors),
|
|
910
|
+
)
|
|
911
|
+
if titles:
|
|
912
|
+
slots[name] = titles
|
|
913
|
+
return slots
|
|
914
|
+
|
|
915
|
+
def _match_slot(
|
|
916
|
+
self, title: str | None, slots: Mapping[str, set[str]]
|
|
917
|
+
) -> str | None:
|
|
918
|
+
"""Return the slot name matching the provided title, if any."""
|
|
919
|
+
if not slots:
|
|
920
|
+
return None
|
|
921
|
+
key = self._normalise_label(title)
|
|
922
|
+
for slot, candidates in slots.items():
|
|
923
|
+
if key and key in candidates:
|
|
924
|
+
return slot
|
|
925
|
+
return None
|
|
926
|
+
|
|
927
|
+
def _normalise_press_overrides(
|
|
928
|
+
self, payload: Any, paper: Any | None
|
|
929
|
+
) -> dict[str, Any]:
|
|
930
|
+
"""Return press overrides merged with a paper alias."""
|
|
931
|
+
press: dict[str, Any] = {}
|
|
932
|
+
if isinstance(payload, Mapping):
|
|
933
|
+
press.update(payload)
|
|
934
|
+
elif payload is not None:
|
|
935
|
+
log.warning(
|
|
936
|
+
"Ignoring invalid 'press' override: expected a mapping, got %r",
|
|
937
|
+
type(payload),
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
if paper is not None:
|
|
941
|
+
press["paper"] = paper
|
|
942
|
+
return press
|
|
943
|
+
|
|
944
|
+
def _build_snippet_urls(
|
|
945
|
+
self, page: Any, block: snippet.SnippetBlock
|
|
946
|
+
) -> tuple[str, str]:
|
|
947
|
+
self._ensure_site_snippet_assets(page, block)
|
|
948
|
+
pdf_name = snippet.asset_filename(block.digest, ".pdf")
|
|
949
|
+
png_name = snippet.asset_filename(block.digest, ".png")
|
|
950
|
+
return (
|
|
951
|
+
self._relative_snippet_path(page, pdf_name),
|
|
952
|
+
self._relative_snippet_path(page, png_name),
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
def _relative_snippet_path(self, page: Any, filename: str) -> str:
|
|
956
|
+
file_attr = getattr(page, "file", None)
|
|
957
|
+
dest_path = getattr(file_attr, "dest_path", "") if file_attr else ""
|
|
958
|
+
parent = PurePosixPath(dest_path).parent
|
|
959
|
+
if str(parent) in {"", "."}:
|
|
960
|
+
prefix = ""
|
|
961
|
+
else:
|
|
962
|
+
depth = len([part for part in parent.parts if part and part != "."])
|
|
963
|
+
prefix = "../" * depth
|
|
964
|
+
return f"{prefix}assets/{snippet.SNIPPET_DIR}/{filename}"
|
|
965
|
+
|
|
966
|
+
def _publish_snippet_assets(self, output_root: Path) -> None:
|
|
967
|
+
source_dir = output_root / snippet.SNIPPET_DIR
|
|
968
|
+
site_dir = self._site_dir
|
|
969
|
+
if site_dir is None or not source_dir.exists():
|
|
970
|
+
return
|
|
971
|
+
target_dir = site_dir / "assets" / snippet.SNIPPET_DIR
|
|
972
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
973
|
+
for asset in source_dir.glob("*.pdf"):
|
|
974
|
+
shutil.copy2(asset, target_dir / asset.name)
|
|
975
|
+
for asset in source_dir.glob("*.png"):
|
|
976
|
+
shutil.copy2(asset, target_dir / asset.name)
|
|
977
|
+
|
|
978
|
+
def _run_pdf_build(
|
|
979
|
+
self,
|
|
980
|
+
*,
|
|
981
|
+
output_root: Path,
|
|
982
|
+
tex_path: Path,
|
|
983
|
+
template_context: Mapping[str, Any],
|
|
984
|
+
document_state: DocumentState,
|
|
985
|
+
bibliography_present: bool,
|
|
986
|
+
) -> None:
|
|
987
|
+
env_engine = os.environ.get("TEXSMITH_ENGINE")
|
|
988
|
+
engine_preference = env_engine.strip() if env_engine else "tectonic"
|
|
989
|
+
use_system_tectonic = self._env_flag_enabled(
|
|
990
|
+
os.environ.get("TEXSMITH_SYSTEM_TECTONIC")
|
|
991
|
+
)
|
|
992
|
+
|
|
993
|
+
template_engine = None
|
|
994
|
+
raw_engine = template_context.get("latex_engine")
|
|
995
|
+
if isinstance(raw_engine, str) and raw_engine.strip():
|
|
996
|
+
template_engine = raw_engine.strip()
|
|
997
|
+
|
|
998
|
+
engine_choice = resolve_engine(engine_preference, template_engine)
|
|
999
|
+
|
|
1000
|
+
features = compute_features(
|
|
1001
|
+
requires_shell_escape=bool(
|
|
1002
|
+
template_context.get("requires_shell_escape", False)
|
|
1003
|
+
),
|
|
1004
|
+
bibliography=bibliography_present,
|
|
1005
|
+
document_state=document_state,
|
|
1006
|
+
template_context=template_context,
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
tectonic_binary: Path | None = None
|
|
1010
|
+
biber_binary: Path | None = None
|
|
1011
|
+
makeglossaries_binary: Path | None = None
|
|
1012
|
+
bundled_bin: Path | None = None
|
|
1013
|
+
if engine_choice.backend == "tectonic":
|
|
1014
|
+
try:
|
|
1015
|
+
selection = select_tectonic_binary(
|
|
1016
|
+
use_system_tectonic,
|
|
1017
|
+
console=None,
|
|
1018
|
+
)
|
|
1019
|
+
tectonic_binary = selection.path
|
|
1020
|
+
if features.bibliography and not use_system_tectonic:
|
|
1021
|
+
biber_binary = select_biber_binary(console=None)
|
|
1022
|
+
bundled_bin = biber_binary.parent
|
|
1023
|
+
if features.has_glossary:
|
|
1024
|
+
glossaries = select_makeglossaries(console=None)
|
|
1025
|
+
makeglossaries_binary = glossaries.path
|
|
1026
|
+
if glossaries.source == "bundled":
|
|
1027
|
+
bundled_bin = bundled_bin or glossaries.path.parent
|
|
1028
|
+
except (
|
|
1029
|
+
TectonicAcquisitionError,
|
|
1030
|
+
BiberAcquisitionError,
|
|
1031
|
+
MakeglossariesAcquisitionError,
|
|
1032
|
+
) as exc:
|
|
1033
|
+
raise PluginError(str(exc)) from exc
|
|
1034
|
+
|
|
1035
|
+
available_bins: dict[str, Path] = {}
|
|
1036
|
+
if biber_binary:
|
|
1037
|
+
available_bins["biber"] = biber_binary
|
|
1038
|
+
if makeglossaries_binary:
|
|
1039
|
+
available_bins["makeglossaries"] = makeglossaries_binary
|
|
1040
|
+
|
|
1041
|
+
missing = missing_dependencies(
|
|
1042
|
+
engine_choice,
|
|
1043
|
+
features,
|
|
1044
|
+
use_system_tectonic=use_system_tectonic
|
|
1045
|
+
if engine_choice.backend == "tectonic"
|
|
1046
|
+
else False,
|
|
1047
|
+
available_binaries=available_bins or None,
|
|
1048
|
+
)
|
|
1049
|
+
if missing:
|
|
1050
|
+
readable = ", ".join(sorted(set(missing)))
|
|
1051
|
+
raise PluginError(
|
|
1052
|
+
f"LaTeX build skipped for '{tex_path.name}': "
|
|
1053
|
+
f"missing dependencies ({readable})."
|
|
1054
|
+
)
|
|
1055
|
+
|
|
1056
|
+
if engine_choice.backend == "latexmk":
|
|
1057
|
+
self._ensure_latexmkrc(
|
|
1058
|
+
tex_path=tex_path,
|
|
1059
|
+
engine=engine_choice.latexmk_engine,
|
|
1060
|
+
features=features,
|
|
1061
|
+
)
|
|
1062
|
+
|
|
1063
|
+
command_plan = ensure_command_paths(
|
|
1064
|
+
build_engine_command(
|
|
1065
|
+
engine_choice,
|
|
1066
|
+
features,
|
|
1067
|
+
main_tex_path=tex_path,
|
|
1068
|
+
tectonic_binary=tectonic_binary,
|
|
1069
|
+
)
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
env = build_tex_env(
|
|
1073
|
+
tex_path.parent,
|
|
1074
|
+
isolate_cache=False,
|
|
1075
|
+
extra_path=bundled_bin,
|
|
1076
|
+
biber_path=biber_binary,
|
|
1077
|
+
)
|
|
1078
|
+
console = Console(
|
|
1079
|
+
file=sys.stdout,
|
|
1080
|
+
force_terminal=False,
|
|
1081
|
+
color_system=None,
|
|
1082
|
+
no_color=True,
|
|
1083
|
+
)
|
|
1084
|
+
|
|
1085
|
+
engine_label = (
|
|
1086
|
+
engine_choice.latexmk_engine
|
|
1087
|
+
if engine_choice.backend == "latexmk"
|
|
1088
|
+
else "tectonic"
|
|
1089
|
+
)
|
|
1090
|
+
bundle_label = self._relativise(
|
|
1091
|
+
self._project_dir or output_root, tex_path.parent
|
|
1092
|
+
)
|
|
1093
|
+
log.info(
|
|
1094
|
+
"TEXSMITH_BUILD enabled: building '%s' with %s.",
|
|
1095
|
+
bundle_label.as_posix(),
|
|
1096
|
+
engine_label,
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
result = run_engine_command(
|
|
1100
|
+
command_plan,
|
|
1101
|
+
backend=engine_choice.backend,
|
|
1102
|
+
workdir=tex_path.parent,
|
|
1103
|
+
env=env,
|
|
1104
|
+
console=console,
|
|
1105
|
+
verbosity=1,
|
|
1106
|
+
)
|
|
1107
|
+
|
|
1108
|
+
if result.messages:
|
|
1109
|
+
self._log_engine_messages(result.messages)
|
|
1110
|
+
|
|
1111
|
+
if result.returncode != 0:
|
|
1112
|
+
log_path = self._relativise(tex_path.parent, result.log_path)
|
|
1113
|
+
raise PluginError(
|
|
1114
|
+
f"LaTeX build failed for '{tex_path.name}' (see {log_path})."
|
|
1115
|
+
)
|
|
1116
|
+
|
|
1117
|
+
pdf_path = result.pdf_path
|
|
1118
|
+
if not pdf_path.is_absolute():
|
|
1119
|
+
pdf_path = (tex_path.parent / pdf_path).resolve()
|
|
1120
|
+
pdf_label = self._relativise(self._project_dir or tex_path.parent, pdf_path)
|
|
1121
|
+
log.info("LaTeX build complete: %s", pdf_label)
|
|
1122
|
+
|
|
1123
|
+
def _ensure_latexmkrc(
|
|
1124
|
+
self, *, tex_path: Path, engine: str | None, features: EngineFeatures
|
|
1125
|
+
) -> Path | None:
|
|
1126
|
+
rc_path = tex_path.parent / ".latexmkrc"
|
|
1127
|
+
if rc_path.exists():
|
|
1128
|
+
return rc_path
|
|
1129
|
+
|
|
1130
|
+
try:
|
|
1131
|
+
content = build_latexmkrc_content(
|
|
1132
|
+
root_filename=tex_path.stem,
|
|
1133
|
+
engine=engine,
|
|
1134
|
+
requires_shell_escape=features.requires_shell_escape,
|
|
1135
|
+
bibliography=features.bibliography,
|
|
1136
|
+
index_engine=features.index_engine,
|
|
1137
|
+
has_index=features.has_index,
|
|
1138
|
+
has_glossary=features.has_glossary,
|
|
1139
|
+
)
|
|
1140
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
1141
|
+
log.warning("Unable to prepare latexmkrc for '%s': %s", tex_path.name, exc)
|
|
1142
|
+
return None
|
|
1143
|
+
|
|
1144
|
+
try:
|
|
1145
|
+
rc_path.write_text(content, encoding="utf-8")
|
|
1146
|
+
except OSError as exc: # pragma: no cover - filesystem
|
|
1147
|
+
log.warning("Failed to write latexmkrc for '%s': %s", tex_path.name, exc)
|
|
1148
|
+
return None
|
|
1149
|
+
|
|
1150
|
+
return rc_path
|
|
1151
|
+
|
|
1152
|
+
def _log_engine_messages(self, messages: Iterable[LatexMessage]) -> None:
|
|
1153
|
+
for message in messages:
|
|
1154
|
+
summary = message.summary.strip()
|
|
1155
|
+
details = "; ".join(
|
|
1156
|
+
part.strip() for part in message.details if part.strip()
|
|
1157
|
+
)
|
|
1158
|
+
payload = f"{summary}: {details}" if details else summary
|
|
1159
|
+
|
|
1160
|
+
if message.severity is LatexMessageSeverity.ERROR:
|
|
1161
|
+
log.error("LaTeX: %s", payload)
|
|
1162
|
+
elif message.severity is LatexMessageSeverity.WARNING:
|
|
1163
|
+
log.warning("LaTeX: %s", payload)
|
|
1164
|
+
else:
|
|
1165
|
+
log.info("LaTeX: %s", payload)
|
|
1166
|
+
|
|
1167
|
+
def _ensure_site_snippet_assets(
|
|
1168
|
+
self, page: Any, block: snippet.SnippetBlock
|
|
1169
|
+
) -> None:
|
|
1170
|
+
dest_dir = self._site_snippet_dir()
|
|
1171
|
+
emitter = self._diagnostic_emitter or _MkdocsEmitter(
|
|
1172
|
+
logger_obj=log, debug_enabled=self._is_serve
|
|
1173
|
+
)
|
|
1174
|
+
abs_src = getattr(page.file, "abs_src_path", None)
|
|
1175
|
+
if not abs_src:
|
|
1176
|
+
raise PluginError(
|
|
1177
|
+
"Unable to determine the source path for snippet rendering."
|
|
1178
|
+
)
|
|
1179
|
+
source_path = Path(abs_src)
|
|
1180
|
+
try:
|
|
1181
|
+
snippet.ensure_snippet_assets(
|
|
1182
|
+
block,
|
|
1183
|
+
output_dir=dest_dir,
|
|
1184
|
+
source_path=source_path,
|
|
1185
|
+
emitter=emitter,
|
|
1186
|
+
)
|
|
1187
|
+
except Exception as exc: # pragma: no cover - passthrough
|
|
1188
|
+
raise PluginError(
|
|
1189
|
+
f"Failed to render snippet on page '{page.file.src_path}': {exc}"
|
|
1190
|
+
) from exc
|
|
1191
|
+
|
|
1192
|
+
def _site_snippet_dir(self) -> Path:
|
|
1193
|
+
site_dir = self._site_dir
|
|
1194
|
+
if site_dir is None:
|
|
1195
|
+
config_site = getattr(self._mkdocs_config, "site_dir", None)
|
|
1196
|
+
if config_site:
|
|
1197
|
+
site_dir = Path(config_site).resolve()
|
|
1198
|
+
self._site_dir = site_dir
|
|
1199
|
+
else:
|
|
1200
|
+
raise PluginError("MkDocs site directory is not initialised.")
|
|
1201
|
+
target_dir = site_dir / "assets" / snippet.SNIPPET_DIR
|
|
1202
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
1203
|
+
return target_dir
|
|
1204
|
+
|
|
1205
|
+
def _persist_html_snapshot(
|
|
1206
|
+
self, output_root: Path, src_path: str, html: str
|
|
1207
|
+
) -> None:
|
|
1208
|
+
snapshot_path = output_root / "html" / Path(src_path).with_suffix(".html")
|
|
1209
|
+
snapshot_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1210
|
+
snapshot_path.write_text(html, encoding="utf-8")
|
|
1211
|
+
|
|
1212
|
+
def _write_assets_manifest(
|
|
1213
|
+
self, output_root: Path, assets_map: dict[str, Path]
|
|
1214
|
+
) -> None:
|
|
1215
|
+
manifest = {
|
|
1216
|
+
key: self._relativise(output_root, path).as_posix()
|
|
1217
|
+
for key, path in assets_map.items()
|
|
1218
|
+
}
|
|
1219
|
+
manifest_path = output_root / "assets_map.yml"
|
|
1220
|
+
manifest_path.write_text(
|
|
1221
|
+
yaml.safe_dump(manifest, sort_keys=True), encoding="utf-8"
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
def _prune_unused_assets(
|
|
1225
|
+
self, output_root: Path, referenced: Iterable[Path]
|
|
1226
|
+
) -> None:
|
|
1227
|
+
assets_dir = output_root / "assets"
|
|
1228
|
+
if not assets_dir.exists():
|
|
1229
|
+
return
|
|
1230
|
+
resolved_paths: set[Path] = set()
|
|
1231
|
+
for path in referenced:
|
|
1232
|
+
candidate = Path(path)
|
|
1233
|
+
if not candidate.is_absolute():
|
|
1234
|
+
candidate = (output_root / candidate).resolve()
|
|
1235
|
+
else:
|
|
1236
|
+
try:
|
|
1237
|
+
candidate = candidate.resolve()
|
|
1238
|
+
except OSError:
|
|
1239
|
+
continue
|
|
1240
|
+
resolved_paths.add(candidate)
|
|
1241
|
+
|
|
1242
|
+
for candidate in assets_dir.rglob("*"):
|
|
1243
|
+
if not candidate.is_file():
|
|
1244
|
+
continue
|
|
1245
|
+
try:
|
|
1246
|
+
resolved = candidate.resolve()
|
|
1247
|
+
except OSError:
|
|
1248
|
+
continue
|
|
1249
|
+
if resolved not in resolved_paths:
|
|
1250
|
+
candidate.unlink()
|
|
1251
|
+
|
|
1252
|
+
def _copy_extra_files(self, config: BookConfig, output_root: Path) -> None:
|
|
1253
|
+
project_dir = self._project_dir
|
|
1254
|
+
if project_dir is None:
|
|
1255
|
+
return
|
|
1256
|
+
|
|
1257
|
+
for pattern, destination in config.copy_files.items():
|
|
1258
|
+
src_pattern = (project_dir / pattern).resolve()
|
|
1259
|
+
dest_candidate = output_root / destination
|
|
1260
|
+
|
|
1261
|
+
matched = list(src_pattern.parent.glob(src_pattern.name))
|
|
1262
|
+
if not matched:
|
|
1263
|
+
log.warning("Copy pattern '%s' resolved no files.", pattern)
|
|
1264
|
+
continue
|
|
1265
|
+
|
|
1266
|
+
for src in matched:
|
|
1267
|
+
if dest_candidate.is_dir() or destination.endswith("/"):
|
|
1268
|
+
target = dest_candidate / src.name
|
|
1269
|
+
elif dest_candidate.suffix:
|
|
1270
|
+
target = dest_candidate
|
|
1271
|
+
else:
|
|
1272
|
+
target = dest_candidate / src.name
|
|
1273
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1274
|
+
shutil.copy2(src, target)
|
|
1275
|
+
log.info("Copied '%s' to '%s'.", src, target)
|
|
1276
|
+
|
|
1277
|
+
def _resolve_page_fragment_path(self, entry: NavEntry, index: int) -> Path:
|
|
1278
|
+
base = entry.src_path or entry.title or f"page-{index}"
|
|
1279
|
+
normalised = slugify(base.replace("/", "-"), separator="-")
|
|
1280
|
+
if not normalised:
|
|
1281
|
+
normalised = f"page-{index}"
|
|
1282
|
+
return Path("pages") / f"{normalised}.tex"
|
|
1283
|
+
|
|
1284
|
+
def _log_render_warning(self, entry: NavEntry, warning: WarningMessage) -> None:
|
|
1285
|
+
"""Surface warnings raised during page rendering as MkDocs warnings."""
|
|
1286
|
+
page_label = entry.title or entry.src_path or "page"
|
|
1287
|
+
message = str(warning.message).strip()
|
|
1288
|
+
category = getattr(warning.category, "__name__", "Warning")
|
|
1289
|
+
|
|
1290
|
+
location = ""
|
|
1291
|
+
filename = getattr(warning, "filename", "") or ""
|
|
1292
|
+
if filename:
|
|
1293
|
+
candidate = Path(filename)
|
|
1294
|
+
try:
|
|
1295
|
+
candidate = candidate.resolve()
|
|
1296
|
+
except OSError:
|
|
1297
|
+
pass
|
|
1298
|
+
project_dir = self._project_dir
|
|
1299
|
+
if project_dir is not None:
|
|
1300
|
+
try:
|
|
1301
|
+
candidate = candidate.relative_to(project_dir)
|
|
1302
|
+
except ValueError:
|
|
1303
|
+
try:
|
|
1304
|
+
candidate = Path(os.path.relpath(candidate, project_dir))
|
|
1305
|
+
except ValueError:
|
|
1306
|
+
pass
|
|
1307
|
+
display_path = candidate.as_posix()
|
|
1308
|
+
location = f" ({display_path}:{warning.lineno})"
|
|
1309
|
+
|
|
1310
|
+
log.warning(
|
|
1311
|
+
"TeXSmith warning on page '%s': %s%s [%s]",
|
|
1312
|
+
page_label,
|
|
1313
|
+
message,
|
|
1314
|
+
location,
|
|
1315
|
+
category,
|
|
1316
|
+
)
|
|
1317
|
+
|
|
1318
|
+
def _announce_latexmk_command(self, output_root: Path, tex_path: Path) -> None:
|
|
1319
|
+
"""Log a helpful hint showing how to compile the generated project."""
|
|
1320
|
+
base_dir = self._project_dir or output_root
|
|
1321
|
+
bundle_path = self._relativise(base_dir, output_root)
|
|
1322
|
+
try:
|
|
1323
|
+
tex_rel = tex_path.relative_to(output_root)
|
|
1324
|
+
except ValueError:
|
|
1325
|
+
tex_rel = tex_path
|
|
1326
|
+
|
|
1327
|
+
log.info(
|
|
1328
|
+
(
|
|
1329
|
+
"Press bundle ready in '%s'. "
|
|
1330
|
+
"Run 'latexmk -cd %s/%s' to build the documentation."
|
|
1331
|
+
),
|
|
1332
|
+
bundle_path.as_posix(),
|
|
1333
|
+
bundle_path.as_posix(),
|
|
1334
|
+
tex_rel.as_posix(),
|
|
1335
|
+
)
|
|
1336
|
+
|
|
1337
|
+
def _load_inline_bibliography(
|
|
1338
|
+
self,
|
|
1339
|
+
collection: BibliographyCollection,
|
|
1340
|
+
entries: Mapping[str, InlineBibliographyEntry],
|
|
1341
|
+
*,
|
|
1342
|
+
source_label: str,
|
|
1343
|
+
fetcher: DoiBibliographyFetcher,
|
|
1344
|
+
) -> None:
|
|
1345
|
+
if not entries:
|
|
1346
|
+
return
|
|
1347
|
+
|
|
1348
|
+
source_path = self._inline_bibliography_source_path(source_label)
|
|
1349
|
+
for key, entry in entries.items():
|
|
1350
|
+
if entry.doi:
|
|
1351
|
+
try:
|
|
1352
|
+
payload = fetcher.fetch(entry.doi)
|
|
1353
|
+
except DoiLookupError as exc:
|
|
1354
|
+
log.warning(
|
|
1355
|
+
"Failed to resolve DOI '%s' for entry '%s': %s",
|
|
1356
|
+
entry.doi,
|
|
1357
|
+
key,
|
|
1358
|
+
exc,
|
|
1359
|
+
)
|
|
1360
|
+
continue
|
|
1361
|
+
try:
|
|
1362
|
+
data = bibliography_data_from_string(payload, key)
|
|
1363
|
+
except PybtexError as exc:
|
|
1364
|
+
log.warning(
|
|
1365
|
+
"Failed to parse bibliography entry '%s': %s",
|
|
1366
|
+
key,
|
|
1367
|
+
exc,
|
|
1368
|
+
)
|
|
1369
|
+
continue
|
|
1370
|
+
collection.load_data(data, source=source_path)
|
|
1371
|
+
continue
|
|
1372
|
+
|
|
1373
|
+
if entry.is_manual:
|
|
1374
|
+
try:
|
|
1375
|
+
data = bibliography_data_from_inline_entry(key, entry)
|
|
1376
|
+
except (ValueError, PybtexError) as exc:
|
|
1377
|
+
log.warning(
|
|
1378
|
+
"Failed to materialise bibliography entry '%s': %s",
|
|
1379
|
+
key,
|
|
1380
|
+
exc,
|
|
1381
|
+
)
|
|
1382
|
+
continue
|
|
1383
|
+
collection.load_data(data, source=source_path)
|
|
1384
|
+
continue
|
|
1385
|
+
|
|
1386
|
+
log.warning(
|
|
1387
|
+
"Skipping bibliography entry '%s': no DOI and no manual fields.",
|
|
1388
|
+
key,
|
|
1389
|
+
)
|
|
1390
|
+
|
|
1391
|
+
def _inline_bibliography_source_path(self, label: str) -> Path:
|
|
1392
|
+
slug = slugify(label, separator="-")
|
|
1393
|
+
if not slug:
|
|
1394
|
+
slug = "frontmatter"
|
|
1395
|
+
return Path(f"frontmatter-{slug}.bib")
|
|
1396
|
+
|
|
1397
|
+
def _coerce_paths(
|
|
1398
|
+
self, values: Iterable[str], *, relative_to: Path | None = None
|
|
1399
|
+
) -> list[Path]:
|
|
1400
|
+
base = relative_to or self._project_dir or Path.cwd()
|
|
1401
|
+
paths: list[Path] = []
|
|
1402
|
+
for raw in values:
|
|
1403
|
+
candidate = Path(raw)
|
|
1404
|
+
raw_path = os.fspath(raw)
|
|
1405
|
+
# Windows treats POSIX-style roots ("/tmp/foo") as missing a drive
|
|
1406
|
+
# letter, so pathlib reports them as relative. Preserve already-absolute
|
|
1407
|
+
# inputs by checking for either separator prefix as well.
|
|
1408
|
+
is_absolute = (
|
|
1409
|
+
os.path.isabs(raw_path)
|
|
1410
|
+
or posixpath.isabs(raw_path)
|
|
1411
|
+
or raw_path.startswith(("/", "\\"))
|
|
1412
|
+
)
|
|
1413
|
+
if not is_absolute:
|
|
1414
|
+
candidate = (base / candidate).resolve()
|
|
1415
|
+
paths.append(candidate)
|
|
1416
|
+
return paths
|
|
1417
|
+
|
|
1418
|
+
def _relativise(self, base: Path, target: Path) -> Path:
|
|
1419
|
+
try:
|
|
1420
|
+
return target.relative_to(base)
|
|
1421
|
+
except ValueError:
|
|
1422
|
+
try:
|
|
1423
|
+
return Path(os.path.relpath(target, base))
|
|
1424
|
+
except ValueError:
|
|
1425
|
+
return target
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
__all__ = ["LatexPlugin"]
|