imprenta 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.
- imprenta/__init__.py +30 -0
- imprenta/__main__.py +3 -0
- imprenta/build.py +161 -0
- imprenta/catalog.py +245 -0
- imprenta/check.py +362 -0
- imprenta/cli.py +153 -0
- imprenta/content.py +74 -0
- imprenta/data/toc.lua +94 -0
- imprenta/edit.py +150 -0
- imprenta/parts.py +196 -0
- imprenta/project.py +154 -0
- imprenta/render.py +177 -0
- imprenta/targets.py +190 -0
- imprenta/tui.py +567 -0
- imprenta-0.1.0.dist-info/METADATA +195 -0
- imprenta-0.1.0.dist-info/RECORD +19 -0
- imprenta-0.1.0.dist-info/WHEEL +4 -0
- imprenta-0.1.0.dist-info/entry_points.txt +2 -0
- imprenta-0.1.0.dist-info/licenses/LICENSE +21 -0
imprenta/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""imprenta: a content arranger and styler.
|
|
2
|
+
|
|
3
|
+
Markdown files in `content/` are arranged into products (PDFs, EPUBs) by one or
|
|
4
|
+
more `catalog.<name>.yaml` files. The engine knows content, parts, groups,
|
|
5
|
+
recipes, products, themes and render variants, and nothing about what the
|
|
6
|
+
content is about or what language it is in.
|
|
7
|
+
|
|
8
|
+
from imprenta import Project
|
|
9
|
+
|
|
10
|
+
project = Project.find() # the repo that holds catalog.*.yaml
|
|
11
|
+
for target in project.targets(): # (catalog, product, variant)
|
|
12
|
+
project.build(target)
|
|
13
|
+
errors, warnings = project.check()
|
|
14
|
+
|
|
15
|
+
The command line (`imprenta build|check|status|affected|doctor|manager`) and
|
|
16
|
+
the manager TUI (`pip install imprenta[tui]`) are thin layers over Project.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
20
|
+
|
|
21
|
+
from .catalog import Catalog
|
|
22
|
+
from .project import Project
|
|
23
|
+
from .targets import Target
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
__version__ = version("imprenta")
|
|
27
|
+
except PackageNotFoundError: # running from a checkout that is not installed
|
|
28
|
+
__version__ = "0.0.0"
|
|
29
|
+
|
|
30
|
+
__all__ = ["Catalog", "Project", "Target", "__version__"]
|
imprenta/__main__.py
ADDED
imprenta/build.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Building one target: freshness, assembly, pandoc, Paged.js, the stamp."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from . import parts, render
|
|
10
|
+
from .catalog import GENERATED
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .project import Project
|
|
14
|
+
from .targets import Target
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BuildError(RuntimeError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------- freshness ----------
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_fresh(target: Path, inputs, stamp: str | None = None, tmp: Path | None = None) -> bool:
|
|
25
|
+
"""True if `target` exists, is newer than every input path, and (when a
|
|
26
|
+
`stamp` is given) was last built from the same catalog rows and engine
|
|
27
|
+
version. The stamp lives in tmp/, so editing one product's line never
|
|
28
|
+
makes every other output stale."""
|
|
29
|
+
if not target.exists():
|
|
30
|
+
return False
|
|
31
|
+
mtime = target.stat().st_mtime
|
|
32
|
+
if not all(p.stat().st_mtime <= mtime for p in inputs if p.exists()):
|
|
33
|
+
return False
|
|
34
|
+
return stamp is None or read_stamp(target, tmp) == stamp
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def stamp_path(target: Path, tmp: Path | None) -> Path:
|
|
38
|
+
return (tmp or target.parent) / f"{target.name}.stamp"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def read_stamp(target: Path, tmp: Path | None) -> str | None:
|
|
42
|
+
p = stamp_path(target, tmp)
|
|
43
|
+
return p.read_text(encoding="utf-8") if p.exists() else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def write_stamp(target: Path, stamp: str, tmp: Path | None) -> None:
|
|
47
|
+
p = stamp_path(target, tmp)
|
|
48
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
p.write_text(stamp, encoding="utf-8")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def row_stamp(project: Project, target: Target) -> str:
|
|
53
|
+
"""The catalog rows a target renders from plus the engine version, serialized."""
|
|
54
|
+
from . import __version__
|
|
55
|
+
|
|
56
|
+
cat = project.catalog(target.catalog)
|
|
57
|
+
row = cat.render_row(target.product, target.variant)
|
|
58
|
+
row["imprenta"] = __version__
|
|
59
|
+
return json.dumps(row, sort_keys=True, ensure_ascii=False, default=str)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def inputs(project: Project, target: Target) -> list[Path]:
|
|
63
|
+
"""File inputs of one target: its content files and the template-side files
|
|
64
|
+
its variant uses (the catalog rows are covered by the stamp)."""
|
|
65
|
+
cat = project.catalog(target.catalog)
|
|
66
|
+
files = [project.content_path(n) for n, _ in cat.resolve_parts(target.product) if n not in GENERATED]
|
|
67
|
+
templates = project.templates
|
|
68
|
+
renderer = cat.variant_renderer(target.variant)
|
|
69
|
+
if renderer == "pdf":
|
|
70
|
+
files += [*templates.glob("*.html"), templates / "print.css"]
|
|
71
|
+
else:
|
|
72
|
+
files.append(templates / "epub.css")
|
|
73
|
+
files += cat.variant_layers(target.variant)
|
|
74
|
+
files += list((templates / "fonts").glob("*"))
|
|
75
|
+
return files
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------- the build ----------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build(
|
|
82
|
+
project: Project, target: Target, *, pdf: bool = True, preview: bool = False, force: bool = False, log=print
|
|
83
|
+
) -> Path:
|
|
84
|
+
"""Build one target by arranging its parts. Returns the output path (the
|
|
85
|
+
HTML path when `pdf` is False). Raises BuildError for a bad target."""
|
|
86
|
+
cat = project.catalog(target.catalog)
|
|
87
|
+
if not cat.data:
|
|
88
|
+
raise BuildError(f"No catalog '{target.catalog}' (catalog.{target.catalog}.yaml)")
|
|
89
|
+
if target.product not in cat.products:
|
|
90
|
+
raise BuildError(f"Unknown product '{target.product}' in catalog.{target.catalog}.yaml")
|
|
91
|
+
variant = cat.resolve_variant(target.variant)
|
|
92
|
+
renderer = cat.variant_renderer(variant)
|
|
93
|
+
if renderer != "pdf" and (preview or not pdf):
|
|
94
|
+
raise BuildError(f"Variant '{variant}' renders an EPUB; --html/--preview apply to pdf variants only.")
|
|
95
|
+
out = cat.output_path(target.product, variant)
|
|
96
|
+
stamp = row_stamp(project, target)
|
|
97
|
+
if pdf and not force and is_fresh(out, inputs(project, target), stamp, project.tmp):
|
|
98
|
+
log(f"Up to date: {project.rel(out)}")
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
files, names = parts.assemble(cat, target.product, project.tmp)
|
|
102
|
+
if not files:
|
|
103
|
+
raise BuildError(f"{target.catalog}/{target.product}: nothing to assemble (none of its parts exist)")
|
|
104
|
+
log(f"{target.catalog}/{target.product} [{variant}]: {len(files)} parts")
|
|
105
|
+
meta = {
|
|
106
|
+
"title": parts.page_title(cat, target.product),
|
|
107
|
+
"lang": parts.page_lang(cat, target.product),
|
|
108
|
+
"edition": cat.plan(target.product).get("version"),
|
|
109
|
+
"date": render.build_date(),
|
|
110
|
+
}
|
|
111
|
+
common = ["--strip-comments", "--lua-filter", str(render.toc_filter()), *map(str, files)]
|
|
112
|
+
if renderer == "epub":
|
|
113
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
render.pandoc(
|
|
115
|
+
project.root,
|
|
116
|
+
[
|
|
117
|
+
*common,
|
|
118
|
+
*render.epub_args(cat, variant),
|
|
119
|
+
*render.meta_args(meta, "title", "lang", "date"),
|
|
120
|
+
"-o",
|
|
121
|
+
str(out),
|
|
122
|
+
],
|
|
123
|
+
)
|
|
124
|
+
write_stamp(out, stamp, project.tmp)
|
|
125
|
+
log(f"Built {project.rel(out)}")
|
|
126
|
+
return out
|
|
127
|
+
render.copy_assets(project)
|
|
128
|
+
html = project.tmp / f"{target.product}.{target.catalog}.{variant}.html"
|
|
129
|
+
common_meta = {"version": render.git_version(project.root), "date": meta["date"], **cat.variant_meta(variant)}
|
|
130
|
+
if preview:
|
|
131
|
+
common_meta.update(preview="true", polyfill=render.polyfill_src(project))
|
|
132
|
+
render.pandoc(
|
|
133
|
+
project.root,
|
|
134
|
+
[
|
|
135
|
+
*common,
|
|
136
|
+
"--template",
|
|
137
|
+
str(project.theme_template(cat.theme(target.product))),
|
|
138
|
+
*render.meta_args(meta, "title", "lang", "edition"),
|
|
139
|
+
*render.meta_args(
|
|
140
|
+
common_meta,
|
|
141
|
+
"version",
|
|
142
|
+
"date",
|
|
143
|
+
"preview",
|
|
144
|
+
"polyfill",
|
|
145
|
+
"variant",
|
|
146
|
+
"size",
|
|
147
|
+
"palette",
|
|
148
|
+
"size_css",
|
|
149
|
+
"palette_css",
|
|
150
|
+
),
|
|
151
|
+
"-o",
|
|
152
|
+
str(html),
|
|
153
|
+
],
|
|
154
|
+
)
|
|
155
|
+
if not pdf:
|
|
156
|
+
log(f"Wrote {project.rel(html)}")
|
|
157
|
+
return html
|
|
158
|
+
render.render_pdf(project, html, out)
|
|
159
|
+
write_stamp(out, stamp, project.tmp)
|
|
160
|
+
log(f"Built {project.rel(out)}")
|
|
161
|
+
return out
|
imprenta/catalog.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""A catalog: one set of products arranged from the project's content.
|
|
2
|
+
|
|
3
|
+
`catalog.<name>.yaml` has only these sections:
|
|
4
|
+
|
|
5
|
+
defaults publishing fields every product inherits (variants, tags)
|
|
6
|
+
rendering default, variants {name: {renderer, size, palette}}, bundles {name: [variants]}
|
|
7
|
+
strings reader-facing labels for the generated parts (toc_title, terms_title, terms_label)
|
|
8
|
+
terms glossary column aliases: columns {term|literal|meaning|example: [headers]}
|
|
9
|
+
groups named, ordered lists of content names
|
|
10
|
+
recipes shapes products share: {theme, parts}; `$name` is the product's own name
|
|
11
|
+
products what gets built: {recipe | theme + parts, status, version, variants, tags}
|
|
12
|
+
|
|
13
|
+
A part name resolves as a generated part (`toc`, `terms`), then a group
|
|
14
|
+
(expanded in place), then a content file. An entry may carry settings as a
|
|
15
|
+
one-key mapping: `- toc: {depth: 2}` or `- cover.en: {style: cover, toc: false}`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from .project import Project
|
|
25
|
+
|
|
26
|
+
GENERATED = ("toc", "terms")
|
|
27
|
+
PLAN_KEYS = ("status", "version", "variants", "tags")
|
|
28
|
+
PRODUCT_KEYS = ("recipe", "theme", "parts", *PLAN_KEYS)
|
|
29
|
+
SECTIONS = ("defaults", "rendering", "strings", "terms", "groups", "recipes", "products")
|
|
30
|
+
STATUSES = ("draft", "ready", "published")
|
|
31
|
+
RENDERERS = ("pdf", "epub")
|
|
32
|
+
DEFAULT_THEME = "default"
|
|
33
|
+
|
|
34
|
+
# How the glossary recognizes a term table's columns when a catalog has no
|
|
35
|
+
# `terms.columns`: a header matches a role when it contains one of these.
|
|
36
|
+
DEFAULT_TERM_COLUMNS = {
|
|
37
|
+
"term": ["term"],
|
|
38
|
+
"literal": ["literal", "origin", "source", "equivalent"],
|
|
39
|
+
"meaning": ["meaning"],
|
|
40
|
+
"example": ["example", "note"],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def substitute(value: Any, product: str) -> Any:
|
|
45
|
+
"""Replace `$name` with the product's name throughout a part name or settings mapping."""
|
|
46
|
+
if isinstance(value, str):
|
|
47
|
+
return value.replace("$name", product)
|
|
48
|
+
if isinstance(value, dict):
|
|
49
|
+
return {substitute(k, product): substitute(v, product) for k, v in value.items()}
|
|
50
|
+
if isinstance(value, list):
|
|
51
|
+
return [substitute(v, product) for v in value]
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def normalize_parts(parts: list | None) -> list[tuple[str, dict]]:
|
|
56
|
+
"""A parts list as [(name, settings)]."""
|
|
57
|
+
out = []
|
|
58
|
+
for entry in parts or []:
|
|
59
|
+
if isinstance(entry, dict) and len(entry) == 1:
|
|
60
|
+
((name, settings),) = entry.items()
|
|
61
|
+
out.append((str(name), dict(settings or {})))
|
|
62
|
+
else:
|
|
63
|
+
out.append((str(entry), {}))
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Catalog:
|
|
68
|
+
"""One parsed catalog. Pure over its data plus the project's content folder."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, project: Project, name: str, data: dict):
|
|
71
|
+
self.project = project
|
|
72
|
+
self.name = name
|
|
73
|
+
self.data = data or {}
|
|
74
|
+
|
|
75
|
+
def __repr__(self) -> str:
|
|
76
|
+
return f"Catalog({self.name!r}, {len(self.products)} products)"
|
|
77
|
+
|
|
78
|
+
# ----- sections -----
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def defaults(self) -> dict:
|
|
82
|
+
return self.data.get("defaults") or {}
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def products(self) -> dict:
|
|
86
|
+
return self.data.get("products") or {}
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def groups(self) -> dict:
|
|
90
|
+
return self.data.get("groups") or {}
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def recipes(self) -> dict:
|
|
94
|
+
return self.data.get("recipes") or {}
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def strings(self) -> dict:
|
|
98
|
+
return dict(self.data.get("strings") or {})
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def term_columns(self) -> dict[str, list[str]]:
|
|
102
|
+
"""Role -> header aliases for the glossary, the catalog's over the defaults."""
|
|
103
|
+
cols = dict(DEFAULT_TERM_COLUMNS)
|
|
104
|
+
for role, aliases in ((self.data.get("terms") or {}).get("columns") or {}).items():
|
|
105
|
+
cols[role] = [str(a).lower() for a in (aliases or [])]
|
|
106
|
+
return cols
|
|
107
|
+
|
|
108
|
+
# ----- rendering -----
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def rendering(self) -> dict:
|
|
112
|
+
cfg = dict(self.data.get("rendering") or {})
|
|
113
|
+
cfg.setdefault("variants", {})
|
|
114
|
+
cfg.setdefault("bundles", cfg.pop("products", {})) # `products` is the old name
|
|
115
|
+
return cfg
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def variants(self) -> dict:
|
|
119
|
+
return self.rendering["variants"]
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def bundles(self) -> dict:
|
|
123
|
+
return self.rendering["bundles"]
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def default_variant(self) -> str | None:
|
|
127
|
+
return self.rendering.get("default")
|
|
128
|
+
|
|
129
|
+
def resolve_variant(self, name: str | None = None) -> str:
|
|
130
|
+
"""`name`, else the catalog's default; raises KeyError for an unknown one."""
|
|
131
|
+
name = name or self.default_variant
|
|
132
|
+
if name not in self.variants:
|
|
133
|
+
known = ", ".join(sorted(self.variants)) or "none defined"
|
|
134
|
+
raise KeyError(f"Unknown variant '{name}' in catalog.{self.name}.yaml (have: {known})")
|
|
135
|
+
return name
|
|
136
|
+
|
|
137
|
+
def variant_renderer(self, variant: str) -> str:
|
|
138
|
+
return self.variants[variant].get("renderer", "pdf")
|
|
139
|
+
|
|
140
|
+
def variant_layers(self, variant: str) -> list[Path]:
|
|
141
|
+
"""The css layers a variant adds: size + palette for pdf, optional palette for epub."""
|
|
142
|
+
v = self.variants[variant]
|
|
143
|
+
layers = []
|
|
144
|
+
if v.get("size"):
|
|
145
|
+
layers.append(self.project.templates / "sizes" / f"{v['size']}.css")
|
|
146
|
+
if v.get("palette"):
|
|
147
|
+
layers.append(self.project.templates / "palettes" / f"{v['palette']}.css")
|
|
148
|
+
return layers
|
|
149
|
+
|
|
150
|
+
def variant_meta(self, variant: str) -> dict:
|
|
151
|
+
"""Pandoc metadata the HTML templates use to link the variant's css layers."""
|
|
152
|
+
v = self.variants[variant]
|
|
153
|
+
m = {"variant": variant, "size": v.get("size"), "palette": v.get("palette")}
|
|
154
|
+
if v.get("size"):
|
|
155
|
+
m["size_css"] = f"sizes/{v['size']}.css"
|
|
156
|
+
if v.get("palette"):
|
|
157
|
+
m["palette_css"] = f"palettes/{v['palette']}.css"
|
|
158
|
+
return m
|
|
159
|
+
|
|
160
|
+
def variant_filter(self, variant: str | None = None, bundle: str | None = None) -> list[str] | None:
|
|
161
|
+
"""Variant names selected by a --variant / --bundle option, or None for all."""
|
|
162
|
+
if variant:
|
|
163
|
+
return [self.resolve_variant(variant)]
|
|
164
|
+
if bundle:
|
|
165
|
+
if bundle not in self.bundles:
|
|
166
|
+
known = ", ".join(sorted(self.bundles)) or "none defined"
|
|
167
|
+
raise KeyError(f"Unknown bundle '{bundle}' (have: {known})")
|
|
168
|
+
return list(self.bundles[bundle])
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
# ----- products -----
|
|
172
|
+
|
|
173
|
+
def product_def(self, product: str) -> dict | None:
|
|
174
|
+
"""A product's full definition: its recipe's fields (with `$name` filled in)
|
|
175
|
+
under its own. None if the product does not exist."""
|
|
176
|
+
spec = self.products.get(product)
|
|
177
|
+
if spec is None:
|
|
178
|
+
return None
|
|
179
|
+
spec = dict(spec or {})
|
|
180
|
+
base: dict = {}
|
|
181
|
+
if spec.get("recipe"):
|
|
182
|
+
base = substitute(dict(self.recipes.get(spec["recipe"]) or {}), product)
|
|
183
|
+
merged = {**base, **{k: v for k, v in spec.items() if k != "recipe"}}
|
|
184
|
+
merged.setdefault("theme", DEFAULT_THEME)
|
|
185
|
+
return merged
|
|
186
|
+
|
|
187
|
+
def plan(self, product: str) -> dict:
|
|
188
|
+
"""A product's publishing plan: defaults with the product's fields on top.
|
|
189
|
+
`variants` falls back to every rendering variant; `tags` to []."""
|
|
190
|
+
plan: dict = {"status": None, "version": None, "variants": None, "tags": None}
|
|
191
|
+
plan.update({k: v for k, v in self.defaults.items() if k in PLAN_KEYS})
|
|
192
|
+
plan.update({k: v for k, v in (self.product_def(product) or {}).items() if k in PLAN_KEYS})
|
|
193
|
+
if plan["variants"] is None:
|
|
194
|
+
plan["variants"] = list(self.variants)
|
|
195
|
+
plan["tags"] = [str(t) for t in (plan["tags"] or [])]
|
|
196
|
+
return plan
|
|
197
|
+
|
|
198
|
+
def theme(self, product: str) -> str:
|
|
199
|
+
return (self.product_def(product) or {}).get("theme") or DEFAULT_THEME
|
|
200
|
+
|
|
201
|
+
def part_entries(self, product: str) -> list[tuple[str, dict]]:
|
|
202
|
+
return normalize_parts((self.product_def(product) or {}).get("parts"))
|
|
203
|
+
|
|
204
|
+
def resolve_parts(self, product: str) -> list[tuple[str, dict]]:
|
|
205
|
+
"""Expand a product's parts: groups in place, generated parts kept, content
|
|
206
|
+
names dropped when their file is missing or is an empty placeholder (only
|
|
207
|
+
comments), since an empty part would still start a page of its own."""
|
|
208
|
+
from . import content
|
|
209
|
+
|
|
210
|
+
out = []
|
|
211
|
+
for name, settings in self.part_entries(product):
|
|
212
|
+
names = list(self.groups[name] or []) if name in self.groups else [name]
|
|
213
|
+
for n in names:
|
|
214
|
+
if n in GENERATED:
|
|
215
|
+
out.append((n, settings if n == name else {}))
|
|
216
|
+
continue
|
|
217
|
+
path = self.project.content_path(n)
|
|
218
|
+
if path.exists() and not content.is_placeholder(path):
|
|
219
|
+
out.append((n, settings if n == name else {}))
|
|
220
|
+
return out
|
|
221
|
+
|
|
222
|
+
def content_names(self, product: str) -> list[str]:
|
|
223
|
+
"""The content files a product renders, in order (generated parts left out)."""
|
|
224
|
+
return [n for n, _ in self.resolve_parts(product) if n not in GENERATED]
|
|
225
|
+
|
|
226
|
+
def render_row(self, product: str, variant: str | None = None) -> dict:
|
|
227
|
+
"""Everything in the catalog that changes one rendered file (not whether it
|
|
228
|
+
exists): the resolved parts with settings, the theme, strings, glossary
|
|
229
|
+
columns, the product's status/version, and the variant's definition.
|
|
230
|
+
Content (and its front matter) is tracked by file mtime instead."""
|
|
231
|
+
spec = self.product_def(product) or {}
|
|
232
|
+
row = {
|
|
233
|
+
"parts": self.resolve_parts(product),
|
|
234
|
+
"theme": spec.get("theme"),
|
|
235
|
+
"strings": self.strings,
|
|
236
|
+
"terms": self.term_columns,
|
|
237
|
+
"product": {k: v for k, v in spec.items() if k in ("status", "version")},
|
|
238
|
+
}
|
|
239
|
+
if variant is not None:
|
|
240
|
+
row["variant"] = self.variants.get(variant)
|
|
241
|
+
return row
|
|
242
|
+
|
|
243
|
+
def output_path(self, product: str, variant: str) -> Path:
|
|
244
|
+
"""Flat output: output/<product>.<catalog>.<variant>.<pdf|epub>."""
|
|
245
|
+
return self.project.output / f"{product}.{self.name}.{variant}.{self.variant_renderer(variant)}"
|