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 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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)}"