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.
- package/README.md +267 -45
- package/cli.mjs +6 -3
- package/lib/agent-process.mjs +7 -3
- package/lib/agent-tools.mjs +32 -9
- package/lib/capabilities.mjs +121 -0
- package/lib/claude.mjs +7 -3
- package/lib/codex.mjs +4 -2
- package/lib/deck-files.mjs +94 -0
- package/lib/eids.mjs +101 -8
- package/lib/frameworks.mjs +59 -0
- package/lib/instructions.mjs +74 -0
- package/lib/models.mjs +2 -1
- package/lib/pdf-render.jxa.js +5 -3
- package/lib/pdf.mjs +4 -2
- package/lib/platform-notice.mjs +6 -6
- package/lib/pptx-render.mjs +78 -9
- package/lib/system-prompt.md +5 -3
- package/lib/waveform.mjs +9 -3
- package/package.json +6 -3
- package/public/bridge.js +248 -69
- package/public/editor.css +1 -0
- package/public/editor.js +85 -24
- package/public/index.html +2 -2
- package/server.mjs +660 -173
|
@@ -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, ...
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
/*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|
package/lib/pdf-render.jxa.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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;
|