@liustack/pptfast 0.1.0 → 0.3.0

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/dist/cli.js CHANGED
@@ -1,17 +1,32 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ AUDIENCE_VALUES,
4
+ DELIVERY_BUDGETS,
5
+ MODE_DEFINITIONS,
3
6
  PptfastError,
7
+ SCENARIO_PRESETS,
8
+ StyleOverrideSchema,
4
9
  VERSION,
10
+ assembleDeck,
11
+ auditDeck,
12
+ disassembleDeck,
13
+ formatInvalidPlanError,
5
14
  formatIssues,
6
15
  generatePptx,
16
+ getInstalledThemeIds,
7
17
  irJsonSchema,
8
18
  listThemes,
19
+ planJsonSchema,
9
20
  renderSlideSvg,
10
- validateIr
11
- } from "./chunk-DVMJWFDL.js";
21
+ resolvePlanThemeId,
22
+ resolveScenario,
23
+ styleJsonSchema,
24
+ validateIr,
25
+ validatePlan
26
+ } from "./chunk-ZXWCOWJI.js";
12
27
  import {
13
28
  installNodePlatform
14
- } from "./chunk-4WISUDXE.js";
29
+ } from "./chunk-SROEW6BH.js";
15
30
  import {
16
31
  getPlatform
17
32
  } from "./chunk-G76EVNVT.js";
@@ -20,39 +35,114 @@ import {
20
35
  import { Command } from "commander";
21
36
 
22
37
  // src/cli/commands.ts
23
- import { mkdir, writeFile } from "fs/promises";
24
- import { dirname, join, resolve as resolve2 } from "path";
38
+ import { mkdir as mkdir2, rm, writeFile as writeFile2 } from "fs/promises";
39
+ import { dirname as dirname2, join as join4, relative as relative2, resolve as resolve5 } from "path";
25
40
 
26
- // src/cli/load-ir.ts
41
+ // src/cli/config.ts
27
42
  import { readFile } from "fs/promises";
28
- import { extname, isAbsolute, resolve } from "path";
43
+ import { dirname, join as join2, resolve as resolve2 } from "path";
44
+ import { z } from "zod";
45
+
46
+ // src/cli/home.ts
47
+ import { homedir } from "os";
48
+ import { join, resolve } from "path";
49
+ function pptfastHome() {
50
+ return process.env.PPTFAST_HOME ?? join(homedir(), ".pptfast");
51
+ }
52
+ function decksRoot(config) {
53
+ return resolve(pptfastHome(), config?.decksDir ?? "decks");
54
+ }
55
+ function userConfigPath() {
56
+ return join(pptfastHome(), "config.json");
57
+ }
58
+
59
+ // src/cli/config.ts
60
+ var ConfigSchema = z.object({
61
+ theme: z.string().optional(),
62
+ style: StyleOverrideSchema.optional(),
63
+ decksDir: z.string().optional()
64
+ }).strict();
65
+ var UserConfigSchema = z.object({
66
+ theme: z.string().optional(),
67
+ style: StyleOverrideSchema.optional(),
68
+ decksDir: z.string().optional()
69
+ }).strict();
70
+ var CONFIG_FILENAME = "pptfast.config.json";
71
+ async function readConfigFile(path, schema) {
72
+ let text;
73
+ try {
74
+ text = await readFile(path, "utf8");
75
+ } catch {
76
+ return null;
77
+ }
78
+ let raw;
79
+ try {
80
+ raw = JSON.parse(text);
81
+ } catch (e) {
82
+ throw new PptfastError(`${path} is not valid JSON: ${e.message}`);
83
+ }
84
+ const r = schema.safeParse(raw);
85
+ if (!r.success) {
86
+ const detail = r.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
87
+ throw new PptfastError(`invalid ${path}:
88
+ ${detail}`);
89
+ }
90
+ return { path, config: r.data };
91
+ }
92
+ async function findConfig(startDir) {
93
+ let dir = resolve2(startDir);
94
+ for (; ; ) {
95
+ const hit = await readConfigFile(join2(dir, CONFIG_FILENAME), ConfigSchema);
96
+ if (hit) return hit;
97
+ const parent = dirname(dir);
98
+ if (parent === dir) return null;
99
+ dir = parent;
100
+ }
101
+ }
102
+ async function findUserConfig() {
103
+ return readConfigFile(userConfigPath(), UserConfigSchema);
104
+ }
105
+
106
+ // src/cli/deck-dir.ts
107
+ import { copyFile, mkdir, readFile as readFile3, readdir, stat, writeFile } from "fs/promises";
108
+ import { basename, extname as extname2, isAbsolute as isAbsolute2, join as join3, relative, resolve as resolve4 } from "path";
109
+
110
+ // src/cli/load-ir.ts
111
+ import { readFile as readFile2 } from "fs/promises";
112
+ import { extname, isAbsolute, resolve as resolve3 } from "path";
29
113
  var MIME_BY_EXT = {
30
114
  ".png": "image/png",
31
115
  ".jpg": "image/jpeg",
32
116
  ".jpeg": "image/jpeg",
33
117
  ".gif": "image/gif"
34
118
  };
35
- async function loadIrFile(irPath) {
119
+ var EXT_BY_MIME = {
120
+ "image/png": ".png",
121
+ "image/jpeg": ".jpg",
122
+ "image/gif": ".gif",
123
+ "image/webp": ".webp"
124
+ };
125
+ async function loadIrFile(irPath, kind = "IR") {
36
126
  let text;
37
127
  try {
38
- text = await readFile(irPath, "utf8");
128
+ text = await readFile2(irPath, "utf8");
39
129
  } catch {
40
- throw new PptfastError(`cannot read IR file: ${irPath}`);
130
+ throw new PptfastError(`cannot read ${kind} file: ${irPath}`);
41
131
  }
42
132
  try {
43
133
  return JSON.parse(text);
44
134
  } catch (e) {
45
- throw new PptfastError(`${irPath} is not valid JSON: ${e.message}`);
135
+ throw new PptfastError(`${kind} file ${irPath} is not valid JSON: ${e.message}`);
46
136
  }
47
137
  }
48
138
  async function resolveLocalAssets(ir, baseDir) {
49
139
  for (const [name, asset] of Object.entries(ir.assets.images)) {
50
140
  const src = asset.src;
51
141
  if (src.startsWith("data:") || /^https?:\/\//.test(src)) continue;
52
- const abs = isAbsolute(src) ? src : resolve(baseDir, src);
142
+ const abs = isAbsolute(src) ? src : resolve3(baseDir, src);
53
143
  let bytes;
54
144
  try {
55
- bytes = await readFile(abs);
145
+ bytes = await readFile2(abs);
56
146
  } catch {
57
147
  throw new PptfastError(`asset "${name}": cannot read image file ${abs} (from src "${src}")`);
58
148
  }
@@ -71,53 +161,685 @@ async function resolveLocalAssets(ir, baseDir) {
71
161
  }
72
162
  }
73
163
 
164
+ // src/cli/deck-dir.ts
165
+ var PLAN_FILENAME = "deck.plan.json";
166
+ var PAGES_DIRNAME = "pages";
167
+ var ASSETS_DIRNAME = "assets";
168
+ function assertSafeFileSegment(id, context) {
169
+ const safeBase = resolve4("/pptfast-safe-base");
170
+ const rel = relative(safeBase, resolve4(safeBase, id));
171
+ const safe = !isAbsolute2(id) && !id.includes("/") && !id.includes("\\") && id !== ".." && !rel.startsWith("..") && !isAbsolute2(rel);
172
+ if (!safe) {
173
+ throw new PptfastError(
174
+ `${context} "${id}" is not a safe file name \u2014 ids used as page/asset file names must not contain path separators or ".."`
175
+ );
176
+ }
177
+ }
178
+ async function isDeckDirectory(path) {
179
+ try {
180
+ return (await stat(path)).isDirectory();
181
+ } catch (e) {
182
+ if (e.code === "ENOENT") return false;
183
+ throw new PptfastError(`cannot check ${path}: ${e.message}`);
184
+ }
185
+ }
186
+ async function pathExists(path) {
187
+ try {
188
+ await stat(path);
189
+ return true;
190
+ } catch (e) {
191
+ if (e.code === "ENOENT") return false;
192
+ throw new PptfastError(`cannot check path ${path}: ${e.message}`);
193
+ }
194
+ }
195
+ async function resolveDeckTarget(arg, config, cwd = process.cwd()) {
196
+ if (arg.trim() === "") throw new PptfastError("deck target must not be empty");
197
+ if (arg.includes("/") || arg.includes("\\")) return resolve4(cwd, arg);
198
+ const local = resolve4(cwd, arg);
199
+ if (await pathExists(local)) return local;
200
+ const fallback = join3(decksRoot(config), arg);
201
+ return await pathExists(fallback) ? fallback : local;
202
+ }
203
+ function expectedLayoutHint() {
204
+ const rows = [
205
+ [PLAN_FILENAME, "the locked plan (see `pptfast plan validate`)"],
206
+ [`${PAGES_DIRNAME}/<page-id>.json`, "one file per filled page (missing pages become placeholders)"],
207
+ [`${ASSETS_DIRNAME}/`, "optional local images"]
208
+ ];
209
+ const width = Math.max(...rows.map(([name]) => name.length)) + 2;
210
+ return rows.map(([name, desc]) => ` ${name.padEnd(width)}${desc}`).join("\n");
211
+ }
212
+ async function readPlanFile(dir) {
213
+ const planPath = join3(dir, PLAN_FILENAME);
214
+ let text;
215
+ try {
216
+ text = await readFile3(planPath, "utf8");
217
+ } catch (e) {
218
+ if (e.code === "ENOENT") {
219
+ throw new PptfastError(
220
+ `no ${PLAN_FILENAME} in ${dir} \u2014 expected a deck project directory:
221
+ ${expectedLayoutHint()}`
222
+ );
223
+ }
224
+ throw new PptfastError(`cannot read plan file: ${planPath}`);
225
+ }
226
+ try {
227
+ return JSON.parse(text);
228
+ } catch (e) {
229
+ throw new PptfastError(`plan file ${planPath} is not valid JSON: ${e.message}`);
230
+ }
231
+ }
232
+ async function readPages(dir) {
233
+ const pagesDir = join3(dir, PAGES_DIRNAME);
234
+ let entries;
235
+ try {
236
+ entries = (await readdir(pagesDir, { withFileTypes: true })).filter((entry) => entry.isFile() && extname2(entry.name) === ".json").map((entry) => entry.name);
237
+ } catch (e) {
238
+ if (e.code === "ENOENT") return {};
239
+ throw new PptfastError(`cannot read ${PAGES_DIRNAME}/ directory ${pagesDir}: ${e.message}`);
240
+ }
241
+ const pages = {};
242
+ await Promise.all(
243
+ entries.map(async (entry) => {
244
+ const id = basename(entry, ".json");
245
+ pages[id] = await loadIrFile(join3(pagesDir, entry), `page "${id}"`);
246
+ })
247
+ );
248
+ return pages;
249
+ }
250
+ async function scanAssets(dir) {
251
+ const assetsDir = join3(dir, ASSETS_DIRNAME);
252
+ let entries;
253
+ try {
254
+ entries = (await readdir(assetsDir, { withFileTypes: true })).filter((entry) => entry.isFile() && !entry.name.startsWith(".")).map((entry) => entry.name);
255
+ } catch (e) {
256
+ if (e.code === "ENOENT") return {};
257
+ throw new PptfastError(`cannot read ${ASSETS_DIRNAME}/ directory ${assetsDir}: ${e.message}`);
258
+ }
259
+ const images = {};
260
+ const sourceFile = /* @__PURE__ */ new Map();
261
+ for (const entry of entries) {
262
+ const id = basename(entry, extname2(entry));
263
+ const previous = sourceFile.get(id);
264
+ if (previous !== void 0) {
265
+ throw new PptfastError(
266
+ `${ASSETS_DIRNAME}/${previous} and ${ASSETS_DIRNAME}/${entry} both register image id "${id}" \u2014 rename one of the files`
267
+ );
268
+ }
269
+ sourceFile.set(id, entry);
270
+ images[id] = { src: `${ASSETS_DIRNAME}/${entry}` };
271
+ }
272
+ return images;
273
+ }
274
+ async function readDeckDir(dir) {
275
+ const deckDir = resolve4(dir);
276
+ const plan2 = await readPlanFile(deckDir);
277
+ const pages = await readPages(deckDir);
278
+ const { ir, generatedSeed, materializedLayoutCount } = assembleDeck(plan2, pages);
279
+ const images = await scanAssets(deckDir);
280
+ const merged = { ...ir, assets: { images: { ...ir.assets.images, ...images } } };
281
+ return { ir: merged, generatedSeed, materializedLayoutCount, deckDir };
282
+ }
283
+ async function writeDeckAssets(images, outDir, sourceBaseDir) {
284
+ const entries = Object.entries(images);
285
+ const assetsDir = join3(outDir, ASSETS_DIRNAME);
286
+ if (entries.length === 0) return { count: 0, assetsDir };
287
+ await mkdir(assetsDir, { recursive: true });
288
+ await Promise.all(entries.map(([id, asset]) => writeOneAsset(id, asset.src, assetsDir, sourceBaseDir)));
289
+ return { count: entries.length, assetsDir };
290
+ }
291
+ var DATA_URI_RE = /^data:([^;,]+);base64,(.*)$/s;
292
+ async function writeOneAsset(id, src, assetsDir, sourceBaseDir) {
293
+ assertSafeFileSegment(id, "asset id");
294
+ if (src.startsWith("data:")) {
295
+ const match = DATA_URI_RE.exec(src);
296
+ if (!match) {
297
+ throw new PptfastError(`asset "${id}": only base64-encoded data URIs can be disassembled (malformed data URI)`);
298
+ }
299
+ const mime = match[1];
300
+ const payload = match[2];
301
+ const ext = EXT_BY_MIME[mime];
302
+ if (!ext) {
303
+ throw new PptfastError(
304
+ `asset "${id}": cannot disassemble a data URI with mime "${mime}" \u2014 expected one of ${Object.keys(EXT_BY_MIME).join(", ")}`
305
+ );
306
+ }
307
+ await writeFile(join3(assetsDir, `${id}${ext}`), Buffer.from(payload, "base64"));
308
+ return;
309
+ }
310
+ if (/^https?:\/\//.test(src)) {
311
+ throw new PptfastError(
312
+ `asset "${id}": URL assets cannot be disassembled into a deck directory \u2014 inline it as a data URI or download it first`
313
+ );
314
+ }
315
+ const abs = isAbsolute2(src) ? src : resolve4(sourceBaseDir, src);
316
+ try {
317
+ await copyFile(abs, join3(assetsDir, `${id}${extname2(abs)}`));
318
+ } catch {
319
+ throw new PptfastError(`asset "${id}": cannot read source image ${abs} (from src "${src}") \u2014 cannot disassemble`);
320
+ }
321
+ }
322
+
323
+ // src/cli/preview-html.ts
324
+ function escapeHtml(s) {
325
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
326
+ }
327
+ function slideNode(slide) {
328
+ const idAttr = slide.id !== void 0 ? ` data-id="${escapeHtml(slide.id)}"` : "";
329
+ const badge = slide.placeholder ? `<div class="pf-badge" aria-hidden="true">unfilled</div>` : "";
330
+ return `<div class="pf-slide" id="pf-slide-${slide.index}" data-index="${slide.index}"${idAttr}>${badge}${slide.svg}</div>`;
331
+ }
332
+ function thumbDescription(slide) {
333
+ const parts = [`slide ${slide.index + 1}`, slide.type];
334
+ if (slide.id !== void 0) parts.push(slide.id);
335
+ if (slide.placeholder) parts.push("unfilled");
336
+ return escapeHtml(parts.join(" \xB7 "));
337
+ }
338
+ function positionLabel(slide) {
339
+ const idPart = slide.id !== void 0 ? ` \xB7 ${slide.id}` : "";
340
+ return escapeHtml(`${slide.index + 1}${idPart}`);
341
+ }
342
+ function counterText(slide, total) {
343
+ const idPart = slide.id !== void 0 ? ` \xB7 ${slide.id}` : "";
344
+ return escapeHtml(`${slide.index + 1} / ${total}${idPart}`);
345
+ }
346
+ function thumbButton(slide, isActive, slotContent) {
347
+ const description = thumbDescription(slide);
348
+ const badge = slide.placeholder ? `<span class="pf-thumb-badge" aria-hidden="true">unfilled</span>` : "";
349
+ return `<button type="button" class="pf-thumb${isActive ? " pf-thumb-active" : ""}" id="pf-thumb-${slide.index}" data-index="${slide.index}" title="${description}" aria-label="${description}"><span class="pf-thumb-slot" id="pf-slot-${slide.index}">${slotContent}</span><span class="pf-thumb-label">${positionLabel(slide)}</span>${badge}</button>`;
350
+ }
351
+ var CSS = `
352
+ :root{color-scheme:light}
353
+ *{box-sizing:border-box}
354
+ html,body{height:100%;margin:0}
355
+ body{display:flex;flex-direction:column;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;background:#f4f4f4;color:#1a1a1a}
356
+ header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;background:#fff;border-bottom:1px solid #ddd;font-size:14px;flex:0 0 auto}
357
+ #pf-title{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
358
+ #pf-counter{font-variant-numeric:tabular-nums;color:#555;white-space:nowrap}
359
+ #pf-stage-wrap{flex:1 1 auto;min-height:0;display:flex;align-items:center;justify-content:center;padding:16px}
360
+ #pf-stage{position:relative;background:#000;box-shadow:0 2px 16px rgba(0,0,0,.15);aspect-ratio:16/9;width:min(100%,calc((100vh - 190px) * 16 / 9));max-height:100%}
361
+ #pf-stage,.pf-thumb-slot{position:relative}
362
+ .pf-slide{position:absolute;inset:0}
363
+ .pf-slide svg{display:block;width:100%;height:100%}
364
+ #pf-filmstrip{display:flex;gap:8px;padding:10px 16px;overflow-x:auto;background:#fff;border-top:1px solid #ddd;flex:0 0 auto}
365
+ .pf-thumb{flex:0 0 auto;width:160px;padding:0;margin:0;border:2px solid transparent;background:#eee;cursor:pointer;border-radius:6px;overflow:hidden;position:relative;font:inherit;text-align:left}
366
+ .pf-thumb:hover{border-color:#93c5fd}
367
+ .pf-thumb-active,.pf-thumb-active:hover{border-color:#2563eb;background:#dbeafe}
368
+ .pf-thumb-slot{display:block;width:100%;aspect-ratio:16/9;background:#ddd}
369
+ .pf-thumb-label{display:block;font-size:11px;line-height:1.4;padding:3px 6px;color:#444;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
370
+ .pf-badge,.pf-thumb-badge{position:absolute;top:4px;right:4px;background:#d97706;color:#fff;font-size:10px;font-weight:700;letter-spacing:.03em;padding:2px 6px;border-radius:3px;text-transform:uppercase;z-index:2;pointer-events:none}
371
+ `.trim();
372
+ var JS = `
373
+ (function () {
374
+ var stage = document.getElementById('pf-stage')
375
+ var counter = document.getElementById('pf-counter')
376
+ var thumbs = Array.prototype.slice.call(document.querySelectorAll('.pf-thumb'))
377
+ var total = thumbs.length
378
+ if (total === 0) return
379
+ var current = parseInt(thumbs[0].getAttribute('data-index'), 10)
380
+
381
+ function slideEl(i) { return document.getElementById('pf-slide-' + i) }
382
+ function slotEl(i) { return document.getElementById('pf-slot-' + i) }
383
+ function thumbEl(i) { return document.getElementById('pf-thumb-' + i) }
384
+ function thumbPos(i) {
385
+ for (var t = 0; t < thumbs.length; t++) {
386
+ if (parseInt(thumbs[t].getAttribute('data-index'), 10) === i) return t
387
+ }
388
+ return -1
389
+ }
390
+
391
+ function updateCounter(i) {
392
+ var el = slideEl(i)
393
+ if (!el) return
394
+ var text = (thumbPos(i) + 1) + ' / ' + total
395
+ var id = el.getAttribute('data-id')
396
+ if (id) text += ' \xB7 ' + id
397
+ counter.textContent = text
398
+ }
399
+
400
+ function activate(i) {
401
+ if (i === current) return
402
+ var nextSlide = slideEl(i)
403
+ var nextThumb = thumbEl(i)
404
+ if (!nextSlide || !nextThumb) return
405
+ var prevSlide = slideEl(current)
406
+ var prevSlot = slotEl(current)
407
+ if (prevSlide && prevSlot) prevSlot.appendChild(prevSlide)
408
+ var prevThumb = thumbEl(current)
409
+ if (prevThumb) prevThumb.classList.remove('pf-thumb-active')
410
+ stage.appendChild(nextSlide)
411
+ nextThumb.classList.add('pf-thumb-active')
412
+ current = i
413
+ updateCounter(i)
414
+ }
415
+
416
+ thumbs.forEach(function (t) {
417
+ t.addEventListener('click', function () {
418
+ activate(parseInt(t.getAttribute('data-index'), 10))
419
+ })
420
+ })
421
+
422
+ document.addEventListener('keydown', function (e) {
423
+ var pos = thumbPos(current)
424
+ if (e.key === 'ArrowRight' && pos < total - 1) {
425
+ activate(parseInt(thumbs[pos + 1].getAttribute('data-index'), 10))
426
+ } else if (e.key === 'ArrowLeft' && pos > 0) {
427
+ activate(parseInt(thumbs[pos - 1].getAttribute('data-index'), 10))
428
+ }
429
+ })
430
+ })()
431
+ `.trim();
432
+ function buildPreviewHtml(input) {
433
+ const { title, slides } = input;
434
+ const total = slides.length;
435
+ const escapedTitle = escapeHtml(title);
436
+ const stageSlide = total > 0 ? slideNode(slides[0]) : "";
437
+ const thumbs = slides.map((s, i) => thumbButton(s, i === 0, i === 0 ? "" : slideNode(s))).join("");
438
+ const initialCounter = total > 0 ? counterText(slides[0], total) : "0 / 0";
439
+ return `<!doctype html>
440
+ <html lang="en">
441
+ <head>
442
+ <meta charset="utf-8">
443
+ <meta name="viewport" content="width=device-width, initial-scale=1">
444
+ <title>${escapedTitle} \u2014 pptfast preview</title>
445
+ <style>${CSS}</style>
446
+ </head>
447
+ <body>
448
+ <header>
449
+ <span id="pf-title">${escapedTitle}</span>
450
+ <span id="pf-counter">${initialCounter}</span>
451
+ </header>
452
+ <div id="pf-stage-wrap"><div id="pf-stage">${stageSlide}</div></div>
453
+ <nav id="pf-filmstrip" aria-label="slides">${thumbs}</nav>
454
+ <script>${JS}</script>
455
+ </body>
456
+ </html>
457
+ `;
458
+ }
459
+
74
460
  // src/cli/commands.ts
75
- async function runRender(irPath, opts) {
76
- const raw = await loadIrFile(irPath);
77
- if (opts.theme && typeof raw === "object" && raw !== null) {
78
- ;
79
- raw.theme = { id: opts.theme };
461
+ async function loadStyleFile(path) {
462
+ const raw = await loadIrFile(path);
463
+ const r = StyleOverrideSchema.safeParse(raw);
464
+ if (!r.success) {
465
+ const detail = r.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
466
+ throw new PptfastError(`invalid style file ${path}:
467
+ ${detail}`);
468
+ }
469
+ return r.data;
470
+ }
471
+ function describeThemeSource(opts, projectHit, userHit) {
472
+ if (opts.theme !== void 0) return "--theme";
473
+ if (projectHit?.config.theme !== void 0) return projectHit.path;
474
+ if (userHit?.config.theme !== void 0) return userHit.path;
475
+ return "the deck's own theme";
476
+ }
477
+ function resolveDecksDirSource(projectHit, userHit) {
478
+ if (projectHit?.config.decksDir !== void 0) {
479
+ return { decksDir: resolve5(dirname2(projectHit.path), projectHit.config.decksDir) };
80
480
  }
481
+ return userHit?.config;
482
+ }
483
+ async function applyDeckConfig(raw, opts) {
484
+ if (typeof raw !== "object" || raw === null) return;
485
+ const deck = raw;
486
+ const irTheme = typeof deck.theme === "object" && deck.theme !== null ? deck.theme : {};
487
+ const [projectHit, userHit] = await Promise.all([
488
+ opts.projectHit !== void 0 ? Promise.resolve(opts.projectHit) : findConfig(opts.cwd),
489
+ opts.userHit !== void 0 ? Promise.resolve(opts.userHit) : findUserConfig()
490
+ ]);
491
+ const theme = opts.theme ?? projectHit?.config.theme ?? userHit?.config.theme ?? irTheme.id;
492
+ const style = opts.stylePath ? await loadStyleFile(opts.stylePath) : projectHit?.config.style ?? userHit?.config.style ?? irTheme.style;
493
+ if (theme !== void 0) {
494
+ const installedThemeIds = getInstalledThemeIds();
495
+ if (!installedThemeIds.includes(theme)) {
496
+ throw new PptfastError(
497
+ `unknown theme "${theme}" (from ${describeThemeSource(opts, projectHit, userHit)}) \u2014 available: ${installedThemeIds.join(", ")} (see \`pptfast themes\`)`
498
+ );
499
+ }
500
+ }
501
+ if (theme === void 0 && style === void 0) return;
502
+ deck.theme = { ...irTheme, id: theme, ...style !== void 0 ? { style } : {} };
503
+ }
504
+ async function loadDeckTarget(arg, cwd, projectHit, userHit) {
505
+ const target = await resolveDeckTarget(arg, resolveDecksDirSource(projectHit, userHit), cwd);
506
+ if (await isDeckDirectory(target)) {
507
+ const { ir, deckDir } = await readDeckDir(target);
508
+ return { raw: ir, baseDir: deckDir, isDir: true };
509
+ }
510
+ const raw = await loadIrFile(target);
511
+ return { raw, baseDir: dirname2(resolve5(target)), isDir: false };
512
+ }
513
+ async function runRender(irPath, opts) {
514
+ const cwd = opts.cwd ?? process.cwd();
515
+ const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
516
+ const { raw, baseDir } = await loadDeckTarget(irPath, cwd, projectHit, userHit);
517
+ await applyDeckConfig(raw, { theme: opts.theme, stylePath: opts.stylePath, cwd, projectHit, userHit });
81
518
  const v = validateIr(raw);
82
519
  if (!v.ok) throw new PptfastError(`invalid IR:
83
520
  ${formatIssues(v.errors)}`);
84
- await resolveLocalAssets(v.ir, dirname(resolve2(irPath)));
85
- const bytes = await generatePptx(v.ir);
86
- await mkdir(dirname(resolve2(opts.output)), { recursive: true });
87
- await writeFile(opts.output, bytes);
88
- return `wrote ${opts.output} (${v.ir.slides.length} slides, ${bytes.length} bytes)`;
521
+ await resolveLocalAssets(v.ir, baseDir);
522
+ const bytes = await generatePptx(v.ir, { draft: opts.draft });
523
+ await mkdir2(dirname2(resolve5(opts.output)), { recursive: true });
524
+ await writeFile2(opts.output, bytes);
525
+ const ok = `wrote ${opts.output} (${v.ir.slides.length} slides, ${bytes.length} bytes)`;
526
+ const note = normalizedNote(v.normalized);
527
+ return note ? `${ok}
528
+ ${note}` : ok;
89
529
  }
90
- async function runValidate(irPath) {
91
- const raw = await loadIrFile(irPath);
530
+ function normalizedNote(normalized) {
531
+ if (!normalized || normalized.length === 0) return void 0;
532
+ const n = normalized.length;
533
+ return `note: ${n} field alias${n === 1 ? "" : "es"} normalized
534
+ ${normalized.map((line) => ` ${line}`).join("\n")}`;
535
+ }
536
+ function placeholderNote(ir) {
537
+ const placeholders = ir.slides.map((slide, i) => ({ slide, page: i + 1 })).filter(({ slide }) => slide.placeholder);
538
+ if (placeholders.length === 0) return void 0;
539
+ const refs = placeholders.map(({ slide, page }) => slide.id ? `${slide.id} (page ${page})` : `page ${page}`).join(", ");
540
+ return `note: ${placeholders.length} unfilled placeholder page${placeholders.length === 1 ? "" : "s"}: ${refs}`;
541
+ }
542
+ async function runValidate(irPath, cwd = process.cwd()) {
543
+ const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
544
+ const { raw, isDir } = await loadDeckTarget(irPath, cwd, projectHit, userHit);
545
+ await applyDeckConfig(raw, { cwd, projectHit, userHit });
92
546
  const v = validateIr(raw);
93
547
  if (!v.ok)
94
548
  throw new PptfastError(
95
549
  `invalid IR (${v.errors.length} issue${v.errors.length === 1 ? "" : "s"}):
96
550
  ${formatIssues(v.errors)}`
97
551
  );
98
- return `OK \u2014 ${v.ir.slides.length} slides, theme "${v.ir.theme.id}"`;
552
+ const ok = `OK \u2014 ${v.ir.slides.length} slides, theme "${v.ir.theme.id}"`;
553
+ const notes = [];
554
+ const aliasNote = normalizedNote(v.normalized);
555
+ if (aliasNote) notes.push(aliasNote);
556
+ if (isDir) {
557
+ const note = placeholderNote(v.ir);
558
+ if (note) notes.push(note);
559
+ }
560
+ return notes.length > 0 ? `${ok}
561
+ ${notes.join("\n")}` : ok;
562
+ }
563
+ function formatAuditFinding(f) {
564
+ const idSuffix = f.slideId !== void 0 ? ` (${f.slideId})` : "";
565
+ return `page ${f.page}${idSuffix}: [${f.code}] ${f.message}`;
99
566
  }
100
- function runSchema() {
101
- return JSON.stringify(irJsonSchema(), null, 2);
567
+ function formatAuditReport(report, ir) {
568
+ const lines = report.findings.map(formatAuditFinding);
569
+ lines.push(
570
+ `audited ${report.pagesAudited} page${report.pagesAudited === 1 ? "" : "s"}, ${report.pagesSkipped} skipped, ${report.findings.length} finding${report.findings.length === 1 ? "" : "s"}`
571
+ );
572
+ const note = placeholderNote(ir);
573
+ if (note) lines.push(note);
574
+ return lines.join("\n");
575
+ }
576
+ async function runAudit(target, opts = {}) {
577
+ const cwd = opts.cwd ?? process.cwd();
578
+ const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
579
+ const { raw, baseDir } = await loadDeckTarget(target, cwd, projectHit, userHit);
580
+ await applyDeckConfig(raw, { cwd, projectHit, userHit });
581
+ const v = validateIr(raw);
582
+ if (!v.ok) {
583
+ throw new PptfastError(
584
+ `invalid IR (${v.errors.length} issue${v.errors.length === 1 ? "" : "s"}):
585
+ ${formatIssues(v.errors)}`
586
+ );
587
+ }
588
+ await resolveLocalAssets(v.ir, baseDir);
589
+ const report = auditDeck(v.ir);
590
+ const hasFindings = report.findings.length > 0;
591
+ const output = opts.json ? JSON.stringify(report, null, 2) : formatAuditReport(report, v.ir);
592
+ return { output, hasFindings };
593
+ }
594
+ async function runPlanValidate(planPath) {
595
+ const raw = await loadIrFile(planPath, "plan");
596
+ const v = validatePlan(raw);
597
+ if (!v.ok) {
598
+ throw new PptfastError(formatInvalidPlanError(v.errors));
599
+ }
600
+ const plan2 = v.plan;
601
+ const axes = resolveScenario(plan2.scenario);
602
+ return `OK \u2014 ${plan2.pages.length} pages, scenario ${axes.mode}/${axes.delivery}/${axes.audience}, theme "${resolvePlanThemeId(plan2)}"`;
603
+ }
604
+ function runSchema(mode) {
605
+ const schema = mode === "style" ? styleJsonSchema() : mode === "plan" ? planJsonSchema() : irJsonSchema();
606
+ return JSON.stringify(schema, null, 2);
102
607
  }
103
608
  function runThemes(asJson) {
104
609
  const themes = listThemes();
105
610
  if (asJson) return JSON.stringify(themes, null, 2);
106
611
  return themes.map((t) => `${t.id.padEnd(12)} ${t.label}`).join("\n");
107
612
  }
108
- async function runPreview(irPath, outDir) {
109
- const raw = await loadIrFile(irPath);
613
+ function runScenarios(asJson) {
614
+ if (asJson) {
615
+ return JSON.stringify(
616
+ {
617
+ presets: SCENARIO_PRESETS,
618
+ modes: MODE_DEFINITIONS,
619
+ deliveries: DELIVERY_BUDGETS,
620
+ audiences: AUDIENCE_VALUES
621
+ },
622
+ null,
623
+ 2
624
+ );
625
+ }
626
+ const rows = Object.values(SCENARIO_PRESETS).map((p) => ({
627
+ id: p.id,
628
+ axes: `${p.axes.mode}/${p.axes.delivery}/${p.axes.audience}`,
629
+ themes: p.themeRecommendations.join(", ")
630
+ }));
631
+ const idWidth = Math.max(...rows.map((r) => r.id.length));
632
+ const axesWidth = Math.max(...rows.map((r) => r.axes.length));
633
+ return rows.map((r) => `${r.id.padEnd(idWidth + 2)}${r.axes.padEnd(axesWidth + 2)}${r.themes}`).join("\n");
634
+ }
635
+ var CONFIG_TEMPLATE = {
636
+ theme: "consulting",
637
+ style: {
638
+ colors: { primary: "#0B5FFF", accent: "#FF6A00" }
639
+ }
640
+ };
641
+ async function runInit(cwd = process.cwd()) {
642
+ const target = join4(cwd, CONFIG_FILENAME);
643
+ try {
644
+ await writeFile2(target, JSON.stringify(CONFIG_TEMPLATE, null, 2) + "\n", { flag: "wx" });
645
+ } catch (e) {
646
+ if (e.code === "EEXIST") {
647
+ throw new PptfastError(`${target} already exists \u2014 edit it instead`);
648
+ }
649
+ throw e;
650
+ }
651
+ return `wrote ${target} \u2014 themes: \`pptfast themes\`, style schema: \`pptfast schema --style\``;
652
+ }
653
+ async function runPreview(irPath, outDir, opts = {}) {
654
+ const cwd = opts.cwd ?? process.cwd();
655
+ const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
656
+ const { raw, baseDir } = await loadDeckTarget(irPath, cwd, projectHit, userHit);
657
+ await applyDeckConfig(raw, { cwd, projectHit, userHit });
110
658
  const v = validateIr(raw);
111
659
  if (!v.ok) throw new PptfastError(`invalid IR:
112
660
  ${formatIssues(v.errors)}`);
113
- await resolveLocalAssets(v.ir, dirname(resolve2(irPath)));
114
- await mkdir(outDir, { recursive: true });
661
+ await resolveLocalAssets(v.ir, baseDir);
662
+ await mkdir2(outDir, { recursive: true });
115
663
  const ir = v.ir;
664
+ const svgs = [];
116
665
  for (let i = 0; i < ir.slides.length; i++) {
666
+ const svg = renderSlideSvg(ir, i);
667
+ svgs.push(svg);
117
668
  const name = `${String(i + 1).padStart(3, "0")}-${ir.slides[i].type}.svg`;
118
- await writeFile(join(outDir, name), renderSlideSvg(ir, i));
669
+ await writeFile2(join4(outDir, name), svg);
670
+ }
671
+ const ok = `wrote ${ir.slides.length} SVG files to ${outDir}`;
672
+ const notes = [];
673
+ const aliasNote = normalizedNote(v.normalized);
674
+ if (aliasNote) notes.push(aliasNote);
675
+ if (opts.htmlOut) {
676
+ const htmlPath = join4(outDir, "preview.html");
677
+ const html = buildPreviewHtml({
678
+ title: ir.filename,
679
+ slides: ir.slides.map((slide, i) => ({
680
+ index: i,
681
+ id: slide.id,
682
+ type: slide.type,
683
+ svg: svgs[i],
684
+ placeholder: slide.placeholder
685
+ }))
686
+ });
687
+ await writeFile2(htmlPath, html);
688
+ notes.push(`note: wrote self-contained preview to ${htmlPath}`);
689
+ }
690
+ return notes.length > 0 ? `${ok}
691
+ ${notes.join("\n")}` : ok;
692
+ }
693
+ function withRewrittenAssetPaths(ir, deckDir, outDir) {
694
+ const images = Object.fromEntries(
695
+ Object.entries(ir.assets.images).map(([id, asset]) => {
696
+ if (asset.src.startsWith("data:") || /^https?:\/\//.test(asset.src)) return [id, asset];
697
+ return [id, { ...asset, src: relative2(outDir, join4(deckDir, asset.src)) }];
698
+ })
699
+ );
700
+ return { ...ir, assets: { images } };
701
+ }
702
+ async function runAssemble(target, opts = {}) {
703
+ const cwd = opts.cwd ?? process.cwd();
704
+ const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
705
+ const dir = await resolveDeckTarget(target, resolveDecksDirSource(projectHit, userHit), cwd);
706
+ if (await pathExists(dir) && !await isDeckDirectory(dir)) {
707
+ throw new PptfastError(`expected a deck project directory: ${dir}`);
708
+ }
709
+ const { ir, generatedSeed, materializedLayoutCount, deckDir } = await readDeckDir(dir);
710
+ const outPath = opts.output ? resolve5(cwd, opts.output) : join4(deckDir, "deck.json");
711
+ const outDir = dirname2(outPath);
712
+ const outIr = outDir === deckDir ? ir : withRewrittenAssetPaths(ir, deckDir, outDir);
713
+ await mkdir2(outDir, { recursive: true });
714
+ await writeFile2(outPath, JSON.stringify(outIr, null, 2) + "\n");
715
+ const placeholderCount = outIr.slides.filter((s) => s.placeholder).length;
716
+ const summary = `wrote ${outPath} (${outIr.slides.length} slides, ${placeholderCount} placeholder${placeholderCount === 1 ? "" : "s"})`;
717
+ const notes = [];
718
+ if (generatedSeed !== void 0) {
719
+ notes.push(`note: generated seed ${generatedSeed} \u2014 add "seed": ${generatedSeed} to deck.plan.json for revision stability`);
720
+ }
721
+ if (materializedLayoutCount !== void 0) {
722
+ notes.push(
723
+ `note: ${materializedLayoutCount} layout${materializedLayoutCount === 1 ? "" : "s"} auto-selected into deck.json \u2014 pin "layout" in a page file to lock one`
724
+ );
725
+ }
726
+ return [summary, ...notes].join("\n");
727
+ }
728
+ async function runDisassemble(irPath, outDir) {
729
+ const raw = await loadIrFile(irPath);
730
+ const v = validateIr(raw);
731
+ if (!v.ok) throw new PptfastError(`invalid IR:
732
+ ${formatIssues(v.errors)}`);
733
+ const { plan: plan2, pages } = disassembleDeck(v.ir);
734
+ const ids = Object.keys(pages);
735
+ for (const id of ids) assertSafeFileSegment(id, "slide id");
736
+ const planPath = join4(outDir, "deck.plan.json");
737
+ await mkdir2(outDir, { recursive: true });
738
+ try {
739
+ await writeFile2(planPath, JSON.stringify(plan2, null, 2) + "\n", { flag: "wx" });
740
+ } catch (e) {
741
+ if (e.code === "EEXIST") {
742
+ throw new PptfastError(`${planPath} already exists \u2014 refusing to overwrite an existing deck project`);
743
+ }
744
+ throw e;
745
+ }
746
+ const pagesDir = join4(outDir, "pages");
747
+ try {
748
+ if (ids.length > 0) {
749
+ await mkdir2(pagesDir, { recursive: true });
750
+ await Promise.all(
751
+ ids.map((id) => {
752
+ const content = pages[id];
753
+ return writeFile2(join4(pagesDir, `${id}.json`), JSON.stringify(content, null, 2) + "\n");
754
+ })
755
+ );
756
+ }
757
+ const { count: assetCount, assetsDir } = await writeDeckAssets(
758
+ v.ir.assets.images,
759
+ outDir,
760
+ dirname2(resolve5(irPath))
761
+ );
762
+ const pagesNote = ids.length > 0 ? `${ids.length} page file${ids.length === 1 ? "" : "s"} to ${pagesDir}` : "no pages (every slide was a placeholder)";
763
+ const assetsNote = assetCount > 0 ? `, and ${assetCount} asset file${assetCount === 1 ? "" : "s"} to ${assetsDir}` : "";
764
+ return `wrote ${planPath}, ${pagesNote}${assetsNote}`;
765
+ } catch (e) {
766
+ await rm(planPath, { force: true }).catch(() => {
767
+ });
768
+ throw e;
119
769
  }
120
- return `wrote ${ir.slides.length} SVG files to ${outDir}`;
770
+ }
771
+
772
+ // src/cli/update.ts
773
+ import { execFile } from "child_process";
774
+ var PACKAGE_NAME = "@liustack/pptfast";
775
+ function normalizeVersion(version) {
776
+ const normalized = version.trim().replace(/^v/i, "").split("-")[0];
777
+ if (!normalized) throw new Error(`invalid version: ${version}`);
778
+ return normalized;
779
+ }
780
+ function parseVersion(version) {
781
+ return normalizeVersion(version).split(".").map((segment) => {
782
+ const value = Number.parseInt(segment, 10);
783
+ if (!Number.isFinite(value)) throw new Error(`invalid version segment: ${segment}`);
784
+ return value;
785
+ });
786
+ }
787
+ function compareVersions(left, right) {
788
+ const l = parseVersion(left);
789
+ const r = parseVersion(right);
790
+ for (let i = 0; i < Math.max(l.length, r.length); i++) {
791
+ const a = l[i] ?? 0;
792
+ const b = r[i] ?? 0;
793
+ if (a !== b) return a < b ? -1 : 1;
794
+ }
795
+ return 0;
796
+ }
797
+ var runCommand = (command, args) => new Promise((resolve6, reject) => {
798
+ execFile(command, args, { encoding: "utf8" }, (error, stdout, stderr) => {
799
+ if (error) {
800
+ reject(new Error(stderr.trim() || error.message));
801
+ return;
802
+ }
803
+ resolve6(stdout.trim());
804
+ });
805
+ });
806
+ async function checkForUpdate({
807
+ currentVersion,
808
+ packageName = PACKAGE_NAME,
809
+ run = runCommand
810
+ }) {
811
+ const current = normalizeVersion(currentVersion);
812
+ try {
813
+ const latest = normalizeVersion(await run("npm", ["view", packageName, "version"]));
814
+ return {
815
+ packageName,
816
+ currentVersion: current,
817
+ latestVersion: latest,
818
+ updateAvailable: compareVersions(current, latest) < 0,
819
+ checked: true
820
+ };
821
+ } catch (error) {
822
+ return {
823
+ packageName,
824
+ currentVersion: current,
825
+ latestVersion: null,
826
+ updateAvailable: false,
827
+ checked: false,
828
+ error: error instanceof Error ? error.message : String(error)
829
+ };
830
+ }
831
+ }
832
+ function createSelfUpdater(run = runCommand) {
833
+ return async ({
834
+ currentVersion,
835
+ packageName = PACKAGE_NAME
836
+ }) => {
837
+ const info = await checkForUpdate({ currentVersion, packageName, run });
838
+ if (!info.checked) throw new Error(`unable to check for updates: ${info.error ?? "unknown error"}`);
839
+ if (!info.updateAvailable) return { ...info, updated: false };
840
+ await run("npm", ["install", "-g", `${packageName}@latest`]);
841
+ return { ...info, updated: true };
842
+ };
121
843
  }
122
844
 
123
845
  // src/cli.ts
@@ -128,25 +850,92 @@ function fail(e) {
128
850
  console.error(e instanceof Error ? e.message : String(e));
129
851
  process.exit(1);
130
852
  }
131
- program.command("render").description("Render an IR JSON file to a .pptx").argument("<ir.json>", "path to the IR file").requiredOption("-o, --output <file>", "output .pptx path").option("--theme <id>", "override the deck theme (see `pptfast themes`)").action(async (ir, opts) => {
853
+ program.command("render").description("Render an IR JSON file, deck project directory, or bare deck name to a .pptx").argument("<target>", "IR JSON file, deck project directory, or bare name under ~/.pptfast/decks").requiredOption("-o, --output <file>", "output .pptx path").option("--theme <id>", "override the deck theme (see `pptfast themes`)").option("--style <path>", "style overrides JSON re-coloring the theme (see `pptfast schema --style`)").option("--draft", "allow unfilled placeholder pages (skip the draft gate)").action(async (target, opts) => {
854
+ try {
855
+ console.log(
856
+ await runRender(target, {
857
+ output: opts.output,
858
+ theme: opts.theme,
859
+ stylePath: opts.style,
860
+ draft: opts.draft
861
+ })
862
+ );
863
+ } catch (e) {
864
+ fail(e);
865
+ }
866
+ });
867
+ program.command("validate").description("Validate an IR JSON file, deck project directory, or bare deck name against the schema").argument("<target>", "IR JSON file, deck project directory, or bare name under ~/.pptfast/decks").action(async (target) => {
132
868
  try {
133
- console.log(await runRender(ir, opts));
869
+ console.log(await runValidate(target));
134
870
  } catch (e) {
135
871
  fail(e);
136
872
  }
137
873
  });
138
- program.command("validate").description("Validate an IR JSON file against the schema").argument("<ir.json>").action(async (ir) => {
874
+ program.command("audit").description(
875
+ "Deterministic geometry audit (overflow, out-of-bounds, low-contrast, overlap) \u2014 exits 1 when it finds anything"
876
+ ).argument("<target>", "IR JSON file, deck project directory, or bare name under ~/.pptfast/decks").option("--json", "machine-readable output (the full AuditReport)").action(async (target, opts) => {
139
877
  try {
140
- console.log(await runValidate(ir));
878
+ const { output, hasFindings } = await runAudit(target, { json: opts.json });
879
+ console.log(output);
880
+ if (hasFindings) process.exit(1);
881
+ } catch (e) {
882
+ fail(e);
883
+ }
884
+ });
885
+ program.command("schema").description("Print the IR JSON Schema (feed this to a model before it writes IR)").option("--style", "print the style-override schema instead").option("--plan", "print the deck plan schema instead").action(
886
+ (opts) => console.log(runSchema(opts.plan ? "plan" : opts.style ? "style" : void 0))
887
+ );
888
+ var plan = program.command("plan").description("Deck plan commands (spec \xA75)");
889
+ plan.command("validate").description("Validate a deck plan JSON file against the schema and mode-aware hard gates").argument("<plan.json>").action(async (planPath) => {
890
+ try {
891
+ console.log(await runPlanValidate(planPath));
892
+ } catch (e) {
893
+ fail(e);
894
+ }
895
+ });
896
+ program.command("assemble").description("Assemble a deck project directory (deck.plan.json + pages/ + assets/) into an IR JSON file").argument("<dir|name>", "deck project directory, or bare name under ~/.pptfast/decks").option("-o, --output <file>", "output IR JSON path (default: <dir>/deck.json)").action(async (target, opts) => {
897
+ try {
898
+ console.log(await runAssemble(target, { output: opts.output }));
899
+ } catch (e) {
900
+ fail(e);
901
+ }
902
+ });
903
+ program.command("disassemble").description("Split an IR JSON file into a deck project directory (deck.plan.json + pages/)").argument("<ir.json>", "path to the IR file").requiredOption("-o, --output <dir>", "output deck project directory").action(async (irPath, opts) => {
904
+ try {
905
+ console.log(await runDisassemble(irPath, opts.output));
141
906
  } catch (e) {
142
907
  fail(e);
143
908
  }
144
909
  });
145
- program.command("schema").description("Print the IR JSON Schema (feed this to a model before it writes IR)").action(() => console.log(runSchema()));
146
910
  program.command("themes").description("List built-in themes").option("--json", "machine-readable output").action((opts) => console.log(runThemes(Boolean(opts.json))));
147
- program.command("preview").description("Render each slide to an SVG file for visual self-check").argument("<ir.json>").requiredOption("-o, --output <dir>", "output directory").action(async (ir, opts) => {
911
+ program.command("scenarios").description("List named scenario presets (mode/delivery/audience axes + theme recommendations)").option("--json", "machine-readable output").action((opts) => console.log(runScenarios(Boolean(opts.json))));
912
+ program.command("init").description("Scaffold a pptfast.config.json in the current directory").action(async () => {
148
913
  try {
149
- console.log(await runPreview(ir, opts.output));
914
+ console.log(await runInit());
915
+ } catch (e) {
916
+ fail(e);
917
+ }
918
+ });
919
+ program.command("preview").description("Render each slide to an SVG file for visual self-check").argument("<target>", "IR JSON file, deck project directory, or bare name under ~/.pptfast/decks").requiredOption("-o, --output <dir>", "output directory").option("--html", "also write a self-contained preview.html (all slides inlined \u2014 thumbnail strip, keyboard navigation) for human review").action(async (target, opts) => {
920
+ try {
921
+ console.log(await runPreview(target, opts.output, { htmlOut: opts.html }));
922
+ } catch (e) {
923
+ fail(e);
924
+ }
925
+ });
926
+ program.command("check-update").description("Check npm for a newer pptfast release").action(async () => {
927
+ const info = await checkForUpdate({ currentVersion: VERSION });
928
+ if (!info.checked) fail(new Error(`update check failed: ${info.error}`));
929
+ console.log(
930
+ info.updateAvailable ? `update available: ${info.currentVersion} \u2192 ${info.latestVersion} (run \`pptfast self-update\`)` : `pptfast ${info.currentVersion} is up to date`
931
+ );
932
+ });
933
+ program.command("self-update").description("Update the global pptfast install to the latest release").action(async () => {
934
+ try {
935
+ const result = await createSelfUpdater()({ currentVersion: VERSION });
936
+ console.log(
937
+ result.updated ? `updated: ${result.currentVersion} \u2192 ${result.latestVersion}` : `already at the latest version (${result.currentVersion})`
938
+ );
150
939
  } catch (e) {
151
940
  fail(e);
152
941
  }