@liustack/pptwise 0.22.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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +127 -0
  3. package/README.zh-CN.md +136 -0
  4. package/cordis.patch.yml +5 -0
  5. package/dist/chunk-3ZUKISTY.js +114 -0
  6. package/dist/chunk-3ZUKISTY.js.map +1 -0
  7. package/dist/chunk-M35M4QUC.js +1167 -0
  8. package/dist/chunk-M35M4QUC.js.map +1 -0
  9. package/dist/chunk-VUOLBHD7.js +19 -0
  10. package/dist/chunk-VUOLBHD7.js.map +1 -0
  11. package/dist/chunk-WL5KWYKS.js +49762 -0
  12. package/dist/chunk-WL5KWYKS.js.map +1 -0
  13. package/dist/cli.js +4753 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/index.d.ts +4224 -0
  16. package/dist/index.js +99 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/node.d.ts +7 -0
  19. package/dist/node.js +11 -0
  20. package/dist/node.js.map +1 -0
  21. package/dist/pixel-audit-H5K6JK3X.js +218 -0
  22. package/dist/pixel-audit-H5K6JK3X.js.map +1 -0
  23. package/dist/registry-C0GJH7ZT.d.ts +46 -0
  24. package/dsh/client.js +1398 -0
  25. package/dsh/index.js +141 -0
  26. package/dsh/preview-tool.js +1931 -0
  27. package/dsh/spawnHidden.js +109 -0
  28. package/package.json +113 -0
  29. package/skills/pptwise/SKILL.md +100 -0
  30. package/skills/pptwise/SKILL.zh-CN.md +102 -0
  31. package/skills/pptwise/references/branding.md +18 -0
  32. package/skills/pptwise/references/branding.zh-CN.md +21 -0
  33. package/skills/pptwise/references/components.md +35 -0
  34. package/skills/pptwise/references/components.zh-CN.md +40 -0
  35. package/skills/pptwise/references/density.md +17 -0
  36. package/skills/pptwise/references/density.zh-CN.md +22 -0
  37. package/skills/pptwise/references/images.md +42 -0
  38. package/skills/pptwise/references/images.zh-CN.md +47 -0
  39. package/skills/pptwise/references/layouts.md +37 -0
  40. package/skills/pptwise/references/layouts.zh-CN.md +42 -0
  41. package/skills/pptwise/references/spec.md +107 -0
  42. package/skills/pptwise/references/spec.zh-CN.md +112 -0
  43. package/skills/pptwise/references/validate.md +82 -0
  44. package/skills/pptwise/references/validate.zh-CN.md +87 -0
  45. package/skills/pptwise/scripts/run.ps1 +192 -0
  46. package/skills/pptwise/scripts/run.sh +229 -0
@@ -0,0 +1,1931 @@
1
+ // The `pptwise_preview` DSH tool.
2
+ //
3
+ // Why a tool at all, when this plugin already registers a skill: a skill
4
+ // teaches the model to drive the CLI from the terminal, so every call in the
5
+ // transcript belongs to `bash` and renders in DSH's generic terminal card.
6
+ // pptwise owns no surface there, which is why the review loop has been "open
7
+ // http://127.0.0.1:4400 yourself" — the harness had nowhere to put a button.
8
+ // A registered tool owns its own `tool.call.toolview` key, and that key is
9
+ // the seat the in-conversation preview sits in.
10
+ //
11
+ // The payload split is the whole design, and the channel it rides took two
12
+ // attempts. `output.presentationMeta` looks like the right home — a
13
+ // structured, persisted, non-model-facing projection — but the registry
14
+ // computes it for TOP-LEVEL calls only, and this repo's own default agent
15
+ // preset runs in Code Mode, where every tool is invoked from inside
16
+ // `run_code` and is therefore a sub-call. Verified against a real session
17
+ // log: 34 top-level `run_code` calls, `pptwise_preview` never once among
18
+ // them, and no `presentationMeta` anywhere in the persisted result. The card
19
+ // dutifully rendered nothing.
20
+ //
21
+ // So the deck rides an HTTP route instead (`registerRoute`), which is
22
+ // indifferent to call depth:
23
+ //
24
+ // - the MODEL sees one short line from `output.render`, plus a preview id.
25
+ // A deck's SVG runs to tens of kilobytes and carries nothing the model can
26
+ // act on, so it never enters the transcript.
27
+ // - the CARD reads that id out of the result text and fetches from the route:
28
+ // the bundle for its thumbnail strip, and `preview.html` for the viewer it
29
+ // opens in an iframe. Same-origin loopback only.
30
+ //
31
+ // Nothing here re-renders anything of its own, and — since the viewer became
32
+ // an iframe — nothing here reimplements anything either. It shells out to the
33
+ // same packaged CLI the skill teaches and serves what `preview --html` already
34
+ // wrote: `manifest.json` plus one SVG per page for the strip, and the
35
+ // self-contained `preview.html` (filmstrip, keyboard paging, light/dark
36
+ // surround, audit findings) for the full-size view. Keeping one rendering path
37
+ // is the point, and it now covers the reading path too: a second renderer in a
38
+ // UI is how the promotional images and the review conclusions would stop
39
+ // describing the same product, and a second *viewer* is how the card and the
40
+ // review bundle would stop showing the same deck.
41
+ //
42
+ // Everything stateful lives inside `createPreviewService`, never at module
43
+ // scope. Two services (a plugin reload, a second profile, a test) must not be
44
+ // able to see each other's CLI path — a module-level `cliPath` meant the
45
+ // second `apply()` silently re-pointed the route the first one had already
46
+ // registered.
47
+ //
48
+ // The decks themselves are the opposite case, and getting that backwards is
49
+ // what this file was fixed for. See PREVIEWS ARE HISTORY below.
50
+ //
51
+ // ONE RENDER WINDOW is the rule everything else here follows. A preview and
52
+ // its .pptx are produced by a single `execute` call, from one snapshot, by
53
+ // one CLI process generation. Pinning the IR alone was not enough: a second
54
+ // CLI run re-reads project and user configuration (theme, style), re-reads
55
+ // image files off disk, re-fetches http assets, and may even be a different
56
+ // renderer version after a plugin upgrade. None of that is captured by an IR
57
+ // file, so the export could differ from the deck the user just approved in
58
+ // four separate ways. The download route therefore serves a file, and starts
59
+ // no process. The cost is one export per preview, including the previews
60
+ // nobody downloads. That is the deliberate price.
61
+ //
62
+ // What that buys, precisely — the earlier wording here claimed more than the
63
+ // code delivers, so here is the honest list:
64
+ //
65
+ // - the deck structure and text: pinned, by the snapshot.
66
+ // - local image BYTES: pinned, by inlining them into the snapshot as data
67
+ // URIs (`inlineLocalImages`). Preview and render are still two processes
68
+ // with a real window between them, and an image file edited inside that
69
+ // window used to land in the export but not in the preview. Neither
70
+ // process reads those files any more.
71
+ // - the renderer build: the same `cliPath` for both runs, so only an
72
+ // upgrade mid-`execute` could split them. Not defended against.
73
+ // - project/user configuration: every run reads it fresh from the same cwd,
74
+ // and the window between the preview run and the export run is a whole
75
+ // render, so seconds rather than an instant. Not pinned — an edit landing
76
+ // inside that window would split preview from export. Rare enough to
77
+ // accept, not rare enough to call impossible.
78
+ // - http(s) assets, and local images in formats that need a recode (webp
79
+ // and friends): still fetched or read per run. See `inlineLocalImages`.
80
+
81
+ import { randomUUID } from 'node:crypto'
82
+ import { cpSync, existsSync, realpathSync, renameSync, rmSync } from 'node:fs'
83
+ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
84
+ import { homedir } from 'node:os'
85
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
86
+ import { runChild } from './spawnHidden.js'
87
+
88
+ /**
89
+ * How many pages get their SVG inlined into the bundle.
90
+ *
91
+ * This is a count of pages, not a budget of bytes, and it is bound to one
92
+ * thing only: how many thumbnails the card's strip draws (`STRIP_PAGES`,
93
+ * ./client.js). The bundle has no other consumer — the full-size viewer is the
94
+ * `preview.html` served below, which carries every page inside itself and asks
95
+ * this route for no markup at all. Pages past this point still travel, with
96
+ * their metadata, so the card can count and name them.
97
+ *
98
+ * What stood here before was an 8 MB budget on the total inlined markup, and
99
+ * the difference matters. A byte budget makes a page's fate depend on the
100
+ * pages before it: a real nine-page deck with a photo on every slide came back
101
+ * with pages 2 and 6 blank — the early photos had spent the budget, and the
102
+ * later, cheaper pages fit in what was left. Counting pages cannot produce
103
+ * that, because which pages arrive without markup no longer has anything to do
104
+ * with how heavy their neighbours were.
105
+ */
106
+ const THUMBNAIL_STRIP_PAGES = 12
107
+
108
+ /** The self-contained review page `preview --html` writes into `outDir`. */
109
+ const PREVIEW_HTML_FILE = 'preview.html'
110
+
111
+ /**
112
+ * What a missing preview looks like to the one consumer that renders what it
113
+ * is handed rather than parsing it.
114
+ *
115
+ * Every other thing this route serves is read by code, so JSON is the right
116
+ * answer for it. `/html` is the exception: its consumer is the card's iframe,
117
+ * and an iframe displays the response body whatever the status line said. So a
118
+ * missing preview reached the user as a bare browser document reading
119
+ * `{"error":"unknown preview id"}`, pretty-print checkbox and all, framed by
120
+ * the viewer's own Close and Download buttons. The status code is unchanged —
121
+ * a status code is not a document, and the card still reads it — but the body
122
+ * is now a sentence a person can act on.
123
+ *
124
+ * The word "expired" is deliberately not in it any more. Nothing expires a
125
+ * preview: no timer, no budget, no sweep. A deck is missing because it was
126
+ * deleted, which makes "where they live and that they stay there" the useful
127
+ * thing to say — the previous wording sent people looking for a setting that
128
+ * does not exist.
129
+ *
130
+ * Self-contained and colourless on purpose: it renders inside a modal that is
131
+ * already black, in a browser that may be in either theme, with no stylesheet
132
+ * of its own to inherit.
133
+ */
134
+ function noticePage(title, heading, message, hint) {
135
+ return [
136
+ '<!doctype html>',
137
+ '<meta charset="utf-8">',
138
+ '<meta name="color-scheme" content="dark light">',
139
+ `<title>${escapeHtml(title)}</title>`,
140
+ '<style>',
141
+ 'html,body{height:100%;margin:0}',
142
+ 'body{display:flex;align-items:center;justify-content:center;background:#111;color:#eee;',
143
+ 'font:14px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;padding:24px}',
144
+ 'main{max-width:44ch;text-align:center}',
145
+ 'h1{font-size:15px;font-weight:600;margin:0 0 8px}',
146
+ 'p{margin:0 0 8px;color:#aaa}',
147
+ 'p:last-child{margin:0}',
148
+ 'code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:#ccc}',
149
+ '</style>',
150
+ '<main>',
151
+ `<h1>${escapeHtml(heading)}</h1>`,
152
+ `<p>${escapeHtml(message)}</p>`,
153
+ `<p>${hint}</p>`,
154
+ '</main>',
155
+ ].join('\n')
156
+ }
157
+
158
+ /**
159
+ * The deck is not under this root: a final answer, and usually one the user
160
+ * caused. Usually, not always — the root follows `PPTWISE_HOME`, so a deck
161
+ * written under a different one is alive and out of reach. That is why the
162
+ * hint below prints the root it actually looked in rather than telling the
163
+ * reader what they must have done.
164
+ */
165
+ function missingPage(message) {
166
+ return noticePage(
167
+ 'Preview not found',
168
+ 'This deck is no longer on disk',
169
+ message,
170
+ `Rendered decks stay in <code>${escapeHtml(previewRoot())}</code> until you delete them. ` +
171
+ 'Run <code>pptwise_preview</code> again to rebuild this one.',
172
+ )
173
+ }
174
+
175
+ /**
176
+ * The deck may well be fine and this process could not read it.
177
+ *
178
+ * A separate page rather than a reworded one, because the two say opposite
179
+ * things to the person reading them: one means "rebuild it", the other means
180
+ * "wait and try again". Handing a permission blip the "no longer on disk" page
181
+ * would send a user to rebuild a deck that is sitting right there.
182
+ */
183
+ function unreadablePage(message) {
184
+ return noticePage(
185
+ 'Preview unavailable',
186
+ 'This deck could not be read just now',
187
+ message,
188
+ 'The files may still be there. Close this and open it again in a moment.',
189
+ )
190
+ }
191
+
192
+ /**
193
+ * The files are here; what describes them is not readable.
194
+ *
195
+ * A third page, for the same reason there is a second one. "No longer on disk"
196
+ * is a claim about what the user did, and it is false here — the deck was
197
+ * rendered, the pages are probably sitting right next to the file that went
198
+ * bad. Telling someone their deck was deleted when a `record.json` was
199
+ * truncated by a full disk sends them looking in the wrong place, and quietly
200
+ * blames them for it.
201
+ */
202
+ function damagedPage(message) {
203
+ return noticePage(
204
+ 'Preview damaged',
205
+ 'This deck cannot be opened',
206
+ message,
207
+ "The rendered pages may still be there — it is the file describing them that this version cannot read. " +
208
+ 'Run <code>pptwise_preview</code> again to rebuild it.',
209
+ )
210
+ }
211
+
212
+ /**
213
+ * The page that goes with a failure code. One mapping, and the route's only way
214
+ * to reach these three pages, so the wording follows the verdict rather than
215
+ * being chosen again at the call site.
216
+ */
217
+ function noticePageFor(code, message) {
218
+ if (code === FAILURE_CODES.unreadable) return unreadablePage(message)
219
+ if (code === FAILURE_CODES.damaged) return damagedPage(message)
220
+ return missingPage(message)
221
+ }
222
+
223
+ /** The four characters that could turn a filesystem path in a message into markup. */
224
+ function escapeHtml(value) {
225
+ return String(value)
226
+ .replace(/&/g, '&amp;')
227
+ .replace(/</g, '&lt;')
228
+ .replace(/>/g, '&gt;')
229
+ .replace(/"/g, '&quot;')
230
+ }
231
+
232
+ export const TOOL_NAME = 'pptwise_preview'
233
+
234
+ export const PREVIEW_ROUTE = '/pptwise/preview'
235
+
236
+ /**
237
+ * The stamp every response from this route carries, and the card's only proof
238
+ * that a status came from here.
239
+ *
240
+ * A status code says nothing about who produced it. A 404 can mean "this
241
+ * module has never heard of that id" or it can mean the plugin's route never
242
+ * registered, or that a proxy answered first, or that the harness served its
243
+ * own not-found page for an unknown path. The card acts on our 404 by retiring
244
+ * a deck permanently, so it has to be able to tell the difference — and since
245
+ * the route is same-origin, a response header is readable. Anything that writes
246
+ * a response could write this one, so it is not a signature: it is a name
247
+ * nothing else answering on this port has any reason to set, which is enough to
248
+ * stop an unrelated 404 from retiring a live deck.
249
+ */
250
+ export const ROUTE_HEADER = 'x-pptwise-preview'
251
+ export const ROUTE_HEADER_VALUE = '1'
252
+
253
+ /**
254
+ * The machine-readable half of every failure this route reports.
255
+ *
256
+ * A status code has three values to say four things with, and prose is not a
257
+ * protocol. The card has to tell a deleted preview from a damaged one — they
258
+ * are both final, they are both 410, and they need opposite sentences in front
259
+ * of the user — so it was reading the difference out of a status code that
260
+ * cannot carry it. It could not, so both arrived as "deleted", and the damaged
261
+ * case existed only in a server log.
262
+ *
263
+ * Sent in the JSON body rather than only in a header so anything reading this
264
+ * route by hand sees it too, and mirrored on the status line by nothing: the
265
+ * body is the contract.
266
+ */
267
+ export const FAILURE_CODES = {
268
+ /** No such preview here. Deleted, or never in this root at all. */
269
+ unknown: 'preview_unknown',
270
+ /** The preview is here and has lost files it needs. */
271
+ missing: 'preview_missing',
272
+ /** The files are here; the bookkeeping that describes them cannot be read. */
273
+ damaged: 'preview_damaged',
274
+ /** This process could not read it. Says nothing about the preview. */
275
+ unreadable: 'preview_unreadable',
276
+ }
277
+
278
+ // PREVIEWS ARE HISTORY, NOT TEMPORARY FILES.
279
+ //
280
+ // That sentence is the whole storage design, and it is the one the previous
281
+ // layout got wrong in two independent ways. Both are worth writing down,
282
+ // because both looked reasonable and both guaranteed the same user-visible
283
+ // failure: cards that go dead for no reason the user can see.
284
+ //
285
+ // 1. The records lived in `$TMPDIR/pptwise-previews/<sha256(cliPath)[0:16]>/`.
286
+ // An npm install path carries the version in it
287
+ // (`.pnpm/@liustack+pptwise@0.19.2/…`), so that hash changed on every
288
+ // single plugin upgrade and every historical preview was orphaned the
289
+ // moment the user updated. Measured on a real machine: 14 records, 7 live
290
+ // decks, and every dead one predated the commit that introduced the
291
+ // bucket.
292
+ // 2. The rendered decks lived in `$TMPDIR` too, via `mkdtemp`. macOS sweeps
293
+ // that directory on its own schedule, so every card expired after a few
294
+ // days no matter what the records did.
295
+ //
296
+ // The bucket was there for two stated reasons and neither survives contact:
297
+ // "one service must not see another's decks" is a privacy claim about one
298
+ // person's own machine, where the only way to reach a deck is to already have
299
+ // its UUID out of the transcript it belongs to; and "a record written by a
300
+ // different renderer build is not visible" is a version fence guarding
301
+ // nothing, because recall serves files that were rendered once and starts no
302
+ // renderer. What the fence actually bought was the bug.
303
+ //
304
+ // So: one fixed root, keyed by nothing. No version, no install path, no
305
+ // process id, nothing that an upgrade can move. One directory per call, named
306
+ // by the id itself, so a lookup is a path computed from the id rather than a
307
+ // path followed out of a file — and published in a single `rename`, so the
308
+ // route sees a whole preview or none of one. The three notes below (expiry,
309
+ // `PARTIAL_SUFFIX`, `OWNER_MARKER`) are where each of those is argued.
310
+
311
+ /** Directory under `$PPTWISE_HOME` that holds every preview this plugin has kept. */
312
+ const PREVIEW_DIR = 'previews'
313
+
314
+ /**
315
+ * Root of everything this module writes.
316
+ *
317
+ * Must match `src/cli/home.ts` `pptwiseHome()`: `PPTWISE_HOME` wins,
318
+ * then `PPTPRESS_HOME`, then `PPTFAST_HOME` (warn once when a legacy name
319
+ * supplies the value), empty string is unset, default `~/.pptwise` with a
320
+ * one-time copy from `~/.pptpress` or else `~/.pptfast` when the new dir is
321
+ * missing. The plugin cannot import the TypeScript helper (this file is
322
+ * dependency-free plain JS with no build step) so the two rules are
323
+ * duplicated, deliberately and identically.
324
+ *
325
+ * Resolved to an absolute path: a relative root would make every path here
326
+ * depend on where the harness happened to be started.
327
+ */
328
+ const warnedLegacyHome = new Set()
329
+
330
+ function nonemptyEnv(env, key) {
331
+ const value = env[key]
332
+ return value === undefined || value === '' ? undefined : value
333
+ }
334
+
335
+ function warnLegacyHome(legacyKey) {
336
+ if (warnedLegacyHome.has(legacyKey)) return
337
+ warnedLegacyHome.add(legacyKey)
338
+ process.stderr.write(`${legacyKey} is deprecated. Use PPTWISE_HOME instead.\n`)
339
+ }
340
+
341
+ function migrateLegacyHome(legacyDir, nextDir) {
342
+ if (existsSync(nextDir) || !existsSync(legacyDir)) return
343
+ // Must match src/cli/home.ts: realpath so a directory symlink is copied
344
+ // as a real tree. Default cpSync would copy the link itself.
345
+ const source = realpathSync(legacyDir)
346
+ const tmpDir = `${nextDir}.migrating`
347
+ rmSync(tmpDir, { recursive: true, force: true })
348
+ try {
349
+ cpSync(source, tmpDir, { recursive: true })
350
+ renameSync(tmpDir, nextDir)
351
+ } catch (error) {
352
+ try {
353
+ rmSync(tmpDir, { recursive: true, force: true })
354
+ } catch {
355
+ // still throw the original copy/rename failure
356
+ }
357
+ throw error
358
+ }
359
+ }
360
+
361
+ function resolvePluginHome(opts = {}) {
362
+ const env = opts.env ?? process.env
363
+ const homeFn = opts.homedir ?? homedir
364
+ const current = nonemptyEnv(env, 'PPTWISE_HOME')
365
+ if (current !== undefined) return current
366
+ for (const key of ['PPTPRESS_HOME', 'PPTFAST_HOME']) {
367
+ const legacy = nonemptyEnv(env, key)
368
+ if (legacy !== undefined) {
369
+ warnLegacyHome(key)
370
+ return legacy
371
+ }
372
+ }
373
+ const base = homeFn()
374
+ const next = join(base, '.pptwise')
375
+ for (const dirname of ['.pptpress', '.pptfast']) {
376
+ const legacy = join(base, dirname)
377
+ if (existsSync(legacy)) {
378
+ migrateLegacyHome(legacy, next)
379
+ break
380
+ }
381
+ }
382
+ return next
383
+ }
384
+
385
+ export function previewRoot(opts = {}) {
386
+ return join(resolve(resolvePluginHome(opts)), PREVIEW_DIR)
387
+ }
388
+
389
+ /**
390
+ * The record, the snapshot, the pages, the viewer page and the .pptx all live
391
+ * in `<root>/<id>/`, and the id IS the directory name.
392
+ *
393
+ * The old layout kept the record in one place and the deck in another, joined
394
+ * by an absolute path stored inside the record. That made "the record survived
395
+ * but the deck did not" an ordinary, reachable state — the one every dead card
396
+ * in the wild was in. Here they no longer live in separate places that can be
397
+ * swept independently, and `recallAnywhere` finds a preview by computing a path
398
+ * from the id rather than by following one it read off disk. Taking part of one
399
+ * directory still splits them, which is what outcome (3) and `PreviewExpired`
400
+ * are for — the difference is that it now takes a deliberate hand rather than
401
+ * an ordinary temp sweep.
402
+ *
403
+ * That has a second consequence, and it is the one the single deletion left in
404
+ * this file leans on: no value out of a record is ever used as a directory to
405
+ * delete.
406
+ */
407
+ const RECORD_FILE = 'record.json'
408
+
409
+ /** The page index `preview --html` writes, and the file a bundle is read from. */
410
+ const MANIFEST_FILE = 'manifest.json'
411
+
412
+ // NOTHING HERE EXPIRES A PREVIEW.
413
+ //
414
+ // An earlier draft of this file carried a count budget, a byte budget, a
415
+ // least-recently-used sort and a sweep for abandoned directories. All four are
416
+ // gone on purpose, and the note is here so the next reader does not restore
417
+ // them as an obvious omission.
418
+ //
419
+ // A card is a row in a transcript, and how far back a transcript stays worth
420
+ // reading is the user's call — not a number picked in this file. Every
421
+ // automatic rule that could delete a deck was a rule that would eventually
422
+ // delete one the user was still scrolling back to, silently, with a dead card
423
+ // as the only notice. That is the exact complaint this whole change answers,
424
+ // and buying it back at a longer interval is still buying it back.
425
+ //
426
+ // Disk therefore grows with the number of calls. The answer to that is
427
+ // visibility rather than deletion: every run reports its `outDir` to the model
428
+ // and the user, the root is one fixed directory, and removing that directory
429
+ // is a safe thing for a person to do.
430
+ //
431
+ // One deletion survives, and only one: a render that threw removes the
432
+ // half-written directory it was building — a directory whose name no id
433
+ // resolves to and which nobody was ever handed. A render killed outright
434
+ // (SIGKILL, a lost machine) leaves that directory behind, permanently, and
435
+ // that is accepted rather than swept: an unpublished `<id>.partial` is
436
+ // unreachable from every route, and collecting it means reintroducing exactly
437
+ // the timer this design removed.
438
+
439
+ /**
440
+ * The name a preview is assembled under, before it is published.
441
+ *
442
+ * `rename` inside one filesystem is atomic, which is the whole mechanism: the
443
+ * route sees a preview directory complete or not at all, never mid-render.
444
+ *
445
+ * That is a claim about visibility and not about durability, and the difference
446
+ * is worth keeping straight. Nothing here calls fsync, so a machine that loses
447
+ * power just after the rename can come back with the directory published and
448
+ * files inside it that never reached the platter. What the route finds then is
449
+ * a preview with pieces missing, which it already has an answer for — 410, and
450
+ * the name of what it could not read. What it will not find is half a render
451
+ * presenting itself as a whole one, and that is the part rename buys.
452
+ *
453
+ * The suffix cannot collide with a published preview, and that is a property
454
+ * of `ID_PATTERN` rather than of luck — the pattern admits no `.`, so no id
455
+ * can spell a name ending in `.partial`, and every path this module looks up
456
+ * is built from an id that passed it.
457
+ */
458
+ const PARTIAL_SUFFIX = '.partial'
459
+
460
+ /**
461
+ * Written into a directory this module creates, and required before it will
462
+ * remove one again.
463
+ *
464
+ * The job is narrower than the note that used to stand here, which argued from
465
+ * an eviction path that no longer exists. Back then a record's `outDir` — a
466
+ * string read out of JSON, pointing anywhere — was passed to a recursive
467
+ * delete, so the marker was the only thing standing between a forged record
468
+ * and `rm -rf`. There is no such input any more: the one path this module
469
+ * deletes is `<root>/<id>.partial`, built from a uuid this call generated
470
+ * moments earlier.
471
+ *
472
+ * What is left is smaller and still real. That path is a name, and a name can
473
+ * already be taken — by a leftover from an older layout, by a concurrent
474
+ * writer, by something planted. `createOwnedDir` refuses to merge into an
475
+ * existing directory, and this file looks for the marker again before removing
476
+ * anything. It is a filename, not a signature: the payload written beside it is
477
+ * never read back, so it stops a delete from wandering into a directory that
478
+ * never belonged to this tool and would not stop one that had the marker
479
+ * planted in it. Keeping it costs one `stat`; removing it is an argument about
480
+ * a different subject, and belongs in its own change rather than smuggled into
481
+ * a move of the storage root.
482
+ */
483
+ const OWNER_MARKER = '.pptwise-preview-owner'
484
+
485
+ /**
486
+ * Ids reach this module from a URL path and become directory names, so the
487
+ * shape is checked rather than trusted. `randomUUID` is the only producer.
488
+ *
489
+ * Hex and dashes only, which is what makes every path built from an id stay
490
+ * inside the root: `.` is not in the class, so `..` cannot be spelled, and
491
+ * neither can a separator. That is checked on both sides — a read that builds
492
+ * a lookup path and a write that creates a directory — because this module
493
+ * exports entry points (`remember`) that take an id from a caller.
494
+ */
495
+ const ID_PATTERN = /^[0-9a-fA-F-]{8,64}$/
496
+
497
+ /** The resolved deck a preview is pinned to, written next to its rendered pages. */
498
+ const SNAPSHOT_FILE = 'snapshot.ir.json'
499
+
500
+ /** A deck-local brand theme, copied beside the snapshot for the same reason. */
501
+ const THEME_FILE = 'theme.json'
502
+
503
+ /**
504
+ * Thrown when the record survived but the deck it points at did not.
505
+ *
506
+ * The old code answered this case by re-reading the user's original target,
507
+ * and the round after that by re-rendering the pinned snapshot. Both are the
508
+ * same bug at different depths: what comes back is built now, out of whatever
509
+ * the configuration, the image files and the installed renderer happen to be
510
+ * now, and is then presented as the deck sitting in the card. A preview whose
511
+ * files are gone is gone, and says so.
512
+ */
513
+ class PreviewExpired extends Error {}
514
+
515
+ /**
516
+ * Thrown when a preview could not be READ, which is not the same as gone.
517
+ *
518
+ * This distinction is the whole point of the class existing, and collapsing it
519
+ * is a bug this plugin has now grown twice in two different files. The card
520
+ * treats 404 and 410 as final — nothing regenerates a preview, so re-asking is
521
+ * pointless — and everything else as worth another try. That contract is only
522
+ * safe if the server never spends a final status on a temporary problem.
523
+ *
524
+ * `readRecord` used to answer every failure with `undefined`, which the route
525
+ * turns into "unknown preview id". So a permission bit changed by a backup
526
+ * tool, an `EIO` off a failing disk, or an `EMFILE` while the harness was
527
+ * busy, all reported that a preview the user was looking at had never existed
528
+ * — and because the card had just been taught not to retry a 404, it stayed
529
+ * that way for the life of the page. That is the exact bug that was fixed on
530
+ * the client side, re-appearing one layer down.
531
+ */
532
+ class PreviewUnreadable extends Error {}
533
+
534
+ /**
535
+ * Thrown when a preview's own bookkeeping is corrupt: present, readable, and
536
+ * not something this code can make sense of.
537
+ *
538
+ * A third case, because it is genuinely a third thing and the first draft made
539
+ * it wear the first one's clothes. A `record.json` that does not parse used to
540
+ * be reported as "unknown preview id" — the answer an id nobody was ever given
541
+ * gets — which tells the user their deck never existed when what actually
542
+ * happened is a write torn in half by a full disk.
543
+ *
544
+ * It shares a status with `PreviewExpired` (410) rather than getting a 5xx,
545
+ * and that is deliberate: re-reading the same bytes produces the same failure
546
+ * for ever, so telling the card to try again would spin it. What changes is
547
+ * the sentence the user reads, which no longer accuses them of deleting
548
+ * something.
549
+ */
550
+ class PreviewDamaged extends Error {}
551
+
552
+ /**
553
+ * Does this error mean nothing is there, or that we could not look?
554
+ *
555
+ * `ENOENT` is a fact about the filesystem: no file has that name. `ENOTDIR` is
556
+ * the same fact arriving through a path component, and is treated identically
557
+ * — asking for `<id>/manifest.json` when `<id>` is a regular file means the
558
+ * manifest is not there either.
559
+ *
560
+ * Everything else is a fact about this attempt, not about the deck.
561
+ */
562
+ function isAbsent(error) {
563
+ const code = error && error.code
564
+ return code === 'ENOENT' || code === 'ENOTDIR'
565
+ }
566
+
567
+ /**
568
+ * The errnos a retry can honestly do something about.
569
+ *
570
+ * An allowlist, and the direction matters more than the contents. This used to
571
+ * be "anything with a `code` that is not `ENOENT`", which is a blocklist — and
572
+ * a blocklist answers "is this fixed bad data?" with "I have not heard of it,
573
+ * so no". It let two permanent conditions through as retryable: a `record.json`
574
+ * that is actually a directory (`EISDIR`), and a page name containing a NUL
575
+ * byte (`ERR_INVALID_ARG_VALUE`). No number of retries turns a directory into
576
+ * a JSON file.
577
+ *
578
+ * So retryable is now the exception that has to be argued for, one entry at a
579
+ * time. Each of these can succeed on a later identical call with nobody
580
+ * editing anything:
581
+ *
582
+ * - `EACCES`, `EPERM` a permission bit a backup or sync tool flipped, and
583
+ * can flip back.
584
+ * - `EAGAIN` the kernel said "not right now" in as many words.
585
+ * - `EBUSY` another process has it open; it will let go.
586
+ * - `EMFILE`, `ENFILE` out of descriptors, this process or the machine. The
587
+ * file is fine; the moment is not.
588
+ * - `ENOMEM` same shape, different resource.
589
+ * - `EIO` a read that failed at the device. Disks do this once
590
+ * and then succeed, which is exactly what a retry is for.
591
+ * - `ENOBUFS` the kernel is out of buffer space. Same shape as
592
+ * `ENOMEM`, different pool.
593
+ * - `ETIMEDOUT`, `ESTALE`, `ENETDOWN`, `ENETRESET`, `ENETUNREACH`,
594
+ * `EHOSTDOWN`, `EHOSTUNREACH`, `ENOTCONN`, `ECONNRESET`, `ECONNABORTED`,
595
+ * `ECONNREFUSED`, `ENONET`, `EREMOTEIO`
596
+ * a network filesystem in a state it comes out of. The
597
+ * first version of this list took the mounted-share
598
+ * cases and stopped short of the connection ones, which
599
+ * is not a distinction the kernel makes: a read from an
600
+ * NFS or FUSE mount answers with any of these while the
601
+ * deck underneath is perfectly intact. Leaving them out
602
+ * meant one connection reset permanently retired a live
603
+ * deck — the exact cost this list exists to avoid, in a
604
+ * different set of errnos.
605
+ *
606
+ * Everything else — including every error with no `code` at all, and every
607
+ * code this list has not thought about — is final. That is the safe direction
608
+ * to be wrong in: a final answer on something transient costs one card that a
609
+ * re-render fixes, while a retryable answer on fixed bad data is a card that
610
+ * offers a button which can never work.
611
+ */
612
+ const RETRYABLE_ERRNOS = new Set([
613
+ 'EACCES',
614
+ 'EPERM',
615
+ 'EAGAIN',
616
+ 'EBUSY',
617
+ 'EMFILE',
618
+ 'ENFILE',
619
+ 'ENOMEM',
620
+ 'ENOBUFS',
621
+ 'EIO',
622
+ 'ETIMEDOUT',
623
+ 'ESTALE',
624
+ 'ENETDOWN',
625
+ 'ENETRESET',
626
+ 'ENETUNREACH',
627
+ 'EHOSTDOWN',
628
+ 'EHOSTUNREACH',
629
+ 'ENOTCONN',
630
+ 'ECONNRESET',
631
+ 'ECONNABORTED',
632
+ 'ECONNREFUSED',
633
+ 'ENONET',
634
+ 'EREMOTEIO',
635
+ ])
636
+
637
+ function isTransient(error) {
638
+ return RETRYABLE_ERRNOS.has(error && error.code)
639
+ }
640
+
641
+ /**
642
+ * What a failed read means, in the three words this module answers in.
643
+ *
644
+ * One classifier so the three read sites cannot drift: absent is a deletion,
645
+ * an allowlisted errno is worth another go, and everything else is data that
646
+ * will fail the same way for ever. `ELOOP`, `ENAMETOOLONG`, `EISDIR` and a NUL
647
+ * in a filename all land in that last group, which is where they belong — they
648
+ * describe something wrong with the deck, not with the moment.
649
+ */
650
+ function classifyReadFailure(error) {
651
+ if (isAbsent(error)) return 'missing'
652
+ if (isTransient(error)) return 'unreadable'
653
+ return 'damaged'
654
+ }
655
+
656
+ /** Name the file and the reason, so a retryable failure says what to look at. */
657
+ function describeUnreadable(error, fallback) {
658
+ const path = error && typeof error.path === 'string' ? error.path : fallback
659
+ const code = error && typeof error.code === 'string' ? error.code : 'unknown error'
660
+ return `${path} (${code})`
661
+ }
662
+
663
+ /**
664
+ * The one directory a preview id is allowed to name, or nothing.
665
+ *
666
+ * Every preview *directory* in this module is built here, from an id, and
667
+ * never followed out of a record. Names that do come out of a record or a
668
+ * manifest (`pptxFile`, `themeFile`, a page's `file`) are joined onto the
669
+ * directory this returns, after `isSafeFileName` has had them, so they cannot
670
+ * lead anywhere else. That is the single property both the lookups and the
671
+ * one deletion rest on, so it is worth stating plainly: `join(root, id)` with
672
+ * `id` matching `ID_PATTERN` cannot leave `root`, because the pattern admits
673
+ * no `.` and no separator.
674
+ *
675
+ * Note what is deliberately *not* checked anywhere in this module: the tool's
676
+ * `target`. A user may legitimately ask for `../deck`, and the CLI resolves
677
+ * exactly that. The id is the value that becomes a directory name, so the id
678
+ * is the value with a shape to enforce.
679
+ */
680
+ function previewDir(root, id) {
681
+ if (typeof id !== 'string' || !ID_PATTERN.test(id)) return undefined
682
+ return join(root, id)
683
+ }
684
+
685
+ /** Same, for the paths that must fail loudly rather than quietly resolving to nothing. */
686
+ function requirePreviewDir(root, id) {
687
+ const dir = previewDir(root, id)
688
+ if (!dir) throw new Error(`refusing to write a preview record for an unsafe id: ${id}`)
689
+ return dir
690
+ }
691
+
692
+ /**
693
+ * Where a preview is built before it is published under its own id.
694
+ *
695
+ * Derived from `requirePreviewDir` rather than from the raw id, so the shape
696
+ * check guards the staging path too — otherwise the one directory this module
697
+ * creates and deletes would be the one path that skipped it.
698
+ */
699
+ function partialDir(root, id) {
700
+ return `${requirePreviewDir(root, id)}${PARTIAL_SUFFIX}`
701
+ }
702
+
703
+ /**
704
+ * Create a preview's staging directory, and stamp it as this call's to remove.
705
+ *
706
+ * Not `recursive` on the leaf, and that is the point of the two calls: a
707
+ * directory already sitting at this name belongs to something else, and
708
+ * `mkdir -p` would silently adopt it — after which the failure path would
709
+ * delete somebody else's files with a marker this call had just written into
710
+ * them. `EEXIST` is the right answer, and it is unreachable in practice
711
+ * because the name carries a freshly generated uuid.
712
+ *
713
+ * 0o700 because a deck is the user's own work and this now lives in their home
714
+ * directory rather than in the per-user temp directory it used to. Ignored on
715
+ * Windows, and applied only to directories the call actually creates.
716
+ */
717
+ async function createOwnedDir(root, dir) {
718
+ await mkdir(root, { recursive: true, mode: 0o700 })
719
+ await mkdir(dir, { mode: 0o700 })
720
+ try {
721
+ await writeFile(join(dir, OWNER_MARKER), JSON.stringify({ tool: TOOL_NAME, created: Date.now() }))
722
+ } catch (error) {
723
+ // The window this closes is narrow and permanent: the directory exists and
724
+ // its proof of ownership does not, so every later cleanup — which asks for
725
+ // that proof before deleting anything — would refuse it for ever. A full
726
+ // disk while writing fifty bytes is enough to reach it.
727
+ //
728
+ // This is the one moment ownership can be established without the marker,
729
+ // and that is what makes the removal safe: the `mkdir` above is
730
+ // non-recursive, so it succeeded only by creating this directory, and
731
+ // nothing else has been given its name yet.
732
+ await rm(dir, { recursive: true, force: true }).catch(() => {})
733
+ throw error
734
+ }
735
+ }
736
+
737
+ /**
738
+ * Remove a directory this module built, and leave alone anything it did not.
739
+ *
740
+ * Best-effort: a staging directory that cannot be tidied up is disk the user
741
+ * can see and delete, whereas a cleanup that throws would replace the render's
742
+ * own error — the one that says why the deck failed — with a filesystem
743
+ * complaint about the wreckage.
744
+ */
745
+ async function discardOwnedDir(dir) {
746
+ if (typeof dir !== 'string' || dir === '') return
747
+ if (!(await isFile(join(dir, OWNER_MARKER)))) return
748
+ await rm(dir, { recursive: true, force: true }).catch(() => {})
749
+ }
750
+
751
+ /**
752
+ * A file a record names inside its own preview directory, or nothing.
753
+ *
754
+ * A record is JSON on disk, so the names in it are data, and two of them
755
+ * (`pptxFile`, `themeFile`) get joined onto a path and served to a browser.
756
+ * Basename-only is the whole rule: `..`, a separator, or an absolute path is
757
+ * refused rather than resolved, so the worst a corrupted or hand-edited record
758
+ * can do is point at another file in the same preview directory, or at one that
759
+ * is not there — which the route already answers with a 410. What it cannot do
760
+ * is point outside.
761
+ */
762
+ function fileInside(dir, name) {
763
+ if (!isSafeFileName(name)) return undefined
764
+ return join(dir, name)
765
+ }
766
+
767
+ /**
768
+ * Is this a plain file name — something that names a file inside a directory
769
+ * and cannot name anything outside it?
770
+ *
771
+ * Split out of `fileInside` so the *rule* can be applied where no path is being
772
+ * built. That distinction is not academic: the manifest's page names used to be
773
+ * checked only when a page's markup was about to be read, which meant the first
774
+ * twelve pages of a deck were validated and the rest were not. A thirteen-page
775
+ * manifest with `"file": "../../escape.svg"` on page thirteen was accepted, and
776
+ * the bad name travelled to the card inside the bundle.
777
+ *
778
+ * Validation exists to refuse bad data, not to keep the renderer from
779
+ * tripping over it. Whether this process happens to read a value is not a
780
+ * reason to decide whether the value is allowed.
781
+ */
782
+ function isSafeFileName(name) {
783
+ if (typeof name !== 'string' || name === '' || name === '.' || name === '..') return false
784
+ // Control characters, and NUL above all. A name is checked here so that
785
+ // whatever is built from it is a path the filesystem will take seriously;
786
+ // a name holding a NUL byte passed every other clause and then made `readFile`
787
+ // throw `ERR_INVALID_ARG_VALUE`, which is not a filesystem error at all and was
788
+ // being reported as a temporary one. Refuse the name and it never gets that
789
+ // far.
790
+ // Matching control characters is the entire point of this line.
791
+ // eslint-disable-next-line no-control-regex
792
+ if (/[\u0000-\u001f\u007f]/.test(name)) return false
793
+ return !/[\\/]/.test(name) && !isAbsolute(name)
794
+ }
795
+
796
+ /**
797
+ * The absolute paths a preview's own directory implies, layered over the
798
+ * record's few stored fields.
799
+ *
800
+ * Nothing about where a preview's files are is stored, because storing it is
801
+ * what broke: an absolute path written into a record in 2026 is a claim about
802
+ * a machine's filesystem that no later version, upgrade or home-directory move
803
+ * can keep. Derived instead, the whole tree relocates for free, and a record
804
+ * from an older layout — which carried `outDir`, `snapshot` and `pptxPath` as
805
+ * absolute paths into `$TMPDIR` — has those fields overwritten here rather
806
+ * than followed. That is the entire legacy-format story: old fields are
807
+ * shadowed, not trusted, and an old id simply is not in this root, so it
808
+ * reaches the user as the missing-deck card the client already draws.
809
+ */
810
+ function entryFromRecord(dir, record) {
811
+ return {
812
+ ...record,
813
+ outDir: dir,
814
+ snapshot: join(dir, SNAPSHOT_FILE),
815
+ themeFile: fileInside(dir, record.themeFile),
816
+ pptxPath: fileInside(dir, record.pptxFile),
817
+ }
818
+ }
819
+
820
+ /**
821
+ * The few fields a preview directory cannot imply about itself.
822
+ *
823
+ * Which target the user asked for, what the export is called, and why there is
824
+ * none. Every path this module *derives* is dropped here and re-built by
825
+ * `entryFromRecord`, so memory and disk cannot end up describing two different
826
+ * decks. `target` is the exception and stays as the user typed it, absolute or
827
+ * not: it records what was asked for rather than where anything is now, and
828
+ * nothing reads it back as a path.
829
+ */
830
+ function recordFrom(entry) {
831
+ return {
832
+ target: String(entry.target ?? ''),
833
+ themeFile: entry.themeFile ? basename(entry.themeFile) : undefined,
834
+ pptxFile: entry.pptxPath ? basename(entry.pptxPath) : undefined,
835
+ pptxError: entry.pptxError,
836
+ created: Date.now(),
837
+ }
838
+ }
839
+
840
+ /**
841
+ * Write a record into a directory that already exists.
842
+ *
843
+ * Published by rename, which is atomic: a reader sees the old file or the new
844
+ * one, never half of either. Same caveat as `PARTIAL_SUFFIX` — that is about
845
+ * what a reader can see, not about what survives a power cut. This replaced a
846
+ * single shared JSON index, whose
847
+ * read-modify-write meant two previews finishing at once lost one of them, and
848
+ * a reader catching the file mid-write fell back to `{}` and then overwrote
849
+ * everything in it. The scratch name carries its own uuid so two writers for
850
+ * one id cannot collide on the scratch file either.
851
+ *
852
+ * Takes a directory rather than an id because `execute` writes this into
853
+ * `<id>.partial` — the record has to be inside the directory being published,
854
+ * or the publish would not be one step.
855
+ */
856
+ async function writeRecordInto(dir, record) {
857
+ const scratch = join(dir, `.${RECORD_FILE}.${randomUUID()}.tmp`)
858
+ await writeFile(scratch, JSON.stringify(record))
859
+ await rename(scratch, join(dir, RECORD_FILE))
860
+ }
861
+
862
+ /**
863
+ * The same, addressed by id.
864
+ *
865
+ * The id is checked here, at the write boundary, and not only where records
866
+ * are read: `remember` is an exported entry point, and before this check
867
+ * existed `remember("../../victim", …)` resolved straight out of the root. The
868
+ * plugin itself only ever passes a `randomUUID`, so nothing in production
869
+ * reached it, but the guarantee was claimed before it was true.
870
+ */
871
+ async function writeRecord(root, id, record) {
872
+ const dir = requirePreviewDir(root, id)
873
+ await mkdir(dir, { recursive: true, mode: 0o700 })
874
+ await writeRecordInto(dir, record)
875
+ }
876
+
877
+ /**
878
+ * The record for an id, or nothing — with "nothing" meaning only one thing.
879
+ *
880
+ * Reading and parsing are separate steps here because they fail for opposite
881
+ * reasons and deserve opposite answers. A read that fails is about the disk; a
882
+ * parse that fails is about the bytes, which will not improve on a second
883
+ * look. Wrapping both in one `catch` is what let a permission error report
884
+ * itself as an id that had never existed.
885
+ */
886
+ async function readRecord(root, id) {
887
+ const dir = previewDir(root, id)
888
+ if (!dir) return undefined
889
+ const path = join(dir, RECORD_FILE)
890
+ let raw
891
+ try {
892
+ raw = await readFile(path, 'utf8')
893
+ } catch (error) {
894
+ const failure = classifyReadFailure(error)
895
+ if (failure === 'missing') return undefined
896
+ if (failure === 'unreadable') {
897
+ throw new PreviewUnreadable(
898
+ `this preview's record could not be read right now: ${describeUnreadable(error, path)}`,
899
+ )
900
+ }
901
+ // Fixed bad data rather than a bad moment: a `record.json` that is really a
902
+ // directory, a name the filesystem will not accept. Retrying is pointless,
903
+ // and saying so is the difference between a card that offers a button that
904
+ // works and one that offers a button that cannot.
905
+ throw new PreviewDamaged(`this preview's record cannot be read: ${describeUnreadable(error, path)}`)
906
+ }
907
+ let record
908
+ try {
909
+ record = JSON.parse(raw)
910
+ } catch {
911
+ // Truncated by a full disk, or hand-edited. Final, not retryable: the same
912
+ // bytes parse the same way for ever. But it is emphatically not "no such
913
+ // preview" — the file is right there, and saying otherwise blames the user
914
+ // for a write this program failed to finish.
915
+ throw new PreviewDamaged(`this preview's record is present but unreadable: ${path}`)
916
+ }
917
+ // A record has to be an object before anything reads fields off it: this file
918
+ // is on disk between runs, and `JSON.parse` will happily hand back a number
919
+ // or null. Written by an older or newer layout, or by something else
920
+ // entirely — either way this code cannot act on it, and it should say which
921
+ // of the two it is rather than pretend the id is unknown.
922
+ if (!record || typeof record !== 'object' || Array.isArray(record)) {
923
+ throw new PreviewDamaged(`this preview's record is not in a shape this version understands: ${path}`)
924
+ }
925
+ // The fields this module actually reads, checked for type when present.
926
+ // Validating only the outer shape left a `pptxFile` of the wrong type to be
927
+ // quietly turned into `undefined` by `fileInside`, and reported to the user
928
+ // as "this preview has no exported deck" — a statement about the deck, made
929
+ // on the strength of a record this version could not read. Absent is a fact;
930
+ // present-and-wrong-type is damage, and they must not look the same.
931
+ //
932
+ // Fields an older layout wrote and this one ignores (`outDir`, `snapshot`,
933
+ // `pptxPath`) are deliberately not checked: they are shadowed rather than
934
+ // read, so their type cannot affect anything.
935
+ for (const field of ['target', 'themeFile', 'pptxFile', 'pptxError']) {
936
+ if (record[field] !== undefined && typeof record[field] !== 'string') {
937
+ throw new PreviewDamaged(`this preview's record has an unusable "${field}": ${path}`)
938
+ }
939
+ }
940
+ if (record.created !== undefined && typeof record.created !== 'number') {
941
+ throw new PreviewDamaged(`this preview's record has an unusable "created": ${path}`)
942
+ }
943
+ return record
944
+ }
945
+
946
+ function resolveCliCommand() {
947
+ if (process.versions.electron) {
948
+ return process.env.npm_node_execpath || 'node'
949
+ }
950
+ return process.execPath
951
+ }
952
+
953
+ function cliChildEnv() {
954
+ return { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
955
+ }
956
+
957
+ /** Run the packaged CLI, resolving with its combined output.
958
+ *
959
+ * GitHub issue #1: two libvips in one Electron process crash the renderer.
960
+ * The plugin must never import() the CLI. Electron's process.execPath is not
961
+ * a Node binary, so the child is a real node when inside Electron, and
962
+ * ELECTRON_RUN_AS_NODE is always set so an Electron fallback cannot boot as
963
+ * an app.
964
+ */
965
+ function runCli(cliPath, args, signal) {
966
+ return runChild(resolveCliCommand(), [cliPath, ...args], {
967
+ env: cliChildEnv(),
968
+ signal,
969
+ }).then(({ code, stdout, stderr }) => {
970
+ if (code === 0) return { stdout, stderr }
971
+ throw new Error(stderr.trim() || stdout.trim() || `pptwise exited with code ${code}`)
972
+ })
973
+ }
974
+
975
+ /**
976
+ * Is there a directory here, nothing here, or something this process cannot
977
+ * look at?
978
+ *
979
+ * Three answers rather than the two `isDirectory` gives, because the caller has
980
+ * to tell "no such preview" from "a preview that lost a file" from "ask again
981
+ * later", and a boolean collapses the last one into whichever of the first two
982
+ * it was standing next to. `'other'` — a plain file sitting where a preview
983
+ * directory should be — is reported as its own thing and treated as absent by
984
+ * the caller: whatever it is, it is not a preview.
985
+ */
986
+ async function directoryState(path) {
987
+ try {
988
+ return (await stat(path)).isDirectory() ? 'directory' : 'other'
989
+ } catch (error) {
990
+ // The same three-way split every other read uses. This branch is reachable
991
+ // for its own reasons, not as an echo of the read before it: a permission
992
+ // bit that changes between the two answers here and not there, because they
993
+ // are separate system calls with a filesystem free to move underneath them.
994
+ // An earlier round called it unreachable by construction; that was an
995
+ // assumption about ordering, not a fact about it.
996
+ const failure = classifyReadFailure(error)
997
+ if (failure === 'missing') return 'absent'
998
+ if (failure === 'unreadable') {
999
+ throw new PreviewUnreadable(
1000
+ `this preview's directory could not be read right now: ${describeUnreadable(error, path)}`,
1001
+ )
1002
+ }
1003
+ throw new PreviewDamaged(`this preview's directory cannot be used: ${describeUnreadable(error, path)}`)
1004
+ }
1005
+ }
1006
+
1007
+ async function isDirectory(path) {
1008
+ try {
1009
+ return (await stat(path)).isDirectory()
1010
+ } catch {
1011
+ return false
1012
+ }
1013
+ }
1014
+
1015
+ async function isFile(path) {
1016
+ try {
1017
+ return (await stat(path)).isFile()
1018
+ } catch {
1019
+ return false
1020
+ }
1021
+ }
1022
+
1023
+ /**
1024
+ * Where a deck project directory target actually lives, for the one thing
1025
+ * `assemble` cannot carry into the snapshot: a deck-local `theme.json`.
1026
+ *
1027
+ * The CLI auto-loads that file for a deck *directory* only, so an assembled
1028
+ * IR naming a brand theme would fail to render from anywhere else. Path-form
1029
+ * targets and bare names under the default decks root are both covered here.
1030
+ * A bare name under a project-configured `decksDir` is not — that combination
1031
+ * fails loudly on an unknown theme id rather than rendering the wrong thing,
1032
+ * which is the disposition the rest of this module takes too.
1033
+ */
1034
+ async function locateDeckDir(target) {
1035
+ const direct = resolve(target)
1036
+ if (await isDirectory(direct)) return direct
1037
+ // `dirname(previewRoot())` rather than a second hard-coded `~/.pptwise`, so
1038
+ // this follows `PPTWISE_HOME` the way the CLI's own `decksRoot()` does. The
1039
+ // previous literal ignored that variable and looked in the wrong home
1040
+ // whenever it was set.
1041
+ const named = join(dirname(previewRoot()), 'decks', target)
1042
+ if (await isDirectory(named)) return named
1043
+ return undefined
1044
+ }
1045
+
1046
+ /**
1047
+ * Copy a single-file IR target, rewriting every local asset src to an
1048
+ * absolute path.
1049
+ *
1050
+ * A relative src in an IR file resolves against that file's own directory
1051
+ * (the CLI's `loadDeckTarget`), so a byte-for-byte copy into the preview directory
1052
+ * would quietly lose every image. `assemble` performs the same rewrite for
1053
+ * the directory case; this is its single-file counterpart.
1054
+ */
1055
+ async function snapshotIrFile(target, snapshotPath) {
1056
+ const raw = await readFile(target, 'utf8')
1057
+ const baseDir = dirname(resolve(target))
1058
+ let ir
1059
+ try {
1060
+ ir = JSON.parse(raw)
1061
+ } catch {
1062
+ // Not this module's error to explain. Hand the file to the CLI unchanged
1063
+ // and let its own parser produce the message the user should see.
1064
+ await writeFile(snapshotPath, raw)
1065
+ return
1066
+ }
1067
+ const images = ir?.assets?.images
1068
+ if (images && typeof images === 'object') {
1069
+ for (const asset of Object.values(images)) {
1070
+ const src = asset?.src
1071
+ if (typeof src !== 'string') continue
1072
+ if (src.startsWith('data:') || /^https?:\/\//.test(src) || isAbsolute(src)) continue
1073
+ asset.src = resolve(baseDir, src)
1074
+ }
1075
+ }
1076
+ await writeFile(snapshotPath, JSON.stringify(ir))
1077
+ }
1078
+
1079
+ /**
1080
+ * Extension -> mime, for the formats a data URI can carry straight through
1081
+ * both the preview renderer and the export. Deliberately the same four the
1082
+ * CLI's own `resolveLocalAssets` recognizes by extension (`src/cli/load-ir.ts`).
1083
+ */
1084
+ const MIME_BY_EXT = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif' }
1085
+
1086
+ /**
1087
+ * What these bytes actually are, by magic number — never by filename.
1088
+ *
1089
+ * Trusting the extension here would quietly undo a check the CLI makes on
1090
+ * purpose: it rejects a file whose header disagrees with its name rather than
1091
+ * relabelling it, because a media part whose declared type and real bytes
1092
+ * disagree is exactly what the package audit cannot see. Returning null for
1093
+ * anything unrecognized keeps that judgement where it already lives.
1094
+ */
1095
+ function sniffImageMime(bytes) {
1096
+ if (bytes.length >= 8 && bytes.readUInt32BE(0) === 0x89504e47 && bytes.readUInt32BE(4) === 0x0d0a1a0a) {
1097
+ return 'image/png'
1098
+ }
1099
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
1100
+ if (bytes.length >= 6 && bytes.toString('latin1', 0, 6).match(/^GIF8[79]a$/)) return 'image/gif'
1101
+ return null
1102
+ }
1103
+
1104
+ /**
1105
+ * Replace local image paths in the snapshot with the bytes they point at.
1106
+ *
1107
+ * This is the other half of pinning a deck. `execute` runs the CLI twice —
1108
+ * once for the preview, once for the export — and between those two processes
1109
+ * there is a real window: an agent regenerating a logo, a designer saving over
1110
+ * a file, a build step rewriting `assets/`. A snapshot that names images by
1111
+ * path lets each run resolve them independently, so the deck on screen and the
1112
+ * file the user downloads could genuinely be built from different pictures,
1113
+ * with nothing anywhere to say so. Inlined, neither run reads those files at
1114
+ * all.
1115
+ *
1116
+ * Deliberately narrow, and these are the edges it does not cover:
1117
+ *
1118
+ * - `http(s)` sources stay URLs. Fetching them here would mean a second
1119
+ * fetcher, a second cache policy and a second failure vocabulary next to
1120
+ * the one the export pipeline already has (`src/platform/inline-assets.ts`),
1121
+ * and a remote asset can change under a stable URL regardless.
1122
+ * - formats needing a recode (webp and friends) stay paths. Turning those
1123
+ * into something PowerPoint accepts is a sharp/canvas job, and this file is
1124
+ * dependency-free by design; the CLI already owns that decode, and owning
1125
+ * it twice is how two renderers start disagreeing.
1126
+ * - a file that fails a check — unreadable, empty, header not matching its
1127
+ * extension — is left as a path on purpose, so the CLI raises its own
1128
+ * precise error instead of this function inventing a worse one.
1129
+ *
1130
+ * Anything left as a path keeps the old exposure, which is why the cases are
1131
+ * listed rather than waved at.
1132
+ */
1133
+ async function inlineLocalImages(snapshotPath) {
1134
+ let ir
1135
+ try {
1136
+ ir = JSON.parse(await readFile(snapshotPath, 'utf8'))
1137
+ } catch {
1138
+ // Not valid JSON, so not this function's file to rewrite — the CLI's own
1139
+ // parser owns that error message.
1140
+ return
1141
+ }
1142
+ const images = ir?.assets?.images
1143
+ if (!images || typeof images !== 'object') return
1144
+
1145
+ let changed = false
1146
+ await Promise.all(
1147
+ Object.values(images).map(async (asset) => {
1148
+ const src = asset?.src
1149
+ if (typeof src !== 'string' || src === '') return
1150
+ if (src.startsWith('data:') || /^https?:\/\//.test(src)) return
1151
+ let bytes
1152
+ try {
1153
+ bytes = await readFile(src)
1154
+ } catch {
1155
+ return
1156
+ }
1157
+ if (bytes.length === 0) return
1158
+ const sniffed = sniffImageMime(bytes)
1159
+ if (!sniffed) return
1160
+ // An extension the CLI knows must agree with the bytes. When it does
1161
+ // not, the deck is already broken and the CLI says so precisely; when
1162
+ // the extension is unknown to it (webp and friends), the recode path
1163
+ // owns the file and inlining would take it away from there.
1164
+ const declared = MIME_BY_EXT[(src.match(/\.[^.\\/]+$/)?.[0] || '').toLowerCase()]
1165
+ if (declared !== sniffed) return
1166
+ asset.src = `data:${sniffed};base64,${bytes.toString('base64')}`
1167
+ changed = true
1168
+ }),
1169
+ )
1170
+ if (changed) await writeFile(snapshotPath, JSON.stringify(ir))
1171
+ }
1172
+
1173
+ /**
1174
+ * Pin the target to one immutable deck, written into `outDir`.
1175
+ *
1176
+ * This is what makes the preview, the export and every later recall the same
1177
+ * deck. They used to be three independent readings of the user's target: the
1178
+ * user previewed deck A, edited a page, hit download and got deck B, and a
1179
+ * card reopened next week re-rendered whatever the target had become. Now the
1180
+ * target is read exactly once, here, and nothing downstream ever touches it
1181
+ * again.
1182
+ *
1183
+ * `assemble` rewrites local image paths, it does not inline them, so a
1184
+ * snapshot on its own pins *which* deck rather than the bytes it is made of —
1185
+ * which left every later run free to re-read those files. `inlineLocalImages`
1186
+ * closes that for the formats it can (see its own note for the ones it
1187
+ * cannot).
1188
+ */
1189
+ async function captureSnapshot(cliPath, target, outDir, signal) {
1190
+ const snapshot = join(outDir, SNAPSHOT_FILE)
1191
+ if (await isFile(target)) {
1192
+ await snapshotIrFile(target, snapshot)
1193
+ await inlineLocalImages(snapshot)
1194
+ return { snapshot, themeFile: undefined }
1195
+ }
1196
+ await runCli(cliPath, ['assemble', target, '-o', snapshot], signal)
1197
+ await inlineLocalImages(snapshot)
1198
+ const deckDir = await locateDeckDir(target)
1199
+ if (deckDir) {
1200
+ const source = join(deckDir, THEME_FILE)
1201
+ if (await isFile(source)) {
1202
+ const themeFile = join(outDir, THEME_FILE)
1203
+ await writeFile(themeFile, await readFile(source, 'utf8'))
1204
+ return { snapshot, themeFile }
1205
+ }
1206
+ }
1207
+ return { snapshot, themeFile: undefined }
1208
+ }
1209
+
1210
+ function themeArgs(record) {
1211
+ return record.themeFile ? ['--theme-file', record.themeFile] : []
1212
+ }
1213
+
1214
+ /**
1215
+ * Read what the card's thumbnail strip needs: the manifest, plus the SVG of
1216
+ * every page the strip will actually draw, inlined so the card needs no
1217
+ * filesystem access of its own (it runs in a browser).
1218
+ *
1219
+ * Every page keeps its metadata. `svg: null` means "this page is past the end
1220
+ * of the strip", never "this page failed" — the deck in full is one route away
1221
+ * (`preview.html`), so a page without markup here is not a page the user
1222
+ * cannot see. There is deliberately no flag saying the markup was cut short:
1223
+ * the strip is a fixed-length teaser by design, and a card that announced
1224
+ * "preview shortened" for every deck over twelve pages would be reporting the
1225
+ * design as a defect.
1226
+ */
1227
+ async function readPreviewBundle(outDir) {
1228
+ const manifestPath = join(outDir, MANIFEST_FILE)
1229
+ const manifest = parseManifest(await readFile(manifestPath, 'utf8'), manifestPath)
1230
+ const pages = []
1231
+ for (const page of manifest.pages) {
1232
+ // Every name in this manifest was checked by `parseManifest`, for every
1233
+ // page, before this loop began — so the join below cannot leave `outDir`
1234
+ // and this loop has no validation left to do. Reading a name only when it
1235
+ // is about to be used is what let the pages past the strip through
1236
+ // unchecked.
1237
+ const drawn = pages.length < THUMBNAIL_STRIP_PAGES
1238
+ pages.push({ ...page, svg: drawn ? await readFile(join(outDir, page.file), 'utf8') : null })
1239
+ }
1240
+ // `draft` travels with the bundle rather than with the record, so a card
1241
+ // reopened after a restart still says so: `recallAnywhere` rebuilds the
1242
+ // bundle from this manifest, and the manifest is where the unfilled pages
1243
+ // are named in the first place.
1244
+ return { ...manifest, pages, draft: pages.some((p) => p.placeholder === true) }
1245
+ }
1246
+
1247
+ /**
1248
+ * A manifest, or a clear statement that this one is not usable.
1249
+ *
1250
+ * Checked rather than trusted for the same reason the record is: it is a file
1251
+ * on disk between runs, so it can be half-written, hand-edited, or produced by
1252
+ * a version whose shape this one does not know. Every one of those used to
1253
+ * arrive as a `TypeError` on `manifest.pages` — which the layer above turned
1254
+ * into "the rendered deck is no longer complete", telling the user a file had
1255
+ * been deleted when nothing had.
1256
+ */
1257
+ function parseManifest(raw, path) {
1258
+ let manifest
1259
+ try {
1260
+ manifest = JSON.parse(raw)
1261
+ } catch {
1262
+ throw new PreviewDamaged(`${path} is present but unreadable`)
1263
+ }
1264
+ if (!manifest || typeof manifest !== 'object' || !Array.isArray(manifest.pages)) {
1265
+ throw new PreviewDamaged(`${path} is not in a shape this version understands`)
1266
+ }
1267
+ // Every element, not just the array around them. Checking the container and
1268
+ // then trusting its contents left `{"pages":[null]}` — legal JSON, and a
1269
+ // shape a half-written file can genuinely have — to throw a `TypeError` on
1270
+ // `page.file` two lines later, which the layer above dressed up as "a file
1271
+ // has been deleted". Validating the outside of a structure and reaching
1272
+ // straight into the inside is the same bug as not validating at all.
1273
+ for (const [index, page] of manifest.pages.entries()) {
1274
+ if (!page || typeof page !== 'object' || Array.isArray(page)) {
1275
+ throw new PreviewDamaged(`${path} has a page ${index + 1} this version cannot read`)
1276
+ }
1277
+ // Every page, not only the ones whose markup this call is about to read.
1278
+ // Checking a name at the moment it becomes a path meant the check followed
1279
+ // the reader: the thumbnail strip reads twelve pages, so pages thirteen and
1280
+ // up were never looked at, and `"file": "../../escape.svg"` on page
1281
+ // thirteen came back in the bundle unchallenged.
1282
+ if (!isSafeFileName(page.file)) {
1283
+ throw new PreviewDamaged(`${path} names a page ${index + 1} file this version will not open: ${page.file}`)
1284
+ }
1285
+ }
1286
+ return manifest
1287
+ }
1288
+
1289
+ /**
1290
+ * Name what is wrong with a preview directory whose record survived.
1291
+ *
1292
+ * This becomes the body of a 410, and the difference between "your preview is
1293
+ * gone" and "your preview is gone because `001.svg` is not there" is the
1294
+ * difference between a dead end and something the user can look at. Node's
1295
+ * filesystem errors carry the path they were about, which is exactly the fact
1296
+ * worth forwarding — and this is a message about the user's own home
1297
+ * directory, on a route the harness serves locally, so there is nothing here
1298
+ * worth withholding. Nothing in this file enforces that locality: it comes from
1299
+ * whatever `ctx.webServer` binds to, which is a thing to check again if a
1300
+ * harness ever binds outward.
1301
+ *
1302
+ * "is missing" is asserted only when the error says nothing is there, which is
1303
+ * `ENOENT` and the `ENOTDIR` carrying the same fact one component up. Saying it
1304
+ * for every error that happened to carry a path is how an `EACCES` came to be
1305
+ * reported as a deletion — a claim about the user's own actions, made on no
1306
+ * evidence, and one the card acts on by retiring the deck.
1307
+ */
1308
+ function describeIncomplete(error, dir) {
1309
+ if (isAbsent(error) && typeof error.path === 'string') return `${error.path} is missing`
1310
+ if (error instanceof PreviewDamaged) return error.message
1311
+ return `${join(dir, MANIFEST_FILE)} could not be read`
1312
+ }
1313
+
1314
+ /**
1315
+ * The filename the browser will save the export under.
1316
+ *
1317
+ * Computed at render time, not at download time, because the file now exists
1318
+ * on disk before anyone asks for it. Everything outside `\w.-` collapses to a
1319
+ * dash so the value is safe both as a path segment and inside a quoted
1320
+ * `content-disposition` header.
1321
+ *
1322
+ * A deck with unfilled pages says so in its filename. The card already carries
1323
+ * a badge, but the file outlives the card: it gets mailed, uploaded and opened
1324
+ * by people who never saw this conversation, and `-draft` is the one part of
1325
+ * it that travels with the bytes.
1326
+ */
1327
+ function exportName(bundle, target) {
1328
+ const raw =
1329
+ (bundle && bundle.title) || String(target).split(/[\\/]/).pop().replace(/\.[^.]+$/, '') || 'deck'
1330
+ const safe = raw.replace(/[^\w.-]+/g, '-').replace(/^[.-]+/, '')
1331
+ return `${safe || 'deck'}${bundle && bundle.draft ? '-draft' : ''}.pptx`
1332
+ }
1333
+
1334
+ /** One short model-facing line — never the markup. */
1335
+ function modelSummary(value) {
1336
+ const bits = [`rendered ${value.pageCount} page${value.pageCount === 1 ? '' : 's'} to ${value.outDir}`]
1337
+ // The card finds the deck by this id. It has to travel in model-facing
1338
+ // text because that is the only part of a sub-call's result the card is
1339
+ // guaranteed to see — see this module's own header for why the structured
1340
+ // channel was not an option.
1341
+ bits.unshift(`pptwise-preview:${value.previewId}`)
1342
+ // The model is the one who can act on this: the pages are still unfilled,
1343
+ // and the export it just handed the user is labelled a draft.
1344
+ if (value.bundle && value.bundle.draft) bits.push('draft — some pages are unfilled placeholders')
1345
+ if (value.findingCount > 0) bits.push(`${value.findingCount} audit finding${value.findingCount === 1 ? '' : 's'}`)
1346
+ else if (value.audited) bits.push('audit clean')
1347
+ else bits.push('audit skipped')
1348
+ bits.push('the user can page through it in this card — do not tell them to open a URL')
1349
+ return bits.join(' · ')
1350
+ }
1351
+
1352
+ /**
1353
+ * One preview service: a tool, the route its card fetches from, and the
1354
+ * decks the two share.
1355
+ *
1356
+ * Everything the pair needs is captured here rather than at module scope.
1357
+ * The CLI path in particular: it used to be a module-level variable the tool
1358
+ * factory assigned, so building a second tool re-pointed the route the first
1359
+ * one had already registered at the first one's CLI — silent, and wrong in
1360
+ * exactly the situations (reload, second profile, test) where it matters.
1361
+ */
1362
+ export function createPreviewService(cliPath) {
1363
+ /**
1364
+ * The shared preview root, deliberately shared.
1365
+ *
1366
+ * Two services on one machine — a reload, a second profile, two DSH windows,
1367
+ * an old plugin version and a new one side by side — now read and write the
1368
+ * same directory, which is the reverse of what this file used to do. It is
1369
+ * also the point: an upgraded plugin has to find the previews the old one
1370
+ * rendered, and "the same user's own decks" is not a boundary worth
1371
+ * defending. Ids are UUIDs, so the only way to name a deck is to already
1372
+ * have its transcript. Concurrent writers never share a file (one directory
1373
+ * per id, records published by rename), and no service deletes anything
1374
+ * another one could be reading.
1375
+ *
1376
+ * Captured once here rather than re-read per call, so no lookup can relocate
1377
+ * under its own route mid-session. The notice page and `locateDeckDir` still
1378
+ * read the environment when they run, which only matters to a process that
1379
+ * moves its own home while running.
1380
+ */
1381
+ const root = previewRoot()
1382
+
1383
+ // THE DISK IS THE SOURCE OF TRUTH. THERE IS NO READ CACHE.
1384
+ //
1385
+ // There was one: `id -> bundle`, checked before the filesystem and returned
1386
+ // whole on a hit. It looked like a pure performance win and was a
1387
+ // correctness bug, because it made the answer to "does this preview exist"
1388
+ // depend on process state instead of on disk. Delete a preview directory and
1389
+ // the three routes disagreed on the spot: the bundle route answered 200 from
1390
+ // memory, `/html` and `/pptx` went to disk and answered 410, and the moment
1391
+ // the entry aged out of the map — or the harness restarted — all three
1392
+ // switched to 404. One fact, three answers, and which one you got depended on
1393
+ // how many other previews had been rendered since.
1394
+ //
1395
+ // That is fatal to the whole missing-state design, because the card is built
1396
+ // on those statuses meaning something stable: 404 and 410 are final and are
1397
+ // never re-asked. A cached 200 over a deleted deck is a card that draws
1398
+ // thumbnails for files that are not there and a download button that fails
1399
+ // when clicked.
1400
+ //
1401
+ // So every read goes to disk, every time. If a cache comes back it must be
1402
+ // an optimisation over the same answer — validated against disk on every hit,
1403
+ // never consulted in place of it — and the reason to want one is not obvious:
1404
+ // a bundle read is a manifest plus at most `THUMBNAIL_STRIP_PAGES` files, on
1405
+ // a local disk, a handful of times per session.
1406
+
1407
+ /**
1408
+ * Persist a preview under an id already chosen, and hand back the entry the
1409
+ * route will see.
1410
+ *
1411
+ * `execute` does not go through here — it writes its record into the staging
1412
+ * directory so that the whole preview publishes in one `rename`. What is left
1413
+ * is the entry point for a caller that has files in place and wants an id to
1414
+ * point at them, which is what the tests use it for.
1415
+ */
1416
+ async function remember(id, entry) {
1417
+ const dir = requirePreviewDir(root, id)
1418
+ const record = recordFrom(entry)
1419
+ // Awaited, not fired and forgotten: returning before the record lands means
1420
+ // handing out an id the disk has never heard of.
1421
+ await writeRecord(root, id, record)
1422
+ return { ...entryFromRecord(dir, record), bundle: entry.bundle }
1423
+ }
1424
+
1425
+ /**
1426
+ * Find a preview by id, on disk, every time.
1427
+ *
1428
+ * A card lives in a transcript, and a transcript outlives everything: the
1429
+ * user scrolls back to a session from last week, and the card has to show
1430
+ * the deck it showed then. Memory cannot do that — it dies with the process,
1431
+ * and DSH restarts on every plugin reload — so a historical session would
1432
+ * have rendered an empty card and a download that saved a 404 body. The
1433
+ * record survives the restart, and the rendered bundle is still sitting in
1434
+ * `outDir`, so a reload costs a re-read and nothing else.
1435
+ *
1436
+ * There used to be a third tier: when `outDir` was gone, re-render the deck
1437
+ * from the pinned snapshot. It is deliberately gone, for two reasons.
1438
+ * `captureSnapshot` writes the snapshot *into* `outDir`, so "the directory
1439
+ * is gone but the snapshot survives" was close to unreachable in the first
1440
+ * place. And a re-render today is not the deck this card is showing: it
1441
+ * reads whatever configuration, theme and image bytes exist now, through
1442
+ * whatever renderer version is installed now. It could not reproduce the
1443
+ * .pptx either, so keeping it would have left the card showing one deck and
1444
+ * the download button reporting the deck as gone. One honest answer beats
1445
+ * two halves that disagree.
1446
+ *
1447
+ * FIVE OUTCOMES, EXHAUSTIVE AND MUTUALLY EXCLUSIVE. They are what the
1448
+ * route's status codes are made of, so a real failure landing in the wrong
1449
+ * one is a lie told to the user, not a cosmetic slip. In the order this
1450
+ * function decides them:
1451
+ *
1452
+ * 1. the id is not a shape this module hands out -> undefined -> 404
1453
+ * `preview_unknown`. Nothing touches the filesystem.
1454
+ * 2. nothing usable at `<root>/<id>` — deleted wholesale, an id from the
1455
+ * old `$TMPDIR` layout that was never in this root, or something at that
1456
+ * name that is not a directory -> undefined -> 404 `preview_unknown`.
1457
+ * All three are the same answer to the card.
1458
+ * 3. the directory is there and `record.json` is not -> `PreviewExpired` ->
1459
+ * 410 `preview_missing`. This one used to fall into (2), which claimed
1460
+ * the id had never existed while its rendered pages were sitting right
1461
+ * there. A missing bookkeeping file is a preview that lost part of
1462
+ * itself, which is exactly what `missing` means.
1463
+ * 4. the record or the manifest is present and cannot be understood ->
1464
+ * `PreviewDamaged` -> 410 `preview_damaged`.
1465
+ * 5. anything this process could not read -> `PreviewUnreadable` -> 503
1466
+ * `preview_unreadable`.
1467
+ *
1468
+ * The split between (2) and (3) is a `stat` of the directory, and it is
1469
+ * errno-aware for the same reason everything else here is: a directory this
1470
+ * process cannot stat is (5) when a retry could fix the errno and (4) when it
1471
+ * could not. Never (2) — which is the half that matters, since (2) is the one
1472
+ * answer that retires a deck.
1473
+ *
1474
+ * Nothing is written on this path, and nothing is remembered from it, so the
1475
+ * answer is a function of the disk alone.
1476
+ */
1477
+ async function recallAnywhere(id) {
1478
+ const dir = previewDir(root, id)
1479
+ if (!dir) return undefined
1480
+
1481
+ const record = await readRecord(root, id)
1482
+ if (!record) {
1483
+ // Which of the two absences is this? "No such directory" and "a
1484
+ // directory that lost its record" are different facts about the user's
1485
+ // disk and deserve different answers.
1486
+ // Anything that is not a directory is not a preview, whatever it is, so
1487
+ // it answers the same way an empty root does.
1488
+ if ((await directoryState(dir)) !== 'directory') return undefined
1489
+ throw new PreviewExpired(
1490
+ `the rendered deck for this preview is no longer complete: ${join(dir, RECORD_FILE)} is missing`,
1491
+ )
1492
+ }
1493
+
1494
+ let bundle
1495
+ try {
1496
+ bundle = await readPreviewBundle(dir)
1497
+ } catch (error) {
1498
+ // Three outcomes, from the one classifier. A page that is not there is
1499
+ // final and is a deletion. A page this process could not open right now
1500
+ // is not final at all — answering that with a 410 would retire a whole
1501
+ // deck over a permission bit. Anything else is fixed bad data, and
1502
+ // reporting *that* as a deletion is the accusation this module keeps
1503
+ // having to be stopped from making.
1504
+ if (error instanceof PreviewDamaged) throw error
1505
+ const failure = classifyReadFailure(error)
1506
+ if (failure === 'unreadable') {
1507
+ throw new PreviewUnreadable(
1508
+ `this preview's rendered deck could not be read right now: ${describeUnreadable(error, dir)}`,
1509
+ )
1510
+ }
1511
+ if (failure === 'damaged') {
1512
+ throw new PreviewDamaged(
1513
+ `this preview's rendered deck cannot be read: ${describeUnreadable(error, dir)}`,
1514
+ )
1515
+ }
1516
+ throw new PreviewExpired(
1517
+ `the rendered deck for this preview is no longer complete: ${describeIncomplete(error, dir)}`,
1518
+ )
1519
+ }
1520
+ return { ...entryFromRecord(dir, record), bundle }
1521
+ }
1522
+
1523
+ /**
1524
+ * Serve a rendered deck to this plugin's own card.
1525
+ *
1526
+ * Loopback-only by the same reasoning modlens's routes use: this is a local
1527
+ * dev surface, and a deck the user just generated is theirs alone. The id
1528
+ * is random rather than sequential so a page on another origin cannot walk
1529
+ * the space even if it somehow reached the port.
1530
+ */
1531
+ function registerRoute(ctx) {
1532
+ ctx.webServer.register({
1533
+ name: 'pptwise-preview',
1534
+ kind: 'prefix',
1535
+ path: PREVIEW_ROUTE,
1536
+ handler: async (req, res) => {
1537
+ const rest = String(req.url || '').split(PREVIEW_ROUTE)[1]?.split('?')[0]?.replace(/^\//, '') ?? ''
1538
+ // `<id>`, `<id>/pptx` or `<id>/html`. Matched off the end so the id is
1539
+ // whatever is left, and then shape-checked before anything touches the
1540
+ // filesystem — a traversal attempt has to fail on all three paths, not
1541
+ // only on the bare one.
1542
+ const want = rest.endsWith('/pptx') ? 'pptx' : rest.endsWith('/html') ? 'html' : 'bundle'
1543
+ const id = want === 'bundle' ? rest : rest.slice(0, -(want.length + 1))
1544
+ // Every answer this handler writes carries the same stamp, success and
1545
+ // failure alike, and the card refuses to read a verdict off a response
1546
+ // without it. The reason is that a 404 is not self-identifying: a
1547
+ // plugin whose route failed to register, a proxy in front of the
1548
+ // harness, or a shell serving its own not-found page all produce one
1549
+ // that looks identical to ours. The card treats our 404 as final, so an
1550
+ // unstamped 404 must not be allowed to retire a deck that this module
1551
+ // was never even asked about.
1552
+ const send = (status, headers, body) => {
1553
+ res.writeHead(status, { ...headers, [ROUTE_HEADER]: ROUTE_HEADER_VALUE })
1554
+ res.end(body)
1555
+ }
1556
+ // One failure vocabulary, three statuses, two representations. The
1557
+ // status is the part the card acts on: 404/410 are verdicts about the
1558
+ // deck and are never re-asked, 5xx is a verdict about this request and
1559
+ // is. The page follows the status rather than the call site, so a
1560
+ // temporary failure can never reach a reader wearing the "rebuild it"
1561
+ // wording. Splitting the representation per branch instead is how the
1562
+ // html route once ended up answering a person with a JSON object.
1563
+ const fail = (status, code, message) => {
1564
+ if (want === 'html') {
1565
+ // Chosen from the code, never from the call site. Every branch that
1566
+ // reports a failure now names what kind it is, so the page a person
1567
+ // reads and the code a program reads cannot disagree — which they
1568
+ // did: the body said "present but unreadable" and the page said
1569
+ // "no longer on disk".
1570
+ const page = noticePageFor(code, message)
1571
+ send(
1572
+ status,
1573
+ { 'content-type': 'text/html; charset=utf-8', 'content-length': Buffer.byteLength(page) },
1574
+ page,
1575
+ )
1576
+ return
1577
+ }
1578
+ send(status, { 'content-type': 'application/json' }, JSON.stringify({ code, error: message }))
1579
+ }
1580
+ /** A file this route serves: gone is final, unreadable is not. */
1581
+ const failReading = (error, path, gonePhrase) => {
1582
+ const failure = classifyReadFailure(error)
1583
+ if (failure === 'unreadable') {
1584
+ fail(
1585
+ 503,
1586
+ FAILURE_CODES.unreadable,
1587
+ `this preview could not be read right now: ${describeUnreadable(error, path)}`,
1588
+ )
1589
+ return
1590
+ }
1591
+ if (failure === 'damaged') {
1592
+ fail(410, FAILURE_CODES.damaged, `this preview cannot be read: ${describeUnreadable(error, path)}`)
1593
+ return
1594
+ }
1595
+ fail(410, FAILURE_CODES.missing, gonePhrase)
1596
+ }
1597
+ let entry
1598
+ try {
1599
+ entry = ID_PATTERN.test(id) ? await recallAnywhere(id) : undefined
1600
+ } catch (error) {
1601
+ if (error instanceof PreviewUnreadable) {
1602
+ // 503, and specifically not 404 or 410. The preview may be entirely
1603
+ // intact; this process could not look at it. Since the card retires
1604
+ // a deck for good on a 4xx, spending one on a permission change or
1605
+ // a bad sector would lose a deck that is sitting right there — the
1606
+ // same mistake the client half was just fixed for, one layer down.
1607
+ fail(503, FAILURE_CODES.unreadable, error.message)
1608
+ return
1609
+ }
1610
+ if (error instanceof PreviewDamaged) {
1611
+ // 410, because re-reading the same bytes fails the same way for
1612
+ // ever — but under its own code, which is the part that was
1613
+ // missing. The status says "final"; the code says which kind of
1614
+ // final, and without it the card had no way to avoid telling the
1615
+ // user they had deleted a file that is sitting right there.
1616
+ fail(410, FAILURE_CODES.damaged, error.message)
1617
+ return
1618
+ }
1619
+ if (!(error instanceof PreviewExpired)) throw error
1620
+ // 410, not 404, and the distinction is one the card acts on. The id
1621
+ // named a directory that is still there and no longer whole, so the
1622
+ // message names what went missing. The alternative — a re-render of
1623
+ // today's version of the deck, passed off as the one in the card — is
1624
+ // what this whole module exists to refuse.
1625
+ fail(410, FAILURE_CODES.missing, error.message)
1626
+ return
1627
+ }
1628
+ if (!entry) {
1629
+ // Nothing at `<root>/<id>` at all: deleted wholesale, or written by a
1630
+ // version that kept its previews somewhere else. There is deliberately
1631
+ // no gravestone on disk to tell those apart — see the note on
1632
+ // expiry — so the honest answer is that this id is not here. The
1633
+ // card fills in the rest from the transcript, which still carries the
1634
+ // page count from the run that produced this id.
1635
+ fail(404, FAILURE_CODES.unknown, 'unknown preview id')
1636
+ return
1637
+ }
1638
+ if (want === 'bundle') {
1639
+ send(200, { 'content-type': 'application/json' }, JSON.stringify(entry.bundle))
1640
+ return
1641
+ }
1642
+ if (want === 'html') {
1643
+ // The viewer the card's modal loads in an iframe. Served straight
1644
+ // off disk exactly as `preview --html` wrote it: it already has the
1645
+ // filmstrip, the keyboard paging, the light/dark surround and the
1646
+ // audit panel, and it is the same file a harness with no plugin UI
1647
+ // is told to open. Reimplementing any of that in React here is how
1648
+ // the card ended up with a page-count budget, a stand-in for
1649
+ // "missing" pages and its own arrow keys, none of which the real
1650
+ // preview has.
1651
+ const htmlPath = join(entry.outDir, PREVIEW_HTML_FILE)
1652
+ let html
1653
+ try {
1654
+ html = await readFile(htmlPath)
1655
+ } catch (error) {
1656
+ // Same disposition as a missing .pptx: if the file is really not
1657
+ // there the id was real and the page is not, which is a 410 rather
1658
+ // than a 404 or a re-render — the iframe must never be handed a
1659
+ // page rebuilt out of today's configuration and passed off as the
1660
+ // deck in the card. But only for `ENOENT`: a file this process
1661
+ // could not open is a 503, or one unlucky permission bit would
1662
+ // retire the viewer for good.
1663
+ failReading(error, htmlPath, `the preview page for this preview is gone (${htmlPath})`)
1664
+ return
1665
+ }
1666
+ send(200, { 'content-type': 'text/html; charset=utf-8', 'content-length': html.length }, html)
1667
+ return
1668
+ }
1669
+ // Served, not rendered. This handler starts no process and writes
1670
+ // nothing — the .pptx was produced during the same `execute` that
1671
+ // produced the SVGs the user paged through, which is the closest the
1672
+ // two can get to being one deck — what that does and does not pin is
1673
+ // the list at the top of this file. It also means two
1674
+ // browsers hitting the same id at once are two readers of one file
1675
+ // rather than two renderers racing to write it.
1676
+ if (!entry.pptxPath) {
1677
+ // Either the export failed while the preview itself succeeded, or
1678
+ // this record predates exports being rendered up front. Both are
1679
+ // permanent for this id: there is no second render to fall back to.
1680
+ // `missing` rather than `damaged`: the deck itself is fine and the
1681
+ // card should keep drawing it — what is absent is the export.
1682
+ fail(
1683
+ 410,
1684
+ FAILURE_CODES.missing,
1685
+ entry.pptxError || 'this preview has no exported deck and cannot produce one now',
1686
+ )
1687
+ return
1688
+ }
1689
+ let bytes
1690
+ try {
1691
+ bytes = await readFile(entry.pptxPath)
1692
+ } catch (error) {
1693
+ // Same disposition as a missing bundle: if the file is really not
1694
+ // there, the id was real and the export is not. Re-rendering from the
1695
+ // snapshot would hand back a deck built from today's configuration
1696
+ // and today's image bytes, which is exactly the substitution this
1697
+ // design exists to prevent. `EACCES` and friends are a 503 instead —
1698
+ // the file is there, this process just could not open it.
1699
+ failReading(error, entry.pptxPath, `the exported deck for this preview is gone (${entry.pptxPath})`)
1700
+ return
1701
+ }
1702
+ send(
1703
+ 200,
1704
+ {
1705
+ 'content-type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
1706
+ 'content-disposition': `attachment; filename="${basename(entry.pptxPath)}"`,
1707
+ 'content-length': bytes.length,
1708
+ },
1709
+ bytes,
1710
+ )
1711
+ },
1712
+ })
1713
+ }
1714
+
1715
+ /**
1716
+ * Everything one `execute` writes into one preview directory.
1717
+ *
1718
+ * Takes the directory rather than deriving it from the id, because the
1719
+ * directory it fills is `<id>.partial` and not the one that id resolves to.
1720
+ * Nothing written here is reachable through the route: the id becomes
1721
+ * answerable in the single `rename` that `execute` performs afterwards.
1722
+ */
1723
+ async function render(outDir, target, exec) {
1724
+ const { snapshot, themeFile } = await captureSnapshot(cliPath, target, outDir, exec?.signal)
1725
+ // Previewed from the snapshot, not the target: this is the single read
1726
+ // that everything the user later does with this preview refers back to.
1727
+ await runCli(
1728
+ cliPath,
1729
+ ['preview', snapshot, '-o', outDir, '--html', ...themeArgs({ themeFile })],
1730
+ exec?.signal,
1731
+ )
1732
+ const bundle = await readPreviewBundle(outDir)
1733
+ const findingCount = bundle.pages.reduce((n, p) => n + (p.findings?.length ?? 0), 0)
1734
+
1735
+ // The export, here, now, in the same call — see ONE RENDER WINDOW at
1736
+ // the top of this file. The directory is named after an id nobody else
1737
+ // has yet been given, so there is no other writer to publish around.
1738
+ const pptxPath = join(outDir, exportName(bundle, target))
1739
+ let pptxError
1740
+ try {
1741
+ // `--draft` exactly when the preview shows unfilled pages, and never
1742
+ // otherwise. `render` refuses a deck with placeholders by default,
1743
+ // while `preview` renders it happily — so without this the card looked
1744
+ // fine and its download button was guaranteed to fail, forever, with
1745
+ // the user finding out only by clicking. Exporting is the better half
1746
+ // of that trade: an unfinished deck is still the thing the user is
1747
+ // iterating on, and refusing to hand it over means they cannot show it
1748
+ // to anyone or open it in PowerPoint to judge it. The gate exists so
1749
+ // nobody ships placeholders unknowingly, so the knowing is what is
1750
+ // restored: the card carries a draft badge, the model is told, and the
1751
+ // file itself is named `-draft`. Passing the flag unconditionally
1752
+ // would instead disable the gate for every deck, including the ones
1753
+ // whose placeholders the user has not seen.
1754
+ const draftArgs = bundle.draft ? ['--draft'] : []
1755
+ await runCli(
1756
+ cliPath,
1757
+ ['render', snapshot, '-o', pptxPath, ...draftArgs, ...themeArgs({ themeFile })],
1758
+ exec?.signal,
1759
+ )
1760
+ } catch (error) {
1761
+ // A failed export must not cost the user the preview: paging through
1762
+ // the deck is most of the value, and the audit findings on screen may
1763
+ // well explain the failure. The reason is recorded so the download
1764
+ // route can state it instead of returning a bare 404 the browser
1765
+ // saves as a file.
1766
+ pptxError = `the export for this preview failed to render: ${String(error && error.message ? error.message : error)}`
1767
+ }
1768
+ const record = recordFrom({ target, themeFile, pptxPath: pptxError ? undefined : pptxPath, pptxError })
1769
+ // Written last, and into the directory it describes. `recallAnywhere` looks
1770
+ // for this file first, so a directory without one is a render that never
1771
+ // reached the end. The rename below means no preview is *published* in that
1772
+ // state; something removing the record afterwards still puts one there, and
1773
+ // that is what outcome (3) answers.
1774
+ await writeRecordInto(outDir, record)
1775
+ return { record, bundle, findingCount }
1776
+ }
1777
+
1778
+ const tool = {
1779
+ name: TOOL_NAME,
1780
+ description:
1781
+ 'Render a pptwise deck and show it to the user as a slide preview inside this conversation. ' +
1782
+ 'Accepts the same targets as the CLI: a deck project directory, a single IR json file, or a bare deck name. ' +
1783
+ 'Prefer this over telling the user to open a preview URL — they can page through the deck right here.',
1784
+ parameters: {
1785
+ type: 'object',
1786
+ properties: {
1787
+ target: {
1788
+ type: 'string',
1789
+ description: 'Deck project directory, IR json file, or bare deck name — the same target the CLI takes.',
1790
+ },
1791
+ },
1792
+ required: ['target'],
1793
+ additionalProperties: false,
1794
+ },
1795
+ output: {
1796
+ schema: {
1797
+ type: 'object',
1798
+ properties: {
1799
+ previewId: { type: 'string' },
1800
+ outDir: { type: 'string' },
1801
+ pageCount: { type: 'number' },
1802
+ findingCount: { type: 'number' },
1803
+ audited: { type: 'boolean' },
1804
+ bundle: { type: 'object', additionalProperties: true },
1805
+ },
1806
+ required: ['previewId', 'outDir', 'pageCount', 'findingCount', 'audited', 'bundle'],
1807
+ additionalProperties: true,
1808
+ },
1809
+ // Model-facing: one line. The deck itself is not information the model
1810
+ // can act on, and putting it here would spend the context window on
1811
+ // markup while telling the model nothing it does not already know.
1812
+ render(_args, value) {
1813
+ return [{ type: 'text', text: modelSummary(value) }]
1814
+ },
1815
+ // Still declared: on a top-level (native-mode) call this is the better
1816
+ // channel, and the card prefers it when present. Code Mode simply never
1817
+ // computes it, which is why the route exists as well.
1818
+ presentationMeta(_args, value) {
1819
+ return { card: 'pptwise-preview', previewId: value.previewId, bundle: value.bundle }
1820
+ },
1821
+ },
1822
+ async execute(args, exec) {
1823
+ const target = String(args.target)
1824
+ // The id comes first, because it names the directory everything else is
1825
+ // written into. That inversion is what removes the half-dead state: there
1826
+ // is no moment where rendered pages exist under one name and the record
1827
+ // that finds them is being written under another.
1828
+ const previewId = randomUUID()
1829
+ const outDir = requirePreviewDir(root, previewId)
1830
+ const stageDir = partialDir(root, previewId)
1831
+ let rendered
1832
+ try {
1833
+ // Inside the try, not before it. `createOwnedDir` can fail after
1834
+ // creating the directory (see its own note), and a failure that lands
1835
+ // outside the cleanup is a directory nothing will ever collect.
1836
+ await createOwnedDir(root, stageDir)
1837
+ rendered = await render(stageDir, target, exec)
1838
+ // The publish, and the only moment this id means anything. A render
1839
+ // takes seconds and writes a dozen files; a machine that dies part-way
1840
+ // through one used to leave a directory the route would happily serve
1841
+ // — a manifest naming SVGs that were never written, a card with holes
1842
+ // in it, a download button pointing at nothing. `rename` inside one
1843
+ // filesystem is atomic, so no reader ever sees half a publish, and a
1844
+ // machine that dies mid-render leaves an `<id>.partial` no id resolves
1845
+ // to. Nothing here calls fsync, so a machine that dies in the seconds
1846
+ // after the rename is still on its own — a far smaller window than the
1847
+ // one this replaced, not the absence of one.
1848
+ await rename(stageDir, outDir)
1849
+ } catch (error) {
1850
+ // Only ever the staging directory, and only when this call created it:
1851
+ // a failing target must not park hundreds of megabytes of half-rendered
1852
+ // deck in the user's home, and must not touch anything published.
1853
+ await discardOwnedDir(stageDir)
1854
+ throw error
1855
+ }
1856
+ const { bundle, findingCount } = rendered
1857
+ return {
1858
+ previewId,
1859
+ outDir,
1860
+ pageCount: bundle.pages.length,
1861
+ findingCount,
1862
+ // `checks` is present only when the audit actually ran. Absent is not
1863
+ // "clean" — the preview manifest goes out of its way to keep those two
1864
+ // apart, and collapsing them here would undo that.
1865
+ audited: Boolean(bundle.checks),
1866
+ bundle,
1867
+ }
1868
+ },
1869
+ timeoutMs: 120_000,
1870
+ }
1871
+
1872
+ // No `recall` any more, and its absence is the point: there is no second,
1873
+ // faster way to ask this service about an id. Everything goes through
1874
+ // `recallAnywhere`, which goes to disk.
1875
+ return { tool, registerRoute, remember, recallAnywhere, root }
1876
+ }
1877
+
1878
+ /**
1879
+ * Shorthand for a service whose route is never registered — the tool alone.
1880
+ * Each call builds its own service, so no two callers share a CLI path or a
1881
+ * route. They do share the preview root, deliberately, for the reasons argued
1882
+ * where it is captured.
1883
+ */
1884
+ export function definePreviewTool(cliPath) {
1885
+ return createPreviewService(cliPath).tool
1886
+ }
1887
+
1888
+ /** Exposed for the plugin's own tests — not part of any DSH contract. */
1889
+ export const __testing = {
1890
+ readPreviewBundle,
1891
+ modelSummary,
1892
+ captureSnapshot,
1893
+ exportName,
1894
+ readRecord,
1895
+ writeRecord,
1896
+ entryFromRecord,
1897
+ fileInside,
1898
+ previewDir,
1899
+ partialDir,
1900
+ isSafeFileName,
1901
+ directoryState,
1902
+ createOwnedDir,
1903
+ discardOwnedDir,
1904
+ missingPage,
1905
+ unreadablePage,
1906
+ damagedPage,
1907
+ noticePageFor,
1908
+ describeIncomplete,
1909
+ parseManifest,
1910
+ inlineLocalImages,
1911
+ isAbsent,
1912
+ isTransient,
1913
+ classifyReadFailure,
1914
+ RETRYABLE_ERRNOS,
1915
+ THUMBNAIL_STRIP_PAGES,
1916
+ PREVIEW_HTML_FILE,
1917
+ PREVIEW_DIR,
1918
+ RECORD_FILE,
1919
+ MANIFEST_FILE,
1920
+ SNAPSHOT_FILE,
1921
+ PARTIAL_SUFFIX,
1922
+ OWNER_MARKER,
1923
+ resetLegacyHomeWarnings() {
1924
+ warnedLegacyHome.clear()
1925
+ },
1926
+ PreviewExpired,
1927
+ PreviewUnreadable,
1928
+ PreviewDamaged,
1929
+ resolveCliCommand,
1930
+ cliChildEnv,
1931
+ }