@liustack/pptfast 0.1.0 → 0.4.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,58 +1,153 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- PptfastError,
3
+ PptxIRV3Schema,
4
4
  VERSION,
5
+ assembleDeck,
6
+ disassembleDeck,
7
+ formatInvalidSpecError,
8
+ migrateDeckPlanToSpec,
9
+ migrateIrV3ToV4,
10
+ resolveSpecThemeId,
11
+ specJsonSchema,
12
+ validateSpec
13
+ } from "./chunk-4W2YZPJJ.js";
14
+ import {
15
+ installNodePlatform
16
+ } from "./chunk-HFRNKYZ6.js";
17
+ import {
18
+ AUDIENCE_VALUES,
19
+ NARRATIVE_PRESETS,
20
+ PACING_BUDGETS,
21
+ PptfastError,
22
+ STRATEGY_DEFINITIONS,
23
+ StyleOverrideSchema,
24
+ auditDeck,
5
25
  formatIssues,
6
26
  generatePptx,
27
+ getInstalledThemeIds,
7
28
  irJsonSchema,
8
29
  listThemes,
9
30
  renderSlideSvg,
31
+ resolveNarrative,
32
+ styleJsonSchema,
10
33
  validateIr
11
- } from "./chunk-DVMJWFDL.js";
12
- import {
13
- installNodePlatform
14
- } from "./chunk-4WISUDXE.js";
34
+ } from "./chunk-7N4HGSMW.js";
15
35
  import {
16
36
  getPlatform
17
- } from "./chunk-G76EVNVT.js";
37
+ } from "./chunk-L524YK63.js";
18
38
 
19
39
  // src/cli.ts
20
40
  import { Command } from "commander";
21
41
 
22
42
  // src/cli/commands.ts
23
- import { mkdir, writeFile } from "fs/promises";
24
- import { dirname, join, resolve as resolve2 } from "path";
43
+ import { mkdir as mkdir2, rm, writeFile as writeFile2 } from "fs/promises";
44
+ import { dirname as dirname2, join as join4, relative as relative2, resolve as resolve5 } from "path";
25
45
 
26
- // src/cli/load-ir.ts
46
+ // src/cli/config.ts
27
47
  import { readFile } from "fs/promises";
28
- import { extname, isAbsolute, resolve } from "path";
48
+ import { dirname, join as join2, resolve as resolve2 } from "path";
49
+ import { z } from "zod";
50
+
51
+ // src/cli/home.ts
52
+ import { homedir } from "os";
53
+ import { join, resolve } from "path";
54
+ function pptfastHome() {
55
+ return process.env.PPTFAST_HOME ?? join(homedir(), ".pptfast");
56
+ }
57
+ function decksRoot(config) {
58
+ return resolve(pptfastHome(), config?.decksDir ?? "decks");
59
+ }
60
+ function userConfigPath() {
61
+ return join(pptfastHome(), "config.json");
62
+ }
63
+
64
+ // src/cli/config.ts
65
+ var ConfigSchema = z.object({
66
+ theme: z.string().optional(),
67
+ style: StyleOverrideSchema.optional(),
68
+ decksDir: z.string().optional()
69
+ }).strict();
70
+ var UserConfigSchema = z.object({
71
+ theme: z.string().optional(),
72
+ style: StyleOverrideSchema.optional(),
73
+ decksDir: z.string().optional()
74
+ }).strict();
75
+ var CONFIG_FILENAME = "pptfast.config.json";
76
+ async function readConfigFile(path, schema) {
77
+ let text;
78
+ try {
79
+ text = await readFile(path, "utf8");
80
+ } catch {
81
+ return null;
82
+ }
83
+ let raw;
84
+ try {
85
+ raw = JSON.parse(text);
86
+ } catch (e) {
87
+ throw new PptfastError(`${path} is not valid JSON: ${e.message}`);
88
+ }
89
+ const r = schema.safeParse(raw);
90
+ if (!r.success) {
91
+ const detail = r.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
92
+ throw new PptfastError(`invalid ${path}:
93
+ ${detail}`);
94
+ }
95
+ return { path, config: r.data };
96
+ }
97
+ async function findConfig(startDir) {
98
+ let dir = resolve2(startDir);
99
+ for (; ; ) {
100
+ const hit = await readConfigFile(join2(dir, CONFIG_FILENAME), ConfigSchema);
101
+ if (hit) return hit;
102
+ const parent = dirname(dir);
103
+ if (parent === dir) return null;
104
+ dir = parent;
105
+ }
106
+ }
107
+ async function findUserConfig() {
108
+ return readConfigFile(userConfigPath(), UserConfigSchema);
109
+ }
110
+
111
+ // src/cli/deck-dir.ts
112
+ import { copyFile, mkdir, readFile as readFile3, readdir, stat, writeFile } from "fs/promises";
113
+ import { basename, extname as extname2, isAbsolute as isAbsolute2, join as join3, relative, resolve as resolve4 } from "path";
114
+
115
+ // src/cli/load-ir.ts
116
+ import { readFile as readFile2 } from "fs/promises";
117
+ import { extname, isAbsolute, resolve as resolve3 } from "path";
29
118
  var MIME_BY_EXT = {
30
119
  ".png": "image/png",
31
120
  ".jpg": "image/jpeg",
32
121
  ".jpeg": "image/jpeg",
33
122
  ".gif": "image/gif"
34
123
  };
35
- async function loadIrFile(irPath) {
124
+ var EXT_BY_MIME = {
125
+ "image/png": ".png",
126
+ "image/jpeg": ".jpg",
127
+ "image/gif": ".gif",
128
+ "image/webp": ".webp"
129
+ };
130
+ async function loadIrFile(irPath, kind = "IR") {
36
131
  let text;
37
132
  try {
38
- text = await readFile(irPath, "utf8");
133
+ text = await readFile2(irPath, "utf8");
39
134
  } catch {
40
- throw new PptfastError(`cannot read IR file: ${irPath}`);
135
+ throw new PptfastError(`cannot read ${kind} file: ${irPath}`);
41
136
  }
42
137
  try {
43
138
  return JSON.parse(text);
44
139
  } catch (e) {
45
- throw new PptfastError(`${irPath} is not valid JSON: ${e.message}`);
140
+ throw new PptfastError(`${kind} file ${irPath} is not valid JSON: ${e.message}`);
46
141
  }
47
142
  }
48
143
  async function resolveLocalAssets(ir, baseDir) {
49
144
  for (const [name, asset] of Object.entries(ir.assets.images)) {
50
145
  const src = asset.src;
51
146
  if (src.startsWith("data:") || /^https?:\/\//.test(src)) continue;
52
- const abs = isAbsolute(src) ? src : resolve(baseDir, src);
147
+ const abs = isAbsolute(src) ? src : resolve3(baseDir, src);
53
148
  let bytes;
54
149
  try {
55
- bytes = await readFile(abs);
150
+ bytes = await readFile2(abs);
56
151
  } catch {
57
152
  throw new PptfastError(`asset "${name}": cannot read image file ${abs} (from src "${src}")`);
58
153
  }
@@ -71,53 +166,924 @@ async function resolveLocalAssets(ir, baseDir) {
71
166
  }
72
167
  }
73
168
 
169
+ // src/cli/deck-dir.ts
170
+ var PLAN_FILENAME = "deck.plan.json";
171
+ var SPEC_FILENAME = "deck.spec.json";
172
+ var PAGES_DIRNAME = "pages";
173
+ var ASSETS_DIRNAME = "assets";
174
+ function assertSafeFileSegment(id, context) {
175
+ const safeBase = resolve4("/pptfast-safe-base");
176
+ const rel = relative(safeBase, resolve4(safeBase, id));
177
+ const safe = !isAbsolute2(id) && !id.includes("/") && !id.includes("\\") && id !== ".." && !rel.startsWith("..") && !isAbsolute2(rel);
178
+ if (!safe) {
179
+ throw new PptfastError(
180
+ `${context} "${id}" is not a safe file name \u2014 ids used as page/asset file names must not contain path separators or ".."`
181
+ );
182
+ }
183
+ }
184
+ async function isDeckDirectory(path) {
185
+ try {
186
+ return (await stat(path)).isDirectory();
187
+ } catch (e) {
188
+ if (e.code === "ENOENT") return false;
189
+ throw new PptfastError(`cannot check ${path}: ${e.message}`);
190
+ }
191
+ }
192
+ async function pathExists(path) {
193
+ try {
194
+ await stat(path);
195
+ return true;
196
+ } catch (e) {
197
+ if (e.code === "ENOENT") return false;
198
+ throw new PptfastError(`cannot check path ${path}: ${e.message}`);
199
+ }
200
+ }
201
+ async function resolveDeckTarget(arg, config, cwd = process.cwd()) {
202
+ if (arg.trim() === "") throw new PptfastError("deck target must not be empty");
203
+ if (arg.includes("/") || arg.includes("\\")) return resolve4(cwd, arg);
204
+ const local = resolve4(cwd, arg);
205
+ if (await pathExists(local)) return local;
206
+ const fallback = join3(decksRoot(config), arg);
207
+ return await pathExists(fallback) ? fallback : local;
208
+ }
209
+ function expectedLayoutHint() {
210
+ const rows = [
211
+ [SPEC_FILENAME, "the locked spec (see `pptfast spec validate`)"],
212
+ [`${PAGES_DIRNAME}/<page-id>.json`, "one file per filled page (missing pages become placeholders)"],
213
+ [`${ASSETS_DIRNAME}/`, "optional local images"]
214
+ ];
215
+ const width = Math.max(...rows.map(([name]) => name.length)) + 2;
216
+ return rows.map(([name, desc]) => ` ${name.padEnd(width)}${desc}`).join("\n");
217
+ }
218
+ async function readSpecFile(dir) {
219
+ const specPath = join3(dir, SPEC_FILENAME);
220
+ const planPath = join3(dir, PLAN_FILENAME);
221
+ const [specExists, planExists] = await Promise.all([pathExists(specPath), pathExists(planPath)]);
222
+ if (specExists && planExists) {
223
+ throw new PptfastError(
224
+ `both ${SPEC_FILENAME} and ${PLAN_FILENAME} exist in ${dir} \u2014 ambiguous, refusing to guess which one wins. Delete ${PLAN_FILENAME} once you have confirmed ${SPEC_FILENAME} is correct (\`pptfast migrate\` never deletes the source file it read)`
225
+ );
226
+ }
227
+ if (!specExists && planExists) {
228
+ throw new PptfastError(
229
+ `${dir} has ${PLAN_FILENAME} but no ${SPEC_FILENAME} \u2014 deck project directories now use ${SPEC_FILENAME}. Run \`pptfast migrate ${dir} -o ${dir}\` to convert it`
230
+ );
231
+ }
232
+ let text;
233
+ try {
234
+ text = await readFile3(specPath, "utf8");
235
+ } catch (e) {
236
+ if (e.code === "ENOENT") {
237
+ throw new PptfastError(
238
+ `no ${SPEC_FILENAME} in ${dir} \u2014 expected a deck project directory:
239
+ ${expectedLayoutHint()}`
240
+ );
241
+ }
242
+ throw new PptfastError(`cannot read spec file: ${specPath}`);
243
+ }
244
+ try {
245
+ return JSON.parse(text);
246
+ } catch (e) {
247
+ throw new PptfastError(`spec file ${specPath} is not valid JSON: ${e.message}`);
248
+ }
249
+ }
250
+ async function readPages(dir) {
251
+ const pagesDir = join3(dir, PAGES_DIRNAME);
252
+ let entries;
253
+ try {
254
+ entries = (await readdir(pagesDir, { withFileTypes: true })).filter((entry) => entry.isFile() && extname2(entry.name) === ".json").map((entry) => entry.name);
255
+ } catch (e) {
256
+ if (e.code === "ENOENT") return {};
257
+ throw new PptfastError(`cannot read ${PAGES_DIRNAME}/ directory ${pagesDir}: ${e.message}`);
258
+ }
259
+ const pages = {};
260
+ await Promise.all(
261
+ entries.map(async (entry) => {
262
+ const id = basename(entry, ".json");
263
+ pages[id] = await loadIrFile(join3(pagesDir, entry), `page "${id}"`);
264
+ })
265
+ );
266
+ return pages;
267
+ }
268
+ async function scanAssets(dir) {
269
+ const assetsDir = join3(dir, ASSETS_DIRNAME);
270
+ let entries;
271
+ try {
272
+ entries = (await readdir(assetsDir, { withFileTypes: true })).filter((entry) => entry.isFile() && !entry.name.startsWith(".")).map((entry) => entry.name);
273
+ } catch (e) {
274
+ if (e.code === "ENOENT") return {};
275
+ throw new PptfastError(`cannot read ${ASSETS_DIRNAME}/ directory ${assetsDir}: ${e.message}`);
276
+ }
277
+ const images = {};
278
+ const sourceFile = /* @__PURE__ */ new Map();
279
+ for (const entry of entries) {
280
+ const id = basename(entry, extname2(entry));
281
+ const previous = sourceFile.get(id);
282
+ if (previous !== void 0) {
283
+ throw new PptfastError(
284
+ `${ASSETS_DIRNAME}/${previous} and ${ASSETS_DIRNAME}/${entry} both register image id "${id}" \u2014 rename one of the files`
285
+ );
286
+ }
287
+ sourceFile.set(id, entry);
288
+ images[id] = { src: `${ASSETS_DIRNAME}/${entry}` };
289
+ }
290
+ return images;
291
+ }
292
+ async function readDeckDir(dir) {
293
+ const deckDir = resolve4(dir);
294
+ const spec2 = await readSpecFile(deckDir);
295
+ const pages = await readPages(deckDir);
296
+ const { ir, generatedSeed, materializedLayoutCount } = assembleDeck(spec2, pages);
297
+ const images = await scanAssets(deckDir);
298
+ const merged = { ...ir, assets: { images: { ...ir.assets.images, ...images } } };
299
+ return { ir: merged, generatedSeed, materializedLayoutCount, deckDir };
300
+ }
301
+ async function writeDeckAssets(images, outDir, sourceBaseDir) {
302
+ const entries = Object.entries(images);
303
+ const assetsDir = join3(outDir, ASSETS_DIRNAME);
304
+ if (entries.length === 0) return { count: 0, assetsDir };
305
+ await mkdir(assetsDir, { recursive: true });
306
+ await Promise.all(entries.map(([id, asset]) => writeOneAsset(id, asset.src, assetsDir, sourceBaseDir)));
307
+ return { count: entries.length, assetsDir };
308
+ }
309
+ var DATA_URI_RE = /^data:([^;,]+);base64,(.*)$/s;
310
+ async function writeOneAsset(id, src, assetsDir, sourceBaseDir) {
311
+ assertSafeFileSegment(id, "asset id");
312
+ if (src.startsWith("data:")) {
313
+ const match = DATA_URI_RE.exec(src);
314
+ if (!match) {
315
+ throw new PptfastError(`asset "${id}": only base64-encoded data URIs can be disassembled (malformed data URI)`);
316
+ }
317
+ const mime = match[1];
318
+ const payload = match[2];
319
+ const ext = EXT_BY_MIME[mime];
320
+ if (!ext) {
321
+ throw new PptfastError(
322
+ `asset "${id}": cannot disassemble a data URI with mime "${mime}" \u2014 expected one of ${Object.keys(EXT_BY_MIME).join(", ")}`
323
+ );
324
+ }
325
+ await writeFile(join3(assetsDir, `${id}${ext}`), Buffer.from(payload, "base64"));
326
+ return;
327
+ }
328
+ if (/^https?:\/\//.test(src)) {
329
+ throw new PptfastError(
330
+ `asset "${id}": URL assets cannot be disassembled into a deck directory \u2014 inline it as a data URI or download it first`
331
+ );
332
+ }
333
+ const abs = isAbsolute2(src) ? src : resolve4(sourceBaseDir, src);
334
+ try {
335
+ await copyFile(abs, join3(assetsDir, `${id}${extname2(abs)}`));
336
+ } catch {
337
+ throw new PptfastError(`asset "${id}": cannot read source image ${abs} (from src "${src}") \u2014 cannot disassemble`);
338
+ }
339
+ }
340
+
341
+ // src/cli/preview-html.ts
342
+ function escapeHtml(s) {
343
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
344
+ }
345
+ function embedJson(value) {
346
+ return JSON.stringify(value).replace(/</g, "\\u003c");
347
+ }
348
+ function findingBadge(count, className) {
349
+ if (count === 0) return "";
350
+ return `<div class="${className}" aria-hidden="true">${count}</div>`;
351
+ }
352
+ function slideNode(slide, findingCount) {
353
+ const idAttr = slide.id !== void 0 ? ` data-id="${escapeHtml(slide.id)}"` : "";
354
+ const badge = slide.placeholder ? `<div class="pf-badge" aria-hidden="true">unfilled</div>` : "";
355
+ const fBadge = findingBadge(findingCount, "pf-finding-badge");
356
+ return `<div class="pf-slide" id="pf-slide-${slide.index}" data-index="${slide.index}"${idAttr}>${badge}${fBadge}${slide.svg}</div>`;
357
+ }
358
+ function thumbDescription(slide) {
359
+ const parts = [`slide ${slide.index + 1}`, slide.type];
360
+ if (slide.id !== void 0) parts.push(slide.id);
361
+ if (slide.placeholder) parts.push("unfilled");
362
+ return escapeHtml(parts.join(" \xB7 "));
363
+ }
364
+ function positionLabel(slide) {
365
+ const idPart = slide.id !== void 0 ? ` \xB7 ${slide.id}` : "";
366
+ return escapeHtml(`${slide.index + 1}${idPart}`);
367
+ }
368
+ function counterText(slide, total) {
369
+ const idPart = slide.id !== void 0 ? ` \xB7 ${slide.id}` : "";
370
+ return escapeHtml(`${slide.index + 1} / ${total}${idPart}`);
371
+ }
372
+ function thumbButton(slide, isActive, slotContent, findingCount) {
373
+ const description = thumbDescription(slide);
374
+ const badge = slide.placeholder ? `<span class="pf-thumb-badge" aria-hidden="true">unfilled</span>` : "";
375
+ const fBadge = findingBadge(findingCount, "pf-thumb-finding-badge");
376
+ 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}${fBadge}</button>`;
377
+ }
378
+ function findingPanelEntry(f) {
379
+ const idPart = f.slideId !== void 0 ? ` \xB7 ${escapeHtml(f.slideId)}` : "";
380
+ return `<button type="button" class="pf-finding" data-page-index="${f.page - 1}"><span class="pf-finding-loc">page ${f.page}${idPart}</span><span class="pf-finding-code">[${escapeHtml(f.code)}]</span> <span class="pf-finding-msg">${escapeHtml(f.message)}</span></button>`;
381
+ }
382
+ var CSS = `
383
+ :root{color-scheme:light}
384
+ *{box-sizing:border-box}
385
+ html,body{height:100%;margin:0}
386
+ body{display:flex;flex-direction:column;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;background:#f4f4f4;color:#1a1a1a}
387
+ 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}
388
+ #pf-title{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
389
+ #pf-counter{font-variant-numeric:tabular-nums;color:#555;white-space:nowrap}
390
+ #pf-audit-note{color:#b45309;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
391
+ #pf-export-btn{font:inherit;font-size:13px;padding:6px 10px;border:1px solid #2563eb;background:#2563eb;color:#fff;border-radius:4px;cursor:pointer;white-space:nowrap;flex:0 0 auto}
392
+ #pf-export-btn:hover{background:#1d4ed8}
393
+ #pf-stage-wrap{flex:1 1 auto;min-height:0;display:flex;align-items:center;justify-content:center;gap:16px;padding:16px}
394
+ #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%}
395
+ #pf-stage,.pf-thumb-slot{position:relative}
396
+ .pf-slide{position:absolute;inset:0}
397
+ .pf-slide svg{display:block;width:100%;height:100%}
398
+ #pf-side{flex:0 0 260px;align-self:stretch;overflow-y:auto;background:#fff;border:1px solid #ddd;border-radius:6px;padding:12px;font-size:13px}
399
+ #pf-side h2{margin:0 0 8px;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:#666}
400
+ #pf-side section+section{margin-top:16px;padding-top:16px;border-top:1px solid #eee}
401
+ #pf-audit-checks{margin:0 0 12px;font-size:12px;color:#555}
402
+ .pf-finding{display:block;width:100%;text-align:left;background:#fff;border:1px solid #eee;border-radius:4px;padding:6px 8px;margin-bottom:6px;cursor:pointer;font:inherit}
403
+ .pf-finding:hover{border-color:#93c5fd}
404
+ .pf-finding-loc{display:block;font-size:11px;color:#888}
405
+ .pf-finding-code{display:inline-block;font-size:11px;font-weight:700;color:#b91c1c}
406
+ .pf-finding-msg{font-size:12px;color:#333}
407
+ #pf-annotate-current-label{font-size:11px;color:#888;margin-bottom:6px}
408
+ #pf-annotate-list{list-style:none;margin:0 0 8px;padding:0}
409
+ .pf-annotate-item{display:flex;justify-content:space-between;gap:6px;align-items:flex-start;padding:4px 0;border-bottom:1px solid #f0f0f0;font-size:12px}
410
+ .pf-annotate-remove{border:none;background:none;color:#999;cursor:pointer;font-size:14px;line-height:1;padding:0 2px}
411
+ .pf-annotate-remove:hover{color:#dc2626}
412
+ #pf-annotate-input{width:100%;box-sizing:border-box;font:inherit;font-size:12px;padding:6px;border:1px solid #ddd;border-radius:4px;resize:vertical}
413
+ #pf-annotate-add{font:inherit;font-size:12px;margin-top:6px;width:100%;padding:6px 10px;border:1px solid #2563eb;background:#2563eb;color:#fff;border-radius:4px;cursor:pointer}
414
+ #pf-annotate-add:hover{background:#1d4ed8}
415
+ #pf-filmstrip{display:flex;gap:8px;padding:10px 16px;overflow-x:auto;background:#fff;border-top:1px solid #ddd;flex:0 0 auto}
416
+ .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}
417
+ .pf-thumb:hover{border-color:#93c5fd}
418
+ .pf-thumb-active,.pf-thumb-active:hover{border-color:#2563eb;background:#dbeafe}
419
+ .pf-thumb-slot{display:block;width:100%;aspect-ratio:16/9;background:#ddd}
420
+ .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}
421
+ .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}
422
+ .pf-finding-badge,.pf-thumb-finding-badge{position:absolute;top:4px;left:4px;background:#dc2626;color:#fff;font-size:10px;font-weight:700;padding:2px 6px;border-radius:3px;z-index:2;pointer-events:none}
423
+ `.trim();
424
+ var JS = `
425
+ (function () {
426
+ var stage = document.getElementById('pf-stage')
427
+ var counter = document.getElementById('pf-counter')
428
+ var thumbs = Array.prototype.slice.call(document.querySelectorAll('.pf-thumb'))
429
+ var total = thumbs.length
430
+ if (total === 0) return
431
+ var current = parseInt(thumbs[0].getAttribute('data-index'), 10)
432
+
433
+ function slideEl(i) { return document.getElementById('pf-slide-' + i) }
434
+ function slotEl(i) { return document.getElementById('pf-slot-' + i) }
435
+ function thumbEl(i) { return document.getElementById('pf-thumb-' + i) }
436
+ function thumbPos(i) {
437
+ for (var t = 0; t < thumbs.length; t++) {
438
+ if (parseInt(thumbs[t].getAttribute('data-index'), 10) === i) return t
439
+ }
440
+ return -1
441
+ }
442
+
443
+ function updateCounter(i) {
444
+ var el = slideEl(i)
445
+ if (!el) return
446
+ var text = (thumbPos(i) + 1) + ' / ' + total
447
+ var id = el.getAttribute('data-id')
448
+ if (id) text += ' \xB7 ' + id
449
+ counter.textContent = text
450
+ }
451
+
452
+ function activate(i) {
453
+ if (i === current) return
454
+ var nextSlide = slideEl(i)
455
+ var nextThumb = thumbEl(i)
456
+ if (!nextSlide || !nextThumb) return
457
+ var prevSlide = slideEl(current)
458
+ var prevSlot = slotEl(current)
459
+ if (prevSlide && prevSlot) prevSlot.appendChild(prevSlide)
460
+ var prevThumb = thumbEl(current)
461
+ if (prevThumb) prevThumb.classList.remove('pf-thumb-active')
462
+ stage.appendChild(nextSlide)
463
+ nextThumb.classList.add('pf-thumb-active')
464
+ current = i
465
+ updateCounter(i)
466
+ renderAnnotations()
467
+ }
468
+
469
+ thumbs.forEach(function (t) {
470
+ t.addEventListener('click', function () {
471
+ activate(parseInt(t.getAttribute('data-index'), 10))
472
+ })
473
+ })
474
+
475
+ document.addEventListener('keydown', function (e) {
476
+ var pos = thumbPos(current)
477
+ if (e.key === 'ArrowRight' && pos < total - 1) {
478
+ activate(parseInt(thumbs[pos + 1].getAttribute('data-index'), 10))
479
+ } else if (e.key === 'ArrowLeft' && pos > 0) {
480
+ activate(parseInt(thumbs[pos - 1].getAttribute('data-index'), 10))
481
+ }
482
+ })
483
+
484
+ // ---- audit findings panel: click a finding, jump to its page (same
485
+ // activate() every thumbnail click already uses) ----
486
+ Array.prototype.slice.call(document.querySelectorAll('.pf-finding')).forEach(function (b) {
487
+ b.addEventListener('click', function () {
488
+ activate(parseInt(b.getAttribute('data-page-index'), 10))
489
+ })
490
+ })
491
+
492
+ // ---- annotations: in-memory only, keyed by the slide's 0-based array
493
+ // index (current) \u2014 never by pageId, since an object key coerces every
494
+ // key to a string and would silently collide a numeric page-fallback
495
+ // pageId with a same-looking slide id (or lose the numeric/string type
496
+ // distinction the exported JSON needs); pageIdFor() below resolves the
497
+ // real pageId fresh from the DOM only at render/export time. ----
498
+ var annotations = {}
499
+ var annotateList = document.getElementById('pf-annotate-list')
500
+ var annotateInput = document.getElementById('pf-annotate-input')
501
+ var annotateLabel = document.getElementById('pf-annotate-current-label')
502
+ var annotateAdd = document.getElementById('pf-annotate-add')
503
+ var exportBtn = document.getElementById('pf-export-btn')
504
+
505
+ // Slide id when the active slide has one, else its 1-based page number \u2014
506
+ // mirrors AuditFinding.page's own "1-based page number when there is no
507
+ // slide id" convention (../svg/audit/deck-audit.ts) rather than this
508
+ // file's internal 0-based data-index, so a revision-request.json's
509
+ // pageId lines up with what pptfast audit/validate already print.
510
+ function pageIdFor(i) {
511
+ var el = slideEl(i)
512
+ var id = el ? el.getAttribute('data-id') : null
513
+ return id !== null ? id : thumbPos(i) + 1
514
+ }
515
+
516
+ function renderAnnotations() {
517
+ var pid = pageIdFor(current)
518
+ annotateLabel.textContent = 'page ' + (thumbPos(current) + 1) + (typeof pid === 'string' ? ' \xB7 ' + pid : '')
519
+ var list = annotations[current] || []
520
+ annotateList.innerHTML = ''
521
+ list.forEach(function (text, idx) {
522
+ var li = document.createElement('li')
523
+ li.className = 'pf-annotate-item'
524
+ var span = document.createElement('span')
525
+ span.textContent = text
526
+ var rm = document.createElement('button')
527
+ rm.type = 'button'
528
+ rm.className = 'pf-annotate-remove'
529
+ rm.setAttribute('aria-label', 'remove annotation')
530
+ rm.textContent = '\\u00d7'
531
+ rm.addEventListener('click', function () {
532
+ list.splice(idx, 1)
533
+ renderAnnotations()
534
+ })
535
+ li.appendChild(span)
536
+ li.appendChild(rm)
537
+ annotateList.appendChild(li)
538
+ })
539
+ }
540
+
541
+ annotateAdd.addEventListener('click', function () {
542
+ var text = annotateInput.value.trim()
543
+ if (!text) return
544
+ if (!annotations[current]) annotations[current] = []
545
+ annotations[current].push(text)
546
+ annotateInput.value = ''
547
+ renderAnnotations()
548
+ })
549
+
550
+ // "Export revision requests": preview.html stays read-only end to end \u2014
551
+ // this never writes back into the deck itself, only produces a JSON file
552
+ // of requests for an agent/human to route through pages/*.json (see
553
+ // skills/pptfast/SKILL.md's phase-6 revision-request handling). Zero
554
+ // network/storage \u2014 an in-memory Blob + a synthetic <a download> click,
555
+ // the standard browser-only download pattern.
556
+ exportBtn.addEventListener('click', function () {
557
+ var requests = []
558
+ Object.keys(annotations).forEach(function (key) {
559
+ var i = parseInt(key, 10)
560
+ var pid = pageIdFor(i)
561
+ annotations[i].forEach(function (text) {
562
+ requests.push({ pageId: pid, annotation: text, createdAt: new Date().toISOString() })
563
+ })
564
+ })
565
+ var deckTitle = document.getElementById('pf-title').textContent
566
+ var payload = { version: '1', deck: deckTitle, requests: requests }
567
+ var blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
568
+ var url = URL.createObjectURL(blob)
569
+ var a = document.createElement('a')
570
+ a.href = url
571
+ a.download = 'revision-request.json'
572
+ document.body.appendChild(a)
573
+ a.click()
574
+ document.body.removeChild(a)
575
+ URL.revokeObjectURL(url)
576
+ })
577
+
578
+ renderAnnotations()
579
+ })()
580
+ `.trim();
581
+ var ANNOTATE_PANEL = `<section id="pf-annotate-panel">
582
+ <h2>Annotations</h2>
583
+ <div id="pf-annotate-current-label"></div>
584
+ <ul id="pf-annotate-list"></ul>
585
+ <textarea id="pf-annotate-input" rows="3" placeholder="Add a note for this page\u2026"></textarea>
586
+ <button type="button" id="pf-annotate-add">Add annotation</button>
587
+ </section>`;
588
+ function buildPreviewHtml(input) {
589
+ const { title, slides, findings = [], auditNote, checks } = input;
590
+ const total = slides.length;
591
+ const escapedTitle = escapeHtml(title);
592
+ const findingsByPage = /* @__PURE__ */ new Map();
593
+ for (const f of findings) {
594
+ const list = findingsByPage.get(f.page);
595
+ if (list) list.push(f);
596
+ else findingsByPage.set(f.page, [f]);
597
+ }
598
+ const countFor = (slide) => findingsByPage.get(slide.index + 1)?.length ?? 0;
599
+ const stageSlide = total > 0 ? slideNode(slides[0], countFor(slides[0])) : "";
600
+ const thumbs = slides.map((s, i) => thumbButton(s, i === 0, i === 0 ? "" : slideNode(s, countFor(s)), countFor(s))).join("");
601
+ const initialCounter = total > 0 ? counterText(slides[0], total) : "0 / 0";
602
+ const auditPanel = findings.length > 0 ? `<section id="pf-audit-panel"><h2>Audit findings (${findings.length})</h2><div id="pf-audit-list">${findings.map(findingPanelEntry).join("")}</div></section>` : "";
603
+ const findingsDataScript = findings.length > 0 ? `<script type="application/json" id="pf-audit-findings">${embedJson(findings)}</script>` : "";
604
+ const auditNoteHtml = auditNote !== void 0 ? `<span id="pf-audit-note">${escapeHtml(auditNote)}</span>` : "";
605
+ const checksLine = checks !== void 0 ? `<div id="pf-audit-checks">audit checks: svg ${checks.svg} \xB7 pixels ${checks.pixels}</div>` : "";
606
+ return `<!doctype html>
607
+ <html lang="en">
608
+ <head>
609
+ <meta charset="utf-8">
610
+ <meta name="viewport" content="width=device-width, initial-scale=1">
611
+ <title>${escapedTitle} \u2014 pptfast preview</title>
612
+ <style>${CSS}</style>
613
+ </head>
614
+ <body>
615
+ <header>
616
+ <span id="pf-title">${escapedTitle}</span>
617
+ <span id="pf-counter">${initialCounter}</span>
618
+ ${auditNoteHtml}
619
+ <button type="button" id="pf-export-btn">Export revision requests</button>
620
+ </header>
621
+ <div id="pf-stage-wrap"><div id="pf-stage">${stageSlide}</div><aside id="pf-side">${checksLine}${auditPanel}${ANNOTATE_PANEL}</aside></div>
622
+ <nav id="pf-filmstrip" aria-label="slides">${thumbs}</nav>
623
+ ${findingsDataScript}
624
+ <script>${JS}</script>
625
+ </body>
626
+ </html>
627
+ `;
628
+ }
629
+
74
630
  // 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 };
631
+ async function loadStyleFile(path) {
632
+ const raw = await loadIrFile(path);
633
+ const r = StyleOverrideSchema.safeParse(raw);
634
+ if (!r.success) {
635
+ const detail = r.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
636
+ throw new PptfastError(`invalid style file ${path}:
637
+ ${detail}`);
638
+ }
639
+ return r.data;
640
+ }
641
+ function describeThemeSource(opts, projectHit, userHit) {
642
+ if (opts.theme !== void 0) return "--theme";
643
+ if (projectHit?.config.theme !== void 0) return projectHit.path;
644
+ if (userHit?.config.theme !== void 0) return userHit.path;
645
+ return "the deck's own theme";
646
+ }
647
+ function resolveDecksDirSource(projectHit, userHit) {
648
+ if (projectHit?.config.decksDir !== void 0) {
649
+ return { decksDir: resolve5(dirname2(projectHit.path), projectHit.config.decksDir) };
650
+ }
651
+ return userHit?.config;
652
+ }
653
+ async function applyDeckConfig(raw, opts) {
654
+ if (typeof raw !== "object" || raw === null) return;
655
+ const deck = raw;
656
+ const irTheme = typeof deck.theme === "object" && deck.theme !== null ? deck.theme : {};
657
+ const [projectHit, userHit] = await Promise.all([
658
+ opts.projectHit !== void 0 ? Promise.resolve(opts.projectHit) : findConfig(opts.cwd),
659
+ opts.userHit !== void 0 ? Promise.resolve(opts.userHit) : findUserConfig()
660
+ ]);
661
+ const theme = opts.theme ?? projectHit?.config.theme ?? userHit?.config.theme ?? irTheme.id;
662
+ const style = opts.stylePath ? await loadStyleFile(opts.stylePath) : projectHit?.config.style ?? userHit?.config.style ?? irTheme.style;
663
+ if (theme !== void 0) {
664
+ const installedThemeIds = getInstalledThemeIds();
665
+ if (!installedThemeIds.includes(theme)) {
666
+ throw new PptfastError(
667
+ `unknown theme "${theme}" (from ${describeThemeSource(opts, projectHit, userHit)}) \u2014 available: ${installedThemeIds.join(", ")} (see \`pptfast themes\`)`
668
+ );
669
+ }
80
670
  }
671
+ if (theme === void 0 && style === void 0) return;
672
+ deck.theme = { ...irTheme, id: theme, ...style !== void 0 ? { style } : {} };
673
+ }
674
+ async function loadDeckTarget(arg, cwd, projectHit, userHit) {
675
+ const target = await resolveDeckTarget(arg, resolveDecksDirSource(projectHit, userHit), cwd);
676
+ if (await isDeckDirectory(target)) {
677
+ const { ir, deckDir } = await readDeckDir(target);
678
+ return { raw: ir, baseDir: deckDir, isDir: true };
679
+ }
680
+ const raw = await loadIrFile(target);
681
+ return { raw, baseDir: dirname2(resolve5(target)), isDir: false };
682
+ }
683
+ async function runRender(irPath, opts) {
684
+ const cwd = opts.cwd ?? process.cwd();
685
+ const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
686
+ const { raw, baseDir } = await loadDeckTarget(irPath, cwd, projectHit, userHit);
687
+ await applyDeckConfig(raw, { theme: opts.theme, stylePath: opts.stylePath, cwd, projectHit, userHit });
81
688
  const v = validateIr(raw);
82
689
  if (!v.ok) throw new PptfastError(`invalid IR:
83
690
  ${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)`;
691
+ await resolveLocalAssets(v.ir, baseDir);
692
+ const bytes = await generatePptx(v.ir, { draft: opts.draft });
693
+ await mkdir2(dirname2(resolve5(opts.output)), { recursive: true });
694
+ await writeFile2(opts.output, bytes);
695
+ const ok = `wrote ${opts.output} (${v.ir.slides.length} slides, ${bytes.length} bytes)`;
696
+ const note = normalizedNote(v.normalized);
697
+ return note ? `${ok}
698
+ ${note}` : ok;
89
699
  }
90
- async function runValidate(irPath) {
91
- const raw = await loadIrFile(irPath);
700
+ function normalizedNote(normalized) {
701
+ if (!normalized || normalized.length === 0) return void 0;
702
+ const n = normalized.length;
703
+ return `note: ${n} field alias${n === 1 ? "" : "es"} normalized
704
+ ${normalized.map((line) => ` ${line}`).join("\n")}`;
705
+ }
706
+ function placeholderNote(ir) {
707
+ const placeholders = ir.slides.map((slide, i) => ({ slide, page: i + 1 })).filter(({ slide }) => slide.placeholder);
708
+ if (placeholders.length === 0) return void 0;
709
+ const refs = placeholders.map(({ slide, page }) => slide.id ? `${slide.id} (page ${page})` : `page ${page}`).join(", ");
710
+ return `note: ${placeholders.length} unfilled placeholder page${placeholders.length === 1 ? "" : "s"}: ${refs}`;
711
+ }
712
+ async function runValidate(irPath, cwd = process.cwd()) {
713
+ const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
714
+ const { raw, isDir } = await loadDeckTarget(irPath, cwd, projectHit, userHit);
715
+ await applyDeckConfig(raw, { cwd, projectHit, userHit });
92
716
  const v = validateIr(raw);
93
717
  if (!v.ok)
94
718
  throw new PptfastError(
95
719
  `invalid IR (${v.errors.length} issue${v.errors.length === 1 ? "" : "s"}):
96
720
  ${formatIssues(v.errors)}`
97
721
  );
98
- return `OK \u2014 ${v.ir.slides.length} slides, theme "${v.ir.theme.id}"`;
722
+ const ok = `OK \u2014 ${v.ir.slides.length} slides, theme "${v.ir.theme.id}"`;
723
+ const notes = [];
724
+ const aliasNote = normalizedNote(v.normalized);
725
+ if (aliasNote) notes.push(aliasNote);
726
+ if (isDir) {
727
+ const note = placeholderNote(v.ir);
728
+ if (note) notes.push(note);
729
+ }
730
+ return notes.length > 0 ? `${ok}
731
+ ${notes.join("\n")}` : ok;
732
+ }
733
+ function formatAuditFinding(f) {
734
+ const idSuffix = f.slideId !== void 0 ? ` (${f.slideId})` : "";
735
+ return `page ${f.page}${idSuffix}: [${f.code}] ${f.message}`;
99
736
  }
100
- function runSchema() {
101
- return JSON.stringify(irJsonSchema(), null, 2);
737
+ function formatAuditReport(report, ir) {
738
+ const lines = report.findings.map(formatAuditFinding);
739
+ lines.push(
740
+ `audited ${report.pagesAudited} page${report.pagesAudited === 1 ? "" : "s"}, ${report.pagesSkipped} skipped, ${report.findings.length} finding${report.findings.length === 1 ? "" : "s"}`
741
+ );
742
+ if (report.checks.pixels === "completed") {
743
+ lines.push("pixel-contrast check: completed");
744
+ }
745
+ const note = placeholderNote(ir);
746
+ if (note) lines.push(note);
747
+ return lines.join("\n");
748
+ }
749
+ async function runAudit(target, opts = {}) {
750
+ const cwd = opts.cwd ?? process.cwd();
751
+ const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
752
+ const { raw, baseDir } = await loadDeckTarget(target, cwd, projectHit, userHit);
753
+ await applyDeckConfig(raw, { cwd, projectHit, userHit });
754
+ const v = validateIr(raw);
755
+ if (!v.ok) {
756
+ throw new PptfastError(
757
+ `invalid IR (${v.errors.length} issue${v.errors.length === 1 ? "" : "s"}):
758
+ ${formatIssues(v.errors)}`
759
+ );
760
+ }
761
+ await resolveLocalAssets(v.ir, baseDir);
762
+ const report = opts.pixels ? await auditDeck(v.ir, { pixels: true }) : auditDeck(v.ir);
763
+ const hasFindings = report.findings.length > 0;
764
+ const output = opts.json ? JSON.stringify(report, null, 2) : formatAuditReport(report, v.ir);
765
+ return { output, hasFindings };
766
+ }
767
+ async function runSpecValidate(specPath) {
768
+ const raw = await loadIrFile(specPath, "spec");
769
+ const v = validateSpec(raw);
770
+ if (!v.ok) {
771
+ throw new PptfastError(formatInvalidSpecError(v.errors));
772
+ }
773
+ const spec2 = v.spec;
774
+ const axes = resolveNarrative(spec2.narrative);
775
+ return `OK \u2014 ${spec2.pages.length} pages, narrative ${axes.strategy}/${axes.pacing}/${axes.audience}, theme "${resolveSpecThemeId(spec2)}"`;
776
+ }
777
+ function runSchema(mode) {
778
+ const schema = mode === "style" ? styleJsonSchema() : mode === "spec" ? specJsonSchema() : irJsonSchema();
779
+ return JSON.stringify(schema, null, 2);
102
780
  }
103
781
  function runThemes(asJson) {
104
782
  const themes = listThemes();
105
783
  if (asJson) return JSON.stringify(themes, null, 2);
106
784
  return themes.map((t) => `${t.id.padEnd(12)} ${t.label}`).join("\n");
107
785
  }
108
- async function runPreview(irPath, outDir) {
109
- const raw = await loadIrFile(irPath);
786
+ function runNarratives(asJson) {
787
+ if (asJson) {
788
+ return JSON.stringify(
789
+ {
790
+ presets: NARRATIVE_PRESETS,
791
+ strategies: STRATEGY_DEFINITIONS,
792
+ pacings: PACING_BUDGETS,
793
+ audiences: AUDIENCE_VALUES
794
+ },
795
+ null,
796
+ 2
797
+ );
798
+ }
799
+ const rows = Object.values(NARRATIVE_PRESETS).map((p) => ({
800
+ id: p.id,
801
+ axes: `${p.axes.strategy}/${p.axes.pacing}/${p.axes.audience}`,
802
+ themes: p.themeRecommendations.join(", ")
803
+ }));
804
+ const idWidth = Math.max(...rows.map((r) => r.id.length));
805
+ const axesWidth = Math.max(...rows.map((r) => r.axes.length));
806
+ return rows.map((r) => `${r.id.padEnd(idWidth + 2)}${r.axes.padEnd(axesWidth + 2)}${r.themes}`).join("\n");
807
+ }
808
+ var CONFIG_TEMPLATE = {
809
+ theme: "consulting",
810
+ style: {
811
+ colors: { primary: "#0B5FFF", accent: "#FF6A00" }
812
+ }
813
+ };
814
+ async function runInit(cwd = process.cwd()) {
815
+ const target = join4(cwd, CONFIG_FILENAME);
816
+ try {
817
+ await writeFile2(target, JSON.stringify(CONFIG_TEMPLATE, null, 2) + "\n", { flag: "wx" });
818
+ } catch (e) {
819
+ if (e.code === "EEXIST") {
820
+ throw new PptfastError(`${target} already exists \u2014 edit it instead`);
821
+ }
822
+ throw e;
823
+ }
824
+ return `wrote ${target} \u2014 themes: \`pptfast themes\`, style schema: \`pptfast schema --style\``;
825
+ }
826
+ async function runPreview(irPath, outDir, opts = {}) {
827
+ const cwd = opts.cwd ?? process.cwd();
828
+ const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
829
+ const { raw, baseDir } = await loadDeckTarget(irPath, cwd, projectHit, userHit);
830
+ await applyDeckConfig(raw, { cwd, projectHit, userHit });
110
831
  const v = validateIr(raw);
111
832
  if (!v.ok) throw new PptfastError(`invalid IR:
112
833
  ${formatIssues(v.errors)}`);
113
- await resolveLocalAssets(v.ir, dirname(resolve2(irPath)));
114
- await mkdir(outDir, { recursive: true });
834
+ await resolveLocalAssets(v.ir, baseDir);
835
+ await mkdir2(outDir, { recursive: true });
115
836
  const ir = v.ir;
837
+ const svgs = [];
116
838
  for (let i = 0; i < ir.slides.length; i++) {
839
+ const svg = renderSlideSvg(ir, i);
840
+ svgs.push(svg);
117
841
  const name = `${String(i + 1).padStart(3, "0")}-${ir.slides[i].type}.svg`;
118
- await writeFile(join(outDir, name), renderSlideSvg(ir, i));
842
+ await writeFile2(join4(outDir, name), svg);
843
+ }
844
+ const ok = `wrote ${ir.slides.length} SVG files to ${outDir}`;
845
+ const notes = [];
846
+ const aliasNote = normalizedNote(v.normalized);
847
+ if (aliasNote) notes.push(aliasNote);
848
+ if (opts.htmlOut) {
849
+ const htmlPath = join4(outDir, "preview.html");
850
+ const hasPlaceholder = ir.slides.some((slide) => slide.placeholder);
851
+ const auditReport = hasPlaceholder ? void 0 : auditDeck(ir);
852
+ const auditFindings = auditReport?.findings ?? [];
853
+ const html = buildPreviewHtml({
854
+ title: ir.filename,
855
+ slides: ir.slides.map((slide, i) => ({
856
+ index: i,
857
+ id: slide.id,
858
+ type: slide.type,
859
+ svg: svgs[i],
860
+ placeholder: slide.placeholder
861
+ })),
862
+ findings: auditFindings.map((f) => ({ page: f.page, slideId: f.slideId, code: f.code, message: f.message })),
863
+ auditNote: hasPlaceholder ? "audit overlay skipped \u2014 deck has unfilled placeholder pages; fill every page and re-run `pptfast preview --html` to see audit findings" : void 0,
864
+ checks: auditReport?.checks
865
+ });
866
+ await writeFile2(htmlPath, html);
867
+ notes.push(`note: wrote self-contained preview to ${htmlPath}`);
868
+ if (auditFindings.length > 0) {
869
+ notes.push(`note: audit found ${auditFindings.length} finding${auditFindings.length === 1 ? "" : "s"} \u2014 see preview.html`);
870
+ }
119
871
  }
120
- return `wrote ${ir.slides.length} SVG files to ${outDir}`;
872
+ return notes.length > 0 ? `${ok}
873
+ ${notes.join("\n")}` : ok;
874
+ }
875
+ function withRewrittenAssetPaths(ir, deckDir, outDir) {
876
+ const images = Object.fromEntries(
877
+ Object.entries(ir.assets.images).map(([id, asset]) => {
878
+ if (asset.src.startsWith("data:") || /^https?:\/\//.test(asset.src)) return [id, asset];
879
+ return [id, { ...asset, src: relative2(outDir, join4(deckDir, asset.src)) }];
880
+ })
881
+ );
882
+ return { ...ir, assets: { images } };
883
+ }
884
+ async function runAssemble(target, opts = {}) {
885
+ const cwd = opts.cwd ?? process.cwd();
886
+ const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
887
+ const dir = await resolveDeckTarget(target, resolveDecksDirSource(projectHit, userHit), cwd);
888
+ if (await pathExists(dir) && !await isDeckDirectory(dir)) {
889
+ throw new PptfastError(`expected a deck project directory: ${dir}`);
890
+ }
891
+ const { ir, generatedSeed, materializedLayoutCount, deckDir } = await readDeckDir(dir);
892
+ const outPath = opts.output ? resolve5(cwd, opts.output) : join4(deckDir, "deck.json");
893
+ const outDir = dirname2(outPath);
894
+ const outIr = outDir === deckDir ? ir : withRewrittenAssetPaths(ir, deckDir, outDir);
895
+ await mkdir2(outDir, { recursive: true });
896
+ await writeFile2(outPath, JSON.stringify(outIr, null, 2) + "\n");
897
+ const placeholderCount = outIr.slides.filter((s) => s.placeholder).length;
898
+ const summary = `wrote ${outPath} (${outIr.slides.length} slides, ${placeholderCount} placeholder${placeholderCount === 1 ? "" : "s"})`;
899
+ const notes = [];
900
+ if (generatedSeed !== void 0) {
901
+ notes.push(`note: generated seed ${generatedSeed} \u2014 add "seed": ${generatedSeed} to deck.spec.json for revision stability`);
902
+ }
903
+ if (materializedLayoutCount !== void 0) {
904
+ notes.push(
905
+ `note: ${materializedLayoutCount} layout${materializedLayoutCount === 1 ? "" : "s"} auto-selected into deck.json \u2014 pin "layout" in a page file to lock one`
906
+ );
907
+ }
908
+ return [summary, ...notes].join("\n");
909
+ }
910
+ async function runDisassemble(irPath, outDir) {
911
+ const raw = await loadIrFile(irPath);
912
+ const v = validateIr(raw);
913
+ if (!v.ok) throw new PptfastError(`invalid IR:
914
+ ${formatIssues(v.errors)}`);
915
+ const { spec: spec2, pages } = disassembleDeck(v.ir);
916
+ const ids = Object.keys(pages);
917
+ for (const id of ids) assertSafeFileSegment(id, "slide id");
918
+ const specPath = join4(outDir, "deck.spec.json");
919
+ await mkdir2(outDir, { recursive: true });
920
+ try {
921
+ await writeFile2(specPath, JSON.stringify(spec2, null, 2) + "\n", { flag: "wx" });
922
+ } catch (e) {
923
+ if (e.code === "EEXIST") {
924
+ throw new PptfastError(`${specPath} already exists \u2014 refusing to overwrite an existing deck project`);
925
+ }
926
+ throw e;
927
+ }
928
+ const pagesDir = join4(outDir, "pages");
929
+ try {
930
+ if (ids.length > 0) {
931
+ await mkdir2(pagesDir, { recursive: true });
932
+ await Promise.all(
933
+ ids.map((id) => {
934
+ const content = pages[id];
935
+ return writeFile2(join4(pagesDir, `${id}.json`), JSON.stringify(content, null, 2) + "\n");
936
+ })
937
+ );
938
+ }
939
+ const { count: assetCount, assetsDir } = await writeDeckAssets(
940
+ v.ir.assets.images,
941
+ outDir,
942
+ dirname2(resolve5(irPath))
943
+ );
944
+ const pagesNote = ids.length > 0 ? `${ids.length} page file${ids.length === 1 ? "" : "s"} to ${pagesDir}` : "no pages (every slide was a placeholder)";
945
+ const assetsNote = assetCount > 0 ? `, and ${assetCount} asset file${assetCount === 1 ? "" : "s"} to ${assetsDir}` : "";
946
+ return `wrote ${specPath}, ${pagesNote}${assetsNote}`;
947
+ } catch (e) {
948
+ await rm(specPath, { force: true }).catch(() => {
949
+ });
950
+ throw e;
951
+ }
952
+ }
953
+ async function runMigrate(input, output, cwd = process.cwd()) {
954
+ const resolvedInput = resolve5(cwd, input);
955
+ if (await isDeckDirectory(resolvedInput)) {
956
+ return runMigrateDeckDir(resolvedInput, output, cwd);
957
+ }
958
+ return runMigrateIrFile(resolvedInput, output, cwd);
959
+ }
960
+ async function runMigrateDeckDir(dir, output, cwd) {
961
+ const planPath = join4(dir, PLAN_FILENAME);
962
+ const sourceSpecPath = join4(dir, SPEC_FILENAME);
963
+ if (!await pathExists(planPath) && await pathExists(sourceSpecPath)) {
964
+ throw new PptfastError(
965
+ `${dir} has ${SPEC_FILENAME} but no ${PLAN_FILENAME} \u2014 this deck project is already migrated, nothing to do`
966
+ );
967
+ }
968
+ const raw = await loadIrFile(planPath, "plan");
969
+ const migrated = migrateDeckPlanToSpec(raw);
970
+ const outDir = resolve5(cwd, output);
971
+ const specPath = join4(outDir, SPEC_FILENAME);
972
+ await mkdir2(outDir, { recursive: true });
973
+ try {
974
+ await writeFile2(specPath, JSON.stringify(migrated, null, 2) + "\n", { flag: "wx" });
975
+ } catch (e) {
976
+ if (e.code === "EEXIST") {
977
+ throw new PptfastError(`${specPath} already exists \u2014 refusing to overwrite, delete it first or choose a different -o`);
978
+ }
979
+ throw e;
980
+ }
981
+ return `wrote ${specPath} \u2014 run \`pptfast spec validate ${specPath}\` to confirm it, then delete ${planPath} (a directory with both files present is rejected)`;
982
+ }
983
+ async function runMigrateIrFile(filePath, output, cwd) {
984
+ const raw = await loadIrFile(filePath);
985
+ const version = typeof raw === "object" && raw !== null ? raw.version : void 0;
986
+ if (version === "2") {
987
+ throw new PptfastError(
988
+ "pptfast migrate does not support IR v2 (spec \xA715.3: v2 has no real users) \u2014 run `pptfast validate` on the v2 file to see the full v2\u2192v4 combined field mapping and rewrite it by hand"
989
+ );
990
+ }
991
+ if (version !== "3") {
992
+ throw new PptfastError(
993
+ `pptfast migrate only converts an IR v3 file (version: "3") or a deck project directory containing ${PLAN_FILENAME} \u2014 got version ${JSON.stringify(version)} in ${filePath}`
994
+ );
995
+ }
996
+ const parsed = PptxIRV3Schema.safeParse(raw);
997
+ if (!parsed.success) {
998
+ const detail = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n");
999
+ throw new PptfastError(`invalid IR v3 file ${filePath}:
1000
+ ${detail}`);
1001
+ }
1002
+ const migrated = migrateIrV3ToV4(parsed.data);
1003
+ const outPath = resolve5(cwd, output);
1004
+ await mkdir2(dirname2(outPath), { recursive: true });
1005
+ try {
1006
+ await writeFile2(outPath, JSON.stringify(migrated, null, 2) + "\n", { flag: "wx" });
1007
+ } catch (e) {
1008
+ if (e.code === "EEXIST") {
1009
+ throw new PptfastError(`${outPath} already exists \u2014 refusing to overwrite, delete it first or choose a different -o`);
1010
+ }
1011
+ throw e;
1012
+ }
1013
+ return `wrote ${outPath} (migrated IR v3 \u2192 v4)`;
1014
+ }
1015
+
1016
+ // src/cli/update.ts
1017
+ import { execFile } from "child_process";
1018
+ var PACKAGE_NAME = "@liustack/pptfast";
1019
+ function normalizeVersion(version) {
1020
+ const normalized = version.trim().replace(/^v/i, "").split("-")[0];
1021
+ if (!normalized) throw new Error(`invalid version: ${version}`);
1022
+ return normalized;
1023
+ }
1024
+ function parseVersion(version) {
1025
+ return normalizeVersion(version).split(".").map((segment) => {
1026
+ const value = Number.parseInt(segment, 10);
1027
+ if (!Number.isFinite(value)) throw new Error(`invalid version segment: ${segment}`);
1028
+ return value;
1029
+ });
1030
+ }
1031
+ function compareVersions(left, right) {
1032
+ const l = parseVersion(left);
1033
+ const r = parseVersion(right);
1034
+ for (let i = 0; i < Math.max(l.length, r.length); i++) {
1035
+ const a = l[i] ?? 0;
1036
+ const b = r[i] ?? 0;
1037
+ if (a !== b) return a < b ? -1 : 1;
1038
+ }
1039
+ return 0;
1040
+ }
1041
+ var runCommand = (command, args) => new Promise((resolve6, reject) => {
1042
+ execFile(command, args, { encoding: "utf8" }, (error, stdout, stderr) => {
1043
+ if (error) {
1044
+ reject(new Error(stderr.trim() || error.message));
1045
+ return;
1046
+ }
1047
+ resolve6(stdout.trim());
1048
+ });
1049
+ });
1050
+ async function checkForUpdate({
1051
+ currentVersion,
1052
+ packageName = PACKAGE_NAME,
1053
+ run = runCommand
1054
+ }) {
1055
+ const current = normalizeVersion(currentVersion);
1056
+ try {
1057
+ const latest = normalizeVersion(await run("npm", ["view", packageName, "version"]));
1058
+ return {
1059
+ packageName,
1060
+ currentVersion: current,
1061
+ latestVersion: latest,
1062
+ updateAvailable: compareVersions(current, latest) < 0,
1063
+ checked: true
1064
+ };
1065
+ } catch (error) {
1066
+ return {
1067
+ packageName,
1068
+ currentVersion: current,
1069
+ latestVersion: null,
1070
+ updateAvailable: false,
1071
+ checked: false,
1072
+ error: error instanceof Error ? error.message : String(error)
1073
+ };
1074
+ }
1075
+ }
1076
+ function createSelfUpdater(run = runCommand) {
1077
+ return async ({
1078
+ currentVersion,
1079
+ packageName = PACKAGE_NAME
1080
+ }) => {
1081
+ const info = await checkForUpdate({ currentVersion, packageName, run });
1082
+ if (!info.checked) throw new Error(`unable to check for updates: ${info.error ?? "unknown error"}`);
1083
+ if (!info.updateAvailable) return { ...info, updated: false };
1084
+ await run("npm", ["install", "-g", `${packageName}@latest`]);
1085
+ return { ...info, updated: true };
1086
+ };
121
1087
  }
122
1088
 
123
1089
  // src/cli.ts
@@ -128,25 +1094,109 @@ function fail(e) {
128
1094
  console.error(e instanceof Error ? e.message : String(e));
129
1095
  process.exit(1);
130
1096
  }
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) => {
1097
+ 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) => {
1098
+ try {
1099
+ console.log(
1100
+ await runRender(target, {
1101
+ output: opts.output,
1102
+ theme: opts.theme,
1103
+ stylePath: opts.style,
1104
+ draft: opts.draft
1105
+ })
1106
+ );
1107
+ } catch (e) {
1108
+ fail(e);
1109
+ }
1110
+ });
1111
+ 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) => {
1112
+ try {
1113
+ console.log(await runValidate(target));
1114
+ } catch (e) {
1115
+ fail(e);
1116
+ }
1117
+ });
1118
+ program.command("audit").description(
1119
+ "Deterministic geometry audit (overflow, out-of-bounds, low-contrast, overlap, content-truncated, content-dropped), plus an optional --pixels contrast pass \u2014 exits 1 when it finds anything"
1120
+ ).argument("<target>", "IR JSON file, deck project directory, or bare name under ~/.pptfast/decks").option("--json", "machine-readable output (the full AuditReport)").option("--pixels", "also run the optional pixel-contrast pass over image-backed text (requires sharp)").action(async (target, opts) => {
1121
+ try {
1122
+ const { output, hasFindings } = await runAudit(target, { json: opts.json, pixels: opts.pixels });
1123
+ console.log(output);
1124
+ if (hasFindings) process.exit(1);
1125
+ } catch (e) {
1126
+ fail(e);
1127
+ }
1128
+ });
1129
+ 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("--spec", "print the deck spec schema instead").option("--plan", "removed \u2014 use --spec instead").action((opts) => {
1130
+ if (opts.plan) {
1131
+ fail(new Error("`pptfast schema --plan` has been renamed to `pptfast schema --spec` \u2014 run `pptfast schema --spec` instead"));
1132
+ }
1133
+ console.log(runSchema(opts.spec ? "spec" : opts.style ? "style" : void 0));
1134
+ });
1135
+ var plan = program.command("plan").description("Removed \u2014 use `pptfast spec` instead");
1136
+ plan.command("validate").description("Removed \u2014 use `pptfast spec validate` instead").argument("<file>").action(() => {
1137
+ fail(new Error("`pptfast plan validate` has been renamed to `pptfast spec validate` \u2014 run `pptfast spec validate <file>` instead"));
1138
+ });
1139
+ var spec = program.command("spec").description("Deck spec commands (spec \xA76)");
1140
+ spec.command("validate").description("Validate a deck spec JSON file against the schema and strategy-aware hard gates").argument("<spec.json>").action(async (specPath) => {
1141
+ try {
1142
+ console.log(await runSpecValidate(specPath));
1143
+ } catch (e) {
1144
+ fail(e);
1145
+ }
1146
+ });
1147
+ program.command("assemble").description("Assemble a deck project directory (deck.spec.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) => {
1148
+ try {
1149
+ console.log(await runAssemble(target, { output: opts.output }));
1150
+ } catch (e) {
1151
+ fail(e);
1152
+ }
1153
+ });
1154
+ program.command("disassemble").description("Split an IR JSON file into a deck project directory (deck.spec.json + pages/)").argument("<ir.json>", "path to the IR file").requiredOption("-o, --output <dir>", "output deck project directory").action(async (irPath, opts) => {
132
1155
  try {
133
- console.log(await runRender(ir, opts));
1156
+ console.log(await runDisassemble(irPath, opts.output));
134
1157
  } catch (e) {
135
1158
  fail(e);
136
1159
  }
137
1160
  });
138
- program.command("validate").description("Validate an IR JSON file against the schema").argument("<ir.json>").action(async (ir) => {
1161
+ program.command("migrate").description("Convert a v3 IR file to v4, or a deck.plan.json project directory to deck.spec.json \u2014 deterministic, no model").argument("<input>", "IR v3 JSON file, or a deck project directory containing deck.plan.json").requiredOption("-o, --output <output>", "output path \u2014 an IR JSON file for a v3 file input, a directory for a deck-project-directory input").action(async (input, opts) => {
139
1162
  try {
140
- console.log(await runValidate(ir));
1163
+ console.log(await runMigrate(input, opts.output));
141
1164
  } catch (e) {
142
1165
  fail(e);
143
1166
  }
144
1167
  });
145
- program.command("schema").description("Print the IR JSON Schema (feed this to a model before it writes IR)").action(() => console.log(runSchema()));
146
1168
  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) => {
1169
+ program.command("narratives").description("List named narrative presets (strategy/pacing/audience axes + theme recommendations)").option("--json", "machine-readable output").action((opts) => console.log(runNarratives(Boolean(opts.json))));
1170
+ program.command("scenarios").description("Removed \u2014 use `pptfast narratives` instead").action(() => {
1171
+ fail(new Error("`pptfast scenarios` has been renamed to `pptfast narratives` \u2014 run `pptfast narratives` instead"));
1172
+ });
1173
+ program.command("init").description("Scaffold a pptfast.config.json in the current directory").action(async () => {
1174
+ try {
1175
+ console.log(await runInit());
1176
+ } catch (e) {
1177
+ fail(e);
1178
+ }
1179
+ });
1180
+ 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) => {
1181
+ try {
1182
+ console.log(await runPreview(target, opts.output, { htmlOut: opts.html }));
1183
+ } catch (e) {
1184
+ fail(e);
1185
+ }
1186
+ });
1187
+ program.command("check-update").description("Check npm for a newer pptfast release").action(async () => {
1188
+ const info = await checkForUpdate({ currentVersion: VERSION });
1189
+ if (!info.checked) fail(new Error(`update check failed: ${info.error}`));
1190
+ console.log(
1191
+ info.updateAvailable ? `update available: ${info.currentVersion} \u2192 ${info.latestVersion} (run \`pptfast self-update\`)` : `pptfast ${info.currentVersion} is up to date`
1192
+ );
1193
+ });
1194
+ program.command("self-update").description("Update the global pptfast install to the latest release").action(async () => {
148
1195
  try {
149
- console.log(await runPreview(ir, opts.output));
1196
+ const result = await createSelfUpdater()({ currentVersion: VERSION });
1197
+ console.log(
1198
+ result.updated ? `updated: ${result.currentVersion} \u2192 ${result.latestVersion}` : `already at the latest version (${result.currentVersion})`
1199
+ );
150
1200
  } catch (e) {
151
1201
  fail(e);
152
1202
  }