pindeck 2.9.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.mjs CHANGED
@@ -24,7 +24,7 @@ import path from "node:path";
24
24
  import crypto from "node:crypto";
25
25
  import { spawn, execFile } from "node:child_process";
26
26
  import { fileURLToPath, pathToFileURL } from "node:url";
27
- import { assignEids, parse, findSlides, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
27
+ import { assignEids, parse, findSlides, slideSelector, deckSize, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
28
28
  import { unifiedDiff } from "./lib/diff.mjs";
29
29
  import { ClaudeSession } from "./lib/claude.mjs";
30
30
  import { formatLayout, formatPin } from "./lib/layout.mjs";
@@ -33,21 +33,29 @@ import { readPptx } from "./lib/pptx.mjs";
33
33
  import { exportPdf as pptxToPdf, renderJobs, renderAvailable, pdfInfo } from "./lib/pptx-render.mjs";
34
34
  import { buildImport, planSlides } from "./lib/pptx-import.mjs";
35
35
  import { isSupportedPlatform, detectCapabilities, publicCapabilities, capabilityPrompt, parsePort, cliCommand, findExecutable } from "./lib/capabilities.mjs";
36
- import { writeAtomic, sameContent, revision } from "./lib/deck-files.mjs";
36
+ import { unsupportedMessage } from "./lib/frameworks.mjs";
37
+ import { readInstructions } from "./lib/instructions.mjs";
38
+ import { writeAtomic, sameContent, revision, scanFiles, scanChanges } from "./lib/deck-files.mjs";
37
39
 
38
40
  /* ---- Config ------------------------------------------------------------------ */
39
- const USAGE = `Usage: pindeck [deck-folder] [--port 5180] [--model <name>]
40
- pindeck login [deck-folder]
41
+ const USAGE = `Usage: pindeck [deck-folder | deck-file.html] [--port 5180] [--model <name>] [--asset-root <folder>]...
42
+ pindeck login [deck-folder | deck-file.html]
41
43
 
42
- Opens the reveal.js deck in deck-folder (default: the current folder) in PinDeck.
43
- The folder must contain index.html.
44
+ Opens a reveal.js deck in PinDeck: a deck file such as talk.html, or a folder (default: the current
45
+ folder), which then opens its index.html. A deck made otherwise names its slides in its <head> with
46
+ <meta name="pindeck:slides" content="<CSS selector>"> and <meta name="pindeck:size" content="1440x1080">.
44
47
  login prints a new one-time login link for the PinDeck running for that deck
45
- (without a folder: for the PinDeck started last).`;
48
+ (without a deck: for the PinDeck started last).
49
+ --asset-root lets the deck use files outside its folder: a folder that contains the deck (paths such as
50
+ ../shared/style.css then work), or one that symlinks in the deck may point to. Repeatable; also
51
+ "assetRoots" in ~/.pindeck/config.json.`;
46
52
  const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
47
53
  const argv = process.argv.slice(2);
48
54
  if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
49
55
  const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv.splice(i, 2)[1] : dflt; };
50
56
  const portArg = opt("--port", process.env.PORT || 5180);
57
+ const assetArgs = [];
58
+ for (let a; (a = opt("--asset-root")) !== undefined;) assetArgs.push(a);
51
59
  let PORT = parsePort(portArg); // the port actually bound once listening (--port 0 picks a free one)
52
60
  if (PORT === null) { console.error(`Invalid port "${portArg}": use a number from 1 to 65535.\n\n${USAGE}`); process.exit(2); }
53
61
  if (!isSupportedPlatform(process.platform)) {
@@ -58,13 +66,21 @@ if (!isSupportedPlatform(process.platform)) {
58
66
  const LOGIN = argv[0] === "login";
59
67
  if (LOGIN) argv.shift();
60
68
  const MODEL = opt("--model", process.env.CLAUDE_MODEL || "");
61
- const DECK_DIR = path.resolve(argv[0] || process.cwd());
62
- const DECK_FILE = "index.html";
63
- const EDITABLE = ["index.html", "theme.css"]; // Claude may edit these
69
+ /* The deck: a file (talk.html), or a folder, which means its index.html. A path ending in .html or .htm
70
+ that is not a folder is a file, so a missing file gets a clear message. */
71
+ const TARGET = path.resolve(argv[0] || process.cwd());
72
+ const isFolder = (() => { try { return fs.statSync(TARGET).isDirectory(); } catch { return false; } })();
73
+ const DECK_DIR = isFolder || !/\.html?$/i.test(TARGET) ? TARGET : path.dirname(TARGET);
74
+ const DECK_FILE = DECK_DIR === TARGET ? "index.html" : path.basename(TARGET);
75
+ const NAMED = DECK_FILE !== "index.html"; // a deck file with its own name; several can share a folder
76
+ const DECK_NAME = NAMED ? DECK_FILE.replace(/\.html?$/i, "") : path.basename(DECK_DIR); // for exports and titles
77
+ const EDITABLE = [DECK_FILE, "theme.css"]; // Claude may edit these
64
78
  const LOCKED = ["brand.css"]; // never, enforced twice
65
79
  const HOME_DIR = process.env.PINDECK_HOME || path.join(os.homedir(), ".pindeck");
66
- const deckKey = path.basename(DECK_DIR).replace(/[^\w.-]+/g, "_") + "-" +
67
- crypto.createHash("sha1").update(DECK_DIR).digest("hex").slice(0, 8);
80
+ /* State per deck file. A folder's index.html keeps the key it always had, so existing chats and undo
81
+ histories stay; a named file gets its own, so two decks in one folder never share chat or undo. */
82
+ const deckKey = (NAMED ? `${path.basename(DECK_DIR)}-${DECK_NAME}` : path.basename(DECK_DIR)).replace(/[^\w.-]+/g, "_") + "-" +
83
+ crypto.createHash("sha1").update(NAMED ? path.join(DECK_DIR, DECK_FILE) : DECK_DIR).digest("hex").slice(0, 8);
68
84
  const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
69
85
  const SHOT_DIR = path.join(STATE_DIR, "shots");
70
86
  const PDF_DIR = path.join(STATE_DIR, "attachments"); // attached PDFs: reading material, not part of the deck
@@ -107,6 +123,13 @@ if (deckProblem) {
107
123
  console.error(`${deckProblem}\n\n${USAGE}`);
108
124
  process.exit(1);
109
125
  }
126
+ // reveal.js, or a deck that names its slides: another framework's deck is left exactly as it is (no ids, no state)
127
+ const deckAtStart = fs.readFileSync(path.join(DECK_DIR, DECK_FILE), "utf8");
128
+ const frameworkProblem = unsupportedMessage(deckAtStart, DECK_FILE);
129
+ if (frameworkProblem) {
130
+ console.error(frameworkProblem);
131
+ process.exit(1);
132
+ }
110
133
  /* Runtime files hold the token, the chat and attachments: private to the user. Written only after the
111
134
  port is bound (see Start), so a failed start leaves no state behind. */
112
135
  const SERVER_FILES = [path.join(STATE_DIR, "server.json"), path.join(HOME_DIR, "server.json")]; // the second: the CLI's fallback
@@ -134,6 +157,22 @@ const MIME = { html: "text/html; charset=utf-8", css: "text/css; charset=utf-8",
134
157
  mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", pdf: "application/pdf", md: "text/plain; charset=utf-8" };
135
158
  const hash = (s) => crypto.createHash("sha1").update(s).digest("hex");
136
159
  const readDeck = (f) => { try { return fs.readFileSync(path.join(DECK_DIR, f), "utf8"); } catch { return null; } };
160
+ /* A deck made without reveal.js names its slides with <meta name="pindeck:slides"> (null for reveal.js) and
161
+ their size with <meta name="pindeck:size">. Read from the deck as it is now, so a changed tag counts from
162
+ the next headless run or Claude session. Headless Chrome opens such a deck at the slide size, so a
163
+ screenshot is the slide itself. */
164
+ function deckSlides() {
165
+ const tree = parse(readDeck(DECK_FILE) || "");
166
+ const selector = slideSelector(tree);
167
+ return { selector, size: (selector && deckSize(tree)) || { width: 1280, height: 720 } };
168
+ }
169
+ /* Why the deck no longer names slides PinDeck can use (a tag changed while PinDeck runs), or null: the
170
+ start-up check's message, which the editor shows and Claude is told */
171
+ function deckProblemNow() {
172
+ const html = readDeck(DECK_FILE);
173
+ return (html != null && unsupportedMessage(html, DECK_FILE)?.replace(/ Nothing was changed\.$/, "")) || null;
174
+ }
175
+ let deckNow = { selector: deckSlides().selector, problem: null }; // for the editor; kept current by scheduleFlush
137
176
  const ourWrites = new Map(); // file -> { hash, manual } of what we wrote, to tell our writes from others
138
177
  function writeDeck(f, content, manual = false) {
139
178
  ourWrites.set(f, { hash: hash(content), manual });
@@ -147,6 +186,25 @@ function writeDeckFile(rel, data, manual = true) {
147
186
  writeAtomic(abs, data);
148
187
  }
149
188
  const snapshot = (files) => Object.fromEntries(files.map((f) => [f, readDeck(f)]));
189
+ /* A change (a Claude turn or a hand edit) keeps each file it touched as it was before and after: text as
190
+ a string, or as a Buffer from a scan of the whole deck (null: the file did not exist). */
191
+ const changeFiles = (t) => [...new Set([...Object.keys(t.before), ...Object.keys(t.after)])];
192
+ const isText = (v) => v == null || typeof v === "string" || !v.includes(0);
193
+ const asText = (v) => (v == null ? "" : String(v));
194
+ const isAs = (rel, v) => sameContent(path.join(DECK_DIR, rel), v ?? null);
195
+ function putDeckFile(rel, v) { // undo and redo: back to v (null: remove the file)
196
+ if (v == null) return fs.rmSync(path.join(DECK_DIR, rel), { force: true });
197
+ fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
198
+ writeDeck(rel, v);
199
+ }
200
+ function fileChange(rel, before, after) {
201
+ if (!isText(before) || !isText(after)) return { file: rel, added: 0, removed: 0, binary: true };
202
+ const d = unifiedDiff(asText(before), asText(after), rel);
203
+ return { file: rel, added: d.added, removed: d.removed };
204
+ }
205
+ const fileDiff = (rel, before, after) => (!isText(before) || !isText(after)
206
+ ? `Binary file ${rel} ${before == null ? "added" : after == null ? "deleted" : "changed"}`
207
+ : unifiedDiff(asText(before), asText(after), rel).text);
150
208
  /* The revision of the editable files: sent with every API answer, checked on the editor's changes */
151
209
  const deckRev = () => revision(DECK_DIR, EDITABLE);
152
210
  const json = (res, code, obj) => {
@@ -239,15 +297,25 @@ const CHROME = CAPS.chromePath; // null when Chrome features are unavailable (
239
297
  /* Headless Chrome on macOS often lingers long after it has written its output, so we
240
298
  watch the output and stop Chrome as soon as `done(stdout, stderr)` says we have it. */
241
299
  let chromeSeq = 0;
300
+ /* The page gets the window's size too. Chrome lays it out about 87 px shorter than the window (room for
301
+ a toolbar it does not draw) and grows it only for the screenshot itself, too late for some versions
302
+ to lay the slide out again, so shot and dump lay the deck out for this size (see bridge.js). */
303
+ function sized(url, size) {
304
+ const u = new URL(url);
305
+ u.searchParams.set("width", size.width);
306
+ u.searchParams.set("height", size.height);
307
+ return u.href;
308
+ }
242
309
  function chrome(args, done) {
243
310
  if (!CHROME) return Promise.reject(new Error(`Screenshots are not available: ${CAPS.screenshot.reason}.`));
244
311
  return new Promise((resolve, reject) => {
245
312
  // a profile per run: concurrent runs must not share one
246
313
  const profile = path.join(STATE_DIR, "chrome", `p${process.pid}-${++chromeSeq}`);
314
+ const { size } = deckSlides();
247
315
  const proc = spawn(CHROME, ["--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run",
248
316
  "--no-default-browser-check", "--disable-extensions", "--mute-audio", `--user-data-dir=${profile}`,
249
- "--window-size=1280,720", "--force-device-scale-factor=1", "--virtual-time-budget=5000",
250
- ...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, a) : a)]);
317
+ `--window-size=${size.width},${size.height}`, "--force-device-scale-factor=1", "--virtual-time-budget=5000",
318
+ ...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, sized(a, size)) : a)]);
251
319
  let out = "", err = "", finished = false;
252
320
  const finish = (fn, v) => {
253
321
  if (finished) return;
@@ -267,7 +335,7 @@ function chrome(args, done) {
267
335
  });
268
336
  }
269
337
  const deckUrl = (mode, params = {}) =>
270
- `${ORIGIN}/deck/${DECK_FILE}?` + new URLSearchParams({ editor: mode, showHiddenSlides: "true", ...params });
338
+ `${ORIGIN}${DECK_PAGE}?` + new URLSearchParams({ editor: mode, showHiddenSlides: "true", ...params });
271
339
  async function takeShot(slide) {
272
340
  const pins = state.pins.filter((p) => p.slide.eid === slide.eid).map((p) => ({ n: p.n, eid: p.eid, at: p.at, point: p.point }));
273
341
  const out = path.join(SHOT_DIR, `${slide.eid.replace(/[^\w-]/g, "_")}-${Date.now()}.png`);
@@ -311,21 +379,21 @@ const measure = (slide) => measureInEditor(slide).catch((e) => {
311
379
  presentation: one page per build step (a slide with two clicks gives three pages).
312
380
  Files go to <deck>/export/. One export at a time. */
313
381
  let exportQueue = Promise.resolve(), snapSeq = 0;
314
- const snapshots = new Map(); // id -> static print layout, served at /deck/__pindeck-print-<id>.html
382
+ const snapshots = new Map(); // id -> static print layout, served next to the deck page as __pindeck-print-<id>.html
315
383
  function exportPdf(kind, includeHidden = false) {
316
384
  if (!CAPS.pdfExport.available) return Promise.reject(Object.assign(new Error(`PDF export is not available: ${CAPS.pdfExport.reason}.`), { status: 501 }));
317
385
  const split = kind === "presentation";
318
- const name = `${path.basename(DECK_DIR)}-${split ? "presentation" : "handout"}.pdf`;
386
+ const name = `${DECK_NAME}-${split ? "presentation" : "handout"}.pdf`;
319
387
  const out = path.join(DECK_DIR, "export", name);
320
388
  const profile = (step) => path.join(STATE_DIR, "chrome", `pdf-${process.pid}-${Date.now()}-${step}`);
321
389
  const run = async () => {
322
390
  fs.mkdirSync(path.dirname(out), { recursive: true });
323
391
  const layoutProfile = profile("layout"), printProfile = profile("print");
324
392
  const html = await printLayout(CHROME, chromeStart(layoutProfile,
325
- `${ORIGIN}/deck/${DECK_FILE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`), layoutProfile);
393
+ `${ORIGIN}${DECK_PAGE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`), layoutProfile);
326
394
  const id = `${process.pid}-${++snapSeq}`;
327
395
  snapshots.set(id, html);
328
- try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}/deck/__pindeck-print-${id}.html`), out, printProfile); }
396
+ try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}${DECK_BASE}__pindeck-print-${id}.html`), out, printProfile); }
329
397
  finally { snapshots.delete(id); }
330
398
  return { kind: split ? "presentation" : "handout", path: out, rel: path.relative(DECK_DIR, out),
331
399
  url: `/deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
@@ -381,6 +449,37 @@ if (MODELS.includes(fileConfig.model)) settings.model = fileConfig.model;
381
449
  else if (fileConfig.model !== undefined) console.warn(`config.json: unknown model "${fileConfig.model}" (use ${MODELS.join(" or ")}); using ${DEFAULTS.model}.`);
382
450
  if (EFFORTS.includes(fileConfig.effort)) settings.effort = fileConfig.effort;
383
451
  else if (fileConfig.effort !== undefined) console.warn(`config.json: unknown effort "${fileConfig.effort}" (use ${EFFORTS.join(", ")}); using ${DEFAULTS.effort}.`);
452
+ /* Asset roots (--asset-root, and "assetRoots" in config.json): folders outside the deck whose files the
453
+ deck may use. The outermost one that contains the deck is served at /files/, with the deck page inside
454
+ it, so a relative path such as ../shared/style.css reaches its file. Every file served, from the deck
455
+ or from /files/, must really lie (after following symlinks) inside the deck folder or an asset root:
456
+ a symlink that leads anywhere else is refused. */
457
+ const expandHome = (p) => (p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p);
458
+ const ASSET_ROOTS = [...assetArgs, ...(Array.isArray(fileConfig.assetRoots) ? fileConfig.assetRoots : [])].map((r) => {
459
+ const abs = path.resolve(expandHome(String(r)));
460
+ let st = null;
461
+ try { st = fs.statSync(abs); } catch {}
462
+ if (!st?.isDirectory()) { console.error(`Asset root ${abs} is not a folder.\n\n${USAGE}`); process.exit(2); }
463
+ return abs;
464
+ });
465
+ const within = (dir, p) => p === dir || p.startsWith(dir + path.sep);
466
+ const realOr = (p) => { try { return fs.realpathSync(p); } catch { return p; } };
467
+ const SERVE_ROOT = ASSET_ROOTS.filter((r) => within(r, DECK_DIR)).sort((a, b) => a.length - b.length)[0] || null;
468
+ const ALLOWED_REAL = [DECK_DIR, ...ASSET_ROOTS].map(realOr);
469
+ const encPath = (rel) => rel.split(path.sep).filter(Boolean).map(encodeURIComponent).join("/");
470
+ /* The deck page's URL, and the folder URL its relative paths resolve against */
471
+ const DECK_PAGE = SERVE_ROOT ? `/files/${[encPath(path.relative(SERVE_ROOT, DECK_DIR)), encodeURIComponent(DECK_FILE)].filter(Boolean).join("/")}`
472
+ : `/deck/${encodeURIComponent(DECK_FILE)}`;
473
+ const DECK_BASE = DECK_PAGE.slice(0, DECK_PAGE.lastIndexOf("/") + 1);
474
+ /* A file to serve: inside its base by path, and inside the deck or an asset root once symlinks are
475
+ followed. null: refuse. A missing file passes, and is answered 404. */
476
+ function servable(base, rel) {
477
+ const f = safeJoin(base, rel);
478
+ if (!f) return null;
479
+ let real;
480
+ try { real = fs.realpathSync(f); } catch { return f; }
481
+ return ALLOWED_REAL.some((d) => within(d, real)) ? f : null;
482
+ }
384
483
  const saveSettings = () => fs.writeFile(CONFIG_FILE, JSON.stringify({ ...fileConfig, model: settings.model, effort: settings.effort }, null, 2) + "\n", () => {});
385
484
  const completeSettings = () => { // create or complete the file (at start, once the port is bound)
386
485
  if (fileConfig.model !== settings.model || fileConfig.effort !== settings.effort) saveSettings();
@@ -389,22 +488,46 @@ if (MODEL) settings.model = MODEL; // --model on the command line: this run on
389
488
 
390
489
  const status = () => ({ busy: state.busy, model: state.model, sessionId: state.sessionId, settings: { ...settings },
391
490
  running: session.running, deck: DECK_DIR, deckFile: DECK_FILE, chrome: CAPS.screenshot.available,
491
+ slideSelector: deckNow.selector, deckProblem: deckNow.problem,
392
492
  capabilities: publicCapabilities(CAPS), rev: deckRev(),
393
493
  undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
394
494
  const pushStatus = () => broadcast("status", status());
395
495
 
396
496
  const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md"), "utf8")
397
- .replaceAll("{{CLI}}", CLI_CMD) + capabilityPrompt(CAPS, CLI_CMD);
497
+ .replaceAll("{{CLI}}", CLI_CMD).replaceAll("{{DECK}}", DECK_FILE) + capabilityPrompt(CAPS, CLI_CMD) +
498
+ // a template's own notes usually say index.html; other decks may share this folder
499
+ (NAMED ? `\n\n## This deck's file\nThe slides are in ${DECK_FILE}. Where the deck's notes below say index.html, they mean ` +
500
+ `${DECK_FILE}. Other .html files in this folder are other decks: do not edit them.\n` : "") +
501
+ (ASSET_ROOTS.length ? `\n\n## Files outside the deck folder\nThe deck may use files in ${ASSET_ROOTS.join(", ")}: refer to ` +
502
+ `them with paths relative to ${DECK_FILE} (such as ../shared/style.css). You can read them; do not edit them, ` +
503
+ `other decks may use them too.\n` : "");
504
+ /* For a deck made without reveal.js: what its slides are, from its selector as it is at each session start */
505
+ function slidesInstructions() {
506
+ const { selector } = deckSlides();
507
+ return selector ? `\n\n## Slides of this deck\nThis deck is not made with reveal.js: its slides are the elements matching the ` +
508
+ `CSS selector \`${selector}\` (<meta name="pindeck:slides">), and the deck's own script shows them. Where these ` +
509
+ `instructions say <section> or reveal.js, read an element matching that selector, and that script. A new slide is ` +
510
+ `a new such element next to the others; keep the pindeck meta tags. Hidden slides (data-visibility="hidden") are ` +
511
+ `left out of PinDeck's PDF exports; whether presenting skips them depends on the deck's script.\n` : "";
512
+ }
513
+ /* What Claude may never change, whatever the edit rules allow: the locked brand files, vendored libraries,
514
+ git's own files, and other decks in this folder (a named deck shares its folder with them). Deny rules
515
+ win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
516
+ function lockedPaths() {
517
+ let others = [];
518
+ try { others = fs.readdirSync(DECK_DIR).filter((f) => /\.html?$/i.test(f) && f !== DECK_FILE); } catch {}
519
+ return [...LOCKED, "vendor/**", ".git/**", ...others];
520
+ }
398
521
  /* Claude Code loads no settings files (--setting-sources ""): an allow rule in the user's, the deck's
399
522
  or a parent repo's settings would let Claude run more than PinDeck grants (and the user's hooks and
400
- MCP servers have no place in a slide editor). That also skips the deck's CLAUDE.md, so it goes into
401
- the system prompt instead, read afresh at each session start, together with the deck's AGENTS.md (the
402
- name other coding agents read). Decks copied from the example call the CLI as `node ../cli.mjs`, which
403
- is right only next to PinDeck: that becomes this server's command. */
523
+ MCP servers have no place in a slide editor). That also skips CLAUDE.md files, so the deck's own go
524
+ into the system prompt instead, read afresh at each session start: CLAUDE.md and AGENTS.md (the name
525
+ other coding agents read), with their @path imports (lib/instructions.mjs). Parent folders' files are
526
+ not loaded; a deck imports shared rules explicitly. Decks copied from the example call the CLI as
527
+ `node ../cli.mjs`, which is right only next to PinDeck: that becomes this server's command. */
404
528
  function deckInstructions() {
405
529
  return ["CLAUDE.md", "AGENTS.md"].map((name) => {
406
- let s;
407
- try { s = fs.readFileSync(path.join(DECK_DIR, name), "utf8").trim(); } catch { return ""; }
530
+ const s = readInstructions(path.join(DECK_DIR, name));
408
531
  return s ? `\n\n## The deck's ${name}\n${s.replace(/node \.\.\/cli\.mjs\b/g, () => CLI_CMD)}\n` : "";
409
532
  }).join("");
410
533
  }
@@ -413,11 +536,12 @@ const session = new ClaudeSession({
413
536
  cwd: DECK_DIR,
414
537
  env: () => ({ DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN }),
415
538
  args: () => [
416
- "--append-system-prompt", systemPrompt + deckInstructions(),
539
+ "--append-system-prompt", systemPrompt + slidesInstructions() + deckInstructions(),
417
540
  "--setting-sources", "", "--strict-mcp-config", // no settings files, no MCP servers or connectors
418
541
  "--permission-mode", "default",
419
- "--allowedTools", "Read", "Glob", "Grep", ...EDITABLE.map((f) => `Edit(./${f})`), `Bash(${CLI_CMD}:*)`,
420
- "--disallowedTools", ...LOCKED.flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
542
+ // any file in the deck folder (Edit rules cover Write too; Write is named as well, to be explicit)
543
+ "--allowedTools", "Read", "Glob", "Grep", "Edit(./**)", "Write(./**)", `Bash(${CLI_CMD}:*)`,
544
+ "--disallowedTools", ...lockedPaths().flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
421
545
  ...(settings.model ? ["--model", settings.model] : []),
422
546
  ...(settings.effort ? ["--effort", settings.effort] : []),
423
547
  ],
@@ -492,7 +616,9 @@ function handleEvent(ev) {
492
616
  }
493
617
 
494
618
  function startTurn(promptText, userEntry) {
495
- state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]) };
619
+ // every deck file as it is now: Claude may change any of them (not brand.css or vendor/), and undo
620
+ // takes back all it changed
621
+ state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]), scan: scanFiles(DECK_DIR) };
496
622
  state.busy = true;
497
623
  pushStatus();
498
624
  ensureSession();
@@ -516,11 +642,13 @@ function finishTurn({ error, cost, duration, denials = [] }) {
516
642
  }
517
643
  toolEntries.clear();
518
644
  const eid = runEidPass();
519
- const after = snapshot(EDITABLE);
520
- const files = EDITABLE.map((f) => {
521
- const d = unifiedDiff(turn.before[f] ?? "", after[f] ?? "", f);
522
- return { file: f, added: d.added, removed: d.removed };
523
- }).filter((f) => f.added || f.removed);
645
+ const { changed, tooLarge } = scanChanges(turn.scan, scanFiles(DECK_DIR));
646
+ const before = {}, after = {};
647
+ for (const [rel, c] of Object.entries(changed)) { before[rel] = c.before; after[rel] = c.after; }
648
+ // the deck file and theme.css first, then the rest by name
649
+ const order = (rel) => (EDITABLE.includes(rel) ? EDITABLE.indexOf(rel) : EDITABLE.length);
650
+ const files = Object.keys(changed).sort((a, b) => order(a) - order(b) || a.localeCompare(b))
651
+ .map((rel) => fileChange(rel, before[rel], after[rel]));
524
652
  // after a hand-off: imported pictures that Claude replaced (nothing refers to them any more) are
525
653
  // deleted; undo brings them back with the turn
526
654
  const deletedFiles = [];
@@ -530,11 +658,13 @@ function finishTurn({ error, cost, duration, denials = [] }) {
530
658
  deletedFiles.push({ rel, data: fs.readFileSync(abs) });
531
659
  fs.rmSync(abs, { force: true });
532
660
  }
533
- const t = { id: turn.id, kind: "claude", before: turn.before, after, files, deletedFiles, undone: false };
661
+ const t = { id: turn.id, kind: "claude", before, after, files, deletedFiles, undone: false };
534
662
  if (files.length) { state.turns.push(t); state.redo = []; }
535
663
  addEntry({ kind: "turn", turnId: files.length ? t.id : null, files, error, cost, duration,
536
664
  denials, restored, newIds: eid.assigned.length });
537
665
  if (deletedFiles.length) addEntry({ kind: "notice", text: `Removed ${deletedFiles.map((f) => f.rel).join(", ")}: replaced, and no longer used.` });
666
+ if (tooLarge.length) addEntry({ kind: "notice", text: `${tooLarge.join(", ")} changed in this turn but ${tooLarge.length > 1 ? "are" : "is"} ` +
667
+ `too large to keep a copy of (over 2 MB): Undo leaves ${tooLarge.length > 1 ? "them" : "it"} as ${tooLarge.length > 1 ? "they are" : "it is"}.` });
538
668
  pushStatus();
539
669
  }
540
670
 
@@ -593,7 +723,7 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
593
723
  for (const L of layouts) ctx.push("Layout:\n" + formatLayout(L));
594
724
  if (screenshot) ctx.push(`Screenshot of slide ${screenshot.slide} with the pin markers: ${screenshot.path} (Read it).`);
595
725
  if (attachments.length) ctx.push("Attached files (the user added them to this message; they are saved in the deck folder, " +
596
- "use these paths relative to index.html):\n" + attachments.map((a) => formatAttachment(a) + formatDrop(a)).join("\n"));
726
+ `use these paths relative to ${DECK_FILE}):\n` + attachments.map((a) => formatAttachment(a) + formatDrop(a)).join("\n"));
597
727
  return (ctx.length ? `<editor-context>\n${ctx.join("\n\n")}\n</editor-context>\n\n` : "") + userText;
598
728
  }
599
729
 
@@ -620,7 +750,7 @@ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = ""
620
750
  if (out && fs.existsSync(path.join(DECK_DIR, out)) && !state.pdfFigures.has(out)) return bad(`${out} exists already; choose another name.`, 409);
621
751
  const tmp = path.join(SHOT_DIR, `pdf-${slug(path.basename(abs, ".pdf"))}-p${n}-${Date.now()}.png`);
622
752
  await renderJobs(abs, [{ page: n, out: tmp, box: pts, scale }], STATE_DIR, { slideWidth: pw });
623
- if (!fs.existsSync(tmp)) return bad("PDFKit did not write the picture.", 500);
753
+ if (!fs.existsSync(tmp)) return bad("The PDF page could not be rendered.", 500);
624
754
  const size = `${Math.round(pts[2] * scale)} × ${Math.round(pts[3] * scale)} px`;
625
755
  if (!out) return { text: `${tmp} (${size}). Read it; add a figures/ name to save it into the deck.` };
626
756
  const buf = fs.readFileSync(tmp);
@@ -670,6 +800,14 @@ function scheduleFlush() {
670
800
  };
671
801
  eidTimer = setTimeout(pass, 800);
672
802
  }
803
+ if (files.includes(DECK_FILE)) { // the slide tags, by hand or by Claude: tell both when they stop working
804
+ const now = { selector: deckSlides().selector, problem: deckProblemNow() };
805
+ if (now.selector !== deckNow.selector || now.problem !== deckNow.problem) {
806
+ if (now.problem) state.notes = [...new Set([...state.notes, `PinDeck cannot use the deck's slides now: ${now.problem}`])];
807
+ deckNow = now;
808
+ pushStatus();
809
+ }
810
+ }
673
811
  // manual: only drag/resize writes, which the browser already shows; no reload of the visible slide
674
812
  broadcast("files", { files, manual, rev: deckRev() });
675
813
  }, 120);
@@ -768,7 +906,7 @@ function deletePlan(eid) {
768
906
  Then nothing is written and the answer is 409 with the files that differ. */
769
907
  function undoConflicts(t, direction) {
770
908
  const expect = direction === "undo" ? t.after : t.before;
771
- const files = EDITABLE.filter((f) => (readDeck(f) ?? null) !== (expect[f] ?? null));
909
+ const files = changeFiles(t).filter((rel) => !isAs(rel, expect[rel]));
772
910
  // undo brings deleted files back (their place must be free, or hold the same bytes) and removes added
773
911
  // ones (which must be unchanged); redo the other way round
774
912
  const comeBack = direction === "undo" ? t.deletedFiles || [] : t.addedFiles || [];
@@ -794,11 +932,7 @@ function undo(res) {
794
932
  if (!t) return json(res, 409, { error: "Nothing to undo." });
795
933
  const diverged = undoConflicts(t, "undo");
796
934
  if (diverged.length) return conflict(res, "undo", diverged);
797
- for (const f of EDITABLE) {
798
- if (readDeck(f) === t.before[f]) continue;
799
- if (t.before[f] != null) writeDeck(f, t.before[f]);
800
- else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // the change created the file
801
- }
935
+ for (const rel of changeFiles(t)) if (!isAs(rel, t.before[rel])) putDeckFile(rel, t.before[rel] ?? null); // null: the change created it
802
936
  for (const { rel, data } of t.deletedFiles || []) writeDeckFile(rel, data, false); // files deleted with an element come back too
803
937
  for (const { rel } of t.addedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true }); // imported figures go
804
938
  t.undone = true;
@@ -1037,23 +1171,35 @@ const server = http.createServer(async (req, res) => {
1037
1171
  const f = safeJoin(path.join(EDITOR_DIR, "public"), p.slice(8));
1038
1172
  return f ? serveFile(res, f, null, req) : text(res, 403, "Forbidden");
1039
1173
  }
1040
- if (p.startsWith("/deck/") && !ok) return text(res, 403, "Not logged in");
1174
+ if ((p.startsWith("/deck/") || p.startsWith("/files/")) && !ok) return text(res, 403, "Not logged in");
1041
1175
  let snap;
1042
- if (req.method === "GET" && (snap = p.match(/^\/deck\/__pindeck-print-([\w-]+)\.html$/))) {
1176
+ if (req.method === "GET" && (snap = p.match(/^\/(?:deck|files)\/(?:.*\/)?__pindeck-print-([\w-]+)\.html$/))) {
1043
1177
  const html = snapshots.get(snap[1]);
1044
1178
  if (!html) return text(res, 404, "Not found");
1045
1179
  res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
1046
1180
  return res.end(html);
1047
1181
  }
1048
- if (req.method === "GET" && p.startsWith("/deck/")) {
1049
- const f = safeJoin(DECK_DIR, p.slice(6) || DECK_FILE);
1050
- if (!f) return text(res, 403, "Forbidden");
1182
+ // /deck/: the deck folder; /deck/ alone is the deck page. With an asset root the page lives under
1183
+ // /files/, so that ../ paths reach the root: /deck/ alone redirects there.
1184
+ if (req.method === "GET" && p === "/deck/" && SERVE_ROOT) {
1185
+ res.writeHead(302, { location: DECK_PAGE + url.search, "cache-control": "no-store" });
1186
+ return res.end();
1187
+ }
1188
+ const deckPath = req.method === "GET" && (p.startsWith("/deck/") ? servable(DECK_DIR, p.slice(6) || DECK_FILE)
1189
+ : p.startsWith("/files/") && SERVE_ROOT ? servable(SERVE_ROOT, p.slice(7)) : undefined);
1190
+ if (deckPath === null) return text(res, 403, "Forbidden");
1191
+ if (deckPath) {
1192
+ const f = deckPath;
1051
1193
  const mode = url.searchParams.get("editor");
1052
- if (!mode || !f.endsWith(".html")) return serveFile(res, f, null, req);
1194
+ if (!mode || !/\.html?$/i.test(f)) return serveFile(res, f, null, req);
1053
1195
  return serveFile(res, f, (s) => {
1054
1196
  // headless runs must not stream media, or Chrome never finishes the page
1055
1197
  if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
1056
- if (mode === "pdf") return s.replace(/<\/head>/i, PDF_HEAD_TAG).replace(/<\/body>/i, PDF_PAGE_TAG);
1198
+ if (mode === "pdf") {
1199
+ s = s.replace(/<\/head>/i, PDF_HEAD_TAG).replace(/<\/body>/i, PDF_PAGE_TAG);
1200
+ // a deck that names its slides (in the file as it is now) is laid out by the bridge; reveal.js lays out its own
1201
+ return slideSelector(parse(s)) ? s.replace(/<\/body>/i, BRIDGE_TAG) : s;
1202
+ }
1057
1203
  return s.replace(/<\/body>/i, BRIDGE_TAG);
1058
1204
  });
1059
1205
  }
@@ -1170,14 +1316,16 @@ const server = http.createServer(async (req, res) => {
1170
1316
  const b = await body(req);
1171
1317
  if (staleEdit(res, b)) return;
1172
1318
  const before = snapshot(EDITABLE);
1173
- const slides = findSlides(parse(before[DECK_FILE]));
1319
+ const tree = parse(before[DECK_FILE]), slides = findSlides(tree);
1174
1320
  const index = slides.findIndex((s) => s.attrs["data-eid"]?.value === b.eid);
1175
1321
  if (index < 0) return json(res, 404, { error: "Slide not found in the file." });
1176
1322
  const r = setAttr(before[DECK_FILE], b.eid, "data-visibility", b.hidden ? "hidden" : null);
1177
1323
  if (!r.changed) return json(res, 200, { unchanged: true });
1324
+ const skipped = slideSelector(tree) ? "left out of PDF exports (whether presenting skips them depends on the deck's script)"
1325
+ : "skipped when presenting and in PDF exports";
1178
1326
  const turnId = recordHandEdit(before, r.html, `${b.hidden ? "Hid" : "Showed"} slide ${index + 1}`,
1179
1327
  `The user ${b.hidden ? "hid" : "showed again"} slide ${index + 1} (${b.eid}). Hidden slides carry ` +
1180
- `data-visibility="hidden": they stay in the file but are skipped when presenting and in PDF exports.`,
1328
+ `data-visibility="hidden": they stay in the file but are ${skipped}.`,
1181
1329
  { manual: true, chat: false }); // the editor reloads the slide itself; undo is in the toolbar
1182
1330
  return json(res, 200, { turnId, index });
1183
1331
  }
@@ -1269,7 +1417,7 @@ const server = http.createServer(async (req, res) => {
1269
1417
  if ((m = p.match(/^\/api\/turn\/(\d+)\/diff$/))) {
1270
1418
  const t = state.turns.find((t) => t.id === +m[1]);
1271
1419
  if (!t) return text(res, 404, "No such change");
1272
- return text(res, 200, EDITABLE.map((f) => unifiedDiff(t.before[f] ?? "", t.after[f] ?? "", f).text).filter(Boolean).join("\n\n"));
1420
+ return text(res, 200, changeFiles(t).map((rel) => fileDiff(rel, t.before[rel] ?? null, t.after[rel] ?? null)).filter(Boolean).join("\n\n"));
1273
1421
  }
1274
1422
  if ((m = p.match(/^\/api\/turn\/(\d+)\/undo$/)) && req.method === "POST") {
1275
1423
  const t = liveTurns().at(-1);
@@ -1284,11 +1432,7 @@ const server = http.createServer(async (req, res) => {
1284
1432
  const diverged = undoConflicts(t, "redo");
1285
1433
  if (diverged.length) return conflict(res, "redo", diverged);
1286
1434
  state.redo.pop();
1287
- for (const f of EDITABLE) {
1288
- if (readDeck(f) === t.after[f]) continue;
1289
- if (t.after[f] != null) writeDeck(f, t.after[f]);
1290
- else fs.rmSync(path.join(DECK_DIR, f), { force: true });
1291
- }
1435
+ for (const rel of changeFiles(t)) if (!isAs(rel, t.after[rel])) putDeckFile(rel, t.after[rel] ?? null);
1292
1436
  for (const { rel } of t.deletedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true });
1293
1437
  for (const { rel, data } of t.addedFiles || []) writeDeckFile(rel, data, false);
1294
1438
  t.undone = false;
@@ -1422,11 +1566,13 @@ const server = http.createServer(async (req, res) => {
1422
1566
  return text(res, r.status || 200, r.text);
1423
1567
  }
1424
1568
  if (p === "/api/slides") {
1425
- return text(res, 200, slideList().map((s) => `${String(s.index + 1).padStart(2)} ${s.eid}`).join("\n"));
1569
+ const list = slideList(), why = !list.length && deckProblemNow();
1570
+ if (why) return text(res, 409, why);
1571
+ return text(res, 200, list.map((s) => `${String(s.index + 1).padStart(2)} ${s.eid}`).join("\n"));
1426
1572
  }
1427
1573
  if (p === "/api/layout" || p === "/api/shot") {
1428
1574
  const slide = resolveSlide(url.searchParams.get("slide"));
1429
- if (!slide) return text(res, 404, `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
1575
+ if (!slide) return text(res, 404, deckProblemNow() || `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
1430
1576
  if (!slide.eid) runEidPass();
1431
1577
  const s2 = slide.eid ? slide : resolveSlide(String(slide.index + 1));
1432
1578
  if (p === "/api/shot") {