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
|
@@ -4,6 +4,7 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
import { execFile } from "node:child_process";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { evaluatePageFromUrl as evaluatePageFromUrlPortable, renderPdfFromHtml as renderPdfFromHtmlPortable, sanitizePdfFileName as sanitizePdfFileNamePortable } from "./pdf-export.mjs";
|
|
7
8
|
|
|
8
9
|
const WORKSPACE_DIR = ".workspace";
|
|
9
10
|
const WORKSPACE_FILE = "workspace.json";
|
|
@@ -28,18 +29,80 @@ const MIME_EXT = {
|
|
|
28
29
|
const MANAGED_ASSET_RE = /^assets\/[a-f0-9]{64}\.(png|jpe?g|webp|gif|svg)$/i;
|
|
29
30
|
const ACTOR_TTL_MS = 30000;
|
|
30
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
|
+
};
|
|
31
49
|
const ROOT_LINK_HEADER = '</llms.txt>; rel="describedby"; type="text/plain", </.well-known/viteboard.json>; rel="alternate"; type="application/json"';
|
|
32
50
|
const execFileAsync = promisify(execFile);
|
|
33
51
|
|
|
34
|
-
|
|
35
|
-
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());
|
|
36
78
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
37
79
|
const workspaceDir = path.resolve(cwd, WORKSPACE_DIR);
|
|
38
80
|
const workspaceFile = path.join(workspaceDir, WORKSPACE_FILE);
|
|
39
81
|
const machineDir = path.join(packageRoot, "content", "machine");
|
|
40
82
|
const actorSessions = new Map();
|
|
41
83
|
const locks = new Map();
|
|
84
|
+
const mutationQueues = new Map();
|
|
42
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
|
+
}
|
|
43
106
|
|
|
44
107
|
function send(res, status, body) {
|
|
45
108
|
res.statusCode = status;
|
|
@@ -53,12 +116,30 @@ export function createContentApiPlugin() {
|
|
|
53
116
|
res.end(body);
|
|
54
117
|
}
|
|
55
118
|
|
|
119
|
+
function sendBinary(res, status, body, type, fileName) {
|
|
120
|
+
res.statusCode = status;
|
|
121
|
+
res.setHeader("Content-Type", type);
|
|
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
|
+
}
|
|
128
|
+
res.end(body);
|
|
129
|
+
}
|
|
130
|
+
|
|
56
131
|
function sendSse(res, event, body) {
|
|
57
132
|
res.write(`event: ${event}\n`);
|
|
58
133
|
res.write(`data: ${JSON.stringify(body)}\n\n`);
|
|
59
134
|
}
|
|
60
135
|
|
|
61
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
|
+
}
|
|
62
143
|
for (const client of sseClients) {
|
|
63
144
|
try { sendSse(client, event, body); } catch { sseClients.delete(client); }
|
|
64
145
|
}
|
|
@@ -77,6 +158,34 @@ export function createContentApiPlugin() {
|
|
|
77
158
|
return `${target.targetType}:${target.rootId}:${suffix}`;
|
|
78
159
|
}
|
|
79
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
|
+
|
|
80
189
|
function activeActors() { return [...actorSessions.values()].sort((a, b) => a.label.localeCompare(b.label)); }
|
|
81
190
|
function activeLocks() { return [...locks.values()].sort((a, b) => a.acquiredAt - b.acquiredAt); }
|
|
82
191
|
function broadcastPresence() { broadcast("presence", { actors: activeActors(), locks: activeLocks() }); }
|
|
@@ -103,6 +212,15 @@ export function createContentApiPlugin() {
|
|
|
103
212
|
}
|
|
104
213
|
|
|
105
214
|
setInterval(pruneEphemeral, 10000).unref?.();
|
|
215
|
+
setInterval(() => {
|
|
216
|
+
for (const client of sseClients) {
|
|
217
|
+
if (client.destroyed || client.writableEnded) {
|
|
218
|
+
sseClients.delete(client);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
try { client.write(": keep-alive\n\n"); } catch { sseClients.delete(client); }
|
|
222
|
+
}
|
|
223
|
+
}, 20000).unref?.();
|
|
106
224
|
|
|
107
225
|
function revisionForText(content) { return createHash("sha1").update(content).digest("hex"); }
|
|
108
226
|
function hashPath(value) {
|
|
@@ -136,6 +254,10 @@ export function createContentApiPlugin() {
|
|
|
136
254
|
try { return path.resolve(await fs.realpath(resolved)); } catch { return resolved; }
|
|
137
255
|
}
|
|
138
256
|
|
|
257
|
+
async function isDirectory(absPath) {
|
|
258
|
+
try { return (await fs.stat(absPath)).isDirectory(); } catch { return false; }
|
|
259
|
+
}
|
|
260
|
+
|
|
139
261
|
function normalizeRelativePath(relPath) {
|
|
140
262
|
return relPath.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
141
263
|
}
|
|
@@ -246,9 +368,25 @@ export function createContentApiPlugin() {
|
|
|
246
368
|
const raw = await fs.readFile(workspaceFile, "utf-8");
|
|
247
369
|
const parsed = JSON.parse(raw);
|
|
248
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
|
+
}
|
|
249
387
|
return {
|
|
250
388
|
version: 1,
|
|
251
|
-
projects
|
|
389
|
+
projects,
|
|
252
390
|
};
|
|
253
391
|
} catch { return { version: 1, projects: [] }; }
|
|
254
392
|
}
|
|
@@ -263,11 +401,22 @@ export function createContentApiPlugin() {
|
|
|
263
401
|
return rel === "" ? "docs" : rel;
|
|
264
402
|
}
|
|
265
403
|
|
|
266
|
-
|
|
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)}`; }
|
|
267
406
|
function projectIdFor(absRoot) { return `project-${hashPath(absRoot)}`; }
|
|
268
407
|
|
|
269
408
|
async function discoverDocsRoots(project) {
|
|
270
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
|
+
}
|
|
271
420
|
const walk = async (dir) => {
|
|
272
421
|
let entries;
|
|
273
422
|
try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
@@ -278,7 +427,7 @@ export function createContentApiPlugin() {
|
|
|
278
427
|
if (entry.name === "docs") {
|
|
279
428
|
const relativePath = normalizeRelativePath(path.relative(project.rootPath, abs));
|
|
280
429
|
roots.push({
|
|
281
|
-
id: docsRootId(
|
|
430
|
+
id: docsRootId(abs),
|
|
282
431
|
projectId: project.id,
|
|
283
432
|
projectLabel: project.label,
|
|
284
433
|
absPath: abs,
|
|
@@ -293,13 +442,37 @@ export function createContentApiPlugin() {
|
|
|
293
442
|
return roots.sort((a, b) => a.displayPath.localeCompare(b.displayPath));
|
|
294
443
|
}
|
|
295
444
|
|
|
296
|
-
async function
|
|
445
|
+
async function loadWorkspace() {
|
|
297
446
|
const manifest = await loadManifest();
|
|
298
447
|
const projects = [];
|
|
299
448
|
for (const project of manifest.projects) projects.push({ ...project, docsRoots: await discoverDocsRoots(project) });
|
|
300
449
|
return projects;
|
|
301
450
|
}
|
|
302
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
|
+
|
|
303
476
|
function toWorkspaceResponse(projects) {
|
|
304
477
|
return {
|
|
305
478
|
projects: projects.map((project) => ({
|
|
@@ -323,6 +496,34 @@ export function createContentApiPlugin() {
|
|
|
323
496
|
return abs;
|
|
324
497
|
}
|
|
325
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
|
+
|
|
326
527
|
async function resolveRoot(rootId) {
|
|
327
528
|
const projects = await getWorkspace();
|
|
328
529
|
for (const project of projects) {
|
|
@@ -355,6 +556,363 @@ export function createContentApiPlugin() {
|
|
|
355
556
|
return nodes;
|
|
356
557
|
}
|
|
357
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
|
+
|
|
358
916
|
function firstMarkdownPath(nodes) {
|
|
359
917
|
for (const node of nodes) {
|
|
360
918
|
if (node.type === "file" && node.name.endsWith(".md")) return node.path;
|
|
@@ -443,10 +1001,11 @@ export function createContentApiPlugin() {
|
|
|
443
1001
|
if (!project) return null;
|
|
444
1002
|
const existing = (await discoverDocsRoots(project))[0];
|
|
445
1003
|
if (existing) return existing;
|
|
446
|
-
const abs =
|
|
1004
|
+
const abs = await resolveSafeFilesystemPath(project.rootPath, "docs");
|
|
1005
|
+
if (!abs) return null;
|
|
447
1006
|
await fs.mkdir(abs, { recursive: true });
|
|
448
1007
|
return {
|
|
449
|
-
id: docsRootId(
|
|
1008
|
+
id: docsRootId(abs),
|
|
450
1009
|
projectId: project.id,
|
|
451
1010
|
projectLabel: project.label,
|
|
452
1011
|
absPath: abs,
|
|
@@ -458,12 +1017,13 @@ export function createContentApiPlugin() {
|
|
|
458
1017
|
async function createDocsFolder(projectId, parentPath) {
|
|
459
1018
|
const project = await resolveWorkspaceProject(projectId);
|
|
460
1019
|
if (!project) return null;
|
|
461
|
-
const parentAbs =
|
|
1020
|
+
const parentAbs = await resolveSafeFilesystemPath(project.rootPath, parentPath || ".");
|
|
462
1021
|
if (!parentAbs) return null;
|
|
463
|
-
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;
|
|
464
1024
|
await fs.mkdir(abs, { recursive: true });
|
|
465
1025
|
return {
|
|
466
|
-
id: docsRootId(
|
|
1026
|
+
id: docsRootId(abs),
|
|
467
1027
|
projectId: project.id,
|
|
468
1028
|
projectLabel: project.label,
|
|
469
1029
|
absPath: abs,
|
|
@@ -485,8 +1045,8 @@ export function createContentApiPlugin() {
|
|
|
485
1045
|
}
|
|
486
1046
|
|
|
487
1047
|
async function renamePath(rootAbsPath, oldPath, newPath, ignoreMissing = false) {
|
|
488
|
-
const oldAbs =
|
|
489
|
-
const newAbs =
|
|
1048
|
+
const oldAbs = await resolveSafeFilesystemPath(rootAbsPath, oldPath);
|
|
1049
|
+
const newAbs = await resolveSafeFilesystemPath(rootAbsPath, newPath);
|
|
490
1050
|
if (!oldAbs || !newAbs) throw new Error("Invalid path");
|
|
491
1051
|
try { await fs.access(oldAbs); } catch (err) {
|
|
492
1052
|
if (ignoreMissing && err.code === "ENOENT") return;
|
|
@@ -506,6 +1066,7 @@ export function createContentApiPlugin() {
|
|
|
506
1066
|
const method = req.method || "GET";
|
|
507
1067
|
if (route === "/llms.txt" && method === "GET") return void (await sendMachineFile(res, "llms.txt", "text/plain; charset=utf-8"));
|
|
508
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"));
|
|
509
1070
|
if (route === "/board-v1.schema.json" && method === "GET") return void (await sendMachineFile(res, "board-v1.schema.json", "application/schema+json; charset=utf-8"));
|
|
510
1071
|
if (route === "/viteboard-example.board.json" && method === "GET") return void (await sendMachineFile(res, "example.board.json", "application/json; charset=utf-8"));
|
|
511
1072
|
if (route === "/.well-known/viteboard.json" && method === "GET") return void (await sendMachineFile(res, "viteboard.json", "application/json; charset=utf-8"));
|
|
@@ -518,6 +1079,7 @@ export function createContentApiPlugin() {
|
|
|
518
1079
|
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
519
1080
|
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
520
1081
|
res.setHeader("Connection", "keep-alive");
|
|
1082
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
521
1083
|
res.flushHeaders?.();
|
|
522
1084
|
sseClients.add(res);
|
|
523
1085
|
sendSse(res, "hello", { actors: activeActors(), locks: activeLocks() });
|
|
@@ -525,6 +1087,82 @@ export function createContentApiPlugin() {
|
|
|
525
1087
|
return;
|
|
526
1088
|
}
|
|
527
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
|
+
|
|
528
1166
|
if (route === "/__api/workspace" && method === "GET") return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
|
|
529
1167
|
if (route === "/__api/project-tree" && method === "GET") {
|
|
530
1168
|
const projectId = parsed.searchParams.get("projectId") ?? "";
|
|
@@ -543,7 +1181,7 @@ export function createContentApiPlugin() {
|
|
|
543
1181
|
const rel = parsed.searchParams.get("path") ?? "";
|
|
544
1182
|
const resolved = await resolveRoot(rootId);
|
|
545
1183
|
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
546
|
-
const abs =
|
|
1184
|
+
const abs = await resolveSafeFilesystemPath(resolved.root.absPath, rel);
|
|
547
1185
|
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
548
1186
|
try {
|
|
549
1187
|
const content = await fs.readFile(abs, "utf-8");
|
|
@@ -554,44 +1192,61 @@ export function createContentApiPlugin() {
|
|
|
554
1192
|
const { rootId, path: rel, content, expectedRevision } = JSON.parse(await readBody(req));
|
|
555
1193
|
const resolved = await resolveRoot(String(rootId ?? ""));
|
|
556
1194
|
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
557
|
-
const abs =
|
|
1195
|
+
const abs = await resolveSafeFilesystemPath(resolved.root.absPath, String(rel ?? ""));
|
|
558
1196
|
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
+
});
|
|
569
1215
|
}
|
|
570
1216
|
if (route === "/__api/file" && method === "PATCH") {
|
|
571
1217
|
const { rootId, oldPath, newPath, ignoreMissing } = JSON.parse(await readBody(req));
|
|
572
1218
|
const resolved = await resolveRoot(String(rootId ?? ""));
|
|
573
1219
|
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
574
|
-
await
|
|
575
|
-
|
|
576
|
-
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
|
+
});
|
|
577
1228
|
}
|
|
578
1229
|
if (route === "/__api/file" && method === "DELETE") {
|
|
579
1230
|
const rootId = parsed.searchParams.get("rootId") ?? "";
|
|
580
1231
|
const rel = parsed.searchParams.get("path") ?? "";
|
|
581
1232
|
const resolved = await resolveRoot(rootId);
|
|
582
1233
|
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
583
|
-
const abs =
|
|
1234
|
+
const abs = await resolveSafeFilesystemPath(resolved.root.absPath, rel);
|
|
584
1235
|
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
+
});
|
|
589
1244
|
}
|
|
590
1245
|
if (route === "/__api/directory" && method === "PUT") {
|
|
591
1246
|
const { rootId, path: rel } = JSON.parse(await readBody(req));
|
|
592
1247
|
const resolved = await resolveRoot(String(rootId ?? ""));
|
|
593
1248
|
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
594
|
-
const abs =
|
|
1249
|
+
const abs = await resolveSafeFilesystemPath(resolved.root.absPath, String(rel ?? ""));
|
|
595
1250
|
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
596
1251
|
await fs.mkdir(abs, { recursive: true });
|
|
597
1252
|
broadcast("file-changed", { rootId: resolved.root.id, path: String(rel), action: "mkdir", actorId: actor?.actorId ?? null });
|
|
@@ -602,7 +1257,7 @@ export function createContentApiPlugin() {
|
|
|
602
1257
|
const id = parsed.searchParams.get("id") ?? "";
|
|
603
1258
|
const resolved = await resolveRoot(rootId);
|
|
604
1259
|
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
605
|
-
const abs =
|
|
1260
|
+
const abs = await resolveSafeFilesystemPath(resolved.root.absPath, `${id}.board.json`);
|
|
606
1261
|
if (!abs) return send(res, 400, { error: "Invalid id" });
|
|
607
1262
|
try {
|
|
608
1263
|
const content = await fs.readFile(abs, "utf-8");
|
|
@@ -613,34 +1268,111 @@ export function createContentApiPlugin() {
|
|
|
613
1268
|
const { rootId, id, doc, expectedRevision } = JSON.parse(await readBody(req));
|
|
614
1269
|
const resolved = await resolveRoot(String(rootId ?? ""));
|
|
615
1270
|
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
616
|
-
const abs =
|
|
1271
|
+
const abs = await resolveSafeFilesystemPath(resolved.root.absPath, `${id}.board.json`);
|
|
617
1272
|
if (!abs) return send(res, 400, { error: "Invalid id" });
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
+
});
|
|
626
1289
|
}
|
|
627
1290
|
if (route === "/__api/board" && method === "DELETE") {
|
|
628
1291
|
const rootId = parsed.searchParams.get("rootId") ?? "";
|
|
629
1292
|
const id = parsed.searchParams.get("id") ?? "";
|
|
630
1293
|
const resolved = await resolveRoot(rootId);
|
|
631
1294
|
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
632
|
-
const abs =
|
|
1295
|
+
const abs = await resolveSafeFilesystemPath(resolved.root.absPath, `${id}.board.json`);
|
|
633
1296
|
if (!abs) return send(res, 400, { error: "Invalid id" });
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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);
|
|
637
1369
|
}
|
|
638
1370
|
if (route === "/__api/asset" && method === "GET") {
|
|
639
1371
|
const rootId = parsed.searchParams.get("rootId") ?? "";
|
|
640
1372
|
const rel = parsed.searchParams.get("path") ?? "";
|
|
641
1373
|
const resolved = await resolveRoot(rootId);
|
|
642
1374
|
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
643
|
-
const abs =
|
|
1375
|
+
const abs = await resolveSafeFilesystemPath(resolved.root.absPath, rel);
|
|
644
1376
|
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
645
1377
|
try {
|
|
646
1378
|
const buf = await fs.readFile(abs);
|
|
@@ -658,7 +1390,7 @@ export function createContentApiPlugin() {
|
|
|
658
1390
|
const ext = MIME_EXT[String(mimeType ?? "")] ?? (path.extname(String(name ?? "")) || ".png").toLowerCase();
|
|
659
1391
|
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
660
1392
|
const rel = `assets/${digest}${ext}`;
|
|
661
|
-
const abs =
|
|
1393
|
+
const abs = await resolveSafeFilesystemPath(resolved.root.absPath, rel);
|
|
662
1394
|
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
663
1395
|
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
664
1396
|
try { await fs.access(abs); } catch { await fs.writeFile(abs, bytes); }
|
|
@@ -673,6 +1405,51 @@ export function createContentApiPlugin() {
|
|
|
673
1405
|
if (removed > 0) broadcast("asset-changed", { rootId: resolved.root.id, action: "cleanup", removed, actorId: actor?.actorId ?? null });
|
|
674
1406
|
return send(res, 200, { ok: true, removed });
|
|
675
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
|
+
}
|
|
1447
|
+
if (route === "/__api/export/pdf" && method === "POST") {
|
|
1448
|
+
const { html, fileName, pdf } = JSON.parse(await readBody(req));
|
|
1449
|
+
if (typeof html !== "string" || !html.trim()) return send(res, 400, { error: "Missing HTML payload" });
|
|
1450
|
+
const pdfBuffer = await renderPdfFromHtmlPortable(html, String(fileName ?? "export.pdf"), typeof pdf === "object" && pdf ? pdf : {});
|
|
1451
|
+
return sendBinary(res, 200, pdfBuffer, "application/pdf", sanitizePdfFileNamePortable(String(fileName ?? "export.pdf")));
|
|
1452
|
+
}
|
|
676
1453
|
if (route === "/__api/workspace-file" && method === "GET") {
|
|
677
1454
|
await ensureWorkspaceDir();
|
|
678
1455
|
const manifest = await loadManifest();
|
|
@@ -680,28 +1457,41 @@ export function createContentApiPlugin() {
|
|
|
680
1457
|
}
|
|
681
1458
|
if (route === "/__api/workspace/project" && method === "POST") {
|
|
682
1459
|
const { rootPath, label } = JSON.parse(await readBody(req));
|
|
683
|
-
|
|
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" });
|
|
684
1463
|
const manifest = await loadManifest();
|
|
685
|
-
const
|
|
1464
|
+
const existingProject = manifest.projects.find((project) => project.rootPath === absRoot);
|
|
1465
|
+
const projectId = existingProject?.id ?? projectIdFor(absRoot);
|
|
686
1466
|
const overlap = manifest.projects.find((project) => project.id !== projectId && (isNestedPath(project.rootPath, absRoot) || isNestedPath(absRoot, project.rootPath)));
|
|
687
1467
|
if (overlap) return send(res, 409, { error: overlapError(overlap, absRoot) });
|
|
688
|
-
|
|
689
|
-
|
|
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);
|
|
690
1474
|
await saveManifest(manifest);
|
|
1475
|
+
broadcast("workspace-changed", { action: existingProject ? "project-updated" : "project-added", projectId, actorId: actor?.actorId ?? null });
|
|
691
1476
|
}
|
|
692
1477
|
return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
|
|
693
1478
|
}
|
|
694
1479
|
if (route === "/__api/workspace/project" && method === "DELETE") {
|
|
695
1480
|
const projectId = parsed.searchParams.get("id") ?? "";
|
|
696
1481
|
const manifest = await loadManifest();
|
|
1482
|
+
const previousCount = manifest.projects.length;
|
|
697
1483
|
manifest.projects = manifest.projects.filter((project) => project.id !== projectId);
|
|
698
|
-
|
|
1484
|
+
if (manifest.projects.length !== previousCount) {
|
|
1485
|
+
await saveManifest(manifest);
|
|
1486
|
+
broadcast("workspace-changed", { action: "project-removed", projectId, actorId: actor?.actorId ?? null });
|
|
1487
|
+
}
|
|
699
1488
|
return send(res, 200, { ok: true, workspace: await toWorkspaceResponse(await getWorkspace()) });
|
|
700
1489
|
}
|
|
701
1490
|
if (route === "/__api/workspace/pick-project" && method === "POST") {
|
|
702
1491
|
const picked = await pickProjectFromSystemDialog();
|
|
703
1492
|
if (!picked) return send(res, 200, { cancelled: true, workspace: await toWorkspaceResponse(await getWorkspace()) });
|
|
704
1493
|
const absRoot = await canonicalizeProjectRoot(picked);
|
|
1494
|
+
if (!(await isDirectory(absRoot))) return send(res, 400, { error: "Project root not found" });
|
|
705
1495
|
const manifest = await loadManifest();
|
|
706
1496
|
const projectId = projectIdFor(absRoot);
|
|
707
1497
|
const overlap = manifest.projects.find((project) => project.id !== projectId && (isNestedPath(project.rootPath, absRoot) || isNestedPath(absRoot, project.rootPath)));
|
|
@@ -709,21 +1499,44 @@ export function createContentApiPlugin() {
|
|
|
709
1499
|
if (!manifest.projects.some((project) => project.id === projectId)) {
|
|
710
1500
|
manifest.projects.push({ id: projectId, label: path.basename(absRoot) || "Project", rootPath: absRoot });
|
|
711
1501
|
await saveManifest(manifest);
|
|
1502
|
+
broadcast("workspace-changed", { action: "project-added", projectId, actorId: actor?.actorId ?? null });
|
|
712
1503
|
}
|
|
713
1504
|
const workspace = await getWorkspace();
|
|
714
1505
|
return send(res, 200, { cancelled: false, workspace: await toWorkspaceResponse(workspace), addedProjectId: projectId });
|
|
715
1506
|
}
|
|
716
1507
|
if (route === "/__api/workspace/project/docs-root" && method === "POST") {
|
|
717
1508
|
const { projectId } = JSON.parse(await readBody(req));
|
|
718
|
-
const
|
|
719
|
-
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 });
|
|
720
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" });
|
|
721
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) });
|
|
722
1532
|
}
|
|
723
1533
|
if (route === "/__api/workspace/project/create-docs-folder" && method === "POST") {
|
|
724
1534
|
const { projectId, parentPath } = JSON.parse(await readBody(req));
|
|
725
|
-
const
|
|
726
|
-
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 });
|
|
727
1540
|
const workspace = await getWorkspace();
|
|
728
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) });
|
|
729
1542
|
}
|