viteboard 0.2.0 → 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 +861 -190
- package/bin/pdf-export.mjs +132 -12
- 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 -1169
- package/dist/assets/index-CD-CmqNM.js +0 -97
- package/dist/assets/index-CD3PKrv7.css +0 -1
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import os from "node:os";
|
|
4
3
|
import { createHash } from "node:crypto";
|
|
5
4
|
import { execFile } from "node:child_process";
|
|
6
5
|
import { promisify } from "node:util";
|
|
7
|
-
import { fileURLToPath
|
|
8
|
-
import { renderPdfFromHtml as renderPdfFromHtmlPortable, sanitizePdfFileName as sanitizePdfFileNamePortable } from "./pdf-export.mjs";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { evaluatePageFromUrl as evaluatePageFromUrlPortable, renderPdfFromHtml as renderPdfFromHtmlPortable, sanitizePdfFileName as sanitizePdfFileNamePortable } from "./pdf-export.mjs";
|
|
9
8
|
|
|
10
9
|
const WORKSPACE_DIR = ".workspace";
|
|
11
10
|
const WORKSPACE_FILE = "workspace.json";
|
|
@@ -30,18 +29,80 @@ const MIME_EXT = {
|
|
|
30
29
|
const MANAGED_ASSET_RE = /^assets\/[a-f0-9]{64}\.(png|jpe?g|webp|gif|svg)$/i;
|
|
31
30
|
const ACTOR_TTL_MS = 30000;
|
|
32
31
|
const LOCK_TTL_MS = 30000;
|
|
32
|
+
const CAPABILITY_CACHE_MS = 1000;
|
|
33
|
+
const WORKSPACE_CACHE_MS = 500;
|
|
34
|
+
const SKILL_BUNDLE_MAX_BYTES = 16 * 1024 * 1024;
|
|
35
|
+
const SKILL_SUPPORT_EXTENSIONS = new Set([
|
|
36
|
+
".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".csv", ".xml",
|
|
37
|
+
".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".sh", ".py", ".sql",
|
|
38
|
+
".html", ".css",
|
|
39
|
+
]);
|
|
40
|
+
const SKILL_ASSET_TYPES = {
|
|
41
|
+
".svg": "image/svg+xml",
|
|
42
|
+
".png": "image/png",
|
|
43
|
+
".jpg": "image/jpeg",
|
|
44
|
+
".jpeg": "image/jpeg",
|
|
45
|
+
".webp": "image/webp",
|
|
46
|
+
".gif": "image/gif",
|
|
47
|
+
".pdf": "application/pdf",
|
|
48
|
+
};
|
|
33
49
|
const ROOT_LINK_HEADER = '</llms.txt>; rel="describedby"; type="text/plain", </.well-known/viteboard.json>; rel="alternate"; type="application/json"';
|
|
34
50
|
const execFileAsync = promisify(execFile);
|
|
35
51
|
|
|
36
|
-
|
|
37
|
-
const
|
|
52
|
+
function boardExportFileName(title, format) {
|
|
53
|
+
const extension = format === "jpeg" ? "jpg" : format;
|
|
54
|
+
const base = String(title ?? "board")
|
|
55
|
+
.trim()
|
|
56
|
+
.replace(/[\\/:*?"<>|]+/g, "-")
|
|
57
|
+
.replace(/[\u0000-\u001f\u007f]/g, "")
|
|
58
|
+
.replace(/\s+/g, " ")
|
|
59
|
+
.replace(/\.(pdf|jpe?g|svg)$/i, "")
|
|
60
|
+
.slice(0, 120) || "board";
|
|
61
|
+
return `${base}.${extension}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function decodeExportDataUrl(dataUrl, expectedMimeType) {
|
|
65
|
+
if (typeof dataUrl !== "string" || !dataUrl.startsWith("data:")) throw new Error("Board renderer returned invalid image data.");
|
|
66
|
+
const comma = dataUrl.indexOf(",");
|
|
67
|
+
if (comma < 0) throw new Error("Board renderer returned invalid image data.");
|
|
68
|
+
const metadata = dataUrl.slice(5, comma);
|
|
69
|
+
const mimeType = metadata.split(";")[0];
|
|
70
|
+
if (mimeType !== expectedMimeType) throw new Error("Board renderer returned an unexpected image format.");
|
|
71
|
+
const payload = dataUrl.slice(comma + 1);
|
|
72
|
+
if (metadata.split(";").includes("base64")) return Buffer.from(payload, "base64");
|
|
73
|
+
return Buffer.from(decodeURIComponent(payload), "utf-8");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createContentApiPlugin(options = {}) {
|
|
77
|
+
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
38
78
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
39
79
|
const workspaceDir = path.resolve(cwd, WORKSPACE_DIR);
|
|
40
80
|
const workspaceFile = path.join(workspaceDir, WORKSPACE_FILE);
|
|
41
81
|
const machineDir = path.join(packageRoot, "content", "machine");
|
|
42
82
|
const actorSessions = new Map();
|
|
43
83
|
const locks = new Map();
|
|
84
|
+
const mutationQueues = new Map();
|
|
44
85
|
const sseClients = new Set();
|
|
86
|
+
const capabilityCaches = new Map();
|
|
87
|
+
const capabilityCachePromises = new Map();
|
|
88
|
+
let capabilityGeneration = 0;
|
|
89
|
+
let workspaceCache = null;
|
|
90
|
+
let workspaceCacheExpiresAt = 0;
|
|
91
|
+
let workspaceCachePromise = null;
|
|
92
|
+
let workspaceGeneration = 0;
|
|
93
|
+
|
|
94
|
+
function invalidateCapabilityCache() {
|
|
95
|
+
capabilityGeneration++;
|
|
96
|
+
capabilityCaches.clear();
|
|
97
|
+
capabilityCachePromises.clear();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function invalidateWorkspaceCache() {
|
|
101
|
+
workspaceGeneration++;
|
|
102
|
+
workspaceCache = null;
|
|
103
|
+
workspaceCacheExpiresAt = 0;
|
|
104
|
+
workspaceCachePromise = null;
|
|
105
|
+
}
|
|
45
106
|
|
|
46
107
|
function send(res, status, body) {
|
|
47
108
|
res.statusCode = status;
|
|
@@ -58,7 +119,12 @@ export function createContentApiPlugin() {
|
|
|
58
119
|
function sendBinary(res, status, body, type, fileName) {
|
|
59
120
|
res.statusCode = status;
|
|
60
121
|
res.setHeader("Content-Type", type);
|
|
61
|
-
if (fileName)
|
|
122
|
+
if (fileName) {
|
|
123
|
+
const value = String(fileName).replace(/[\r\n\\"]/g, "-");
|
|
124
|
+
const asciiName = value.replace(/[^\x20-\x7e]/g, "_");
|
|
125
|
+
const encodedName = encodeURIComponent(value).replace(/['()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
126
|
+
res.setHeader("Content-Disposition", `attachment; filename="${asciiName}"; filename*=UTF-8''${encodedName}`);
|
|
127
|
+
}
|
|
62
128
|
res.end(body);
|
|
63
129
|
}
|
|
64
130
|
|
|
@@ -68,6 +134,12 @@ export function createContentApiPlugin() {
|
|
|
68
134
|
}
|
|
69
135
|
|
|
70
136
|
function broadcast(event, body) {
|
|
137
|
+
if (event === "workspace-changed") {
|
|
138
|
+
invalidateCapabilityCache();
|
|
139
|
+
invalidateWorkspaceCache();
|
|
140
|
+
} else if (event === "file-changed" && [body?.path, body?.oldPath].some((filePath) => filePath?.startsWith("capabilities/"))) {
|
|
141
|
+
invalidateCapabilityCache();
|
|
142
|
+
}
|
|
71
143
|
for (const client of sseClients) {
|
|
72
144
|
try { sendSse(client, event, body); } catch { sseClients.delete(client); }
|
|
73
145
|
}
|
|
@@ -86,6 +158,34 @@ export function createContentApiPlugin() {
|
|
|
86
158
|
return `${target.targetType}:${target.rootId}:${suffix}`;
|
|
87
159
|
}
|
|
88
160
|
|
|
161
|
+
function fileMutationKey(absPath) {
|
|
162
|
+
const normalized = path.normalize(absPath);
|
|
163
|
+
return `file:${process.platform === "win32" ? normalized.toLowerCase() : normalized}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function withMutationLock(key, operation) {
|
|
167
|
+
const previous = mutationQueues.get(key) ?? Promise.resolve();
|
|
168
|
+
let release;
|
|
169
|
+
const gate = new Promise((resolve) => { release = resolve; });
|
|
170
|
+
const queued = previous.then(() => gate);
|
|
171
|
+
mutationQueues.set(key, queued);
|
|
172
|
+
await previous;
|
|
173
|
+
try {
|
|
174
|
+
return await operation();
|
|
175
|
+
} finally {
|
|
176
|
+
release();
|
|
177
|
+
if (mutationQueues.get(key) === queued) mutationQueues.delete(key);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function withMutationLocks(keys, operation) {
|
|
182
|
+
const uniqueKeys = [...new Set(keys)].sort();
|
|
183
|
+
const acquire = (index) => index >= uniqueKeys.length
|
|
184
|
+
? operation()
|
|
185
|
+
: withMutationLock(uniqueKeys[index], () => acquire(index + 1));
|
|
186
|
+
return acquire(0);
|
|
187
|
+
}
|
|
188
|
+
|
|
89
189
|
function activeActors() { return [...actorSessions.values()].sort((a, b) => a.label.localeCompare(b.label)); }
|
|
90
190
|
function activeLocks() { return [...locks.values()].sort((a, b) => a.acquiredAt - b.acquiredAt); }
|
|
91
191
|
function broadcastPresence() { broadcast("presence", { actors: activeActors(), locks: activeLocks() }); }
|
|
@@ -112,135 +212,17 @@ export function createContentApiPlugin() {
|
|
|
112
212
|
}
|
|
113
213
|
|
|
114
214
|
setInterval(pruneEphemeral, 10000).unref?.();
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
chromiumBinaryPromise = (async () => {
|
|
121
|
-
for (const candidate of ["chromium-browser", "chromium", "google-chrome", "google-chrome-stable"]) {
|
|
122
|
-
try {
|
|
123
|
-
await execFileAsync("which", [candidate]);
|
|
124
|
-
return candidate;
|
|
125
|
-
} catch {
|
|
126
|
-
/* continue */
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
throw new Error("No Chromium-compatible browser is available for PDF export.");
|
|
130
|
-
})();
|
|
131
|
-
return chromiumBinaryPromise;
|
|
132
|
-
}
|
|
133
|
-
function sanitizePdfFileName(input) {
|
|
134
|
-
const cleaned = String(input ?? "").trim().replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, " ").slice(0, 120) || "export";
|
|
135
|
-
return cleaned.toLowerCase().endsWith(".pdf") ? cleaned : `${cleaned}.pdf`;
|
|
136
|
-
}
|
|
137
|
-
function mmToInches(mm) {
|
|
138
|
-
return Number(mm ?? 0) / 25.4;
|
|
139
|
-
}
|
|
140
|
-
async function openCdpPage(port) {
|
|
141
|
-
const res = await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" });
|
|
142
|
-
const target = await res.json();
|
|
143
|
-
if (!target.webSocketDebuggerUrl) throw new Error("Could not open Chromium page target for PDF export.");
|
|
144
|
-
return target.webSocketDebuggerUrl;
|
|
145
|
-
}
|
|
146
|
-
async function printPdfViaCdp(wsUrl, url, pdf) {
|
|
147
|
-
const ws = new WebSocket(wsUrl);
|
|
148
|
-
let nextId = 0;
|
|
149
|
-
const pending = new Map();
|
|
150
|
-
let loadResolve = null;
|
|
151
|
-
const loaded = new Promise((resolve) => { loadResolve = resolve; });
|
|
152
|
-
ws.onmessage = (event) => {
|
|
153
|
-
const message = JSON.parse(String(event.data));
|
|
154
|
-
if (message.method === "Page.loadEventFired") {
|
|
155
|
-
if (loadResolve) loadResolve();
|
|
156
|
-
loadResolve = null;
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
if (!message.id) return;
|
|
160
|
-
const entry = pending.get(message.id);
|
|
161
|
-
if (!entry) return;
|
|
162
|
-
pending.delete(message.id);
|
|
163
|
-
if (message.error) entry.reject(new Error(message.error.message || "CDP error"));
|
|
164
|
-
else entry.resolve(message.result);
|
|
165
|
-
};
|
|
166
|
-
const opened = new Promise((resolve, reject) => {
|
|
167
|
-
ws.onopen = resolve;
|
|
168
|
-
ws.onerror = () => reject(new Error("Could not connect to Chromium PDF export session."));
|
|
169
|
-
});
|
|
170
|
-
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
|
171
|
-
const id = ++nextId;
|
|
172
|
-
pending.set(id, { resolve, reject });
|
|
173
|
-
ws.send(JSON.stringify({ id, method, params }));
|
|
174
|
-
});
|
|
175
|
-
await opened;
|
|
176
|
-
try {
|
|
177
|
-
await send("Page.enable");
|
|
178
|
-
await send("Runtime.enable");
|
|
179
|
-
await send("Page.navigate", { url });
|
|
180
|
-
await loaded;
|
|
181
|
-
await send("Runtime.evaluate", {
|
|
182
|
-
expression: "document.fonts ? document.fonts.ready.then(() => true) : true",
|
|
183
|
-
awaitPromise: true,
|
|
184
|
-
returnByValue: true,
|
|
185
|
-
});
|
|
186
|
-
const result = await send("Page.printToPDF", {
|
|
187
|
-
printBackground: true,
|
|
188
|
-
preferCSSPageSize: true,
|
|
189
|
-
displayHeaderFooter: Boolean(pdf.displayHeaderFooter),
|
|
190
|
-
headerTemplate: pdf.headerTemplate || "<div></div>",
|
|
191
|
-
footerTemplate: pdf.footerTemplate || "<div></div>",
|
|
192
|
-
marginTop: mmToInches(pdf.marginTopMm ?? 12),
|
|
193
|
-
marginBottom: mmToInches(pdf.marginBottomMm ?? 12),
|
|
194
|
-
marginLeft: mmToInches(pdf.marginLeftMm ?? 12),
|
|
195
|
-
marginRight: mmToInches(pdf.marginRightMm ?? 12),
|
|
196
|
-
});
|
|
197
|
-
return Buffer.from(result.data, "base64");
|
|
198
|
-
} finally {
|
|
199
|
-
try { ws.close(); } catch { /* ignore */ }
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
async function renderPdfFromHtml(html, fileName, pdf = {}) {
|
|
203
|
-
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "viteboard-pdf-"));
|
|
204
|
-
const htmlPath = path.join(tmpDir, "publication.html");
|
|
205
|
-
const profileDir = path.join(tmpDir, "profile");
|
|
206
|
-
const browser = await resolveChromiumBinary();
|
|
207
|
-
let browserProcess = null;
|
|
208
|
-
try {
|
|
209
|
-
await fs.writeFile(htmlPath, html, "utf-8");
|
|
210
|
-
await fs.mkdir(profileDir, { recursive: true });
|
|
211
|
-
browserProcess = execFile(browser, [
|
|
212
|
-
"--headless",
|
|
213
|
-
"--disable-gpu",
|
|
214
|
-
"--no-sandbox",
|
|
215
|
-
`--user-data-dir=${profileDir}`,
|
|
216
|
-
"--remote-debugging-port=0",
|
|
217
|
-
"about:blank",
|
|
218
|
-
], { windowsHide: true });
|
|
219
|
-
const activePortPath = path.join(profileDir, "DevToolsActivePort");
|
|
220
|
-
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
221
|
-
try {
|
|
222
|
-
const raw = await fs.readFile(activePortPath, "utf-8");
|
|
223
|
-
const port = raw.split(/\r?\n/)[0]?.trim();
|
|
224
|
-
if (!port) throw new Error("Chromium debugging port missing.");
|
|
225
|
-
const wsUrl = await openCdpPage(port);
|
|
226
|
-
return await printPdfViaCdp(wsUrl, pathToFileURL(htmlPath).href, pdf);
|
|
227
|
-
} catch {
|
|
228
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
throw new Error("Timed out starting Chromium PDF export session.");
|
|
232
|
-
} finally {
|
|
233
|
-
if (browserProcess) {
|
|
234
|
-
const exited = new Promise((resolve) => {
|
|
235
|
-
browserProcess?.once("exit", resolve);
|
|
236
|
-
browserProcess?.once("close", resolve);
|
|
237
|
-
});
|
|
238
|
-
try { browserProcess.kill(); } catch { /* ignore */ }
|
|
239
|
-
await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 500))]);
|
|
215
|
+
setInterval(() => {
|
|
216
|
+
for (const client of sseClients) {
|
|
217
|
+
if (client.destroyed || client.writableEnded) {
|
|
218
|
+
sseClients.delete(client);
|
|
219
|
+
continue;
|
|
240
220
|
}
|
|
241
|
-
|
|
221
|
+
try { client.write(": keep-alive\n\n"); } catch { sseClients.delete(client); }
|
|
242
222
|
}
|
|
243
|
-
}
|
|
223
|
+
}, 20000).unref?.();
|
|
224
|
+
|
|
225
|
+
function revisionForText(content) { return createHash("sha1").update(content).digest("hex"); }
|
|
244
226
|
function hashPath(value) {
|
|
245
227
|
let hash = 2166136261;
|
|
246
228
|
for (let i = 0; i < value.length; i++) {
|
|
@@ -272,6 +254,10 @@ export function createContentApiPlugin() {
|
|
|
272
254
|
try { return path.resolve(await fs.realpath(resolved)); } catch { return resolved; }
|
|
273
255
|
}
|
|
274
256
|
|
|
257
|
+
async function isDirectory(absPath) {
|
|
258
|
+
try { return (await fs.stat(absPath)).isDirectory(); } catch { return false; }
|
|
259
|
+
}
|
|
260
|
+
|
|
275
261
|
function normalizeRelativePath(relPath) {
|
|
276
262
|
return relPath.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
277
263
|
}
|
|
@@ -382,9 +368,25 @@ export function createContentApiPlugin() {
|
|
|
382
368
|
const raw = await fs.readFile(workspaceFile, "utf-8");
|
|
383
369
|
const parsed = JSON.parse(raw);
|
|
384
370
|
const rawProjects = Array.isArray(parsed.projects) ? parsed.projects : [];
|
|
371
|
+
const projects = [];
|
|
372
|
+
const seenIds = new Set();
|
|
373
|
+
const seenPaths = new Set();
|
|
374
|
+
for (const item of rawProjects) {
|
|
375
|
+
if (typeof item?.rootPath !== "string") continue;
|
|
376
|
+
const rootPath = await canonicalizeProjectRoot(item.rootPath);
|
|
377
|
+
const id = typeof item.id === "string" && item.id.trim() ? item.id.trim() : projectIdFor(rootPath);
|
|
378
|
+
if (seenIds.has(id) || seenPaths.has(rootPath)) continue;
|
|
379
|
+
seenIds.add(id);
|
|
380
|
+
seenPaths.add(rootPath);
|
|
381
|
+
projects.push({
|
|
382
|
+
id,
|
|
383
|
+
label: typeof item.label === "string" && item.label.trim() ? item.label.trim() : path.basename(rootPath),
|
|
384
|
+
rootPath,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
385
387
|
return {
|
|
386
388
|
version: 1,
|
|
387
|
-
projects
|
|
389
|
+
projects,
|
|
388
390
|
};
|
|
389
391
|
} catch { return { version: 1, projects: [] }; }
|
|
390
392
|
}
|
|
@@ -399,11 +401,22 @@ export function createContentApiPlugin() {
|
|
|
399
401
|
return rel === "" ? "docs" : rel;
|
|
400
402
|
}
|
|
401
403
|
|
|
402
|
-
|
|
404
|
+
// Preserve the path-only IDs used by earlier versions so saved routes and API references remain valid.
|
|
405
|
+
function docsRootId(absPath) { return `root-${hashPath(absPath)}`; }
|
|
403
406
|
function projectIdFor(absRoot) { return `project-${hashPath(absRoot)}`; }
|
|
404
407
|
|
|
405
408
|
async function discoverDocsRoots(project) {
|
|
406
409
|
const roots = [];
|
|
410
|
+
if (path.basename(project.rootPath) === "docs") {
|
|
411
|
+
roots.push({
|
|
412
|
+
id: docsRootId(project.rootPath),
|
|
413
|
+
projectId: project.id,
|
|
414
|
+
projectLabel: project.label,
|
|
415
|
+
absPath: project.rootPath,
|
|
416
|
+
relativePath: "docs",
|
|
417
|
+
displayPath: "docs",
|
|
418
|
+
});
|
|
419
|
+
}
|
|
407
420
|
const walk = async (dir) => {
|
|
408
421
|
let entries;
|
|
409
422
|
try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
@@ -414,7 +427,7 @@ export function createContentApiPlugin() {
|
|
|
414
427
|
if (entry.name === "docs") {
|
|
415
428
|
const relativePath = normalizeRelativePath(path.relative(project.rootPath, abs));
|
|
416
429
|
roots.push({
|
|
417
|
-
id: docsRootId(
|
|
430
|
+
id: docsRootId(abs),
|
|
418
431
|
projectId: project.id,
|
|
419
432
|
projectLabel: project.label,
|
|
420
433
|
absPath: abs,
|
|
@@ -429,13 +442,37 @@ export function createContentApiPlugin() {
|
|
|
429
442
|
return roots.sort((a, b) => a.displayPath.localeCompare(b.displayPath));
|
|
430
443
|
}
|
|
431
444
|
|
|
432
|
-
async function
|
|
445
|
+
async function loadWorkspace() {
|
|
433
446
|
const manifest = await loadManifest();
|
|
434
447
|
const projects = [];
|
|
435
448
|
for (const project of manifest.projects) projects.push({ ...project, docsRoots: await discoverDocsRoots(project) });
|
|
436
449
|
return projects;
|
|
437
450
|
}
|
|
438
451
|
|
|
452
|
+
async function getWorkspace() {
|
|
453
|
+
if (workspaceCache && Date.now() < workspaceCacheExpiresAt) return workspaceCache;
|
|
454
|
+
if (workspaceCachePromise) return workspaceCachePromise;
|
|
455
|
+
const generation = workspaceGeneration;
|
|
456
|
+
const pending = loadWorkspace();
|
|
457
|
+
workspaceCachePromise = pending;
|
|
458
|
+
try {
|
|
459
|
+
const projects = await pending;
|
|
460
|
+
if (generation !== workspaceGeneration) return getWorkspace();
|
|
461
|
+
workspaceCache = projects;
|
|
462
|
+
workspaceCacheExpiresAt = Date.now() + WORKSPACE_CACHE_MS;
|
|
463
|
+
const expiresAt = workspaceCacheExpiresAt;
|
|
464
|
+
setTimeout(() => {
|
|
465
|
+
if (workspaceCache === projects && workspaceCacheExpiresAt === expiresAt) {
|
|
466
|
+
workspaceCache = null;
|
|
467
|
+
workspaceCacheExpiresAt = 0;
|
|
468
|
+
}
|
|
469
|
+
}, WORKSPACE_CACHE_MS).unref?.();
|
|
470
|
+
return projects;
|
|
471
|
+
} finally {
|
|
472
|
+
if (workspaceCachePromise === pending) workspaceCachePromise = null;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
439
476
|
function toWorkspaceResponse(projects) {
|
|
440
477
|
return {
|
|
441
478
|
projects: projects.map((project) => ({
|
|
@@ -459,6 +496,34 @@ export function createContentApiPlugin() {
|
|
|
459
496
|
return abs;
|
|
460
497
|
}
|
|
461
498
|
|
|
499
|
+
function isWithinPath(root, candidate) {
|
|
500
|
+
const relative = path.relative(root, candidate);
|
|
501
|
+
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function resolveSafeFilesystemPath(root, rel) {
|
|
505
|
+
const abs = safeResolve(root, rel);
|
|
506
|
+
if (!abs) return null;
|
|
507
|
+
let realRoot;
|
|
508
|
+
try { realRoot = await fs.realpath(root); } catch { return null; }
|
|
509
|
+
|
|
510
|
+
let existing = abs;
|
|
511
|
+
while (true) {
|
|
512
|
+
try {
|
|
513
|
+
const stat = await fs.lstat(existing);
|
|
514
|
+
if (stat.isSymbolicLink()) return null;
|
|
515
|
+
const realExisting = await fs.realpath(existing);
|
|
516
|
+
if (!isWithinPath(realRoot, realExisting)) return null;
|
|
517
|
+
return abs;
|
|
518
|
+
} catch (error) {
|
|
519
|
+
if (error.code !== "ENOENT") return null;
|
|
520
|
+
const parent = path.dirname(existing);
|
|
521
|
+
if (parent === existing) return null;
|
|
522
|
+
existing = parent;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
462
527
|
async function resolveRoot(rootId) {
|
|
463
528
|
const projects = await getWorkspace();
|
|
464
529
|
for (const project of projects) {
|
|
@@ -491,6 +556,363 @@ export function createContentApiPlugin() {
|
|
|
491
556
|
return nodes;
|
|
492
557
|
}
|
|
493
558
|
|
|
559
|
+
function capabilityBranches(tree) {
|
|
560
|
+
const capabilityRoot = tree.find((node) => node.type === "dir" && node.name === "capabilities");
|
|
561
|
+
if (!capabilityRoot || capabilityRoot.type !== "dir") return [];
|
|
562
|
+
|
|
563
|
+
const branches = [];
|
|
564
|
+
const walk = (nodes) => {
|
|
565
|
+
for (const node of nodes) {
|
|
566
|
+
if (node.type === "file") {
|
|
567
|
+
const relative = node.path.slice("capabilities/".length);
|
|
568
|
+
if (node.path.startsWith("capabilities/") && node.name.endsWith(".md") && !relative.includes("/")) {
|
|
569
|
+
branches.push({ branchPath: node.path, entryPath: node.path, node });
|
|
570
|
+
}
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const entry = node.children
|
|
575
|
+
.filter((child) => child.type === "file" && child.name.toLowerCase().endsWith(".md"))
|
|
576
|
+
.sort((a, b) => (a.name === "index.md" ? -1 : b.name === "index.md" ? 1 : a.name.localeCompare(b.name)))[0];
|
|
577
|
+
if (entry) branches.push({ branchPath: node.path, entryPath: entry.path, node });
|
|
578
|
+
walk(node.children);
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
walk(capabilityRoot.children);
|
|
582
|
+
return branches.sort((a, b) => a.branchPath.localeCompare(b.branchPath));
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function flattenTreeFiles(nodes) {
|
|
586
|
+
const files = [];
|
|
587
|
+
for (const node of nodes) {
|
|
588
|
+
if (node.type === "file") files.push(node);
|
|
589
|
+
else files.push(...flattenTreeFiles(node.children));
|
|
590
|
+
}
|
|
591
|
+
return files;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function skillFrontmatter(content) {
|
|
595
|
+
const match = String(content ?? "").match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
596
|
+
const fields = {};
|
|
597
|
+
if (!match) return { fields, body: String(content ?? "") };
|
|
598
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
599
|
+
const field = line.match(/^([a-zA-Z][\w-]*):\s*(.*)$/);
|
|
600
|
+
if (!field) continue;
|
|
601
|
+
let value = field[2].trim();
|
|
602
|
+
if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
603
|
+
value = value.slice(1, -1);
|
|
604
|
+
}
|
|
605
|
+
fields[field[1]] = value;
|
|
606
|
+
}
|
|
607
|
+
return { fields, body: String(content ?? "").slice(match[0].length).trim() };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function simpleMarkdownMetadata(content) {
|
|
611
|
+
const frontmatter = String(content ?? "").match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1] ?? "";
|
|
612
|
+
const fields = {};
|
|
613
|
+
for (const line of frontmatter.split(/\r?\n/)) {
|
|
614
|
+
const field = line.match(/^([a-zA-Z][\w-]*):\s*(.*)$/);
|
|
615
|
+
if (!field) continue;
|
|
616
|
+
fields[field[1]] = field[2].trim().replace(/^(?:"(.*)"|'(.*)')$/, (_, doubleQuoted, singleQuoted) => doubleQuoted ?? singleQuoted);
|
|
617
|
+
}
|
|
618
|
+
return fields;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function firstCapabilityParagraph(content) {
|
|
622
|
+
const body = String(content ?? "").replace(/^---\r?\n[\s\S]*?\r?\n---\s*/m, "");
|
|
623
|
+
const purpose = body.match(/^#{1,6}\s+purpose\s*\r?\n+([\s\S]*?)(?=\r?\n#{1,6}\s|$)/im)?.[1];
|
|
624
|
+
const source = purpose ?? body;
|
|
625
|
+
return source
|
|
626
|
+
.split(/\r?\n\s*\r?\n/)
|
|
627
|
+
.map((part) => part.trim().replace(/^#{1,6}\s+/, "").replace(/^>\s?/, ""))
|
|
628
|
+
.find((part) => part && !part.startsWith("-") && !part.startsWith("```"))
|
|
629
|
+
?.replace(/\s+/g, " ") ?? "";
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function firstCapabilityHeading(content) {
|
|
633
|
+
const body = String(content ?? "").replace(/^---\r?\n[\s\S]*?\r?\n---\s*/m, "");
|
|
634
|
+
return body.match(/^#\s+(.+?)\s*#*\s*$/m)?.[1]?.trim() ?? "";
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function prettifyCapabilityName(value) {
|
|
638
|
+
const base = String(value ?? "Capability").replace(/\.md$/i, "").split(/[\\/]/).pop() ?? "Capability";
|
|
639
|
+
const title = base.replace(/[-_]+/g, " ").trim();
|
|
640
|
+
return title ? title.charAt(0).toUpperCase() + title.slice(1) : "Capability";
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function portableSkillId(project, root, branchPath) {
|
|
644
|
+
const routePath = branchPath.replace(/^capabilities\//, "").replace(/\.md$/i, "");
|
|
645
|
+
const slug = `${project.label}-${root.displayPath}-${routePath}`
|
|
646
|
+
.normalize("NFKD")
|
|
647
|
+
.toLowerCase()
|
|
648
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
649
|
+
.replace(/^-+|-+$/g, "") || "capability";
|
|
650
|
+
const suffix = createHash("sha1").update(`${root.id}:${branchPath}`).digest("hex").slice(0, 8);
|
|
651
|
+
return `${slug.slice(0, 55).replace(/-+$/g, "")}-${suffix}`;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function renderAgentSkill(capability) {
|
|
655
|
+
const sourceSkill = capability.files.find((file) => file.relativePath === "SKILL.md")?.content ?? "";
|
|
656
|
+
const sourceBody = skillFrontmatter(sourceSkill).body;
|
|
657
|
+
const machineRules = capability.machineInstructions.trim();
|
|
658
|
+
const supportingFiles = capability.files
|
|
659
|
+
.filter((file) => file.relativePath !== "SKILL.md")
|
|
660
|
+
.map((file) => `- \`${file.relativePath}\` (source: \`${file.sourcePath}\`)`);
|
|
661
|
+
const omittedFiles = capability.omittedFiles.map((file) => `- \`${file}\``);
|
|
662
|
+
const body = [
|
|
663
|
+
`# ${capability.title}`,
|
|
664
|
+
capability.summary,
|
|
665
|
+
"",
|
|
666
|
+
"## ViteBoard source",
|
|
667
|
+
"",
|
|
668
|
+
`- Project: ${capability.projectLabel}`,
|
|
669
|
+
`- Docs root: ${capability.docsRoot} (${capability.rootId})`,
|
|
670
|
+
`- Branch: \`${capability.branchPath}\``,
|
|
671
|
+
`- Detail API: \`/__api/capability?rootId=${encodeURIComponent(capability.rootId)}&path=${encodeURIComponent(capability.branchPath)}\``,
|
|
672
|
+
"",
|
|
673
|
+
...(sourceBody ? ["## Capability-specific skill instructions", "", sourceBody, ""] : []),
|
|
674
|
+
"## Capability rules",
|
|
675
|
+
"",
|
|
676
|
+
machineRules || `No llms.txt is present. Read ${capability.entryPath} and inspect the relevant supporting files before acting.`,
|
|
677
|
+
"",
|
|
678
|
+
"## Supporting files",
|
|
679
|
+
"",
|
|
680
|
+
...(supportingFiles.length ? supportingFiles : ["No additional files are listed."]),
|
|
681
|
+
...(omittedFiles.length ? ["", "Files not included in this skill bundle (for example, unsupported types or files over the size limit):", "", ...omittedFiles] : []),
|
|
682
|
+
"",
|
|
683
|
+
"The capability source of truth is the ViteBoard branch listed above. Preserve its rules and use the ViteBoard capability API or the project files to read current supporting material.",
|
|
684
|
+
].join("\n").trim();
|
|
685
|
+
return `---\nname: ${capability.id}\ndescription: ${JSON.stringify(capability.description)}\n---\n\n${body}\n`;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async function buildCapabilityIndex(scope = {}) {
|
|
689
|
+
const projects = await getWorkspace();
|
|
690
|
+
const capabilities = [];
|
|
691
|
+
for (const project of projects) {
|
|
692
|
+
if (scope.projectId && project.id !== scope.projectId) continue;
|
|
693
|
+
for (const root of project.docsRoots) {
|
|
694
|
+
if (scope.rootId && root.id !== scope.rootId) continue;
|
|
695
|
+
const nestedRoots = new Set(project.docsRoots
|
|
696
|
+
.filter((item) => item.id !== root.id && item.absPath.startsWith(root.absPath + path.sep))
|
|
697
|
+
.map((item) => item.relativePath));
|
|
698
|
+
const tree = await buildTree(root.absPath, nestedRoots);
|
|
699
|
+
for (const branch of capabilityBranches(tree)) {
|
|
700
|
+
const sourceNodes = branch.node.type === "file" ? [branch.node] : flattenTreeFiles(branch.node.children);
|
|
701
|
+
const preferredPaths = new Set([
|
|
702
|
+
branch.entryPath,
|
|
703
|
+
branch.node.type === "dir" ? `${branch.branchPath}/llms.txt` : "",
|
|
704
|
+
branch.node.type === "dir" ? `${branch.branchPath}/SKILL.md` : "",
|
|
705
|
+
]);
|
|
706
|
+
sourceNodes.sort((a, b) => Number(preferredPaths.has(b.path)) - Number(preferredPaths.has(a.path)) || a.path.localeCompare(b.path));
|
|
707
|
+
const files = [];
|
|
708
|
+
let bundleBytes = 0;
|
|
709
|
+
for (const node of sourceNodes) {
|
|
710
|
+
const extension = path.extname(node.name).toLowerCase();
|
|
711
|
+
const isText = SKILL_SUPPORT_EXTENSIONS.has(extension);
|
|
712
|
+
const contentType = isText
|
|
713
|
+
? extension === ".md"
|
|
714
|
+
? "text/markdown; charset=utf-8"
|
|
715
|
+
: extension === ".json"
|
|
716
|
+
? "application/json; charset=utf-8"
|
|
717
|
+
: extension === ".js" || extension === ".mjs" || extension === ".cjs"
|
|
718
|
+
? "text/javascript; charset=utf-8"
|
|
719
|
+
: extension === ".css"
|
|
720
|
+
? "text/css; charset=utf-8"
|
|
721
|
+
: "text/plain; charset=utf-8"
|
|
722
|
+
: SKILL_ASSET_TYPES[extension];
|
|
723
|
+
if (!contentType) continue;
|
|
724
|
+
const absolutePath = await resolveSafeFilesystemPath(root.absPath, node.path);
|
|
725
|
+
if (!absolutePath) continue;
|
|
726
|
+
try {
|
|
727
|
+
const stat = await fs.lstat(absolutePath);
|
|
728
|
+
const fileLimit = isText ? 1024 * 1024 : 5 * 1024 * 1024;
|
|
729
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > fileLimit || bundleBytes + stat.size > SKILL_BUNDLE_MAX_BYTES) continue;
|
|
730
|
+
const bytes = await fs.readFile(absolutePath);
|
|
731
|
+
const content = isText ? bytes.toString("utf-8") : bytes;
|
|
732
|
+
bundleBytes += stat.size;
|
|
733
|
+
const isSingleFile = branch.node.type === "file";
|
|
734
|
+
let relativePath = isSingleFile
|
|
735
|
+
? `references/${path.basename(node.path)}`
|
|
736
|
+
: node.path.slice(branch.branchPath.length + 1);
|
|
737
|
+
if (relativePath === "SKILL.md") relativePath = "SKILL.md";
|
|
738
|
+
files.push({
|
|
739
|
+
sourcePath: node.path,
|
|
740
|
+
relativePath,
|
|
741
|
+
catalogPath: relativePath === "SKILL.md" ? "references/source-SKILL.md" : relativePath,
|
|
742
|
+
content,
|
|
743
|
+
contentType,
|
|
744
|
+
size: stat.size,
|
|
745
|
+
isText,
|
|
746
|
+
});
|
|
747
|
+
if (files.length >= 200) break;
|
|
748
|
+
} catch {
|
|
749
|
+
/* Ignore unreadable, binary, or concurrently removed support files. */
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
const includedSourcePaths = new Set(files.map((file) => file.sourcePath));
|
|
753
|
+
const omittedFiles = sourceNodes.filter((node) => !includedSourcePaths.has(node.path)).map((node) => node.path);
|
|
754
|
+
|
|
755
|
+
const entryFile = files.find((file) => file.sourcePath === branch.entryPath);
|
|
756
|
+
const sourceSkill = files.find((file) => file.relativePath === "SKILL.md");
|
|
757
|
+
const machinePath = branch.node.type === "dir" ? `${branch.branchPath}/llms.txt` : null;
|
|
758
|
+
const machineFile = files.find((file) => file.sourcePath === machinePath);
|
|
759
|
+
const metadata = simpleMarkdownMetadata(entryFile?.content ?? "");
|
|
760
|
+
const authoredSkillMetadata = simpleMarkdownMetadata(sourceSkill?.content ?? "");
|
|
761
|
+
const title = metadata.title || firstCapabilityHeading(entryFile?.content) || prettifyCapabilityName(branch.entryPath);
|
|
762
|
+
const summary = metadata.summary || firstCapabilityParagraph(machineFile?.content) || firstCapabilityParagraph(entryFile?.content);
|
|
763
|
+
const description = (authoredSkillMetadata.description || summary || `Reusable instructions and resources for ${title}.`)
|
|
764
|
+
.replace(/\s+/g, " ")
|
|
765
|
+
.slice(0, 900);
|
|
766
|
+
const id = portableSkillId(project, root, branch.branchPath);
|
|
767
|
+
const routePath = branch.branchPath.replace(/^capabilities\//, "");
|
|
768
|
+
const record = {
|
|
769
|
+
id,
|
|
770
|
+
skillId: id,
|
|
771
|
+
title,
|
|
772
|
+
name: title,
|
|
773
|
+
description,
|
|
774
|
+
summary,
|
|
775
|
+
kind: metadata.kind || undefined,
|
|
776
|
+
tags: String(metadata.tags ?? "").replace(/^\[|\]$/g, "").split(",").map((tag) => tag.trim().replace(/^['\"]|['\"]$/g, "")).filter(Boolean),
|
|
777
|
+
rootId: root.id,
|
|
778
|
+
projectId: project.id,
|
|
779
|
+
projectLabel: project.label,
|
|
780
|
+
docsRoot: root.displayPath,
|
|
781
|
+
branchPath: branch.branchPath,
|
|
782
|
+
entryPath: branch.entryPath,
|
|
783
|
+
routePath,
|
|
784
|
+
machinePath,
|
|
785
|
+
machineInstructions: machineFile?.content ?? "",
|
|
786
|
+
files,
|
|
787
|
+
omittedFiles,
|
|
788
|
+
};
|
|
789
|
+
record.skillMarkdown = renderAgentSkill(record);
|
|
790
|
+
const versionHash = createHash("sha256").update(record.skillMarkdown);
|
|
791
|
+
for (const file of files) versionHash.update(file.catalogPath).update("\0").update(file.content).update("\0");
|
|
792
|
+
record.skillVersion = versionHash.digest("hex");
|
|
793
|
+
record.files = files.map(({ content, ...file }) => file);
|
|
794
|
+
delete record.machineInstructions;
|
|
795
|
+
delete record.skillMarkdown;
|
|
796
|
+
capabilities.push(record);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
return capabilities.sort((a, b) => `${a.projectLabel}/${a.docsRoot}/${a.branchPath}`.localeCompare(`${b.projectLabel}/${b.docsRoot}/${b.branchPath}`));
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async function getCapabilityIndex(scope = {}) {
|
|
804
|
+
const projects = await getWorkspace();
|
|
805
|
+
if (scope.projectId && !projects.some((project) => project.id === scope.projectId)) return [];
|
|
806
|
+
if (scope.rootId && !projects.some((project) => project.docsRoots.some((root) => root.id === scope.rootId))) return [];
|
|
807
|
+
if (scope.rootId && scope.projectId && !projects.some((project) =>
|
|
808
|
+
project.id === scope.projectId && project.docsRoots.some((root) => root.id === scope.rootId)
|
|
809
|
+
)) return [];
|
|
810
|
+
const cacheKey = `${scope.rootId ?? ""}\0${scope.projectId ?? ""}`;
|
|
811
|
+
const cached = capabilityCaches.get(cacheKey);
|
|
812
|
+
if (cached && Date.now() < cached.expiresAt) return cached.records;
|
|
813
|
+
const existing = capabilityCachePromises.get(cacheKey);
|
|
814
|
+
if (existing) return existing;
|
|
815
|
+
const generation = capabilityGeneration;
|
|
816
|
+
const pending = buildCapabilityIndex(scope);
|
|
817
|
+
capabilityCachePromises.set(cacheKey, pending);
|
|
818
|
+
try {
|
|
819
|
+
const records = await pending;
|
|
820
|
+
if (generation !== capabilityGeneration) return getCapabilityIndex(scope);
|
|
821
|
+
const cacheEntry = { records, expiresAt: Date.now() + CAPABILITY_CACHE_MS };
|
|
822
|
+
capabilityCaches.set(cacheKey, cacheEntry);
|
|
823
|
+
setTimeout(() => {
|
|
824
|
+
if (capabilityCaches.get(cacheKey) === cacheEntry) capabilityCaches.delete(cacheKey);
|
|
825
|
+
}, CAPABILITY_CACHE_MS).unref?.();
|
|
826
|
+
return records;
|
|
827
|
+
} finally {
|
|
828
|
+
if (capabilityCachePromises.get(cacheKey) === pending) capabilityCachePromises.delete(cacheKey);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
async function readCapabilityFile(record, file) {
|
|
833
|
+
const resolved = await resolveRoot(record.rootId);
|
|
834
|
+
if (!resolved) return null;
|
|
835
|
+
const absolutePath = await resolveSafeFilesystemPath(resolved.root.absPath, file.sourcePath);
|
|
836
|
+
if (!absolutePath) return null;
|
|
837
|
+
try {
|
|
838
|
+
const stat = await fs.lstat(absolutePath);
|
|
839
|
+
const fileLimit = file.isText ? 1024 * 1024 : 5 * 1024 * 1024;
|
|
840
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > fileLimit) return null;
|
|
841
|
+
const bytes = await fs.readFile(absolutePath);
|
|
842
|
+
return file.isText ? bytes.toString("utf-8") : bytes;
|
|
843
|
+
} catch {
|
|
844
|
+
return null;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function generateCapabilitySkill(record, machineInstructions) {
|
|
849
|
+
const sourceSkill = record.files.find((file) => file.relativePath === "SKILL.md");
|
|
850
|
+
const sourceSkillContent = sourceSkill ? await readCapabilityFile(record, sourceSkill) : "";
|
|
851
|
+
const files = sourceSkill
|
|
852
|
+
? record.files.map((file) => file === sourceSkill ? { ...file, content: sourceSkillContent ?? "" } : file)
|
|
853
|
+
: record.files;
|
|
854
|
+
return renderAgentSkill({ ...record, files, machineInstructions });
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function capabilitySummary(record) {
|
|
858
|
+
return {
|
|
859
|
+
id: record.id,
|
|
860
|
+
skillId: record.skillId,
|
|
861
|
+
name: record.name,
|
|
862
|
+
title: record.title,
|
|
863
|
+
description: record.description,
|
|
864
|
+
summary: record.summary,
|
|
865
|
+
kind: record.kind,
|
|
866
|
+
tags: record.tags,
|
|
867
|
+
rootId: record.rootId,
|
|
868
|
+
projectId: record.projectId,
|
|
869
|
+
projectLabel: record.projectLabel,
|
|
870
|
+
docsRoot: record.docsRoot,
|
|
871
|
+
branchPath: record.branchPath,
|
|
872
|
+
entryPath: record.entryPath,
|
|
873
|
+
routePath: record.routePath,
|
|
874
|
+
machinePath: record.machinePath,
|
|
875
|
+
fileCount: record.files.length,
|
|
876
|
+
omittedFileCount: record.omittedFiles.length,
|
|
877
|
+
version: record.skillVersion,
|
|
878
|
+
route: `#/root/${encodeURIComponent(record.rootId)}/capabilities/${encodeURIComponent(record.routePath)}`,
|
|
879
|
+
skillUrl: `/__api/skills/catalog/${record.id}/SKILL.md`,
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
async function capabilityDetail(record) {
|
|
884
|
+
const machineFile = record.machinePath ? record.files.find((file) => file.sourcePath === record.machinePath) : null;
|
|
885
|
+
const machineInstructions = machineFile ? await readCapabilityFile(record, machineFile) : "";
|
|
886
|
+
return {
|
|
887
|
+
...capabilitySummary(record),
|
|
888
|
+
entrypoints: {
|
|
889
|
+
human: { path: record.entryPath, url: `/__api/file?rootId=${encodeURIComponent(record.rootId)}&path=${encodeURIComponent(record.entryPath)}` },
|
|
890
|
+
machine: record.machinePath
|
|
891
|
+
? { path: record.machinePath, url: `/__api/file?rootId=${encodeURIComponent(record.rootId)}&path=${encodeURIComponent(record.machinePath)}` }
|
|
892
|
+
: null,
|
|
893
|
+
skill: { format: "Agent Skills", path: "SKILL.md", url: capabilitySummary(record).skillUrl },
|
|
894
|
+
},
|
|
895
|
+
rules: { path: record.machinePath, content: typeof machineInstructions === "string" ? machineInstructions : "" },
|
|
896
|
+
skillMarkdown: await generateCapabilitySkill(record, typeof machineInstructions === "string" ? machineInstructions : ""),
|
|
897
|
+
files: record.files.map((file) => ({
|
|
898
|
+
path: file.sourcePath,
|
|
899
|
+
relativePath: file.relativePath,
|
|
900
|
+
catalogPath: file.catalogPath,
|
|
901
|
+
url: `/__api/skills/catalog/${record.id}/${file.catalogPath.split("/").map(encodeURIComponent).join("/")}`,
|
|
902
|
+
sourceUrl: `${file.isText ? "/__api/file" : "/__api/asset"}?rootId=${encodeURIComponent(record.rootId)}&path=${encodeURIComponent(file.sourcePath)}`,
|
|
903
|
+
contentType: file.contentType,
|
|
904
|
+
size: file.size,
|
|
905
|
+
})),
|
|
906
|
+
omittedFiles: record.omittedFiles,
|
|
907
|
+
skillLocations: {
|
|
908
|
+
opencode: `.opencode/skills/${record.id}/SKILL.md or .agents/skills/${record.id}/SKILL.md`,
|
|
909
|
+
codex: `.agents/skills/${record.id}/SKILL.md`,
|
|
910
|
+
claudeCode: `.claude/skills/${record.id}/SKILL.md`,
|
|
911
|
+
},
|
|
912
|
+
openCodeCatalog: "/__api/skills/catalog/",
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
|
|
494
916
|
function firstMarkdownPath(nodes) {
|
|
495
917
|
for (const node of nodes) {
|
|
496
918
|
if (node.type === "file" && node.name.endsWith(".md")) return node.path;
|
|
@@ -579,10 +1001,11 @@ export function createContentApiPlugin() {
|
|
|
579
1001
|
if (!project) return null;
|
|
580
1002
|
const existing = (await discoverDocsRoots(project))[0];
|
|
581
1003
|
if (existing) return existing;
|
|
582
|
-
const abs =
|
|
1004
|
+
const abs = await resolveSafeFilesystemPath(project.rootPath, "docs");
|
|
1005
|
+
if (!abs) return null;
|
|
583
1006
|
await fs.mkdir(abs, { recursive: true });
|
|
584
1007
|
return {
|
|
585
|
-
id: docsRootId(
|
|
1008
|
+
id: docsRootId(abs),
|
|
586
1009
|
projectId: project.id,
|
|
587
1010
|
projectLabel: project.label,
|
|
588
1011
|
absPath: abs,
|
|
@@ -594,12 +1017,13 @@ export function createContentApiPlugin() {
|
|
|
594
1017
|
async function createDocsFolder(projectId, parentPath) {
|
|
595
1018
|
const project = await resolveWorkspaceProject(projectId);
|
|
596
1019
|
if (!project) return null;
|
|
597
|
-
const parentAbs =
|
|
1020
|
+
const parentAbs = await resolveSafeFilesystemPath(project.rootPath, parentPath || ".");
|
|
598
1021
|
if (!parentAbs) return null;
|
|
599
|
-
const abs = path.join(parentAbs, "docs");
|
|
1022
|
+
const abs = await resolveSafeFilesystemPath(project.rootPath, path.relative(project.rootPath, path.join(parentAbs, "docs")));
|
|
1023
|
+
if (!abs) return null;
|
|
600
1024
|
await fs.mkdir(abs, { recursive: true });
|
|
601
1025
|
return {
|
|
602
|
-
id: docsRootId(
|
|
1026
|
+
id: docsRootId(abs),
|
|
603
1027
|
projectId: project.id,
|
|
604
1028
|
projectLabel: project.label,
|
|
605
1029
|
absPath: abs,
|
|
@@ -621,8 +1045,8 @@ export function createContentApiPlugin() {
|
|
|
621
1045
|
}
|
|
622
1046
|
|
|
623
1047
|
async function renamePath(rootAbsPath, oldPath, newPath, ignoreMissing = false) {
|
|
624
|
-
const oldAbs =
|
|
625
|
-
const newAbs =
|
|
1048
|
+
const oldAbs = await resolveSafeFilesystemPath(rootAbsPath, oldPath);
|
|
1049
|
+
const newAbs = await resolveSafeFilesystemPath(rootAbsPath, newPath);
|
|
626
1050
|
if (!oldAbs || !newAbs) throw new Error("Invalid path");
|
|
627
1051
|
try { await fs.access(oldAbs); } catch (err) {
|
|
628
1052
|
if (ignoreMissing && err.code === "ENOENT") return;
|
|
@@ -642,6 +1066,7 @@ export function createContentApiPlugin() {
|
|
|
642
1066
|
const method = req.method || "GET";
|
|
643
1067
|
if (route === "/llms.txt" && method === "GET") return void (await sendMachineFile(res, "llms.txt", "text/plain; charset=utf-8"));
|
|
644
1068
|
if (route === "/viteboard-guide.md" && method === "GET") return void (await sendMachineFile(res, "guide.md", "text/markdown; charset=utf-8"));
|
|
1069
|
+
if (route === "/viteboard-skill.md" && method === "GET") return void (await sendMachineFile(res, "viteboard-skill.md", "text/markdown; charset=utf-8"));
|
|
645
1070
|
if (route === "/board-v1.schema.json" && method === "GET") return void (await sendMachineFile(res, "board-v1.schema.json", "application/schema+json; charset=utf-8"));
|
|
646
1071
|
if (route === "/viteboard-example.board.json" && method === "GET") return void (await sendMachineFile(res, "example.board.json", "application/json; charset=utf-8"));
|
|
647
1072
|
if (route === "/.well-known/viteboard.json" && method === "GET") return void (await sendMachineFile(res, "viteboard.json", "application/json; charset=utf-8"));
|
|
@@ -654,6 +1079,7 @@ export function createContentApiPlugin() {
|
|
|
654
1079
|
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
655
1080
|
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
656
1081
|
res.setHeader("Connection", "keep-alive");
|
|
1082
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
657
1083
|
res.flushHeaders?.();
|
|
658
1084
|
sseClients.add(res);
|
|
659
1085
|
sendSse(res, "hello", { actors: activeActors(), locks: activeLocks() });
|
|
@@ -661,6 +1087,82 @@ export function createContentApiPlugin() {
|
|
|
661
1087
|
return;
|
|
662
1088
|
}
|
|
663
1089
|
|
|
1090
|
+
if (route === "/__api/capabilities" && method === "GET") {
|
|
1091
|
+
const rootId = parsed.searchParams.get("rootId");
|
|
1092
|
+
const projectId = parsed.searchParams.get("projectId");
|
|
1093
|
+
const capabilities = await getCapabilityIndex({ rootId, projectId });
|
|
1094
|
+
return send(res, 200, {
|
|
1095
|
+
version: 1,
|
|
1096
|
+
skillFormat: "Agent Skills",
|
|
1097
|
+
catalog: { openCode: "/__api/skills/catalog/", index: "/__api/skills/catalog/index.json" },
|
|
1098
|
+
locations: {
|
|
1099
|
+
openCode: ".opencode/skills/<skill-id>/SKILL.md or .agents/skills/<skill-id>/SKILL.md",
|
|
1100
|
+
codex: ".agents/skills/<skill-id>/SKILL.md",
|
|
1101
|
+
claudeCode: ".claude/skills/<skill-id>/SKILL.md",
|
|
1102
|
+
},
|
|
1103
|
+
total: capabilities.length,
|
|
1104
|
+
capabilities: capabilities.map(capabilitySummary),
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
if (route === "/__api/capability" && method === "GET") {
|
|
1109
|
+
const rootId = parsed.searchParams.get("rootId") ?? "";
|
|
1110
|
+
const requestedPath = parsed.searchParams.get("path") ?? "";
|
|
1111
|
+
if (!rootId || !requestedPath) return send(res, 400, { error: "Expected rootId and capability path" });
|
|
1112
|
+
const normalizedPath = normalizeRelativePath(requestedPath);
|
|
1113
|
+
const branchPath = normalizedPath.startsWith("capabilities/") ? normalizedPath : `capabilities/${normalizedPath}`;
|
|
1114
|
+
const capability = (await getCapabilityIndex({ rootId })).find((record) =>
|
|
1115
|
+
record.rootId === rootId && (record.branchPath === branchPath || record.routePath === normalizedPath)
|
|
1116
|
+
);
|
|
1117
|
+
if (!capability) return send(res, 404, { error: "Capability not found" });
|
|
1118
|
+
return send(res, 200, await capabilityDetail(capability));
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
if (route === "/__api/skills/catalog/index.json" && method === "GET") {
|
|
1122
|
+
const records = await getCapabilityIndex();
|
|
1123
|
+
const catalog = {
|
|
1124
|
+
skills: records.map((record) => ({
|
|
1125
|
+
name: record.id,
|
|
1126
|
+
version: record.skillVersion,
|
|
1127
|
+
files: ["SKILL.md", ...new Set(record.files.map((file) => file.catalogPath))],
|
|
1128
|
+
})),
|
|
1129
|
+
};
|
|
1130
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
1131
|
+
return sendText(res, 200, JSON.stringify(catalog, null, 2) + "\n", "application/json; charset=utf-8");
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const skillCatalogPrefix = "/__api/skills/catalog/";
|
|
1135
|
+
if (route.startsWith(skillCatalogPrefix) && method === "GET") {
|
|
1136
|
+
const segments = route.slice(skillCatalogPrefix.length).split("/");
|
|
1137
|
+
let decoded;
|
|
1138
|
+
try {
|
|
1139
|
+
decoded = segments.map((segment) => decodeURIComponent(segment));
|
|
1140
|
+
} catch {
|
|
1141
|
+
return send(res, 400, { error: "Invalid skill path" });
|
|
1142
|
+
}
|
|
1143
|
+
const [skillId, ...fileParts] = decoded;
|
|
1144
|
+
const filePath = fileParts.join("/");
|
|
1145
|
+
if (!skillId || !filePath || fileParts.some((part) => !part || part === "." || part === ".." || part.includes("/") || part.includes("\\") || part.startsWith("."))) {
|
|
1146
|
+
return send(res, 400, { error: "Invalid skill path" });
|
|
1147
|
+
}
|
|
1148
|
+
const capability = (await getCapabilityIndex()).find((record) => record.id === skillId);
|
|
1149
|
+
if (!capability) return send(res, 404, { error: "Skill not found" });
|
|
1150
|
+
if (filePath === "SKILL.md") {
|
|
1151
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
1152
|
+
const machineFile = capability.machinePath ? capability.files.find((file) => file.sourcePath === capability.machinePath) : null;
|
|
1153
|
+
const machineInstructions = machineFile ? await readCapabilityFile(capability, machineFile) : "";
|
|
1154
|
+
const skillMarkdown = await generateCapabilitySkill(capability, typeof machineInstructions === "string" ? machineInstructions : "");
|
|
1155
|
+
return sendText(res, 200, skillMarkdown, "text/markdown; charset=utf-8");
|
|
1156
|
+
}
|
|
1157
|
+
const file = capability.files.find((item) => item.catalogPath === filePath);
|
|
1158
|
+
if (!file) return send(res, 404, { error: "Skill file not found" });
|
|
1159
|
+
const content = await readCapabilityFile(capability, file);
|
|
1160
|
+
if (content === null) return send(res, 404, { error: "Skill file is unavailable" });
|
|
1161
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
1162
|
+
if (Buffer.isBuffer(content)) return sendBinary(res, 200, content, file.contentType);
|
|
1163
|
+
return sendText(res, 200, content, file.contentType);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
664
1166
|
if (route === "/__api/workspace" && method === "GET") return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
|
|
665
1167
|
if (route === "/__api/project-tree" && method === "GET") {
|
|
666
1168
|
const projectId = parsed.searchParams.get("projectId") ?? "";
|
|
@@ -679,7 +1181,7 @@ export function createContentApiPlugin() {
|
|
|
679
1181
|
const rel = parsed.searchParams.get("path") ?? "";
|
|
680
1182
|
const resolved = await resolveRoot(rootId);
|
|
681
1183
|
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
682
|
-
const abs =
|
|
1184
|
+
const abs = await resolveSafeFilesystemPath(resolved.root.absPath, rel);
|
|
683
1185
|
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
684
1186
|
try {
|
|
685
1187
|
const content = await fs.readFile(abs, "utf-8");
|
|
@@ -690,44 +1192,61 @@ export function createContentApiPlugin() {
|
|
|
690
1192
|
const { rootId, path: rel, content, expectedRevision } = JSON.parse(await readBody(req));
|
|
691
1193
|
const resolved = await resolveRoot(String(rootId ?? ""));
|
|
692
1194
|
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
693
|
-
const abs =
|
|
1195
|
+
const abs = await resolveSafeFilesystemPath(resolved.root.absPath, String(rel ?? ""));
|
|
694
1196
|
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
1197
|
+
return withMutationLock(fileMutationKey(abs), async () => {
|
|
1198
|
+
try {
|
|
1199
|
+
const safeAbs = await resolveSafeFilesystemPath(resolved.root.absPath, String(rel ?? ""));
|
|
1200
|
+
if (!safeAbs) return send(res, 400, { error: "Invalid path" });
|
|
1201
|
+
const current = await fs.readFile(safeAbs, "utf-8").catch((err) => err.code === "ENOENT" ? null : Promise.reject(err));
|
|
1202
|
+
if (expectedRevision !== undefined) {
|
|
1203
|
+
if (expectedRevision !== null && typeof expectedRevision !== "string") return send(res, 400, { error: "expectedRevision must be a string or null" });
|
|
1204
|
+
const currentRevision = current === null ? null : revisionForText(current);
|
|
1205
|
+
if (expectedRevision !== currentRevision) return send(res, 409, { error: "File changed on disk", currentRevision });
|
|
1206
|
+
}
|
|
1207
|
+
await fs.mkdir(path.dirname(safeAbs), { recursive: true });
|
|
1208
|
+
const nextContent = String(content ?? "");
|
|
1209
|
+
await fs.writeFile(safeAbs, nextContent, "utf-8");
|
|
1210
|
+
const revision = revisionForText(nextContent);
|
|
1211
|
+
broadcast("file-changed", { rootId: resolved.root.id, path: String(rel), action: "write", created: current === null, actorId: actor?.actorId ?? null });
|
|
1212
|
+
return send(res, 200, { ok: true, path: rel, revision });
|
|
1213
|
+
} catch (err) { return send(res, 500, { error: err.message }); }
|
|
1214
|
+
});
|
|
705
1215
|
}
|
|
706
1216
|
if (route === "/__api/file" && method === "PATCH") {
|
|
707
1217
|
const { rootId, oldPath, newPath, ignoreMissing } = JSON.parse(await readBody(req));
|
|
708
1218
|
const resolved = await resolveRoot(String(rootId ?? ""));
|
|
709
1219
|
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
710
|
-
await
|
|
711
|
-
|
|
712
|
-
return send(res,
|
|
1220
|
+
const oldAbs = await resolveSafeFilesystemPath(resolved.root.absPath, String(oldPath ?? ""));
|
|
1221
|
+
const newAbs = await resolveSafeFilesystemPath(resolved.root.absPath, String(newPath ?? ""));
|
|
1222
|
+
if (!oldAbs || !newAbs) return send(res, 400, { error: "Invalid path" });
|
|
1223
|
+
return withMutationLocks([fileMutationKey(oldAbs), fileMutationKey(newAbs)], async () => {
|
|
1224
|
+
await renamePath(resolved.root.absPath, String(oldPath ?? ""), String(newPath ?? ""), !!ignoreMissing);
|
|
1225
|
+
broadcast("file-changed", { rootId: resolved.root.id, path: String(newPath), oldPath: String(oldPath), action: "rename", actorId: actor?.actorId ?? null });
|
|
1226
|
+
return send(res, 200, { ok: true });
|
|
1227
|
+
});
|
|
713
1228
|
}
|
|
714
1229
|
if (route === "/__api/file" && method === "DELETE") {
|
|
715
1230
|
const rootId = parsed.searchParams.get("rootId") ?? "";
|
|
716
1231
|
const rel = parsed.searchParams.get("path") ?? "";
|
|
717
1232
|
const resolved = await resolveRoot(rootId);
|
|
718
1233
|
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
719
|
-
const abs =
|
|
1234
|
+
const abs = await resolveSafeFilesystemPath(resolved.root.absPath, rel);
|
|
720
1235
|
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
1236
|
+
return withMutationLock(fileMutationKey(abs), async () => {
|
|
1237
|
+
const safeAbs = await resolveSafeFilesystemPath(resolved.root.absPath, rel);
|
|
1238
|
+
if (!safeAbs) return send(res, 400, { error: "Invalid path" });
|
|
1239
|
+
try { await fs.unlink(safeAbs); } catch (err) { if (err.code !== "ENOENT") throw err; }
|
|
1240
|
+
await deleteEmptyParentDirs(path.dirname(safeAbs), resolved.root.absPath);
|
|
1241
|
+
broadcast("file-changed", { rootId: resolved.root.id, path: rel, action: "delete", actorId: actor?.actorId ?? null });
|
|
1242
|
+
return send(res, 200, { ok: true });
|
|
1243
|
+
});
|
|
725
1244
|
}
|
|
726
1245
|
if (route === "/__api/directory" && method === "PUT") {
|
|
727
1246
|
const { rootId, path: rel } = JSON.parse(await readBody(req));
|
|
728
1247
|
const resolved = await resolveRoot(String(rootId ?? ""));
|
|
729
1248
|
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
730
|
-
const abs =
|
|
1249
|
+
const abs = await resolveSafeFilesystemPath(resolved.root.absPath, String(rel ?? ""));
|
|
731
1250
|
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
732
1251
|
await fs.mkdir(abs, { recursive: true });
|
|
733
1252
|
broadcast("file-changed", { rootId: resolved.root.id, path: String(rel), action: "mkdir", actorId: actor?.actorId ?? null });
|
|
@@ -738,7 +1257,7 @@ export function createContentApiPlugin() {
|
|
|
738
1257
|
const id = parsed.searchParams.get("id") ?? "";
|
|
739
1258
|
const resolved = await resolveRoot(rootId);
|
|
740
1259
|
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
741
|
-
const abs =
|
|
1260
|
+
const abs = await resolveSafeFilesystemPath(resolved.root.absPath, `${id}.board.json`);
|
|
742
1261
|
if (!abs) return send(res, 400, { error: "Invalid id" });
|
|
743
1262
|
try {
|
|
744
1263
|
const content = await fs.readFile(abs, "utf-8");
|
|
@@ -749,34 +1268,111 @@ export function createContentApiPlugin() {
|
|
|
749
1268
|
const { rootId, id, doc, expectedRevision } = JSON.parse(await readBody(req));
|
|
750
1269
|
const resolved = await resolveRoot(String(rootId ?? ""));
|
|
751
1270
|
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
752
|
-
const abs =
|
|
1271
|
+
const abs = await resolveSafeFilesystemPath(resolved.root.absPath, `${id}.board.json`);
|
|
753
1272
|
if (!abs) return send(res, 400, { error: "Invalid id" });
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
1273
|
+
return withMutationLock(fileMutationKey(abs), async () => {
|
|
1274
|
+
const safeAbs = await resolveSafeFilesystemPath(resolved.root.absPath, `${id}.board.json`);
|
|
1275
|
+
if (!safeAbs) return send(res, 400, { error: "Invalid id" });
|
|
1276
|
+
const current = await fs.readFile(safeAbs, "utf-8").catch((err) => err.code === "ENOENT" ? null : Promise.reject(err));
|
|
1277
|
+
if (expectedRevision !== undefined) {
|
|
1278
|
+
if (expectedRevision !== null && typeof expectedRevision !== "string") return send(res, 400, { error: "expectedRevision must be a string or null" });
|
|
1279
|
+
const currentRevision = current === null ? null : revisionForText(current);
|
|
1280
|
+
if (expectedRevision !== currentRevision) return send(res, 409, { error: "Board changed on disk", currentRevision });
|
|
1281
|
+
}
|
|
1282
|
+
await fs.mkdir(path.dirname(safeAbs), { recursive: true });
|
|
1283
|
+
const nextContent = JSON.stringify(doc, null, 2) + "\n";
|
|
1284
|
+
await fs.writeFile(safeAbs, nextContent, "utf-8");
|
|
1285
|
+
const revision = revisionForText(nextContent);
|
|
1286
|
+
broadcast("board-changed", { rootId: resolved.root.id, boardId: String(id), action: current === null ? "create" : "write", created: current === null, actorId: actor?.actorId ?? null });
|
|
1287
|
+
return send(res, 200, { ok: true, id, revision });
|
|
1288
|
+
});
|
|
762
1289
|
}
|
|
763
1290
|
if (route === "/__api/board" && method === "DELETE") {
|
|
764
1291
|
const rootId = parsed.searchParams.get("rootId") ?? "";
|
|
765
1292
|
const id = parsed.searchParams.get("id") ?? "";
|
|
766
1293
|
const resolved = await resolveRoot(rootId);
|
|
767
1294
|
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
768
|
-
const abs =
|
|
1295
|
+
const abs = await resolveSafeFilesystemPath(resolved.root.absPath, `${id}.board.json`);
|
|
769
1296
|
if (!abs) return send(res, 400, { error: "Invalid id" });
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
1297
|
+
return withMutationLock(fileMutationKey(abs), async () => {
|
|
1298
|
+
const safeAbs = await resolveSafeFilesystemPath(resolved.root.absPath, `${id}.board.json`);
|
|
1299
|
+
if (!safeAbs) return send(res, 400, { error: "Invalid id" });
|
|
1300
|
+
try { await fs.unlink(safeAbs); } catch (err) { if (err.code !== "ENOENT") throw err; }
|
|
1301
|
+
broadcast("board-changed", { rootId: resolved.root.id, boardId: id, action: "delete", actorId: actor?.actorId ?? null });
|
|
1302
|
+
return send(res, 200, { ok: true, id });
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
if (route === "/__api/export/board" && method === "POST") {
|
|
1306
|
+
const request = JSON.parse(await readBody(req));
|
|
1307
|
+
if (typeof request.rootId !== "string" || typeof request.boardId !== "string" || !request.boardId.trim()) {
|
|
1308
|
+
return send(res, 400, { error: "Expected rootId and boardId" });
|
|
1309
|
+
}
|
|
1310
|
+
if (!["pdf", "jpeg", "svg"].includes(request.format)) {
|
|
1311
|
+
return send(res, 400, { error: "format must be pdf, jpeg, or svg" });
|
|
1312
|
+
}
|
|
1313
|
+
if (request.region !== undefined && (!Array.isArray(request.region) || request.region.length !== 4 || request.region.some((value) => typeof value !== "number" || !Number.isFinite(value)) || request.region[2] <= 0 || request.region[3] <= 0 || request.region[2] > 10_000_000 || request.region[3] > 10_000_000)) {
|
|
1314
|
+
return send(res, 400, { error: "region must be [x, y, width, height] in board world units" });
|
|
1315
|
+
}
|
|
1316
|
+
if (request.padding !== undefined && (typeof request.padding !== "number" || !Number.isFinite(request.padding) || request.padding < 0 || request.padding > 100_000)) {
|
|
1317
|
+
return send(res, 400, { error: "padding must be a non-negative number of board world units" });
|
|
1318
|
+
}
|
|
1319
|
+
if (request.scale !== undefined && (typeof request.scale !== "number" || !Number.isFinite(request.scale) || request.scale < 0.1 || request.scale > 16)) {
|
|
1320
|
+
return send(res, 400, { error: "scale must be between 0.1 and 16 pixels per board unit" });
|
|
1321
|
+
}
|
|
1322
|
+
if (request.quality !== undefined && (typeof request.quality !== "number" || !Number.isFinite(request.quality) || request.quality < 0.1 || request.quality > 1)) {
|
|
1323
|
+
return send(res, 400, { error: "quality must be between 0.1 and 1" });
|
|
1324
|
+
}
|
|
1325
|
+
if (request.paper !== undefined && !["A4", "Letter"].includes(request.paper)) {
|
|
1326
|
+
return send(res, 400, { error: "paper must be A4 or Letter" });
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
const resolved = await resolveRoot(request.rootId);
|
|
1330
|
+
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
1331
|
+
const boardId = request.boardId.trim().replace(/^\/+/, "").replace(/\.board\.json$/i, "");
|
|
1332
|
+
const boardAbs = await resolveSafeFilesystemPath(resolved.root.absPath, `${boardId}.board.json`);
|
|
1333
|
+
if (!boardAbs) return send(res, 400, { error: "Invalid boardId" });
|
|
1334
|
+
let boardDoc;
|
|
1335
|
+
try {
|
|
1336
|
+
boardDoc = JSON.parse(await fs.readFile(boardAbs, "utf-8"));
|
|
1337
|
+
} catch (err) {
|
|
1338
|
+
if (err.code === "ENOENT") return send(res, 404, { error: "Board not found" });
|
|
1339
|
+
if (err instanceof SyntaxError) return send(res, 400, { error: "Board JSON is invalid" });
|
|
1340
|
+
throw err;
|
|
1341
|
+
}
|
|
1342
|
+
if (boardDoc?.version !== 1 || !Array.isArray(boardDoc.elements)) return send(res, 400, { error: "Board document is invalid" });
|
|
1343
|
+
if (request.region === undefined && !boardDoc.elements.some((element) => element && element.hidden !== true && [element.x, element.y, element.width, element.height].every(Number.isFinite))) {
|
|
1344
|
+
return send(res, 400, { error: "Board has no visible content; provide an explicit region to export" });
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
const port = req.socket?.localPort;
|
|
1348
|
+
let host = String(req.socket?.localAddress ?? "").replace(/^::ffff:/i, "");
|
|
1349
|
+
if (!port || !host) return send(res, 500, { error: "Could not determine the ViteBoard server address" });
|
|
1350
|
+
if (host.includes(":")) host = `[${host}]`;
|
|
1351
|
+
const apiRequest = { ...request, boardId };
|
|
1352
|
+
const expression = `window.__viteboardPrepareBoardExport ? window.__viteboardPrepareBoardExport(${JSON.stringify(apiRequest)}) : Promise.reject(new Error("ViteBoard board exporter did not load"))`;
|
|
1353
|
+
const prepared = await evaluatePageFromUrlPortable(`http://${host}:${port}/`, expression);
|
|
1354
|
+
if (prepared?.error) return send(res, 400, { error: prepared.error });
|
|
1355
|
+
const fileName = boardExportFileName(prepared?.fileName ?? boardDoc.title, request.format);
|
|
1356
|
+
|
|
1357
|
+
if (request.format === "pdf") {
|
|
1358
|
+
if (prepared?.format !== "pdf" || typeof prepared.html !== "string" || !prepared.html.trim()) {
|
|
1359
|
+
return send(res, 500, { error: "Board renderer returned no PDF document" });
|
|
1360
|
+
}
|
|
1361
|
+
const pdfBuffer = await renderPdfFromHtmlPortable(prepared.html, fileName, typeof prepared.pdf === "object" && prepared.pdf ? prepared.pdf : {});
|
|
1362
|
+
return sendBinary(res, 200, pdfBuffer, "application/pdf", fileName);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
const expectedMimeType = request.format === "jpeg" ? "image/jpeg" : "image/svg+xml";
|
|
1366
|
+
if (prepared?.format !== request.format) return send(res, 500, { error: "Board renderer returned an unexpected export format" });
|
|
1367
|
+
const imageBuffer = decodeExportDataUrl(prepared.dataUrl, expectedMimeType);
|
|
1368
|
+
return sendBinary(res, 200, imageBuffer, expectedMimeType, fileName);
|
|
773
1369
|
}
|
|
774
1370
|
if (route === "/__api/asset" && method === "GET") {
|
|
775
1371
|
const rootId = parsed.searchParams.get("rootId") ?? "";
|
|
776
1372
|
const rel = parsed.searchParams.get("path") ?? "";
|
|
777
1373
|
const resolved = await resolveRoot(rootId);
|
|
778
1374
|
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
779
|
-
const abs =
|
|
1375
|
+
const abs = await resolveSafeFilesystemPath(resolved.root.absPath, rel);
|
|
780
1376
|
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
781
1377
|
try {
|
|
782
1378
|
const buf = await fs.readFile(abs);
|
|
@@ -794,7 +1390,7 @@ export function createContentApiPlugin() {
|
|
|
794
1390
|
const ext = MIME_EXT[String(mimeType ?? "")] ?? (path.extname(String(name ?? "")) || ".png").toLowerCase();
|
|
795
1391
|
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
796
1392
|
const rel = `assets/${digest}${ext}`;
|
|
797
|
-
const abs =
|
|
1393
|
+
const abs = await resolveSafeFilesystemPath(resolved.root.absPath, rel);
|
|
798
1394
|
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
799
1395
|
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
800
1396
|
try { await fs.access(abs); } catch { await fs.writeFile(abs, bytes); }
|
|
@@ -809,6 +1405,45 @@ export function createContentApiPlugin() {
|
|
|
809
1405
|
if (removed > 0) broadcast("asset-changed", { rootId: resolved.root.id, action: "cleanup", removed, actorId: actor?.actorId ?? null });
|
|
810
1406
|
return send(res, 200, { ok: true, removed });
|
|
811
1407
|
}
|
|
1408
|
+
if (route === "/__api/export/document/pdf" && method === "POST") {
|
|
1409
|
+
const request = JSON.parse(await readBody(req));
|
|
1410
|
+
if (typeof request.rootId !== "string" || !request.rootId || !["doc", "dir"].includes(request.kind) || typeof request.path !== "string") {
|
|
1411
|
+
return send(res, 400, { error: "Expected rootId, kind (doc or dir), and path" });
|
|
1412
|
+
}
|
|
1413
|
+
if (request.selectedPaths !== undefined && (!Array.isArray(request.selectedPaths) || request.selectedPaths.some((item) => typeof item !== "string"))) {
|
|
1414
|
+
return send(res, 400, { error: "selectedPaths must be an array of document paths" });
|
|
1415
|
+
}
|
|
1416
|
+
const resolved = await resolveRoot(request.rootId);
|
|
1417
|
+
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
1418
|
+
const targetAbs = await resolveSafeFilesystemPath(resolved.root.absPath, request.path);
|
|
1419
|
+
if (!targetAbs) return send(res, 400, { error: "Invalid export path" });
|
|
1420
|
+
if (request.kind === "doc" && !request.path.toLowerCase().endsWith(".md")) {
|
|
1421
|
+
return send(res, 400, { error: "Document PDF export requires a Markdown file" });
|
|
1422
|
+
}
|
|
1423
|
+
try {
|
|
1424
|
+
const targetStat = await fs.stat(targetAbs);
|
|
1425
|
+
if (request.kind === "doc" ? !targetStat.isFile() : !targetStat.isDirectory()) {
|
|
1426
|
+
return send(res, 404, { error: request.kind === "doc" ? "Markdown document not found" : "Directory not found" });
|
|
1427
|
+
}
|
|
1428
|
+
} catch (err) {
|
|
1429
|
+
if (err.code === "ENOENT") return send(res, 404, { error: request.kind === "doc" ? "Markdown document not found" : "Directory not found" });
|
|
1430
|
+
throw err;
|
|
1431
|
+
}
|
|
1432
|
+
const port = req.socket?.localPort;
|
|
1433
|
+
let host = String(req.socket?.localAddress ?? "").replace(/^::ffff:/i, "");
|
|
1434
|
+
if (!port || !host) return send(res, 500, { error: "Could not determine the ViteBoard server address" });
|
|
1435
|
+
if (host.includes(":")) host = `[${host}]`;
|
|
1436
|
+
const expression = `window.__viteboardPreparePdf ? window.__viteboardPreparePdf(${JSON.stringify(request)}) : Promise.reject(new Error("ViteBoard PDF renderer did not load"))`;
|
|
1437
|
+
const fallbackTitle = (request.path.split(/[\\/]/).pop() ?? "export").replace(/\.md$/i, "");
|
|
1438
|
+
const prepared = await evaluatePageFromUrlPortable(`http://${host}:${port}/`, expression);
|
|
1439
|
+
if (prepared?.error) return send(res, 400, { error: prepared.error });
|
|
1440
|
+
if (typeof prepared?.html !== "string" || !prepared.html.trim()) {
|
|
1441
|
+
return send(res, 500, { error: "The publication renderer returned no HTML" });
|
|
1442
|
+
}
|
|
1443
|
+
const fileName = sanitizePdfFileNamePortable(prepared.fileName ?? request.options?.title ?? fallbackTitle);
|
|
1444
|
+
const pdfBuffer = await renderPdfFromHtmlPortable(prepared.html, fileName, typeof prepared.pdf === "object" && prepared.pdf ? prepared.pdf : {});
|
|
1445
|
+
return sendBinary(res, 200, pdfBuffer, "application/pdf", fileName);
|
|
1446
|
+
}
|
|
812
1447
|
if (route === "/__api/export/pdf" && method === "POST") {
|
|
813
1448
|
const { html, fileName, pdf } = JSON.parse(await readBody(req));
|
|
814
1449
|
if (typeof html !== "string" || !html.trim()) return send(res, 400, { error: "Missing HTML payload" });
|
|
@@ -822,28 +1457,41 @@ export function createContentApiPlugin() {
|
|
|
822
1457
|
}
|
|
823
1458
|
if (route === "/__api/workspace/project" && method === "POST") {
|
|
824
1459
|
const { rootPath, label } = JSON.parse(await readBody(req));
|
|
825
|
-
|
|
1460
|
+
if (typeof rootPath !== "string" || !rootPath.trim()) return send(res, 400, { error: "Expected a project root path" });
|
|
1461
|
+
const absRoot = await canonicalizeProjectRoot(rootPath.trim());
|
|
1462
|
+
if (!(await isDirectory(absRoot))) return send(res, 400, { error: "Project root not found" });
|
|
826
1463
|
const manifest = await loadManifest();
|
|
827
|
-
const
|
|
1464
|
+
const existingProject = manifest.projects.find((project) => project.rootPath === absRoot);
|
|
1465
|
+
const projectId = existingProject?.id ?? projectIdFor(absRoot);
|
|
828
1466
|
const overlap = manifest.projects.find((project) => project.id !== projectId && (isNestedPath(project.rootPath, absRoot) || isNestedPath(absRoot, project.rootPath)));
|
|
829
1467
|
if (overlap) return send(res, 409, { error: overlapError(overlap, absRoot) });
|
|
830
|
-
|
|
831
|
-
|
|
1468
|
+
const project = { id: projectId, label: String(label ?? "").trim() || path.basename(absRoot) || "Project", rootPath: absRoot };
|
|
1469
|
+
const projectIndex = manifest.projects.findIndex((item) => item.id === projectId);
|
|
1470
|
+
const changed = projectIndex < 0 || manifest.projects[projectIndex].label !== project.label || manifest.projects[projectIndex].rootPath !== project.rootPath;
|
|
1471
|
+
if (changed) {
|
|
1472
|
+
if (projectIndex < 0) manifest.projects.push(project);
|
|
1473
|
+
else manifest.projects.splice(projectIndex, 1, project);
|
|
832
1474
|
await saveManifest(manifest);
|
|
1475
|
+
broadcast("workspace-changed", { action: existingProject ? "project-updated" : "project-added", projectId, actorId: actor?.actorId ?? null });
|
|
833
1476
|
}
|
|
834
1477
|
return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
|
|
835
1478
|
}
|
|
836
1479
|
if (route === "/__api/workspace/project" && method === "DELETE") {
|
|
837
1480
|
const projectId = parsed.searchParams.get("id") ?? "";
|
|
838
1481
|
const manifest = await loadManifest();
|
|
1482
|
+
const previousCount = manifest.projects.length;
|
|
839
1483
|
manifest.projects = manifest.projects.filter((project) => project.id !== projectId);
|
|
840
|
-
|
|
1484
|
+
if (manifest.projects.length !== previousCount) {
|
|
1485
|
+
await saveManifest(manifest);
|
|
1486
|
+
broadcast("workspace-changed", { action: "project-removed", projectId, actorId: actor?.actorId ?? null });
|
|
1487
|
+
}
|
|
841
1488
|
return send(res, 200, { ok: true, workspace: await toWorkspaceResponse(await getWorkspace()) });
|
|
842
1489
|
}
|
|
843
1490
|
if (route === "/__api/workspace/pick-project" && method === "POST") {
|
|
844
1491
|
const picked = await pickProjectFromSystemDialog();
|
|
845
1492
|
if (!picked) return send(res, 200, { cancelled: true, workspace: await toWorkspaceResponse(await getWorkspace()) });
|
|
846
1493
|
const absRoot = await canonicalizeProjectRoot(picked);
|
|
1494
|
+
if (!(await isDirectory(absRoot))) return send(res, 400, { error: "Project root not found" });
|
|
847
1495
|
const manifest = await loadManifest();
|
|
848
1496
|
const projectId = projectIdFor(absRoot);
|
|
849
1497
|
const overlap = manifest.projects.find((project) => project.id !== projectId && (isNestedPath(project.rootPath, absRoot) || isNestedPath(absRoot, project.rootPath)));
|
|
@@ -851,21 +1499,44 @@ export function createContentApiPlugin() {
|
|
|
851
1499
|
if (!manifest.projects.some((project) => project.id === projectId)) {
|
|
852
1500
|
manifest.projects.push({ id: projectId, label: path.basename(absRoot) || "Project", rootPath: absRoot });
|
|
853
1501
|
await saveManifest(manifest);
|
|
1502
|
+
broadcast("workspace-changed", { action: "project-added", projectId, actorId: actor?.actorId ?? null });
|
|
854
1503
|
}
|
|
855
1504
|
const workspace = await getWorkspace();
|
|
856
1505
|
return send(res, 200, { cancelled: false, workspace: await toWorkspaceResponse(workspace), addedProjectId: projectId });
|
|
857
1506
|
}
|
|
858
1507
|
if (route === "/__api/workspace/project/docs-root" && method === "POST") {
|
|
859
1508
|
const { projectId } = JSON.parse(await readBody(req));
|
|
860
|
-
const
|
|
861
|
-
if (!
|
|
1509
|
+
const projectKey = String(projectId ?? "");
|
|
1510
|
+
if (!(await resolveWorkspaceProject(projectKey))) return send(res, 404, { error: "Project not found" });
|
|
1511
|
+
const root = await ensureProjectDocsRoot(projectKey);
|
|
1512
|
+
if (!root) return send(res, 400, { error: "Invalid docs root path" });
|
|
1513
|
+
broadcast("workspace-changed", { action: "docs-root-ready", projectId: String(projectId), actorId: actor?.actorId ?? null });
|
|
1514
|
+
const workspace = await getWorkspace();
|
|
1515
|
+
return send(res, 200, { root: { id: root.id, projectId: root.projectId, projectLabel: root.projectLabel, relativePath: root.relativePath, displayPath: root.displayPath }, workspace: await toWorkspaceResponse(workspace) });
|
|
1516
|
+
}
|
|
1517
|
+
if (route === "/__api/workspace/project/docs-area" && method === "POST") {
|
|
1518
|
+
const { projectId, folderName } = JSON.parse(await readBody(req));
|
|
1519
|
+
const project = await resolveWorkspaceProject(String(projectId ?? ""));
|
|
1520
|
+
if (!project) return send(res, 404, { error: "Project not found" });
|
|
1521
|
+
const folderAbs = await resolveSafeFilesystemPath(project.rootPath, String(folderName ?? ""));
|
|
1522
|
+
if (!folderAbs) return send(res, 400, { error: "Invalid docs area path" });
|
|
1523
|
+
const docsAbs = await resolveSafeFilesystemPath(project.rootPath, path.relative(project.rootPath, path.join(folderAbs, "docs")));
|
|
1524
|
+
if (!docsAbs) return send(res, 400, { error: "Invalid docs root path" });
|
|
1525
|
+
await fs.mkdir(docsAbs, { recursive: true });
|
|
1526
|
+
broadcast("workspace-changed", { action: "docs-area-ready", projectId: project.id, actorId: actor?.actorId ?? null });
|
|
862
1527
|
const workspace = await getWorkspace();
|
|
1528
|
+
const refreshedProject = workspace.find((item) => item.id === project.id);
|
|
1529
|
+
const root = refreshedProject?.docsRoots.find((item) => item.absPath === docsAbs);
|
|
1530
|
+
if (!root) return send(res, 500, { error: "Could not create docs area" });
|
|
863
1531
|
return send(res, 200, { root: { id: root.id, projectId: root.projectId, projectLabel: root.projectLabel, relativePath: root.relativePath, displayPath: root.displayPath }, workspace: await toWorkspaceResponse(workspace) });
|
|
864
1532
|
}
|
|
865
1533
|
if (route === "/__api/workspace/project/create-docs-folder" && method === "POST") {
|
|
866
1534
|
const { projectId, parentPath } = JSON.parse(await readBody(req));
|
|
867
|
-
const
|
|
868
|
-
if (!
|
|
1535
|
+
const projectKey = String(projectId ?? "");
|
|
1536
|
+
if (!(await resolveWorkspaceProject(projectKey))) return send(res, 404, { error: "Project not found" });
|
|
1537
|
+
const root = await createDocsFolder(projectKey, String(parentPath ?? ""));
|
|
1538
|
+
if (!root) return send(res, 400, { error: "Invalid docs folder path" });
|
|
1539
|
+
broadcast("workspace-changed", { action: "docs-folder-ready", projectId: String(projectId), actorId: actor?.actorId ?? null });
|
|
869
1540
|
const workspace = await getWorkspace();
|
|
870
1541
|
return send(res, 200, { root: { id: root.id, projectId: root.projectId, projectLabel: root.projectLabel, relativePath: root.relativePath, displayPath: root.displayPath }, workspace: await toWorkspaceResponse(workspace) });
|
|
871
1542
|
}
|