pindeck 3.0.0-beta.2 → 3.1.0-beta.1

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 { CodexSession } from "./lib/codex.mjs";
@@ -39,18 +39,14 @@ import { isSupportedPlatform, detectCapabilities, publicCapabilities, capability
39
39
  import { unsupportedMessage } from "./lib/frameworks.mjs";
40
40
  import { readInstructions } from "./lib/instructions.mjs";
41
41
  import { writeAtomic, sameContent, revision, scanFiles, scanChanges } from "./lib/deck-files.mjs";
42
+ import { deckTarget, homeDir } from "./lib/deck-target.mjs";
43
+ import { goodHost, LOGIN_PAGE, createLogin } from "./lib/login.mjs";
44
+ import { USAGE } from "./lib/usage.mjs";
42
45
 
43
46
  /* ---- Config ------------------------------------------------------------------ */
44
- const USAGE = `Usage: pindeck [deck-folder | deck-file.html] [--port 5180] [--provider claude|codex] [--model <name>] [--asset-root <folder>]...
45
- pindeck login [deck-folder | deck-file.html]
46
-
47
- Opens a reveal.js deck in PinDeck: a deck file such as talk.html, or a folder (default: the current
48
- folder), which then opens its index.html.
49
- login prints a new one-time login link for the PinDeck running for that deck
50
- (without a deck: for the PinDeck started last).
51
- --asset-root lets the deck use files outside its folder: a folder that contains the deck (paths such as
52
- ../shared/style.css then work), or one that symlinks in the deck may point to. Repeatable; also
53
- "assetRoots" in ~/.pindeck-v3/config.json.`;
47
+ /* Started by the front (pindeck, front.mjs) for one of the decks it serves: the front logs browsers in,
48
+ prints the links and owns ~/.pindeck-v3/server.json; this server tells it its address over IPC. */
49
+ const FRONT = !!process.env.PINDECK_FRONT && !!process.send;
54
50
  const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
55
51
  const argv = process.argv.slice(2);
56
52
  if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
@@ -69,23 +65,14 @@ const LOGIN = argv[0] === "login";
69
65
  if (LOGIN) argv.shift();
70
66
  const PROVIDER = opt("--provider", "");
71
67
  const MODEL = opt("--model", "");
72
- /* The deck: a file (talk.html), or a folder, which means its index.html. A path ending in .html or .htm
73
- that is not a folder is a file, so a missing file gets a clear message. */
74
- const TARGET = path.resolve(argv[0] || process.cwd());
75
- const isFolder = (() => { try { return fs.statSync(TARGET).isDirectory(); } catch { return false; } })();
76
- const DECK_DIR = isFolder || !/\.html?$/i.test(TARGET) ? TARGET : path.dirname(TARGET);
77
- const DECK_FILE = DECK_DIR === TARGET ? "index.html" : path.basename(TARGET);
78
- const NAMED = DECK_FILE !== "index.html"; // a deck file with its own name; several can share a folder
79
- const DECK_NAME = NAMED ? DECK_FILE.replace(/\.html?$/i, "") : path.basename(DECK_DIR); // for exports and titles
68
+ /* The deck: a file (talk.html), or a folder, which means its index.html (see lib/deck-target.mjs) */
69
+ const { dir: DECK_DIR, file: DECK_FILE, named: NAMED, name: DECK_NAME, key: deckKey } = deckTarget(argv[0] || process.cwd());
80
70
  const EDITABLE = [DECK_FILE, "theme.css"]; // both backends may edit these
81
71
  const LOCKED = ["brand.css"]; // never, enforced twice
82
- const HOME_DIR = process.env.PINDECK_HOME || path.join(os.homedir(), ".pindeck-v3");
83
- /* State per deck file. A folder's index.html keeps the key it always had, so existing chats and undo
84
- histories stay; a named file gets its own, so two decks in one folder never share chat or undo. */
85
- const deckKey = (NAMED ? `${path.basename(DECK_DIR)}-${DECK_NAME}` : path.basename(DECK_DIR)).replace(/[^\w.-]+/g, "_") + "-" +
86
- crypto.createHash("sha1").update(NAMED ? path.join(DECK_DIR, DECK_FILE) : DECK_DIR).digest("hex").slice(0, 8);
72
+ const HOME_DIR = homeDir();
87
73
  const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
88
74
  const SHOT_DIR = path.join(STATE_DIR, "shots");
75
+ const SHOT_NAME = /^[\w-][\w.-]*\.png$/; // a file name in SHOT_DIR, as the editor may ask for it
89
76
  const PDF_DIR = path.join(STATE_DIR, "attachments"); // attached PDFs: reading material, not part of the deck
90
77
  /* pindeck login: a new one-time login link from the running server, asked with the token in its server
91
78
  file (readable only by this user) */
@@ -126,15 +113,18 @@ if (deckProblem) {
126
113
  console.error(`${deckProblem}\n\n${USAGE}`);
127
114
  process.exit(1);
128
115
  }
129
- // only reveal.js for now: another framework's deck is left exactly as it is (no ids, no state)
130
- const frameworkProblem = unsupportedMessage(fs.readFileSync(path.join(DECK_DIR, DECK_FILE), "utf8"), DECK_FILE);
116
+ // reveal.js, or a deck that names its slides: another framework's deck is left exactly as it is (no ids, no state)
117
+ const deckAtStart = fs.readFileSync(path.join(DECK_DIR, DECK_FILE), "utf8");
118
+ const frameworkProblem = unsupportedMessage(deckAtStart, DECK_FILE);
131
119
  if (frameworkProblem) {
132
120
  console.error(frameworkProblem);
133
121
  process.exit(1);
134
122
  }
135
123
  /* Runtime files hold the token, the chat and attachments: private to the user. Written only after the
136
124
  port is bound (see Start), so a failed start leaves no state behind. */
137
- const SERVER_FILES = [path.join(STATE_DIR, "server.json"), path.join(HOME_DIR, "server.json")]; // the second: the CLI's fallback
125
+ // the second: the CLI's fallback. Behind the front none: its own server.json leads pindeck login to the front
126
+ // (a deck server's port is no use to a browser behind a tunnel), and the assistant's CLI gets this server's address.
127
+ const SERVER_FILES = FRONT ? [] : [path.join(STATE_DIR, "server.json"), path.join(HOME_DIR, "server.json")];
138
128
  function writeServerInfo() {
139
129
  for (const d of [HOME_DIR, STATE_DIR, SHOT_DIR]) fs.mkdirSync(d, { recursive: true, mode: 0o700 });
140
130
  const info = JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: DECK_DIR, pid: process.pid }, null, 2);
@@ -159,6 +149,22 @@ const MIME = { html: "text/html; charset=utf-8", css: "text/css; charset=utf-8",
159
149
  mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", pdf: "application/pdf", md: "text/plain; charset=utf-8" };
160
150
  const hash = (s) => crypto.createHash("sha1").update(s).digest("hex");
161
151
  const readDeck = (f) => { try { return fs.readFileSync(path.join(DECK_DIR, f), "utf8"); } catch { return null; } };
152
+ /* A deck made without reveal.js names its slides with <meta name="pindeck:slides"> (null for reveal.js) and
153
+ their size with <meta name="pindeck:size">. Read from the deck as it is now, so a changed tag counts from
154
+ the next headless run or Claude session. Headless Chrome opens such a deck at the slide size, so a
155
+ screenshot is the slide itself. */
156
+ function deckSlides() {
157
+ const tree = parse(readDeck(DECK_FILE) || "");
158
+ const selector = slideSelector(tree);
159
+ return { selector, size: (selector && deckSize(tree)) || { width: 1280, height: 720 } };
160
+ }
161
+ /* Why the deck no longer names slides PinDeck can use (a tag changed while PinDeck runs), or null: the
162
+ start-up check's message, which the editor shows and Claude is told */
163
+ function deckProblemNow() {
164
+ const html = readDeck(DECK_FILE);
165
+ return (html != null && unsupportedMessage(html, DECK_FILE)?.replace(/ Nothing was changed\.$/, "")) || null;
166
+ }
167
+ let deckNow = { selector: deckSlides().selector, problem: null }; // for the editor; kept current by scheduleFlush
162
168
  const ourWrites = new Map(); // file -> { hash, manual } of what we wrote, to tell our writes from others
163
169
  function writeDeck(f, content, manual = false) {
164
170
  ourWrites.set(f, { hash: hash(content), manual });
@@ -283,15 +289,25 @@ const CHROME = CAPS.chromePath; // null when Chrome features are unavailable (
283
289
  /* Headless Chrome on macOS often lingers long after it has written its output, so we
284
290
  watch the output and stop Chrome as soon as `done(stdout, stderr)` says we have it. */
285
291
  let chromeSeq = 0;
292
+ /* The page gets the window's size too. Chrome lays it out about 87 px shorter than the window (room for
293
+ a toolbar it does not draw) and grows it only for the screenshot itself, too late for some versions
294
+ to lay the slide out again, so shot and dump lay the deck out for this size (see bridge.js). */
295
+ function sized(url, size) {
296
+ const u = new URL(url);
297
+ u.searchParams.set("width", size.width);
298
+ u.searchParams.set("height", size.height);
299
+ return u.href;
300
+ }
286
301
  function chrome(args, done) {
287
302
  if (!CHROME) return Promise.reject(new Error(`Screenshots are not available: ${CAPS.screenshot.reason}.`));
288
303
  return new Promise((resolve, reject) => {
289
304
  // a profile per run: concurrent runs must not share one
290
305
  const profile = path.join(STATE_DIR, "chrome", `p${process.pid}-${++chromeSeq}`);
306
+ const { size } = deckSlides();
291
307
  const proc = spawn(CHROME, ["--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run",
292
308
  "--no-default-browser-check", "--disable-extensions", "--mute-audio", `--user-data-dir=${profile}`,
293
- "--window-size=1280,720", "--force-device-scale-factor=1", "--virtual-time-budget=5000",
294
- ...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, a) : a)]);
309
+ `--window-size=${size.width},${size.height}`, "--force-device-scale-factor=1", "--virtual-time-budget=5000",
310
+ ...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, sized(a, size)) : a)]);
295
311
  let out = "", err = "", finished = false;
296
312
  const finish = (fn, v) => {
297
313
  if (finished) return;
@@ -372,7 +388,7 @@ function exportPdf(kind, includeHidden = false) {
372
388
  try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}${DECK_BASE}__pindeck-print-${id}.html`), out, printProfile); }
373
389
  finally { snapshots.delete(id); }
374
390
  return { kind: split ? "presentation" : "handout", path: out, rel: path.relative(DECK_DIR, out),
375
- url: `/deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
391
+ url: `deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
376
392
  };
377
393
  const job = exportQueue.then(run, run);
378
394
  exportQueue = job.catch(() => {});
@@ -467,7 +483,8 @@ let catalog = { claude: { models: [] }, codex: { models: [] } }, discovering = n
467
483
  let session = null, starting = null;
468
484
  const status = () => ({ busy: state.busy, switching: state.switching, model: state.model, sessionId: state.sessionId, settings: { ...settings },
469
485
  running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE,
470
- chrome: CAPS.screenshot.available, capabilities: publicCapabilities(CAPS), rev: deckRev(),
486
+ chrome: CAPS.screenshot.available, slideSelector: deckNow.selector, deckProblem: deckNow.problem,
487
+ capabilities: publicCapabilities(CAPS), rev: deckRev(),
471
488
  undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
472
489
  const pushStatus = () => broadcast("status", status());
473
490
 
@@ -479,6 +496,15 @@ const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md
479
496
  (ASSET_ROOTS.length ? `\n\n## Files outside the deck folder\nThe deck may use files in ${ASSET_ROOTS.join(", ")}: refer to ` +
480
497
  `them with paths relative to ${DECK_FILE} (such as ../shared/style.css). You can read them; do not edit them, ` +
481
498
  `other decks may use them too.\n` : "");
499
+ /* For a deck made without reveal.js: what its slides are, from its selector as it is at each session start */
500
+ function slidesInstructions() {
501
+ const { selector } = deckSlides();
502
+ return selector ? `\n\n## Slides of this deck\nThis deck is not made with reveal.js: its slides are the elements matching the ` +
503
+ `CSS selector \`${selector}\` (<meta name="pindeck:slides">), and the deck's own script shows them. Where these ` +
504
+ `instructions say <section> or reveal.js, read an element matching that selector, and that script. A new slide is ` +
505
+ `a new such element next to the others; keep the pindeck meta tags. Hidden slides (data-visibility="hidden") are ` +
506
+ `left out of PinDeck's PDF exports; whether presenting skips them depends on the deck's script.\n` : "";
507
+ }
482
508
  /* What Claude may never change, whatever the edit rules allow: the locked brand files, vendored libraries,
483
509
  git's own files, and other decks in this folder (a named deck shares its folder with them). Deny rules
484
510
  win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
@@ -515,7 +541,7 @@ When context mentions Read, use pindeck_read. Read screenshots with pindeck_read
515
541
  Read README.md and the deck instructions below for conventions. Use layout after every visual change.
516
542
  PDF tables should be reconstructed as HTML; pdf-figure renders figures. Audio is embedded by path.
517
543
 
518
- ## Hand edits`) + deckInstructions();
544
+ ## Hand edits`) + slidesInstructions() + deckInstructions();
519
545
  function makeSession(choice) {
520
546
  let initial = null;
521
547
  const options = {
@@ -535,7 +561,7 @@ function makeSession(choice) {
535
561
  explicit), PinDeck's own commands, never the locked paths. Deny rules win over allow rules. */
536
562
  const agent = choice.provider === "codex" ? new CodexSession({ ...options, settings: choice,
537
563
  instructions: codexInstructions(), tools: agentTools }) : new ClaudeSession({ ...options,
538
- args: () => ["--append-system-prompt", systemPrompt + deckInstructions(),
564
+ args: () => ["--append-system-prompt", systemPrompt + slidesInstructions() + deckInstructions(),
539
565
  "--setting-sources", "", "--strict-mcp-config", "--safe-mode", "--disable-slash-commands", // no settings files, no MCP servers or connectors
540
566
  "--permission-mode", "default", "--tools", "Read,Glob,Grep,Edit,Write,Bash",
541
567
  "--allowedTools", "Read", "Glob", "Grep", "Edit(./**)", "Write(./**)",
@@ -628,11 +654,13 @@ function toolSummary(name, input = {}) {
628
654
  const r = path.relative(DECK_DIR, p);
629
655
  return !r || r.startsWith("..") ? path.basename(p) : r;
630
656
  };
657
+ // a picture in the screenshots folder: the chat shows it
658
+ const shotOf = (p) => (p && realOr(path.dirname(path.resolve(p))) === realOr(SHOT_DIR) && SHOT_NAME.test(path.basename(p)) ? { shot: path.basename(p) } : {});
631
659
  switch (name) {
632
- case "pindeck_read": return { name: "Read", detail: base(input.path) };
660
+ case "pindeck_read": return { name: "Read", detail: base(input.path), ...shotOf(input.path) };
633
661
  case "pindeck_edit": case "pindeck_write": return { name: "Edit", detail: base(input.path) };
634
662
  case "pindeck_command": return { name: input.command || "Command", detail: (input.args || []).join(" ") };
635
- case "Read": return { name: "Read", detail: base(input.file_path) };
663
+ case "Read": return { name: "Read", detail: base(input.file_path), ...shotOf(input.file_path) };
636
664
  case "Edit": case "MultiEdit": case "Write": return { name: "Edit", detail: base(input.file_path) };
637
665
  case "Bash": {
638
666
  // a PinDeck command only when the whole command is one: a script that ends in `cli.mjs layout`
@@ -867,6 +895,14 @@ function scheduleFlush() {
867
895
  };
868
896
  eidTimer = setTimeout(pass, 800);
869
897
  }
898
+ if (files.includes(DECK_FILE)) { // the slide tags, by hand or by Claude: tell both when they stop working
899
+ const now = { selector: deckSlides().selector, problem: deckProblemNow() };
900
+ if (now.selector !== deckNow.selector || now.problem !== deckNow.problem) {
901
+ if (now.problem) state.notes = [...new Set([...state.notes, `PinDeck cannot use the deck's slides now: ${now.problem}`])];
902
+ deckNow = now;
903
+ pushStatus();
904
+ }
905
+ }
870
906
  // manual: only drag/resize writes, which the browser already shows; no reload of the visible slide
871
907
  broadcast("files", { files, manual, rev: deckRev() });
872
908
  }, 120);
@@ -1115,57 +1151,9 @@ async function refineImport(imp, pdf, plans, eids, first) {
1115
1151
 
1116
1152
  /* ---- HTTP ------------------------------------------------------------------------------------- */
1117
1153
  const BRIDGE_TAG = `<script src="/editor/bridge.js"></script>\n</body>`;
1118
- /* Pages opened as 127.0.0.1 or localhost only, on any port: an SSH tunnel may use another local port.
1119
- Another site's name pointed at 127.0.0.1 (DNS rebinding) must not read the editor page, its token or the deck. */
1120
- const goodHost = (h) => /^(127\.0\.0\.1|localhost)(:\d{1,5})?$/.test(h);
1121
-
1122
- /* Who may read the editor, the deck and the API. The token never appears in a page or on a command line,
1123
- where other users of a shared server could read it (ps, curl). A browser logs in once with a one-time
1124
- code (printed at start, or by `pindeck login`) and then holds the token in an HttpOnly cookie; the CLI
1125
- and Claude send it in a header. Browsers share cookies across all ports of a host, so the cookie name
1126
- carries this server's port. */
1127
- const loginCodes = new Map(); // code -> expiry (ms)
1128
- function loginCode(ttl) {
1129
- const now = Date.now();
1130
- for (const [c, exp] of loginCodes) if (exp < now) loginCodes.delete(c);
1131
- const code = crypto.randomBytes(16).toString("hex");
1132
- loginCodes.set(code, now + ttl);
1133
- return code;
1134
- }
1135
- const loginUrl = (code, next = "/") => `${ORIGIN}/login?` + new URLSearchParams({ code, ...(next !== "/" ? { next } : {}) });
1136
- const cookieName = () => `pindeck-${PORT}`;
1137
- const sameSecret = (a) => typeof a === "string" && a.length === TOKEN.length &&
1138
- crypto.timingSafeEqual(Buffer.from(a), Buffer.from(TOKEN));
1139
- function cookie(req, name) {
1140
- for (const part of String(req.headers.cookie || "").split(";")) {
1141
- const i = part.indexOf("=");
1142
- if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
1143
- }
1144
- return null;
1145
- }
1146
- const authed = (req) => sameSecret(req.headers["x-editor-token"]) || sameSecret(cookie(req, cookieName()));
1147
- const LOGIN_PAGE = `<!doctype html><meta charset="utf-8"><title>PinDeck</title>
1148
- <body style="font:16px system-ui,sans-serif;max-width:36em;margin:4em auto;padding:0 1em">
1149
- <h1>Log in to PinDeck</h1><p>Open the login link that PinDeck printed when it started. Links work once;
1150
- for a new one, run <code>pindeck login</code> in the deck folder (or <code>node server.mjs login</code> in a
1151
- PinDeck checkout).</p>`;
1152
- /* A one-time code: set the cookie, then go on with a same-origin navigation (a redirect from a cross-site
1153
- start, such as a file:// page or a link in a terminal, would not carry a SameSite=Strict cookie). */
1154
- function login(res, url) {
1155
- const code = url.searchParams.get("code") || "";
1156
- const exp = loginCodes.get(code);
1157
- loginCodes.delete(code);
1158
- if (!exp || exp < Date.now()) {
1159
- res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
1160
- return res.end(LOGIN_PAGE.replace("<p>", "<p>This login link has expired or was used already. "));
1161
- }
1162
- let next = url.searchParams.get("next") || "/";
1163
- if (!next.startsWith("/") || next.startsWith("//") || next.includes("\\")) next = "/";
1164
- res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
1165
- "set-cookie": `${cookieName()}=${TOKEN}; HttpOnly; SameSite=Strict; Path=/` });
1166
- const to = JSON.stringify(next).replace(/</g, "\\u003c");
1167
- res.end(`<!doctype html><meta charset="utf-8"><title>PinDeck</title><script>location.replace(${to})</script>`);
1168
- }
1154
+ /* Who may read the editor, the deck and the API: see lib/login.mjs. Behind the front, browsers log in
1155
+ there, and the front sends this server's token in a header. */
1156
+ const { loginCode, loginUrl, authed, login } = createLogin({ token: TOKEN, port: () => PORT, origin: () => ORIGIN });
1169
1157
  /* Headless Chrome gets a start page in its own profile folder (0700) instead of a URL with a secret on
1170
1158
  its command line: the page sends it to a one-time login code, which leads on to the target. */
1171
1159
  function chromeStart(profile, target) {
@@ -1239,9 +1227,10 @@ const server = http.createServer(async (req, res) => {
1239
1227
  return res.end(html);
1240
1228
  }
1241
1229
  // /deck/: the deck folder; /deck/ alone is the deck page. With an asset root the page lives under
1242
- // /files/, so that ../ paths reach the root: /deck/ alone redirects there.
1230
+ // /files/, so that ../ paths reach the root: /deck/ alone redirects there (a relative redirect, which
1231
+ // stays under the front's /d/<deck>/).
1243
1232
  if (req.method === "GET" && p === "/deck/" && SERVE_ROOT) {
1244
- res.writeHead(302, { location: DECK_PAGE + url.search, "cache-control": "no-store" });
1233
+ res.writeHead(302, { location: ".." + DECK_PAGE + url.search, "cache-control": "no-store" });
1245
1234
  return res.end();
1246
1235
  }
1247
1236
  const deckPath = req.method === "GET" && (p.startsWith("/deck/") ? servable(DECK_DIR, p.slice(6) || DECK_FILE)
@@ -1254,11 +1243,26 @@ const server = http.createServer(async (req, res) => {
1254
1243
  return serveFile(res, f, (s) => {
1255
1244
  // headless runs must not stream media, or Chrome never finishes the page
1256
1245
  if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
1257
- if (mode === "pdf") return s.replace(/<\/head>/i, PDF_HEAD_TAG).replace(/<\/body>/i, PDF_PAGE_TAG);
1246
+ if (mode === "pdf") {
1247
+ s = s.replace(/<\/head>/i, PDF_HEAD_TAG).replace(/<\/body>/i, PDF_PAGE_TAG);
1248
+ // 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
1249
+ return slideSelector(parse(s)) ? s.replace(/<\/body>/i, BRIDGE_TAG) : s;
1250
+ }
1258
1251
  return s.replace(/<\/body>/i, BRIDGE_TAG);
1259
1252
  });
1260
1253
  }
1261
1254
 
1255
+ // /shots/: a screenshot for the chat, a PNG directly in the screenshots folder (no subfolder, no symlink out)
1256
+ if (req.method === "GET" && p.startsWith("/shots/")) {
1257
+ if (!ok) return text(res, 403, "Not logged in");
1258
+ const name = decodeURIComponent(p.slice(7));
1259
+ if (!SHOT_NAME.test(name)) return text(res, 403, "Forbidden");
1260
+ const f = path.join(SHOT_DIR, name);
1261
+ let real;
1262
+ try { real = fs.realpathSync(f); } catch { return text(res, 404, "Not found"); }
1263
+ return path.dirname(real) === realOr(SHOT_DIR) ? serveFile(res, f, null, req) : text(res, 403, "Forbidden");
1264
+ }
1265
+
1262
1266
  // API: token required (header or login cookie); POSTs must come from the editor page itself
1263
1267
  if (!p.startsWith("/api/")) return text(res, 404, "Not found");
1264
1268
  if (!ok) return text(res, 403, "Bad token");
@@ -1315,7 +1319,8 @@ const server = http.createServer(async (req, res) => {
1315
1319
  // only files PinDeck itself stored can be attached
1316
1320
  b.attachments = (b.attachments || []).filter((a) => a && state.uploads.has(a.path) && fs.existsSync(uploadFile(a.path)));
1317
1321
  b.attachments.forEach((a) => { state.uploads.delete(a.path); if (a.waveform) state.uploads.delete(a.waveform); });
1318
- const entry = addEntry({ kind: "user", text: b.text, screenshot: !!screenshot,
1322
+ const entry = addEntry({ kind: "user", text: b.text,
1323
+ screenshot: screenshot && { file: path.basename(screenshot.path), slide: screenshot.slide },
1319
1324
  attachments: b.attachments.map((a) => ({ path: a.path, kind: a.kind, name: a.name })),
1320
1325
  selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, ""), slide: b.selection.slide.index + 1 } : null,
1321
1326
  pins: (b.pins || []).map((p) => ({ n: p.n, eid: p.eid, label: label(p), slide: p.slide.index + 1 })) });
@@ -1348,7 +1353,7 @@ const server = http.createServer(async (req, res) => {
1348
1353
  const rel = attachPath(original);
1349
1354
  writeDeckFile(rel, buf);
1350
1355
  state.uploads.add(rel);
1351
- return json(res, 200, { path: rel, kind, bytes: buf.length, url: `/deck/${rel}` });
1356
+ return json(res, 200, { path: rel, kind, bytes: buf.length, url: `deck/${rel}` });
1352
1357
  }
1353
1358
  if (p === "/api/upload-remove" && req.method === "POST") {
1354
1359
  const b = await body(req);
@@ -1371,14 +1376,16 @@ const server = http.createServer(async (req, res) => {
1371
1376
  const b = await body(req);
1372
1377
  if (staleEdit(res, b)) return;
1373
1378
  const before = snapshot(EDITABLE);
1374
- const slides = findSlides(parse(before[DECK_FILE]));
1379
+ const tree = parse(before[DECK_FILE]), slides = findSlides(tree);
1375
1380
  const index = slides.findIndex((s) => s.attrs["data-eid"]?.value === b.eid);
1376
1381
  if (index < 0) return json(res, 404, { error: "Slide not found in the file." });
1377
1382
  const r = setAttr(before[DECK_FILE], b.eid, "data-visibility", b.hidden ? "hidden" : null);
1378
1383
  if (!r.changed) return json(res, 200, { unchanged: true });
1384
+ const skipped = slideSelector(tree) ? "left out of PDF exports (whether presenting skips them depends on the deck's script)"
1385
+ : "skipped when presenting and in PDF exports";
1379
1386
  const turnId = recordHandEdit(before, r.html, `${b.hidden ? "Hid" : "Showed"} slide ${index + 1}`,
1380
1387
  `The user ${b.hidden ? "hid" : "showed again"} slide ${index + 1} (${b.eid}). Hidden slides carry ` +
1381
- `data-visibility="hidden": they stay in the file but are skipped when presenting and in PDF exports.`,
1388
+ `data-visibility="hidden": they stay in the file but are ${skipped}.`,
1382
1389
  { manual: true, chat: false }); // the editor reloads the slide itself; undo is in the toolbar
1383
1390
  return json(res, 200, { turnId, index });
1384
1391
  }
@@ -1624,11 +1631,13 @@ const server = http.createServer(async (req, res) => {
1624
1631
  return text(res, r.status || 200, r.text);
1625
1632
  }
1626
1633
  if (p === "/api/slides") {
1627
- return text(res, 200, slideList().map((s) => `${String(s.index + 1).padStart(2)} ${s.eid}`).join("\n"));
1634
+ const list = slideList(), why = !list.length && deckProblemNow();
1635
+ if (why) return text(res, 409, why);
1636
+ return text(res, 200, list.map((s) => `${String(s.index + 1).padStart(2)} ${s.eid}`).join("\n"));
1628
1637
  }
1629
1638
  if (p === "/api/layout" || p === "/api/shot") {
1630
1639
  const slide = resolveSlide(url.searchParams.get("slide"));
1631
- if (!slide) return text(res, 404, `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
1640
+ if (!slide) return text(res, 404, deckProblemNow() || `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
1632
1641
  if (!slide.eid) runEidPass();
1633
1642
  const s2 = slide.eid ? slide : resolveSlide(String(slide.index + 1));
1634
1643
  if (p === "/api/shot") {
@@ -1673,12 +1682,18 @@ server.listen(PORT, "127.0.0.1", () => {
1673
1682
  process.exit(1);
1674
1683
  }
1675
1684
  ready = true;
1676
- console.log(`PinDeck: ${loginUrl(loginCode(3600000))}`);
1677
- console.log(" (a one-time login link, valid for an hour; for a new one: pindeck login)");
1678
- console.log(`Deck: ${DECK_DIR}`);
1679
- console.log(`Server: ${CAPS.label}`);
1680
- for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
1681
- if (c && c.available === false) console.log(` ${name}: off (${c.reason})`);
1685
+ if (FRONT) {
1686
+ // the front prints the login link and the capabilities; it ends this server when it goes, even killed
1687
+ process.send({ ready: { url: ORIGIN, token: TOKEN } });
1688
+ process.on("disconnect", () => { if (!quitting) quit(); }); // a second quit() would exit at once
1689
+ } else {
1690
+ console.log(`PinDeck: ${loginUrl(loginCode(3600000))}`);
1691
+ console.log(" (a one-time login link, valid for an hour; for a new one: pindeck login)");
1692
+ console.log(`Deck: ${DECK_DIR}`);
1693
+ console.log(`Server: ${CAPS.label}`);
1694
+ for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
1695
+ if (c && c.available === false) console.log(` ${name}: off (${c.reason})`);
1696
+ }
1682
1697
  discoverModels().then(() => {
1683
1698
  if (!settings.model) {
1684
1699
  const models = catalog[settings.provider].models;