viteboard 0.1.9 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +16 -1
- package/bin/content-api-plugin.mjs +872 -59
- package/bin/pdf-export.mjs +457 -0
- package/content/docs/guide/boards-in-markdown.md +4 -3
- package/content/docs/guide/boards.md +8 -0
- package/content/machine/guide.md +101 -4
- package/content/machine/llms.txt +4 -0
- package/content/machine/viteboard-skill.md +102 -0
- package/content/machine/viteboard.json +35 -5
- package/dist/assets/index-BkOYIo6T.css +1 -0
- package/dist/assets/index-aNpa7Sbx.js +97 -0
- package/dist/index.html +2 -2
- package/package.json +5 -3
- package/vite.config.ts +3 -992
- package/dist/assets/index-CE8x3f28.js +0 -95
- package/dist/assets/index-DvhOK1J_.css +0 -1
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import http from "node:http";
|
|
5
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
12
|
+
|
|
13
|
+
let browserBinaryPromise = null;
|
|
14
|
+
|
|
15
|
+
export async function renderPdfFromHtml(html, fileName, pdf = {}) {
|
|
16
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "viteboard-pdf-"));
|
|
17
|
+
const htmlPath = path.join(tmpDir, "publication.html");
|
|
18
|
+
const profileDir = path.join(tmpDir, "profile");
|
|
19
|
+
let browserProcess = null;
|
|
20
|
+
try {
|
|
21
|
+
const browser = await resolveBrowserBinary();
|
|
22
|
+
await fs.writeFile(htmlPath, html, "utf-8");
|
|
23
|
+
await fs.mkdir(profileDir, { recursive: true });
|
|
24
|
+
browserProcess = execFile(browser, [
|
|
25
|
+
"--headless",
|
|
26
|
+
"--disable-gpu",
|
|
27
|
+
"--no-sandbox",
|
|
28
|
+
`--user-data-dir=${profileDir}`,
|
|
29
|
+
"--remote-debugging-port=0",
|
|
30
|
+
"about:blank",
|
|
31
|
+
], { windowsHide: true, detached: process.platform !== "win32" });
|
|
32
|
+
|
|
33
|
+
const activePortPath = path.join(profileDir, "DevToolsActivePort");
|
|
34
|
+
const port = await waitForDevToolsPort(activePortPath);
|
|
35
|
+
const wsUrl = await openCdpPage(port);
|
|
36
|
+
return await withTimeout(printPdfViaCdp(wsUrl, pathToFileURL(htmlPath).href, {
|
|
37
|
+
displayHeaderFooter: Boolean(pdf.displayHeaderFooter),
|
|
38
|
+
headerTemplate: pdf.headerTemplate,
|
|
39
|
+
footerTemplate: pdf.footerTemplate,
|
|
40
|
+
marginTopMm: pdf.marginTopMm,
|
|
41
|
+
marginBottomMm: pdf.marginBottomMm,
|
|
42
|
+
marginLeftMm: pdf.marginLeftMm,
|
|
43
|
+
marginRightMm: pdf.marginRightMm,
|
|
44
|
+
}), 120_000, "PDF rendering timed out.");
|
|
45
|
+
} finally {
|
|
46
|
+
await stopBrowserProcess(browserProcess);
|
|
47
|
+
await removeTemporaryDirectory(tmpDir);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function evaluatePageFromUrl(url, expression) {
|
|
52
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "viteboard-pdf-"));
|
|
53
|
+
const profileDir = path.join(tmpDir, "profile");
|
|
54
|
+
let browserProcess = null;
|
|
55
|
+
try {
|
|
56
|
+
const browser = await resolveBrowserBinary();
|
|
57
|
+
await fs.mkdir(profileDir, { recursive: true });
|
|
58
|
+
browserProcess = execFile(browser, [
|
|
59
|
+
"--headless",
|
|
60
|
+
"--disable-gpu",
|
|
61
|
+
"--no-sandbox",
|
|
62
|
+
`--user-data-dir=${profileDir}`,
|
|
63
|
+
"--remote-debugging-port=0",
|
|
64
|
+
"about:blank",
|
|
65
|
+
], { windowsHide: true, detached: process.platform !== "win32" });
|
|
66
|
+
const port = await waitForDevToolsPort(path.join(profileDir, "DevToolsActivePort"));
|
|
67
|
+
const wsUrl = await openCdpPage(port);
|
|
68
|
+
return await withTimeout(evaluatePageViaCdp(wsUrl, url, expression), 120_000, "PDF preparation timed out.");
|
|
69
|
+
} finally {
|
|
70
|
+
await stopBrowserProcess(browserProcess);
|
|
71
|
+
await removeTemporaryDirectory(tmpDir);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function stopBrowserProcess(browserProcess) {
|
|
76
|
+
if (!browserProcess || !browserProcess.pid) return;
|
|
77
|
+
const exited = new Promise((resolve) => {
|
|
78
|
+
if (browserProcess.exitCode !== null || browserProcess.signalCode !== null) return resolve();
|
|
79
|
+
browserProcess.once("close", resolve);
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
if (process.platform === "win32") {
|
|
83
|
+
await execFileAsync("taskkill", ["/pid", String(browserProcess.pid), "/t", "/f"]).catch(() => browserProcess.kill());
|
|
84
|
+
} else {
|
|
85
|
+
process.kill(-browserProcess.pid, "SIGTERM");
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
try { browserProcess.kill(); } catch { /* already stopped */ }
|
|
89
|
+
}
|
|
90
|
+
if (await Promise.race([exited.then(() => true), delay(1000).then(() => false)])) return;
|
|
91
|
+
try {
|
|
92
|
+
if (process.platform !== "win32") process.kill(-browserProcess.pid, "SIGKILL");
|
|
93
|
+
else browserProcess.kill("SIGKILL");
|
|
94
|
+
} catch { /* already stopped */ }
|
|
95
|
+
await Promise.race([exited, delay(1000)]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function removeTemporaryDirectory(tmpDir) {
|
|
99
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
100
|
+
try {
|
|
101
|
+
await fs.rm(tmpDir, { recursive: true, force: true, maxRetries: 2, retryDelay: 100 });
|
|
102
|
+
return;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (attempt === 7) {
|
|
105
|
+
console.warn("ViteBoard could not remove a temporary PDF export directory:", error.message);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
await delay(100 * (attempt + 1));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function sanitizePdfFileName(input) {
|
|
114
|
+
const cleaned = String(input ?? "").trim().replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, " ").slice(0, 120) || "export";
|
|
115
|
+
return cleaned.toLowerCase().endsWith(".pdf") ? cleaned : `${cleaned}.pdf`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function resolveBrowserBinary() {
|
|
119
|
+
if (browserBinaryPromise) return browserBinaryPromise;
|
|
120
|
+
browserBinaryPromise = (async () => {
|
|
121
|
+
const override = process.env.VITEBOARD_PDF_BROWSER || process.env.VITEBOARD_BROWSER || process.env.BROWSER || "";
|
|
122
|
+
if (override && await fileExists(override)) return override;
|
|
123
|
+
|
|
124
|
+
const commandCandidates = process.platform === "win32"
|
|
125
|
+
? ["msedge.exe", "chrome.exe", "chromium.exe", "brave.exe"]
|
|
126
|
+
: ["microsoft-edge", "microsoft-edge-stable", "google-chrome", "google-chrome-stable", "chromium-browser", "chromium", "brave-browser"];
|
|
127
|
+
const lookupCommand = process.platform === "win32" ? "where" : "which";
|
|
128
|
+
for (const candidate of commandCandidates) {
|
|
129
|
+
try {
|
|
130
|
+
const { stdout } = await execFileAsync(lookupCommand, [candidate]);
|
|
131
|
+
const first = stdout.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
|
132
|
+
if (first) return first;
|
|
133
|
+
} catch {
|
|
134
|
+
/* try next */
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const candidate of absoluteBrowserCandidates()) {
|
|
139
|
+
if (await fileExists(candidate)) return candidate;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
throw new Error("No supported Chromium-based browser was found for PDF export. Install Chrome, Edge, Chromium, or Brave, or set VITEBOARD_PDF_BROWSER.");
|
|
143
|
+
})();
|
|
144
|
+
return browserBinaryPromise;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function absoluteBrowserCandidates() {
|
|
148
|
+
if (process.platform === "darwin") {
|
|
149
|
+
return [
|
|
150
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
151
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
152
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
153
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
154
|
+
path.join(os.homedir(), "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
|
|
155
|
+
path.join(os.homedir(), "Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
if (process.platform === "win32") {
|
|
159
|
+
const roots = [process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA].filter(Boolean);
|
|
160
|
+
const suffixes = [
|
|
161
|
+
path.join("Google", "Chrome", "Application", "chrome.exe"),
|
|
162
|
+
path.join("Microsoft", "Edge", "Application", "msedge.exe"),
|
|
163
|
+
path.join("Chromium", "Application", "chrome.exe"),
|
|
164
|
+
path.join("BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
|
|
165
|
+
];
|
|
166
|
+
return roots.flatMap((root) => suffixes.map((suffix) => path.join(root, suffix)));
|
|
167
|
+
}
|
|
168
|
+
return [
|
|
169
|
+
"/usr/bin/chromium-browser",
|
|
170
|
+
"/usr/bin/chromium",
|
|
171
|
+
"/usr/bin/google-chrome",
|
|
172
|
+
"/usr/bin/google-chrome-stable",
|
|
173
|
+
"/usr/bin/microsoft-edge",
|
|
174
|
+
"/snap/bin/chromium",
|
|
175
|
+
];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function fileExists(filePath) {
|
|
179
|
+
try {
|
|
180
|
+
await fs.access(filePath);
|
|
181
|
+
return true;
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function waitForDevToolsPort(activePortPath) {
|
|
188
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
189
|
+
try {
|
|
190
|
+
const raw = await fs.readFile(activePortPath, "utf-8");
|
|
191
|
+
const port = raw.split(/\r?\n/)[0]?.trim();
|
|
192
|
+
if (port) return port;
|
|
193
|
+
} catch {
|
|
194
|
+
/* wait */
|
|
195
|
+
}
|
|
196
|
+
await delay(100);
|
|
197
|
+
}
|
|
198
|
+
throw new Error("Timed out starting Chromium PDF export session.");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function openCdpPage(port) {
|
|
202
|
+
const response = await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" });
|
|
203
|
+
const target = await response.json();
|
|
204
|
+
if (!target.webSocketDebuggerUrl) throw new Error("Could not open Chromium page target for PDF export.");
|
|
205
|
+
return target.webSocketDebuggerUrl;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function printPdfViaCdp(wsUrl, url, pdf) {
|
|
209
|
+
const ws = await connectWebSocket(wsUrl);
|
|
210
|
+
let nextId = 0;
|
|
211
|
+
const pending = new Map();
|
|
212
|
+
let loadResolve = null;
|
|
213
|
+
const loaded = new Promise((resolve) => { loadResolve = resolve; });
|
|
214
|
+
|
|
215
|
+
ws.onMessage = (text) => {
|
|
216
|
+
const message = JSON.parse(text);
|
|
217
|
+
if (message.method === "Page.loadEventFired") {
|
|
218
|
+
if (loadResolve) loadResolve();
|
|
219
|
+
loadResolve = null;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (!message.id) return;
|
|
223
|
+
const entry = pending.get(message.id);
|
|
224
|
+
if (!entry) return;
|
|
225
|
+
pending.delete(message.id);
|
|
226
|
+
if (message.error) entry.reject(new Error(message.error.message || "CDP error"));
|
|
227
|
+
else entry.resolve(message.result);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
|
231
|
+
const id = ++nextId;
|
|
232
|
+
pending.set(id, { resolve, reject });
|
|
233
|
+
ws.send(JSON.stringify({ id, method, params }));
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
await send("Page.enable");
|
|
238
|
+
await send("Runtime.enable");
|
|
239
|
+
await send("Page.navigate", { url });
|
|
240
|
+
await loaded;
|
|
241
|
+
await send("Runtime.evaluate", {
|
|
242
|
+
expression: "document.fonts ? document.fonts.ready.then(() => true) : true",
|
|
243
|
+
awaitPromise: true,
|
|
244
|
+
returnByValue: true,
|
|
245
|
+
});
|
|
246
|
+
const result = await send("Page.printToPDF", {
|
|
247
|
+
printBackground: true,
|
|
248
|
+
preferCSSPageSize: true,
|
|
249
|
+
displayHeaderFooter: Boolean(pdf.displayHeaderFooter),
|
|
250
|
+
headerTemplate: pdf.headerTemplate || "<div></div>",
|
|
251
|
+
footerTemplate: pdf.footerTemplate || "<div></div>",
|
|
252
|
+
marginTop: mmToInches(pdf.marginTopMm ?? 12),
|
|
253
|
+
marginBottom: mmToInches(pdf.marginBottomMm ?? 12),
|
|
254
|
+
marginLeft: mmToInches(pdf.marginLeftMm ?? 12),
|
|
255
|
+
marginRight: mmToInches(pdf.marginRightMm ?? 12),
|
|
256
|
+
});
|
|
257
|
+
return Buffer.from(result.data, "base64");
|
|
258
|
+
} finally {
|
|
259
|
+
ws.close();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function evaluatePageViaCdp(wsUrl, url, expression) {
|
|
264
|
+
const ws = await connectWebSocket(wsUrl);
|
|
265
|
+
let nextId = 0;
|
|
266
|
+
const pending = new Map();
|
|
267
|
+
let loadResolve = null;
|
|
268
|
+
const loaded = new Promise((resolve) => { loadResolve = resolve; });
|
|
269
|
+
|
|
270
|
+
ws.onMessage = (text) => {
|
|
271
|
+
const message = JSON.parse(text);
|
|
272
|
+
if (message.method === "Page.loadEventFired") {
|
|
273
|
+
if (loadResolve) loadResolve();
|
|
274
|
+
loadResolve = null;
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (!message.id) return;
|
|
278
|
+
const entry = pending.get(message.id);
|
|
279
|
+
if (!entry) return;
|
|
280
|
+
pending.delete(message.id);
|
|
281
|
+
if (message.error) entry.reject(new Error(message.error.message || "CDP error"));
|
|
282
|
+
else entry.resolve(message.result);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
|
286
|
+
const id = ++nextId;
|
|
287
|
+
pending.set(id, { resolve, reject });
|
|
288
|
+
ws.send(JSON.stringify({ id, method, params }));
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
await send("Page.enable");
|
|
293
|
+
await send("Runtime.enable");
|
|
294
|
+
await send("Page.navigate", { url });
|
|
295
|
+
await loaded;
|
|
296
|
+
await send("Runtime.evaluate", {
|
|
297
|
+
expression: "document.fonts ? document.fonts.ready.then(() => true) : true",
|
|
298
|
+
awaitPromise: true,
|
|
299
|
+
returnByValue: true,
|
|
300
|
+
});
|
|
301
|
+
const result = await send("Runtime.evaluate", {
|
|
302
|
+
expression,
|
|
303
|
+
awaitPromise: true,
|
|
304
|
+
returnByValue: true,
|
|
305
|
+
});
|
|
306
|
+
if (result.exceptionDetails) {
|
|
307
|
+
throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Could not prepare ViteBoard PDF export.");
|
|
308
|
+
}
|
|
309
|
+
if (result.result?.type === "undefined") return null;
|
|
310
|
+
return result.result?.value ?? null;
|
|
311
|
+
} finally {
|
|
312
|
+
ws.close();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function mmToInches(mm) {
|
|
317
|
+
return Number(mm ?? 0) / 25.4;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function connectWebSocket(wsUrl) {
|
|
321
|
+
const parsed = new URL(wsUrl);
|
|
322
|
+
if (parsed.protocol !== "ws:") throw new Error(`Unsupported DevTools protocol: ${parsed.protocol}`);
|
|
323
|
+
|
|
324
|
+
return new Promise((resolve, reject) => {
|
|
325
|
+
const key = randomBytes(16).toString("base64");
|
|
326
|
+
const req = http.request({
|
|
327
|
+
hostname: parsed.hostname,
|
|
328
|
+
port: parsed.port || 80,
|
|
329
|
+
path: `${parsed.pathname}${parsed.search}`,
|
|
330
|
+
headers: {
|
|
331
|
+
Connection: "Upgrade",
|
|
332
|
+
Upgrade: "websocket",
|
|
333
|
+
"Sec-WebSocket-Version": 13,
|
|
334
|
+
"Sec-WebSocket-Key": key,
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
req.once("upgrade", (res, socket) => {
|
|
338
|
+
const expectedAccept = createHash("sha1").update(key + WS_GUID).digest("base64");
|
|
339
|
+
if (res.headers["sec-websocket-accept"] !== expectedAccept) {
|
|
340
|
+
socket.destroy();
|
|
341
|
+
reject(new Error("Invalid DevTools WebSocket handshake."));
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
resolve(new SimpleWebSocket(socket));
|
|
345
|
+
});
|
|
346
|
+
req.once("error", reject);
|
|
347
|
+
req.end();
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
class SimpleWebSocket {
|
|
352
|
+
constructor(socket) {
|
|
353
|
+
this.socket = socket;
|
|
354
|
+
this.buffer = Buffer.alloc(0);
|
|
355
|
+
this.onMessage = null;
|
|
356
|
+
socket.on("data", (chunk) => this.handleData(chunk));
|
|
357
|
+
socket.on("error", () => {});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
send(text) {
|
|
361
|
+
const payload = Buffer.from(String(text));
|
|
362
|
+
const frame = encodeClientFrame(0x1, payload);
|
|
363
|
+
this.socket.write(frame);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
close() {
|
|
367
|
+
try { this.socket.end(); } catch { /* ignore */ }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
handleData(chunk) {
|
|
371
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
372
|
+
while (true) {
|
|
373
|
+
const frame = decodeFrame(this.buffer);
|
|
374
|
+
if (!frame) return;
|
|
375
|
+
this.buffer = this.buffer.slice(frame.byteLength);
|
|
376
|
+
if (frame.opcode === 0x1 && this.onMessage) this.onMessage(frame.payload.toString("utf-8"));
|
|
377
|
+
if (frame.opcode === 0x9) this.socket.write(encodeClientFrame(0xA, frame.payload));
|
|
378
|
+
if (frame.opcode === 0x8) {
|
|
379
|
+
this.close();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function encodeClientFrame(opcode, payload) {
|
|
387
|
+
const payloadLength = payload.length;
|
|
388
|
+
const mask = randomBytes(4);
|
|
389
|
+
let header;
|
|
390
|
+
if (payloadLength < 126) {
|
|
391
|
+
header = Buffer.alloc(2);
|
|
392
|
+
header[1] = 0x80 | payloadLength;
|
|
393
|
+
} else if (payloadLength < 65536) {
|
|
394
|
+
header = Buffer.alloc(4);
|
|
395
|
+
header[1] = 0x80 | 126;
|
|
396
|
+
header.writeUInt16BE(payloadLength, 2);
|
|
397
|
+
} else {
|
|
398
|
+
header = Buffer.alloc(10);
|
|
399
|
+
header[1] = 0x80 | 127;
|
|
400
|
+
header.writeBigUInt64BE(BigInt(payloadLength), 2);
|
|
401
|
+
}
|
|
402
|
+
header[0] = 0x80 | (opcode & 0x0f);
|
|
403
|
+
const masked = Buffer.alloc(payloadLength);
|
|
404
|
+
for (let i = 0; i < payloadLength; i += 1) masked[i] = payload[i] ^ mask[i % 4];
|
|
405
|
+
return Buffer.concat([header, mask, masked]);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function decodeFrame(buffer) {
|
|
409
|
+
if (buffer.length < 2) return null;
|
|
410
|
+
const first = buffer[0];
|
|
411
|
+
const second = buffer[1];
|
|
412
|
+
let offset = 2;
|
|
413
|
+
let payloadLength = second & 0x7f;
|
|
414
|
+
if (payloadLength === 126) {
|
|
415
|
+
if (buffer.length < 4) return null;
|
|
416
|
+
payloadLength = buffer.readUInt16BE(2);
|
|
417
|
+
offset = 4;
|
|
418
|
+
} else if (payloadLength === 127) {
|
|
419
|
+
if (buffer.length < 10) return null;
|
|
420
|
+
payloadLength = Number(buffer.readBigUInt64BE(2));
|
|
421
|
+
offset = 10;
|
|
422
|
+
}
|
|
423
|
+
const masked = (second & 0x80) !== 0;
|
|
424
|
+
let mask = null;
|
|
425
|
+
if (masked) {
|
|
426
|
+
if (buffer.length < offset + 4) return null;
|
|
427
|
+
mask = buffer.slice(offset, offset + 4);
|
|
428
|
+
offset += 4;
|
|
429
|
+
}
|
|
430
|
+
if (buffer.length < offset + payloadLength) return null;
|
|
431
|
+
const payload = Buffer.from(buffer.slice(offset, offset + payloadLength));
|
|
432
|
+
if (masked && mask) {
|
|
433
|
+
for (let i = 0; i < payload.length; i += 1) payload[i] ^= mask[i % 4];
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
fin: (first & 0x80) !== 0,
|
|
437
|
+
opcode: first & 0x0f,
|
|
438
|
+
payload,
|
|
439
|
+
byteLength: offset + payloadLength,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function delay(ms) {
|
|
444
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function withTimeout(promise, ms, message) {
|
|
448
|
+
let timer;
|
|
449
|
+
try {
|
|
450
|
+
return await Promise.race([
|
|
451
|
+
promise,
|
|
452
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), ms); }),
|
|
453
|
+
]);
|
|
454
|
+
} finally {
|
|
455
|
+
clearTimeout(timer);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
@@ -32,10 +32,11 @@ The easiest way to create an embed:
|
|
|
32
32
|
|
|
33
33
|
1. Open the board in the editor
|
|
34
34
|
2. Navigate to the region you want to embed
|
|
35
|
-
3. Click **
|
|
36
|
-
4.
|
|
35
|
+
3. Click **Select region to embed or export** in the toolbar and drag over the area
|
|
36
|
+
4. Choose **Embed in a document** from the popup
|
|
37
|
+
5. Paste the copied snippet into your markdown document
|
|
37
38
|
|
|
38
|
-
The copied snippet includes the exact
|
|
39
|
+
The copied snippet includes the exact board coordinates for the selected region. Region selection also offers PDF, JPEG, and SVG export; SVG contains the rendered board as an image rather than editable vector shapes.
|
|
39
40
|
|
|
40
41
|
## How It Works
|
|
41
42
|
|
|
@@ -52,3 +52,11 @@ These files are small, diffable, and work naturally with git.
|
|
|
52
52
|
## Exiting the Board Editor
|
|
53
53
|
|
|
54
54
|
Click the back arrow in the top-left corner to return to the documentation view. Your board is saved automatically.
|
|
55
|
+
|
|
56
|
+
## Exporting
|
|
57
|
+
|
|
58
|
+
Open **Export / Import** in the board toolbar to export the current view or full board as PNG, or to export the board JSON or a JSON-plus-images bundle.
|
|
59
|
+
|
|
60
|
+
To export or embed a specific region, choose **Select region to embed or export** and drag over the board. Then choose **Embed in a document**, **Export as PDF**, **Export as JPEG**, or **Export as SVG**. SVG preserves the canvas appearance by embedding a PNG image; its shapes are not editable vector paths. PDF export uses a Chromium-compatible browser installed on the machine running ViteBoard.
|
|
61
|
+
|
|
62
|
+
From a Markdown document, use its **Export to PDF** action to create a PDF. Before exporting, choose the page format and any optional sections or publication details. ViteBoard returns the PDF for download and does not select a save path on your behalf.
|
package/content/machine/guide.md
CHANGED
|
@@ -54,6 +54,19 @@ Important discovery endpoints:
|
|
|
54
54
|
- `GET /__api/project-tree?projectId={projectId}`
|
|
55
55
|
- `GET /__api/tree?rootId={rootId}`
|
|
56
56
|
|
|
57
|
+
### Live change notifications
|
|
58
|
+
|
|
59
|
+
The UI subscribes to `GET /__api/events` as a Server-Sent Events (SSE) stream. API clients can use the same endpoint with the browser `EventSource` API or another SSE client. It is a one-way notification channel; after receiving an event, fetch the authoritative workspace, tree, file, or board again rather than treating the event as the content itself. Native SSE clients reconnect automatically, and a newly opened stream should trigger a fresh snapshot fetch to cover any events missed while disconnected.
|
|
60
|
+
|
|
61
|
+
Mutations made through the HTTP API publish these named events after writing:
|
|
62
|
+
|
|
63
|
+
- `workspace-changed`: project or docs-root registration changes (`action`, optional `projectId`).
|
|
64
|
+
- `file-changed`: file/directory writes, renames, and deletes (`rootId`, `path`, `action`; rename includes `oldPath`).
|
|
65
|
+
- `board-changed`: board create, write, or delete (`rootId`, `boardId`, `action`).
|
|
66
|
+
- `asset-changed`: asset write or cleanup (`rootId`, optional `path`, `action`).
|
|
67
|
+
|
|
68
|
+
Events include `actorId` when the caller sends `X-ViteBoard-Actor-Id`; the UI uses this to ignore its own write notifications while keeping other tabs current. Notifications refresh views and flag revision conflicts—they do not merge live drafts or replace revision-checked writes. Direct filesystem changes that bypass the API do not emit events.
|
|
69
|
+
|
|
57
70
|
The machine can navigate the same app surfaces a human uses:
|
|
58
71
|
|
|
59
72
|
- workspace home: `#/`
|
|
@@ -144,6 +157,33 @@ automation/docs-refresh
|
|
|
144
157
|
|
|
145
158
|
Machines should treat the whole branch as the capability context and use `llms.txt` as the first machine-facing file when it exists.
|
|
146
159
|
|
|
160
|
+
### Agent Skills compatibility
|
|
161
|
+
|
|
162
|
+
Every capability is also exposed as a portable [Agent Skills](https://agentskills.io) skill. ViteBoard generates `SKILL.md` from the branch metadata, optional branch-local `SKILL.md`, `llms.txt` rules, and the supporting-file list; `index.md`, `llms.txt`, and the rest of the capability branch remain the source of truth.
|
|
163
|
+
|
|
164
|
+
Discover and inspect capabilities through the HTTP API:
|
|
165
|
+
|
|
166
|
+
- `GET /__api/capabilities` lists capabilities across registered docs roots. Add `?rootId={rootId}` or `?projectId={projectId}` to scope the result.
|
|
167
|
+
- `GET /__api/capability?rootId={rootId}&path={branchPath}` returns one capability, including its rules, generated `skillMarkdown`, and supporting-file URLs. `path` is the capability branch path, for example `capabilities/automation/docs-refresh`. `omittedFiles` lists items too large or unsupported for the skill bundle; they remain in the source branch.
|
|
168
|
+
- `GET /__api/skills/catalog/index.json` returns an OpenCode-compatible HTTP skill catalog. Skill files are served under `/__api/skills/catalog/{skillId}/{filePath}`; the catalog version changes when included skill/source content changes.
|
|
169
|
+
|
|
170
|
+
For OpenCode, add the live catalog URL to `skills` in the project's `opencode.json` or `opencode.jsonc` (merge this into existing config):
|
|
171
|
+
|
|
172
|
+
```jsonc
|
|
173
|
+
{
|
|
174
|
+
"$schema": "https://opencode.ai/config.json",
|
|
175
|
+
"skills": ["http://localhost:5173/__api/skills/catalog/"]
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Use the exact ViteBoard origin instead of the example URL. This keeps OpenCode skills live; the catalog includes every registered docs root. For local Agent Skills consumers, save the API's `skillMarkdown` as `SKILL.md` and preserve the returned `catalogPath` files beside it:
|
|
180
|
+
|
|
181
|
+
- Codex: `.agents/skills/{skillId}/`
|
|
182
|
+
- Claude Code: `.claude/skills/{skillId}/`
|
|
183
|
+
- OpenCode: `.opencode/skills/{skillId}/` or `.agents/skills/{skillId}/`
|
|
184
|
+
|
|
185
|
+
The `SKILL.md` uses portable `name` and `description` frontmatter. Up to 200 text/support files (1 MiB each) and common image/PDF assets (5 MiB each) are bundled, with a 16 MiB aggregate content limit; see `omittedFiles` for anything excluded. Catalog metadata is cached briefly, while skill file contents are read on demand. Local copies are snapshots; fetch them again after the capability changes. ViteBoard does not write into agent configuration directories automatically.
|
|
186
|
+
|
|
147
187
|
## 3. Markdown board embeds
|
|
148
188
|
|
|
149
189
|
Use fenced `board` blocks inside Markdown:
|
|
@@ -272,11 +312,14 @@ When deciding how much to change:
|
|
|
272
312
|
|
|
273
313
|
## 8. Concurrent edits
|
|
274
314
|
|
|
275
|
-
ViteBoard keeps autosave, but it does not coordinate live writers.
|
|
315
|
+
ViteBoard keeps autosave, but it does not coordinate live writers with locks.
|
|
316
|
+
|
|
317
|
+
1. Read `GET /__api/file?rootId={rootId}&path={path}` or `GET /__api/board?rootId={rootId}&id={boardId}` and retain its `revision`.
|
|
318
|
+
2. Send that value as `expectedRevision` in the matching `PUT /__api/file` or `PUT /__api/board` request.
|
|
319
|
+
3. For a create-only write, send `"expectedRevision": null`; this succeeds only if the target does not already exist.
|
|
320
|
+
4. If the write returns `409`, reload the latest version and re-apply your change.
|
|
276
321
|
|
|
277
|
-
|
|
278
|
-
2. Write changes with the expected revision.
|
|
279
|
-
3. If the write returns a conflict, reload the latest version and re-apply your change.
|
|
322
|
+
The API serializes writes and deletes for each target, so simultaneous revision-checked requests cannot both pass against the same old state. Omitting `expectedRevision` remains available for compatibility but permits unconditional overwrites; machine clients should always send it.
|
|
280
323
|
|
|
281
324
|
This keeps the runtime simple and fast. The UI does not lock files or boards while an agent writes.
|
|
282
325
|
|
|
@@ -296,3 +339,57 @@ Avoid these anti-patterns:
|
|
|
296
339
|
- Embeds resolve by relative path.
|
|
297
340
|
- Embed crops stay inside the intended frame.
|
|
298
341
|
- The resulting layout is readable to a human.
|
|
342
|
+
|
|
343
|
+
## 10. HTTP PDF export
|
|
344
|
+
|
|
345
|
+
Use `POST /__api/export/document/pdf` to export a document or a directory of Markdown documents. The server renders Markdown and board embeds with ViteBoard's renderer, then returns PDF bytes with `Content-Type: application/pdf` and a suggested filename in `Content-Disposition`. Save those bytes with the agent's own file tools. The server does not write to a path on the agent's machine.
|
|
346
|
+
|
|
347
|
+
Example request:
|
|
348
|
+
|
|
349
|
+
```http
|
|
350
|
+
POST /__api/export/document/pdf
|
|
351
|
+
Content-Type: application/json
|
|
352
|
+
|
|
353
|
+
{
|
|
354
|
+
"rootId": "root-...",
|
|
355
|
+
"kind": "doc",
|
|
356
|
+
"path": "guide/getting-started.md",
|
|
357
|
+
"options": {
|
|
358
|
+
"paper": "A4",
|
|
359
|
+
"style": "clean",
|
|
360
|
+
"scale": "100",
|
|
361
|
+
"showHeaderChrome": false,
|
|
362
|
+
"showFooterChrome": false,
|
|
363
|
+
"includeCoverPage": false,
|
|
364
|
+
"includeTableOfContents": false,
|
|
365
|
+
"includeFilePaths": false,
|
|
366
|
+
"pageBreakBetweenDocs": false
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Set `kind` to `dir` and `path` to a docs-root-relative directory to export its Markdown files. Optional `selectedPaths` limits that set. The `options` object supports the same publication settings as the UI: `title`, `subtitle`, `style` (`clean` or `report`), `paper` (`A4` or `Letter`), `scale` (`90`, `100`, or `110`), `includeCoverPage`, `includeTableOfContents`, `includeFilePaths`, `pageBreakBetweenDocs`, `showHeaderChrome`, `showFooterChrome`, `author`, `organization`, `publicationDate`, `classification`, `legalNotice`, and `logoDataUrl`.
|
|
372
|
+
|
|
373
|
+
Optional features and metadata default to off or blank. Before every export, ask the human to choose the options. Explain each option and do not silently enable one. For agent workflows, see `/viteboard-skill.md`.
|
|
374
|
+
|
|
375
|
+
## 11. HTTP board-region export
|
|
376
|
+
|
|
377
|
+
Use `POST /__api/export/board` to export a saved board as PDF, JPEG, or SVG bytes. Provide a docs `rootId`, `boardId` (board ID without `.board.json`), and `format`. An optional `region` is `[x, y, width, height]` in board world units; it must have positive width and height. If omitted, ViteBoard exports the visible board content with 48 world units of padding. `padding` can override that inferred-content padding; for an explicit region it defaults to zero.
|
|
378
|
+
|
|
379
|
+
```http
|
|
380
|
+
POST /__api/export/board
|
|
381
|
+
Content-Type: application/json
|
|
382
|
+
|
|
383
|
+
{
|
|
384
|
+
"rootId": "root-...",
|
|
385
|
+
"boardId": "product-roadmap",
|
|
386
|
+
"format": "jpeg",
|
|
387
|
+
"region": [-400, 0, 1800, 900],
|
|
388
|
+
"scale": 2,
|
|
389
|
+
"quality": 0.92
|
|
390
|
+
}
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
The optional `scale` sets output pixels per world unit (default `2`, accepted range `0.1`–`16`); dimensions are safely capped. `quality` sets JPEG quality from `0.1` to `1` (default `0.92`). For PDF, `paper` may be `A4` or `Letter` (default `A4`); page orientation follows the selected region. The endpoint returns bytes with the matching `Content-Type` and a suggested `Content-Disposition` filename. Save the response on the agent's machine.
|
|
394
|
+
|
|
395
|
+
SVG exports are standards-compliant SVG containers with the rendered board embedded as a PNG image. This preserves ViteBoard's canvas rendering accurately, but the shapes inside are not editable vector paths; use `scale` to choose raster resolution.
|
package/content/machine/llms.txt
CHANGED
|
@@ -71,6 +71,7 @@ When helpful, tell the human which of these are relevant in the current workspac
|
|
|
71
71
|
## Core machine docs
|
|
72
72
|
|
|
73
73
|
- [ViteBoard authoring guide](/viteboard-guide.md): Workspace model, routes, boards, embeds, capabilities, and revision-checked editing.
|
|
74
|
+
- [ViteBoard agent skill](/viteboard-skill.md): HTTP workflows, safe edits, and user-selected PDF export settings.
|
|
74
75
|
- [Board schema v1](/board-v1.schema.json): JSON Schema for `.board.json` files.
|
|
75
76
|
- [Example board](/viteboard-example.board.json): One complete valid ViteBoard board.
|
|
76
77
|
|
|
@@ -79,8 +80,11 @@ When helpful, tell the human which of these are relevant in the current workspac
|
|
|
79
80
|
- Capabilities are managed directory branches under `capabilities/` inside a docs root.
|
|
80
81
|
- Human entrypoint: `index.md`
|
|
81
82
|
- Machine entrypoint: `llms.txt`
|
|
83
|
+
- Agent Skills entrypoint: generated standard `SKILL.md`, including branch rules from `llms.txt`.
|
|
82
84
|
- The rest of the branch may contain markdown, scripts, helpers, assets, examples, and nested directories.
|
|
83
85
|
|
|
86
|
+
Discover skills with `GET /__api/capabilities`; read a capability's rules, generated skill, and file URLs with `GET /__api/capability?rootId={rootId}&path={branchPath}`. OpenCode can consume the live catalog at `/__api/skills/catalog/`. See `/viteboard-guide.md` for setup and portable local skill locations.
|
|
87
|
+
|
|
84
88
|
## App discovery
|
|
85
89
|
|
|
86
90
|
- [Workspace listing](/__api/workspace): Registered projects and docs roots.
|