pindeck 3.0.0-beta.1 → 3.0.0-beta.3

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.
@@ -0,0 +1,121 @@
1
+ /* What this server can do on this machine, decided once on the backend and used everywhere: the editor
2
+ (status), the API (refusals before any side effect), the CLI (its error text) and the assistant's system prompt.
3
+
4
+ macOS: everything, as before. Linux (beta): editing, the chat, live reload and the PowerPoint import;
5
+ pictures of PDF pages with Poppler, pictures of PowerPoint slides with LibreOffice and Poppler, and
6
+ waveforms of non-WAV audio with ffmpeg, each when installed, else off with a reason. Chrome features (screenshots, headless
7
+ layout, PDF export) work on Linux as on macOS when Chrome or Chromium is found; PINDECK_LINUX_CHROME=0
8
+ turns them off. Each entry is { available, reason }: reason says why not, in plain words. */
9
+
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+
13
+ export const SUPPORTED_PLATFORMS = ["darwin", "linux"];
14
+ export const isSupportedPlatform = (platform) => SUPPORTED_PLATFORMS.includes(platform);
15
+ export const platformLabel = (platform) => ({ darwin: "macOS", linux: "Linux (beta)" }[platform] || platform);
16
+
17
+ const CHROME_PATHS = {
18
+ darwin: ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
19
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
20
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
21
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"],
22
+ linux: ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser",
23
+ "/snap/bin/chromium"],
24
+ };
25
+ const OFFICE_APPS = ["/Applications/Microsoft PowerPoint.app", "/Applications/Keynote.app"];
26
+
27
+ const isExecutable = (p) => {
28
+ try { return fs.statSync(p).isFile() && (fs.accessSync(p, fs.constants.X_OK), true); } catch { return false; }
29
+ };
30
+
31
+ /* An executable by path, or by name on PATH. Never runs it. Returns { path } or { path: null, reason }. */
32
+ export function findExecutable(name, { env = process.env } = {}) {
33
+ if (name.includes("/")) {
34
+ if (isExecutable(name)) return { path: name };
35
+ return { path: null, reason: fs.existsSync(name) ? `${name} is not an executable file` : `${name} does not exist` };
36
+ }
37
+ for (const dir of String(env.PATH || "").split(path.delimiter).filter(Boolean)) {
38
+ const p = path.join(dir, name);
39
+ if (isExecutable(p)) return { path: p };
40
+ }
41
+ return { path: null, reason: `no "${name}" command on the PATH` };
42
+ }
43
+
44
+ const on = () => ({ available: true, reason: "" });
45
+ const off = (reason) => ({ available: false, reason });
46
+
47
+ export function detectCapabilities({ platform = process.platform, env = process.env } = {}) {
48
+ const mac = platform === "darwin";
49
+ const caps = { platform, label: platformLabel(platform) };
50
+
51
+ const claudeBin = env.CLAUDE_BIN || "claude";
52
+ const claude = findExecutable(claudeBin, { env });
53
+ caps.claude = claude.path ? on() : off(`Claude Code is not available on the server (${claude.reason}). ` +
54
+ `Install and log in to the claude CLI there, or set CLAUDE_BIN. Editing by hand still works.`);
55
+ const codex = findExecutable(env.CODEX_BIN || "codex", { env });
56
+ caps.codex = codex.path ? on() : off(`OpenAI Codex is not available on the server (${codex.reason}). ` +
57
+ `Install and log in to the codex CLI there, or set CODEX_BIN. Editing by hand still works.`);
58
+
59
+ let chrome = null, chromeWhy = "";
60
+ if (env.CHROME_BIN) {
61
+ const c = findExecutable(env.CHROME_BIN, { env });
62
+ chrome = c.path;
63
+ chromeWhy = c.reason ? `CHROME_BIN: ${c.reason}` : "";
64
+ } else {
65
+ chrome = (CHROME_PATHS[platform] || []).find(isExecutable) || null;
66
+ chromeWhy = chrome ? "" : "no Chrome or Chromium found (set CHROME_BIN)";
67
+ }
68
+ if (chrome && platform === "linux" && env.PINDECK_LINUX_CHROME === "0") {
69
+ chrome = null;
70
+ chromeWhy = "Chrome features are turned off (PINDECK_LINUX_CHROME=0)";
71
+ }
72
+ const chromeCap = chrome ? on() : off(chromeWhy);
73
+ caps.chromePath = chrome;
74
+ caps.screenshot = chromeCap;
75
+ caps.headlessLayout = chromeCap;
76
+ caps.pdfExport = chromeCap;
77
+
78
+ // Linux: Poppler renders PDF pages, LibreOffice exports PowerPoint slides to PDF, ffmpeg decodes audio
79
+ const has = (name) => !!findExecutable(name, { env }).path;
80
+ const poppler = has("pdftoppm") && has("pdfinfo"), soffice = has("soffice") || has("libreoffice");
81
+ caps.pdfPages = mac || poppler ? on()
82
+ : off("Pictures of PDF pages need Poppler on the server (pdftoppm and pdfinfo; install poppler-utils). Attached PDFs can still be read.");
83
+ caps.pptxImport = on(); // the import itself reads the .pptx file: no other program needed
84
+ caps.pptxPictures = mac ? (OFFICE_APPS.some((a) => fs.existsSync(a)) ? on() : off("Pictures of PowerPoint slides need PowerPoint or Keynote."))
85
+ : soffice && poppler ? on()
86
+ : off("Pictures of PowerPoint slides need LibreOffice (soffice) and Poppler (pdftoppm, pdfinfo) on the server.");
87
+ caps.audioConvert = mac || has("ffmpeg") ? on() : off("Waveforms for audio other than WAV need ffmpeg on the server.");
88
+ return caps;
89
+ }
90
+
91
+ /* The capabilities for the editor and the status API: no executable paths. */
92
+ export const publicCapabilities = ({ chromePath, ...caps }) => caps;
93
+
94
+ /* The lines of Claude's system prompt about what this server cannot do. Empty on a full macOS setup. */
95
+ export function capabilityPrompt(caps, cli) {
96
+ const lines = [];
97
+ if (!caps.screenshot.available)
98
+ lines.push(`- \`${cli} shot\` does not work here: ${caps.screenshot.reason}. Use \`${cli} layout\` to check slides; ` +
99
+ `it works while the editor is open in the user's browser.`);
100
+ if (!caps.pdfExport.available) lines.push(`- \`${cli} pdf\` does not work here: ${caps.pdfExport.reason}.`);
101
+ if (!caps.pdfPages.available) lines.push(`- \`${cli} pdf-figure\` does not work here: ${caps.pdfPages.reason}`);
102
+ if (!lines.length) return "";
103
+ return `\n\n## This PinDeck server (${caps.label})\nSome commands are not available, so do not try them:\n${lines.join("\n")}\n`;
104
+ }
105
+
106
+ /* --port / PORT: an integer 0–65535 (0: any free port, for tests). null if invalid. */
107
+ export function parsePort(value) {
108
+ const s = String(value ?? "").trim();
109
+ if (!/^\d+$/.test(s)) return null;
110
+ const n = Number(s);
111
+ return n <= 65535 ? n : null;
112
+ }
113
+
114
+ /* The command Claude runs for the CLI, quoted for the shell. The Bash permission is this exact prefix, so
115
+ it must be one fixed string; double quotes as before, single quotes when the path has characters that
116
+ double quotes do not protect. */
117
+ export function cliCommand(rel) {
118
+ if (/^[\w./@%+=:,-]+$/.test(rel)) return `node ${rel}`;
119
+ if (!/["$`\\!]/.test(rel)) return `node "${rel}"`;
120
+ return `node '${rel.replaceAll("'", `'\\''`)}'`;
121
+ }
package/lib/claude.mjs CHANGED
@@ -3,19 +3,22 @@ import { promisify } from 'node:util';
3
3
  const exec = promisify(execFile);
4
4
  import { AgentProcess } from './agent-process.mjs';
5
5
  import { normalizeModels } from './models.mjs';
6
+ /* A long-running Claude Code process in stream-json mode. One process serves the whole chat; it runs under
7
+ the user's normal Claude login. env may be a function: the server's address is known only once it listens. */
6
8
  export class ClaudeSession {
7
9
  constructor(options) { Object.assign(this, options); this.transport = null; this.sessionId = null; }
8
10
  get running() { return !!this.transport && !this.transport.closed; }
9
11
  async start(resumeId) {
10
12
  if (this.running) return;
11
13
  this.stopped = false;
14
+ const env = typeof this.env === 'function' ? this.env() : this.env;
12
15
  try {
13
16
  const { stdout } = await exec(process.env.CLAUDE_BIN || 'claude', ['auth', 'status', '--json'],
14
- { env: { ...process.env, ...this.env }, timeout: 15000, maxBuffer: 65536 });
17
+ { env: { ...process.env, ...env }, timeout: 15000, maxBuffer: 65536 });
15
18
  if (!JSON.parse(stdout).loggedIn) throw new Error('Not signed in.');
16
19
  } catch (e) { throw new Error(e.code === 'ENOENT' ? 'Claude CLI is not installed (or set CLAUDE_BIN).' : 'Claude is not signed in. Run claude auth login, then refresh models.'); }
17
20
  if (this.stopped) throw new Error('Agent stopped.');
18
- const transport = new AgentProcess({ binary: process.env.CLAUDE_BIN || 'claude', cwd: this.cwd, env: this.env,
21
+ const transport = new AgentProcess({ binary: process.env.CLAUDE_BIN || 'claude', cwd: this.cwd, env,
19
22
  args: ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose',
20
23
  ...(typeof this.args === 'function' ? this.args() : this.args || []), ...(resumeId ? ['--resume', resumeId] : [])],
21
24
  onStderr: this.onStderr,
@@ -34,5 +37,6 @@ export class ClaudeSession {
34
37
  }
35
38
  async send(content) { this.transport.write({ type: 'user', message: { role: 'user', content } }); }
36
39
  async interrupt() { if (this.running) await this.transport.request('interrupt', {}, { claude: true, timeout: 5000 }); }
37
- stop() { this.stopped = true; const t = this.transport; this.transport = null; t?.stop(); }
40
+ /* Resolves once the process has exited, so shutdown can wait for it. */
41
+ stop() { this.stopped = true; const t = this.transport; this.transport = null; return t ? t.stop() : Promise.resolve(); }
38
42
  }
package/lib/codex.mjs CHANGED
@@ -1,6 +1,8 @@
1
+ import fs from 'node:fs';
1
2
  import { AgentProcess } from './agent-process.mjs';
2
3
  import { normalizeModels } from './models.mjs';
3
4
  import { deckTools } from './agent-tools.mjs';
5
+ const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
4
6
  export const CODEX_CONFIG = {
5
7
  'features.shell_tool': false, 'features.unified_exec': false, 'features.js_repl': false,
6
8
  'features.apps': false, 'features.plugins': false, 'features.multi_agent': false,
@@ -23,7 +25,7 @@ export class CodexSession {
23
25
  });
24
26
  this.transport = transport;
25
27
  try {
26
- await transport.request('initialize', { clientInfo: { name: 'pindeck', version: '3.0.0-beta.1' }, capabilities: { experimentalApi: true } });
28
+ await transport.request('initialize', { clientInfo: { name: 'pindeck', version: VERSION }, capabilities: { experimentalApi: true } });
27
29
  transport.write({ method: 'initialized', params: {} });
28
30
  const auth = await transport.request('account/read', { refreshToken: false });
29
31
  if (auth.requiresOpenaiAuth && !auth.account) throw new Error('Codex is not signed in. Run codex login, then refresh models.');
@@ -100,5 +102,5 @@ export class CodexSession {
100
102
  this.cancelRequested = true; this.controller?.abort();
101
103
  if (this.turnId && this.running) await this.transport.request('turn/interrupt', { threadId: this.sessionId, turnId: this.turnId });
102
104
  }
103
- stop() { this.controller?.abort(); this.controller = null; const t = this.transport; this.transport = null; t?.stop(); }
105
+ stop() { this.controller?.abort(); this.controller = null; const t = this.transport; this.transport = null; return t ? t.stop() : Promise.resolve(); }
104
106
  }
@@ -0,0 +1,94 @@
1
+ /* Writing deck files safely, and telling whether they changed.
2
+
3
+ writeAtomic: the new content goes to a temporary file next to the target, is flushed, gets the target's
4
+ permissions and then replaces the target with one rename. A reader (the browser, Claude, an editor, the
5
+ watcher) sees the old file or the new one, never half of it. A symlinked target is written where the link
6
+ points, so the link stays a link. Each file is atomic on its own; a set of files is not one transaction.
7
+
8
+ revision: a hash of the deck's editable files as they are now. The editor sends the revision it shows
9
+ with each change; a different one means someone else changed the files in between (409, nothing written).
10
+ This catches stale edits from the editor. It is not a lock: a program that writes between PinDeck's check
11
+ and its write can still lose its change, so only one writer (PinDeck, Claude, or another tool) at a time. */
12
+
13
+ import crypto from "node:crypto";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+
17
+ export function writeAtomic(file, data) {
18
+ let target = file, mode = null;
19
+ try {
20
+ if (fs.lstatSync(file).isSymbolicLink()) target = fs.realpathSync(file);
21
+ mode = fs.statSync(target).mode & 0o7777;
22
+ } catch (e) { if (e.code !== "ENOENT") throw e; }
23
+ const tmp = path.join(path.dirname(target), `.${path.basename(target)}.pindeck-${crypto.randomBytes(6).toString("hex")}.tmp`);
24
+ let fd = null;
25
+ try {
26
+ fd = fs.openSync(tmp, "wx", mode ?? 0o666); // wx: never reuse an existing file
27
+ fs.writeFileSync(fd, data);
28
+ fs.fsyncSync(fd);
29
+ if (mode != null) fs.fchmodSync(fd, mode); // the umask must not change the file's permissions
30
+ fs.closeSync(fd);
31
+ fd = null;
32
+ fs.renameSync(tmp, target);
33
+ } catch (e) {
34
+ if (fd != null) try { fs.closeSync(fd); } catch {}
35
+ try { fs.rmSync(tmp, { force: true }); } catch {}
36
+ throw new Error(`Could not save ${path.basename(file)}: ${e.message}`);
37
+ }
38
+ }
39
+
40
+ /* Same bytes? (a missing file is null) */
41
+ export function sameContent(file, data) {
42
+ let now;
43
+ try { now = fs.readFileSync(file); } catch (e) { if (e.code === "ENOENT") return data == null; throw e; }
44
+ return data != null && now.equals(Buffer.isBuffer(data) ? data : Buffer.from(data));
45
+ }
46
+
47
+ export function revision(dir, files) {
48
+ const h = crypto.createHash("sha256");
49
+ for (const f of files) {
50
+ let c = null;
51
+ try { c = fs.readFileSync(path.join(dir, f)); } catch {}
52
+ h.update(f + (c == null ? "\0-" : `\0${c.length}\0`));
53
+ if (c) h.update(c);
54
+ }
55
+ return h.digest("hex").slice(0, 16);
56
+ }
57
+
58
+ /* The deck's files for undo: every file a turn may change, with its content (bigger files: only size and
59
+ time, too large to keep a copy of). Hidden files and folders (.git), node_modules, and the vendor/ and
60
+ export/ folders are left out; so are symlinks, which point elsewhere. */
61
+ const SKIP_TOP = new Set(["vendor", "export", "node_modules"]);
62
+ export const KEEP_BYTES = 2 * 1024 * 1024;
63
+ export function scanFiles(dir, maxBytes = KEEP_BYTES) {
64
+ const files = new Map();
65
+ const walk = (rel) => {
66
+ let entries = [];
67
+ try { entries = fs.readdirSync(path.join(dir, rel), { withFileTypes: true }); } catch { return; }
68
+ for (const e of entries) {
69
+ if (e.name.startsWith(".") || e.name === "node_modules") continue;
70
+ const r = rel ? `${rel}/${e.name}` : e.name;
71
+ if (e.isDirectory()) { if (rel || !SKIP_TOP.has(e.name)) walk(r); continue; }
72
+ if (!e.isFile()) continue;
73
+ try {
74
+ const st = fs.statSync(path.join(dir, r));
75
+ files.set(r, { size: st.size, mtimeMs: st.mtimeMs, data: st.size <= maxBytes ? fs.readFileSync(path.join(dir, r)) : null });
76
+ } catch {}
77
+ }
78
+ };
79
+ walk("");
80
+ return files;
81
+ }
82
+
83
+ /* What changed between two scans: { rel: { before, after } } with the content on each side (null: the
84
+ file did not exist), and the files that changed but are too large to keep a copy of. */
85
+ export function scanChanges(before, after) {
86
+ const changed = {}, tooLarge = [];
87
+ for (const rel of new Set([...before.keys(), ...after.keys()])) {
88
+ const b = before.get(rel), a = after.get(rel);
89
+ if (b && a && b.size === a.size && (b.data && a.data ? b.data.equals(a.data) : b.mtimeMs === a.mtimeMs)) continue;
90
+ if ((b && !b.data) || (a && !a.data)) { tooLarge.push(rel); continue; }
91
+ changed[rel] = { before: b ? b.data : null, after: a ? a.data : null };
92
+ }
93
+ return { changed, tooLarge };
94
+ }
package/lib/eids.mjs CHANGED
@@ -145,8 +145,97 @@ function find(node, pred) {
145
145
  return hit;
146
146
  }
147
147
 
148
- /* Slides are leaf <section>s inside .slides; vertical-stack containers are not slides. */
148
+ /* ---- Decks made without reveal.js ----------------------------------------------------------------
149
+ A deck names its slides with <meta name="pindeck:slides" content="main.deck > section.slide">, a CSS
150
+ selector, and its size with <meta name="pindeck:size" content="1440x1080">. Selectors stay simple, so
151
+ the server and the browser find the same slides: tag names, #id, .class, [attr] and [attr="value"],
152
+ joined by spaces (descendant) or ">" (child), in a comma-separated list. */
153
+
154
+ const metaOf = (tree, name) => {
155
+ const m = find(tree, (c) => c.name === "meta" && c.attrs.name?.value.trim().toLowerCase() === `pindeck:${name}`);
156
+ return m ? decode(m.attrs.content?.value || "").trim() || null : null;
157
+ };
158
+ /* The deck's slide selector, or null for a reveal.js deck */
159
+ export const slideSelector = (tree) => metaOf(tree, "slides");
160
+ /* The deck's slide size from <meta name="pindeck:size">: { width, height } or null */
161
+ export function deckSize(tree) {
162
+ const m = /^(\d{2,5})\s*[x×*,\s]\s*(\d{2,5})$/i.exec(metaOf(tree, "size") || "");
163
+ return m ? { width: +m[1], height: +m[2] } : null;
164
+ }
165
+
166
+ const IDENT = "-?[_a-zA-Z\\u00a0-\\uffff][\\w\\u00a0-\\uffff-]*";
167
+ const COMPOUND = new RegExp(`^(?:(\\*|${IDENT})|#(${IDENT})|\\.(${IDENT})|\\[\\s*(${IDENT})\\s*(?:=\\s*(?:"([^"]*)"|'([^']*)'|(${IDENT}))\\s*)?\\])`);
168
+ /* "main.deck > section.slide, aside.extra" -> one list of compounds and combinators per selector.
169
+ Throws with the part it cannot read (pseudo-classes, +, ~ ...). */
170
+ export function parseSelector(sel) {
171
+ const parts = []; // top-level commas only: not inside [attr="a,b"]
172
+ let quote = null, depth = 0, from = 0;
173
+ for (let i = 0; i < sel.length; i++) {
174
+ const c = sel[i];
175
+ if (quote) { if (c === quote) quote = null; }
176
+ else if (c === '"' || c === "'") quote = c;
177
+ else if (c === "[") depth++;
178
+ else if (c === "]") depth--;
179
+ else if (c === "," && !depth) { parts.push(sel.slice(from, i)); from = i + 1; }
180
+ }
181
+ parts.push(sel.slice(from));
182
+ return parts.map((part) => {
183
+ const steps = []; // [{ tag, ids, classes, attrs }, ">" | " ", { ... }, ...]
184
+ let s = part.trim(), cur = null;
185
+ if (!s) throw new Error(`empty selector in "${sel}"`);
186
+ while (s) {
187
+ const ws = /^\s+/.exec(s);
188
+ const gt = /^\s*>\s*/.exec(s);
189
+ if (gt) { if (!cur) throw new Error(`"${part.trim()}" starts with ">"`); steps.push(">"); cur = null; s = s.slice(gt[0].length); continue; }
190
+ if (ws) { steps.push(" "); cur = null; s = s.slice(ws[0].length); continue; }
191
+ const m = COMPOUND.exec(s);
192
+ if (!m) throw new Error(`"${s.slice(0, 20)}" in "${part.trim()}" is not supported (use tags, #id, .class, [attr], spaces and ">")`);
193
+ if (!cur) { cur = { tag: null, ids: [], classes: [], attrs: [] }; steps.push(cur); }
194
+ if (m[1]) {
195
+ if (cur.tag || cur.ids.length || cur.classes.length || cur.attrs.length) throw new Error(`a tag name must come first in "${part.trim()}"`);
196
+ cur.tag = m[1] === "*" ? null : m[1].toLowerCase();
197
+ }
198
+ else if (m[2]) cur.ids.push(m[2]);
199
+ else if (m[3]) cur.classes.push(m[3]);
200
+ else cur.attrs.push({ name: m[4].toLowerCase(), value: m[5] ?? m[6] ?? m[7] ?? null });
201
+ s = s.slice(m[0].length);
202
+ }
203
+ if (typeof steps.at(-1) === "string") throw new Error(`"${part.trim()}" ends with a combinator`);
204
+ return steps;
205
+ });
206
+ }
207
+ function compoundMatches(node, c) {
208
+ if (!node.attrs) return false; // the root
209
+ if (c.tag && node.name !== c.tag) return false;
210
+ if (c.ids.some((id) => node.attrs.id?.value !== id)) return false;
211
+ const cls = classesOf(node);
212
+ if (c.classes.some((k) => !cls.includes(k))) return false;
213
+ return c.attrs.every((a) => node.attrs[a.name] && (a.value == null || node.attrs[a.name].value === a.value));
214
+ }
215
+ /* steps[i] matches node, and the steps before it match its ancestors as their combinators say */
216
+ function stepsMatch(node, steps, i = steps.length - 1) {
217
+ if (!compoundMatches(node, steps[i])) return false;
218
+ if (i === 0) return true;
219
+ if (steps[i - 1] === ">") return !!node.parent && stepsMatch(node.parent, steps, i - 2);
220
+ for (let a = node.parent; a; a = a.parent) if (stepsMatch(a, steps, i - 2)) return true;
221
+ return false;
222
+ }
223
+ /* Elements matching the selector, in document order; an element holding another match is a stack of
224
+ slides, not a slide (as with reveal.js's vertical stacks) */
225
+ function selectorSlides(tree, sel) {
226
+ let list;
227
+ try { list = parseSelector(sel); } catch { return []; }
228
+ const hits = [];
229
+ walk(tree, (c) => { if (list.some((steps) => stepsMatch(c, steps))) hits.push(c); });
230
+ const inside = (a, b) => { for (let p = b.parent; p; p = p.parent) if (p === a) return true; return false; };
231
+ return hits.filter((h) => !hits.some((o) => o !== h && inside(h, o)));
232
+ }
233
+
234
+ /* Slides are leaf <section>s inside .slides; vertical-stack containers are not slides. A deck that names
235
+ its slides with <meta name="pindeck:slides"> has the elements that selector matches instead. */
149
236
  export function findSlides(tree) {
237
+ const sel = slideSelector(tree);
238
+ if (sel) return selectorSlides(tree, sel);
150
239
  const slidesEl = find(tree, (c) => classesOf(c).includes("slides"));
151
240
  if (!slidesEl) return [];
152
241
  const out = [];
@@ -187,6 +276,7 @@ function elementName(node) {
187
276
  export function assignEids(html) {
188
277
  const tree = parse(html);
189
278
  const sections = findSlides(tree);
279
+ const isSlide = slideSelector(tree) ? ((c) => sections.includes(c)) : ((c) => c.name === "section");
190
280
  const used = new Set();
191
281
  const edits = []; // { at, insert } or { start, end, replace }
192
282
  const assigned = [], renamed = [];
@@ -240,7 +330,7 @@ export function assignEids(html) {
240
330
  // Pass 2: elements inside the slide
241
331
  const visit = (node, inSvg) => {
242
332
  for (const c of node.children) {
243
- if (c.name === "section") continue; // nested slide, handled on its own
333
+ if (isSlide(c)) continue; // nested slide, handled on its own
244
334
  const cls = classesOf(c);
245
335
  if (c.name === "aside" && cls.includes("notes")) continue;
246
336
  if (c.attrs["data-noeid"]) continue;
@@ -423,10 +513,11 @@ const slideKind = (n) => classList(n).find((c) => /-slide$/.test(c)) || "content
423
513
  content title and two placeholder bullets, from a content slide (the current one if it is one)
424
514
  section a section divider, from the deck's first divider
425
515
  duplicate a copy of the current slide
426
- Element ids are left out; assignEids() gives the new slide fresh ones.
516
+ Element ids are left out; assignEids() gives the new slide fresh ones. In a deck made without reveal.js,
517
+ the slide's own id is left out too: such decks (and Present) go to slides by their id.
427
518
  Returns { html, index } (index of the new slide) or { error }. */
428
519
  export function insertSlide(html, kind, afterEid) {
429
- const slides = findSlides(parse(html));
520
+ const tree = parse(html), slides = findSlides(tree);
430
521
  if (!slides.length) return { error: "The deck has no slides." };
431
522
  const after = slides.find((s) => s.attrs["data-eid"]?.value === afterEid) || slides.at(-1);
432
523
  const hasHead = (s) => { let h = false; walk({ children: [s] }, (c) => { if (/^h[1-3]$/.test(c.name)) h = true; }); return h; };
@@ -437,6 +528,7 @@ export function insertSlide(html, kind, afterEid) {
437
528
  if (!tpl) return { error: kind === "section" ? "This deck has no section divider to model a new one on." : "This deck has no content slide to model a new one on." };
438
529
 
439
530
  let frag = html.slice(tpl.start, elementEnd(html, tpl)).replace(/\s+data-eid="[^"]*"/g, "");
531
+ if (slideSelector(tree)) frag = frag.replace(/^(<[^>]*?)\s+id=(?:"[^"]*"|'[^']*'|[^\s>]+)/, "$1"); // the opening tag only
440
532
  if (kind !== "duplicate") frag = placeholderSlide(frag, kind, slides.filter((s, i) => i <= slides.indexOf(after) && slideKind(s) === "section-slide").length + 1);
441
533
 
442
534
  const indent = lineIndent(html, after.start);
@@ -500,12 +592,13 @@ function slideBlock(html, s) {
500
592
  are. Slides inside vertical stacks are not moved. Returns { html, changed, from, index } or { error }. */
501
593
  export function moveSlide(html, eid, beforeEid) {
502
594
  const byEid = (all, e) => all.find((s) => s.attrs["data-eid"]?.value === e);
503
- const slides = findSlides(parse(html));
595
+ const tree = parse(html), slides = findSlides(tree);
596
+ const bySelector = !!slideSelector(tree); // such decks have no vertical stacks
504
597
  const slide = byEid(slides, eid), target = beforeEid == null ? null : byEid(slides, beforeEid);
505
598
  if (!slide || (beforeEid != null && !target)) return { error: "Slide not found in the file." };
506
- if ([slide, target].some((s) => s?.parent.name === "section")) return { error: "Slides inside a vertical stack cannot be moved yet." };
599
+ if (!bySelector && [slide, target].some((s) => s?.parent.name === "section")) return { error: "Slides inside a vertical stack cannot be moved yet." };
507
600
  const from = slides.indexOf(slide);
508
- const top = slide.parent.children.filter((c) => c.name === "section");
601
+ const top = slide.parent.children.filter((c) => (bySelector ? slides.includes(c) : c.name === "section"));
509
602
  const next = top[top.indexOf(slide) + 1] || null;
510
603
  if (target === slide || target === next) return { html, changed: false, from, index: from };
511
604
 
@@ -520,7 +613,7 @@ export function moveSlide(html, eid, beforeEid) {
520
613
  out = cut.slice(0, at) + text + `\n\n${indent}` + cut.slice(at);
521
614
  } else {
522
615
  // A final vertical stack is absent from the leaf-slide list, but we must insert after it.
523
- const last = find(parse(cut), (c) => classesOf(c).includes("slides"))
616
+ const last = bySelector ? rest.at(-1) : find(parse(cut), (c) => classesOf(c).includes("slides"))
524
617
  .children.filter((c) => c.name === "section").at(-1);
525
618
  const at = elementEnd(cut, last);
526
619
  out = cut.slice(0, at) + `\n\n${indent}` + text + cut.slice(at);
@@ -0,0 +1,59 @@
1
+ /* Which slide framework a deck uses. PinDeck edits reveal.js decks: <section> slides inside an element
2
+ with class "slides" (inside <div class="reveal">). Any other deck can name its slides itself with
3
+ <meta name="pindeck:slides" content="CSS selector"> (see lib/eids.mjs) and is then edited too. Other
4
+ frameworks are recognised by their markup and scripts, so the user gets a clear "not supported (yet)"
5
+ before PinDeck changes anything, instead of an editor without slides. Only tags and scripts are looked
6
+ at, never the slides' text, so a talk that mentions "impress.js" is not taken for one. */
7
+
8
+ import { parse, findSlides, slideSelector, deckSize, parseSelector } from "./eids.mjs";
9
+
10
+ const OTHERS = [ // name, markers; the first match wins (Marp before bespoke.js, which it is built on)
11
+ ["Marp", /\bmarpit\b|bespoke-marp|data-marpit|marp-cli/i],
12
+ ["Slidev", /slidev/i],
13
+ ["impress.js", /id=["']impress["']|impress(\.min)?\.js/i],
14
+ ["remark", /remark(-latest)?(\.min)?\.js|remark\.create\s*\(/i],
15
+ ["Spectacle", /<script[^>]+src=["'][^"']*spectacle/i],
16
+ ["Shower", /class=["'][^"']*\bshower\b|shower-core/i],
17
+ ["deck.js", /deck-container|deck\.core(\.min)?\.js/i],
18
+ ["bespoke.js", /bespoke(\.min)?\.js|bespoke\.from\s*\(/i],
19
+ ];
20
+
21
+ /* Tags and script contents only: the slides' text could name any framework */
22
+ const markup = (html) => [...String(html).matchAll(/<script\b[^>]*>[\s\S]*?<\/script>|<[^>]+>/gi)].map((m) => m[0]).join("\n");
23
+
24
+ /* { name, supported }: reveal.js decks are supported, including one without slides yet, and so is a deck
25
+ whose <meta name="pindeck:slides"> selector finds slides and whose <meta name="pindeck:size"> gives their
26
+ size ({ name: "selector", selector, supported }) */
27
+ export function detectFramework(html) {
28
+ const tree = parse(html), selector = slideSelector(tree);
29
+ if (selector) return { name: "selector", selector, supported: findSlides(tree).length > 0 && !!deckSize(tree) };
30
+ if (findSlides(tree).length) return { name: "reveal.js", supported: true };
31
+ const tags = markup(html);
32
+ const other = OTHERS.find(([, re]) => re.test(tags));
33
+ if (other) return { name: other[0], supported: false };
34
+ if (/class=["'][^"']*\breveal\b/i.test(tags) || /Reveal\.initialize\s*\(/.test(tags)) return { name: "reveal.js", supported: true };
35
+ return { name: null, supported: false };
36
+ }
37
+
38
+ const HOW = `A deck made otherwise can name its slides in its <head>: ` +
39
+ `<meta name="pindeck:slides" content="main.deck > section.slide"> (a CSS selector for the slides) and ` +
40
+ `<meta name="pindeck:size" content="1440x1080"> (their size in px).`;
41
+
42
+ /* The message for a deck PinDeck cannot edit, or null */
43
+ export function unsupportedMessage(html, file) {
44
+ const fw = detectFramework(html);
45
+ if (fw.supported) return null;
46
+ if (fw.name === "selector") {
47
+ // the slides are found: the size is what is missing (headless Chrome and the PDF pages need it)
48
+ if (findSlides(parse(html)).length) return `${file}: <meta name="pindeck:size"> is missing or not a size ` +
49
+ `such as content="1440x1080" (the slides' width and height in px). PinDeck opens the slides at that size ` +
50
+ `for screenshots and PDFs. Nothing was changed.`;
51
+ let why = "matches no elements";
52
+ try { parseSelector(fw.selector); } catch (e) { why = `cannot be used: ${e.message}`; }
53
+ return `${file}: the slide selector "${fw.selector}" in <meta name="pindeck:slides"> ${why}. Nothing was changed.`;
54
+ }
55
+ const what = fw.name ? `${file} is a ${fw.name} deck.` :
56
+ `${file} has no reveal.js slides (a <div class="reveal"> with <div class="slides"> holding <section> slides).`;
57
+ return `${what} PinDeck works with reveal.js decks; other frameworks may follow. ${HOW} ` +
58
+ `Nothing was changed.`;
59
+ }
@@ -0,0 +1,74 @@
1
+ /* The deck's own instructions for Claude (CLAUDE.md, AGENTS.md), with @path imports expanded.
2
+
3
+ PinDeck does not load CLAUDE.md files from parent folders the way Claude Code does: those are usually
4
+ written for coding work PinDeck's Claude cannot do (tests, commits, package managers), they change
5
+ with where the deck happens to sit, and on a shared server a parent folder may be writable by others.
6
+ A deck pulls in shared rules explicitly instead: "Follow @../shared/STYLE.md" includes that file.
7
+
8
+ An import is "@" followed by a path (relative to the file it is in, absolute, or ~/...), at the start
9
+ of a line or after a space or "(". It is expanded only when that path is a text file: an e-mail address
10
+ or an @mention stays as it is. Imports in code spans and code blocks are left alone. Included files may
11
+ import others, up to 5 levels deep; each file is included once (no cycles), and all included text
12
+ together is limited to 200 KB. The imported text follows the file that imports it. */
13
+
14
+ import fs from "node:fs";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+
18
+ const MAX_DEPTH = 5, MAX_BYTES = 200_000;
19
+
20
+ /* Apply fn to the text outside code: fenced blocks (``` or ~~~) and `code spans` stay as they are */
21
+ function outsideCode(text, fn) {
22
+ let fence = null;
23
+ return text.split("\n").map((line) => {
24
+ const f = /^\s*(```|~~~)/.exec(line);
25
+ if (f) { fence = fence ? (fence === f[1] ? null : fence) : f[1]; return line; }
26
+ if (fence) return line;
27
+ return line.split(/(`+[^`]*`+)/).map((part, i) => (i % 2 ? part : fn(part))).join("");
28
+ }).join("\n");
29
+ }
30
+
31
+ const resolveRef = (ref, dir) => (ref === "~" || ref.startsWith("~/") ? path.join(os.homedir(), ref.slice(1))
32
+ : path.resolve(dir, ref));
33
+
34
+ function readText(file) {
35
+ try {
36
+ if (!fs.statSync(file).isFile()) return null;
37
+ const buf = fs.readFileSync(file);
38
+ return buf.includes(0) ? null : buf.toString("utf8");
39
+ } catch { return null; }
40
+ }
41
+
42
+ function expand(file, text, depth, seen, budget) {
43
+ const dir = path.dirname(file), found = [];
44
+ const body = outsideCode(text, (part) => part.replace(/(^|[\s(])@([^\s`()<>"']+)/g, (m, pre, raw) => {
45
+ const ref = raw.replace(/[.,:;!?]+$/, ""), tail = raw.slice(ref.length); // "see @STYLE.md." ends a sentence
46
+ const abs = resolveRef(ref, dir);
47
+ const content = readText(abs);
48
+ if (content == null) return m; // not a file: an e-mail address, an @mention, a typo
49
+ let real = abs;
50
+ try { real = fs.realpathSync(abs); } catch {}
51
+ const note = (why) => `${pre}${ref} (${why})${tail}`;
52
+ if (seen.has(real)) return note("included once, above or below");
53
+ if (depth >= MAX_DEPTH) return note("not included: imports nested too deep");
54
+ if (Buffer.byteLength(content) > budget.left) return note("not included: imported files over 200 KB in all");
55
+ budget.left -= Buffer.byteLength(content);
56
+ seen.add(real);
57
+ found.push({ ref, abs, content });
58
+ return note("included below");
59
+ }));
60
+ const parts = [body.trim()];
61
+ for (const imp of found) {
62
+ parts.push(`### ${imp.ref} (included by ${path.basename(file)})\n` + expand(imp.abs, imp.content, depth + 1, seen, budget));
63
+ }
64
+ return parts.filter(Boolean).join("\n\n");
65
+ }
66
+
67
+ /* A deck instruction file with its imports, or null when it is missing or empty */
68
+ export function readInstructions(file) {
69
+ const text = readText(file);
70
+ if (text == null || !text.trim()) return null;
71
+ let real = file;
72
+ try { real = fs.realpathSync(file); } catch {}
73
+ return expand(file, text, 0, new Set([real]), { left: MAX_BYTES });
74
+ }
package/lib/models.mjs CHANGED
@@ -22,7 +22,8 @@ export function selectModel(catalog, current, requested, preferences = {}) {
22
22
  const provider = requested.provider ?? current.provider;
23
23
  if (!PROVIDERS.includes(provider)) throw new Error('Unknown provider.');
24
24
  const models = catalog[provider]?.models || [];
25
- const id = requested.model ?? (provider === current.provider ? current.model : models.find(m => m.isDefault)?.id || models[0]?.id);
25
+ // no model chosen yet (a provider picked on the command line, before discovery): the backend's default
26
+ const id = requested.model ?? ((provider === current.provider && current.model) || models.find(m => m.isDefault)?.id || models[0]?.id);
26
27
  const model = models.find(m => m.id === id);
27
28
  if (!model) throw new Error(catalog[provider]?.error || 'This model is not available. Refresh the model list.');
28
29
  const sameModel = provider === current.provider && id === current.model;
@@ -39,10 +39,12 @@ function run(argv) {
39
39
  null, w, h, 8, 4, true, false, $.NSDeviceRGBColorSpace, 0, 0);
40
40
  const ctx = $.NSGraphicsContext.graphicsContextWithBitmapImageRep(rep);
41
41
  $.NSGraphicsContext.saveGraphicsState;
42
- $.NSGraphicsContext.currentContext = ctx;
43
- $.NSColor.whiteColor.setFill;
44
- $.NSRectFill($.NSMakeRect(0, 0, w, h));
42
+ $.NSGraphicsContext.setCurrentContext(ctx);
45
43
  const cg = ctx.CGContext;
44
+ // a white page, as in a PDF viewer (and as Poppler renders on Linux): most PDFs paint no background,
45
+ // and a transparent picture would show dark text on a dark slide
46
+ $.CGContextSetRGBFillColor(cg, 1, 1, 1, 1);
47
+ $.CGContextFillRect(cg, $.CGRectMake(0, 0, w, h));
46
48
  $.CGContextScaleCTM(cg, scale, scale);
47
49
  $.CGContextTranslateCTM(cg, tx, ty);
48
50
  page.drawWithBoxToContext($.kPDFDisplayBoxMediaBox, cg);
package/lib/pdf.mjs CHANGED
@@ -46,7 +46,8 @@ function chromeRun(chrome, args, profile, done, timeout) {
46
46
  proc.stdout.on("data", (d) => { out += d; check(); });
47
47
  proc.stderr.on("data", (d) => { err += d; check(); });
48
48
  proc.on("error", (e) => finish(e));
49
- proc.on("exit", () => (done(out, err) ? finish(null, out) : finish(new Error("Chrome exited early."))));
49
+ // "close", not "exit": at exit Chrome's last output (the line saying the file is written) may still be unread
50
+ proc.on("close", () => (done(out, err) ? finish(null, out) : finish(new Error("Chrome exited early."))));
50
51
  });
51
52
  }
52
53
 
@@ -61,7 +62,8 @@ export async function printLayout(chrome, url, profile) {
61
62
  /* Step 2: print a (static) page to a PDF file. */
62
63
  export async function printPdf(chrome, url, out, profile) {
63
64
  try { fs.rmSync(out, { force: true }); } catch {}
64
- await chromeRun(chrome, ["--no-pdf-header-footer", `--print-to-pdf=${out}`, url], profile,
65
+ // the virtual-time budget lets Chrome follow PinDeck's login start page to the print page before printing
66
+ await chromeRun(chrome, ["--virtual-time-budget=5000", "--no-pdf-header-footer", `--print-to-pdf=${out}`, url], profile,
65
67
  (_o, e) => /bytes written to file/.test(e), 90000);
66
68
  if (!fs.existsSync(out)) throw new Error("Chrome did not write the PDF.");
67
69
  return out;