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/README.md +123 -20
- package/cli.mjs +2 -1
- package/lib/capabilities.mjs +13 -7
- package/lib/deck-files.mjs +38 -0
- package/lib/eids.mjs +101 -8
- package/lib/frameworks.mjs +59 -0
- package/lib/instructions.mjs +74 -0
- package/lib/pptx-render.mjs +78 -9
- package/lib/system-prompt.md +5 -3
- package/lib/waveform.mjs +9 -3
- package/package.json +1 -1
- package/public/bridge.js +248 -69
- package/public/editor.js +17 -9
- package/public/index.html +1 -1
- package/server.mjs +207 -61
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 {
|
|
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
|
|
43
|
-
|
|
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
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
|
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 = `${
|
|
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}
|
|
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}
|
|
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
|
|
401
|
-
the system prompt instead, read afresh at each session start
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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
|
-
|
|
420
|
-
"--
|
|
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
|
-
|
|
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
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
|
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
|
-
|
|
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("
|
|
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 =
|
|
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
|
|
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
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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 ||
|
|
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")
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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") {
|