demolab-cli 1.0.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.
Files changed (129) hide show
  1. demolab_cli/CHANGELOG.md +445 -0
  2. demolab_cli/VERSION +1 -0
  3. demolab_cli/__init__.py +10 -0
  4. demolab_cli/_paths.py +63 -0
  5. demolab_cli/build.py +284 -0
  6. demolab_cli/cli.py +414 -0
  7. demolab_cli/deploy/deploy.yml +61 -0
  8. demolab_cli/deploy/preview.yml +56 -0
  9. demolab_cli/devserver.py +380 -0
  10. demolab_cli/guides/AUTORESEARCH-RULES.md +149 -0
  11. demolab_cli/guides/GLOSSARY.md +55 -0
  12. demolab_cli/guides/HOUSESTYLE.md +108 -0
  13. demolab_cli/guides/RULES.md +142 -0
  14. demolab_cli/guides/SLIDES.md +145 -0
  15. demolab_cli/guides/STRUCTURE.md +78 -0
  16. demolab_cli/guides/SUPPORT.md +36 -0
  17. demolab_cli/overlay.py +47 -0
  18. demolab_cli/runbooks/AUTORESEARCH.md +67 -0
  19. demolab_cli/runbooks/DOCTOR.md +116 -0
  20. demolab_cli/runbooks/EMBED-DOCS.md +39 -0
  21. demolab_cli/runbooks/FROM-JUPYTER.md +84 -0
  22. demolab_cli/runbooks/FROM-PAPER.md +48 -0
  23. demolab_cli/runbooks/GETTING-STARTED.md +281 -0
  24. demolab_cli/runbooks/GROUND-CLAIMS.md +166 -0
  25. demolab_cli/runbooks/LINT.md +176 -0
  26. demolab_cli/runbooks/MIGRATE-CODE.md +49 -0
  27. demolab_cli/runbooks/MIGRATE-STACK.md +50 -0
  28. demolab_cli/runbooks/NEXT.md +52 -0
  29. demolab_cli/runbooks/NIGHT-SHIFT.md +68 -0
  30. demolab_cli/runbooks/PLAN.md +64 -0
  31. demolab_cli/runbooks/RED-TEAM.md +72 -0
  32. demolab_cli/runbooks/STEELMAN.md +44 -0
  33. demolab_cli/runbooks/TOUR.md +42 -0
  34. demolab_cli/runbooks/UPDATE.md +84 -0
  35. demolab_cli/scaffold/demo/artifacts/data/ar018/swe-bench.svg +1 -0
  36. demolab_cli/scaffold/demo/artifacts/data/exp000/lif.svg +1103 -0
  37. demolab_cli/scaffold/demo/artifacts/data/exp000/net.png +0 -0
  38. demolab_cli/scaffold/demo/artifacts/data/exp000/numbers.json +38 -0
  39. demolab_cli/scaffold/demo/artifacts/data/exp000/run.sh +3 -0
  40. demolab_cli/scaffold/demo/artifacts/data/exp001/eif.svg +1122 -0
  41. demolab_cli/scaffold/demo/artifacts/data/exp001/enet.png +0 -0
  42. demolab_cli/scaffold/demo/artifacts/data/exp001/numbers.json +40 -0
  43. demolab_cli/scaffold/demo/artifacts/data/exp001/run.sh +3 -0
  44. demolab_cli/scaffold/demo/artifacts/data/exp002/cartpole.mp4 +0 -0
  45. demolab_cli/scaffold/demo/artifacts/data/exp002/numbers.json +18 -0
  46. demolab_cli/scaffold/demo/artifacts/data/exp002/run.sh +3 -0
  47. demolab_cli/scaffold/demo/artifacts/data/exp003/double_pendulum.mp4 +0 -0
  48. demolab_cli/scaffold/demo/artifacts/data/exp003/numbers.json +21 -0
  49. demolab_cli/scaffold/demo/artifacts/data/exp003/run.sh +3 -0
  50. demolab_cli/scaffold/demo/artifacts/pdfs/ar000.pdf +546 -0
  51. demolab_cli/scaffold/demo/artifacts/pdfs/ar003.pdf +773 -1
  52. demolab_cli/scaffold/demo/artifacts/pdfs/ar004.pdf +2846 -2
  53. demolab_cli/scaffold/demo/artifacts/pdfs/ar005.pdf +4216 -5
  54. demolab_cli/scaffold/demo/artifacts/pdfs/ar006.pdf +1200 -0
  55. demolab_cli/scaffold/demo/artifacts/pdfs/ar010.pdf +718 -0
  56. demolab_cli/scaffold/demo/artifacts/pdfs/ar011.pdf +530 -0
  57. demolab_cli/scaffold/demo/artifacts/pdfs/ar012.pdf +634 -1
  58. demolab_cli/scaffold/demo/artifacts/pdfs/ar013.pdf +575 -1
  59. demolab_cli/scaffold/demo/artifacts/pdfs/ar014.pdf +0 -0
  60. demolab_cli/scaffold/demo/artifacts/pdfs/ar015.pdf +0 -0
  61. demolab_cli/scaffold/demo/artifacts/pdfs/ar016.pdf +520 -0
  62. demolab_cli/scaffold/demo/artifacts/pdfs/ar017.pdf +875 -1
  63. demolab_cli/scaffold/demo/artifacts/pdfs/ar018.pdf +871 -1
  64. demolab_cli/scaffold/demo/artifacts/pdfs/book.pdf +10343 -6
  65. demolab_cli/scaffold/demo/artifacts/pdfs/exp000.pdf +2036 -4
  66. demolab_cli/scaffold/demo/artifacts/pdfs/exp001.pdf +2226 -2
  67. demolab_cli/scaffold/demo/artifacts/pdfs/exp002.pdf +683 -0
  68. demolab_cli/scaffold/demo/artifacts/pdfs/exp003.pdf +767 -0
  69. demolab_cli/scaffold/demo/demolab.yaml +26 -0
  70. demolab_cli/scaffold/demo/experiments/exp000.py +106 -0
  71. demolab_cli/scaffold/demo/experiments/exp001.py +106 -0
  72. demolab_cli/scaffold/demo/experiments/exp002.py +74 -0
  73. demolab_cli/scaffold/demo/experiments/exp003.py +73 -0
  74. demolab_cli/scaffold/demo/experiments/playground.py +157 -0
  75. demolab_cli/scaffold/demo/site/CNAME +1 -0
  76. demolab_cli/scaffold/demo/site/landing.typ +38 -0
  77. demolab_cli/scaffold/demo/tools/mujoco/test_mujoco.py +42 -0
  78. demolab_cli/scaffold/demo/tools/mujoco/tool.py +393 -0
  79. demolab_cli/scaffold/demo/tools/neuron/test_neuron.py +106 -0
  80. demolab_cli/scaffold/demo/tools/neuron/tool.py +526 -0
  81. demolab_cli/scaffold/demo/writings/ar000.typ +86 -0
  82. demolab_cli/scaffold/demo/writings/ar003.typ +82 -0
  83. demolab_cli/scaffold/demo/writings/ar004.slide.typ +418 -0
  84. demolab_cli/scaffold/demo/writings/ar005.slide.typ +294 -0
  85. demolab_cli/scaffold/demo/writings/ar006.typ +148 -0
  86. demolab_cli/scaffold/demo/writings/ar010.typ +54 -0
  87. demolab_cli/scaffold/demo/writings/ar011.typ +32 -0
  88. demolab_cli/scaffold/demo/writings/ar012.typ +52 -0
  89. demolab_cli/scaffold/demo/writings/ar013.typ +47 -0
  90. demolab_cli/scaffold/demo/writings/ar014.typ +56 -0
  91. demolab_cli/scaffold/demo/writings/ar015.typ +33 -0
  92. demolab_cli/scaffold/demo/writings/ar016.typ +29 -0
  93. demolab_cli/scaffold/demo/writings/ar017.typ +98 -0
  94. demolab_cli/scaffold/demo/writings/ar018.typ +128 -0
  95. demolab_cli/scaffold/demo/writings/exp000.typ +77 -0
  96. demolab_cli/scaffold/demo/writings/exp001.typ +81 -0
  97. demolab_cli/scaffold/demo/writings/exp002.typ +47 -0
  98. demolab_cli/scaffold/demo/writings/exp003.typ +61 -0
  99. demolab_cli/scaffold/demo-manifest.json +54 -0
  100. demolab_cli/scaffold/root/AGENTS.md +54 -0
  101. demolab_cli/scaffold/root/CLAUDE.md +18 -0
  102. demolab_cli/scaffold/root/README.md +28 -0
  103. demolab_cli/scaffold/root/github/workflows/tests.yml +21 -0
  104. demolab_cli/scaffold/root/gitignore +30 -0
  105. demolab_cli/scaffold/root/pyproject.toml +32 -0
  106. demolab_cli/scaffold/skeleton/HOUSESTYLE.local.md +25 -0
  107. demolab_cli/scaffold/skeleton/artifacts/data/.gitkeep +0 -0
  108. demolab_cli/scaffold/skeleton/artifacts/pdfs/.gitkeep +0 -0
  109. demolab_cli/scaffold/skeleton/demolab.yaml +40 -0
  110. demolab_cli/scaffold/skeleton/experiments/helpers/__init__.py +1 -0
  111. demolab_cli/scaffold/skeleton/experiments/helpers/provenance.py +78 -0
  112. demolab_cli/scaffold/skeleton/experiments/helpers/style.py +39 -0
  113. demolab_cli/scaffold/skeleton/experiments/helpers/test_provenance.py +37 -0
  114. demolab_cli/scaffold/skeleton/tools/.gitkeep +0 -0
  115. demolab_cli/scaffold/skeleton/writings/.gitkeep +0 -0
  116. demolab_cli/scaffold/starters/monte-carlo-pi/README.md +41 -0
  117. demolab_cli/scaffold/starters/monte-carlo-pi/exp000.py +110 -0
  118. demolab_cli/scaffold/starters/monte-carlo-pi/exp000.typ +63 -0
  119. demolab_cli/slides.py +42 -0
  120. demolab_cli/typ/cite-popover.js +38 -0
  121. demolab_cli/typ/favicon.svg +8 -0
  122. demolab_cli/typ/lib.typ +518 -0
  123. demolab_cli/typ/main.typ +92 -0
  124. demolab_cli/typ/style.css +263 -0
  125. demolab_cli-1.0.0.dist-info/METADATA +101 -0
  126. demolab_cli-1.0.0.dist-info/RECORD +129 -0
  127. demolab_cli-1.0.0.dist-info/WHEEL +4 -0
  128. demolab_cli-1.0.0.dist-info/entry_points.txt +3 -0
  129. demolab_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
demolab_cli/build.py ADDED
@@ -0,0 +1,284 @@
1
+ """Discover writings/*.typ, write a JSON manifest, and compile all three targets.
2
+
3
+ build.py does only what Typst can't: it globs the filesystem (Typst has no directory
4
+ listing) and orchestrates the compiler. It stages the engine's Typst surface into the lab
5
+ (.demolab/ + temp/bundle/main.typ — typst --root confines reads to the lab tree, and the
6
+ engine lives in site-packages), writes the discovered id/asset lists to
7
+ temp/bundle/index.json, and compiles; the static typ/main.typ reads that manifest and does
8
+ the rest (imports, documents, assets) in plain Typst — there is no generated source.
9
+
10
+ One `typst compile --format bundle --features bundle,html temp/bundle/main.typ` emits,
11
+ into artifacts/site/:
12
+ index.html — homepage index of experiments + articles
13
+ <id>.html — per-entry web page (figures inline, video plays)
14
+ <id>.mp4 — video assets
15
+ pdfs/<id>.pdf — per-entry individual PDF
16
+ pdfs/book.pdf — every entry concatenated into one PDF (book mode)
17
+
18
+ The site (artifacts/site/) is a self-contained build output (gitignored, deployed to
19
+ Pages). The PDFs are also mirrored to the committed artifacts/pdfs/ as shareable
20
+ deliverables.
21
+
22
+ Each writings/<id>.typ exposes `#let meta = (...)` and `#let body = [...]`.
23
+ Entries not yet in that convention are skipped (incremental migration).
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import re
30
+ import shutil
31
+ import subprocess
32
+ import sys
33
+ from pathlib import Path
34
+
35
+ from demolab_cli import _paths
36
+
37
+ # The lab being built. Normally found by walking up from the cwd for demolab.yaml;
38
+ # DEMOLAB_ROOT overrides it (the test fixtures and `demolab dev --demo` build materialised
39
+ # labs in scratch dirs). Falls back to the cwd so an empty dir still gets the friendly
40
+ # empty-state build rather than an import-time error.
41
+ ROOT = Path(os.environ.get("DEMOLAB_ROOT") or _paths.find_lab_root() or Path.cwd()).resolve()
42
+ WRITINGS = ROOT / "writings"
43
+ BUILD = ROOT / "temp" / "bundle" # scratch: staged main.typ + manifest + deck PDFs
44
+ MAIN = BUILD / "main.typ" # staged copy of the packaged typ/main.typ
45
+ MANIFEST = BUILD / "index.json" # scratch: id/asset lists main.typ reads
46
+ DECKS = BUILD / "decks" # scratch: compiled deck PDFs, embedded as assets
47
+ SITE = ROOT / "artifacts" / "site" # bundle output (HTML + mp4 + pdfs/), gitignored
48
+ PDFS = ROOT / "artifacts" / "pdfs" # committed copy of the PDFs (shareable)
49
+
50
+
51
+ def _find_typst() -> str:
52
+ """The typst CLI — needs --features bundle,html (experimental). A lab-local install wins
53
+ (.tools/bin — the no-package-manager fallback for locked-down machines), then PATH;
54
+ shutil.which resolves typst.exe on Windows, where a bare name can fail."""
55
+ for name in ("typst.exe", "typst"):
56
+ local = ROOT / ".tools" / "bin" / name
57
+ if local.exists():
58
+ return str(local)
59
+ return shutil.which("typst") or "typst"
60
+
61
+
62
+ TYPST = _find_typst()
63
+
64
+
65
+ def stage() -> None:
66
+ """Materialise everything Typst reads from inside the lab: .demolab/ (lib + web assets,
67
+ version-stamped) and the bundle root main.typ (copied fresh every build — it's tiny, and
68
+ a stale copy after an engine upgrade would be a subtle bug)."""
69
+ _paths.stage(ROOT)
70
+ BUILD.mkdir(parents=True, exist_ok=True)
71
+ shutil.copy2(_paths.TYP / "main.typ", MAIN)
72
+
73
+
74
+ def discover():
75
+ """Entry ids (exp*/ar*) that follow the meta+body convention, sorted.
76
+
77
+ Match real top-level definitions (`#let meta` / `#let body` at line start), not
78
+ prose or comments that merely mention them. Slide decks (`*.slide.typ`) are a
79
+ separate category — see discover_decks — so they're skipped here."""
80
+ ids = []
81
+ for p in sorted(WRITINGS.glob("*.typ")):
82
+ if p.name.endswith(".slide.typ"):
83
+ continue
84
+ lines = p.read_text().splitlines()
85
+ has_meta = any(ln.startswith("#let meta") for ln in lines)
86
+ has_body = any(ln.startswith("#let body") for ln in lines)
87
+ if has_meta and has_body:
88
+ ids.append(p.stem)
89
+ return ids
90
+
91
+
92
+ def discover_decks():
93
+ """Deck ids from `writings/<id>.slide.typ` — standalone touying slide decks, sorted.
94
+
95
+ Touying decks are paged-only (they don't survive HTML export, see the deck header
96
+ comment), so they aren't bundle entries. Instead they're compiled to standalone PDFs
97
+ and linked from the homepage. Each deck declares `#let meta` (title/date) but no
98
+ `#let body`; the meta is imported to label the link."""
99
+ return [p.name.removesuffix(".slide.typ") for p in sorted(WRITINGS.glob("*.slide.typ"))]
100
+
101
+
102
+ def write_manifest(ids: list[str], deck_ids: list[str], broken: dict | None = None) -> None:
103
+ """Write temp/bundle/index.json — the id/asset lists the staged main.typ reads.
104
+
105
+ This is the only place per-entry knowledge is assembled, and it's pure data (no Typst
106
+ source): the entry ids + kind, each entry's mp4 filenames (globbed here because Typst
107
+ can't list a directory), and the deck ids. An entry in `broken` (id -> error text) carries an
108
+ `error` field and loads no assets — main.typ renders it as a stub instead of importing it."""
109
+ broken = broken or {}
110
+ entries = []
111
+ for i in ids:
112
+ entry = {
113
+ "id": i,
114
+ "kind": "experiment" if i.startswith("exp") else "article",
115
+ "videos": [] if i in broken else [v.name for v in sorted((ROOT / "artifacts" / "data" / i).glob("*.mp4"))],
116
+ }
117
+ if i in broken:
118
+ entry["error"] = broken[i]
119
+ entries.append(entry)
120
+ # Signal whether the optional root demolab.yaml / landing.typ exist — Typst can't stat
121
+ # files, so main.typ only reads them (branding / the custom landing page) when these
122
+ # flags say they're there.
123
+ manifest = {
124
+ "entries": entries,
125
+ "decks": [{"id": d} for d in deck_ids],
126
+ "has_brand_config": (ROOT / "demolab.yaml").exists(),
127
+ "has_landing": (ROOT / "landing.typ").exists(),
128
+ }
129
+ MANIFEST.write_text(json.dumps(manifest, indent=2) + "\n")
130
+
131
+
132
+ def compile_decks(deck_ids: list[str]) -> list[str]:
133
+ """Compile each standalone deck to a scratch PDF (temp/bundle/decks/<id>.pdf); return the ones
134
+ that built. A deck that fails to compile is skipped with a warning rather than failing the whole
135
+ build (main.typ only embeds decks that produced a PDF).
136
+
137
+ main.typ embeds these as bundle assets at pdfs/<id>.pdf. Must run before the bundle compile so
138
+ the asset `read(...)` finds the files. The dev server (devserver.py) reruns this on every
139
+ change, so deck edits and new decks live-reload like any entry."""
140
+ DECKS.mkdir(parents=True, exist_ok=True)
141
+ good = []
142
+ for d in deck_ids:
143
+ proc = subprocess.run(
144
+ [TYPST, "compile", "--root", str(ROOT),
145
+ str(WRITINGS / f"{d}.slide.typ"), str(DECKS / f"{d}.pdf")],
146
+ capture_output=True, text=True,
147
+ )
148
+ if proc.returncode == 0:
149
+ good.append(d)
150
+ else:
151
+ print(f" ⚠ deck {d} failed to build — skipping it: "
152
+ + _error_excerpt(proc.stdout + proc.stderr).splitlines()[0], flush=True)
153
+ return good
154
+
155
+
156
+ def _entry_from_error(err: str, candidates: set) -> str | None:
157
+ """Which entry did a bundle-compile error come from? Parsed from a `/writings/<id>.typ` mention
158
+ in the message (only ids we can still drop)."""
159
+ for m in re.finditer(r"/writings/([A-Za-z0-9_-]+)\.typ", err):
160
+ if m.group(1) in candidates:
161
+ return m.group(1)
162
+ return None
163
+
164
+
165
+ def _error_excerpt(err: str, lines: int = 8) -> str:
166
+ """The first `error:` block from Typst's output, for the stub page and the warning."""
167
+ rows = err.splitlines()
168
+ for i, row in enumerate(rows):
169
+ if row.lstrip().startswith("error:"):
170
+ return "\n".join(rows[i:i + lines]).strip()
171
+ return err.strip() or "build failed"
172
+
173
+
174
+ def compile_bundle(ids: list[str], deck_ids: list[str]) -> dict:
175
+ """Compile the whole bundle. If an entry fails (a missing figure, a Typst error), flag it and
176
+ retry, so it renders as a stub page instead of taking the rest of the site down with it. Returns
177
+ the {id: error} map of entries that were stubbed."""
178
+ broken: dict = {}
179
+ while True:
180
+ write_manifest(ids, deck_ids, broken=broken)
181
+ proc = subprocess.run(
182
+ [TYPST, "compile", "--format", "bundle", "--features", "bundle,html",
183
+ "--root", str(ROOT), str(MAIN), str(SITE) + "/"],
184
+ capture_output=True, text=True,
185
+ )
186
+ if proc.returncode == 0:
187
+ return broken
188
+ err = proc.stdout + proc.stderr
189
+ bad = _entry_from_error(err, set(ids) - broken.keys())
190
+ if bad is None:
191
+ # Not attributable to one entry (an engine, asset, or deck error): surface the real
192
+ # failure rather than looping.
193
+ sys.stderr.write(err)
194
+ raise subprocess.CalledProcessError(proc.returncode, proc.args, proc.stdout, proc.stderr)
195
+ broken[bad] = _error_excerpt(err)
196
+ print(f" ⚠ {bad} failed to build — stubbing it, keeping the rest: "
197
+ + broken[bad].splitlines()[0], flush=True)
198
+
199
+
200
+ def _warn_if_content_misplaced(ids: list[str]) -> None:
201
+ """Tripwire for the classic agent slip: creating experiments/ or writings/ one level ABOVE
202
+ the lab — a patch applied from the workspace root instead of the lab root. build.py globs
203
+ from the lab root (found by its demolab.yaml marker, not the bare cwd), so misplaced files are
204
+ simply invisible: the build quietly renders the empty-state homepage and gives no signal that
205
+ the work landed in the wrong place. So when the root has no content, peek at the parent dir; if
206
+ the stray dirs are sitting there with real files, say so loudly and point at the fix.
207
+
208
+ A warning, not an error: an empty scaffold is a valid state, and a nested/monorepo layout could
209
+ put an unrelated experiments/ in the parent — we don't want a false positive to block a build.
210
+ ASCII only: this output is often captured by an agent harness on Windows (CP1252), where a
211
+ stray non-ASCII byte would crash the pipe (see devserver.py)."""
212
+ # A populated lab has writings (ids) or a top-level experiment runner. helpers/*.py in a bare
213
+ # scaffold live in a subdir, so a non-recursive experiments/*.py glob stays empty until there's
214
+ # a real runner — no false "has content" on a fresh scaffold.
215
+ if ids or any((ROOT / "experiments").glob("*.py")):
216
+ return
217
+ stray = [d for d, pat in ((ROOT.parent / "writings", "*.typ"),
218
+ (ROOT.parent / "experiments", "*.py"))
219
+ if d.is_dir() and any(d.glob(pat))]
220
+ if not stray:
221
+ return
222
+ print("WARNING: this lab has no content at its root, but found populated content OUTSIDE the",
223
+ file=sys.stderr)
224
+ print(f" lab root ({ROOT}) — the build cannot see these:", file=sys.stderr)
225
+ for d in stray:
226
+ print(f" {d}", file=sys.stderr)
227
+ print(" This usually means files were created from the workspace root instead of the",
228
+ file=sys.stderr)
229
+ print(f" lab root. Move them into {ROOT} (writings/, experiments/) and rebuild.",
230
+ file=sys.stderr)
231
+
232
+
233
+ def main() -> None:
234
+ # --generate-only writes the manifest + deck PDFs without compiling the bundle: a hand
235
+ # tool for inspecting what the compiler will see. (Dev serving is devserver.py, which runs
236
+ # a full build on each change; it doesn't use this flag.)
237
+ generate_only = "--generate-only" in sys.argv
238
+ # --skip-decks reuses the deck PDFs already in temp/bundle/decks/ instead of recompiling
239
+ # them. The dev server passes it when a change touched no deck source or data asset, so a
240
+ # prose/CSS/lib edit doesn't pay for deck compilation it can't have affected. Safe only when
241
+ # those PDFs exist (a full build ran first); a bare `demolab build` never skips.
242
+ skip_decks = "--skip-decks" in sys.argv
243
+ stage()
244
+ ids = discover()
245
+ _warn_if_content_misplaced(ids)
246
+ deck_ids = discover_decks()
247
+ # Zero writings is a valid state (a freshly `demolab scaffold`-ed repo): main.typ renders
248
+ # a friendly empty-state homepage, so we build rather than error.
249
+ # Compile decks first so their PDFs exist for the asset embeds in main.typ (skip reuses the
250
+ # PDFs already on disk). Either way, only decks that actually have a PDF are referenced.
251
+ if skip_decks:
252
+ good_decks = [d for d in deck_ids if (DECKS / f"{d}.pdf").exists()]
253
+ else:
254
+ good_decks = compile_decks(deck_ids)
255
+ write_manifest(ids, good_decks)
256
+ SITE.mkdir(parents=True, exist_ok=True)
257
+ if generate_only:
258
+ print(f"wrote manifest for {len(ids)} entries: {', '.join(ids)}"
259
+ + (f" + {len(good_decks)} decks: {', '.join(good_decks)}" if good_decks else ""))
260
+ return
261
+ # One bad entry (a missing figure, a Typst error) becomes a stub page instead of failing the
262
+ # whole site — compile_bundle flags it and retries.
263
+ broken = compile_bundle(ids, good_decks)
264
+ good = [i for i in ids if i not in broken]
265
+ # mirror the compiled PDFs (entries, book, and decks) to the committed artifacts/pdfs/
266
+ PDFS.mkdir(parents=True, exist_ok=True)
267
+ for pdf in sorted((SITE / "pdfs").glob("*.pdf")):
268
+ shutil.copy(pdf, PDFS / pdf.name)
269
+ # The verbose detail (which ids built / stubbed, where the PDFs mirror) goes on its own line;
270
+ # the CONCISE summary is printed LAST, because the dev-server watch loop echoes only build.py's
271
+ # final stdout line on each rebuild. So a `demolab dev` session shows a terse one-liner, while a
272
+ # one-shot `demolab build` still prints the full id list above it.
273
+ print(f" entries: {', '.join(good)}"
274
+ + (f" · decks: {', '.join(good_decks)}" if good_decks else "")
275
+ + (f" · ⚠ stubbed: {', '.join(sorted(broken))}" if broken else "")
276
+ + f" · pdfs -> {PDFS.relative_to(ROOT)}/")
277
+ summary = f"built {len(good)} entries" + (f" + {len(good_decks)} decks" if good_decks else "")
278
+ if broken:
279
+ summary += f", {len(broken)} stubbed"
280
+ print(f"{summary} -> {SITE.relative_to(ROOT)}/", flush=True)
281
+
282
+
283
+ if __name__ == "__main__":
284
+ sys.exit(main())