pindeck 2.9.0 → 2.11.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/public/index.html CHANGED
@@ -95,7 +95,7 @@
95
95
  double-click to change the text. A selected element is what Claude takes “this” to mean.</p>
96
96
  <p>Add figures, audio, a PDF or a screenshot with <strong>+</strong>, or drag and drop or paste a file; Claude can put it on a slide
97
97
  or, for a paper or report, take the details from it.</p>
98
- <p>Claude edits <code>index.html</code> and <code>theme.css</code>, checks the layout, and the slide updates here.
98
+ <p>Claude edits <code id="deck-file">index.html</code> and <code>theme.css</code>, checks the layout, and the slide updates here.
99
99
  <code>brand.css</code> is locked.</p>
100
100
  </div>
101
101
  </div>
@@ -155,6 +155,10 @@
155
155
  </form>
156
156
  </dialog>
157
157
 
158
+ <dialog id="shot-dialog" class="shot-dialog" aria-label="Screenshot">
159
+ <img id="shot-img" alt="Screenshot of the slide">
160
+ </dialog>
161
+
158
162
  <dialog id="diff-dialog" class="diff-dialog">
159
163
  <div class="dialog-head"><span id="diff-title">Changes</span><button class="small" id="diff-close">Close</button></div>
160
164
  <pre id="diff-body" class="diff"></pre>
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,15 +66,24 @@ 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");
86
+ const SHOT_NAME = /^[\w-]+\.png$/; // what takeShot writes, and all /shots/ serves
70
87
  const PDF_DIR = path.join(STATE_DIR, "attachments"); // attached PDFs: reading material, not part of the deck
71
88
  /* pindeck login: a new one-time login link from the running server, asked with the token in its server
72
89
  file (readable only by this user) */
@@ -107,6 +124,13 @@ if (deckProblem) {
107
124
  console.error(`${deckProblem}\n\n${USAGE}`);
108
125
  process.exit(1);
109
126
  }
127
+ // reveal.js, or a deck that names its slides: another framework's deck is left exactly as it is (no ids, no state)
128
+ const deckAtStart = fs.readFileSync(path.join(DECK_DIR, DECK_FILE), "utf8");
129
+ const frameworkProblem = unsupportedMessage(deckAtStart, DECK_FILE);
130
+ if (frameworkProblem) {
131
+ console.error(frameworkProblem);
132
+ process.exit(1);
133
+ }
110
134
  /* Runtime files hold the token, the chat and attachments: private to the user. Written only after the
111
135
  port is bound (see Start), so a failed start leaves no state behind. */
112
136
  const SERVER_FILES = [path.join(STATE_DIR, "server.json"), path.join(HOME_DIR, "server.json")]; // the second: the CLI's fallback
@@ -134,6 +158,22 @@ const MIME = { html: "text/html; charset=utf-8", css: "text/css; charset=utf-8",
134
158
  mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", pdf: "application/pdf", md: "text/plain; charset=utf-8" };
135
159
  const hash = (s) => crypto.createHash("sha1").update(s).digest("hex");
136
160
  const readDeck = (f) => { try { return fs.readFileSync(path.join(DECK_DIR, f), "utf8"); } catch { return null; } };
161
+ /* A deck made without reveal.js names its slides with <meta name="pindeck:slides"> (null for reveal.js) and
162
+ their size with <meta name="pindeck:size">. Read from the deck as it is now, so a changed tag counts from
163
+ the next headless run or Claude session. Headless Chrome opens such a deck at the slide size, so a
164
+ screenshot is the slide itself. */
165
+ function deckSlides() {
166
+ const tree = parse(readDeck(DECK_FILE) || "");
167
+ const selector = slideSelector(tree);
168
+ return { selector, size: (selector && deckSize(tree)) || { width: 1280, height: 720 } };
169
+ }
170
+ /* Why the deck no longer names slides PinDeck can use (a tag changed while PinDeck runs), or null: the
171
+ start-up check's message, which the editor shows and Claude is told */
172
+ function deckProblemNow() {
173
+ const html = readDeck(DECK_FILE);
174
+ return (html != null && unsupportedMessage(html, DECK_FILE)?.replace(/ Nothing was changed\.$/, "")) || null;
175
+ }
176
+ let deckNow = { selector: deckSlides().selector, problem: null }; // for the editor; kept current by scheduleFlush
137
177
  const ourWrites = new Map(); // file -> { hash, manual } of what we wrote, to tell our writes from others
138
178
  function writeDeck(f, content, manual = false) {
139
179
  ourWrites.set(f, { hash: hash(content), manual });
@@ -147,6 +187,25 @@ function writeDeckFile(rel, data, manual = true) {
147
187
  writeAtomic(abs, data);
148
188
  }
149
189
  const snapshot = (files) => Object.fromEntries(files.map((f) => [f, readDeck(f)]));
190
+ /* A change (a Claude turn or a hand edit) keeps each file it touched as it was before and after: text as
191
+ a string, or as a Buffer from a scan of the whole deck (null: the file did not exist). */
192
+ const changeFiles = (t) => [...new Set([...Object.keys(t.before), ...Object.keys(t.after)])];
193
+ const isText = (v) => v == null || typeof v === "string" || !v.includes(0);
194
+ const asText = (v) => (v == null ? "" : String(v));
195
+ const isAs = (rel, v) => sameContent(path.join(DECK_DIR, rel), v ?? null);
196
+ function putDeckFile(rel, v) { // undo and redo: back to v (null: remove the file)
197
+ if (v == null) return fs.rmSync(path.join(DECK_DIR, rel), { force: true });
198
+ fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
199
+ writeDeck(rel, v);
200
+ }
201
+ function fileChange(rel, before, after) {
202
+ if (!isText(before) || !isText(after)) return { file: rel, added: 0, removed: 0, binary: true };
203
+ const d = unifiedDiff(asText(before), asText(after), rel);
204
+ return { file: rel, added: d.added, removed: d.removed };
205
+ }
206
+ const fileDiff = (rel, before, after) => (!isText(before) || !isText(after)
207
+ ? `Binary file ${rel} ${before == null ? "added" : after == null ? "deleted" : "changed"}`
208
+ : unifiedDiff(asText(before), asText(after), rel).text);
150
209
  /* The revision of the editable files: sent with every API answer, checked on the editor's changes */
151
210
  const deckRev = () => revision(DECK_DIR, EDITABLE);
152
211
  const json = (res, code, obj) => {
@@ -239,15 +298,25 @@ const CHROME = CAPS.chromePath; // null when Chrome features are unavailable (
239
298
  /* Headless Chrome on macOS often lingers long after it has written its output, so we
240
299
  watch the output and stop Chrome as soon as `done(stdout, stderr)` says we have it. */
241
300
  let chromeSeq = 0;
301
+ /* The page gets the window's size too. Chrome lays it out about 87 px shorter than the window (room for
302
+ a toolbar it does not draw) and grows it only for the screenshot itself, too late for some versions
303
+ to lay the slide out again, so shot and dump lay the deck out for this size (see bridge.js). */
304
+ function sized(url, size) {
305
+ const u = new URL(url);
306
+ u.searchParams.set("width", size.width);
307
+ u.searchParams.set("height", size.height);
308
+ return u.href;
309
+ }
242
310
  function chrome(args, done) {
243
311
  if (!CHROME) return Promise.reject(new Error(`Screenshots are not available: ${CAPS.screenshot.reason}.`));
244
312
  return new Promise((resolve, reject) => {
245
313
  // a profile per run: concurrent runs must not share one
246
314
  const profile = path.join(STATE_DIR, "chrome", `p${process.pid}-${++chromeSeq}`);
315
+ const { size } = deckSlides();
247
316
  const proc = spawn(CHROME, ["--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run",
248
317
  "--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)]);
318
+ `--window-size=${size.width},${size.height}`, "--force-device-scale-factor=1", "--virtual-time-budget=5000",
319
+ ...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, sized(a, size)) : a)]);
251
320
  let out = "", err = "", finished = false;
252
321
  const finish = (fn, v) => {
253
322
  if (finished) return;
@@ -267,7 +336,7 @@ function chrome(args, done) {
267
336
  });
268
337
  }
269
338
  const deckUrl = (mode, params = {}) =>
270
- `${ORIGIN}/deck/${DECK_FILE}?` + new URLSearchParams({ editor: mode, showHiddenSlides: "true", ...params });
339
+ `${ORIGIN}${DECK_PAGE}?` + new URLSearchParams({ editor: mode, showHiddenSlides: "true", ...params });
271
340
  async function takeShot(slide) {
272
341
  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
342
  const out = path.join(SHOT_DIR, `${slide.eid.replace(/[^\w-]/g, "_")}-${Date.now()}.png`);
@@ -311,21 +380,21 @@ const measure = (slide) => measureInEditor(slide).catch((e) => {
311
380
  presentation: one page per build step (a slide with two clicks gives three pages).
312
381
  Files go to <deck>/export/. One export at a time. */
313
382
  let exportQueue = Promise.resolve(), snapSeq = 0;
314
- const snapshots = new Map(); // id -> static print layout, served at /deck/__pindeck-print-<id>.html
383
+ const snapshots = new Map(); // id -> static print layout, served next to the deck page as __pindeck-print-<id>.html
315
384
  function exportPdf(kind, includeHidden = false) {
316
385
  if (!CAPS.pdfExport.available) return Promise.reject(Object.assign(new Error(`PDF export is not available: ${CAPS.pdfExport.reason}.`), { status: 501 }));
317
386
  const split = kind === "presentation";
318
- const name = `${path.basename(DECK_DIR)}-${split ? "presentation" : "handout"}.pdf`;
387
+ const name = `${DECK_NAME}-${split ? "presentation" : "handout"}.pdf`;
319
388
  const out = path.join(DECK_DIR, "export", name);
320
389
  const profile = (step) => path.join(STATE_DIR, "chrome", `pdf-${process.pid}-${Date.now()}-${step}`);
321
390
  const run = async () => {
322
391
  fs.mkdirSync(path.dirname(out), { recursive: true });
323
392
  const layoutProfile = profile("layout"), printProfile = profile("print");
324
393
  const html = await printLayout(CHROME, chromeStart(layoutProfile,
325
- `${ORIGIN}/deck/${DECK_FILE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`), layoutProfile);
394
+ `${ORIGIN}${DECK_PAGE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`), layoutProfile);
326
395
  const id = `${process.pid}-${++snapSeq}`;
327
396
  snapshots.set(id, html);
328
- try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}/deck/__pindeck-print-${id}.html`), out, printProfile); }
397
+ try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}${DECK_BASE}__pindeck-print-${id}.html`), out, printProfile); }
329
398
  finally { snapshots.delete(id); }
330
399
  return { kind: split ? "presentation" : "handout", path: out, rel: path.relative(DECK_DIR, out),
331
400
  url: `/deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
@@ -381,6 +450,37 @@ if (MODELS.includes(fileConfig.model)) settings.model = fileConfig.model;
381
450
  else if (fileConfig.model !== undefined) console.warn(`config.json: unknown model "${fileConfig.model}" (use ${MODELS.join(" or ")}); using ${DEFAULTS.model}.`);
382
451
  if (EFFORTS.includes(fileConfig.effort)) settings.effort = fileConfig.effort;
383
452
  else if (fileConfig.effort !== undefined) console.warn(`config.json: unknown effort "${fileConfig.effort}" (use ${EFFORTS.join(", ")}); using ${DEFAULTS.effort}.`);
453
+ /* Asset roots (--asset-root, and "assetRoots" in config.json): folders outside the deck whose files the
454
+ deck may use. The outermost one that contains the deck is served at /files/, with the deck page inside
455
+ it, so a relative path such as ../shared/style.css reaches its file. Every file served, from the deck
456
+ or from /files/, must really lie (after following symlinks) inside the deck folder or an asset root:
457
+ a symlink that leads anywhere else is refused. */
458
+ const expandHome = (p) => (p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p);
459
+ const ASSET_ROOTS = [...assetArgs, ...(Array.isArray(fileConfig.assetRoots) ? fileConfig.assetRoots : [])].map((r) => {
460
+ const abs = path.resolve(expandHome(String(r)));
461
+ let st = null;
462
+ try { st = fs.statSync(abs); } catch {}
463
+ if (!st?.isDirectory()) { console.error(`Asset root ${abs} is not a folder.\n\n${USAGE}`); process.exit(2); }
464
+ return abs;
465
+ });
466
+ const within = (dir, p) => p === dir || p.startsWith(dir + path.sep);
467
+ const realOr = (p) => { try { return fs.realpathSync(p); } catch { return p; } };
468
+ const SERVE_ROOT = ASSET_ROOTS.filter((r) => within(r, DECK_DIR)).sort((a, b) => a.length - b.length)[0] || null;
469
+ const ALLOWED_REAL = [DECK_DIR, ...ASSET_ROOTS].map(realOr);
470
+ const encPath = (rel) => rel.split(path.sep).filter(Boolean).map(encodeURIComponent).join("/");
471
+ /* The deck page's URL, and the folder URL its relative paths resolve against */
472
+ const DECK_PAGE = SERVE_ROOT ? `/files/${[encPath(path.relative(SERVE_ROOT, DECK_DIR)), encodeURIComponent(DECK_FILE)].filter(Boolean).join("/")}`
473
+ : `/deck/${encodeURIComponent(DECK_FILE)}`;
474
+ const DECK_BASE = DECK_PAGE.slice(0, DECK_PAGE.lastIndexOf("/") + 1);
475
+ /* A file to serve: inside its base by path, and inside the deck or an asset root once symlinks are
476
+ followed. null: refuse. A missing file passes, and is answered 404. */
477
+ function servable(base, rel) {
478
+ const f = safeJoin(base, rel);
479
+ if (!f) return null;
480
+ let real;
481
+ try { real = fs.realpathSync(f); } catch { return f; }
482
+ return ALLOWED_REAL.some((d) => within(d, real)) ? f : null;
483
+ }
384
484
  const saveSettings = () => fs.writeFile(CONFIG_FILE, JSON.stringify({ ...fileConfig, model: settings.model, effort: settings.effort }, null, 2) + "\n", () => {});
385
485
  const completeSettings = () => { // create or complete the file (at start, once the port is bound)
386
486
  if (fileConfig.model !== settings.model || fileConfig.effort !== settings.effort) saveSettings();
@@ -389,22 +489,46 @@ if (MODEL) settings.model = MODEL; // --model on the command line: this run on
389
489
 
390
490
  const status = () => ({ busy: state.busy, model: state.model, sessionId: state.sessionId, settings: { ...settings },
391
491
  running: session.running, deck: DECK_DIR, deckFile: DECK_FILE, chrome: CAPS.screenshot.available,
492
+ slideSelector: deckNow.selector, deckProblem: deckNow.problem,
392
493
  capabilities: publicCapabilities(CAPS), rev: deckRev(),
393
494
  undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
394
495
  const pushStatus = () => broadcast("status", status());
395
496
 
396
497
  const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md"), "utf8")
397
- .replaceAll("{{CLI}}", CLI_CMD) + capabilityPrompt(CAPS, CLI_CMD);
498
+ .replaceAll("{{CLI}}", CLI_CMD).replaceAll("{{DECK}}", DECK_FILE) + capabilityPrompt(CAPS, CLI_CMD) +
499
+ // a template's own notes usually say index.html; other decks may share this folder
500
+ (NAMED ? `\n\n## This deck's file\nThe slides are in ${DECK_FILE}. Where the deck's notes below say index.html, they mean ` +
501
+ `${DECK_FILE}. Other .html files in this folder are other decks: do not edit them.\n` : "") +
502
+ (ASSET_ROOTS.length ? `\n\n## Files outside the deck folder\nThe deck may use files in ${ASSET_ROOTS.join(", ")}: refer to ` +
503
+ `them with paths relative to ${DECK_FILE} (such as ../shared/style.css). You can read them; do not edit them, ` +
504
+ `other decks may use them too.\n` : "");
505
+ /* For a deck made without reveal.js: what its slides are, from its selector as it is at each session start */
506
+ function slidesInstructions() {
507
+ const { selector } = deckSlides();
508
+ return selector ? `\n\n## Slides of this deck\nThis deck is not made with reveal.js: its slides are the elements matching the ` +
509
+ `CSS selector \`${selector}\` (<meta name="pindeck:slides">), and the deck's own script shows them. Where these ` +
510
+ `instructions say <section> or reveal.js, read an element matching that selector, and that script. A new slide is ` +
511
+ `a new such element next to the others; keep the pindeck meta tags. Hidden slides (data-visibility="hidden") are ` +
512
+ `left out of PinDeck's PDF exports; whether presenting skips them depends on the deck's script.\n` : "";
513
+ }
514
+ /* What Claude may never change, whatever the edit rules allow: the locked brand files, vendored libraries,
515
+ git's own files, and other decks in this folder (a named deck shares its folder with them). Deny rules
516
+ win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
517
+ function lockedPaths() {
518
+ let others = [];
519
+ try { others = fs.readdirSync(DECK_DIR).filter((f) => /\.html?$/i.test(f) && f !== DECK_FILE); } catch {}
520
+ return [...LOCKED, "vendor/**", ".git/**", ...others];
521
+ }
398
522
  /* Claude Code loads no settings files (--setting-sources ""): an allow rule in the user's, the deck's
399
523
  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. */
524
+ MCP servers have no place in a slide editor). That also skips CLAUDE.md files, so the deck's own go
525
+ into the system prompt instead, read afresh at each session start: CLAUDE.md and AGENTS.md (the name
526
+ other coding agents read), with their @path imports (lib/instructions.mjs). Parent folders' files are
527
+ not loaded; a deck imports shared rules explicitly. Decks copied from the example call the CLI as
528
+ `node ../cli.mjs`, which is right only next to PinDeck: that becomes this server's command. */
404
529
  function deckInstructions() {
405
530
  return ["CLAUDE.md", "AGENTS.md"].map((name) => {
406
- let s;
407
- try { s = fs.readFileSync(path.join(DECK_DIR, name), "utf8").trim(); } catch { return ""; }
531
+ const s = readInstructions(path.join(DECK_DIR, name));
408
532
  return s ? `\n\n## The deck's ${name}\n${s.replace(/node \.\.\/cli\.mjs\b/g, () => CLI_CMD)}\n` : "";
409
533
  }).join("");
410
534
  }
@@ -413,11 +537,12 @@ const session = new ClaudeSession({
413
537
  cwd: DECK_DIR,
414
538
  env: () => ({ DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN }),
415
539
  args: () => [
416
- "--append-system-prompt", systemPrompt + deckInstructions(),
540
+ "--append-system-prompt", systemPrompt + slidesInstructions() + deckInstructions(),
417
541
  "--setting-sources", "", "--strict-mcp-config", // no settings files, no MCP servers or connectors
418
542
  "--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})`]),
543
+ // any file in the deck folder (Edit rules cover Write too; Write is named as well, to be explicit)
544
+ "--allowedTools", "Read", "Glob", "Grep", "Edit(./**)", "Write(./**)", `Bash(${CLI_CMD}:*)`,
545
+ "--disallowedTools", ...lockedPaths().flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
421
546
  ...(settings.model ? ["--model", settings.model] : []),
422
547
  ...(settings.effort ? ["--effort", settings.effort] : []),
423
548
  ],
@@ -447,7 +572,12 @@ function toolSummary(name, input = {}) {
447
572
  return !r || r.startsWith("..") ? path.basename(p) : r;
448
573
  };
449
574
  switch (name) {
450
- case "Read": return { name: "Read", detail: base(input.file_path) };
575
+ case "Read": {
576
+ // a screenshot PinDeck took: the chat shows it, by its name in the shots folder
577
+ const f = input.file_path && path.resolve(DECK_DIR, input.file_path);
578
+ const shot = f && path.dirname(f) === SHOT_DIR && SHOT_NAME.test(path.basename(f)) ? path.basename(f) : undefined;
579
+ return { name: "Read", detail: base(input.file_path), shot };
580
+ }
451
581
  case "Edit": case "MultiEdit": case "Write": return { name: "Edit", detail: base(input.file_path) };
452
582
  case "Bash": {
453
583
  // a PinDeck command only when the whole command is one: a script that ends in `cli.mjs layout`
@@ -492,7 +622,9 @@ function handleEvent(ev) {
492
622
  }
493
623
 
494
624
  function startTurn(promptText, userEntry) {
495
- state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]) };
625
+ // every deck file as it is now: Claude may change any of them (not brand.css or vendor/), and undo
626
+ // takes back all it changed
627
+ state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]), scan: scanFiles(DECK_DIR) };
496
628
  state.busy = true;
497
629
  pushStatus();
498
630
  ensureSession();
@@ -516,11 +648,13 @@ function finishTurn({ error, cost, duration, denials = [] }) {
516
648
  }
517
649
  toolEntries.clear();
518
650
  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);
651
+ const { changed, tooLarge } = scanChanges(turn.scan, scanFiles(DECK_DIR));
652
+ const before = {}, after = {};
653
+ for (const [rel, c] of Object.entries(changed)) { before[rel] = c.before; after[rel] = c.after; }
654
+ // the deck file and theme.css first, then the rest by name
655
+ const order = (rel) => (EDITABLE.includes(rel) ? EDITABLE.indexOf(rel) : EDITABLE.length);
656
+ const files = Object.keys(changed).sort((a, b) => order(a) - order(b) || a.localeCompare(b))
657
+ .map((rel) => fileChange(rel, before[rel], after[rel]));
524
658
  // after a hand-off: imported pictures that Claude replaced (nothing refers to them any more) are
525
659
  // deleted; undo brings them back with the turn
526
660
  const deletedFiles = [];
@@ -530,11 +664,13 @@ function finishTurn({ error, cost, duration, denials = [] }) {
530
664
  deletedFiles.push({ rel, data: fs.readFileSync(abs) });
531
665
  fs.rmSync(abs, { force: true });
532
666
  }
533
- const t = { id: turn.id, kind: "claude", before: turn.before, after, files, deletedFiles, undone: false };
667
+ const t = { id: turn.id, kind: "claude", before, after, files, deletedFiles, undone: false };
534
668
  if (files.length) { state.turns.push(t); state.redo = []; }
535
669
  addEntry({ kind: "turn", turnId: files.length ? t.id : null, files, error, cost, duration,
536
670
  denials, restored, newIds: eid.assigned.length });
537
671
  if (deletedFiles.length) addEntry({ kind: "notice", text: `Removed ${deletedFiles.map((f) => f.rel).join(", ")}: replaced, and no longer used.` });
672
+ if (tooLarge.length) addEntry({ kind: "notice", text: `${tooLarge.join(", ")} changed in this turn but ${tooLarge.length > 1 ? "are" : "is"} ` +
673
+ `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
674
  pushStatus();
539
675
  }
540
676
 
@@ -593,7 +729,7 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
593
729
  for (const L of layouts) ctx.push("Layout:\n" + formatLayout(L));
594
730
  if (screenshot) ctx.push(`Screenshot of slide ${screenshot.slide} with the pin markers: ${screenshot.path} (Read it).`);
595
731
  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"));
732
+ `use these paths relative to ${DECK_FILE}):\n` + attachments.map((a) => formatAttachment(a) + formatDrop(a)).join("\n"));
597
733
  return (ctx.length ? `<editor-context>\n${ctx.join("\n\n")}\n</editor-context>\n\n` : "") + userText;
598
734
  }
599
735
 
@@ -620,7 +756,7 @@ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = ""
620
756
  if (out && fs.existsSync(path.join(DECK_DIR, out)) && !state.pdfFigures.has(out)) return bad(`${out} exists already; choose another name.`, 409);
621
757
  const tmp = path.join(SHOT_DIR, `pdf-${slug(path.basename(abs, ".pdf"))}-p${n}-${Date.now()}.png`);
622
758
  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);
759
+ if (!fs.existsSync(tmp)) return bad("The PDF page could not be rendered.", 500);
624
760
  const size = `${Math.round(pts[2] * scale)} × ${Math.round(pts[3] * scale)} px`;
625
761
  if (!out) return { text: `${tmp} (${size}). Read it; add a figures/ name to save it into the deck.` };
626
762
  const buf = fs.readFileSync(tmp);
@@ -670,6 +806,14 @@ function scheduleFlush() {
670
806
  };
671
807
  eidTimer = setTimeout(pass, 800);
672
808
  }
809
+ if (files.includes(DECK_FILE)) { // the slide tags, by hand or by Claude: tell both when they stop working
810
+ const now = { selector: deckSlides().selector, problem: deckProblemNow() };
811
+ if (now.selector !== deckNow.selector || now.problem !== deckNow.problem) {
812
+ if (now.problem) state.notes = [...new Set([...state.notes, `PinDeck cannot use the deck's slides now: ${now.problem}`])];
813
+ deckNow = now;
814
+ pushStatus();
815
+ }
816
+ }
673
817
  // manual: only drag/resize writes, which the browser already shows; no reload of the visible slide
674
818
  broadcast("files", { files, manual, rev: deckRev() });
675
819
  }, 120);
@@ -768,7 +912,7 @@ function deletePlan(eid) {
768
912
  Then nothing is written and the answer is 409 with the files that differ. */
769
913
  function undoConflicts(t, direction) {
770
914
  const expect = direction === "undo" ? t.after : t.before;
771
- const files = EDITABLE.filter((f) => (readDeck(f) ?? null) !== (expect[f] ?? null));
915
+ const files = changeFiles(t).filter((rel) => !isAs(rel, expect[rel]));
772
916
  // undo brings deleted files back (their place must be free, or hold the same bytes) and removes added
773
917
  // ones (which must be unchanged); redo the other way round
774
918
  const comeBack = direction === "undo" ? t.deletedFiles || [] : t.addedFiles || [];
@@ -794,11 +938,7 @@ function undo(res) {
794
938
  if (!t) return json(res, 409, { error: "Nothing to undo." });
795
939
  const diverged = undoConflicts(t, "undo");
796
940
  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
- }
941
+ for (const rel of changeFiles(t)) if (!isAs(rel, t.before[rel])) putDeckFile(rel, t.before[rel] ?? null); // null: the change created it
802
942
  for (const { rel, data } of t.deletedFiles || []) writeDeckFile(rel, data, false); // files deleted with an element come back too
803
943
  for (const { rel } of t.addedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true }); // imported figures go
804
944
  t.undone = true;
@@ -1037,23 +1177,41 @@ const server = http.createServer(async (req, res) => {
1037
1177
  const f = safeJoin(path.join(EDITOR_DIR, "public"), p.slice(8));
1038
1178
  return f ? serveFile(res, f, null, req) : text(res, 403, "Forbidden");
1039
1179
  }
1040
- if (p.startsWith("/deck/") && !ok) return text(res, 403, "Not logged in");
1180
+ if ((p.startsWith("/deck/") || p.startsWith("/files/") || p.startsWith("/shots/")) && !ok) return text(res, 403, "Not logged in");
1181
+ // /shots/: the screenshots of this deck, for the chat; only PNG files directly in its shots folder
1182
+ if (p.startsWith("/shots/")) {
1183
+ const name = decodeURIComponent(p.slice(7));
1184
+ if (req.method !== "GET" || !SHOT_NAME.test(name)) return text(res, 404, "Not found");
1185
+ return serveFile(res, path.join(SHOT_DIR, name), null, req);
1186
+ }
1041
1187
  let snap;
1042
- if (req.method === "GET" && (snap = p.match(/^\/deck\/__pindeck-print-([\w-]+)\.html$/))) {
1188
+ if (req.method === "GET" && (snap = p.match(/^\/(?:deck|files)\/(?:.*\/)?__pindeck-print-([\w-]+)\.html$/))) {
1043
1189
  const html = snapshots.get(snap[1]);
1044
1190
  if (!html) return text(res, 404, "Not found");
1045
1191
  res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
1046
1192
  return res.end(html);
1047
1193
  }
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");
1194
+ // /deck/: the deck folder; /deck/ alone is the deck page. With an asset root the page lives under
1195
+ // /files/, so that ../ paths reach the root: /deck/ alone redirects there.
1196
+ if (req.method === "GET" && p === "/deck/" && SERVE_ROOT) {
1197
+ res.writeHead(302, { location: DECK_PAGE + url.search, "cache-control": "no-store" });
1198
+ return res.end();
1199
+ }
1200
+ const deckPath = req.method === "GET" && (p.startsWith("/deck/") ? servable(DECK_DIR, p.slice(6) || DECK_FILE)
1201
+ : p.startsWith("/files/") && SERVE_ROOT ? servable(SERVE_ROOT, p.slice(7)) : undefined);
1202
+ if (deckPath === null) return text(res, 403, "Forbidden");
1203
+ if (deckPath) {
1204
+ const f = deckPath;
1051
1205
  const mode = url.searchParams.get("editor");
1052
- if (!mode || !f.endsWith(".html")) return serveFile(res, f, null, req);
1206
+ if (!mode || !/\.html?$/i.test(f)) return serveFile(res, f, null, req);
1053
1207
  return serveFile(res, f, (s) => {
1054
1208
  // headless runs must not stream media, or Chrome never finishes the page
1055
1209
  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);
1210
+ if (mode === "pdf") {
1211
+ s = s.replace(/<\/head>/i, PDF_HEAD_TAG).replace(/<\/body>/i, PDF_PAGE_TAG);
1212
+ // 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
1213
+ return slideSelector(parse(s)) ? s.replace(/<\/body>/i, BRIDGE_TAG) : s;
1214
+ }
1057
1215
  return s.replace(/<\/body>/i, BRIDGE_TAG);
1058
1216
  });
1059
1217
  }
@@ -1108,7 +1266,8 @@ const server = http.createServer(async (req, res) => {
1108
1266
  // only files PinDeck itself stored can be attached
1109
1267
  b.attachments = (b.attachments || []).filter((a) => a && state.uploads.has(a.path) && fs.existsSync(uploadFile(a.path)));
1110
1268
  b.attachments.forEach((a) => { state.uploads.delete(a.path); if (a.waveform) state.uploads.delete(a.waveform); });
1111
- const entry = addEntry({ kind: "user", text: b.text, screenshot: !!screenshot,
1269
+ // the file's name, so the chat can show it (true in chats from before 2.11)
1270
+ const entry = addEntry({ kind: "user", text: b.text, screenshot: screenshot ? path.basename(screenshot.path) : false,
1112
1271
  attachments: b.attachments.map((a) => ({ path: a.path, kind: a.kind, name: a.name })),
1113
1272
  selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, ""), slide: b.selection.slide.index + 1 } : null,
1114
1273
  pins: (b.pins || []).map((p) => ({ n: p.n, eid: p.eid, label: label(p), slide: p.slide.index + 1 })) });
@@ -1170,14 +1329,16 @@ const server = http.createServer(async (req, res) => {
1170
1329
  const b = await body(req);
1171
1330
  if (staleEdit(res, b)) return;
1172
1331
  const before = snapshot(EDITABLE);
1173
- const slides = findSlides(parse(before[DECK_FILE]));
1332
+ const tree = parse(before[DECK_FILE]), slides = findSlides(tree);
1174
1333
  const index = slides.findIndex((s) => s.attrs["data-eid"]?.value === b.eid);
1175
1334
  if (index < 0) return json(res, 404, { error: "Slide not found in the file." });
1176
1335
  const r = setAttr(before[DECK_FILE], b.eid, "data-visibility", b.hidden ? "hidden" : null);
1177
1336
  if (!r.changed) return json(res, 200, { unchanged: true });
1337
+ const skipped = slideSelector(tree) ? "left out of PDF exports (whether presenting skips them depends on the deck's script)"
1338
+ : "skipped when presenting and in PDF exports";
1178
1339
  const turnId = recordHandEdit(before, r.html, `${b.hidden ? "Hid" : "Showed"} slide ${index + 1}`,
1179
1340
  `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.`,
1341
+ `data-visibility="hidden": they stay in the file but are ${skipped}.`,
1181
1342
  { manual: true, chat: false }); // the editor reloads the slide itself; undo is in the toolbar
1182
1343
  return json(res, 200, { turnId, index });
1183
1344
  }
@@ -1269,7 +1430,7 @@ const server = http.createServer(async (req, res) => {
1269
1430
  if ((m = p.match(/^\/api\/turn\/(\d+)\/diff$/))) {
1270
1431
  const t = state.turns.find((t) => t.id === +m[1]);
1271
1432
  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"));
1433
+ return text(res, 200, changeFiles(t).map((rel) => fileDiff(rel, t.before[rel] ?? null, t.after[rel] ?? null)).filter(Boolean).join("\n\n"));
1273
1434
  }
1274
1435
  if ((m = p.match(/^\/api\/turn\/(\d+)\/undo$/)) && req.method === "POST") {
1275
1436
  const t = liveTurns().at(-1);
@@ -1284,11 +1445,7 @@ const server = http.createServer(async (req, res) => {
1284
1445
  const diverged = undoConflicts(t, "redo");
1285
1446
  if (diverged.length) return conflict(res, "redo", diverged);
1286
1447
  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
- }
1448
+ for (const rel of changeFiles(t)) if (!isAs(rel, t.after[rel])) putDeckFile(rel, t.after[rel] ?? null);
1292
1449
  for (const { rel } of t.deletedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true });
1293
1450
  for (const { rel, data } of t.addedFiles || []) writeDeckFile(rel, data, false);
1294
1451
  t.undone = false;
@@ -1422,11 +1579,13 @@ const server = http.createServer(async (req, res) => {
1422
1579
  return text(res, r.status || 200, r.text);
1423
1580
  }
1424
1581
  if (p === "/api/slides") {
1425
- return text(res, 200, slideList().map((s) => `${String(s.index + 1).padStart(2)} ${s.eid}`).join("\n"));
1582
+ const list = slideList(), why = !list.length && deckProblemNow();
1583
+ if (why) return text(res, 409, why);
1584
+ return text(res, 200, list.map((s) => `${String(s.index + 1).padStart(2)} ${s.eid}`).join("\n"));
1426
1585
  }
1427
1586
  if (p === "/api/layout" || p === "/api/shot") {
1428
1587
  const slide = resolveSlide(url.searchParams.get("slide"));
1429
- if (!slide) return text(res, 404, `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
1588
+ if (!slide) return text(res, 404, deckProblemNow() || `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
1430
1589
  if (!slide.eid) runEidPass();
1431
1590
  const s2 = slide.eid ? slide : resolveSlide(String(slide.index + 1));
1432
1591
  if (p === "/api/shot") {