texsmith 0.0.2.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- texsmith/__init__.py +107 -0
- texsmith/_alias.py +59 -0
- texsmith/adapters/__init__.py +6 -0
- texsmith/adapters/docker.py +258 -0
- texsmith/adapters/handlers/__init__.py +3 -0
- texsmith/adapters/handlers/_assets.py +363 -0
- texsmith/adapters/handlers/_helpers.py +62 -0
- texsmith/adapters/handlers/_mermaid.py +122 -0
- texsmith/adapters/handlers/admonitions.py +267 -0
- texsmith/adapters/handlers/basic.py +228 -0
- texsmith/adapters/handlers/blocks.py +851 -0
- texsmith/adapters/handlers/code.py +309 -0
- texsmith/adapters/handlers/inline.py +937 -0
- texsmith/adapters/handlers/links.py +239 -0
- texsmith/adapters/handlers/media.py +380 -0
- texsmith/adapters/latex/__init__.py +10 -0
- texsmith/adapters/latex/engines/__init__.py +726 -0
- texsmith/adapters/latex/engines/latex/__init__.py +26 -0
- texsmith/adapters/latex/engines/latex/log.py +720 -0
- texsmith/adapters/latex/engines/latex/runner.py +28 -0
- texsmith/adapters/latex/engines/tectonic/__init__.py +38 -0
- texsmith/adapters/latex/formatter.py +297 -0
- texsmith/adapters/latex/latexmk.py +148 -0
- texsmith/adapters/latex/partials/acronym.tex +1 -0
- texsmith/adapters/latex/partials/add.tex +1 -0
- texsmith/adapters/latex/partials/addition.tex +1 -0
- texsmith/adapters/latex/partials/blockquote.tex +3 -0
- texsmith/adapters/latex/partials/callout.tex +3 -0
- texsmith/adapters/latex/partials/choices.tex +5 -0
- texsmith/adapters/latex/partials/citation.tex +1 -0
- texsmith/adapters/latex/partials/codeblock.tex +7 -0
- texsmith/adapters/latex/partials/codeblock_listings.tex +5 -0
- texsmith/adapters/latex/partials/codeblock_pygments.tex +5 -0
- texsmith/adapters/latex/partials/codeblock_verbatim.tex +12 -0
- texsmith/adapters/latex/partials/codeinline.tex +1 -0
- texsmith/adapters/latex/partials/codeinlinett.tex +1 -0
- texsmith/adapters/latex/partials/comment.tex +1 -0
- texsmith/adapters/latex/partials/del.tex +1 -0
- texsmith/adapters/latex/partials/deletion.tex +1 -0
- texsmith/adapters/latex/partials/description_list.tex +5 -0
- texsmith/adapters/latex/partials/enquote.tex +1 -0
- texsmith/adapters/latex/partials/epigraph.tex +1 -0
- texsmith/adapters/latex/partials/exercises_solutions.tex +7 -0
- texsmith/adapters/latex/partials/figure.tex +33 -0
- texsmith/adapters/latex/partials/figure_tcolorbox.tex +15 -0
- texsmith/adapters/latex/partials/footnote.tex +3 -0
- texsmith/adapters/latex/partials/glossary.tex +1 -0
- texsmith/adapters/latex/partials/heading.tex +14 -0
- texsmith/adapters/latex/partials/highlight.tex +25 -0
- texsmith/adapters/latex/partials/horizontal_rule.tex +1 -0
- texsmith/adapters/latex/partials/href.tex +1 -0
- texsmith/adapters/latex/partials/icon.tex +1 -0
- texsmith/adapters/latex/partials/include.tex +1 -0
- texsmith/adapters/latex/partials/index.tex +1 -0
- texsmith/adapters/latex/partials/italic.tex +1 -0
- texsmith/adapters/latex/partials/keystroke.tex +46 -0
- texsmith/adapters/latex/partials/label.tex +1 -0
- texsmith/adapters/latex/partials/list_acronyms.tex +5 -0
- texsmith/adapters/latex/partials/list_glossary.tex +8 -0
- texsmith/adapters/latex/partials/multicolumn.tex +3 -0
- texsmith/adapters/latex/partials/ordered_list.tex +5 -0
- texsmith/adapters/latex/partials/pagestyle.tex +1 -0
- texsmith/adapters/latex/partials/ref.tex +1 -0
- texsmith/adapters/latex/partials/regex.tex +1 -0
- texsmith/adapters/latex/partials/smallcaps.tex +1 -0
- texsmith/adapters/latex/partials/strikethrough.tex +1 -0
- texsmith/adapters/latex/partials/strong.tex +1 -0
- texsmith/adapters/latex/partials/subscript.tex +1 -0
- texsmith/adapters/latex/partials/substitution.tex +1 -0
- texsmith/adapters/latex/partials/superscript.tex +1 -0
- texsmith/adapters/latex/partials/tabbed.tex +3 -0
- texsmith/adapters/latex/partials/table.tex +48 -0
- texsmith/adapters/latex/partials/underline.tex +1 -0
- texsmith/adapters/latex/partials/unordered_list.tex +5 -0
- texsmith/adapters/latex/partials/url.tex +1 -0
- texsmith/adapters/latex/pygments.py +98 -0
- texsmith/adapters/latex/pyxindy.py +64 -0
- texsmith/adapters/latex/renderer.py +220 -0
- texsmith/adapters/latex/tectonic.py +366 -0
- texsmith/adapters/latex/utils.py +86 -0
- texsmith/adapters/markdown/__init__.py +342 -0
- texsmith/adapters/plugins/__init__.py +8 -0
- texsmith/adapters/plugins/material.py +174 -0
- texsmith/adapters/plugins/snippet.py +1734 -0
- texsmith/adapters/transformers/__init__.py +122 -0
- texsmith/adapters/transformers/base.py +140 -0
- texsmith/adapters/transformers/strategies.py +1456 -0
- texsmith/adapters/transformers/utils.py +59 -0
- texsmith/api/__init__.py +80 -0
- texsmith/api/_utils.py +56 -0
- texsmith/api/document.py +645 -0
- texsmith/api/pipeline.py +229 -0
- texsmith/api/service.py +648 -0
- texsmith/api/templates.py +287 -0
- texsmith/core/__init__.py +6 -0
- texsmith/core/bibliography/__init__.py +57 -0
- texsmith/core/bibliography/collection.py +354 -0
- texsmith/core/bibliography/doi.py +194 -0
- texsmith/core/bibliography/issues.py +15 -0
- texsmith/core/bibliography/parsing.py +45 -0
- texsmith/core/callouts.py +99 -0
- texsmith/core/config.py +191 -0
- texsmith/core/context.py +242 -0
- texsmith/core/conversion/__init__.py +103 -0
- texsmith/core/conversion/core.py +780 -0
- texsmith/core/conversion/debug.py +87 -0
- texsmith/core/conversion/inputs.py +474 -0
- texsmith/core/conversion/renderer.py +578 -0
- texsmith/core/conversion/templates.py +646 -0
- texsmith/core/conversion_contexts.py +95 -0
- texsmith/core/diagnostics.py +118 -0
- texsmith/core/exceptions.py +41 -0
- texsmith/core/fonts/__init__.py +3 -0
- texsmith/core/fragments/__init__.py +716 -0
- texsmith/core/fragments/base.py +117 -0
- texsmith/core/metadata.py +280 -0
- texsmith/core/mustache.py +86 -0
- texsmith/core/partials.py +20 -0
- texsmith/core/rules.py +394 -0
- texsmith/core/templates/__init__.py +58 -0
- texsmith/core/templates/base.py +343 -0
- texsmith/core/templates/builtins.py +68 -0
- texsmith/core/templates/context_usage.py +137 -0
- texsmith/core/templates/loader.py +303 -0
- texsmith/core/templates/manifest.py +861 -0
- texsmith/core/templates/runtime.py +347 -0
- texsmith/core/templates/text.py +14 -0
- texsmith/core/templates/wrapper.py +340 -0
- texsmith/core/user_dir.py +179 -0
- texsmith/devtools.py +28 -0
- texsmith/extensions/__init__.py +162 -0
- texsmith/extensions/index/__init__.py +21 -0
- texsmith/extensions/index/markdown.py +94 -0
- texsmith/extensions/index/mkdocs_plugin.py +136 -0
- texsmith/extensions/index/registry.py +57 -0
- texsmith/extensions/index/renderer.py +183 -0
- texsmith/extensions/index/templates/index.tex +1 -0
- texsmith/extensions/latex_raw.py +115 -0
- texsmith/extensions/latex_text.py +117 -0
- texsmith/extensions/mermaid.py +267 -0
- texsmith/extensions/missing_footnotes.py +125 -0
- texsmith/extensions/multi_citations.py +54 -0
- texsmith/extensions/progressbar/__init__.py +9 -0
- texsmith/extensions/progressbar/markdown.py +212 -0
- texsmith/extensions/progressbar/renderer.py +117 -0
- texsmith/extensions/smallcaps.py +48 -0
- texsmith/extensions/texlogos/__init__.py +10 -0
- texsmith/extensions/texlogos/markdown.py +236 -0
- texsmith/extensions/texlogos/renderer.py +81 -0
- texsmith/extensions/texlogos/specs.py +66 -0
- texsmith/fonts/__init__.py +57 -0
- texsmith/fonts/cache.py +46 -0
- texsmith/fonts/constants.py +60 -0
- texsmith/fonts/coverage.py +275 -0
- texsmith/fonts/downloader.py +103 -0
- texsmith/fonts/fallback.py +438 -0
- texsmith/fonts/html_scripts.py +168 -0
- texsmith/fonts/logging.py +127 -0
- texsmith/fonts/pipeline.py +338 -0
- texsmith/fonts/scripts.py +493 -0
- texsmith/fonts/ucharclasses.py +164 -0
- texsmith/fragments/__init__.py +11 -0
- texsmith/fragments/bibliography/__init__.py +65 -0
- texsmith/fragments/bibliography/fragment.toml +3 -0
- texsmith/fragments/bibliography/ts-bibliography-backmatter.jinja.tex +35 -0
- texsmith/fragments/bibliography/ts-bibliography.jinja.tex +10 -0
- texsmith/fragments/callouts/__init__.py +88 -0
- texsmith/fragments/callouts/fragment.toml +3 -0
- texsmith/fragments/callouts/ts-callouts.jinja.sty +129 -0
- texsmith/fragments/code/__init__.py +82 -0
- texsmith/fragments/code/fragment.toml +3 -0
- texsmith/fragments/code/ts-code.jinja.sty +140 -0
- texsmith/fragments/extra/__init__.py +180 -0
- texsmith/fragments/extra/fragment.toml +3 -0
- texsmith/fragments/extra/ts-extra.jinja.tex +13 -0
- texsmith/fragments/fonts/__init__.py +880 -0
- texsmith/fragments/fonts/fragment.toml +3 -0
- texsmith/fragments/fonts/ts-fonts.jinja.sty +292 -0
- texsmith/fragments/frame/__init__.py +170 -0
- texsmith/fragments/frame/fragment.toml +3 -0
- texsmith/fragments/frame/ts-frame.tex.jinja +49 -0
- texsmith/fragments/geometry/__init__.py +169 -0
- texsmith/fragments/geometry/fragment.toml +3 -0
- texsmith/fragments/geometry/paper.py +526 -0
- texsmith/fragments/geometry/ts_geometry.tex.jinja +53 -0
- texsmith/fragments/glossary/__init__.py +67 -0
- texsmith/fragments/glossary/fragment.toml +3 -0
- texsmith/fragments/glossary/ts-glossary-backmatter.jinja.tex +5 -0
- texsmith/fragments/glossary/ts-glossary.jinja.sty +28 -0
- texsmith/fragments/index/__init__.py +66 -0
- texsmith/fragments/index/fragment.toml +3 -0
- texsmith/fragments/index/ts-index-backmatter.jinja.tex +3 -0
- texsmith/fragments/index/ts-index.jinja.sty +12 -0
- texsmith/fragments/keystrokes/__init__.py +69 -0
- texsmith/fragments/keystrokes/fragment.toml +3 -0
- texsmith/fragments/keystrokes/ts-keystrokes.jinja.sty +20 -0
- texsmith/fragments/todolist/__init__.py +71 -0
- texsmith/fragments/todolist/fragment.toml +3 -0
- texsmith/fragments/todolist/ts-todolist.jinja.sty +21 -0
- texsmith/fragments/typesetting/__init__.py +214 -0
- texsmith/fragments/typesetting/fragment.toml +3 -0
- texsmith/fragments/typesetting/ts-typesetting.tex.jinja +81 -0
- texsmith/index.py +26 -0
- texsmith/plugins/__init__.py +14 -0
- texsmith/progressbar.py +8 -0
- texsmith/quotes.py +40 -0
- texsmith/smart_dashes.py +66 -0
- texsmith/templates/__init__.py +3 -0
- texsmith/templates/article/README.md +33 -0
- texsmith/templates/article/__init__.py +281 -0
- texsmith/templates/article/template/manifest.toml +125 -0
- texsmith/templates/article/template/mermaid-config.json +18 -0
- texsmith/templates/article/template/template.tex +74 -0
- texsmith/templates/book/README.md +26 -0
- texsmith/templates/book/__init__.py +75 -0
- texsmith/templates/book/overrides/codeblock.tex +7 -0
- texsmith/templates/book/overrides/codeinline.tex +1 -0
- texsmith/templates/book/template/fixtoc.sty +81 -0
- texsmith/templates/book/template/manifest.toml +198 -0
- texsmith/templates/book/template/template.tex +314 -0
- texsmith/templates/common/__init__.py +1 -0
- texsmith/templates/common/latexmkrc +46 -0
- texsmith/templates/letter/README.md +59 -0
- texsmith/templates/letter/__init__.py +495 -0
- texsmith/templates/letter/demo.md +31 -0
- texsmith/templates/letter/fonts/modernline bold.otf +0 -0
- texsmith/templates/letter/fonts/modernline.otf +0 -0
- texsmith/templates/letter/manifest.toml +197 -0
- texsmith/templates/letter/template/callouts.jinja.sty +290 -0
- texsmith/templates/letter/template/template.tex +123 -0
- texsmith/templates/snippet/README.md +21 -0
- texsmith/templates/snippet/__init__.py +80 -0
- texsmith/templates/snippet/template/manifest.toml +66 -0
- texsmith/templates/snippet/template/template.tex +47 -0
- texsmith/texlogos.py +15 -0
- texsmith/ui/__init__.py +6 -0
- texsmith/ui/cli/__init__.py +22 -0
- texsmith/ui/cli/_options.py +338 -0
- texsmith/ui/cli/app.py +65 -0
- texsmith/ui/cli/bibliography.py +300 -0
- texsmith/ui/cli/commands/__init__.py +14 -0
- texsmith/ui/cli/commands/render.py +1128 -0
- texsmith/ui/cli/commands/templates.py +397 -0
- texsmith/ui/cli/diagnostics.py +36 -0
- texsmith/ui/cli/presenter.py +663 -0
- texsmith/ui/cli/state.py +263 -0
- texsmith/ui/cli/utils.py +235 -0
- texsmith-0.0.2.dev0.dist-info/METADATA +187 -0
- texsmith-0.0.2.dev0.dist-info/RECORD +252 -0
- texsmith-0.0.2.dev0.dist-info/WHEEL +4 -0
- texsmith-0.0.2.dev0.dist-info/entry_points.txt +22 -0
- texsmith-0.0.2.dev0.dist-info/licenses/LICENSE.md +21 -0
texsmith/ui/cli/state.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Shared CLI state management utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from contextvars import ContextVar
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
import sys
|
|
9
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"CLIState",
|
|
20
|
+
"debug_enabled",
|
|
21
|
+
"emit_error",
|
|
22
|
+
"emit_warning",
|
|
23
|
+
"ensure_rich_compat",
|
|
24
|
+
"get_cli_state",
|
|
25
|
+
"render_message",
|
|
26
|
+
"set_cli_state",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def ensure_rich_compat() -> None:
|
|
31
|
+
"""Patch Rich stub modules provided by tests to expose required attributes."""
|
|
32
|
+
import importlib.machinery
|
|
33
|
+
import sys as _sys
|
|
34
|
+
import types
|
|
35
|
+
|
|
36
|
+
rich_mod = _sys.modules.get("rich")
|
|
37
|
+
if rich_mod is None:
|
|
38
|
+
return
|
|
39
|
+
if getattr(rich_mod, "__spec__", None) is None:
|
|
40
|
+
rich_mod.__spec__ = importlib.machinery.ModuleSpec("rich", loader=None)
|
|
41
|
+
|
|
42
|
+
is_stub = getattr(rich_mod, "__file__", None) is None
|
|
43
|
+
|
|
44
|
+
if is_stub:
|
|
45
|
+
try:
|
|
46
|
+
import typer.core as typer_core
|
|
47
|
+
|
|
48
|
+
cast(Any, typer_core).HAS_RICH = False
|
|
49
|
+
except ImportError: # pragma: no cover - typer not available
|
|
50
|
+
pass
|
|
51
|
+
try:
|
|
52
|
+
import typer.main as typer_main
|
|
53
|
+
|
|
54
|
+
cast(Any, typer_main).HAS_RICH = False
|
|
55
|
+
except ImportError: # pragma: no cover - typer not available
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
if not hasattr(rich_mod, "box"):
|
|
59
|
+
box_module = types.ModuleType("rich.box")
|
|
60
|
+
cast(Any, box_module).SQUARE = object()
|
|
61
|
+
cast(Any, box_module).MINIMAL_DOUBLE_HEAD = object()
|
|
62
|
+
cast(Any, box_module).SIMPLE = object()
|
|
63
|
+
cast(Any, rich_mod).box = box_module
|
|
64
|
+
_sys.modules.setdefault("rich.box", box_module)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(slots=True)
|
|
68
|
+
class CLIState:
|
|
69
|
+
"""Shared state controlling CLI diagnostics."""
|
|
70
|
+
|
|
71
|
+
verbosity: int = 0
|
|
72
|
+
show_tracebacks: bool = False
|
|
73
|
+
events: dict[str, list[dict[str, Any]]] = field(default_factory=dict, init=False)
|
|
74
|
+
_console: Console | None = field(default=None, init=False, repr=False)
|
|
75
|
+
_err_console: Console | None = field(default=None, init=False, repr=False)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def console(self) -> Console:
|
|
79
|
+
"""Return a lazily instantiated stdout console."""
|
|
80
|
+
from rich.console import Console
|
|
81
|
+
|
|
82
|
+
current = getattr(self._console, "file", None)
|
|
83
|
+
if self._console is None or current is not sys.stdout:
|
|
84
|
+
try:
|
|
85
|
+
self._console = Console(file=sys.stdout)
|
|
86
|
+
except TypeError: # pragma: no cover - stub Console fallback
|
|
87
|
+
self._console = Console()
|
|
88
|
+
return self._console
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def err_console(self) -> Console:
|
|
92
|
+
"""Return a lazily instantiated stderr console."""
|
|
93
|
+
from rich.console import Console
|
|
94
|
+
|
|
95
|
+
current = getattr(self._err_console, "file", None)
|
|
96
|
+
if self._err_console is None or current is not sys.stderr:
|
|
97
|
+
try:
|
|
98
|
+
self._err_console = Console(file=sys.stderr, highlight=False)
|
|
99
|
+
except TypeError: # pragma: no cover - stub Console fallback
|
|
100
|
+
self._err_console = Console()
|
|
101
|
+
return self._err_console
|
|
102
|
+
|
|
103
|
+
def record_event(self, name: str, payload: Mapping[str, Any] | None = None) -> None:
|
|
104
|
+
"""Store a structured diagnostic event for later presentation."""
|
|
105
|
+
entry = dict(payload or {})
|
|
106
|
+
self.events.setdefault(name, []).append(entry)
|
|
107
|
+
|
|
108
|
+
def consume_events(self, name: str) -> list[dict[str, Any]]:
|
|
109
|
+
"""Retrieve and clear events for the given name."""
|
|
110
|
+
return self.events.pop(name, [])
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
_STATE_VAR: ContextVar[CLIState | None] = ContextVar("texsmith_cli_state", default=None)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_cli_state(
|
|
117
|
+
ctx: typer.Context | click.Context | None = None,
|
|
118
|
+
*,
|
|
119
|
+
create: bool = True,
|
|
120
|
+
) -> CLIState:
|
|
121
|
+
"""Return the CLI state associated with the active Typer context."""
|
|
122
|
+
if ctx is None:
|
|
123
|
+
try:
|
|
124
|
+
candidate = click.get_current_context(silent=True)
|
|
125
|
+
except RuntimeError:
|
|
126
|
+
candidate = None
|
|
127
|
+
if isinstance(candidate, typer.Context):
|
|
128
|
+
ctx = candidate
|
|
129
|
+
|
|
130
|
+
state: CLIState | None = None
|
|
131
|
+
|
|
132
|
+
if isinstance(ctx, typer.Context):
|
|
133
|
+
current_ctx: typer.Context | None = ctx
|
|
134
|
+
while current_ctx is not None:
|
|
135
|
+
obj = getattr(current_ctx, "obj", None)
|
|
136
|
+
if isinstance(obj, CLIState):
|
|
137
|
+
state = obj
|
|
138
|
+
break
|
|
139
|
+
current_ctx = getattr(current_ctx, "parent", None)
|
|
140
|
+
if state is None and create:
|
|
141
|
+
state = CLIState()
|
|
142
|
+
current_ctx = ctx
|
|
143
|
+
current_ctx.obj = state
|
|
144
|
+
if state is not None:
|
|
145
|
+
_STATE_VAR.set(state)
|
|
146
|
+
|
|
147
|
+
if state is None:
|
|
148
|
+
fallback = _STATE_VAR.get(None)
|
|
149
|
+
if fallback is None:
|
|
150
|
+
if not create:
|
|
151
|
+
raise RuntimeError("CLI state is not initialised for this context.")
|
|
152
|
+
fallback = CLIState()
|
|
153
|
+
_STATE_VAR.set(fallback)
|
|
154
|
+
state = fallback
|
|
155
|
+
|
|
156
|
+
return state
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def set_cli_state(
|
|
160
|
+
*,
|
|
161
|
+
ctx: typer.Context | None = None,
|
|
162
|
+
verbosity: int | None = None,
|
|
163
|
+
debug: bool | None = None,
|
|
164
|
+
) -> CLIState:
|
|
165
|
+
"""Update the CLI state, returning the current instance."""
|
|
166
|
+
state = get_cli_state(ctx)
|
|
167
|
+
if verbosity is not None:
|
|
168
|
+
state.verbosity = max(0, verbosity)
|
|
169
|
+
if debug is not None:
|
|
170
|
+
state.show_tracebacks = debug
|
|
171
|
+
return state
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _exception_chain(exc: BaseException) -> list[str]:
|
|
175
|
+
chain: list[str] = []
|
|
176
|
+
visited: set[int] = set()
|
|
177
|
+
current = exc.__cause__ or exc.__context__
|
|
178
|
+
while current is not None and id(current) not in visited:
|
|
179
|
+
visited.add(id(current))
|
|
180
|
+
chain.append(f"{type(current).__name__}: {current}")
|
|
181
|
+
current = current.__cause__ or current.__context__
|
|
182
|
+
return chain
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def render_message(
|
|
186
|
+
level: str,
|
|
187
|
+
message: str,
|
|
188
|
+
*,
|
|
189
|
+
exception: BaseException | None = None,
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Render a formatted message to the console, including optional diagnostics."""
|
|
192
|
+
state = get_cli_state()
|
|
193
|
+
|
|
194
|
+
if level == "info":
|
|
195
|
+
# Use a neutral console log for info to align with pipeline logging.
|
|
196
|
+
console = state.console
|
|
197
|
+
if getattr(console, "log", None):
|
|
198
|
+
console.log(message)
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
from rich.text import Text
|
|
202
|
+
|
|
203
|
+
style = "red" if level == "error" else "yellow"
|
|
204
|
+
label_style = f"bold {style}"
|
|
205
|
+
if hasattr(Text, "assemble"):
|
|
206
|
+
text = Text.assemble((f"{level}: ", label_style), (message, style))
|
|
207
|
+
else: # pragma: no cover - stub Text fallback
|
|
208
|
+
text = Text()
|
|
209
|
+
text.append(f"{level}: ")
|
|
210
|
+
text.append(message)
|
|
211
|
+
extra_lines: list[str] = []
|
|
212
|
+
if exception is not None and state.verbosity >= 1:
|
|
213
|
+
detail = str(exception).strip()
|
|
214
|
+
if detail and detail not in message:
|
|
215
|
+
extra_lines.append(detail)
|
|
216
|
+
extra_lines.append(f"type: {type(exception).__name__}")
|
|
217
|
+
notes = getattr(exception, "__notes__", None)
|
|
218
|
+
if notes:
|
|
219
|
+
extra_lines.extend(str(note) for note in notes)
|
|
220
|
+
if state.verbosity >= 2:
|
|
221
|
+
chain = _exception_chain(exception)
|
|
222
|
+
if chain:
|
|
223
|
+
extra_lines.append("caused by:")
|
|
224
|
+
extra_lines.extend(f" {entry}" for entry in chain)
|
|
225
|
+
if state.verbosity >= 3:
|
|
226
|
+
extra_lines.append(f"repr: {exception!r}")
|
|
227
|
+
|
|
228
|
+
if extra_lines:
|
|
229
|
+
if hasattr(text, "append"):
|
|
230
|
+
text.append("\n")
|
|
231
|
+
if hasattr(Text, "assemble"):
|
|
232
|
+
text.append("\n".join(extra_lines), style=style)
|
|
233
|
+
else: # pragma: no cover - stub Text fallback
|
|
234
|
+
text.append("\n".join(extra_lines))
|
|
235
|
+
else: # pragma: no cover - fallback if text is a plain string
|
|
236
|
+
text = f"{text}\n" + "\n".join(extra_lines)
|
|
237
|
+
|
|
238
|
+
console = state.err_console if level != "info" else state.console
|
|
239
|
+
if type(console).__name__.startswith("_Stub"): # pragma: no cover - stub Console fallback
|
|
240
|
+
target = sys.stderr if level != "info" else sys.stdout
|
|
241
|
+
print(text if isinstance(text, str) else str(text), file=target)
|
|
242
|
+
else:
|
|
243
|
+
console.print(text)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def emit_warning(message: str, *, exception: BaseException | None = None) -> None:
|
|
247
|
+
"""Log a warning-level message to stderr respecting verbosity settings."""
|
|
248
|
+
render_message("warning", message, exception=exception)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def emit_error(message: str, *, exception: BaseException | None = None) -> None:
|
|
252
|
+
"""Log an error-level message to stderr respecting verbosity settings."""
|
|
253
|
+
if exception is not None and getattr(exception, "_texsmith_logged", False):
|
|
254
|
+
return
|
|
255
|
+
render_message("error", message, exception=exception)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def debug_enabled() -> bool:
|
|
259
|
+
"""Return whether full tracebacks should be displayed."""
|
|
260
|
+
try:
|
|
261
|
+
return get_cli_state(create=False).show_tracebacks
|
|
262
|
+
except RuntimeError:
|
|
263
|
+
return False
|
texsmith/ui/cli/utils.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Auxiliary helpers used by CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from texsmith.api.service import SlotAssignment
|
|
11
|
+
from texsmith.core.conversion.inputs import DOCUMENT_SELECTOR_SENTINEL
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_slot_option(values: Iterable[str] | None) -> dict[str, str]:
|
|
15
|
+
"""Parse CLI slot overrides declared as 'slot:Section' pairs."""
|
|
16
|
+
overrides: dict[str, str] = {}
|
|
17
|
+
if not values:
|
|
18
|
+
return overrides
|
|
19
|
+
|
|
20
|
+
for raw in values:
|
|
21
|
+
if not isinstance(raw, str):
|
|
22
|
+
continue
|
|
23
|
+
entry = raw.strip()
|
|
24
|
+
if not entry:
|
|
25
|
+
continue
|
|
26
|
+
if ":" not in entry:
|
|
27
|
+
raise ValueError(f"Invalid slot override '{raw}', expected format 'slot:Section'.")
|
|
28
|
+
slot_name, selector = entry.split(":", 1)
|
|
29
|
+
slot_name = slot_name.strip()
|
|
30
|
+
selector = selector.strip()
|
|
31
|
+
if not slot_name or not selector:
|
|
32
|
+
raise ValueError(f"Invalid slot override '{raw}', expected format 'slot:Section'.")
|
|
33
|
+
overrides[slot_name] = selector
|
|
34
|
+
|
|
35
|
+
return overrides
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def determine_output_target(
|
|
39
|
+
template_selected: bool,
|
|
40
|
+
documents: list[Path],
|
|
41
|
+
output_option: Path | None,
|
|
42
|
+
) -> tuple[str, Path | None]:
|
|
43
|
+
"""Infer where conversion output should be written based on CLI arguments."""
|
|
44
|
+
if template_selected:
|
|
45
|
+
if output_option is None:
|
|
46
|
+
base_dir = documents[0].parent if documents else Path()
|
|
47
|
+
return "template", (base_dir / "build")
|
|
48
|
+
suffix = output_option.suffix.lower()
|
|
49
|
+
if suffix == ".pdf":
|
|
50
|
+
return "template-pdf", output_option
|
|
51
|
+
if output_option.exists() and output_option.is_file():
|
|
52
|
+
raise typer.BadParameter("Template output must be a directory.")
|
|
53
|
+
if suffix:
|
|
54
|
+
raise typer.BadParameter("Template output must be a directory path.")
|
|
55
|
+
return "template", output_option
|
|
56
|
+
|
|
57
|
+
if output_option is None:
|
|
58
|
+
return "stdout", None
|
|
59
|
+
|
|
60
|
+
if output_option.exists() and output_option.is_dir():
|
|
61
|
+
return "directory", output_option
|
|
62
|
+
|
|
63
|
+
if output_option.suffix:
|
|
64
|
+
return "file", output_option
|
|
65
|
+
|
|
66
|
+
return "directory", output_option
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def write_output_file(target: Path, content: str) -> None:
|
|
70
|
+
"""Persist LaTeX content to disk, creating parent directories as needed."""
|
|
71
|
+
try:
|
|
72
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
target.write_text(content, encoding="utf-8")
|
|
74
|
+
except OSError as exc: # pragma: no cover - filesystem errors
|
|
75
|
+
raise OSError(f"Failed to write LaTeX output to '{target}': {exc}") from exc
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def looks_like_document_path(candidate: str) -> bool:
|
|
79
|
+
"""Return True when the string has an extension resembling a document."""
|
|
80
|
+
suffix = Path(candidate).suffix.lower()
|
|
81
|
+
return bool(suffix) and suffix in {
|
|
82
|
+
".md",
|
|
83
|
+
".markdown",
|
|
84
|
+
".mdown",
|
|
85
|
+
".mkd",
|
|
86
|
+
".html",
|
|
87
|
+
".htm",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def normalise_selector(selector: str | None) -> str | None:
|
|
92
|
+
"""Strip surrounding quotes and whitespace from user-provided selectors."""
|
|
93
|
+
if selector is None:
|
|
94
|
+
return None
|
|
95
|
+
candidate = selector.strip()
|
|
96
|
+
if len(candidate) >= 2 and candidate[0] == candidate[-1] and candidate[0] in {'"', "'"}:
|
|
97
|
+
candidate = candidate[1:-1].strip()
|
|
98
|
+
return candidate or None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_cli_slot_tokens(
|
|
102
|
+
values: Iterable[str] | None,
|
|
103
|
+
) -> list[tuple[str, str | None, str | None, str]]:
|
|
104
|
+
"""Tokenise slot overrides into (slot, path, selector, raw) tuples."""
|
|
105
|
+
tokens: list[tuple[str, str | None, str | None, str]] = []
|
|
106
|
+
if not values:
|
|
107
|
+
return tokens
|
|
108
|
+
|
|
109
|
+
for raw in values:
|
|
110
|
+
if not isinstance(raw, str):
|
|
111
|
+
continue
|
|
112
|
+
entry = raw.strip()
|
|
113
|
+
if not entry:
|
|
114
|
+
continue
|
|
115
|
+
if ":" not in entry:
|
|
116
|
+
raise typer.BadParameter(
|
|
117
|
+
f"Invalid slot override '{raw}', expected format "
|
|
118
|
+
f"'slot:selector' or 'slot:file[:selector]'."
|
|
119
|
+
)
|
|
120
|
+
slot_name, remainder = entry.split(":", 1)
|
|
121
|
+
slot_name = slot_name.strip()
|
|
122
|
+
remainder = remainder.strip()
|
|
123
|
+
if not slot_name or not remainder:
|
|
124
|
+
raise typer.BadParameter(
|
|
125
|
+
f"Invalid slot override '{raw}', expected format "
|
|
126
|
+
f"'slot:selector' or 'slot:file[:selector]'."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
path_hint: str | None
|
|
130
|
+
selector_value: str | None
|
|
131
|
+
if ":" in remainder:
|
|
132
|
+
path_part, selector_part = remainder.split(":", 1)
|
|
133
|
+
path_part = path_part.strip()
|
|
134
|
+
selector_value = normalise_selector(selector_part)
|
|
135
|
+
path_hint = path_part or None
|
|
136
|
+
else:
|
|
137
|
+
if looks_like_document_path(remainder):
|
|
138
|
+
path_hint = remainder
|
|
139
|
+
selector_value = None
|
|
140
|
+
else:
|
|
141
|
+
path_hint = None
|
|
142
|
+
selector_value = normalise_selector(remainder)
|
|
143
|
+
|
|
144
|
+
tokens.append((slot_name, path_hint, selector_value, raw))
|
|
145
|
+
|
|
146
|
+
return tokens
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def resolve_slot_assignments(
|
|
150
|
+
tokens: list[tuple[str, str | None, str | None, str]],
|
|
151
|
+
documents: list[Path],
|
|
152
|
+
) -> dict[Path, list[SlotAssignment]]:
|
|
153
|
+
"""Resolve parsed slot tokens against provided documents."""
|
|
154
|
+
assignments: dict[Path, list[SlotAssignment]] = {doc: [] for doc in documents}
|
|
155
|
+
if not tokens:
|
|
156
|
+
return assignments
|
|
157
|
+
|
|
158
|
+
resolved_index = {doc.resolve(): doc for doc in documents}
|
|
159
|
+
name_index: dict[str, list[Path]] = {}
|
|
160
|
+
for doc in documents:
|
|
161
|
+
name_index.setdefault(doc.name, []).append(doc)
|
|
162
|
+
|
|
163
|
+
for slot_name, path_hint, selector_value, raw in tokens:
|
|
164
|
+
target_doc: Path | None = None
|
|
165
|
+
if path_hint is None:
|
|
166
|
+
if len(documents) == 1:
|
|
167
|
+
target_doc = documents[0]
|
|
168
|
+
else:
|
|
169
|
+
raise typer.BadParameter(
|
|
170
|
+
f"slot override '{raw}' requires a document "
|
|
171
|
+
"path when multiple inputs are provided."
|
|
172
|
+
)
|
|
173
|
+
else:
|
|
174
|
+
candidate_path = Path(path_hint)
|
|
175
|
+
resolved_candidate: Path | None = None
|
|
176
|
+
try:
|
|
177
|
+
base = (
|
|
178
|
+
candidate_path if candidate_path.is_absolute() else Path.cwd() / candidate_path
|
|
179
|
+
)
|
|
180
|
+
resolved_candidate = base.resolve()
|
|
181
|
+
except OSError:
|
|
182
|
+
resolved_candidate = None
|
|
183
|
+
|
|
184
|
+
if resolved_candidate is not None and resolved_candidate in resolved_index:
|
|
185
|
+
target_doc = resolved_index[resolved_candidate]
|
|
186
|
+
else:
|
|
187
|
+
matches = name_index.get(candidate_path.name, [])
|
|
188
|
+
if len(matches) == 1:
|
|
189
|
+
target_doc = matches[0]
|
|
190
|
+
elif len(matches) > 1:
|
|
191
|
+
raise typer.BadParameter(
|
|
192
|
+
f"slot override '{raw}' is ambiguous; multiple "
|
|
193
|
+
f"documents match '{candidate_path.name}'."
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if target_doc is None:
|
|
197
|
+
raise typer.BadParameter(f"slot override '{raw}' does not match any provided document.")
|
|
198
|
+
|
|
199
|
+
selector_clean = selector_value
|
|
200
|
+
include_document = False
|
|
201
|
+
if selector_clean is None:
|
|
202
|
+
include_document = True
|
|
203
|
+
else:
|
|
204
|
+
token_lower = selector_clean.strip().lower()
|
|
205
|
+
if token_lower in {"*", DOCUMENT_SELECTOR_SENTINEL.lower()}:
|
|
206
|
+
include_document = True
|
|
207
|
+
selector_clean = None
|
|
208
|
+
|
|
209
|
+
assignments[target_doc].append(
|
|
210
|
+
SlotAssignment(
|
|
211
|
+
slot=slot_name, selector=selector_clean, include_document=include_document
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return assignments
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def organise_slot_overrides(
|
|
219
|
+
values: Iterable[str] | None,
|
|
220
|
+
documents: list[Path],
|
|
221
|
+
) -> tuple[dict[Path, dict[str, str]], dict[Path, list[SlotAssignment]]]:
|
|
222
|
+
"""Produce slot selector overrides and assignments for downstream processing."""
|
|
223
|
+
tokens = parse_cli_slot_tokens(values)
|
|
224
|
+
assignments = resolve_slot_assignments(tokens, documents)
|
|
225
|
+
|
|
226
|
+
slot_overrides: dict[Path, dict[str, str]] = {}
|
|
227
|
+
for doc, entries in assignments.items():
|
|
228
|
+
if not entries:
|
|
229
|
+
continue
|
|
230
|
+
mapping = slot_overrides.setdefault(doc, {})
|
|
231
|
+
for entry in entries:
|
|
232
|
+
if entry.selector is not None:
|
|
233
|
+
mapping[entry.slot] = entry.selector
|
|
234
|
+
|
|
235
|
+
return slot_overrides, assignments
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: texsmith
|
|
3
|
+
Version: 0.0.2.dev0
|
|
4
|
+
Summary: A Markdown to LaTeX converter.
|
|
5
|
+
Project-URL: Homepage, https://github.com/yves-chevallier/texsmith
|
|
6
|
+
Author-email: Yves Chevallier <yves.chevalier@heig-vd.ch>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE.md
|
|
9
|
+
Keywords: convert,extensions,latex,markdown,mkdocs
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: beautifulsoup4>=4.14.2
|
|
19
|
+
Requires-Dist: emoji>=2.12.1
|
|
20
|
+
Requires-Dist: jinja2>=3.1.6
|
|
21
|
+
Requires-Dist: markdown>=3.7
|
|
22
|
+
Requires-Dist: pint>=0.24
|
|
23
|
+
Requires-Dist: playwright>=1.56.0
|
|
24
|
+
Requires-Dist: pybtex>=0.24.0
|
|
25
|
+
Requires-Dist: pydantic>=2.7.0
|
|
26
|
+
Requires-Dist: pygments>=2.18.0
|
|
27
|
+
Requires-Dist: pylatexenc>=2.10
|
|
28
|
+
Requires-Dist: pymdown-extensions>=10.9
|
|
29
|
+
Requires-Dist: pymupdf>=1.24.3
|
|
30
|
+
Requires-Dist: python-markdown-math>=0.9
|
|
31
|
+
Requires-Dist: python-slugify>=8.0.4
|
|
32
|
+
Requires-Dist: pyxindy>=0.0.3
|
|
33
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
34
|
+
Requires-Dist: rich>=13.9.0
|
|
35
|
+
Requires-Dist: tomli>=2.0.1; python_version < '3.11'
|
|
36
|
+
Requires-Dist: typer>=0.12.5
|
|
37
|
+
Requires-Dist: unicodeblocks>=0.3
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
|
|
40
|
+
# TeXSmith
|
|
41
|
+
|
|
42
|
+
[](https://github.com/yves-chevallier/texsmith/actions/workflows/ci.yml)
|
|
43
|
+
[](https://codecov.io/gh/yves-chevallier/texsmith)
|
|
44
|
+
[](https://pypi.org/project/texsmith/)
|
|
45
|
+
[](https://github.com/yves-chevallier/texsmith)
|
|
46
|
+
[](https://pypi.org/project/texsmith/)
|
|
47
|
+
[](LICENSE.md)
|
|
48
|
+
|
|
49
|
+

|
|
50
|
+

|
|
51
|
+

|
|
52
|
+
|
|
53
|
+
TeXSmith is a [Python](https://www.python.org/) package and CLI tool to convert **Markdown** or **HTML** documents into LaTeX format. It is designed to be extensible via templates and integrates with [MkDocs](https://www.mkdocs.org/) for generating printable documents from documentation sites.
|
|
54
|
+
|
|
55
|
+
<p align="center">
|
|
56
|
+
<img src="docs/assets/ts-logo.svg" width="70%" />
|
|
57
|
+
</p>
|
|
58
|
+
|
|
59
|
+
## TL;DR
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install texsmith
|
|
63
|
+
texsmith input.md input.bib -o build/ --build
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Key features
|
|
67
|
+
|
|
68
|
+
- **MkDocs-native Markdown** – Ships with the same Material + pymdown extension stack you use in MkDocs, so tabs, callouts, annotations, tooltips, and data tables survive the conversion.
|
|
69
|
+
- **Template-first runtime** – Bundle multiple fragments into slots, merge front matter metadata, and emit LaTeX projects ready for Tectonic or latexmk with Docker-friendly manifests.
|
|
70
|
+
- **CLI and Python parity** – The Typer-powered CLI wraps the same ConversionService you can consume as a library, making CI/CD and notebooks behave like local runs.
|
|
71
|
+
- **Actionable diagnostics** – Structured emitters, verbosity switches, and `--debug` traces keep LaTeX issues debuggable even in automated pipelines.
|
|
72
|
+
- **Extensible converters** – Override Markdown parsers, hook into RenderPhase handlers, or ship diagram transformers (Mermaid, Draw.io, Svgbob) that plug directly into the engine.
|
|
73
|
+
|
|
74
|
+
## Installation
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# uv (recommended for isolated CLI installs)
|
|
78
|
+
uv tool install texsmith
|
|
79
|
+
|
|
80
|
+
# pip / pipx
|
|
81
|
+
pip install texsmith
|
|
82
|
+
pipx install texsmith
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
TeXSmith targets Python 3.10+ and expects a LaTeX distribution (TeX Live, MiKTeX, or MacTeX) when you pass `--build`. Optional converters such as Mermaid rely on Docker (`minlag/mermaid-cli`) unless you register custom handlers.
|
|
86
|
+
|
|
87
|
+
### Platform notes
|
|
88
|
+
|
|
89
|
+
- **Linux** – Install TeX Live (full) via your package manager or `install-tl`. When running inside CI containers, cache `~/.texliveYY` so repeated latexmk runs stay fast—or use the default Tectonic engine to minimise setup.
|
|
90
|
+
- **macOS** – Use [MacTeX](https://www.tug.org/mactex/) or `BasicTeX` plus the tlmgr packages reported by `texsmith --template <name> --template-info`. Homebrew’s `mactex` cask works well when paired with `uv`.
|
|
91
|
+
- **Windows** – TeXSmith runs via native Python or WSL. For PDF builds we recommend [MiKTeX](https://miktex.org/) + PowerShell, or WSL2 with TeX Live and Docker Desktop (needed for Mermaid).
|
|
92
|
+
- **Docker workflows** – Run `texsmith --build` inside a TeX Live container, mounting your project plus the template directory. Copy tlmgr prerequisites from `--template-info` so images compile without network access.
|
|
93
|
+
|
|
94
|
+
See the [Getting Started guide](docs/guide/getting-started.md) for a step-by-step walkthrough, verification commands, and Python API examples.
|
|
95
|
+
|
|
96
|
+
## Documentation
|
|
97
|
+
|
|
98
|
+
Browse the full documentation at [yves-chevallier.github.io/texsmith](https://yves-chevallier.github.io/texsmith) for:
|
|
99
|
+
|
|
100
|
+
- [Getting Started](docs/guide/getting-started.md): installation, prerequisites, and API snippets.
|
|
101
|
+
- [CLI Reference](docs/cli/index.md): every flag, including the template inspector.
|
|
102
|
+
- [Markdown Directory](docs/markdown/supported.md): exhaustive syntax coverage.
|
|
103
|
+
- [API Reference](docs/api/index.md): ConversionService, TemplateSession, handlers, and plugins.
|
|
104
|
+
- [Template Cookbook](docs/guide/template-cookbook.md): practical recipes for slots, overrides, packaging, and testing.
|
|
105
|
+
- [Release Notes & Compatibility](docs/guide/release-notes.md): TeXSmith feature history plus template/TeX Live requirements.
|
|
106
|
+
|
|
107
|
+
## Template catalog
|
|
108
|
+
|
|
109
|
+
Inspect templates by name or path to understand their slots, metadata attributes, TeX Live requirements, and declared assets:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
texsmith --template article --template-info
|
|
113
|
+
# or inspect a local path
|
|
114
|
+
texsmith --template ./templates/nature --template-info
|
|
115
|
+
texsmith templates # view discovery order across built-ins/packages/local/home
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Use this command before wiring slots or when you need to confirm which tlmgr packages to preinstall in CI.
|
|
119
|
+
|
|
120
|
+
## Examples
|
|
121
|
+
|
|
122
|
+
The `examples/` directory includes reproducible demos:
|
|
123
|
+
|
|
124
|
+
- `examples/paper` – end-to-end render with bibliographies and latexmk (or Tectonic with `--engine tectonic`).
|
|
125
|
+
- `examples/diagrams` – Mermaid and Draw.io conversions.
|
|
126
|
+
- `examples/markdown` – exhaustive Markdown showcase with diagram/front-matter overrides.
|
|
127
|
+
|
|
128
|
+
Each example ships build instructions inside [`docs/examples/index.md`](docs/examples/index.md).
|
|
129
|
+
|
|
130
|
+
## Project layout
|
|
131
|
+
|
|
132
|
+
The source tree is organised around three top-level namespaces:
|
|
133
|
+
|
|
134
|
+
- `texsmith.core` contains the conversion pipeline, document models, diagnostics, and template helpers.
|
|
135
|
+
- `texsmith.adapters` hosts infrastructure integrations such as Markdown parsing, LaTeX rendering, Docker helpers, and transformer utilities.
|
|
136
|
+
- `texsmith.ui` provides end-user interfaces, including the Typer-powered CLI.
|
|
137
|
+
|
|
138
|
+
## Core architecture highlights
|
|
139
|
+
|
|
140
|
+
- `ConversionService` encapsulates the orchestration that previously lived in `texsmith.api.service` helpers. Provide a `ConversionRequest` and receive a `ConversionResponse` with rendered bundles and diagnostics.
|
|
141
|
+
- `TemplateRenderer` now owns slot aggregation and LaTeX assembly. `TemplateSession` focuses on session state, template options, and bibliography tracking.
|
|
142
|
+
- `DocumentSlots` unify slot directives from front matter, CLI flags, and programmatic overrides. Every entry point now speaks the same data model.
|
|
143
|
+
- `DiagnosticEmitter` replaces ad-hoc callback bags so warnings, errors, and structured events flow through a predictable interface (CLI uses `CliEmitter`; libraries can plug in their own).
|
|
144
|
+
- Fragments use a `BaseFragment` + config dataclass model (`fragment = YourFragment()` export referenced by `fragment.toml` entrypoints). No legacy factories remain.
|
|
145
|
+
|
|
146
|
+
### Programmatic conversions with `ConversionService`
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from pathlib import Path
|
|
150
|
+
|
|
151
|
+
from texsmith.api.service import ConversionRequest, ConversionService
|
|
152
|
+
|
|
153
|
+
service = ConversionService()
|
|
154
|
+
request = ConversionRequest(
|
|
155
|
+
documents=[Path("docs/index.html")],
|
|
156
|
+
bibliography_files=[Path("references.bib")],
|
|
157
|
+
template="article",
|
|
158
|
+
render_dir=Path("build"),
|
|
159
|
+
)
|
|
160
|
+
prepared = service.prepare_documents(request)
|
|
161
|
+
response = service.execute(request, prepared=prepared)
|
|
162
|
+
|
|
163
|
+
print("Main TeX:", response.render_result.main_tex_path)
|
|
164
|
+
print("Diagnostics:", [event.name for event in response.diagnostics])
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
If you only need a quick conversion, the high-level helpers (`texsmith.Document`, `texsmith.convert_documents`, `texsmith.TemplateSession`) continue to work, but they now reuse the same ConversionService plumbing as the CLI.
|
|
168
|
+
|
|
169
|
+
> Refer to `UPGRADE.md` for release notes and migration guidance from earlier builds.
|
|
170
|
+
|
|
171
|
+
## Render engine phases
|
|
172
|
+
|
|
173
|
+
The rendering pipeline walks the BeautifulSoup tree four times. Each pass maps
|
|
174
|
+
to a value of `RenderPhase` so handlers can opt into the point where their
|
|
175
|
+
transform should fire:
|
|
176
|
+
|
|
177
|
+
- `RenderPhase.PRE`: Early normalisation. Use it to clean the DOM and
|
|
178
|
+
replace nodes before structural changes happen (e.g. unwrap unwanted tags,
|
|
179
|
+
turn inline `<code>` into LaTeX).
|
|
180
|
+
- `RenderPhase.BLOCK`: Block-level transformations once the tree structure
|
|
181
|
+
is stable. Typical consumers convert paragraphs, lists, or blockquotes into
|
|
182
|
+
LaTeX environments.
|
|
183
|
+
- `RenderPhase.INLINE`: Inline formatting where block layout is already
|
|
184
|
+
resolved. It is the right place for emphasis, inline math, or link handling.
|
|
185
|
+
- `RenderPhase.POST`: Final pass after children are processed. Use it for
|
|
186
|
+
tasks that depend on previous passes such as heading numbering or emitting
|
|
187
|
+
collected assets.
|