slidemind 0.0.27

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.
@@ -0,0 +1,724 @@
1
+ #!/usr/bin/env node
2
+ // Build a self-contained HTML presentation from per-slide .jsx files.
3
+ // Pure Node stdlib — no external dependencies.
4
+ //
5
+ // Usage:
6
+ // slidemind build <deck>
7
+ // slidemind build <deck> --open
8
+ //
9
+ // Convention:
10
+ // <deck>/
11
+ // slides/
12
+ // 01-cover.jsx
13
+ // 02-agenda.jsx
14
+ // ...
15
+ //
16
+ // Each slide is a thin JSX component that composes a layout from
17
+ // skills/create-slides/layouts/. Slide files do NOT import layouts; the
18
+ // build inlines all layouts once and exposes them as globals (SlideCover,
19
+ // SlideAgendaBlack, ...) inside the slide scope.
20
+ //
21
+ // The build:
22
+ // 1. Inlines every layout .jsx as a <script type="text/jsx-template"> block.
23
+ // 2. Inlines every slide .jsx the same way, in filename order.
24
+ // 3. Copies corporate-design.css, layouts.css, and the assets/ folder into
25
+ // the deck directory so the output is fully self-contained.
26
+ // 4. Emits presentation.html with React + Babel-standalone from CDN and a
27
+ // bootstrap that transpiles on load and renders a keyboard-navigable
28
+ // deck.
29
+
30
+ import { spawn } from "node:child_process";
31
+ import fs from "node:fs";
32
+ import path from "node:path";
33
+ import { fileURLToPath, pathToFileURL } from "node:url";
34
+
35
+ import { lintSlidesOrThrow } from "./lint-slides.mjs";
36
+ import { resolveSkillForDeck } from "./resolve-skill.mjs";
37
+
38
+ const __filename = fileURLToPath(import.meta.url);
39
+ const __dirname = path.dirname(__filename);
40
+ const PACKAGE_ROOT = path.resolve(__dirname, "..", "..");
41
+ const RUNTIME_APPLIER = path.join(__dirname, "runtime-applier.js");
42
+
43
+ function safeInline(src) {
44
+ // Guard against stray </script> sequences breaking the inline block.
45
+ return src.replace(/<\/script>/g, "<\\/script>");
46
+ }
47
+
48
+ function safeInlineJson(data) {
49
+ return JSON.stringify(data).replace(/<\/script>/g, "<\\/script>");
50
+ }
51
+
52
+ function stripImports(src) {
53
+ // Remove `import ... from "..."` lines. Layouts live in global scope in
54
+ // the generated HTML, so slide files never need to import them explicitly.
55
+ return src.replace(
56
+ /^\s*import\s+[^;]+?from\s+["'][^"']+["']\s*;?\s*$/gm,
57
+ "",
58
+ );
59
+ }
60
+
61
+ function rewriteAssetsPath(src) {
62
+ // The layout defaults point at "../assets" (correct for in-repo authoring
63
+ // under skills/create-slides/layouts/). In a built deck the copied
64
+ // assets sit next to the HTML, so the default should be "./assets".
65
+ return src.replace(
66
+ /assetsPath = "\.\.\/assets"/g,
67
+ 'assetsPath = "./assets"',
68
+ );
69
+ }
70
+
71
+ function inlineLayout(file) {
72
+ const stem = path.basename(file, ".jsx");
73
+ const raw = rewriteAssetsPath(fs.readFileSync(file, "utf8"));
74
+ const binding = raw.match(/export\s+default\s+function\s+([A-Za-z_$][\w$]*)\s*\(/)?.[1] || layoutBindingNameForStem(stem);
75
+ const src = safeInline(raw);
76
+ return (
77
+ `<script type="text/jsx-template" data-kind="layout" ` +
78
+ `data-name="${stem}" data-binding="${binding}">\n${src}\n</script>`
79
+ );
80
+ }
81
+
82
+ function layoutBindingNameForStem(stem) {
83
+ return "Slide" + stem.split("-").map((p) => (
84
+ p ? p[0].toUpperCase() + p.slice(1) : ""
85
+ )).join("");
86
+ }
87
+
88
+ function inlineSlide(file, index) {
89
+ const stem = path.basename(file, ".jsx");
90
+ const src = safeInline(stripImports(fs.readFileSync(file, "utf8")));
91
+ return (
92
+ `<script type="text/jsx-template" data-kind="slide" ` +
93
+ `data-index="${index}" data-name="${stem}">\n${src}\n</script>`
94
+ );
95
+ }
96
+
97
+ function copyDesignSystem(deckDir, skill) {
98
+ // Copy CSS + assets into the deck directory so the HTML is portable.
99
+ // Layering: brand assets first (logos, shared decor from the design
100
+ // bundle), then the active skill's own assets on top — so a skill
101
+ // can override a brand file by shipping its own copy with the same name.
102
+ // For create-slides itself, skill.assetsDir === skill.brand.assetsDir, so
103
+ // this is a single copy pass.
104
+ const outAssets = path.join(deckDir, "assets");
105
+ fs.mkdirSync(outAssets, { recursive: true });
106
+ const assetSources =
107
+ skill.brand.assetsDir === skill.assetsDir
108
+ ? [skill.assetsDir]
109
+ : [skill.brand.assetsDir, skill.assetsDir];
110
+ for (const src of assetSources) {
111
+ if (!fs.existsSync(src)) continue;
112
+ for (const entry of fs.readdirSync(src)) {
113
+ const p = path.join(src, entry);
114
+ if (fs.statSync(p).isFile()) {
115
+ fs.copyFileSync(p, path.join(outAssets, entry));
116
+ }
117
+ }
118
+ }
119
+ fs.copyFileSync(skill.cssFile, path.join(deckDir, "corporate-design.css"));
120
+
121
+ // layouts.css references url("../assets/...") relative to the in-repo
122
+ // layouts dir. The build copies it flat into the deck next to assets/,
123
+ // so rewrite the URLs to "./assets/...".
124
+ //
125
+ // Layering: brand layouts.css first (lower in the cascade), then the
126
+ // active skill's layouts.css. Skill rules sharing a selector win.
127
+ const cssSources =
128
+ skill.brand.layoutsCssFile === path.join(skill.layoutsDir, "layouts.css")
129
+ ? [path.join(skill.layoutsDir, "layouts.css")]
130
+ : [skill.brand.layoutsCssFile, path.join(skill.layoutsDir, "layouts.css")];
131
+ const cssChunks = [];
132
+ for (const cssPath of cssSources) {
133
+ if (!fs.existsSync(cssPath)) continue;
134
+ let css = fs.readFileSync(cssPath, "utf8");
135
+ css = css.replace(/url\("\.\.\/assets\//g, 'url("./assets/');
136
+ css = css.replace(/url\('\.\.\/assets\//g, "url('./assets/");
137
+ css = css.replace(/url\(\.\.\/assets\//g, "url(./assets/");
138
+ cssChunks.push(css);
139
+ }
140
+ fs.writeFileSync(path.join(deckDir, "layouts.css"), cssChunks.join("\n\n"));
141
+ }
142
+
143
+ function loadOverrides(deckDir) {
144
+ const file = path.join(deckDir, "overrides.json");
145
+ if (!fs.existsSync(file)) return { version: 1, slides: {} };
146
+ let data;
147
+ try {
148
+ data = JSON.parse(fs.readFileSync(file, "utf8"));
149
+ } catch (err) {
150
+ throw new Error(`Invalid overrides.json: ${err.message}`);
151
+ }
152
+ if (!data || typeof data !== "object") return { version: 1, slides: {} };
153
+ if (data.version === undefined) data.version = 1;
154
+ if (data.slides === undefined) data.slides = {};
155
+ return data;
156
+ }
157
+
158
+ // Read the deck's SVG assets (after copyDesignSystem has populated assets/) so
159
+ // the preview can inline them. An <img src="x.svg"> can't inherit `color`, so a
160
+ // CSS-tinted icon looks wrong in the HTML preview yet right in the .pptx render
161
+ // (which substitutes currentColor) — the source-mismatch that sends agents off
162
+ // to inspect SVGs. Inlining the SVG at runtime makes the preview honour `color`
163
+ // exactly as the render does. Keyed three ways so any of the src spellings an
164
+ // author uses ("./assets/x.svg", "assets/x.svg", "x.svg") resolves.
165
+ function collectSvgAssets(deckDir) {
166
+ const assetsDir = path.join(deckDir, "assets");
167
+ const map = {};
168
+ let entries;
169
+ try {
170
+ entries = fs.readdirSync(assetsDir);
171
+ } catch {
172
+ return map;
173
+ }
174
+ for (const name of entries) {
175
+ if (!name.toLowerCase().endsWith(".svg")) continue;
176
+ let markup;
177
+ try {
178
+ markup = fs.readFileSync(path.join(assetsDir, name), "utf8");
179
+ } catch {
180
+ continue;
181
+ }
182
+ map[`./assets/${name}`] = markup;
183
+ map[`assets/${name}`] = markup;
184
+ map[name] = markup;
185
+ }
186
+ return map;
187
+ }
188
+
189
+ const HTML_TEMPLATE = `<!doctype html>
190
+ <html lang="en">
191
+ <head>
192
+ <meta charset="utf-8" />
193
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
194
+ <title>__TITLE__</title>
195
+ <!-- Arimo: open-source Arial-metric-compatible font. We alias it to
196
+ "Arial" so master text pinned to Arial gets a font with the same
197
+ horizontal extents PowerPoint/LibreOffice use, instead of macOS
198
+ Chrome's slightly wider system Arial that pushes long strings
199
+ (addresses, single-line headers) past their bounding boxes. -->
200
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
201
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
202
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Arimo:ital,wght@0,400..700;1,400..700&display=swap" />
203
+ <link rel="stylesheet" href="./corporate-design.css" />
204
+ <link rel="stylesheet" href="./layouts.css" />
205
+ <style>
206
+ /* Substitute Arial with Arimo (a metric clone) so wrap behaviour
207
+ matches PowerPoint's rendering. Local Arial wins only if Arimo
208
+ hasn't loaded yet. */
209
+ @font-face {
210
+ font-family: "Arial";
211
+ src: local("Arimo"), local("Arial");
212
+ font-weight: 400;
213
+ font-style: normal;
214
+ }
215
+ @font-face {
216
+ font-family: "Arial";
217
+ src: local("Arimo Bold"), local("Arial Bold");
218
+ font-weight: 700;
219
+ font-style: normal;
220
+ }
221
+ @font-face {
222
+ font-family: "Arial";
223
+ src: local("Arimo Italic"), local("Arial Italic");
224
+ font-weight: 400;
225
+ font-style: italic;
226
+ }
227
+ @font-face {
228
+ font-family: "Arial";
229
+ src: local("Arimo Bold Italic"), local("Arial Bold Italic");
230
+ font-weight: 700;
231
+ font-style: italic;
232
+ }
233
+ </style>
234
+ <style>
235
+ html, body { margin: 0; padding: 0; height: 100%; background: #0a0a0a; overflow: hidden; }
236
+ .deck {
237
+ position: fixed;
238
+ inset: 0;
239
+ display: flex;
240
+ align-items: center;
241
+ justify-content: center;
242
+ }
243
+ .deck__stage {
244
+ width: 1280px;
245
+ height: 720px;
246
+ transform-origin: center center;
247
+ background: var(--bg-1);
248
+ box-shadow: 0 20px 60px rgba(0,0,0,0.35);
249
+ }
250
+ .deck__nav {
251
+ position: fixed;
252
+ bottom: 16px;
253
+ right: 16px;
254
+ padding: 6px 12px;
255
+ border-radius: 999px;
256
+ color: rgba(255,255,255,0.65);
257
+ background: rgba(255,255,255,0.08);
258
+ backdrop-filter: blur(6px);
259
+ font-family: var(--font-sans, sans-serif);
260
+ font-size: 12px;
261
+ letter-spacing: 0.02em;
262
+ user-select: none;
263
+ }
264
+ .deck__error {
265
+ padding: 32px;
266
+ color: #ff6b6b;
267
+ background: #1a0000;
268
+ font-family: var(--font-mono, monospace);
269
+ font-size: 14px;
270
+ white-space: pre-wrap;
271
+ }
272
+ /* Export mode (?export=1) — used by the PPTX exporter's headless Chrome
273
+ so it captures a clean 1280x720 stage with no letterbox/nav/scaling. */
274
+ body.deck--export { background: #ffffff !important; overflow: hidden; }
275
+ body.deck--export .deck { position: static; display: block; inset: auto; }
276
+ body.deck--export .deck__stage {
277
+ transform: none !important;
278
+ box-shadow: none !important;
279
+ margin: 0;
280
+ position: static;
281
+ }
282
+ body.deck--export .deck__nav { display: none !important; }
283
+ /* Print mode (?print=1) — stacks every slide as its own page so the
284
+ browser's Print / Cmd-P renders one slide per page (proofing). */
285
+ body.deck--print {
286
+ background: #ffffff !important;
287
+ overflow: visible !important;
288
+ height: auto !important;
289
+ }
290
+ body.deck--print .deck,
291
+ body.deck--print .deck__nav { display: none !important; }
292
+ .print-deck { background: #ffffff; }
293
+ .print-page {
294
+ width: 1280px;
295
+ height: 720px;
296
+ position: relative;
297
+ overflow: hidden;
298
+ background: var(--bg-1);
299
+ page-break-after: always;
300
+ break-after: page;
301
+ }
302
+ .print-page:last-child {
303
+ page-break-after: auto;
304
+ break-after: auto;
305
+ }
306
+ .print-page__error {
307
+ padding: 32px;
308
+ color: #ff6b6b;
309
+ background: #1a0000;
310
+ font-family: var(--font-mono, monospace);
311
+ font-size: 14px;
312
+ white-space: pre-wrap;
313
+ width: 100%;
314
+ height: 100%;
315
+ box-sizing: border-box;
316
+ }
317
+ @media print {
318
+ @page { size: 1280px 720px; margin: 0; }
319
+ html, body { background: #ffffff !important; }
320
+ }
321
+ </style>
322
+ </head>
323
+ <body>
324
+ <div class="deck">
325
+ <div class="deck__stage" id="stage"></div>
326
+ </div>
327
+ <div class="deck__nav" id="nav">loading…</div>
328
+
329
+ <!-- Layout sources (inlined from skills/create-slides/layouts/*.jsx).
330
+ Each one is evaluated once and exposed under its binding name
331
+ (SlideCover, SlideAgendaBlack, ...) so slides can reference it. -->
332
+ __LAYOUTS__
333
+
334
+ <!-- Slide sources (inlined from this deck's slides/ folder, filename order). -->
335
+ __SLIDES__
336
+
337
+ <!-- Runtime: React + Babel-standalone from CDN. -->
338
+ <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
339
+ <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
340
+ <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
341
+ <script>window.__SLIDE_EDITOR_OVERRIDES__ = __OVERRIDES__;</script>
342
+ <script>window.SVG_ASSETS = __SVG_ASSETS__;</script>
343
+ <script>
344
+ __RUNTIME_APPLIER__
345
+ </script>
346
+
347
+ <script>
348
+ (function () {
349
+ // Map kebab-case filename stem -> PascalCase layout binding name.
350
+ function layoutBindingName(stem) {
351
+ return "Slide" + stem.split("-").map(function (p) {
352
+ return p ? p[0].toUpperCase() + p.slice(1) : "";
353
+ }).join("");
354
+ }
355
+
356
+ function evalJsx(src, filename, extra) {
357
+ var result = Babel.transform(src, {
358
+ presets: [["react", { runtime: "classic" }]],
359
+ sourceType: "module",
360
+ filename: filename
361
+ });
362
+ var body = result.code.replace(
363
+ /export\\s+default\\s+/g,
364
+ "__module.default = "
365
+ );
366
+ var names = ["__module", "React"].concat(Object.keys(extra || {}));
367
+ var values = [{}, React].concat(Object.keys(extra || {}).map(function (k) {
368
+ return extra[k];
369
+ }));
370
+ var factory = new Function(
371
+ names.join(","),
372
+ body + "\\nreturn __module.default;"
373
+ );
374
+ return factory.apply(null, values);
375
+ }
376
+
377
+ // Preview parity: replace <img src="*.svg"> whose file we inlined into
378
+ // window.SVG_ASSETS with the SVG markup itself, carrying the <img>'s box
379
+ // and color onto a wrapper so fill="currentColor" resolves — the same
380
+ // tint the .pptx renderer applies. Re-runs on every React render via a
381
+ // MutationObserver so navigating slides keeps icons inlined.
382
+ function smInlineSvgAssets() {
383
+ var ASSETS = (typeof window !== "undefined" && window.SVG_ASSETS) || null;
384
+ if (!ASSETS || !Object.keys(ASSETS).length) return;
385
+
386
+ function lookup(src) {
387
+ if (!src) return null;
388
+ var s = src.split("?")[0].split("#")[0];
389
+ var alt = s.indexOf("./") === 0 ? s.slice(2) : s;
390
+ var base = s.split("/").pop();
391
+ return ASSETS[s] || ASSETS[alt] || ASSETS[base] || null;
392
+ }
393
+
394
+ function inlineWithin(root) {
395
+ if (!root || !root.querySelectorAll) return;
396
+ var imgs = root.querySelectorAll("img");
397
+ for (var i = 0; i < imgs.length; i++) {
398
+ var img = imgs[i];
399
+ if (img.getAttribute("data-sm-inlined") === "1") continue;
400
+ var markup = lookup(img.getAttribute("src"));
401
+ if (!markup) continue;
402
+ var span = document.createElement("span");
403
+ span.setAttribute("data-sm-inlined", "1");
404
+ if (img.className) span.className = img.className;
405
+ var styleAttr = img.getAttribute("style");
406
+ if (styleAttr) span.setAttribute("style", styleAttr);
407
+ // Pull the laid-out box + colour even when they came from CSS, not
408
+ // an inline style, so sizing holds and currentColor resolves.
409
+ var cs = window.getComputedStyle ? window.getComputedStyle(img) : null;
410
+ var hasW = !!span.style.width;
411
+ var hasH = !!span.style.height;
412
+ if (cs) {
413
+ if (!hasW && cs.width && cs.width !== "auto" && cs.width !== "0px") {
414
+ span.style.width = cs.width;
415
+ hasW = true;
416
+ }
417
+ if (!hasH && cs.height && cs.height !== "auto" && cs.height !== "0px") {
418
+ span.style.height = cs.height;
419
+ hasH = true;
420
+ }
421
+ if (!span.style.color && cs.color) span.style.color = cs.color;
422
+ }
423
+ if (!span.style.display || span.style.display === "inline") {
424
+ span.style.display = "inline-flex";
425
+ }
426
+ if (!span.style.alignItems) span.style.alignItems = "center";
427
+ if (!span.style.justifyContent) span.style.justifyContent = "center";
428
+ span.innerHTML = markup;
429
+ var svg = span.querySelector("svg");
430
+ if (svg) {
431
+ svg.style.display = "block";
432
+ if (hasW || hasH) {
433
+ svg.style.width = "100%";
434
+ svg.style.height = "100%";
435
+ svg.removeAttribute("width");
436
+ svg.removeAttribute("height");
437
+ }
438
+ }
439
+ if (img.parentNode) img.parentNode.replaceChild(span, img);
440
+ }
441
+ }
442
+
443
+ var scheduled = false;
444
+ var raf =
445
+ window.requestAnimationFrame ||
446
+ function (cb) { return setTimeout(cb, 16); };
447
+ function schedule() {
448
+ if (scheduled) return;
449
+ scheduled = true;
450
+ raf(function () {
451
+ scheduled = false;
452
+ inlineWithin(document.body);
453
+ });
454
+ }
455
+ if (typeof MutationObserver !== "undefined") {
456
+ new MutationObserver(schedule).observe(document.body, {
457
+ childList: true,
458
+ subtree: true,
459
+ });
460
+ }
461
+ schedule();
462
+ }
463
+
464
+ var LAYOUTS = {};
465
+ document.querySelectorAll('script[data-kind="layout"]').forEach(function (s) {
466
+ var name = s.dataset.binding || layoutBindingName(s.dataset.name);
467
+ try {
468
+ LAYOUTS[name] = evalJsx(s.textContent, s.dataset.name + ".jsx", {});
469
+ window[name] = LAYOUTS[name];
470
+ } catch (err) {
471
+ console.error("Layout " + name + " failed to load:", err);
472
+ throw err;
473
+ }
474
+ });
475
+
476
+ var slides = [];
477
+ document.querySelectorAll('script[data-kind="slide"]').forEach(function (s) {
478
+ var idx = parseInt(s.dataset.index, 10);
479
+ var name = s.dataset.name;
480
+ try {
481
+ var Component = evalJsx(
482
+ s.textContent,
483
+ name + ".jsx",
484
+ LAYOUTS
485
+ );
486
+ slides[idx] = { name: name, Component: Component };
487
+ } catch (err) {
488
+ console.error("Slide " + name + " failed to load:", err);
489
+ slides[idx] = { name: name, error: err };
490
+ }
491
+ });
492
+
493
+ var stage = document.getElementById("stage");
494
+ var nav = document.getElementById("nav");
495
+ var params = new URLSearchParams(location.search);
496
+ var exportMode = params.get("export") === "1";
497
+ var printMode = params.get("print") === "1";
498
+ if (exportMode) document.body.classList.add("deck--export");
499
+
500
+ smInlineSvgAssets();
501
+
502
+ if (printMode) {
503
+ document.body.classList.add("deck--print");
504
+ var printDeck = document.createElement("div");
505
+ printDeck.className = "print-deck";
506
+ document.body.appendChild(printDeck);
507
+ slides.forEach(function (slide) {
508
+ var page = document.createElement("div");
509
+ page.className = "print-page";
510
+ printDeck.appendChild(page);
511
+ if (slide.error) {
512
+ var errEl = document.createElement("div");
513
+ errEl.className = "print-page__error";
514
+ errEl.textContent = slide.name + ".jsx failed:\\n" + slide.error.message;
515
+ page.appendChild(errEl);
516
+ return;
517
+ }
518
+ ReactDOM.createRoot(page).render(
519
+ React.createElement(slide.Component, { assetsPath: "./assets" })
520
+ );
521
+ });
522
+ return;
523
+ }
524
+
525
+ var startIndex = parseInt(params.get("slide") || "0", 10);
526
+ if (isNaN(startIndex) || startIndex < 0) startIndex = 0;
527
+ if (startIndex >= slides.length) startIndex = slides.length - 1;
528
+ var current = startIndex;
529
+ var root = ReactDOM.createRoot(stage);
530
+
531
+ function render() {
532
+ var slide = slides[current];
533
+ if (!slide) return;
534
+ nav.textContent =
535
+ (current + 1) + " / " + slides.length + " · " + slide.name;
536
+ if (slide.error) {
537
+ root.render(
538
+ React.createElement(
539
+ "div",
540
+ { className: "deck__error" },
541
+ slide.name + ".jsx failed:\\n" + slide.error.message
542
+ )
543
+ );
544
+ return;
545
+ }
546
+ root.render(
547
+ React.createElement(slide.Component, { assetsPath: "./assets" })
548
+ );
549
+ }
550
+
551
+ function fit() {
552
+ if (exportMode) {
553
+ stage.style.transform = "none";
554
+ return;
555
+ }
556
+ var sx = window.innerWidth / 1280;
557
+ var sy = window.innerHeight / 720;
558
+ var s = Math.min(sx, sy) * 0.95;
559
+ stage.style.transform = "scale(" + s + ")";
560
+ }
561
+
562
+ window.addEventListener("resize", fit);
563
+ fit();
564
+ render();
565
+
566
+ document.addEventListener("keydown", function (e) {
567
+ var k = e.key;
568
+ if (k === "ArrowRight" || k === "PageDown" || k === " ") {
569
+ current = Math.min(current + 1, slides.length - 1);
570
+ render();
571
+ e.preventDefault();
572
+ } else if (k === "ArrowLeft" || k === "PageUp") {
573
+ current = Math.max(current - 1, 0);
574
+ render();
575
+ e.preventDefault();
576
+ } else if (k === "Home") {
577
+ current = 0;
578
+ render();
579
+ } else if (k === "End") {
580
+ current = slides.length - 1;
581
+ render();
582
+ } else if (k === "f" || k === "F") {
583
+ if (document.fullscreenElement) document.exitFullscreen();
584
+ else document.documentElement.requestFullscreen();
585
+ }
586
+ });
587
+ })();
588
+ </script>
589
+ </body>
590
+ </html>
591
+ `;
592
+
593
+ export function buildDeck(deckDir, opts = {}) {
594
+ const skill = resolveSkillForDeck(deckDir);
595
+ const slidesDir = path.join(deckDir, "slides");
596
+ if (!fs.existsSync(slidesDir) || !fs.statSync(slidesDir).isDirectory()) {
597
+ throw new Error(`Expected a 'slides/' folder in ${deckDir}`);
598
+ }
599
+ if (skill.mode === "fill-template" && !opts.internalProposalFreeformRender) {
600
+ // Proposal decks are PPTX-native. The exporter may render freeform JSX
601
+ // inserts internally, but `slidemind build` should not create a lossy
602
+ // presentation.html artifact for proposal decks.
603
+ throw new Error(
604
+ `Deck ${path.basename(deckDir)} uses skill '${skill.skillName}' (mode ` +
605
+ `fill-template); it has no HTML preview. Run 'slidemind build-pptx ` +
606
+ `${path.basename(deckDir)}' to produce the editable .pptx.`,
607
+ );
608
+ }
609
+ const slideFiles = fs
610
+ .readdirSync(slidesDir)
611
+ .filter((f) => f.endsWith(".jsx"))
612
+ .sort()
613
+ .map((f) => path.join(slidesDir, f));
614
+ if (slideFiles.length === 0) {
615
+ throw new Error(`No .jsx files in ${slidesDir}`);
616
+ }
617
+
618
+ // Layout discovery: brand layouts first, then the active skill's layouts
619
+ // override on basename collision — so a sibling skill could reach for the
620
+ // brand's freeform workhorse (SlideContentLightBlank) without duplicating the
621
+ // JSX. With only the brand skill present, brand === skill and the dedup pass
622
+ // is a no-op.
623
+ const layoutDirs =
624
+ skill.brand.layoutsDir === skill.layoutsDir
625
+ ? [skill.layoutsDir]
626
+ : [skill.brand.layoutsDir, skill.layoutsDir];
627
+ const layoutByStem = new Map();
628
+ for (const dir of layoutDirs) {
629
+ if (!fs.existsSync(dir)) continue;
630
+ for (const f of fs.readdirSync(dir).filter((x) => x.endsWith(".jsx"))) {
631
+ layoutByStem.set(path.basename(f, ".jsx"), path.join(dir, f));
632
+ }
633
+ }
634
+ const layoutFiles = Array.from(layoutByStem.values()).sort();
635
+ if (layoutFiles.length === 0) {
636
+ throw new Error(`No layouts in ${skill.layoutsDir}`);
637
+ }
638
+
639
+ // Merge the brand primitive registry with the active skill's registry so a
640
+ // sibling skill's aliases and the brand's freeform layouts can coexist in one
641
+ // deck. (With only the brand skill present this is a single registry.)
642
+ lintSlidesOrThrow(slideFiles, {
643
+ primitivesPath: skill.primitivesPath,
644
+ brandPrimitivesPath: skill.brand.primitivesPath,
645
+ // Derive valid layout component names from the materialized layouts/*.jsx so
646
+ // the lint recognizes the bundle's layouts without a primitives.json
647
+ // `layouts` list (the materialized bundle doesn't ship one).
648
+ layoutsDirs: layoutDirs,
649
+ });
650
+
651
+ copyDesignSystem(deckDir, skill);
652
+
653
+ const layoutsBlock = layoutFiles.map(inlineLayout).join("\n");
654
+ const slidesBlock = slideFiles
655
+ .map((file, i) => inlineSlide(file, i))
656
+ .join("\n");
657
+
658
+ const svgAssets = collectSvgAssets(deckDir);
659
+
660
+ const html = HTML_TEMPLATE.replace("__TITLE__", path.basename(deckDir))
661
+ .replace("__LAYOUTS__", layoutsBlock)
662
+ .replace("__SLIDES__", slidesBlock)
663
+ .replace("__OVERRIDES__", safeInlineJson(loadOverrides(deckDir)))
664
+ .replace("__SVG_ASSETS__", safeInlineJson(svgAssets))
665
+ .replace("__RUNTIME_APPLIER__", safeInline(fs.readFileSync(RUNTIME_APPLIER, "utf8")));
666
+
667
+ const out = path.join(deckDir, "presentation.html");
668
+ fs.writeFileSync(out, html);
669
+ return { out, slideCount: slideFiles.length };
670
+ }
671
+
672
+ function openInBrowser(file) {
673
+ const opener =
674
+ process.platform === "darwin"
675
+ ? { cmd: "open", args: [file] }
676
+ : process.platform === "win32"
677
+ ? { cmd: "cmd", args: ["/c", "start", "", file] }
678
+ : { cmd: "xdg-open", args: [file] };
679
+ spawn(opener.cmd, opener.args, { stdio: "ignore", detached: true }).unref();
680
+ }
681
+
682
+ function parseArgs(argv) {
683
+ const args = { deck: null, open: false };
684
+ for (let i = 0; i < argv.length; i++) {
685
+ const a = argv[i];
686
+ if (a === "--deck" && i + 1 < argv.length) {
687
+ args.deck = argv[++i];
688
+ } else if (a === "--open") {
689
+ args.open = true;
690
+ } else if (a === "-h" || a === "--help") {
691
+ process.stdout.write(
692
+ "Usage: slidemind build <deck> [--open]\n" +
693
+ " node src/builders/build.mjs --deck <deck> [--open]\n",
694
+ );
695
+ process.exit(0);
696
+ } else {
697
+ process.stderr.write(`build: unexpected argument '${a}'\n`);
698
+ process.exit(2);
699
+ }
700
+ }
701
+ if (!args.deck) {
702
+ process.stderr.write("build: --deck is required\n");
703
+ process.exit(2);
704
+ }
705
+ return args;
706
+ }
707
+
708
+ function main() {
709
+ const args = parseArgs(process.argv.slice(2));
710
+ const deckDir = path.resolve(args.deck);
711
+ const { out, slideCount } = buildDeck(deckDir);
712
+ const rel = path.relative(process.cwd(), out) || out;
713
+ process.stdout.write(`Built ${rel} (${slideCount} slides)\n`);
714
+ if (args.open) openInBrowser(out);
715
+ }
716
+
717
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
718
+ try {
719
+ main();
720
+ } catch (err) {
721
+ process.stderr.write(`build: ${err.message || err}\n`);
722
+ process.exit(1);
723
+ }
724
+ }