@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,336 @@
1
+ // Bundle-time third-party license collection.
2
+ //
3
+ // A built deck inlines its client import graph, React, Motion, Yjs, the variable
4
+ // fonts, into one minified `.html`. Every one of those licenses (MIT, OFL-1.1, and
5
+ // our own MPL-2.0) requires its notice to travel with the redistributed code, but
6
+ // `minify:true` strips all comments. So we recompute the notice from the *actual*
7
+ // module graph of each build and embed it as an inert <script> (same minify-proof
8
+ // carrier as `embedSource`/`embedManifest`). Recomputing per build is the point:
9
+ // swap a font or add a chart lib and the notice updates itself, declared
10
+ // package.json deps would lie. Everything here is Bun-native (no new dependency).
11
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
12
+ import { dirname, join, sep } from "node:path";
13
+
14
+ /** Inert <script> carrier, the browser never parses it (mirrors SOURCE_ATTR). */
15
+ export const LICENSE_ATTR = "data-liebstoeckel-licenses";
16
+
17
+ const NODE_MODULES = `${sep}node_modules${sep}`;
18
+ const FIRST_PARTY = "@liebstoeckel/";
19
+
20
+ /** SPDX ids we consider satisfiable by attribution alone (no copyleft/source duties
21
+ * beyond shipping the notice, which we do). `licenses --check` fails on anything
22
+ * outside this set, a GPL/AGPL/CC-BY/unknown dep should be a loud build-time stop,
23
+ * not a silent ship. OFL-1.1 is here because we only ever *embed* fonts (allowed),
24
+ * never sell them standalone; MPL-2.0 is our own + build-tool-only deps. */
25
+ export const ALLOWED_SPDX = new Set([
26
+ "MIT",
27
+ "MIT-0",
28
+ "ISC",
29
+ "0BSD",
30
+ "BSD-2-Clause",
31
+ "BSD-3-Clause",
32
+ "Apache-2.0",
33
+ "BlueOak-1.0.0",
34
+ "Unlicense",
35
+ "CC0-1.0",
36
+ "Python-2.0",
37
+ "OFL-1.1",
38
+ "MPL-2.0",
39
+ ]);
40
+
41
+ export interface LicensePackage {
42
+ name: string;
43
+ version: string;
44
+ /** SPDX id (or expression) as declared in the package's package.json. */
45
+ license: string;
46
+ /** Verbatim LICENSE/COPYING text when the package ships one, else a synthesized
47
+ * fallback (see `fallbackLicenseText`); null only if neither is available. */
48
+ text: string | null;
49
+ /** True when `text` came from a real file on disk (vs. an SPDX template). */
50
+ fromFile: boolean;
51
+ }
52
+
53
+ export interface LicenseReport {
54
+ /** Third-party packages whose code/assets landed in the bundle. */
55
+ packages: LicensePackage[];
56
+ /** @liebstoeckel/* packages that contributed (all MPL-2.0; reported once). */
57
+ firstParty: string[];
58
+ /** SPDX ids present that are NOT in ALLOWED_SPDX (drives `--check`). */
59
+ flagged: { name: string; version: string; license: string }[];
60
+ }
61
+
62
+ /** A recorder plugin + a `report()` that resolves the recorded paths to packages.
63
+ * Add `plugin` to a `Bun.build({ plugins })` array; call `report()` after a
64
+ * successful build. The plugin only observes (always returns undefined), so it
65
+ * never alters resolution or loading. Proven to capture both JS modules and
66
+ * CSS `url()` font assets (the resolved absolute path carries the version). */
67
+ export function createLicenseCollector(opts: { selfName?: string } = {}): {
68
+ plugin: import("bun").BunPlugin;
69
+ report: () => LicenseReport;
70
+ /** @liebstoeckel/* packages that resolved to >1 version in the captured graph. */
71
+ conflicts: () => FirstPartyConflict[];
72
+ } {
73
+ const paths = new Set<string>();
74
+ const record = (p: string | undefined) => {
75
+ // Only absolute, real on-disk paths map to a package; ignore bare specifiers,
76
+ // virtual modules (`\0`-prefixed), and data: URIs.
77
+ if (p && p.startsWith(sep) && !p.includes("\0")) paths.add(p);
78
+ };
79
+ // ONLY hook onLoad, never onResolve. A catch-all `onResolve` that returns undefined
80
+ // is NOT a no-op in Bun's bundler: registering it perturbs resolution enough to
81
+ // miscompile some modules under minification (observed: a deck importing `@visx/shape`
82
+ // built minified threw `ReferenceError: <mangled> is not defined` from d3-shape's stack
83
+ // code, because a declaration got dropped while its reference survived). `onLoad` with a
84
+ // catch-all filter that returns undefined IS inert, and its `args.path` is the resolved
85
+ // absolute path of every loaded module (including CSS `url()` font assets), so it
86
+ // captures the full graph (fonts included) without touching the output.
87
+ const plugin: import("bun").BunPlugin = {
88
+ name: "liebstoeckel-license-collector",
89
+ setup(build) {
90
+ build.onLoad({ filter: /.*/ }, (args) => {
91
+ record(args.path);
92
+ return undefined;
93
+ });
94
+ },
95
+ };
96
+ return {
97
+ plugin,
98
+ report: () => buildReport(paths, opts.selfName),
99
+ conflicts: () => firstPartyVersionConflicts(paths, opts.selfName),
100
+ };
101
+ }
102
+
103
+ /** A first-party package that resolved to more than one version in a single build's
104
+ * module graph — i.e. two incompatible copies that would both be inlined. */
105
+ export interface FirstPartyConflict {
106
+ name: string;
107
+ versions: string[];
108
+ }
109
+
110
+ /** Detect `@liebstoeckel/*` packages that appear at more than one version across the
111
+ * bundled module graph. A deck inlines its whole import graph into one `.html`, so two
112
+ * copies of e.g. `plugin-sdk` are two copies of the plugin↔host contract embedded side
113
+ * by side — silently broken. This is the consumption-side guard for caret cross-deps:
114
+ * a skewed/lagging plugin can pin an older sibling than `engine` resolves. A pure
115
+ * reduction over the same paths the license collector already records (no extra build). */
116
+ export function firstPartyVersionConflicts(paths: Iterable<string>, selfName?: string): FirstPartyConflict[] {
117
+ const versions = new Map<string, Set<string>>();
118
+ for (const p of paths) {
119
+ const pkgDir = nearestPackageDir(p);
120
+ if (!pkgDir) continue;
121
+ let meta: { name?: string; version?: string };
122
+ try {
123
+ meta = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"));
124
+ } catch {
125
+ continue;
126
+ }
127
+ if (!meta.name || !meta.name.startsWith(FIRST_PARTY)) continue;
128
+ if (selfName && meta.name === selfName) continue;
129
+ let set = versions.get(meta.name);
130
+ if (!set) {
131
+ set = new Set();
132
+ versions.set(meta.name, set);
133
+ }
134
+ set.add(meta.version ?? "?");
135
+ }
136
+ const conflicts: FirstPartyConflict[] = [];
137
+ for (const [name, set] of versions) {
138
+ if (set.size > 1) conflicts.push({ name, versions: [...set].sort() });
139
+ }
140
+ return conflicts.sort((a, b) => a.name.localeCompare(b.name));
141
+ }
142
+
143
+ /** Loud, actionable message for a single-copy violation (used by `build`/`build --check`). */
144
+ export function formatFirstPartyConflicts(conflicts: FirstPartyConflict[]): string {
145
+ const lines = conflicts.map((c) => ` ${c.name} → ${c.versions.join(", ")}`);
146
+ return (
147
+ "this deck bundles more than one version of a liebstoeckel package:\n" +
148
+ lines.join("\n") +
149
+ "\n\nA deck is inlined into one .html, so mixed versions ship duplicate, incompatible\n" +
150
+ "copies (e.g. two plugin-sdk runtimes) side by side. Update the framework packages\n" +
151
+ "together so each resolves to a single version (e.g. `bun update`), then rebuild."
152
+ );
153
+ }
154
+
155
+ /** Resolve a set of absolute file paths to their owning packages and build a report.
156
+ * `selfName` (the deck's own package name) is excluded, a deck never lists itself.
157
+ * Exported for the CLI's collect-from-dir path and for tests. */
158
+ export function buildReport(paths: Iterable<string>, selfName?: string): LicenseReport {
159
+ const thirdParty = new Map<string, LicensePackage>(); // name@version → pkg
160
+ const firstParty = new Set<string>();
161
+ const flagged: LicenseReport["flagged"] = [];
162
+
163
+ for (const p of paths) {
164
+ const pkgDir = nearestPackageDir(p);
165
+ if (!pkgDir) continue;
166
+ let meta: { name?: string; version?: string; license?: string; author?: unknown };
167
+ try {
168
+ meta = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"));
169
+ } catch {
170
+ continue;
171
+ }
172
+ if (!meta.name) continue;
173
+ if (selfName && meta.name === selfName) continue; // the deck never lists itself
174
+
175
+ // First-party (@liebstoeckel/*) by name, works whether they resolve to the
176
+ // monorepo's packages/*/src or to an installed node_modules/.bun/@liebstoeckel+*.
177
+ if (meta.name.startsWith(FIRST_PARTY)) {
178
+ firstParty.add(meta.name); // all MPL-2.0, reported once
179
+ continue;
180
+ }
181
+ // Everything else is third-party only if it lives under node_modules; a local
182
+ // dir that isn't @liebstoeckel is the deck's own source, skip it.
183
+ if (!p.includes(NODE_MODULES)) continue;
184
+ const key = `${meta.name}@${meta.version ?? "?"}`;
185
+ if (thirdParty.has(key)) continue;
186
+
187
+ const license = normalizeLicense(meta.license);
188
+ const { text, fromFile } = readLicense(pkgDir, license, meta);
189
+ thirdParty.set(key, { name: meta.name, version: meta.version ?? "?", license, text, fromFile });
190
+ if (!spdxAllowed(license)) flagged.push({ name: meta.name, version: meta.version ?? "?", license });
191
+ }
192
+
193
+ const packages = [...thirdParty.values()].sort((a, b) => a.name.localeCompare(b.name));
194
+ return { packages, firstParty: [...firstParty].sort(), flagged };
195
+ }
196
+
197
+ /** Walk up from a file to the nearest directory containing a package.json. Stops at
198
+ * a `node_modules`-segment boundary's package so a nested dep maps to itself. */
199
+ function nearestPackageDir(file: string): string | null {
200
+ let dir = dirname(file);
201
+ // bound the walk; deep node_modules nesting is still shallow in practice
202
+ for (let i = 0; i < 50; i++) {
203
+ if (existsSync(join(dir, "package.json"))) return dir;
204
+ const parent = dirname(dir);
205
+ if (parent === dir) return null;
206
+ dir = parent;
207
+ }
208
+ return null;
209
+ }
210
+
211
+ /** Reduce package.json `license`/`licenses` to an SPDX id/expression string. */
212
+ export function normalizeLicense(license: unknown): string {
213
+ if (typeof license === "string") return license;
214
+ if (license && typeof license === "object") {
215
+ const l = license as { type?: string };
216
+ if (l.type) return l.type;
217
+ }
218
+ if (Array.isArray(license)) {
219
+ return (license as { type?: string }[]).map((l) => l.type ?? "UNKNOWN").join(" OR ");
220
+ }
221
+ return "UNKNOWN";
222
+ }
223
+
224
+ /** True if every required term of an SPDX expression is in ALLOWED_SPDX. For an
225
+ * `OR` expression, one allowed term suffices; for `AND` (and bare ids), all must
226
+ * be allowed. Parenthesised/compound expressions fall back to "all terms allowed". */
227
+ export function spdxAllowed(expr: string): boolean {
228
+ const ids = expr.match(/[A-Za-z0-9.+-]+/g)?.filter((t) => !/^(OR|AND|WITH)$/i.test(t)) ?? [];
229
+ if (ids.length === 0) return false;
230
+ const allowed = (id: string) => ALLOWED_SPDX.has(id);
231
+ if (/\bOR\b/i.test(expr)) return ids.some(allowed);
232
+ return ids.every(allowed);
233
+ }
234
+
235
+ const LICENSE_FILES = ["LICENSE", "LICENSE.md", "LICENSE.txt", "LICENCE", "LICENCE.md", "COPYING", "COPYING.md"];
236
+
237
+ function readLicense(
238
+ pkgDir: string,
239
+ license: string,
240
+ meta: { name?: string; version?: string; author?: unknown },
241
+ ): { text: string | null; fromFile: boolean } {
242
+ for (const f of LICENSE_FILES) {
243
+ const path = join(pkgDir, f);
244
+ if (existsSync(path)) {
245
+ try {
246
+ return { text: readFileSync(path, "utf8").trim(), fromFile: true };
247
+ } catch {
248
+ /* fall through */
249
+ }
250
+ }
251
+ }
252
+ // some packages name it LICENSE-MIT etc.
253
+ try {
254
+ const alt = readdirSync(pkgDir).find((n) => /^licen[cs]e/i.test(n));
255
+ if (alt) return { text: readFileSync(join(pkgDir, alt), "utf8").trim(), fromFile: true };
256
+ } catch {
257
+ /* ignore */
258
+ }
259
+ return { text: fallbackLicenseText(license, meta), fromFile: false };
260
+ }
261
+
262
+ /** Authorship line for templated short licenses (MIT/ISC/BSD). */
263
+ function copyrightHolder(meta: { author?: unknown }): string {
264
+ const a = meta.author as string | { name?: string } | undefined;
265
+ if (typeof a === "string") return a.replace(/\s*<[^>]*>/, "").replace(/\s*\([^)]*\)/, "").trim();
266
+ if (a && typeof a === "object" && a.name) return a.name;
267
+ return "the authors";
268
+ }
269
+
270
+ /** Short SPDX templates for the permissive licenses that occasionally ship no
271
+ * LICENSE file. Longer licenses (Apache-2.0, MPL-2.0, OFL-1.1) effectively always
272
+ * ship their own file in practice; if one ever doesn't we record the SPDX id +
273
+ * canonical URL rather than inline several KB of boilerplate per package. */
274
+ export function fallbackLicenseText(license: string, meta: { author?: unknown }): string | null {
275
+ const holder = copyrightHolder(meta);
276
+ const id = license.trim();
277
+ if (id === "MIT" || id === "ISC") {
278
+ const grant =
279
+ id === "MIT"
280
+ ? `Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.`
281
+ : `Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.`;
282
+ return `${id} License\n\nCopyright (c) ${holder}\n\n${grant}\n\nTHE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.`;
283
+ }
284
+ if (id === "0BSD") {
285
+ return `BSD Zero Clause License\n\nCopyright (c) ${holder}\n\nPermission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.\n\nTHE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE.`;
286
+ }
287
+ const url = `https://spdx.org/licenses/${encodeURIComponent(id)}.html`;
288
+ return `${id}\n\nCopyright (c) ${holder}\nFull license text: ${url}`;
289
+ }
290
+
291
+ export interface RenderOptions {
292
+ /** First-party notice prepended verbatim (our MPL-2.0 line + source URL). */
293
+ selfNotice?: string;
294
+ }
295
+
296
+ const DIVIDER = "\n\n" + "-".repeat(72) + "\n\n";
297
+
298
+ /** Render the human-readable THIRD_PARTY_NOTICES text from a report. */
299
+ export function renderNotices(report: LicenseReport, opts: RenderOptions = {}): string {
300
+ const parts: string[] = [];
301
+ parts.push(
302
+ "THIRD-PARTY SOFTWARE NOTICES\n\n" +
303
+ "This file is generated at build time from the modules actually bundled into\n" +
304
+ "this presentation. It is regenerated on every build, so it reflects exactly\n" +
305
+ "the third-party code and fonts embedded in this .html file.",
306
+ );
307
+ if (opts.selfNotice) parts.push(opts.selfNotice.trim());
308
+ if (report.firstParty.length) {
309
+ parts.push(
310
+ "liebstoeckel framework packages (MPL-2.0):\n " +
311
+ report.firstParty.join("\n ") +
312
+ "\nSource: https://github.com/liebstoeckel/liebstoeckel",
313
+ );
314
+ }
315
+ for (const p of report.packages) {
316
+ const header = `${p.name}@${p.version} , ${p.license}`;
317
+ parts.push(p.text ? `${header}\n\n${p.text}` : header);
318
+ }
319
+ return parts.join(DIVIDER) + "\n";
320
+ }
321
+
322
+ /** Embed the notices as an inert <script> before the last </body> (same insertion
323
+ * rule and rationale as embedSource: survives minification, immune to the
324
+ * `</body>`-in-a-literal hazard since the payload is plain text we control). */
325
+ export function embedLicenses(html: string, notices: string): string {
326
+ const tag = `<script type="text/plain" ${LICENSE_ATTR}>\n${notices}\n</script>`;
327
+ const at = html.lastIndexOf("</body>");
328
+ return at >= 0 ? html.slice(0, at) + tag + html.slice(at) : html + tag;
329
+ }
330
+
331
+ /** Read an embedded notices block back, or null if the HTML carries none. */
332
+ export function extractLicenses(html: string): string | null {
333
+ const re = new RegExp(`<script[^>]*${LICENSE_ATTR}[^>]*>([\\s\\S]*?)</script>`, "i");
334
+ const m = html.match(re);
335
+ return m ? m[1]!.trim() : null;
336
+ }
@@ -0,0 +1,30 @@
1
+ import type { BunPlugin } from "bun";
2
+ import { compile } from "@mdx-js/mdx";
3
+ import rehypeShiki from "@shikijs/rehype";
4
+ import { createCssVariablesTheme } from "shiki";
5
+
6
+ // Fenced code blocks are highlighted at build time with Shiki's css-variables
7
+ // theme: each token gets color:var(--shiki-token-*), which @liebstoeckel/theme binds
8
+ // to the active brand. No highlighter/grammars/WASM ship to the browser.
9
+ const codeTheme = createCssVariablesTheme({ name: "brand", variablePrefix: "--shiki-", fontStyle: true });
10
+
11
+ // Compiles `.mdx` → JS using the automatic React runtime. providerImportSource
12
+ // wires MDX elements to <MDXProvider> components. Works in Bun.build() and, when
13
+ // referenced from bunfig [serve.static].plugins, in the HMR dev server.
14
+ const mdxPlugin: BunPlugin = {
15
+ name: "mdx",
16
+ setup(build) {
17
+ build.onLoad({ filter: /\.mdx$/ }, async (args) => {
18
+ const source = await Bun.file(args.path).text();
19
+ const compiled = await compile(source, {
20
+ jsxImportSource: "react",
21
+ providerImportSource: "@mdx-js/react",
22
+ development: false,
23
+ rehypePlugins: [[rehypeShiki, { theme: codeTheme }]],
24
+ });
25
+ return { contents: String(compiled), loader: "js" };
26
+ });
27
+ },
28
+ };
29
+
30
+ export default mdxPlugin;
@@ -0,0 +1,4 @@
1
+ /** Inert <script> carrier attribute for the embedded deck source package (eject).
2
+ * Lives in its own zero-dependency module so browser runtime (`../source.ts`) can
3
+ * read it without pulling in the Bun/node-only packer in `source-package.ts`. */
4
+ export const SOURCE_ATTR = "data-liebstoeckel-source";
@@ -0,0 +1,210 @@
1
+ import { $ } from "bun";
2
+ import { mkdtempSync, mkdirSync, rmSync, readdirSync, copyFileSync, existsSync, readFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join, dirname } from "node:path";
5
+ import { SOURCE_ATTR } from "./source-attr";
6
+
7
+ // Inline source package (ADR 0039): the single-file deck carries a compressed copy of
8
+ // its own source so a compiled `.html` can be ejected back to an editable project.
9
+ // Collection reuses `bun pm pack` (npm's real ignore algorithm + `files` allowlist),
10
+ // then repacks gzip→zstd for the in-HTML embed. Everything here is Bun-native (no deps),
11
+ // so the engine never grows a dependency for this.
12
+
13
+ /** Inert <script> carrier, mirrors plugin-sdk's `embedManifest` (browser never parses it).
14
+ * Defined in `source-attr.ts` so browser runtime can read it without this packer. */
15
+ export { SOURCE_ATTR };
16
+
17
+ // Fail-closed gate: pack's default-ignore table misses bare `.env`/`.env.local` (only
18
+ // `.env.production`), so we add our own net + a best-effort secret-content scan.
19
+ const ENV_FILE = /(^|\/)\.env($|\.)/i;
20
+ const SECRET_SIGNATURE =
21
+ /-----BEGIN [A-Z ]*PRIVATE KEY|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{36}|sk-[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}/;
22
+
23
+ // Eject is the untrusted-input path (an arbitrary HTML someone sent you). Bound the
24
+ // decompression bomb surface the traversal check can't cover.
25
+ const MAX_COMPRESSED = 8 * 1024 * 1024; // 8 MB of embedded base64-decoded zstd
26
+ const MAX_DECOMPRESSED = 64 * 1024 * 1024; // 64 MB of tar
27
+ const MAX_ENTRIES = 4000;
28
+
29
+ const PACKAGE_PREFIX = "package/";
30
+
31
+ export interface CollectOptions {
32
+ /** Force past the secret gate (loud, explicit). */
33
+ allowSecret?: boolean;
34
+ }
35
+
36
+ export interface DeckTarball {
37
+ /** pack's native gzip `.tgz` bytes, npm/`bun add`-compatible. */
38
+ gzip: Uint8Array<ArrayBuffer>;
39
+ /** repacked zstd of pack's (already-normalized) tar, the in-HTML embed payload. */
40
+ zstd: Uint8Array<ArrayBuffer>;
41
+ /** deck-relative paths in the package (the `package/` prefix stripped). */
42
+ files: string[];
43
+ }
44
+
45
+ /** Parse ustar entry names from a raw tar (Bun.Archive.files() is unusable; this also
46
+ * lets the gate run off the exact bytes we embed, with no second pack invocation). */
47
+ function tarEntryNames(tar: Uint8Array): string[] {
48
+ const names: string[] = [];
49
+ const dec = new TextDecoder();
50
+ for (let off = 0; off + 512 <= tar.length; ) {
51
+ const header = tar.subarray(off, off + 512);
52
+ if (header.every((b) => b === 0)) break; // end-of-archive zero blocks
53
+ const name = dec.decode(header.subarray(0, 100)).replace(/\0.*$/s, "");
54
+ if (!name) break;
55
+ const sizeOct = dec.decode(header.subarray(124, 136)).replace(/\0.*$/s, "").trim();
56
+ const size = parseInt(sizeOct, 8) || 0;
57
+ names.push(name);
58
+ off += 512 + Math.ceil(size / 512) * 512;
59
+ }
60
+ return names;
61
+ }
62
+
63
+ function looksBinary(bytes: Uint8Array): boolean {
64
+ const n = Math.min(bytes.length, 8000);
65
+ for (let i = 0; i < n; i++) if (bytes[i] === 0) return true;
66
+ return false;
67
+ }
68
+
69
+ /** Run `bun pm pack` on a deck dir, gate the result, and repack gzip→zstd.
70
+ * `--ignore-scripts` is a security control (pack runs prepack/prepare by default →
71
+ * arbitrary code); the interpreter is pinned to the running Bun via `process.execPath`,
72
+ * and `dir` reaches pack via `.cwd()` (never interpolated into the command). */
73
+ export async function collectDeckTarball(dir: string, opts: CollectOptions = {}): Promise<DeckTarball> {
74
+ const work = mkdtempSync(join(tmpdir(), "lst-pack-"));
75
+ const tgzPath = join(work, "deck.tgz");
76
+ try {
77
+ const res = await $`${process.execPath} pm pack --ignore-scripts --quiet --filename ${tgzPath}`
78
+ .cwd(dir)
79
+ .quiet()
80
+ .nothrow();
81
+ if (res.exitCode !== 0 || !existsSync(tgzPath)) {
82
+ throw new Error(`\`bun pm pack\` failed in ${dir}:\n${res.stderr.toString() || res.stdout.toString()}`);
83
+ }
84
+
85
+ const gzip = new Uint8Array(await Bun.file(tgzPath).bytes());
86
+ const tar = Bun.gunzipSync(gzip);
87
+ const files = tarEntryNames(tar)
88
+ .filter((n) => n.startsWith(PACKAGE_PREFIX))
89
+ .map((n) => n.slice(PACKAGE_PREFIX.length))
90
+ .filter(Boolean);
91
+
92
+ // pack default-ignores bunfig.toml; if the deck has one but it wasn't packed (not in
93
+ // `files`), the ejected deck loses its dev-server plugins, warn, don't fail.
94
+ if (existsSync(join(dir, "bunfig.toml")) && !files.includes("bunfig.toml")) {
95
+ console.warn('⚠ bunfig.toml is not in the deck\'s "files", the ejected deck won\'t run in dev mode');
96
+ }
97
+
98
+ // ── fail-closed gate ──────────────────────────────────────────────
99
+ const violations: string[] = [];
100
+ for (const rel of files) {
101
+ if (ENV_FILE.test(rel)) {
102
+ violations.push(`${rel} (env file)`);
103
+ continue;
104
+ }
105
+ const abs = join(dir, rel);
106
+ if (!existsSync(abs)) continue;
107
+ const bytes = readFileSync(abs);
108
+ if (!looksBinary(bytes) && SECRET_SIGNATURE.test(bytes.toString("utf8"))) {
109
+ violations.push(`${rel} (secret signature)`);
110
+ }
111
+ }
112
+ if (violations.length && !opts.allowSecret) {
113
+ throw new Error(
114
+ `Refusing to embed source, likely secrets:\n ${violations.join("\n ")}\n` +
115
+ `Add a "files" allowlist (or .npmignore) to the deck's package.json, or pass --allow-secret.`,
116
+ );
117
+ }
118
+
119
+ // Preserve pack's reproducible tar (fixed mtimes); only swap the compressor.
120
+ // Re-taring via Bun.Archive.write would stamp wall-clock mtimes and break determinism.
121
+ const zstd = new Uint8Array(Bun.zstdCompressSync(tar));
122
+ return { gzip, zstd, files };
123
+ } finally {
124
+ rmSync(work, { recursive: true, force: true });
125
+ }
126
+ }
127
+
128
+ /** Embed the zstd source payload as an inert <script> before the last </body>
129
+ * (same insertion rule as `embedManifest`: a deck's inlined JS can contain a literal
130
+ * "</body>", and the real document terminator is the last one). */
131
+ export function embedSource(html: string, zstd: Uint8Array): string {
132
+ const b64 = Buffer.from(zstd).toString("base64");
133
+ const tag = `<script type="application/octet-stream" ${SOURCE_ATTR} data-codec="zstd">${b64}</script>`;
134
+ const at = html.lastIndexOf("</body>");
135
+ return at >= 0 ? html.slice(0, at) + tag + html.slice(at) : html + tag;
136
+ }
137
+
138
+ /** Read the embedded zstd payload back, or null if the HTML carries no source package. */
139
+ export function extractSource(html: string): Uint8Array | null {
140
+ const re = new RegExp(`<script[^>]*${SOURCE_ATTR}[^>]*>([\\s\\S]*?)</script>`, "i");
141
+ const m = html.match(re);
142
+ if (!m) return null;
143
+ try {
144
+ return Buffer.from(m[1]!.trim(), "base64");
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ export interface EjectOptions {
151
+ /** allow extracting into a non-empty directory (default: refuse). */
152
+ force?: boolean;
153
+ }
154
+
155
+ /** Eject the embedded source to `outDir` (the `package/` prefix stripped), or throw a
156
+ * clear error if the HTML has none. Untrusted-input hardened: size/entry caps against
157
+ * decompression bombs + per-entry name validation on top of Bun.Archive's own clamping. */
158
+ export async function ejectSource(html: string, outDir: string, opts: EjectOptions = {}): Promise<string[]> {
159
+ const zstd = extractSource(html);
160
+ if (!zstd) {
161
+ throw new Error(
162
+ "no embedded source package in this HTML, it was built with --no-inline-package " +
163
+ "(or by an older build). Rebuild without that flag to make the deck ejectable.",
164
+ );
165
+ }
166
+ if (zstd.length > MAX_COMPRESSED) throw new Error("embedded source package is implausibly large, refusing to eject");
167
+
168
+ const tar = Bun.zstdDecompressSync(zstd);
169
+ if (tar.length > MAX_DECOMPRESSED) throw new Error("decompressed source exceeds the size cap, refusing to eject");
170
+
171
+ const names = tarEntryNames(tar);
172
+ if (names.length > MAX_ENTRIES) throw new Error("source package has too many entries, refusing to eject");
173
+ for (const n of names) {
174
+ if (n.startsWith("/") || n.split("/").some((seg) => seg === "..")) {
175
+ throw new Error(`unsafe path in source package: ${n}`);
176
+ }
177
+ }
178
+
179
+ if (existsSync(outDir) && readdirSync(outDir).length > 0 && !opts.force) {
180
+ throw new Error(`${outDir} is not empty, pass force to overwrite`);
181
+ }
182
+
183
+ // Extract to a temp dir (Bun.Archive clamps traversal), then lift package/* into outDir.
184
+ const stage = mkdtempSync(join(tmpdir(), "lst-eject-"));
185
+ try {
186
+ const Archive = (Bun as unknown as { Archive: new (b: Uint8Array) => { extract(d: string): Promise<number> } }).Archive;
187
+ await new Archive(tar).extract(stage);
188
+ const root = join(stage, "package");
189
+ const src = existsSync(root) ? root : stage;
190
+ const written: string[] = [];
191
+ const lift = (from: string, rel: string) => {
192
+ for (const ent of readdirSync(from, { withFileTypes: true })) {
193
+ const childRel = rel ? `${rel}/${ent.name}` : ent.name;
194
+ const dest = join(outDir, childRel);
195
+ if (ent.isDirectory()) {
196
+ lift(join(from, ent.name), childRel);
197
+ } else {
198
+ mkdirSync(dirname(dest), { recursive: true });
199
+ // copy (not rename): the stage temp may be on a different filesystem than outDir (EXDEV).
200
+ copyFileSync(join(from, ent.name), dest);
201
+ written.push(childRel);
202
+ }
203
+ }
204
+ };
205
+ lift(src, "");
206
+ return written.sort();
207
+ } finally {
208
+ rmSync(stage, { recursive: true, force: true });
209
+ }
210
+ }
@@ -0,0 +1,49 @@
1
+ // A thumbnails manifest mirrors the plugin manifest: an inert JSON <script> the
2
+ // browser never executes, embedded into the single-file deck. Each entry maps a
3
+ // slide index to a data-URI image (built by @liebstoeckel/thumbnails). The engine
4
+ // reads it to render cheap <img> previews in the overview / presenter instead of
5
+ // mounting N live slides. Pure string/JSON helpers, no DOM, no browser.
6
+
7
+ export interface ThumbnailManifest {
8
+ v: 1;
9
+ /** intrinsic pixel size of each thumbnail */
10
+ w: number;
11
+ h: number;
12
+ /** slide index → data-URI (e.g. "data:image/jpeg;base64,…") */
13
+ thumbs: Record<number, string>;
14
+ }
15
+
16
+ export const THUMBS_ATTR = "data-liebstoeckel-thumbnails";
17
+
18
+ export const encodeThumbnails = (m: ThumbnailManifest): string => JSON.stringify(m);
19
+ export const parseThumbnails = (json: string): ThumbnailManifest => JSON.parse(json) as ThumbnailManifest;
20
+
21
+ /** Embed (or replace) the thumbnails manifest as an inert JSON <script> before
22
+ * the closing </body>. Re-embedding strips any prior block so re-running is idempotent.
23
+ * Inserts before the LAST </body>, a deck's inlined JS bundle can contain the string
24
+ * "</body>" in a literal (e.g. an iframe srcdoc), and the real document </body> is last. */
25
+ export function embedThumbnails(html: string, m: ThumbnailManifest): string {
26
+ const stripped = stripThumbnails(html);
27
+ const tag = `<script type="application/json" ${THUMBS_ATTR}>${encodeThumbnails(m)}</script>`;
28
+ const at = stripped.lastIndexOf("</body>");
29
+ return at >= 0 ? stripped.slice(0, at) + tag + stripped.slice(at) : stripped + tag;
30
+ }
31
+
32
+ const blockRe = new RegExp(`<script[^>]*${THUMBS_ATTR}[^>]*>[\\s\\S]*?</script>`, "i");
33
+
34
+ /** Remove an embedded thumbnails block (if present). */
35
+ export function stripThumbnails(html: string): string {
36
+ return html.replace(blockRe, "");
37
+ }
38
+
39
+ /** Extract the thumbnails manifest from a built HTML, or null if none/invalid. */
40
+ export function extractThumbnails(html: string): ThumbnailManifest | null {
41
+ const re = new RegExp(`<script[^>]*${THUMBS_ATTR}[^>]*>([\\s\\S]*?)</script>`, "i");
42
+ const m = html.match(re);
43
+ if (!m) return null;
44
+ try {
45
+ return parseThumbnails(m[1]!);
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
@@ -0,0 +1,42 @@
1
+ import type { BunPlugin } from "bun";
2
+ import { dirname } from "node:path";
3
+
4
+ /**
5
+ * Redirect deep visx CJS imports (`@visx/<pkg>/lib/...`) to the ESM mirror
6
+ * (`@visx/<pkg>/esm/...`).
7
+ *
8
+ * Why: visx's ESM builds default-import their own CJS `lib/` files, e.g.
9
+ * `@visx/grid` does `import Line from "@visx/shape/lib/shapes/Line"`. Those CJS
10
+ * files are correctly marked (`exports.__esModule = true; exports.default = …`),
11
+ * and Bun's *runtime* honors that. But `Bun.build` does NOT honor `__esModule`
12
+ * for CJS default imports (oven-sh/bun#12463, confirmed unfixed through the
13
+ * 1.4.0 canary): the default import resolves to the module *namespace object*
14
+ * instead of the component. It bundles cleanly and then crashes at render with
15
+ * "Element type is invalid … got: object".
16
+ *
17
+ * This bites the chart registry hard: scaffolded charts are owned source that
18
+ * users/agents extend, pulling in arbitrary visx packages (grid, heatmap,
19
+ * hierarchy, legend, tooltip, …), any of which may use this pattern. Auditing or
20
+ * avoiding packages doesn't scale. The ESM mirror exports real defaults, so
21
+ * redirecting `lib/` → `esm/` sidesteps the broken interop for the whole visx
22
+ * surface in one place. The fallback keeps original resolution if no ESM mirror
23
+ * exists, so the plugin can never make a build fail.
24
+ *
25
+ * Remove once oven-sh/bun#12463 is fixed.
26
+ */
27
+ const visxEsmInterop: BunPlugin = {
28
+ name: "visx-esm-interop",
29
+ setup(build) {
30
+ build.onResolve({ filter: /^@visx\/[^/]+\/lib\// }, (args) => {
31
+ const esm = args.path.replace("/lib/", "/esm/");
32
+ const from = args.importer ? dirname(args.importer) : process.cwd();
33
+ try {
34
+ return { path: Bun.resolveSync(esm, from) };
35
+ } catch {
36
+ return undefined; // no ESM mirror → fall back to default resolution
37
+ }
38
+ });
39
+ },
40
+ };
41
+
42
+ export default visxEsmInterop;