@liebstoeckel/engine 0.3.5

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 (51) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +82 -0
  3. package/package.json +70 -0
  4. package/src/CaptureView.tsx +96 -0
  5. package/src/CodeMagic.tsx +76 -0
  6. package/src/Deck.tsx +286 -0
  7. package/src/DeckChrome.tsx +240 -0
  8. package/src/HelpOverlay.tsx +156 -0
  9. package/src/MobileHint.tsx +71 -0
  10. package/src/PersistentLayer.tsx +168 -0
  11. package/src/Present.tsx +113 -0
  12. package/src/PresenterView.tsx +454 -0
  13. package/src/PrintView.tsx +151 -0
  14. package/src/QrOverlay.tsx +133 -0
  15. package/src/Stage.tsx +82 -0
  16. package/src/Thumb.tsx +36 -0
  17. package/src/build/buildDeck.ts +321 -0
  18. package/src/build/capture-protocol.ts +55 -0
  19. package/src/build/licenses.ts +336 -0
  20. package/src/build/mdx-plugin.ts +30 -0
  21. package/src/build/source-attr.ts +4 -0
  22. package/src/build/source-package.ts +210 -0
  23. package/src/build/thumbnails.ts +49 -0
  24. package/src/build/visx-esm-plugin.ts +42 -0
  25. package/src/code/diff.ts +61 -0
  26. package/src/code/macro.ts +24 -0
  27. package/src/code/tokenize.ts +72 -0
  28. package/src/code/types.ts +24 -0
  29. package/src/delivery.ts +32 -0
  30. package/src/index.ts +55 -0
  31. package/src/live/Plugin.tsx +160 -0
  32. package/src/live/PluginBoundary.tsx +34 -0
  33. package/src/live/breakout.tsx +235 -0
  34. package/src/live/connect.ts +149 -0
  35. package/src/live/deckIndex.ts +77 -0
  36. package/src/live/detect.ts +17 -0
  37. package/src/live/globalChrome.tsx +185 -0
  38. package/src/live/globals.ts +15 -0
  39. package/src/live/index.ts +7 -0
  40. package/src/live/participant.ts +41 -0
  41. package/src/live/presenterPanel.tsx +281 -0
  42. package/src/live/ui.ts +8 -0
  43. package/src/mobile.ts +59 -0
  44. package/src/nav.ts +149 -0
  45. package/src/slides.ts +19 -0
  46. package/src/source.ts +9 -0
  47. package/src/steps.tsx +117 -0
  48. package/src/thumbnails.ts +31 -0
  49. package/src/transitions.ts +88 -0
  50. package/src/useCoarsePointer.ts +17 -0
  51. package/src/useDeckSync.ts +85 -0
@@ -0,0 +1,133 @@
1
+ import { useEffect, useState } from "react";
2
+ import { AnimatePresence, motion } from "motion/react";
3
+ import QRCode from "qrcode";
4
+
5
+ /** Generate a QR data-URL for `url` while `enabled` (no work when closed). */
6
+ function useQrDataUrl(url: string | undefined, enabled: boolean): string {
7
+ const [src, setSrc] = useState("");
8
+ useEffect(() => {
9
+ if (enabled && url) {
10
+ QRCode.toDataURL(url, { margin: 1, width: 420, color: { dark: "#0b0c10", light: "#ffffff" } })
11
+ .then(setSrc)
12
+ .catch(() => setSrc(""));
13
+ }
14
+ }, [enabled, url]);
15
+ return src;
16
+ }
17
+
18
+ /** Close on Escape while `open`. */
19
+ function useEscToClose(open: boolean, onClose: () => void) {
20
+ useEffect(() => {
21
+ if (!open) return;
22
+ const onKey = (e: KeyboardEvent) => {
23
+ if (e.key === "Escape") onClose();
24
+ };
25
+ window.addEventListener("keydown", onKey);
26
+ return () => window.removeEventListener("keydown", onKey);
27
+ }, [open, onClose]);
28
+ }
29
+
30
+ export type QrItem = { url?: string; label: string; sub?: string };
31
+
32
+ /** A labelled QR + the URL underneath. Renders nothing without a url. */
33
+ function QrCard({ url, label, sub, enabled, size }: { url?: string; label: string; sub?: string; enabled: boolean; size: number }) {
34
+ const src = useQrDataUrl(url, enabled);
35
+ if (!url) return null;
36
+ return (
37
+ <div className="flex flex-col items-center gap-4">
38
+ {label && <div className="font-mono text-xs uppercase tracking-[0.3em] text-accent">{label}</div>}
39
+ {src && (
40
+ <motion.img
41
+ src={src}
42
+ alt={label || "QR code"}
43
+ width={size}
44
+ height={size}
45
+ className="rounded-2xl border border-border shadow-2xl"
46
+ initial={{ scale: 0.92 }}
47
+ animate={{ scale: 1 }}
48
+ transition={{ type: "spring", stiffness: 220, damping: 20 }}
49
+ />
50
+ )}
51
+ {sub && <div className="max-w-[18rem] break-all text-center font-mono text-[11px] text-muted">{sub}</div>}
52
+ </div>
53
+ );
54
+ }
55
+
56
+ /** The one QR-share surface: a full-screen, blurred, dismissable overlay (Esc /
57
+ * backdrop tap / ✕) showing one or more labelled QRs. A single QR renders big
58
+ * (the audience "scan to follow along"); several render as a row (presenter share).
59
+ * `QrOverlay` and `PresenterShare` are thin wrappers so behaviour can't drift. */
60
+ export function QrShare({ open, items, title, onClose }: { open: boolean; items: QrItem[]; title?: string; onClose: () => void }) {
61
+ useEscToClose(open, onClose);
62
+ const visible = items.filter((i) => i.url);
63
+ const big = visible.length <= 1;
64
+ return (
65
+ <AnimatePresence>
66
+ {open && visible.length > 0 && (
67
+ <motion.div
68
+ className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-10 overflow-y-auto p-6 backdrop-blur-2xl"
69
+ style={{ background: "color-mix(in srgb, var(--brand-bg) 80%, transparent)" }}
70
+ initial={{ opacity: 0 }}
71
+ animate={{ opacity: 1 }}
72
+ exit={{ opacity: 0 }}
73
+ onClick={onClose}
74
+ >
75
+ <button
76
+ onClick={onClose}
77
+ aria-label="Close"
78
+ className="fixed right-4 flex h-10 w-10 items-center justify-center rounded-full border border-border text-muted transition hover:border-text hover:text-text"
79
+ style={{ top: "calc(1rem + env(safe-area-inset-top))" }}
80
+ >
81
+
82
+ </button>
83
+ {title && (
84
+ <div className="flex items-center gap-3 font-mono text-sm uppercase tracking-[0.35em] text-accent">
85
+ <span className="h-px w-8 bg-accent" />
86
+ {title}
87
+ <span className="h-px w-8 bg-accent" />
88
+ </div>
89
+ )}
90
+ <div className="flex flex-wrap items-start justify-center gap-14" onClick={(e) => e.stopPropagation()}>
91
+ {visible.map((it) => (
92
+ <QrCard key={it.label} url={it.url} label={it.label} sub={it.sub} enabled={open} size={big ? 300 : 240} />
93
+ ))}
94
+ </div>
95
+ <div className="font-mono text-xs text-muted">tap outside, ✕, or press Q / Esc to close</div>
96
+ </motion.div>
97
+ )}
98
+ </AnimatePresence>
99
+ );
100
+ }
101
+
102
+ /** Audience: a single big "follow along" QR (the read-only link). Toggled with Q in
103
+ * the Deck during a live session. */
104
+ export function QrOverlay({ open, url, onClose }: { open: boolean; url?: string; onClose: () => void }) {
105
+ return <QrShare open={open} onClose={onClose} items={[{ url, label: "Scan to follow along", sub: url }]} />;
106
+ }
107
+
108
+ /** Presenter: BOTH links, "Follow along" (read-only viewer) and "Drive from your
109
+ * phone" (presenter; scanning joins as a second driver). Q / header button in the
110
+ * presenter view. (DESIGN §QR & handoff.) */
111
+ export function PresenterShare({
112
+ open,
113
+ viewerUrl,
114
+ presenterUrl,
115
+ onClose,
116
+ }: {
117
+ open: boolean;
118
+ viewerUrl?: string;
119
+ presenterUrl?: string;
120
+ onClose: () => void;
121
+ }) {
122
+ return (
123
+ <QrShare
124
+ open={open}
125
+ onClose={onClose}
126
+ title="share this session"
127
+ items={[
128
+ { url: viewerUrl, label: "Follow along", sub: viewerUrl },
129
+ { url: presenterUrl, label: "Drive from your phone", sub: presenterUrl },
130
+ ]}
131
+ />
132
+ );
133
+ }
package/src/Stage.tsx ADDED
@@ -0,0 +1,82 @@
1
+ import { createContext, useLayoutEffect, useRef, useState, type ReactNode } from "react";
2
+ import { motion } from "motion/react";
3
+ import { Atmosphere } from "@liebstoeckel/components";
4
+
5
+ // Logical canvas. Everything is authored at this size and scaled to fit, so the
6
+ // audience view and presenter thumbnails are pixel-identical (just different scale).
7
+ export const STAGE_W = 1280;
8
+ export const STAGE_H = 720;
9
+
10
+ /** The factor the canvas is scaled by to fit its parent (1 = unscaled). Consumers
11
+ * (e.g. plugins) read it to decide when inline controls are too small to tap. */
12
+ export const StageScaleContext = createContext(1);
13
+
14
+ /** Fits a fixed STAGE_W×STAGE_H canvas into its parent, centered + letterboxed.
15
+ * The fit transform is expressed as **Motion values**, `scale` + a centering
16
+ * `x`/`y` translate, about a **top-left** origin, not a CSS `transform` string and
17
+ * not a center `transform-origin`. Motion's layout-projection tree only accounts
18
+ * for transforms it owns and assumes a top-left origin, so this is what keeps
19
+ * `layoutId`/`layout` morphs (`Magic`, `CodeMagic`) correct under the scaled stage
20
+ * (Motion #3356/#874). The outer div must be a positioned containing block for the
21
+ * absolute canvas (all consumers pass `absolute`/`fixed inset-0`). */
22
+ export function ScaledStage({ children, className }: { children: ReactNode; className?: string }) {
23
+ const ref = useRef<HTMLDivElement>(null);
24
+ const [fit, setFit] = useState({ scale: 0, x: 0, y: 0 });
25
+
26
+ useLayoutEffect(() => {
27
+ const el = ref.current!;
28
+ const measure = () => {
29
+ const { width, height } = el.getBoundingClientRect();
30
+ const scale = Math.min(width / STAGE_W, height / STAGE_H);
31
+ setFit({ scale, x: (width - STAGE_W * scale) / 2, y: (height - STAGE_H * scale) / 2 });
32
+ };
33
+ measure();
34
+ const ro = new ResizeObserver(measure);
35
+ ro.observe(el);
36
+ return () => ro.disconnect();
37
+ }, []);
38
+
39
+ return (
40
+ <div ref={ref} className={`overflow-hidden bg-bg ${className ?? ""}`}>
41
+ <StageScaleContext.Provider value={fit.scale || 1}>
42
+ <motion.div
43
+ style={{
44
+ position: "absolute",
45
+ top: 0,
46
+ left: 0,
47
+ width: STAGE_W,
48
+ height: STAGE_H,
49
+ originX: 0,
50
+ originY: 0,
51
+ scale: fit.scale || 0.0001,
52
+ x: fit.x,
53
+ y: fit.y,
54
+ visibility: fit.scale ? "visible" : "hidden",
55
+ }}
56
+ >
57
+ {children}
58
+ </motion.div>
59
+ </StageScaleContext.Provider>
60
+ </div>
61
+ );
62
+ }
63
+
64
+ /** The visual frame of a slide: brand background + atmosphere + a padded content
65
+ * area. Slides can break out with absolute positioning for full-bleed charts.
66
+ * `still` renders the motionless atmosphere (thumbnails / capture). */
67
+ export function SlideFrame({
68
+ children,
69
+ atmosphere = true,
70
+ still = false,
71
+ }: {
72
+ children: ReactNode;
73
+ atmosphere?: boolean;
74
+ still?: boolean;
75
+ }) {
76
+ return (
77
+ <div className="absolute inset-0 overflow-hidden bg-bg">
78
+ {atmosphere && <Atmosphere still={still} />}
79
+ <div className="absolute inset-0 flex flex-col justify-center px-24 py-20">{children}</div>
80
+ </div>
81
+ );
82
+ }
package/src/Thumb.tsx ADDED
@@ -0,0 +1,36 @@
1
+ import type { ComponentType } from "react";
2
+ import { MDXProvider } from "@mdx-js/react";
3
+ import { mdxComponents } from "@liebstoeckel/components";
4
+ import { ScaledStage, SlideFrame } from "./Stage";
5
+ import { PersistentProvider } from "./PersistentLayer";
6
+
7
+ /** A scaled, non-interactive thumbnail of a slide (overview + presenter).
8
+ *
9
+ * Prefers a pre-rendered image (`src`, from the build-time thumbnails manifest), * a cheap `<img>` instead of a live React subtree. With no `src` it falls back to
10
+ * rendering the slide **statically** (atmosphere frozen, no persistent layer);
11
+ * Slots register harmlessly. */
12
+ export function DeckThumb({ Component, src, alt }: { Component?: ComponentType; src?: string; alt?: string }) {
13
+ if (src) {
14
+ return (
15
+ <img
16
+ src={src}
17
+ alt={alt ?? ""}
18
+ loading="lazy"
19
+ decoding="async"
20
+ draggable={false}
21
+ className="h-full w-full object-cover"
22
+ />
23
+ );
24
+ }
25
+ return (
26
+ <div className="relative h-full w-full overflow-hidden bg-bg">
27
+ <MDXProvider components={mdxComponents}>
28
+ <PersistentProvider>
29
+ <ScaledStage className="absolute inset-0">
30
+ <SlideFrame still>{Component ? <Component /> : null}</SlideFrame>
31
+ </ScaledStage>
32
+ </PersistentProvider>
33
+ </MDXProvider>
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,321 @@
1
+ import { rm } from "node:fs/promises";
2
+ import { basename, join, dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import tailwind from "bun-plugin-tailwind";
5
+ import { discoverFromPackageJson } from "@liebstoeckel/plugin-sdk/discovery";
6
+ import {
7
+ embedManifest,
8
+ encodeServerBundle,
9
+ type PluginManifest,
10
+ type PluginManifestEntry,
11
+ } from "@liebstoeckel/plugin-sdk/manifest";
12
+ import mdx from "./mdx-plugin";
13
+ import visxEsmInterop from "./visx-esm-plugin";
14
+ import {
15
+ createLicenseCollector,
16
+ renderNotices,
17
+ embedLicenses,
18
+ formatFirstPartyConflicts,
19
+ type LicenseReport,
20
+ } from "./licenses";
21
+
22
+ // The single-copy guard primitives are part of the public build surface (the e2e
23
+ // upgrade tier reduces over a real installed tree with them; ADR 0093).
24
+ export { firstPartyVersionConflicts, formatFirstPartyConflicts, type FirstPartyConflict } from "./licenses";
25
+
26
+ /** The plugins every deck build runs (Tailwind CSS gen, MDX compile, visx ESM
27
+ * interop). Shared so the license collector and any collect-only build resolve
28
+ * the exact same module graph as a real build. */
29
+ const DECK_PLUGINS = [tailwind, mdx, visxEsmInterop];
30
+
31
+ /** Default first-party notice embedded alongside the third-party block, the
32
+ * MPL-2.0 line + public source pointer that MPL §3.2(b)/§3.4 require to travel
33
+ * with the inlined engine code. */
34
+ const DEFAULT_SELF_NOTICE =
35
+ "This presentation embeds the liebstoeckel presentation engine, licensed under\n" +
36
+ "the Mozilla Public License 2.0. Source code for the covered files is available\n" +
37
+ "at https://github.com/liebstoeckel/liebstoeckel, you may obtain it at no charge.";
38
+
39
+ /** Bundle a plugin's server entry into a self-contained, base64-encoded ESM module
40
+ * (target:"bun"). It externalizes nothing host-specific because the host injects
41
+ * `ctx`, so it rehydrates with no node_modules resolution. */
42
+ export async function buildServerBundle(entry: string): Promise<string> {
43
+ const built = await Bun.build({ entrypoints: [entry], target: "bun", format: "esm", minify: true });
44
+ if (!built.success) {
45
+ for (const log of built.logs) console.error(log);
46
+ throw new Error(`Failed to build server plugin bundle: ${entry}`);
47
+ }
48
+ return encodeServerBundle(await built.outputs[0]!.text());
49
+ }
50
+
51
+ /** Discover the deck's plugins (from package.json deps) and build a manifest:
52
+ * every plugin listed; server entries embedded as base64. */
53
+ export async function buildPluginManifest(pkgJsonPath: string): Promise<PluginManifest | null> {
54
+ let plugins;
55
+ try {
56
+ plugins = await discoverFromPackageJson(pkgJsonPath);
57
+ } catch {
58
+ return null; // no package.json / unreadable → no plugins
59
+ }
60
+ if (plugins.length === 0) return null;
61
+
62
+ const entries: PluginManifestEntry[] = [];
63
+ for (const p of plugins) {
64
+ const server = p.serverEntry ? await buildServerBundle(p.serverEntry) : undefined;
65
+ entries.push({
66
+ name: p.name,
67
+ version: p.version,
68
+ hasServer: !!server,
69
+ server,
70
+ id: p.id,
71
+ audienceWrites: p.audienceWrites,
72
+ });
73
+ }
74
+ return { v: 1, plugins: entries };
75
+ }
76
+
77
+ /** The deck's own package name (so the license report can exclude it, a deck never
78
+ * lists itself). Best-effort: a missing/unreadable package.json just yields undefined. */
79
+ async function readPkgName(pkgJsonPath: string): Promise<string | undefined> {
80
+ try {
81
+ return (await Bun.file(pkgJsonPath).json()).name;
82
+ } catch {
83
+ return undefined;
84
+ }
85
+ }
86
+
87
+ /** Bun inlines the JS bundle as `<script type="module">…</script>`, but does NOT escape
88
+ * `</script>` sequences that occur inside JS string literals (e.g. a deck embedding HTML
89
+ * through an iframe `srcdoc`). The HTML parser would close the inline script at the first
90
+ * such `</script>`, dumping the rest of the bundle as text. Escape every `</script` →
91
+ * `<\/script` inside the module body (a no-op for the JS string value, but no longer a
92
+ * tag terminator). Runs on Bun's fresh output, where the module bundle is the document's
93
+ * last element, so its real terminator is the final `</script>`. */
94
+ export function escapeInlineModuleScript(html: string): string {
95
+ const open = '<script type="module">';
96
+ const start = html.indexOf(open);
97
+ if (start < 0) return html;
98
+ const contentStart = start + open.length;
99
+ const close = html.lastIndexOf("</script>");
100
+ if (close <= contentStart) return html;
101
+ const body = html.slice(contentStart, close).replace(/<\/script/gi, "<\\/script");
102
+ return html.slice(0, contentStart) + body + html.slice(close);
103
+ }
104
+
105
+ /** Identifies the tool that drove the build (e.g. the CLI), stamped alongside the
106
+ * engine version so a built deck records what produced it. */
107
+ export interface Generator {
108
+ name: string;
109
+ version: string;
110
+ }
111
+
112
+ const ENGINE_PKG = fileURLToPath(new URL("../../package.json", import.meta.url));
113
+
114
+ /** The engine's own version, read from its package.json. Best-effort: a build must
115
+ * never fail because a version string couldn't be read. */
116
+ async function engineVersion(): Promise<string> {
117
+ try {
118
+ return ((await Bun.file(ENGINE_PKG).json()) as { version?: string }).version ?? "0.0.0";
119
+ } catch {
120
+ return "0.0.0";
121
+ }
122
+ }
123
+
124
+ /** Stamp the build's provenance into the document head: a human-readable comment
125
+ * right after the doctype (the first thing in "view source") and a standard
126
+ * `<meta name="generator">` that tooling can parse. Records the engine version
127
+ * (always) and the invoking tool's version (e.g. the CLI) when the caller passes
128
+ * one. Versions only, no timestamp, so the same deck still builds to the same
129
+ * bytes (the source-package embed is byte-deterministic). */
130
+ export function stampGenerator(html: string, parts: { engine: string; generator?: Generator }): string {
131
+ const tools = [`engine ${parts.engine}`];
132
+ if (parts.generator) tools.push(`${parts.generator.name} ${parts.generator.version}`);
133
+ const summary = `liebstoeckel ${tools.join(", ")}`;
134
+ return html
135
+ .replace(/<!doctype html>/i, (m) => `${m}\n<!-- Generated by ${summary} · https://liebstoeckel.app -->`)
136
+ .replace(/<head[^>]*>/i, (m) => `${m}\n <meta name="generator" content="${summary}" />`);
137
+ }
138
+
139
+ // Bundles a deck into a single self-contained .html, the browser-free build
140
+ // primitive (no Chromium). Plugins (Tailwind, MDX) only run via the Bun.build()
141
+ // JS API, NOT the `bun build` CLI. `compile:true` + target:"browser" inline
142
+ // JS/CSS and base64 the assets. Verified on Bun 1.3.
143
+ //
144
+ // For the batteries-included default (this + slide thumbnails) use `buildDeck`
145
+ // from `@liebstoeckel/thumbnails/build`, which wraps this. Kept here, dependency-
146
+ // free, so engine never pulls in the headless-browser capturer (playwright-core).
147
+ export async function bundleDeck({
148
+ entry = "./index.html",
149
+ outdir = "./dist",
150
+ outfile = "index.html",
151
+ minify = true,
152
+ pkgJson = "./package.json",
153
+ inlinePackage = true,
154
+ inlineLicenses = true,
155
+ selfNotice = DEFAULT_SELF_NOTICE,
156
+ allowSecret = false,
157
+ generator,
158
+ }: {
159
+ entry?: string;
160
+ outdir?: string;
161
+ /** Final artifact name within `outdir` (default `index.html`; the user-facing
162
+ * `buildDeck` wrapper passes the deck slug, e.g. `poll-demo.html`, ADR 0068). */
163
+ outfile?: string;
164
+ minify?: boolean;
165
+ pkgJson?: string;
166
+ /** Embed the deck's own source as a recoverable package so the .html is ejectable (ADR 0039). */
167
+ inlinePackage?: boolean;
168
+ /** Embed a THIRD-PARTY-NOTICES block computed from the bundle's real module graph.
169
+ * Minify strips license comments, so we re-add the required notices. */
170
+ inlineLicenses?: boolean;
171
+ /** First-party notice prepended to the notices block (default: the MPL line). */
172
+ selfNotice?: string;
173
+ /** Force the source-embed past its secret gate (loud, explicit). */
174
+ allowSecret?: boolean;
175
+ /** The tool driving the build (e.g. the CLI), recorded in the generator stamp
176
+ * next to the engine version. Omit when the engine builds on its own behalf. */
177
+ generator?: Generator;
178
+ } = {}) {
179
+ // The collector always runs (a pure onLoad observer): besides licenses it backs the
180
+ // single-copy guard below, which must hold whether or not we embed notices.
181
+ const licenses = createLicenseCollector({ selfName: await readPkgName(pkgJson) });
182
+ const result = await Bun.build({
183
+ entrypoints: [entry],
184
+ outdir,
185
+ minify,
186
+ target: "browser",
187
+ compile: true,
188
+ plugins: [...DECK_PLUGINS, licenses.plugin],
189
+ });
190
+
191
+ if (!result.success) {
192
+ for (const log of result.logs) console.error(log);
193
+ throw new Error("Deck build failed");
194
+ }
195
+
196
+ // Fail loud if the bundle pulled two versions of a liebstoeckel package — a deck is
197
+ // one inlined .html, so mixed framework versions ship duplicate, incompatible copies.
198
+ const conflicts = licenses.conflicts();
199
+ if (conflicts.length) throw new Error(formatFirstPartyConflicts(conflicts));
200
+
201
+ // Escape `</script>` inside the inlined bundle, then embed the plugin manifest
202
+ // (incl. base64 server bundles) into the single file. Bun.build names its output
203
+ // after the entry basename (`index.html`); we post-process that and ship it under
204
+ // `outfile` (the deck slug for the user-facing build, ADR 0068).
205
+ const built = join(outdir, basename(entry));
206
+ const outHtml = join(outdir, outfile);
207
+ let html = escapeInlineModuleScript(await Bun.file(built).text());
208
+ const manifest = await buildPluginManifest(pkgJson);
209
+ if (manifest) html = embedManifest(html, manifest);
210
+
211
+ // Re-add the third-party license notices that minify stripped, computed
212
+ // from the modules this build actually bundled, so it tracks font/lib swaps.
213
+ if (inlineLicenses) {
214
+ const report = licenses.report();
215
+ html = embedLicenses(html, renderNotices(report, { selfNotice }));
216
+ const flagged = report.flagged.length ? `, ⚠ ${report.flagged.length} non-standard license(s)` : "";
217
+ console.log(`✓ embedded license notices (${report.packages.length} third-party packages)${flagged}`);
218
+ }
219
+
220
+ // Embed the deck's own source so the compiled .html ejects back to an editable project.
221
+ if (inlinePackage) {
222
+ const { collectDeckTarball, embedSource } = await import("./source-package");
223
+ const { zstd, files } = await collectDeckTarball(dirname(pkgJson), { allowSecret });
224
+ html = embedSource(html, zstd);
225
+ console.log(`✓ embedded source package (${files.length} files, ${(zstd.length / 1024).toFixed(1)}KB)`);
226
+ }
227
+
228
+ // Stamp the build's provenance (engine + invoking-tool versions) into the head.
229
+ html = stampGenerator(html, { engine: await engineVersion(), generator });
230
+
231
+ await Bun.write(outHtml, html);
232
+ // Drop Bun's `index.html` emit when shipping under a different slug name.
233
+ if (built !== outHtml) await rm(built, { force: true });
234
+
235
+ return result;
236
+ }
237
+
238
+ /** Resolve a deck's third-party license report WITHOUT emitting an artifact, runs
239
+ * the same plugin set as a real build so the module graph matches, then discards
240
+ * the output. Backs `liebstoeckel licenses` over a deck directory. */
241
+ export async function collectDeckLicenses({
242
+ entry = "./index.html",
243
+ pkgJson = "./package.json",
244
+ }: { entry?: string; pkgJson?: string } = {}): Promise<LicenseReport> {
245
+ const licenses = createLicenseCollector({ selfName: await readPkgName(pkgJson) });
246
+ const result = await Bun.build({
247
+ entrypoints: [entry],
248
+ minify: false,
249
+ target: "browser",
250
+ plugins: [...DECK_PLUGINS, licenses.plugin],
251
+ });
252
+ if (!result.success) {
253
+ for (const log of result.logs) console.error(log);
254
+ throw new Error("Deck build failed");
255
+ }
256
+ return licenses.report();
257
+ }
258
+
259
+ /** A single build diagnostic, shaped for machine consumption (ADR 0045). */
260
+ export interface DeckDiagnostic {
261
+ level: string;
262
+ message: string;
263
+ file?: string;
264
+ line?: number;
265
+ column?: number;
266
+ }
267
+
268
+ function toDiagnostic(log: unknown): DeckDiagnostic {
269
+ if (typeof log === "string") return { level: "error", message: log };
270
+ const l = log as { level?: string; message?: string; position?: { file?: string; line?: number; column?: number } | null };
271
+ const pos = l?.position ?? undefined;
272
+ const pos1 = (n?: number) => (typeof n === "number" && n > 0 ? n : undefined);
273
+ return {
274
+ level: l?.level ?? "error",
275
+ message: l?.message ?? String(log),
276
+ file: pos?.file || undefined,
277
+ line: pos1(pos?.line),
278
+ column: pos1(pos?.column),
279
+ };
280
+ }
281
+
282
+ /**
283
+ * Validate that a deck **bundles**, resolves, transforms (MDX/Tailwind), and the
284
+ * visx ESM-interop holds, without writing any artifact or capturing thumbnails
285
+ * (ADR 0045). Runs the same plugin pipeline as `bundleDeck` with `throw: false` and
286
+ * returns structured diagnostics for an agent's check → fix loop. It does **not**
287
+ * type-check (Bun.build doesn't); it answers "does this deck build?".
288
+ */
289
+ export async function checkDeck({
290
+ entry = "./index.html",
291
+ pkgJson = "./package.json",
292
+ }: { entry?: string; pkgJson?: string } = {}): Promise<{
293
+ ok: boolean;
294
+ diagnostics: DeckDiagnostic[];
295
+ }> {
296
+ try {
297
+ const licenses = createLicenseCollector({ selfName: await readPkgName(pkgJson) });
298
+ const result = await Bun.build({
299
+ entrypoints: [entry],
300
+ target: "browser",
301
+ plugins: [...DECK_PLUGINS, licenses.plugin],
302
+ throw: false,
303
+ });
304
+ const diagnostics = result.logs.map(toDiagnostic);
305
+ let ok = result.success;
306
+ // Only meaningful on a graph that fully resolved; surface a duplicate liebstoeckel
307
+ // version as a loud diagnostic (ADR: the consumption-side single-copy guard).
308
+ if (ok) {
309
+ const conflicts = licenses.conflicts();
310
+ if (conflicts.length) {
311
+ ok = false;
312
+ diagnostics.push({ level: "error", message: formatFirstPartyConflicts(conflicts) });
313
+ }
314
+ }
315
+ return { ok, diagnostics };
316
+ } catch (err) {
317
+ // resolution / plugin failures can still throw (e.g. AggregateError)
318
+ const errs = err instanceof AggregateError ? err.errors : [err];
319
+ return { ok: false, diagnostics: errs.map(toDiagnostic) };
320
+ }
321
+ }
@@ -0,0 +1,55 @@
1
+ // The contract between the engine's CaptureView / PrintView and the
2
+ // @liebstoeckel/thumbnails capturer (a headless browser). The capturer sets a flag
3
+ // *before* the deck boots (so Present renders a static view, not the live Deck).
4
+ //
5
+ // Two static modes share this file:
6
+ // • Capture (thumbnails / PNG export): CAPTURE_FLAG → CaptureView. Reads
7
+ // SLIDE_COUNT, then for each slide dispatches CAPTURE_EVENT(index) and waits
8
+ // until CAPTURE_READY === index before screenshotting (one slide at a time).
9
+ // • Print (vector PDF export): PRINT_FLAG → PrintView, which stacks every slide
10
+ // onto its own print page so one `page.pdf()` yields a text-preserving,
11
+ // multi-page PDF. Reads SLIDE_COUNT, dispatches PRINT_SELECT_EVENT({indices,
12
+ // token}) to pick the slides, then waits until PRINT_READY === token.
13
+ //
14
+ // Pure (no React / DOM types beyond globalThis) so the capturer package can import
15
+ // the names without pulling the engine's React tree.
16
+
17
+ export const CAPTURE_FLAG = "__LIEBSTOECKEL_CAPTURE__";
18
+ export const SLIDE_COUNT = "__LIEBSTOECKEL_SLIDE_COUNT__";
19
+ export const CAPTURE_READY = "__LIEBSTOECKEL_CAPTURE_READY__";
20
+ export const CAPTURE_EVENT = "liebstoeckel:capture";
21
+
22
+ export const PRINT_FLAG = "__LIEBSTOECKEL_PRINT__";
23
+ export const PRINT_READY = "__LIEBSTOECKEL_PRINT_READY__";
24
+ export const PRINT_SELECT_EVENT = "liebstoeckel:print-select";
25
+
26
+ export interface CaptureFlag {
27
+ /** slide to render first (the capturer then steps through the rest) */
28
+ index?: number;
29
+ }
30
+
31
+ export interface PrintFlag {
32
+ /** 0-based slides to lay out (default: every slide). The driver usually leaves
33
+ * this empty and selects via PRINT_SELECT_EVENT once SLIDE_COUNT is known. */
34
+ indices?: number[];
35
+ }
36
+
37
+ /** The payload of a PRINT_SELECT_EVENT: which slides to render, plus a token the
38
+ * view echoes into PRINT_READY once that selection has painted (so the driver
39
+ * waits for the right frame, not a stale one). */
40
+ export interface PrintSelect {
41
+ indices: number[];
42
+ token: number;
43
+ }
44
+
45
+ /** Read the capture request the capturer injects. Absent → normal deck. */
46
+ export function captureRequest(): CaptureFlag | null {
47
+ const g = globalThis as Record<string, unknown>;
48
+ return (g[CAPTURE_FLAG] as CaptureFlag | undefined) ?? null;
49
+ }
50
+
51
+ /** Read the print request the exporter injects. Absent → not a print render. */
52
+ export function printRequest(): PrintFlag | null {
53
+ const g = globalThis as Record<string, unknown>;
54
+ return (g[PRINT_FLAG] as PrintFlag | undefined) ?? null;
55
+ }