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.
@@ -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"]