@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.
- package/LICENSE +373 -0
- package/README.md +82 -0
- package/package.json +70 -0
- package/src/CaptureView.tsx +96 -0
- package/src/CodeMagic.tsx +76 -0
- package/src/Deck.tsx +286 -0
- package/src/DeckChrome.tsx +240 -0
- package/src/HelpOverlay.tsx +156 -0
- package/src/MobileHint.tsx +71 -0
- package/src/PersistentLayer.tsx +168 -0
- package/src/Present.tsx +113 -0
- package/src/PresenterView.tsx +454 -0
- package/src/PrintView.tsx +151 -0
- package/src/QrOverlay.tsx +133 -0
- package/src/Stage.tsx +82 -0
- package/src/Thumb.tsx +36 -0
- package/src/build/buildDeck.ts +321 -0
- package/src/build/capture-protocol.ts +55 -0
- package/src/build/licenses.ts +336 -0
- package/src/build/mdx-plugin.ts +30 -0
- package/src/build/source-attr.ts +4 -0
- package/src/build/source-package.ts +210 -0
- package/src/build/thumbnails.ts +49 -0
- package/src/build/visx-esm-plugin.ts +42 -0
- package/src/code/diff.ts +61 -0
- package/src/code/macro.ts +24 -0
- package/src/code/tokenize.ts +72 -0
- package/src/code/types.ts +24 -0
- package/src/delivery.ts +32 -0
- package/src/index.ts +55 -0
- package/src/live/Plugin.tsx +160 -0
- package/src/live/PluginBoundary.tsx +34 -0
- package/src/live/breakout.tsx +235 -0
- package/src/live/connect.ts +149 -0
- package/src/live/deckIndex.ts +77 -0
- package/src/live/detect.ts +17 -0
- package/src/live/globalChrome.tsx +185 -0
- package/src/live/globals.ts +15 -0
- package/src/live/index.ts +7 -0
- package/src/live/participant.ts +41 -0
- package/src/live/presenterPanel.tsx +281 -0
- package/src/live/ui.ts +8 -0
- package/src/mobile.ts +59 -0
- package/src/nav.ts +149 -0
- package/src/slides.ts +19 -0
- package/src/source.ts +9 -0
- package/src/steps.tsx +117 -0
- package/src/thumbnails.ts +31 -0
- package/src/transitions.ts +88 -0
- package/src/useCoarsePointer.ts +17 -0
- 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
|
+
}
|