amethyst-cli 0.1.0__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.
- amethyst/__init__.py +5 -0
- amethyst/__main__.py +6 -0
- amethyst/cli.py +644 -0
- amethyst/config.py +387 -0
- amethyst/document.py +228 -0
- amethyst/errors.py +66 -0
- amethyst/ooxml.py +549 -0
- amethyst/parse/__init__.py +20 -0
- amethyst/parse/assets.py +106 -0
- amethyst/parse/frontmatter.py +62 -0
- amethyst/parse/markdown.py +49 -0
- amethyst/remote.py +236 -0
- amethyst/render/__init__.py +42 -0
- amethyst/render/base.py +85 -0
- amethyst/render/docx.py +1060 -0
- amethyst/render/furniture.py +112 -0
- amethyst/render/highlight.py +315 -0
- amethyst/render/html.py +266 -0
- amethyst/render/pdf.py +219 -0
- amethyst/theme/__init__.py +493 -0
- amethyst/theme/builtin/academic.toml +45 -0
- amethyst/theme/builtin/css/base.css +361 -0
- amethyst/theme/builtin/default.toml +42 -0
- amethyst/theme/builtin/github.toml +44 -0
- amethyst/theme/to_css.py +182 -0
- amethyst/theme/to_docx.py +651 -0
- amethyst_cli-0.1.0.dist-info/METADATA +293 -0
- amethyst_cli-0.1.0.dist-info/RECORD +31 -0
- amethyst_cli-0.1.0.dist-info/WHEEL +4 -0
- amethyst_cli-0.1.0.dist-info/entry_points.txt +2 -0
- amethyst_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
amethyst/config.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""Settings that come from somewhere other than the command line.
|
|
2
|
+
|
|
3
|
+
A conversion is described in four places, and they are read in this order,
|
|
4
|
+
each one overriding the last:
|
|
5
|
+
|
|
6
|
+
1. the defaults declared below,
|
|
7
|
+
2. ``~/.config/amethyst/config.toml`` — how this person's documents look,
|
|
8
|
+
3. ``./amethyst.toml`` — how *these* documents look,
|
|
9
|
+
4. the document's own frontmatter,
|
|
10
|
+
5. the flags on the command line.
|
|
11
|
+
|
|
12
|
+
That order is the useful one: the further a statement is from the document, the
|
|
13
|
+
more general it is, and the thing said closest to the moment of conversion is
|
|
14
|
+
the thing that wins.
|
|
15
|
+
|
|
16
|
+
The tuple of :class:`Setting` below is the whole schema. It is what validates a
|
|
17
|
+
file, what rejects a misspelled key, and what ``amethyst init`` writes out with
|
|
18
|
+
its defaults — so adding a setting is adding one entry there and reading it in
|
|
19
|
+
:func:`_build`, and there is nowhere for a second, disagreeing list to hide.
|
|
20
|
+
That is deliberately the same shape the theme layer uses, where
|
|
21
|
+
``default.toml`` is both the defaults and the schema.
|
|
22
|
+
|
|
23
|
+
Two things do not come from here. The input path and the output path are
|
|
24
|
+
arguments rather than settings: a file that named them would convert the same
|
|
25
|
+
document however it was invoked. And the *format* can be set by a config file
|
|
26
|
+
but not by frontmatter — a document may reasonably say how it should look, but
|
|
27
|
+
where its output goes is the invocation's business, and reading it from the
|
|
28
|
+
document would mean parsing the document before the CLI could tell the user
|
|
29
|
+
they forgot ``-o``.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import os
|
|
35
|
+
import sys
|
|
36
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
from amethyst.errors import ConfigError
|
|
42
|
+
from amethyst.render.furniture import MAX_TOC_DEPTH
|
|
43
|
+
from amethyst.theme import is_theme_path
|
|
44
|
+
|
|
45
|
+
if sys.version_info >= (3, 11):
|
|
46
|
+
import tomllib
|
|
47
|
+
else: # pragma: no cover - 3.10 only
|
|
48
|
+
import tomli as tomllib
|
|
49
|
+
|
|
50
|
+
#: The config file a directory of documents carries, and the one ``init``
|
|
51
|
+
#: writes.
|
|
52
|
+
CONFIG_FILENAME = "amethyst.toml"
|
|
53
|
+
|
|
54
|
+
#: The per-user config file, under the base directory named by the XDG
|
|
55
|
+
#: convention — which is what uv, pip and most of this tool's neighbours
|
|
56
|
+
#: already follow on macOS as well as on Linux.
|
|
57
|
+
USER_CONFIG_PARTS = ("amethyst", "config.toml")
|
|
58
|
+
CONFIG_HOME = "XDG_CONFIG_HOME"
|
|
59
|
+
DEFAULT_CONFIG_HOME = Path.home() / ".config"
|
|
60
|
+
|
|
61
|
+
#: What a document's frontmatter may say about its own conversion. Everything
|
|
62
|
+
#: else in the frontmatter is metadata — a title, an author, a date — and is
|
|
63
|
+
#: left alone, which is why frontmatter cannot be checked for unknown keys the
|
|
64
|
+
#: way a config file can.
|
|
65
|
+
FRONTMATTER = "the document's frontmatter"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Setting:
|
|
70
|
+
"""One thing that can be said in a config file or in frontmatter."""
|
|
71
|
+
|
|
72
|
+
name: str
|
|
73
|
+
#: ``str``, ``bool`` or ``int``. A value of another type is refused rather
|
|
74
|
+
#: than coerced: a page size written as a number is a mistake, not a size.
|
|
75
|
+
kind: type
|
|
76
|
+
default: Any
|
|
77
|
+
#: The one-line explanation ``amethyst init`` writes above the key.
|
|
78
|
+
help: str
|
|
79
|
+
#: Values a string setting may take, when there are only a few of them.
|
|
80
|
+
choices: tuple[str, ...] | None = None
|
|
81
|
+
minimum: int | None = None
|
|
82
|
+
maximum: int | None = None
|
|
83
|
+
#: When set, whether a value names a file — and so should be resolved
|
|
84
|
+
#: against the directory of whatever declared it rather than the working
|
|
85
|
+
#: directory. A predicate rather than a flag because the two settings that
|
|
86
|
+
#: take a path do not agree on what one looks like: a stylesheet is always
|
|
87
|
+
#: a file, and a bare word naming a theme is a builtin.
|
|
88
|
+
locate: Callable[[str], bool] | None = None
|
|
89
|
+
#: Whether a document may set this about itself.
|
|
90
|
+
frontmatter: bool = True
|
|
91
|
+
#: What ``init`` shows for a setting whose default is "say nothing".
|
|
92
|
+
example: str | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
SETTINGS: tuple[Setting, ...] = (
|
|
96
|
+
Setting(
|
|
97
|
+
"format",
|
|
98
|
+
str,
|
|
99
|
+
None,
|
|
100
|
+
"Output format when the output path does not imply one.",
|
|
101
|
+
choices=("pdf", "docx"),
|
|
102
|
+
frontmatter=False,
|
|
103
|
+
example='"pdf"',
|
|
104
|
+
),
|
|
105
|
+
Setting(
|
|
106
|
+
"theme",
|
|
107
|
+
str,
|
|
108
|
+
"default",
|
|
109
|
+
"Builtin theme name, or a path to a theme .toml.",
|
|
110
|
+
locate=is_theme_path,
|
|
111
|
+
),
|
|
112
|
+
Setting(
|
|
113
|
+
"css",
|
|
114
|
+
str,
|
|
115
|
+
None,
|
|
116
|
+
"Extra stylesheet, appended after everything else. PDF only.",
|
|
117
|
+
locate=lambda _value: True,
|
|
118
|
+
example='"house-style.css"',
|
|
119
|
+
),
|
|
120
|
+
Setting("toc", bool, False, "Open the document with a table of contents."),
|
|
121
|
+
Setting(
|
|
122
|
+
"toc_depth",
|
|
123
|
+
int,
|
|
124
|
+
3,
|
|
125
|
+
"Heading levels the contents lists.",
|
|
126
|
+
minimum=1,
|
|
127
|
+
maximum=MAX_TOC_DEPTH,
|
|
128
|
+
),
|
|
129
|
+
Setting(
|
|
130
|
+
"title_page", bool, False, "Open with a title page built from frontmatter."
|
|
131
|
+
),
|
|
132
|
+
Setting(
|
|
133
|
+
"page_size",
|
|
134
|
+
str,
|
|
135
|
+
None,
|
|
136
|
+
"Sheet size, overriding the theme's. A4, Letter, or a CSS size.",
|
|
137
|
+
example='"A4"',
|
|
138
|
+
),
|
|
139
|
+
Setting(
|
|
140
|
+
"margin",
|
|
141
|
+
str,
|
|
142
|
+
None,
|
|
143
|
+
"Page margin, overriding the theme's. CSS style: 2cm, or 2cm 2.5cm.",
|
|
144
|
+
example='"2cm"',
|
|
145
|
+
),
|
|
146
|
+
Setting("page_numbers", bool, True, "Number the pages in the footer."),
|
|
147
|
+
Setting(
|
|
148
|
+
"highlight_style",
|
|
149
|
+
str,
|
|
150
|
+
"default",
|
|
151
|
+
"Pygments style for code, or none to leave code uncoloured.",
|
|
152
|
+
),
|
|
153
|
+
Setting("remote", bool, True, "Download images the document links to."),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
_BY_NAME = {setting.name: setting for setting in SETTINGS}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class Settings:
|
|
161
|
+
"""Everything the conversion was told, from wherever it was told it."""
|
|
162
|
+
|
|
163
|
+
format: str | None
|
|
164
|
+
theme: str
|
|
165
|
+
css: str | None
|
|
166
|
+
toc: bool
|
|
167
|
+
toc_depth: int
|
|
168
|
+
title_page: bool
|
|
169
|
+
page_size: str | None
|
|
170
|
+
margin: str | None
|
|
171
|
+
page_numbers: bool
|
|
172
|
+
highlight_style: str
|
|
173
|
+
remote: bool
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def config_files(*, project_dir: Path | None = None) -> list[Path]:
|
|
177
|
+
"""The config files that exist, in the order they are read."""
|
|
178
|
+
directory = Path.cwd() if project_dir is None else project_dir
|
|
179
|
+
candidates = [user_config_path(), directory / CONFIG_FILENAME]
|
|
180
|
+
return [path for path in candidates if path.is_file()]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def user_config_path() -> Path:
|
|
184
|
+
"""Where this user's own config file lives, whether or not it is there."""
|
|
185
|
+
base = _env(CONFIG_HOME)
|
|
186
|
+
root = Path(base) if base else DEFAULT_CONFIG_HOME
|
|
187
|
+
return root.joinpath(*USER_CONFIG_PARTS)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def read_config_files(files: Sequence[Path] | None = None) -> dict[str, Any]:
|
|
191
|
+
"""Everything the config files say, merged, later file winning.
|
|
192
|
+
|
|
193
|
+
Kept apart from :func:`resolve_settings` because the CLI has to settle the
|
|
194
|
+
output format before it has read the document — and then settle everything
|
|
195
|
+
else after, once the frontmatter is in hand. Reading the files once and
|
|
196
|
+
merging twice is the difference between that and parsing them twice.
|
|
197
|
+
"""
|
|
198
|
+
values: dict[str, Any] = {}
|
|
199
|
+
for path in files if files is not None else config_files():
|
|
200
|
+
values.update(read_config(path))
|
|
201
|
+
return values
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def resolve_settings(
|
|
205
|
+
*,
|
|
206
|
+
declared: Mapping[str, Any] | None = None,
|
|
207
|
+
metadata: Mapping[str, Any] | None = None,
|
|
208
|
+
document_dir: Path | None = None,
|
|
209
|
+
overrides: Mapping[str, Any] | None = None,
|
|
210
|
+
) -> Settings:
|
|
211
|
+
"""Merge every source of settings into one answer.
|
|
212
|
+
|
|
213
|
+
``declared`` is what the config files said, from :func:`read_config_files`;
|
|
214
|
+
passing nothing reads them. ``overrides`` is what the command line actually
|
|
215
|
+
said — only the flags that were passed, so that a flag left alone does not
|
|
216
|
+
silently overrule a config file with its own default.
|
|
217
|
+
"""
|
|
218
|
+
values: dict[str, Any] = {setting.name: setting.default for setting in SETTINGS}
|
|
219
|
+
values.update(declared if declared is not None else read_config_files())
|
|
220
|
+
if metadata is not None:
|
|
221
|
+
values.update(from_frontmatter(metadata, document_dir))
|
|
222
|
+
if overrides is not None:
|
|
223
|
+
values.update(overrides)
|
|
224
|
+
return _build(values)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def read_config(path: Path) -> dict[str, Any]:
|
|
228
|
+
"""Read one config file, refusing anything it should not contain."""
|
|
229
|
+
try:
|
|
230
|
+
text = path.read_text(encoding="utf-8")
|
|
231
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
232
|
+
detail = getattr(exc, "strerror", None) or "it is not valid UTF-8 text"
|
|
233
|
+
raise ConfigError(f"Could not read {path}: {detail.lower()}.") from exc
|
|
234
|
+
try:
|
|
235
|
+
data = tomllib.loads(text)
|
|
236
|
+
except tomllib.TOMLDecodeError as exc:
|
|
237
|
+
raise ConfigError(f"{path} is not valid TOML: {exc}.") from exc
|
|
238
|
+
|
|
239
|
+
values: dict[str, Any] = {}
|
|
240
|
+
for key, value in data.items():
|
|
241
|
+
setting = _BY_NAME.get(key)
|
|
242
|
+
if setting is None:
|
|
243
|
+
# Silently ignoring a misspelled key looks exactly like a setting
|
|
244
|
+
# that does not work, which is a much worse afternoon than being
|
|
245
|
+
# told which line is wrong. The theme loader refuses for the same
|
|
246
|
+
# reason.
|
|
247
|
+
raise ConfigError(
|
|
248
|
+
f"{path}: unknown setting {key!r}.",
|
|
249
|
+
hint=f"A config file takes: {', '.join(_BY_NAME)}.",
|
|
250
|
+
)
|
|
251
|
+
values[key] = _checked(setting, value, str(path), path.parent)
|
|
252
|
+
return values
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def from_frontmatter(
|
|
256
|
+
metadata: Mapping[str, Any], document_dir: Path | None = None
|
|
257
|
+
) -> dict[str, Any]:
|
|
258
|
+
"""The conversion settings a document declares about itself.
|
|
259
|
+
|
|
260
|
+
Only the keys that name a setting are read. Everything else in the
|
|
261
|
+
frontmatter is the document's metadata, and an unknown key there is a
|
|
262
|
+
title or a keyword list rather than a mistake.
|
|
263
|
+
"""
|
|
264
|
+
values: dict[str, Any] = {}
|
|
265
|
+
for key, value in metadata.items():
|
|
266
|
+
setting = _BY_NAME.get(str(key).lower())
|
|
267
|
+
if setting is not None and setting.frontmatter:
|
|
268
|
+
values[setting.name] = _checked(setting, value, FRONTMATTER, document_dir)
|
|
269
|
+
return values
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def starter_config() -> str:
|
|
273
|
+
"""The commented file ``amethyst init`` writes.
|
|
274
|
+
|
|
275
|
+
Generated from the settings rather than shipped as data, so that it cannot
|
|
276
|
+
fall behind them: every key is here, with the default it actually has.
|
|
277
|
+
"""
|
|
278
|
+
lines = [
|
|
279
|
+
"# Amethyst settings for the documents in this directory.",
|
|
280
|
+
"#",
|
|
281
|
+
"# Every setting is listed with its default and commented out.",
|
|
282
|
+
"# Uncomment the ones you want to change.",
|
|
283
|
+
"#",
|
|
284
|
+
"# A flag on the command line still wins over anything here, and so",
|
|
285
|
+
"# does a value in a document's own frontmatter.",
|
|
286
|
+
]
|
|
287
|
+
for setting in SETTINGS:
|
|
288
|
+
value = setting.example if setting.default is None else _toml(setting.default)
|
|
289
|
+
lines += ["", f"# {setting.help}", f"# {setting.name} = {value}"]
|
|
290
|
+
return "\n".join([*lines, ""])
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _build(values: Mapping[str, Any]) -> Settings:
|
|
294
|
+
"""Turn merged values into the settings object the CLI reads."""
|
|
295
|
+
return Settings(
|
|
296
|
+
format=values["format"],
|
|
297
|
+
theme=values["theme"],
|
|
298
|
+
css=values["css"],
|
|
299
|
+
toc=values["toc"],
|
|
300
|
+
toc_depth=values["toc_depth"],
|
|
301
|
+
title_page=values["title_page"],
|
|
302
|
+
page_size=values["page_size"],
|
|
303
|
+
margin=values["margin"],
|
|
304
|
+
page_numbers=values["page_numbers"],
|
|
305
|
+
highlight_style=values["highlight_style"],
|
|
306
|
+
remote=values["remote"],
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _checked(setting: Setting, value: Any, source: str, directory: Path | None) -> Any:
|
|
311
|
+
"""Validate one declared value, and locate it if it names a file."""
|
|
312
|
+
if setting.kind is bool:
|
|
313
|
+
if not isinstance(value, bool):
|
|
314
|
+
raise _invalid(setting, value, source, "true or false")
|
|
315
|
+
elif setting.kind is int:
|
|
316
|
+
# A bool is an int as far as Python is concerned, and `toc_depth =
|
|
317
|
+
# true` is not a depth.
|
|
318
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
319
|
+
raise _invalid(setting, value, source, "a whole number")
|
|
320
|
+
if setting.minimum is not None and value < setting.minimum:
|
|
321
|
+
raise _invalid(setting, value, source, f"at least {setting.minimum}")
|
|
322
|
+
if setting.maximum is not None and value > setting.maximum:
|
|
323
|
+
raise _invalid(setting, value, source, f"at most {setting.maximum}")
|
|
324
|
+
else:
|
|
325
|
+
if not isinstance(value, str) or not value.strip():
|
|
326
|
+
raise _invalid(setting, value, source, "text")
|
|
327
|
+
value = value.strip()
|
|
328
|
+
if setting.choices is not None and value not in setting.choices:
|
|
329
|
+
raise _invalid(setting, value, source, f"one of {_or(setting.choices)}")
|
|
330
|
+
names_a_file = setting.locate is not None and setting.locate(value)
|
|
331
|
+
if names_a_file and directory is not None:
|
|
332
|
+
value = _relative_to(value, directory)
|
|
333
|
+
return value
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _relative_to(value: str, directory: Path) -> str:
|
|
337
|
+
"""Resolve a declared path against the file that declared it.
|
|
338
|
+
|
|
339
|
+
A stylesheet named in ``~/.config/amethyst/config.toml`` means the one
|
|
340
|
+
beside that file, not one that happens to share its name with something in
|
|
341
|
+
whatever directory the command was run from.
|
|
342
|
+
"""
|
|
343
|
+
candidate = Path(value)
|
|
344
|
+
if candidate.is_absolute():
|
|
345
|
+
return value
|
|
346
|
+
return str((directory / candidate).resolve())
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _invalid(setting: Setting, value: Any, source: str, must: str) -> ConfigError:
|
|
350
|
+
"""The one shape a bad setting is reported in, wherever it was written."""
|
|
351
|
+
return ConfigError(f"{source}: {setting.name} must be {must}, not {value!r}.")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _or(choices: Sequence[str]) -> str:
|
|
355
|
+
"""Join choices the way a sentence would."""
|
|
356
|
+
return " or ".join(
|
|
357
|
+
[", ".join(choices[:-1]), choices[-1]] if len(choices) > 1 else choices
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _toml(value: Any) -> str:
|
|
362
|
+
"""Write a default the way it would be written in the file."""
|
|
363
|
+
if isinstance(value, bool):
|
|
364
|
+
return "true" if value else "false"
|
|
365
|
+
if isinstance(value, str):
|
|
366
|
+
return f'"{value}"'
|
|
367
|
+
return str(value)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _env(name: str) -> str | None:
|
|
371
|
+
"""One environment variable, treating an empty value as unset."""
|
|
372
|
+
return os.environ.get(name) or None
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
__all__ = [
|
|
376
|
+
"CONFIG_FILENAME",
|
|
377
|
+
"SETTINGS",
|
|
378
|
+
"Setting",
|
|
379
|
+
"Settings",
|
|
380
|
+
"config_files",
|
|
381
|
+
"from_frontmatter",
|
|
382
|
+
"read_config",
|
|
383
|
+
"read_config_files",
|
|
384
|
+
"resolve_settings",
|
|
385
|
+
"starter_config",
|
|
386
|
+
"user_config_path",
|
|
387
|
+
]
|
amethyst/document.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""The parsed document: its metadata, its tokens, and where it came from.
|
|
2
|
+
|
|
3
|
+
This is the only object the renderers are given. It carries the source
|
|
4
|
+
directory rather than the source file because that is what a relative image
|
|
5
|
+
path resolves against, and stdin has the second without having the first.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from markdown_it.token import Token
|
|
17
|
+
|
|
18
|
+
from amethyst.errors import InputError
|
|
19
|
+
from amethyst.parse.assets import Asset, resolve_assets
|
|
20
|
+
from amethyst.parse.frontmatter import split_frontmatter
|
|
21
|
+
from amethyst.parse.markdown import build_parser
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Heading:
|
|
26
|
+
"""One heading, as the contents and the running head see it."""
|
|
27
|
+
|
|
28
|
+
#: 1 for ``h1``, 6 for ``h6``.
|
|
29
|
+
level: int
|
|
30
|
+
#: The heading's visible text, with the markup flattened out of it.
|
|
31
|
+
text: str
|
|
32
|
+
#: The HTML id the anchors plugin gave it, which is what a contents entry
|
|
33
|
+
#: links to and what a Word bookmark is named after. ``None`` only if the
|
|
34
|
+
#: parser ever stops assigning one, in which case the entry is still
|
|
35
|
+
#: listed — unlinked, and without a page number.
|
|
36
|
+
anchor: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Document:
|
|
41
|
+
"""A Markdown file, parsed and ready to render."""
|
|
42
|
+
|
|
43
|
+
#: The token stream, with the frontmatter token removed and local image
|
|
44
|
+
#: sources rewritten to absolute paths.
|
|
45
|
+
tokens: list[Token]
|
|
46
|
+
#: Frontmatter fields, keys lowercased, values as YAML produced them.
|
|
47
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
48
|
+
#: Every reference out of the document — see :func:`resolve_assets`.
|
|
49
|
+
assets: list[Asset] = field(default_factory=list)
|
|
50
|
+
#: The file this came from, or ``None`` when it was read from stdin.
|
|
51
|
+
source: Path | None = None
|
|
52
|
+
#: What relative paths resolve against: the source's directory, or the
|
|
53
|
+
#: working directory for stdin.
|
|
54
|
+
base_dir: Path = field(default_factory=Path.cwd)
|
|
55
|
+
#: The original Markdown, frontmatter included.
|
|
56
|
+
text: str = ""
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_markdown(
|
|
60
|
+
cls,
|
|
61
|
+
text: str,
|
|
62
|
+
*,
|
|
63
|
+
source: Path | None = None,
|
|
64
|
+
base_dir: Path | None = None,
|
|
65
|
+
) -> Document:
|
|
66
|
+
"""Parse Markdown text into a document.
|
|
67
|
+
|
|
68
|
+
``base_dir`` defaults to the source file's directory, and to the
|
|
69
|
+
working directory when there is no source file.
|
|
70
|
+
"""
|
|
71
|
+
if base_dir is None:
|
|
72
|
+
base_dir = source.resolve().parent if source is not None else Path.cwd()
|
|
73
|
+
|
|
74
|
+
metadata, tokens = split_frontmatter(build_parser().parse(text))
|
|
75
|
+
assets = resolve_assets(tokens, base_dir)
|
|
76
|
+
return cls(
|
|
77
|
+
tokens=tokens,
|
|
78
|
+
metadata=metadata,
|
|
79
|
+
assets=assets,
|
|
80
|
+
source=source,
|
|
81
|
+
base_dir=base_dir,
|
|
82
|
+
text=text,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def title(self) -> str | None:
|
|
87
|
+
"""The frontmatter title, or failing that the first level-1 heading."""
|
|
88
|
+
declared = _as_text(self.metadata.get("title"))
|
|
89
|
+
if declared is not None:
|
|
90
|
+
return declared
|
|
91
|
+
first = next((item for item in self.headings if item.level == 1), None)
|
|
92
|
+
return (first.text or None) if first is not None else None
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def subtitle(self) -> str | None:
|
|
96
|
+
"""The declared subtitle. It reaches the title page and the metadata."""
|
|
97
|
+
return _as_text(self.metadata.get("subtitle"))
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def author(self) -> str | None:
|
|
101
|
+
"""The declared author. A YAML list becomes a comma-separated string."""
|
|
102
|
+
return _as_text(self.metadata.get("author"))
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def date(self) -> str | None:
|
|
106
|
+
"""The declared date, as text. YAML dates arrive parsed; ISO them back."""
|
|
107
|
+
return _as_text(self.metadata.get("date"))
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def keywords(self) -> str | None:
|
|
111
|
+
"""The declared keywords, comma-separated — which both formats want."""
|
|
112
|
+
return _as_text(self.metadata.get("keywords"))
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def created(self) -> datetime.date | None:
|
|
116
|
+
"""The declared date as a real date, or ``None`` if it is not one.
|
|
117
|
+
|
|
118
|
+
A document may be dated "Spring 2026", which belongs on a title page
|
|
119
|
+
and nowhere near a file's creation timestamp. Only a date that parses
|
|
120
|
+
reaches the PDF's and Word's metadata; the rest stays text.
|
|
121
|
+
"""
|
|
122
|
+
text = self.date
|
|
123
|
+
if text is None:
|
|
124
|
+
return None
|
|
125
|
+
try:
|
|
126
|
+
return datetime.date.fromisoformat(text)
|
|
127
|
+
except ValueError:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def headings(self) -> list[Heading]:
|
|
132
|
+
"""Every heading in the document, in the order they are written.
|
|
133
|
+
|
|
134
|
+
Derived from the tokens rather than collected during the parse: the
|
|
135
|
+
contents, the running head and Word's bookmarks all want the same
|
|
136
|
+
list, and there is nothing here that the token stream does not already
|
|
137
|
+
say.
|
|
138
|
+
"""
|
|
139
|
+
found: list[Heading] = []
|
|
140
|
+
for index, token in enumerate(self.tokens):
|
|
141
|
+
if token.type != "heading_open":
|
|
142
|
+
continue
|
|
143
|
+
inline = self.tokens[index + 1] if index + 1 < len(self.tokens) else None
|
|
144
|
+
if inline is None or inline.type != "inline":
|
|
145
|
+
continue
|
|
146
|
+
anchor = token.attrGet("id")
|
|
147
|
+
found.append(
|
|
148
|
+
Heading(
|
|
149
|
+
level=int(token.tag[1:]),
|
|
150
|
+
text=_inline_text(inline),
|
|
151
|
+
anchor=anchor if isinstance(anchor, str) and anchor else None,
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
return found
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def missing_assets(self) -> list[Asset]:
|
|
158
|
+
"""Local references with no file at the other end."""
|
|
159
|
+
return [asset for asset in self.assets if asset.is_missing]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def load_document(source: Path | None) -> Document:
|
|
163
|
+
"""Read Markdown from a file, or from stdin when ``source`` is ``None``."""
|
|
164
|
+
if source is None:
|
|
165
|
+
return Document.from_markdown(_read_stdin(), base_dir=Path.cwd())
|
|
166
|
+
return Document.from_markdown(_read_file(source), source=source)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _read_file(source: Path) -> str:
|
|
170
|
+
"""Read a Markdown file as UTF-8, reporting failures as one clear line."""
|
|
171
|
+
try:
|
|
172
|
+
# utf-8-sig so a byte-order mark from a Windows editor does not end up
|
|
173
|
+
# as an invisible first character of the first heading.
|
|
174
|
+
return source.read_text(encoding="utf-8-sig")
|
|
175
|
+
except UnicodeDecodeError as exc:
|
|
176
|
+
raise InputError(
|
|
177
|
+
f"{source} is not valid UTF-8 text.",
|
|
178
|
+
hint="Amethyst reads UTF-8 Markdown; convert the file first.",
|
|
179
|
+
) from exc
|
|
180
|
+
except FileNotFoundError as exc:
|
|
181
|
+
raise InputError(f"No such file: {source}.") from exc
|
|
182
|
+
except OSError as exc:
|
|
183
|
+
detail = exc.strerror or str(exc)
|
|
184
|
+
raise InputError(f"Could not read {source}: {detail.lower()}.") from exc
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _read_stdin() -> str:
|
|
188
|
+
"""Read the whole of stdin as the document."""
|
|
189
|
+
try:
|
|
190
|
+
return sys.stdin.read()
|
|
191
|
+
except UnicodeDecodeError as exc:
|
|
192
|
+
raise InputError(
|
|
193
|
+
"The Markdown on stdin is not valid UTF-8 text.",
|
|
194
|
+
hint="Amethyst reads UTF-8 Markdown; convert the input first.",
|
|
195
|
+
) from exc
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _inline_text(token: Token) -> str:
|
|
199
|
+
"""Flatten an inline token to its visible text, dropping the markup."""
|
|
200
|
+
if not token.children:
|
|
201
|
+
return token.content.strip()
|
|
202
|
+
parts = [
|
|
203
|
+
child.content
|
|
204
|
+
for child in token.children
|
|
205
|
+
if child.type in {"text", "code_inline"}
|
|
206
|
+
]
|
|
207
|
+
return "".join(parts).strip()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _as_text(value: Any) -> str | None:
|
|
211
|
+
"""Render a metadata value as a display string, or ``None`` if it is empty.
|
|
212
|
+
|
|
213
|
+
Frontmatter is YAML, so a value can arrive as a date, a number, a boolean
|
|
214
|
+
or a list. Everything that reaches a title page or a Word core property has
|
|
215
|
+
to be a string, and this is the single place that conversion happens.
|
|
216
|
+
"""
|
|
217
|
+
if value is None:
|
|
218
|
+
return None
|
|
219
|
+
if isinstance(value, str):
|
|
220
|
+
return value.strip() or None
|
|
221
|
+
if isinstance(value, bool):
|
|
222
|
+
return "true" if value else "false"
|
|
223
|
+
if isinstance(value, datetime.date): # covers datetime, which subclasses it
|
|
224
|
+
return value.isoformat()
|
|
225
|
+
if isinstance(value, list | tuple):
|
|
226
|
+
joined = ", ".join(text for text in map(_as_text, value) if text)
|
|
227
|
+
return joined or None
|
|
228
|
+
return str(value)
|
amethyst/errors.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""The error hierarchy Amethyst reports with, rather than crashing.
|
|
2
|
+
|
|
3
|
+
Every error carries the process exit code it should produce, so the CLI can
|
|
4
|
+
turn it into one readable line instead of a traceback. The codes are the ones
|
|
5
|
+
the CLI contract promises: 0 ok, 1 conversion failure, 2 bad usage, 3 missing
|
|
6
|
+
system dependency.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AmethystError(Exception):
|
|
13
|
+
"""Base for anything the user should see as a message, not a traceback.
|
|
14
|
+
|
|
15
|
+
``hint`` is the actionable second line — the command to run, or the flag to
|
|
16
|
+
pass. Leave it out when there is nothing concrete to suggest.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
exit_code = 1
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, *, hint: str | None = None) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.message = message
|
|
24
|
+
self.hint = hint
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UsageError(AmethystError):
|
|
28
|
+
"""The invocation itself was wrong: conflicting flags, unusable paths."""
|
|
29
|
+
|
|
30
|
+
exit_code = 2
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InputError(AmethystError):
|
|
34
|
+
"""The source document could not be read or parsed."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ConfigError(AmethystError):
|
|
38
|
+
"""A config file, or a document's frontmatter, said something unusable.
|
|
39
|
+
|
|
40
|
+
A conversion failure rather than bad usage, for the same reason a broken
|
|
41
|
+
theme is: the invocation was fine, and a file it read was not.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ThemeError(AmethystError):
|
|
46
|
+
"""A theme was located but could not be read or validated.
|
|
47
|
+
|
|
48
|
+
A theme that simply is not there — an unknown builtin name, or a path with
|
|
49
|
+
no file at it — is a ``UsageError`` instead. That is a mistyped invocation,
|
|
50
|
+
not a broken document, and the two deserve different exit codes.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RenderError(AmethystError):
|
|
55
|
+
"""Conversion began but could not produce output."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class MissingDependencyError(AmethystError):
|
|
59
|
+
"""A system library is absent, or present but not on the loader's path.
|
|
60
|
+
|
|
61
|
+
The distinction matters enough to be worth spelling out in the message:
|
|
62
|
+
telling someone to ``brew install pango`` when pango is already installed
|
|
63
|
+
and merely unreachable sends them round in circles.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
exit_code = 3
|