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
package/vite.config.ts
CHANGED
|
@@ -1,1172 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import { promises as fs } from "node:fs";
|
|
4
|
-
import * as path from "node:path";
|
|
5
|
-
import os from "node:os";
|
|
6
|
-
import { execFile } from "node:child_process";
|
|
7
|
-
import { createHash } from "node:crypto";
|
|
8
|
-
import { promisify } from "node:util";
|
|
9
|
-
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
10
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
|
-
import { renderPdfFromHtml as renderPdfFromHtmlPortable, sanitizePdfFileName as sanitizePdfFileNamePortable } from "./bin/pdf-export.mjs";
|
|
12
|
-
|
|
13
|
-
const WORKSPACE_DIR = ".workspace";
|
|
14
|
-
const WORKSPACE_FILE = "workspace.json";
|
|
15
|
-
const IGNORE_DIRS = new Set([
|
|
16
|
-
".git",
|
|
17
|
-
"node_modules",
|
|
18
|
-
"dist",
|
|
19
|
-
"build",
|
|
20
|
-
"coverage",
|
|
21
|
-
".next",
|
|
22
|
-
"out",
|
|
23
|
-
]);
|
|
24
|
-
|
|
25
|
-
const MIME: Record<string, string> = {
|
|
26
|
-
".png": "image/png",
|
|
27
|
-
".jpg": "image/jpeg",
|
|
28
|
-
".jpeg": "image/jpeg",
|
|
29
|
-
".webp": "image/webp",
|
|
30
|
-
".gif": "image/gif",
|
|
31
|
-
".svg": "image/svg+xml",
|
|
32
|
-
".md": "text/markdown; charset=utf-8",
|
|
33
|
-
".json": "application/json; charset=utf-8",
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
const MIME_EXT: Record<string, string> = {
|
|
37
|
-
"image/png": ".png",
|
|
38
|
-
"image/jpeg": ".jpg",
|
|
39
|
-
"image/webp": ".webp",
|
|
40
|
-
"image/gif": ".gif",
|
|
41
|
-
"image/svg+xml": ".svg",
|
|
42
|
-
};
|
|
43
|
-
const MANAGED_ASSET_RE = /^assets\/[a-f0-9]{64}\.(png|jpe?g|webp|gif|svg)$/i;
|
|
44
|
-
const ROOT_LINK_HEADER = '</llms.txt>; rel="describedby"; type="text/plain", </.well-known/viteboard.json>; rel="alternate"; type="application/json"';
|
|
45
|
-
|
|
46
|
-
const execFileAsync = promisify(execFile);
|
|
47
|
-
|
|
48
|
-
type WorkspaceManifest = {
|
|
49
|
-
version: 1;
|
|
50
|
-
projects: WorkspaceProjectRecord[];
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
type WorkspaceProjectRecord = {
|
|
54
|
-
id: string;
|
|
55
|
-
label: string;
|
|
56
|
-
rootPath: string;
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
type WorkspaceProject = WorkspaceProjectRecord & {
|
|
60
|
-
docsRoots: DocsRoot[];
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
type DocsRoot = {
|
|
64
|
-
id: string;
|
|
65
|
-
projectId: string;
|
|
66
|
-
projectLabel: string;
|
|
67
|
-
absPath: string;
|
|
68
|
-
relativePath: string;
|
|
69
|
-
displayPath: string;
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
type WorkspaceResponse = {
|
|
73
|
-
projects: Array<{
|
|
74
|
-
id: string;
|
|
75
|
-
label: string;
|
|
76
|
-
rootPath: string;
|
|
77
|
-
docsRoots: Array<{
|
|
78
|
-
id: string;
|
|
79
|
-
projectId: string;
|
|
80
|
-
projectLabel: string;
|
|
81
|
-
relativePath: string;
|
|
82
|
-
displayPath: string;
|
|
83
|
-
}>;
|
|
84
|
-
}>;
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
type TreeNode =
|
|
88
|
-
| { type: "file"; name: string; path: string }
|
|
89
|
-
| { type: "dir"; name: string; path: string; children: TreeNode[] };
|
|
90
|
-
|
|
91
|
-
function contentApiPlugin(): Plugin {
|
|
92
|
-
const cwd = process.cwd();
|
|
93
|
-
const packageRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
94
|
-
const workspaceDir = path.resolve(cwd, WORKSPACE_DIR);
|
|
95
|
-
const workspaceFile = path.join(workspaceDir, WORKSPACE_FILE);
|
|
96
|
-
const machineDir = path.join(packageRoot, "content", "machine");
|
|
97
|
-
|
|
98
|
-
function send(res: ServerResponse, status: number, body: unknown): void {
|
|
99
|
-
res.statusCode = status;
|
|
100
|
-
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
101
|
-
res.end(JSON.stringify(body));
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function sendText(res: ServerResponse, status: number, body: string, type: string): void {
|
|
105
|
-
res.statusCode = status;
|
|
106
|
-
res.setHeader("Content-Type", type);
|
|
107
|
-
res.end(body);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function sendBinary(res: ServerResponse, status: number, body: Buffer, type: string, fileName?: string): void {
|
|
111
|
-
res.statusCode = status;
|
|
112
|
-
res.setHeader("Content-Type", type);
|
|
113
|
-
if (fileName) res.setHeader("Content-Disposition", `attachment; filename="${fileName.replace(/[\r\n\"]/g, "-")}"`);
|
|
114
|
-
res.end(body);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
async function readMachineAsset(fileName: string): Promise<string> {
|
|
118
|
-
return fs.readFile(path.join(machineDir, fileName), "utf-8");
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
let chromiumBinaryPromise: Promise<string> | null = null;
|
|
122
|
-
|
|
123
|
-
async function resolveChromiumBinary(): Promise<string> {
|
|
124
|
-
if (chromiumBinaryPromise) return chromiumBinaryPromise;
|
|
125
|
-
chromiumBinaryPromise = (async () => {
|
|
126
|
-
const candidates = ["chromium-browser", "chromium", "google-chrome", "google-chrome-stable"];
|
|
127
|
-
for (const candidate of candidates) {
|
|
128
|
-
try {
|
|
129
|
-
await execFileAsync("which", [candidate]);
|
|
130
|
-
return candidate;
|
|
131
|
-
} catch {
|
|
132
|
-
/* keep trying */
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
throw new Error("No Chromium-compatible browser is available for PDF export.");
|
|
136
|
-
})();
|
|
137
|
-
return chromiumBinaryPromise;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function sanitizePdfFileName(input: string): string {
|
|
141
|
-
const cleaned = String(input ?? "").trim().replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, " ").slice(0, 120) || "export";
|
|
142
|
-
return cleaned.toLowerCase().endsWith(".pdf") ? cleaned : `${cleaned}.pdf`;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function mmToInches(mm: number): number {
|
|
146
|
-
return mm / 25.4;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async function openCdpPage(port: string, url: string): Promise<string> {
|
|
150
|
-
const res = await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" });
|
|
151
|
-
const target = await res.json() as { webSocketDebuggerUrl: string };
|
|
152
|
-
if (!target.webSocketDebuggerUrl) throw new Error("Could not open Chromium page target for PDF export.");
|
|
153
|
-
return target.webSocketDebuggerUrl;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
async function printPdfViaCdp(wsUrl: string, url: string, pdf: {
|
|
157
|
-
displayHeaderFooter: boolean;
|
|
158
|
-
headerTemplate?: string;
|
|
159
|
-
footerTemplate?: string;
|
|
160
|
-
marginTopMm?: number;
|
|
161
|
-
marginBottomMm?: number;
|
|
162
|
-
marginLeftMm?: number;
|
|
163
|
-
marginRightMm?: number;
|
|
164
|
-
}): Promise<Buffer> {
|
|
165
|
-
const ws = new WebSocket(wsUrl);
|
|
166
|
-
let nextId = 0;
|
|
167
|
-
const pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void }>();
|
|
168
|
-
let loadResolve: (() => void) | null = null;
|
|
169
|
-
const loaded = new Promise<void>((resolve) => { loadResolve = resolve; });
|
|
170
|
-
ws.onmessage = (event) => {
|
|
171
|
-
const message = JSON.parse(String(event.data));
|
|
172
|
-
if (message.method === "Page.loadEventFired") {
|
|
173
|
-
loadResolve?.();
|
|
174
|
-
loadResolve = null;
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
if (!message.id) return;
|
|
178
|
-
const entry = pending.get(message.id);
|
|
179
|
-
if (!entry) return;
|
|
180
|
-
pending.delete(message.id);
|
|
181
|
-
if (message.error) entry.reject(new Error(message.error.message || "CDP error"));
|
|
182
|
-
else entry.resolve(message.result);
|
|
183
|
-
};
|
|
184
|
-
const opened = new Promise<void>((resolve, reject) => {
|
|
185
|
-
ws.onopen = () => resolve();
|
|
186
|
-
ws.onerror = () => reject(new Error("Could not connect to Chromium PDF export session."));
|
|
187
|
-
});
|
|
188
|
-
const send = (method: string, params: Record<string, unknown> = {}) => new Promise<any>((resolve, reject) => {
|
|
189
|
-
const id = ++nextId;
|
|
190
|
-
pending.set(id, { resolve, reject });
|
|
191
|
-
ws.send(JSON.stringify({ id, method, params }));
|
|
192
|
-
});
|
|
193
|
-
await opened;
|
|
194
|
-
try {
|
|
195
|
-
await send("Page.enable");
|
|
196
|
-
await send("Runtime.enable");
|
|
197
|
-
await send("Page.navigate", { url });
|
|
198
|
-
await loaded;
|
|
199
|
-
await send("Runtime.evaluate", {
|
|
200
|
-
expression: "document.fonts ? document.fonts.ready.then(() => true) : true",
|
|
201
|
-
awaitPromise: true,
|
|
202
|
-
returnByValue: true,
|
|
203
|
-
});
|
|
204
|
-
const result = await send("Page.printToPDF", {
|
|
205
|
-
printBackground: true,
|
|
206
|
-
preferCSSPageSize: true,
|
|
207
|
-
displayHeaderFooter: pdf.displayHeaderFooter,
|
|
208
|
-
headerTemplate: pdf.headerTemplate ?? "<div></div>",
|
|
209
|
-
footerTemplate: pdf.footerTemplate ?? "<div></div>",
|
|
210
|
-
marginTop: mmToInches(pdf.marginTopMm ?? 12),
|
|
211
|
-
marginBottom: mmToInches(pdf.marginBottomMm ?? 12),
|
|
212
|
-
marginLeft: mmToInches(pdf.marginLeftMm ?? 12),
|
|
213
|
-
marginRight: mmToInches(pdf.marginRightMm ?? 12),
|
|
214
|
-
}) as { data: string };
|
|
215
|
-
return Buffer.from(result.data, "base64");
|
|
216
|
-
} finally {
|
|
217
|
-
try { ws.close(); } catch { /* ignore */ }
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
async function renderPdfFromHtml(html: string, fileName: string, pdf: {
|
|
222
|
-
displayHeaderFooter?: boolean;
|
|
223
|
-
headerTemplate?: string;
|
|
224
|
-
footerTemplate?: string;
|
|
225
|
-
marginTopMm?: number;
|
|
226
|
-
marginBottomMm?: number;
|
|
227
|
-
marginLeftMm?: number;
|
|
228
|
-
marginRightMm?: number;
|
|
229
|
-
} = {}): Promise<Buffer> {
|
|
230
|
-
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "viteboard-pdf-"));
|
|
231
|
-
const htmlPath = path.join(tmpDir, "publication.html");
|
|
232
|
-
const profileDir = path.join(tmpDir, "profile");
|
|
233
|
-
const browser = await resolveChromiumBinary();
|
|
234
|
-
let browserProcess: import("node:child_process").ChildProcess | null = null;
|
|
235
|
-
try {
|
|
236
|
-
await fs.writeFile(htmlPath, html, "utf-8");
|
|
237
|
-
await fs.mkdir(profileDir, { recursive: true });
|
|
238
|
-
browserProcess = execFile(browser, [
|
|
239
|
-
"--headless",
|
|
240
|
-
"--disable-gpu",
|
|
241
|
-
"--no-sandbox",
|
|
242
|
-
`--user-data-dir=${profileDir}`,
|
|
243
|
-
"--remote-debugging-port=0",
|
|
244
|
-
"about:blank",
|
|
245
|
-
], { windowsHide: true });
|
|
246
|
-
const activePortPath = path.join(profileDir, "DevToolsActivePort");
|
|
247
|
-
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
248
|
-
try {
|
|
249
|
-
const raw = await fs.readFile(activePortPath, "utf-8");
|
|
250
|
-
const port = raw.split(/\r?\n/)[0]?.trim();
|
|
251
|
-
if (!port) throw new Error("Chromium debugging port missing.");
|
|
252
|
-
const wsUrl = await openCdpPage(port, pathToFileURL(htmlPath).href);
|
|
253
|
-
return await printPdfViaCdp(wsUrl, pathToFileURL(htmlPath).href, {
|
|
254
|
-
displayHeaderFooter: Boolean(pdf.displayHeaderFooter),
|
|
255
|
-
headerTemplate: pdf.headerTemplate,
|
|
256
|
-
footerTemplate: pdf.footerTemplate,
|
|
257
|
-
marginTopMm: pdf.marginTopMm,
|
|
258
|
-
marginBottomMm: pdf.marginBottomMm,
|
|
259
|
-
marginLeftMm: pdf.marginLeftMm,
|
|
260
|
-
marginRightMm: pdf.marginRightMm,
|
|
261
|
-
});
|
|
262
|
-
} catch {
|
|
263
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
throw new Error("Timed out starting Chromium PDF export session.");
|
|
267
|
-
} finally {
|
|
268
|
-
if (browserProcess) {
|
|
269
|
-
const exited = new Promise<void>((resolve) => {
|
|
270
|
-
browserProcess?.once("exit", () => resolve());
|
|
271
|
-
browserProcess?.once("close", () => resolve());
|
|
272
|
-
});
|
|
273
|
-
try { browserProcess.kill(); } catch { /* ignore */ }
|
|
274
|
-
await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 500))]);
|
|
275
|
-
}
|
|
276
|
-
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function revisionForText(content: string): string {
|
|
281
|
-
return createHash("sha1").update(content).digest("hex");
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function readBody(req: IncomingMessage): Promise<string> {
|
|
285
|
-
return new Promise((resolve, reject) => {
|
|
286
|
-
let data = "";
|
|
287
|
-
req.on("data", (chunk) => {
|
|
288
|
-
data += chunk;
|
|
289
|
-
if (data.length > 64 * 1024 * 1024) {
|
|
290
|
-
reject(new Error("Payload too large"));
|
|
291
|
-
req.destroy();
|
|
292
|
-
}
|
|
293
|
-
});
|
|
294
|
-
req.on("end", () => resolve(data));
|
|
295
|
-
req.on("error", reject);
|
|
296
|
-
});
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
function hashPath(value: string): string {
|
|
300
|
-
let hash = 2166136261;
|
|
301
|
-
for (let i = 0; i < value.length; i++) {
|
|
302
|
-
hash ^= value.charCodeAt(i);
|
|
303
|
-
hash = Math.imul(hash, 16777619);
|
|
304
|
-
}
|
|
305
|
-
return (hash >>> 0).toString(36);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
async function canonicalizeProjectRoot(rootPath: string): Promise<string> {
|
|
309
|
-
const resolved = path.resolve(rootPath);
|
|
310
|
-
try {
|
|
311
|
-
return path.resolve(await fs.realpath(resolved));
|
|
312
|
-
} catch {
|
|
313
|
-
return resolved;
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function isNestedPath(parent: string, child: string): boolean {
|
|
318
|
-
const rel = path.relative(parent, child);
|
|
319
|
-
return rel !== "" && rel !== "." && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function findOverlappingProject(
|
|
323
|
-
projects: WorkspaceProjectRecord[],
|
|
324
|
-
candidateRoot: string,
|
|
325
|
-
ignoreProjectId?: string
|
|
326
|
-
): WorkspaceProjectRecord | null {
|
|
327
|
-
for (const project of projects) {
|
|
328
|
-
if (ignoreProjectId && project.id === ignoreProjectId) continue;
|
|
329
|
-
if (
|
|
330
|
-
project.rootPath === candidateRoot ||
|
|
331
|
-
isNestedPath(project.rootPath, candidateRoot) ||
|
|
332
|
-
isNestedPath(candidateRoot, project.rootPath)
|
|
333
|
-
) {
|
|
334
|
-
return project;
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
return null;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function overlapError(existing: WorkspaceProjectRecord, candidateRoot: string): string {
|
|
341
|
-
if (existing.rootPath === candidateRoot) {
|
|
342
|
-
return `\"${existing.label}\" is already added. Pick a different folder, or remove the existing project first.`;
|
|
343
|
-
}
|
|
344
|
-
if (isNestedPath(existing.rootPath, candidateRoot)) {
|
|
345
|
-
return `That folder is inside the existing workspace project \"${existing.label}\". ViteBoard only allows one project root per nested path.`;
|
|
346
|
-
}
|
|
347
|
-
return `That folder contains the existing workspace project \"${existing.label}\". Add the more specific project instead, or remove the current one first.`;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
async function ensureWorkspaceDir(): Promise<void> {
|
|
351
|
-
await fs.mkdir(workspaceDir, { recursive: true });
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
async function loadManifest(): Promise<WorkspaceManifest> {
|
|
355
|
-
try {
|
|
356
|
-
const raw = await fs.readFile(workspaceFile, "utf-8");
|
|
357
|
-
const parsed = JSON.parse(raw) as Partial<WorkspaceManifest>;
|
|
358
|
-
const rawProjects = Array.isArray(parsed.projects)
|
|
359
|
-
? parsed.projects.filter((item): item is WorkspaceProjectRecord => {
|
|
360
|
-
return !!item && typeof item.id === "string" && typeof item.rootPath === "string";
|
|
361
|
-
})
|
|
362
|
-
: [];
|
|
363
|
-
const projects: WorkspaceProjectRecord[] = [];
|
|
364
|
-
const seenIds = new Set<string>();
|
|
365
|
-
const seenPaths = new Set<string>();
|
|
366
|
-
for (const item of rawProjects) {
|
|
367
|
-
const rootPath = await canonicalizeProjectRoot(item.rootPath);
|
|
368
|
-
const id = item.id.trim() || `project-${hashPath(rootPath)}`;
|
|
369
|
-
if (seenIds.has(id) || seenPaths.has(rootPath)) continue;
|
|
370
|
-
seenIds.add(id);
|
|
371
|
-
seenPaths.add(rootPath);
|
|
372
|
-
projects.push({
|
|
373
|
-
id,
|
|
374
|
-
label: typeof item.label === "string" && item.label.trim() ? item.label.trim() : path.basename(rootPath),
|
|
375
|
-
rootPath,
|
|
376
|
-
});
|
|
377
|
-
}
|
|
378
|
-
return { version: 1, projects };
|
|
379
|
-
} catch {
|
|
380
|
-
return { version: 1, projects: [] };
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
async function saveManifest(manifest: WorkspaceManifest): Promise<void> {
|
|
385
|
-
await ensureWorkspaceDir();
|
|
386
|
-
await fs.writeFile(workspaceFile, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
async function dirExists(absPath: string): Promise<boolean> {
|
|
390
|
-
try {
|
|
391
|
-
return (await fs.stat(absPath)).isDirectory();
|
|
392
|
-
} catch {
|
|
393
|
-
return false;
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
async function loadProjects(): Promise<WorkspaceProjectRecord[]> {
|
|
398
|
-
return (await loadManifest()).projects;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
async function openProjectPicker(): Promise<string | null> {
|
|
402
|
-
if (process.platform === "darwin") {
|
|
403
|
-
try {
|
|
404
|
-
const { stdout } = await execFileAsync("osascript", [
|
|
405
|
-
"-e",
|
|
406
|
-
'set chosenFolder to choose folder with prompt "Select a project root to add to ViteBoard"',
|
|
407
|
-
"-e",
|
|
408
|
-
'POSIX path of chosenFolder',
|
|
409
|
-
]);
|
|
410
|
-
const picked = stdout.trim();
|
|
411
|
-
return picked || null;
|
|
412
|
-
} catch {
|
|
413
|
-
return null;
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
if (process.platform === "win32") {
|
|
418
|
-
const script = [
|
|
419
|
-
"Add-Type -AssemblyName System.Windows.Forms",
|
|
420
|
-
'$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
|
|
421
|
-
'$dialog.Description = "Select a project root to add to ViteBoard"',
|
|
422
|
-
'$dialog.ShowNewFolderButton = $false',
|
|
423
|
-
'if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {',
|
|
424
|
-
' [Console]::Out.Write($dialog.SelectedPath)',
|
|
425
|
-
'}',
|
|
426
|
-
].join("; ");
|
|
427
|
-
try {
|
|
428
|
-
const { stdout } = await execFileAsync("powershell", [
|
|
429
|
-
"-NoProfile",
|
|
430
|
-
"-STA",
|
|
431
|
-
"-Command",
|
|
432
|
-
script,
|
|
433
|
-
]);
|
|
434
|
-
const picked = stdout.trim();
|
|
435
|
-
return picked || null;
|
|
436
|
-
} catch {
|
|
437
|
-
return null;
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
const linuxPickers: Array<[string, string[]]> = [
|
|
442
|
-
["zenity", ["--file-selection", "--directory", "--title=Select a project root to add to ViteBoard"]],
|
|
443
|
-
["kdialog", ["--getexistingdirectory", ".", "Select a project root to add to ViteBoard"]],
|
|
444
|
-
];
|
|
445
|
-
let sawInstalledPicker = false;
|
|
446
|
-
for (const [command, args] of linuxPickers) {
|
|
447
|
-
try {
|
|
448
|
-
await execFileAsync("which", [command]);
|
|
449
|
-
sawInstalledPicker = true;
|
|
450
|
-
const { stdout } = await execFileAsync(command, args);
|
|
451
|
-
const picked = stdout.trim();
|
|
452
|
-
if (picked) return picked;
|
|
453
|
-
} catch {
|
|
454
|
-
if (sawInstalledPicker) return null;
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
throw new Error("No supported folder picker found. Install zenity or kdialog.");
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
function isIgnoredDir(name: string): boolean {
|
|
461
|
-
return name.startsWith(".") || IGNORE_DIRS.has(name);
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
async function discoverDocsRoots(project: WorkspaceProjectRecord): Promise<DocsRoot[]> {
|
|
465
|
-
const roots: DocsRoot[] = [];
|
|
466
|
-
const projectRoot = path.resolve(project.rootPath);
|
|
467
|
-
|
|
468
|
-
async function walk(absDir: string): Promise<void> {
|
|
469
|
-
let entries: import("node:fs").Dirent[];
|
|
470
|
-
try {
|
|
471
|
-
entries = await fs.readdir(absDir, { withFileTypes: true });
|
|
472
|
-
} catch {
|
|
473
|
-
return;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
for (const entry of entries) {
|
|
477
|
-
if (!entry.isDirectory()) continue;
|
|
478
|
-
if (isIgnoredDir(entry.name)) continue;
|
|
479
|
-
const child = path.join(absDir, entry.name);
|
|
480
|
-
if (entry.name === "docs") {
|
|
481
|
-
const relativePath = path.relative(projectRoot, child).replace(/\\/g, "/") || "docs";
|
|
482
|
-
roots.push({
|
|
483
|
-
id: `root-${hashPath(child)}`,
|
|
484
|
-
projectId: project.id,
|
|
485
|
-
projectLabel: project.label,
|
|
486
|
-
absPath: child,
|
|
487
|
-
relativePath,
|
|
488
|
-
displayPath: relativePath,
|
|
489
|
-
});
|
|
490
|
-
}
|
|
491
|
-
await walk(child);
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
if (await dirExists(projectRoot)) {
|
|
496
|
-
if (path.basename(projectRoot) === "docs") {
|
|
497
|
-
roots.push({
|
|
498
|
-
id: `root-${hashPath(projectRoot)}`,
|
|
499
|
-
projectId: project.id,
|
|
500
|
-
projectLabel: project.label,
|
|
501
|
-
absPath: projectRoot,
|
|
502
|
-
relativePath: "docs",
|
|
503
|
-
displayPath: "docs",
|
|
504
|
-
});
|
|
505
|
-
}
|
|
506
|
-
await walk(projectRoot);
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
roots.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
510
|
-
return roots.filter((root, index, list) => list.findIndex((other) => other.absPath === root.absPath) === index);
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
async function getWorkspace(): Promise<WorkspaceProject[]> {
|
|
514
|
-
const projects = await loadProjects();
|
|
515
|
-
const out: WorkspaceProject[] = [];
|
|
516
|
-
|
|
517
|
-
for (const project of projects) {
|
|
518
|
-
if (!(await dirExists(project.rootPath))) continue;
|
|
519
|
-
out.push({
|
|
520
|
-
...project,
|
|
521
|
-
docsRoots: await discoverDocsRoots(project),
|
|
522
|
-
});
|
|
523
|
-
}
|
|
524
|
-
return out;
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
function toWorkspaceResponse(projects: WorkspaceProject[]): WorkspaceResponse {
|
|
528
|
-
return {
|
|
529
|
-
projects: projects.map((project) => ({
|
|
530
|
-
id: project.id,
|
|
531
|
-
label: project.label,
|
|
532
|
-
rootPath: project.rootPath,
|
|
533
|
-
docsRoots: project.docsRoots.map((root) => ({
|
|
534
|
-
id: root.id,
|
|
535
|
-
projectId: root.projectId,
|
|
536
|
-
projectLabel: root.projectLabel,
|
|
537
|
-
relativePath: root.relativePath,
|
|
538
|
-
displayPath: root.displayPath,
|
|
539
|
-
})),
|
|
540
|
-
})),
|
|
541
|
-
};
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
async function resolveRoot(rootId: string): Promise<{ root: DocsRoot; nestedRoots: Set<string> } | null> {
|
|
545
|
-
const projects = await getWorkspace();
|
|
546
|
-
for (const project of projects) {
|
|
547
|
-
const root = project.docsRoots.find((item) => item.id === rootId);
|
|
548
|
-
if (!root) continue;
|
|
549
|
-
const nestedRoots = new Set(
|
|
550
|
-
project.docsRoots
|
|
551
|
-
.filter((item) => item.id !== root.id && item.absPath.startsWith(root.absPath + path.sep))
|
|
552
|
-
.map((item) => item.absPath)
|
|
553
|
-
);
|
|
554
|
-
return { root, nestedRoots };
|
|
555
|
-
}
|
|
556
|
-
return null;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
function safeResolve(root: string, rel: string): string | null {
|
|
560
|
-
const normalized = path.normalize(String(rel ?? "").replace(/^[/\\]+/, "")).replace(/\\/g, "/");
|
|
561
|
-
const abs = path.resolve(root, normalized);
|
|
562
|
-
if (abs !== root && !abs.startsWith(root + path.sep)) return null;
|
|
563
|
-
return abs;
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
function normalizeRelativePath(input: string): string {
|
|
567
|
-
const parts: string[] = [];
|
|
568
|
-
for (const raw of String(input ?? "").replace(/\\/g, "/").split("/")) {
|
|
569
|
-
const part = raw.trim();
|
|
570
|
-
if (!part || part === ".") continue;
|
|
571
|
-
if (part === "..") {
|
|
572
|
-
if (parts.length > 0) parts.pop();
|
|
573
|
-
continue;
|
|
574
|
-
}
|
|
575
|
-
parts.push(part);
|
|
576
|
-
}
|
|
577
|
-
return parts.join("/");
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
function dirnamePosix(filePath: string): string {
|
|
581
|
-
const normalized = normalizeRelativePath(filePath);
|
|
582
|
-
const idx = normalized.lastIndexOf("/");
|
|
583
|
-
return idx === -1 ? "" : normalized.slice(0, idx);
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
function resolveDocRelativePath(fromFile: string, target: string): string | null {
|
|
587
|
-
const raw = String(target ?? "").trim();
|
|
588
|
-
if (!raw || /^(https?:|mailto:|tel:|#|data:)/i.test(raw)) return null;
|
|
589
|
-
if (raw.startsWith("/")) return normalizeRelativePath(raw);
|
|
590
|
-
const base = dirnamePosix(fromFile);
|
|
591
|
-
return normalizeRelativePath(base ? `${base}/${raw}` : raw);
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
function isManagedAssetPath(relPath: string): boolean {
|
|
595
|
-
return MANAGED_ASSET_RE.test(normalizeRelativePath(relPath));
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
async function collectFiles(dir: string, base = ""): Promise<string[]> {
|
|
599
|
-
let entries: import("node:fs").Dirent[];
|
|
600
|
-
try {
|
|
601
|
-
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
602
|
-
} catch {
|
|
603
|
-
return [];
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
const files: string[] = [];
|
|
607
|
-
for (const entry of entries) {
|
|
608
|
-
if (entry.name.startsWith(".")) continue;
|
|
609
|
-
const rel = base ? `${base}/${entry.name}` : entry.name;
|
|
610
|
-
const abs = path.join(dir, entry.name);
|
|
611
|
-
if (entry.isDirectory()) {
|
|
612
|
-
files.push(...(await collectFiles(abs, rel)));
|
|
613
|
-
} else {
|
|
614
|
-
files.push(rel);
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
return files;
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
function collectBoardAssetRefs(raw: unknown): Set<string> {
|
|
621
|
-
const refs = new Set<string>();
|
|
622
|
-
if (typeof raw !== "object" || raw === null) return refs;
|
|
623
|
-
const elements = Array.isArray((raw as { elements?: unknown[] }).elements)
|
|
624
|
-
? (raw as { elements: unknown[] }).elements
|
|
625
|
-
: [];
|
|
626
|
-
for (const element of elements) {
|
|
627
|
-
if (typeof element !== "object" || element === null) continue;
|
|
628
|
-
const data = (element as { data?: Record<string, unknown> }).data;
|
|
629
|
-
const src = typeof data?.src === "string" ? normalizeRelativePath(data.src) : "";
|
|
630
|
-
if (isManagedAssetPath(src)) refs.add(src);
|
|
631
|
-
}
|
|
632
|
-
return refs;
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
function collectMarkdownAssetRefs(markdown: string, docPath: string): Set<string> {
|
|
636
|
-
const refs = new Set<string>();
|
|
637
|
-
const imagePatterns = [
|
|
638
|
-
/!\[[^\]]*\]\(([^)\s]+)\)/g,
|
|
639
|
-
/<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi,
|
|
640
|
-
];
|
|
641
|
-
|
|
642
|
-
for (const pattern of imagePatterns) {
|
|
643
|
-
pattern.lastIndex = 0;
|
|
644
|
-
let match: RegExpExecArray | null;
|
|
645
|
-
while ((match = pattern.exec(markdown))) {
|
|
646
|
-
const resolved = resolveDocRelativePath(docPath, match[1]);
|
|
647
|
-
if (resolved && isManagedAssetPath(resolved)) refs.add(resolved);
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
return refs;
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
async function cleanupManagedAssets(rootAbsPath: string, preservePaths: string[] = []): Promise<number> {
|
|
655
|
-
const allFiles = await collectFiles(rootAbsPath);
|
|
656
|
-
const managedAssets = allFiles.filter((file) => isManagedAssetPath(file));
|
|
657
|
-
if (managedAssets.length === 0) return 0;
|
|
658
|
-
|
|
659
|
-
const referenced = new Set<string>();
|
|
660
|
-
for (const relPath of allFiles) {
|
|
661
|
-
const abs = safeResolve(rootAbsPath, relPath);
|
|
662
|
-
if (!abs) continue;
|
|
663
|
-
if (relPath.endsWith(".board.json")) {
|
|
664
|
-
try {
|
|
665
|
-
const raw = JSON.parse(await fs.readFile(abs, "utf-8"));
|
|
666
|
-
for (const ref of collectBoardAssetRefs(raw)) referenced.add(ref);
|
|
667
|
-
} catch {
|
|
668
|
-
// Ignore unreadable or invalid boards; cleanup stays conservative.
|
|
669
|
-
}
|
|
670
|
-
continue;
|
|
671
|
-
}
|
|
672
|
-
if (relPath.endsWith(".md")) {
|
|
673
|
-
try {
|
|
674
|
-
const markdown = await fs.readFile(abs, "utf-8");
|
|
675
|
-
for (const ref of collectMarkdownAssetRefs(markdown, relPath)) referenced.add(ref);
|
|
676
|
-
} catch {
|
|
677
|
-
// Ignore unreadable docs; cleanup stays conservative.
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
for (const relPath of preservePaths) {
|
|
683
|
-
const normalized = normalizeRelativePath(relPath);
|
|
684
|
-
if (isManagedAssetPath(normalized)) referenced.add(normalized);
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
let removed = 0;
|
|
688
|
-
for (const relPath of managedAssets) {
|
|
689
|
-
if (referenced.has(relPath)) continue;
|
|
690
|
-
const abs = safeResolve(rootAbsPath, relPath);
|
|
691
|
-
if (!abs) continue;
|
|
692
|
-
try {
|
|
693
|
-
await fs.unlink(abs);
|
|
694
|
-
removed++;
|
|
695
|
-
} catch (err) {
|
|
696
|
-
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
return removed;
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
async function walkDocsRoot(dir: string, base: string, nestedRoots: Set<string>): Promise<TreeNode[]> {
|
|
703
|
-
let entries: import("node:fs").Dirent[];
|
|
704
|
-
try {
|
|
705
|
-
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
706
|
-
} catch {
|
|
707
|
-
return [];
|
|
708
|
-
}
|
|
709
|
-
const nodes: TreeNode[] = [];
|
|
710
|
-
for (const entry of entries) {
|
|
711
|
-
if (entry.name.startsWith(".")) continue;
|
|
712
|
-
const absEntry = path.join(dir, entry.name);
|
|
713
|
-
if (entry.isDirectory()) {
|
|
714
|
-
if (nestedRoots.has(absEntry)) continue;
|
|
715
|
-
const rel = base ? `${base}/${entry.name}` : entry.name;
|
|
716
|
-
nodes.push({
|
|
717
|
-
type: "dir",
|
|
718
|
-
name: entry.name,
|
|
719
|
-
path: rel,
|
|
720
|
-
children: await walkDocsRoot(absEntry, rel, nestedRoots),
|
|
721
|
-
});
|
|
722
|
-
} else {
|
|
723
|
-
nodes.push({
|
|
724
|
-
type: "file",
|
|
725
|
-
name: entry.name,
|
|
726
|
-
path: base ? `${base}/${entry.name}` : entry.name,
|
|
727
|
-
});
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
nodes.sort((a, b) => {
|
|
731
|
-
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
|
|
732
|
-
return a.name.localeCompare(b.name);
|
|
733
|
-
});
|
|
734
|
-
return nodes;
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
/** Walk the project tree for Raw sidebar mode. */
|
|
738
|
-
async function walkProjectTree(dir: string, base: string): Promise<TreeNode[]> {
|
|
739
|
-
let entries: import("node:fs").Dirent[];
|
|
740
|
-
try {
|
|
741
|
-
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
742
|
-
} catch {
|
|
743
|
-
return [];
|
|
744
|
-
}
|
|
745
|
-
const nodes: TreeNode[] = [];
|
|
746
|
-
for (const entry of entries) {
|
|
747
|
-
if (entry.name.startsWith(".")) continue;
|
|
748
|
-
const rel = base ? `${base}/${entry.name}` : entry.name;
|
|
749
|
-
if (entry.isDirectory()) {
|
|
750
|
-
if (isIgnoredDir(entry.name)) continue;
|
|
751
|
-
nodes.push({
|
|
752
|
-
type: "dir",
|
|
753
|
-
name: entry.name,
|
|
754
|
-
path: rel,
|
|
755
|
-
children: await walkProjectTree(path.join(dir, entry.name), rel),
|
|
756
|
-
});
|
|
757
|
-
} else {
|
|
758
|
-
nodes.push({ type: "file", name: entry.name, path: rel });
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
nodes.sort((a, b) => {
|
|
762
|
-
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
|
|
763
|
-
return a.name.localeCompare(b.name);
|
|
764
|
-
});
|
|
765
|
-
return nodes;
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
async function sendMachineFile(res: ServerResponse, fileName: string, contentType: string): Promise<void> {
|
|
769
|
-
return sendText(res, 200, await readMachineAsset(fileName), contentType);
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
const handler: Connect.NextHandleFunction = (req, res, next) => {
|
|
773
|
-
const url = req.url ?? "";
|
|
774
|
-
const parsed = new URL(url, "http://localhost");
|
|
775
|
-
const route = parsed.pathname;
|
|
776
|
-
|
|
777
|
-
if (route === "/" || route === "/index.html") {
|
|
778
|
-
res.setHeader("Link", ROOT_LINK_HEADER);
|
|
779
|
-
return next();
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
if (route === "/llms.txt") {
|
|
783
|
-
void sendMachineFile(res, "llms.txt", "text/plain; charset=utf-8");
|
|
784
|
-
return;
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
if (route === "/.well-known/viteboard.json") {
|
|
788
|
-
void sendMachineFile(res, "viteboard.json", "application/json; charset=utf-8");
|
|
789
|
-
return;
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
if (route === "/viteboard-guide.md") {
|
|
793
|
-
void sendMachineFile(res, "guide.md", "text/markdown; charset=utf-8");
|
|
794
|
-
return;
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
if (route === "/board-v1.schema.json") {
|
|
798
|
-
void sendMachineFile(res, "board-v1.schema.json", "application/schema+json; charset=utf-8");
|
|
799
|
-
return;
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
if (route === "/viteboard-example.board.json") {
|
|
803
|
-
void sendMachineFile(res, "example.board.json", "application/json; charset=utf-8");
|
|
804
|
-
return;
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
if (!route.startsWith("/__api/")) return next();
|
|
808
|
-
|
|
809
|
-
void (async () => {
|
|
810
|
-
try {
|
|
811
|
-
const method = (req.method ?? "GET").toUpperCase();
|
|
812
|
-
|
|
813
|
-
if (route === "/__api/workspace" && method === "GET") {
|
|
814
|
-
return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
if (route === "/__api/workspace/project" && method === "POST") {
|
|
818
|
-
const { rootPath, label } = JSON.parse(await readBody(req));
|
|
819
|
-
const absRoot = await canonicalizeProjectRoot(String(rootPath ?? "").trim());
|
|
820
|
-
if (!absRoot || !(await dirExists(absRoot))) {
|
|
821
|
-
return send(res, 400, { error: "Project root not found" });
|
|
822
|
-
}
|
|
823
|
-
const manifest = await loadManifest();
|
|
824
|
-
const existingProject = manifest.projects.find((item) => item.rootPath === absRoot);
|
|
825
|
-
const projectId = existingProject?.id ?? `project-${hashPath(absRoot)}`;
|
|
826
|
-
const overlap = findOverlappingProject(manifest.projects, absRoot, projectId);
|
|
827
|
-
if (overlap) return send(res, 409, { error: overlapError(overlap, absRoot) });
|
|
828
|
-
const project: WorkspaceProjectRecord = {
|
|
829
|
-
id: projectId,
|
|
830
|
-
label: String(label ?? "").trim() || path.basename(absRoot) || absRoot,
|
|
831
|
-
rootPath: absRoot,
|
|
832
|
-
};
|
|
833
|
-
const existingIndex = manifest.projects.findIndex((item) => item.rootPath === absRoot || item.id === project.id);
|
|
834
|
-
if (existingIndex === -1) manifest.projects.push(project);
|
|
835
|
-
else manifest.projects.splice(existingIndex, 1, project);
|
|
836
|
-
await saveManifest(manifest);
|
|
837
|
-
return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
if (route === "/__api/workspace/pick-project" && method === "POST") {
|
|
841
|
-
const picked = await openProjectPicker();
|
|
842
|
-
if (!picked) return send(res, 200, { cancelled: true, workspace: await toWorkspaceResponse(await getWorkspace()) });
|
|
843
|
-
const absRoot = await canonicalizeProjectRoot(picked);
|
|
844
|
-
if (!(await dirExists(absRoot))) return send(res, 400, { error: "Project root not found" });
|
|
845
|
-
const manifest = await loadManifest();
|
|
846
|
-
const existingProject = manifest.projects.find((item) => item.rootPath === absRoot);
|
|
847
|
-
const projectId = existingProject?.id ?? `project-${hashPath(absRoot)}`;
|
|
848
|
-
const overlap = findOverlappingProject(manifest.projects, absRoot, projectId);
|
|
849
|
-
if (overlap) return send(res, 409, { error: overlapError(overlap, absRoot) });
|
|
850
|
-
const project: WorkspaceProjectRecord = {
|
|
851
|
-
id: projectId,
|
|
852
|
-
label: path.basename(absRoot) || absRoot,
|
|
853
|
-
rootPath: absRoot,
|
|
854
|
-
};
|
|
855
|
-
const existingIndex = manifest.projects.findIndex((item) => item.rootPath === absRoot || item.id === project.id);
|
|
856
|
-
if (existingIndex === -1) manifest.projects.push(project);
|
|
857
|
-
else manifest.projects.splice(existingIndex, 1, project);
|
|
858
|
-
await saveManifest(manifest);
|
|
859
|
-
return send(res, 200, { cancelled: false, workspace: await toWorkspaceResponse(await getWorkspace()), addedProjectId: project.id });
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
if (route === "/__api/workspace/project/docs-root" && method === "POST") {
|
|
863
|
-
const { projectId } = JSON.parse(await readBody(req));
|
|
864
|
-
const manifest = await loadManifest();
|
|
865
|
-
const project = manifest.projects.find((item) => item.id === String(projectId ?? ""));
|
|
866
|
-
if (!project) return send(res, 404, { error: "Project not found" });
|
|
867
|
-
const docsPath = path.join(project.rootPath, "docs");
|
|
868
|
-
await fs.mkdir(docsPath, { recursive: true });
|
|
869
|
-
const workspace = await getWorkspace();
|
|
870
|
-
const refreshedProject = workspace.find((item) => item.id === project.id);
|
|
871
|
-
const root = refreshedProject?.docsRoots.find((item) => item.absPath === docsPath)
|
|
872
|
-
?? refreshedProject?.docsRoots[0];
|
|
873
|
-
if (!root) return send(res, 500, { error: "Could not create docs root" });
|
|
874
|
-
return send(res, 200, {
|
|
875
|
-
root: {
|
|
876
|
-
id: root.id,
|
|
877
|
-
projectId: root.projectId,
|
|
878
|
-
projectLabel: root.projectLabel,
|
|
879
|
-
relativePath: root.relativePath,
|
|
880
|
-
displayPath: root.displayPath,
|
|
881
|
-
},
|
|
882
|
-
workspace: await toWorkspaceResponse(workspace),
|
|
883
|
-
});
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
if (route === "/__api/workspace/project/docs-area" && method === "POST") {
|
|
887
|
-
const { projectId, folderName } = JSON.parse(await readBody(req));
|
|
888
|
-
const manifest = await loadManifest();
|
|
889
|
-
const project = manifest.projects.find((item) => item.id === String(projectId ?? ""));
|
|
890
|
-
if (!project) return send(res, 404, { error: "Project not found" });
|
|
891
|
-
const folderPath = path.join(project.rootPath, String(folderName ?? ""));
|
|
892
|
-
const docsPath = path.join(folderPath, "docs");
|
|
893
|
-
await fs.mkdir(docsPath, { recursive: true });
|
|
894
|
-
const workspace = await getWorkspace();
|
|
895
|
-
const refreshedProject = workspace.find((item) => item.id === project.id);
|
|
896
|
-
const root = refreshedProject?.docsRoots.find((item) => item.absPath === docsPath);
|
|
897
|
-
if (!root) return send(res, 500, { error: "Could not create docs area" });
|
|
898
|
-
return send(res, 200, {
|
|
899
|
-
root: {
|
|
900
|
-
id: root.id,
|
|
901
|
-
projectId: root.projectId,
|
|
902
|
-
projectLabel: root.projectLabel,
|
|
903
|
-
relativePath: root.relativePath,
|
|
904
|
-
displayPath: root.displayPath,
|
|
905
|
-
},
|
|
906
|
-
workspace: await toWorkspaceResponse(workspace),
|
|
907
|
-
});
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
if (route === "/__api/workspace/project/create-docs-folder" && method === "POST") {
|
|
911
|
-
const { projectId, parentPath } = JSON.parse(await readBody(req));
|
|
912
|
-
const manifest = await loadManifest();
|
|
913
|
-
const project = manifest.projects.find((item) => item.id === String(projectId ?? ""));
|
|
914
|
-
if (!project) return send(res, 404, { error: "Project not found" });
|
|
915
|
-
const docsPath = path.join(project.rootPath, String(parentPath ?? ""), "docs");
|
|
916
|
-
await fs.mkdir(docsPath, { recursive: true });
|
|
917
|
-
return send(res, 200, { ok: true, workspace: await toWorkspaceResponse(await getWorkspace()) });
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
if (route === "/__api/workspace/project" && method === "DELETE") {
|
|
921
|
-
const id = parsed.searchParams.get("id") ?? "";
|
|
922
|
-
const manifest = await loadManifest();
|
|
923
|
-
manifest.projects = manifest.projects.filter((item) => item.id !== id);
|
|
924
|
-
await saveManifest(manifest);
|
|
925
|
-
return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
if (route === "/__api/tree" && method === "GET") {
|
|
929
|
-
const rootId = parsed.searchParams.get("rootId") ?? "";
|
|
930
|
-
const resolved = await resolveRoot(rootId);
|
|
931
|
-
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
932
|
-
const tree = await walkDocsRoot(resolved.root.absPath, "", resolved.nestedRoots);
|
|
933
|
-
return send(res, 200, { tree });
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
if (route === "/__api/project-tree" && method === "GET") {
|
|
937
|
-
const projectId = parsed.searchParams.get("projectId") ?? "";
|
|
938
|
-
const workspace = await getWorkspace();
|
|
939
|
-
const project = workspace.find(p => p.id === projectId);
|
|
940
|
-
if (!project) return send(res, 404, { error: "Project not found" });
|
|
941
|
-
const tree = await walkProjectTree(project.rootPath, "");
|
|
942
|
-
return send(res, 200, { tree });
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
if (route === "/__api/file" && method === "GET") {
|
|
946
|
-
const rootId = parsed.searchParams.get("rootId") ?? "";
|
|
947
|
-
const rel = parsed.searchParams.get("path") ?? "";
|
|
948
|
-
const resolved = await resolveRoot(rootId);
|
|
949
|
-
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
950
|
-
const abs = safeResolve(resolved.root.absPath, rel);
|
|
951
|
-
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
952
|
-
try {
|
|
953
|
-
const content = await fs.readFile(abs, "utf-8");
|
|
954
|
-
return send(res, 200, { path: rel, content, revision: revisionForText(content) });
|
|
955
|
-
} catch {
|
|
956
|
-
return send(res, 404, { error: "Not found" });
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
if (route === "/__api/file" && method === "PUT") {
|
|
961
|
-
const { rootId, path: rel, content, expectedRevision } = JSON.parse(await readBody(req));
|
|
962
|
-
const resolved = await resolveRoot(String(rootId ?? ""));
|
|
963
|
-
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
964
|
-
const abs = safeResolve(resolved.root.absPath, String(rel));
|
|
965
|
-
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
966
|
-
let currentContent: string | null = null;
|
|
967
|
-
try {
|
|
968
|
-
currentContent = await fs.readFile(abs, "utf-8");
|
|
969
|
-
} catch (err) {
|
|
970
|
-
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
971
|
-
}
|
|
972
|
-
const currentRevision = currentContent === null ? null : revisionForText(currentContent);
|
|
973
|
-
if (typeof expectedRevision === "string" && expectedRevision !== currentRevision) {
|
|
974
|
-
return send(res, 409, {
|
|
975
|
-
error: "File changed on disk",
|
|
976
|
-
currentRevision,
|
|
977
|
-
path: rel,
|
|
978
|
-
});
|
|
979
|
-
}
|
|
980
|
-
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
981
|
-
const nextContent = String(content ?? "");
|
|
982
|
-
await fs.writeFile(abs, nextContent, "utf-8");
|
|
983
|
-
const revision = revisionForText(nextContent);
|
|
984
|
-
return send(res, 200, { ok: true, path: rel, revision });
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
if (route === "/__api/directory" && method === "PUT") {
|
|
988
|
-
const { rootId, path: rel } = JSON.parse(await readBody(req));
|
|
989
|
-
const resolved = await resolveRoot(String(rootId ?? ""));
|
|
990
|
-
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
991
|
-
const abs = safeResolve(resolved.root.absPath, String(rel));
|
|
992
|
-
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
993
|
-
await fs.mkdir(abs, { recursive: true });
|
|
994
|
-
return send(res, 200, { ok: true, path: rel });
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
if (route === "/__api/file" && method === "DELETE") {
|
|
998
|
-
const rootId = parsed.searchParams.get("rootId") ?? "";
|
|
999
|
-
const rel = parsed.searchParams.get("path") ?? "";
|
|
1000
|
-
const resolved = await resolveRoot(rootId);
|
|
1001
|
-
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
1002
|
-
const abs = safeResolve(resolved.root.absPath, rel);
|
|
1003
|
-
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
1004
|
-
try {
|
|
1005
|
-
await fs.unlink(abs);
|
|
1006
|
-
} catch (err) {
|
|
1007
|
-
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
1008
|
-
}
|
|
1009
|
-
return send(res, 200, { ok: true });
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
if (route === "/__api/file" && method === "PATCH") {
|
|
1013
|
-
// Rename/move: { rootId, oldPath, newPath }
|
|
1014
|
-
const { rootId, oldPath, newPath, ignoreMissing } = JSON.parse(await readBody(req));
|
|
1015
|
-
const resolved = await resolveRoot(String(rootId ?? ""));
|
|
1016
|
-
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
1017
|
-
const absOld = safeResolve(resolved.root.absPath, String(oldPath));
|
|
1018
|
-
const absNew = safeResolve(resolved.root.absPath, String(newPath));
|
|
1019
|
-
if (!absOld || !absNew) return send(res, 400, { error: "Invalid path" });
|
|
1020
|
-
await fs.mkdir(path.dirname(absNew), { recursive: true });
|
|
1021
|
-
try {
|
|
1022
|
-
await fs.rename(absOld, absNew);
|
|
1023
|
-
} catch (err) {
|
|
1024
|
-
if (ignoreMissing && (err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
1025
|
-
return send(res, 200, { ok: true, path: newPath, skipped: true });
|
|
1026
|
-
}
|
|
1027
|
-
throw err;
|
|
1028
|
-
}
|
|
1029
|
-
return send(res, 200, { ok: true, path: newPath });
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
if (route === "/__api/board" && method === "GET") {
|
|
1033
|
-
const rootId = parsed.searchParams.get("rootId") ?? "";
|
|
1034
|
-
const id = parsed.searchParams.get("id") ?? "";
|
|
1035
|
-
const resolved = await resolveRoot(rootId);
|
|
1036
|
-
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
1037
|
-
const abs = safeResolve(resolved.root.absPath, `${id}.board.json`);
|
|
1038
|
-
if (!abs) return send(res, 400, { error: "Invalid id" });
|
|
1039
|
-
try {
|
|
1040
|
-
const content = await fs.readFile(abs, "utf-8");
|
|
1041
|
-
return send(res, 200, { id, doc: JSON.parse(content), revision: revisionForText(content) });
|
|
1042
|
-
} catch {
|
|
1043
|
-
return send(res, 404, { error: "Not found" });
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
if (route === "/__api/board" && method === "PUT") {
|
|
1048
|
-
const { rootId, id, doc, expectedRevision } = JSON.parse(await readBody(req));
|
|
1049
|
-
const resolved = await resolveRoot(String(rootId ?? ""));
|
|
1050
|
-
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
1051
|
-
const abs = safeResolve(resolved.root.absPath, `${String(id)}.board.json`);
|
|
1052
|
-
if (!abs) return send(res, 400, { error: "Invalid id" });
|
|
1053
|
-
let currentContent: string | null = null;
|
|
1054
|
-
try {
|
|
1055
|
-
currentContent = await fs.readFile(abs, "utf-8");
|
|
1056
|
-
} catch (err) {
|
|
1057
|
-
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
1058
|
-
}
|
|
1059
|
-
const currentRevision = currentContent === null ? null : revisionForText(currentContent);
|
|
1060
|
-
if (typeof expectedRevision === "string" && expectedRevision !== currentRevision) {
|
|
1061
|
-
return send(res, 409, {
|
|
1062
|
-
error: "Board changed on disk",
|
|
1063
|
-
currentRevision,
|
|
1064
|
-
id,
|
|
1065
|
-
});
|
|
1066
|
-
}
|
|
1067
|
-
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
1068
|
-
const nextContent = JSON.stringify(doc, null, 2) + "\n";
|
|
1069
|
-
await fs.writeFile(abs, nextContent, "utf-8");
|
|
1070
|
-
const revision = revisionForText(nextContent);
|
|
1071
|
-
return send(res, 200, { ok: true, id, revision });
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
if (route === "/__api/board" && method === "DELETE") {
|
|
1075
|
-
const rootId = parsed.searchParams.get("rootId") ?? "";
|
|
1076
|
-
const id = parsed.searchParams.get("id") ?? "";
|
|
1077
|
-
const resolved = await resolveRoot(rootId);
|
|
1078
|
-
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
1079
|
-
const abs = safeResolve(resolved.root.absPath, `${id}.board.json`);
|
|
1080
|
-
if (!abs) return send(res, 400, { error: "Invalid id" });
|
|
1081
|
-
try {
|
|
1082
|
-
await fs.unlink(abs);
|
|
1083
|
-
} catch (err) {
|
|
1084
|
-
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
1085
|
-
}
|
|
1086
|
-
return send(res, 200, { ok: true, id });
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
if (route === "/__api/asset" && method === "GET") {
|
|
1090
|
-
const rootId = parsed.searchParams.get("rootId") ?? "";
|
|
1091
|
-
const rel = parsed.searchParams.get("path") ?? "";
|
|
1092
|
-
const resolved = await resolveRoot(rootId);
|
|
1093
|
-
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
1094
|
-
const abs = safeResolve(resolved.root.absPath, rel);
|
|
1095
|
-
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
1096
|
-
try {
|
|
1097
|
-
const buf = await fs.readFile(abs);
|
|
1098
|
-
res.statusCode = 200;
|
|
1099
|
-
res.setHeader("Content-Type", MIME[path.extname(abs).toLowerCase()] ?? "application/octet-stream");
|
|
1100
|
-
res.setHeader("Cache-Control", "no-cache");
|
|
1101
|
-
return res.end(buf);
|
|
1102
|
-
} catch {
|
|
1103
|
-
return send(res, 404, { error: "Not found" });
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
if (route === "/__api/asset" && method === "POST") {
|
|
1108
|
-
const { rootId, name, base64, mimeType } = JSON.parse(await readBody(req));
|
|
1109
|
-
const resolved = await resolveRoot(String(rootId ?? ""));
|
|
1110
|
-
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
1111
|
-
const bytes = Buffer.from(String(base64), "base64");
|
|
1112
|
-
const ext = MIME_EXT[String(mimeType ?? "")] ?? (path.extname(String(name ?? "")) || ".png").toLowerCase();
|
|
1113
|
-
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
1114
|
-
const rel = `assets/${digest}${ext}`;
|
|
1115
|
-
const abs = safeResolve(resolved.root.absPath, rel);
|
|
1116
|
-
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
1117
|
-
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
1118
|
-
try {
|
|
1119
|
-
await fs.access(abs);
|
|
1120
|
-
} catch {
|
|
1121
|
-
await fs.writeFile(abs, bytes);
|
|
1122
|
-
}
|
|
1123
|
-
return send(res, 200, { ok: true, path: rel });
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
if (route === "/__api/asset/cleanup" && method === "POST") {
|
|
1127
|
-
const { rootId, preservePaths } = JSON.parse(await readBody(req));
|
|
1128
|
-
const resolved = await resolveRoot(String(rootId ?? ""));
|
|
1129
|
-
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
1130
|
-
const removed = await cleanupManagedAssets(
|
|
1131
|
-
resolved.root.absPath,
|
|
1132
|
-
Array.isArray(preservePaths) ? preservePaths.filter((item): item is string => typeof item === "string") : []
|
|
1133
|
-
);
|
|
1134
|
-
return send(res, 200, { ok: true, removed });
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
if (route === "/__api/export/pdf" && method === "POST") {
|
|
1138
|
-
const { html, fileName, pdf } = JSON.parse(await readBody(req));
|
|
1139
|
-
if (typeof html !== "string" || !html.trim()) {
|
|
1140
|
-
return send(res, 400, { error: "Missing HTML payload" });
|
|
1141
|
-
}
|
|
1142
|
-
const pdfBuffer = await renderPdfFromHtmlPortable(html, String(fileName ?? "export.pdf"), typeof pdf === "object" && pdf ? pdf : {});
|
|
1143
|
-
return sendBinary(res, 200, pdfBuffer, "application/pdf", sanitizePdfFileNamePortable(String(fileName ?? "export.pdf")));
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
if (route === "/__api/workspace-file" && method === "GET") {
|
|
1147
|
-
await ensureWorkspaceDir();
|
|
1148
|
-
const manifest = await loadManifest();
|
|
1149
|
-
return sendText(res, 200, JSON.stringify(manifest, null, 2) + "\n", "application/json; charset=utf-8");
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
return send(res, 404, { error: "Unknown endpoint" });
|
|
1153
|
-
} catch (err) {
|
|
1154
|
-
return send(res, 500, { error: (err as Error).message });
|
|
1155
|
-
}
|
|
1156
|
-
})();
|
|
1157
|
-
};
|
|
1158
|
-
|
|
1159
|
-
return {
|
|
1160
|
-
name: "viteboard-content-api",
|
|
1161
|
-
configureServer(server) {
|
|
1162
|
-
server.middlewares.use(handler);
|
|
1163
|
-
},
|
|
1164
|
-
configurePreviewServer(server) {
|
|
1165
|
-
server.middlewares.use(handler);
|
|
1166
|
-
},
|
|
1167
|
-
};
|
|
1168
|
-
}
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
|
+
import { createContentApiPlugin } from "./bin/content-api-plugin.mjs";
|
|
1169
3
|
|
|
1170
4
|
export default defineConfig({
|
|
1171
|
-
plugins: [
|
|
5
|
+
plugins: [createContentApiPlugin()],
|
|
1172
6
|
});
|