viteboard 0.1.0

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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/bin/viteboard.mjs +59 -0
  4. package/content/docs/assets/image-mqjpqlyw.png +0 -0
  5. package/content/docs/assets/image-mqjsmenr.png +0 -0
  6. package/content/docs/boards/product-roadmap.board.json +2130 -0
  7. package/content/docs/capabilities.md +89 -0
  8. package/content/docs/docs.md +84 -0
  9. package/content/docs/guide/boards-in-markdown.md +48 -0
  10. package/content/docs/guide/boards.md +92 -0
  11. package/content/docs/guide/getting-started.md +71 -0
  12. package/content/docs/guide/test.md +6 -0
  13. package/content/docs/index.md +47 -0
  14. package/content/docs/product-roadmap.board.json +2130 -0
  15. package/content/docs/test.md +219 -0
  16. package/content/docs/tetst-dir/test-board.board.json +15 -0
  17. package/content/docs/tetst-dir/test-test.md +1 -0
  18. package/content/docs/workspace.md +73 -0
  19. package/dist/assets/index-CEpxLM2o.css +1 -0
  20. package/dist/assets/index-D4xvJdEQ.js +60 -0
  21. package/dist/index.html +13 -0
  22. package/index.html +13 -0
  23. package/package.json +52 -0
  24. package/src/app/AppController.ts +955 -0
  25. package/src/app/BoardState.ts +130 -0
  26. package/src/app/ClipboardService.ts +159 -0
  27. package/src/app/CommandManager.ts +66 -0
  28. package/src/app/ElementActions.ts +152 -0
  29. package/src/app/EmbedRegionSelector.ts +103 -0
  30. package/src/app/ExportPng.ts +107 -0
  31. package/src/app/ImageInsertService.ts +141 -0
  32. package/src/app/KeyboardShortcuts.ts +124 -0
  33. package/src/app/main.ts +6 -0
  34. package/src/board/BoardPreview.ts +189 -0
  35. package/src/board/BoardService.ts +222 -0
  36. package/src/canvas/CanvasRenderer.ts +253 -0
  37. package/src/canvas/CanvasSurface.ts +70 -0
  38. package/src/canvas/HitTester.ts +110 -0
  39. package/src/canvas/ImageCache.ts +123 -0
  40. package/src/canvas/PerformanceMonitor.ts +31 -0
  41. package/src/canvas/RenderScheduler.ts +26 -0
  42. package/src/canvas/Viewport.ts +77 -0
  43. package/src/content/ContentApi.ts +258 -0
  44. package/src/content/Markdown.ts +431 -0
  45. package/src/content/MarkdownView.ts +70 -0
  46. package/src/editor/DocEditor.ts +799 -0
  47. package/src/editor/htmlToMarkdown.ts +333 -0
  48. package/src/elements/renderElement.ts +509 -0
  49. package/src/elements/types.ts +118 -0
  50. package/src/shell/Shell.ts +2950 -0
  51. package/src/shell/Sidebar.ts +1352 -0
  52. package/src/storage/AssetStore.ts +86 -0
  53. package/src/storage/BoardSerializer.ts +114 -0
  54. package/src/storage/ImportExportService.ts +153 -0
  55. package/src/storage/IndexedDbStore.ts +92 -0
  56. package/src/storage/LocalBoardStore.ts +104 -0
  57. package/src/styles.css +3257 -0
  58. package/src/templates/helpers.ts +124 -0
  59. package/src/templates/index.ts +65 -0
  60. package/src/templates/journeyMapTemplate.ts +52 -0
  61. package/src/templates/opportunityTreeTemplate.ts +45 -0
  62. package/src/templates/personaTemplate.ts +41 -0
  63. package/src/templates/prioritizationMatrixTemplate.ts +44 -0
  64. package/src/templates/requirementsFlowTemplate.ts +45 -0
  65. package/src/templates/screenshotReviewTemplate.ts +52 -0
  66. package/src/templates/systemDiagramTemplate.ts +41 -0
  67. package/src/testing/StressTestGenerator.ts +134 -0
  68. package/src/testing/StressTestPanel.ts +64 -0
  69. package/src/tools/ArrowTool.ts +87 -0
  70. package/src/tools/ImageTool.ts +39 -0
  71. package/src/tools/PanTool.ts +34 -0
  72. package/src/tools/SelectTool.ts +377 -0
  73. package/src/tools/ShapeTool.ts +106 -0
  74. package/src/tools/StickyTool.ts +69 -0
  75. package/src/tools/TaskTool.ts +52 -0
  76. package/src/tools/TextTool.ts +45 -0
  77. package/src/tools/ToolContext.ts +44 -0
  78. package/src/tools/ToolController.ts +178 -0
  79. package/src/ui/Modal.ts +58 -0
  80. package/src/ui/PerformanceOverlay.ts +75 -0
  81. package/src/ui/StorageManager.ts +94 -0
  82. package/src/ui/TemplatePicker.ts +67 -0
  83. package/src/ui/TextEditorOverlay.ts +137 -0
  84. package/src/ui/Toolbar.ts +151 -0
  85. package/src/ui/TopControls.ts +137 -0
  86. package/src/ui/icons.ts +50 -0
  87. package/tsconfig.json +19 -0
  88. package/vite.config.ts +694 -0
package/vite.config.ts ADDED
@@ -0,0 +1,694 @@
1
+ // @ts-nocheck — runs under esbuild/Node at dev time; not part of the `tsc` (src-only) build.
2
+ import { defineConfig, type Plugin, type Connect } from "vite";
3
+ import { promises as fs } from "node:fs";
4
+ import * as path from "node:path";
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ import type { IncomingMessage, ServerResponse } from "node:http";
8
+
9
+ const WORKSPACE_DIR = ".workspace";
10
+ const WORKSPACE_FILE = "workspace.json";
11
+ const IGNORE_DIRS = new Set([
12
+ ".git",
13
+ "node_modules",
14
+ "dist",
15
+ "build",
16
+ "coverage",
17
+ ".next",
18
+ "out",
19
+ ]);
20
+
21
+ const MIME: Record<string, string> = {
22
+ ".png": "image/png",
23
+ ".jpg": "image/jpeg",
24
+ ".jpeg": "image/jpeg",
25
+ ".webp": "image/webp",
26
+ ".gif": "image/gif",
27
+ ".svg": "image/svg+xml",
28
+ ".md": "text/markdown; charset=utf-8",
29
+ ".json": "application/json; charset=utf-8",
30
+ };
31
+
32
+ const execFileAsync = promisify(execFile);
33
+
34
+ type WorkspaceManifest = {
35
+ version: 1;
36
+ projects: WorkspaceProjectRecord[];
37
+ };
38
+
39
+ type WorkspaceProjectRecord = {
40
+ id: string;
41
+ label: string;
42
+ rootPath: string;
43
+ };
44
+
45
+ type WorkspaceProject = WorkspaceProjectRecord & {
46
+ docsRoots: DocsRoot[];
47
+ };
48
+
49
+ type DocsRoot = {
50
+ id: string;
51
+ projectId: string;
52
+ projectLabel: string;
53
+ absPath: string;
54
+ relativePath: string;
55
+ displayPath: string;
56
+ };
57
+
58
+ type WorkspaceResponse = {
59
+ projects: Array<{
60
+ id: string;
61
+ label: string;
62
+ docsRoots: Array<{
63
+ id: string;
64
+ projectId: string;
65
+ projectLabel: string;
66
+ relativePath: string;
67
+ displayPath: string;
68
+ }>;
69
+ }>;
70
+ };
71
+
72
+ type TreeNode =
73
+ | { type: "file"; name: string; path: string }
74
+ | { type: "dir"; name: string; path: string; children: TreeNode[] };
75
+
76
+ function contentApiPlugin(): Plugin {
77
+ const cwd = process.cwd();
78
+ const workspaceDir = path.resolve(cwd, WORKSPACE_DIR);
79
+ const workspaceFile = path.join(workspaceDir, WORKSPACE_FILE);
80
+
81
+ function send(res: ServerResponse, status: number, body: unknown): void {
82
+ res.statusCode = status;
83
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
84
+ res.end(JSON.stringify(body));
85
+ }
86
+
87
+ function sendText(res: ServerResponse, status: number, body: string, type: string): void {
88
+ res.statusCode = status;
89
+ res.setHeader("Content-Type", type);
90
+ res.end(body);
91
+ }
92
+
93
+ function readBody(req: IncomingMessage): Promise<string> {
94
+ return new Promise((resolve, reject) => {
95
+ let data = "";
96
+ req.on("data", (chunk) => {
97
+ data += chunk;
98
+ if (data.length > 64 * 1024 * 1024) {
99
+ reject(new Error("Payload too large"));
100
+ req.destroy();
101
+ }
102
+ });
103
+ req.on("end", () => resolve(data));
104
+ req.on("error", reject);
105
+ });
106
+ }
107
+
108
+ function hashPath(value: string): string {
109
+ let hash = 2166136261;
110
+ for (let i = 0; i < value.length; i++) {
111
+ hash ^= value.charCodeAt(i);
112
+ hash = Math.imul(hash, 16777619);
113
+ }
114
+ return (hash >>> 0).toString(36);
115
+ }
116
+
117
+ async function ensureWorkspaceDir(): Promise<void> {
118
+ await fs.mkdir(workspaceDir, { recursive: true });
119
+ }
120
+
121
+ async function loadManifest(): Promise<WorkspaceManifest> {
122
+ try {
123
+ const raw = await fs.readFile(workspaceFile, "utf-8");
124
+ const parsed = JSON.parse(raw) as Partial<WorkspaceManifest>;
125
+ const projects = Array.isArray(parsed.projects)
126
+ ? parsed.projects
127
+ .filter((item): item is WorkspaceProjectRecord => {
128
+ return !!item && typeof item.id === "string" && typeof item.rootPath === "string";
129
+ })
130
+ .map((item) => ({
131
+ id: item.id,
132
+ label: typeof item.label === "string" && item.label.trim() ? item.label.trim() : path.basename(item.rootPath),
133
+ rootPath: path.resolve(item.rootPath),
134
+ }))
135
+ : [];
136
+ return { version: 1, projects };
137
+ } catch {
138
+ return { version: 1, projects: [] };
139
+ }
140
+ }
141
+
142
+ async function saveManifest(manifest: WorkspaceManifest): Promise<void> {
143
+ await ensureWorkspaceDir();
144
+ await fs.writeFile(workspaceFile, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
145
+ }
146
+
147
+ async function dirExists(absPath: string): Promise<boolean> {
148
+ try {
149
+ return (await fs.stat(absPath)).isDirectory();
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ async function loadProjects(): Promise<WorkspaceProjectRecord[]> {
156
+ const manifest = await loadManifest();
157
+ return manifest.projects.filter((item, index, list) => {
158
+ return list.findIndex((other) => other.rootPath === item.rootPath) === index;
159
+ });
160
+ }
161
+
162
+ async function openProjectPicker(): Promise<string | null> {
163
+ if (process.platform === "darwin") {
164
+ try {
165
+ const { stdout } = await execFileAsync("osascript", [
166
+ "-e",
167
+ 'set chosenFolder to choose folder with prompt "Select a project root to add to ViteBoard"',
168
+ "-e",
169
+ 'POSIX path of chosenFolder',
170
+ ]);
171
+ const picked = stdout.trim();
172
+ return picked || null;
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+
178
+ if (process.platform === "win32") {
179
+ const script = [
180
+ "Add-Type -AssemblyName System.Windows.Forms",
181
+ '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
182
+ '$dialog.Description = "Select a project root to add to ViteBoard"',
183
+ '$dialog.ShowNewFolderButton = $false',
184
+ 'if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {',
185
+ ' [Console]::Out.Write($dialog.SelectedPath)',
186
+ '}',
187
+ ].join("; ");
188
+ try {
189
+ const { stdout } = await execFileAsync("powershell", [
190
+ "-NoProfile",
191
+ "-STA",
192
+ "-Command",
193
+ script,
194
+ ]);
195
+ const picked = stdout.trim();
196
+ return picked || null;
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
202
+ const linuxPickers: Array<[string, string[]]> = [
203
+ ["zenity", ["--file-selection", "--directory", "--title=Select a project root to add to ViteBoard"]],
204
+ ["kdialog", ["--getexistingdirectory", ".", "Select a project root to add to ViteBoard"]],
205
+ ];
206
+ let sawInstalledPicker = false;
207
+ for (const [command, args] of linuxPickers) {
208
+ try {
209
+ await execFileAsync("which", [command]);
210
+ sawInstalledPicker = true;
211
+ const { stdout } = await execFileAsync(command, args);
212
+ const picked = stdout.trim();
213
+ if (picked) return picked;
214
+ } catch {
215
+ if (sawInstalledPicker) return null;
216
+ }
217
+ }
218
+ throw new Error("No supported folder picker found. Install zenity or kdialog.");
219
+ }
220
+
221
+ function isIgnoredDir(name: string): boolean {
222
+ return name.startsWith(".") || IGNORE_DIRS.has(name);
223
+ }
224
+
225
+ async function discoverDocsRoots(project: WorkspaceProjectRecord): Promise<DocsRoot[]> {
226
+ const roots: DocsRoot[] = [];
227
+ const projectRoot = path.resolve(project.rootPath);
228
+
229
+ async function walk(absDir: string): Promise<void> {
230
+ let entries: import("node:fs").Dirent[];
231
+ try {
232
+ entries = await fs.readdir(absDir, { withFileTypes: true });
233
+ } catch {
234
+ return;
235
+ }
236
+
237
+ for (const entry of entries) {
238
+ if (!entry.isDirectory()) continue;
239
+ if (isIgnoredDir(entry.name)) continue;
240
+ const child = path.join(absDir, entry.name);
241
+ if (entry.name === "docs") {
242
+ const relativePath = path.relative(projectRoot, child).replace(/\\/g, "/") || "docs";
243
+ roots.push({
244
+ id: `root-${hashPath(child)}`,
245
+ projectId: project.id,
246
+ projectLabel: project.label,
247
+ absPath: child,
248
+ relativePath,
249
+ displayPath: relativePath,
250
+ });
251
+ }
252
+ await walk(child);
253
+ }
254
+ }
255
+
256
+ if (await dirExists(projectRoot)) {
257
+ if (path.basename(projectRoot) === "docs") {
258
+ roots.push({
259
+ id: `root-${hashPath(projectRoot)}`,
260
+ projectId: project.id,
261
+ projectLabel: project.label,
262
+ absPath: projectRoot,
263
+ relativePath: "docs",
264
+ displayPath: "docs",
265
+ });
266
+ }
267
+ await walk(projectRoot);
268
+ }
269
+
270
+ roots.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
271
+ return roots.filter((root, index, list) => list.findIndex((other) => other.absPath === root.absPath) === index);
272
+ }
273
+
274
+ async function getWorkspace(): Promise<WorkspaceProject[]> {
275
+ const projects = await loadProjects();
276
+ const out: WorkspaceProject[] = [];
277
+
278
+ for (const project of projects) {
279
+ if (!(await dirExists(project.rootPath))) continue;
280
+ out.push({
281
+ ...project,
282
+ docsRoots: await discoverDocsRoots(project),
283
+ });
284
+ }
285
+ return out;
286
+ }
287
+
288
+ function toWorkspaceResponse(projects: WorkspaceProject[]): WorkspaceResponse {
289
+ return {
290
+ projects: projects.map((project) => ({
291
+ id: project.id,
292
+ label: project.label,
293
+ docsRoots: project.docsRoots.map((root) => ({
294
+ id: root.id,
295
+ projectId: root.projectId,
296
+ projectLabel: root.projectLabel,
297
+ relativePath: root.relativePath,
298
+ displayPath: root.displayPath,
299
+ })),
300
+ })),
301
+ };
302
+ }
303
+
304
+ async function resolveRoot(rootId: string): Promise<{ root: DocsRoot; nestedRoots: Set<string> } | null> {
305
+ const projects = await getWorkspace();
306
+ for (const project of projects) {
307
+ const root = project.docsRoots.find((item) => item.id === rootId);
308
+ if (!root) continue;
309
+ const nestedRoots = new Set(
310
+ project.docsRoots
311
+ .filter((item) => item.id !== root.id && item.absPath.startsWith(root.absPath + path.sep))
312
+ .map((item) => item.absPath)
313
+ );
314
+ return { root, nestedRoots };
315
+ }
316
+ return null;
317
+ }
318
+
319
+ function safeResolve(root: string, rel: string): string | null {
320
+ const normalized = path.normalize(String(rel ?? "").replace(/^[/\\]+/, "")).replace(/\\/g, "/");
321
+ const abs = path.resolve(root, normalized);
322
+ if (abs !== root && !abs.startsWith(root + path.sep)) return null;
323
+ return abs;
324
+ }
325
+
326
+ async function walkDocsRoot(dir: string, base: string, nestedRoots: Set<string>): Promise<TreeNode[]> {
327
+ let entries: import("node:fs").Dirent[];
328
+ try {
329
+ entries = await fs.readdir(dir, { withFileTypes: true });
330
+ } catch {
331
+ return [];
332
+ }
333
+ const nodes: TreeNode[] = [];
334
+ for (const entry of entries) {
335
+ if (entry.name.startsWith(".")) continue;
336
+ const absEntry = path.join(dir, entry.name);
337
+ if (entry.isDirectory()) {
338
+ if (nestedRoots.has(absEntry)) continue;
339
+ const rel = base ? `${base}/${entry.name}` : entry.name;
340
+ nodes.push({
341
+ type: "dir",
342
+ name: entry.name,
343
+ path: rel,
344
+ children: await walkDocsRoot(absEntry, rel, nestedRoots),
345
+ });
346
+ } else {
347
+ nodes.push({
348
+ type: "file",
349
+ name: entry.name,
350
+ path: base ? `${base}/${entry.name}` : entry.name,
351
+ });
352
+ }
353
+ }
354
+ nodes.sort((a, b) => {
355
+ if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
356
+ return a.name.localeCompare(b.name);
357
+ });
358
+ return nodes;
359
+ }
360
+
361
+ /** Walk full project directory tree — returns only directories (no files). */
362
+ async function walkProjectTree(dir: string, base: string): Promise<TreeNode[]> {
363
+ let entries: import("node:fs").Dirent[];
364
+ try {
365
+ entries = await fs.readdir(dir, { withFileTypes: true });
366
+ } catch {
367
+ return [];
368
+ }
369
+ const nodes: TreeNode[] = [];
370
+ for (const entry of entries) {
371
+ if (!entry.isDirectory()) continue;
372
+ if (entry.name.startsWith(".")) continue;
373
+ if (isIgnoredDir(entry.name)) continue;
374
+ const rel = base ? `${base}/${entry.name}` : entry.name;
375
+ nodes.push({
376
+ type: "dir",
377
+ name: entry.name,
378
+ path: rel,
379
+ children: await walkProjectTree(path.join(dir, entry.name), rel),
380
+ });
381
+ }
382
+ nodes.sort((a, b) => a.name.localeCompare(b.name));
383
+ return nodes;
384
+ }
385
+
386
+ const handler: Connect.NextHandleFunction = (req, res, next) => {
387
+ const url = req.url ?? "";
388
+ if (!url.startsWith("/__api/")) return next();
389
+
390
+ void (async () => {
391
+ try {
392
+ const parsed = new URL(url, "http://localhost");
393
+ const route = parsed.pathname;
394
+ const method = (req.method ?? "GET").toUpperCase();
395
+
396
+ if (route === "/__api/workspace" && method === "GET") {
397
+ return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
398
+ }
399
+
400
+ if (route === "/__api/workspace/project" && method === "POST") {
401
+ const { rootPath, label } = JSON.parse(await readBody(req));
402
+ const absRoot = path.resolve(String(rootPath ?? "").trim());
403
+ if (!absRoot || !(await dirExists(absRoot))) {
404
+ return send(res, 400, { error: "Project root not found" });
405
+ }
406
+ const manifest = await loadManifest();
407
+ const project: WorkspaceProjectRecord = {
408
+ id: `project-${hashPath(absRoot)}`,
409
+ label: String(label ?? "").trim() || path.basename(absRoot) || absRoot,
410
+ rootPath: absRoot,
411
+ };
412
+ const existingIndex = manifest.projects.findIndex((item) => item.rootPath === absRoot || item.id === project.id);
413
+ if (existingIndex === -1) manifest.projects.push(project);
414
+ else manifest.projects.splice(existingIndex, 1, project);
415
+ await saveManifest(manifest);
416
+ return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
417
+ }
418
+
419
+ if (route === "/__api/workspace/pick-project" && method === "POST") {
420
+ const picked = await openProjectPicker();
421
+ if (!picked) return send(res, 200, { cancelled: true, workspace: await toWorkspaceResponse(await getWorkspace()) });
422
+ const absRoot = path.resolve(picked);
423
+ if (!(await dirExists(absRoot))) return send(res, 400, { error: "Project root not found" });
424
+ const manifest = await loadManifest();
425
+ const project: WorkspaceProjectRecord = {
426
+ id: `project-${hashPath(absRoot)}`,
427
+ label: path.basename(absRoot) || absRoot,
428
+ rootPath: absRoot,
429
+ };
430
+ const existingIndex = manifest.projects.findIndex((item) => item.rootPath === absRoot || item.id === project.id);
431
+ if (existingIndex === -1) manifest.projects.push(project);
432
+ else manifest.projects.splice(existingIndex, 1, project);
433
+ await saveManifest(manifest);
434
+ return send(res, 200, { cancelled: false, workspace: await toWorkspaceResponse(await getWorkspace()), addedProjectId: project.id });
435
+ }
436
+
437
+ if (route === "/__api/workspace/project/docs-root" && method === "POST") {
438
+ const { projectId } = JSON.parse(await readBody(req));
439
+ const manifest = await loadManifest();
440
+ const project = manifest.projects.find((item) => item.id === String(projectId ?? ""));
441
+ if (!project) return send(res, 404, { error: "Project not found" });
442
+ const docsPath = path.join(project.rootPath, "docs");
443
+ await fs.mkdir(docsPath, { recursive: true });
444
+ const workspace = await getWorkspace();
445
+ const refreshedProject = workspace.find((item) => item.id === project.id);
446
+ const root = refreshedProject?.docsRoots.find((item) => item.absPath === docsPath)
447
+ ?? refreshedProject?.docsRoots[0];
448
+ if (!root) return send(res, 500, { error: "Could not create docs root" });
449
+ return send(res, 200, {
450
+ root: {
451
+ id: root.id,
452
+ projectId: root.projectId,
453
+ projectLabel: root.projectLabel,
454
+ relativePath: root.relativePath,
455
+ displayPath: root.displayPath,
456
+ },
457
+ workspace: await toWorkspaceResponse(workspace),
458
+ });
459
+ }
460
+
461
+ if (route === "/__api/workspace/project/docs-area" && method === "POST") {
462
+ const { projectId, folderName } = JSON.parse(await readBody(req));
463
+ const manifest = await loadManifest();
464
+ const project = manifest.projects.find((item) => item.id === String(projectId ?? ""));
465
+ if (!project) return send(res, 404, { error: "Project not found" });
466
+ const folderPath = path.join(project.rootPath, String(folderName ?? ""));
467
+ const docsPath = path.join(folderPath, "docs");
468
+ await fs.mkdir(docsPath, { recursive: true });
469
+ const workspace = await getWorkspace();
470
+ const refreshedProject = workspace.find((item) => item.id === project.id);
471
+ const root = refreshedProject?.docsRoots.find((item) => item.absPath === docsPath);
472
+ if (!root) return send(res, 500, { error: "Could not create docs area" });
473
+ return send(res, 200, {
474
+ root: {
475
+ id: root.id,
476
+ projectId: root.projectId,
477
+ projectLabel: root.projectLabel,
478
+ relativePath: root.relativePath,
479
+ displayPath: root.displayPath,
480
+ },
481
+ workspace: await toWorkspaceResponse(workspace),
482
+ });
483
+ }
484
+
485
+ if (route === "/__api/workspace/project/create-docs-folder" && method === "POST") {
486
+ const { projectId, parentPath } = JSON.parse(await readBody(req));
487
+ const manifest = await loadManifest();
488
+ const project = manifest.projects.find((item) => item.id === String(projectId ?? ""));
489
+ if (!project) return send(res, 404, { error: "Project not found" });
490
+ const docsPath = path.join(project.rootPath, String(parentPath ?? ""), "docs");
491
+ await fs.mkdir(docsPath, { recursive: true });
492
+ return send(res, 200, { ok: true, workspace: await toWorkspaceResponse(await getWorkspace()) });
493
+ }
494
+
495
+ if (route === "/__api/workspace/project" && method === "DELETE") {
496
+ const id = parsed.searchParams.get("id") ?? "";
497
+ const manifest = await loadManifest();
498
+ manifest.projects = manifest.projects.filter((item) => item.id !== id);
499
+ await saveManifest(manifest);
500
+ return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
501
+ }
502
+
503
+ if (route === "/__api/tree" && method === "GET") {
504
+ const rootId = parsed.searchParams.get("rootId") ?? "";
505
+ const resolved = await resolveRoot(rootId);
506
+ if (!resolved) return send(res, 404, { error: "Docs root not found" });
507
+ const tree = await walkDocsRoot(resolved.root.absPath, "", resolved.nestedRoots);
508
+ return send(res, 200, { tree });
509
+ }
510
+
511
+ if (route === "/__api/project-tree" && method === "GET") {
512
+ const projectId = parsed.searchParams.get("projectId") ?? "";
513
+ const workspace = await getWorkspace();
514
+ const project = workspace.find(p => p.id === projectId);
515
+ if (!project) return send(res, 404, { error: "Project not found" });
516
+ const tree = await walkProjectTree(project.rootPath, "");
517
+ return send(res, 200, { tree });
518
+ }
519
+
520
+ if (route === "/__api/file" && method === "GET") {
521
+ const rootId = parsed.searchParams.get("rootId") ?? "";
522
+ const rel = parsed.searchParams.get("path") ?? "";
523
+ const resolved = await resolveRoot(rootId);
524
+ if (!resolved) return send(res, 404, { error: "Docs root not found" });
525
+ const abs = safeResolve(resolved.root.absPath, rel);
526
+ if (!abs) return send(res, 400, { error: "Invalid path" });
527
+ try {
528
+ const content = await fs.readFile(abs, "utf-8");
529
+ return send(res, 200, { path: rel, content });
530
+ } catch {
531
+ return send(res, 404, { error: "Not found" });
532
+ }
533
+ }
534
+
535
+ if (route === "/__api/file" && method === "PUT") {
536
+ const { rootId, path: rel, content } = JSON.parse(await readBody(req));
537
+ const resolved = await resolveRoot(String(rootId ?? ""));
538
+ if (!resolved) return send(res, 404, { error: "Docs root not found" });
539
+ const abs = safeResolve(resolved.root.absPath, String(rel));
540
+ if (!abs) return send(res, 400, { error: "Invalid path" });
541
+ await fs.mkdir(path.dirname(abs), { recursive: true });
542
+ await fs.writeFile(abs, String(content ?? ""), "utf-8");
543
+ return send(res, 200, { ok: true, path: rel });
544
+ }
545
+
546
+ if (route === "/__api/directory" && method === "PUT") {
547
+ const { rootId, path: rel } = JSON.parse(await readBody(req));
548
+ const resolved = await resolveRoot(String(rootId ?? ""));
549
+ if (!resolved) return send(res, 404, { error: "Docs root not found" });
550
+ const abs = safeResolve(resolved.root.absPath, String(rel));
551
+ if (!abs) return send(res, 400, { error: "Invalid path" });
552
+ await fs.mkdir(abs, { recursive: true });
553
+ return send(res, 200, { ok: true, path: rel });
554
+ }
555
+
556
+ if (route === "/__api/file" && method === "DELETE") {
557
+ const rootId = parsed.searchParams.get("rootId") ?? "";
558
+ const rel = parsed.searchParams.get("path") ?? "";
559
+ const resolved = await resolveRoot(rootId);
560
+ if (!resolved) return send(res, 404, { error: "Docs root not found" });
561
+ const abs = safeResolve(resolved.root.absPath, rel);
562
+ if (!abs) return send(res, 400, { error: "Invalid path" });
563
+ try {
564
+ await fs.unlink(abs);
565
+ } catch (err) {
566
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
567
+ }
568
+ return send(res, 200, { ok: true });
569
+ }
570
+
571
+ if (route === "/__api/file" && method === "PATCH") {
572
+ // Rename/move: { rootId, oldPath, newPath }
573
+ const { rootId, oldPath, newPath, ignoreMissing } = JSON.parse(await readBody(req));
574
+ const resolved = await resolveRoot(String(rootId ?? ""));
575
+ if (!resolved) return send(res, 404, { error: "Docs root not found" });
576
+ const absOld = safeResolve(resolved.root.absPath, String(oldPath));
577
+ const absNew = safeResolve(resolved.root.absPath, String(newPath));
578
+ if (!absOld || !absNew) return send(res, 400, { error: "Invalid path" });
579
+ await fs.mkdir(path.dirname(absNew), { recursive: true });
580
+ try {
581
+ await fs.rename(absOld, absNew);
582
+ } catch (err) {
583
+ if (ignoreMissing && (err as NodeJS.ErrnoException).code === "ENOENT") {
584
+ return send(res, 200, { ok: true, path: newPath, skipped: true });
585
+ }
586
+ throw err;
587
+ }
588
+ return send(res, 200, { ok: true, path: newPath });
589
+ }
590
+
591
+ if (route === "/__api/board" && method === "GET") {
592
+ const rootId = parsed.searchParams.get("rootId") ?? "";
593
+ const id = parsed.searchParams.get("id") ?? "";
594
+ const resolved = await resolveRoot(rootId);
595
+ if (!resolved) return send(res, 404, { error: "Docs root not found" });
596
+ const abs = safeResolve(resolved.root.absPath, `${id}.board.json`);
597
+ if (!abs) return send(res, 400, { error: "Invalid id" });
598
+ try {
599
+ const content = await fs.readFile(abs, "utf-8");
600
+ return send(res, 200, { id, doc: JSON.parse(content) });
601
+ } catch {
602
+ return send(res, 404, { error: "Not found" });
603
+ }
604
+ }
605
+
606
+ if (route === "/__api/board" && method === "PUT") {
607
+ const { rootId, id, doc } = JSON.parse(await readBody(req));
608
+ const resolved = await resolveRoot(String(rootId ?? ""));
609
+ if (!resolved) return send(res, 404, { error: "Docs root not found" });
610
+ const abs = safeResolve(resolved.root.absPath, `${String(id)}.board.json`);
611
+ if (!abs) return send(res, 400, { error: "Invalid id" });
612
+ await fs.mkdir(path.dirname(abs), { recursive: true });
613
+ await fs.writeFile(abs, JSON.stringify(doc, null, 2) + "\n", "utf-8");
614
+ return send(res, 200, { ok: true, id });
615
+ }
616
+
617
+ if (route === "/__api/board" && method === "DELETE") {
618
+ const rootId = parsed.searchParams.get("rootId") ?? "";
619
+ const id = parsed.searchParams.get("id") ?? "";
620
+ const resolved = await resolveRoot(rootId);
621
+ if (!resolved) return send(res, 404, { error: "Docs root not found" });
622
+ const abs = safeResolve(resolved.root.absPath, `${id}.board.json`);
623
+ if (!abs) return send(res, 400, { error: "Invalid id" });
624
+ try {
625
+ await fs.unlink(abs);
626
+ } catch (err) {
627
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
628
+ }
629
+ return send(res, 200, { ok: true, id });
630
+ }
631
+
632
+ if (route === "/__api/asset" && method === "GET") {
633
+ const rootId = parsed.searchParams.get("rootId") ?? "";
634
+ const rel = parsed.searchParams.get("path") ?? "";
635
+ const resolved = await resolveRoot(rootId);
636
+ if (!resolved) return send(res, 404, { error: "Docs root not found" });
637
+ const abs = safeResolve(resolved.root.absPath, rel);
638
+ if (!abs) return send(res, 400, { error: "Invalid path" });
639
+ try {
640
+ const buf = await fs.readFile(abs);
641
+ res.statusCode = 200;
642
+ res.setHeader("Content-Type", MIME[path.extname(abs).toLowerCase()] ?? "application/octet-stream");
643
+ res.setHeader("Cache-Control", "no-cache");
644
+ return res.end(buf);
645
+ } catch {
646
+ return send(res, 404, { error: "Not found" });
647
+ }
648
+ }
649
+
650
+ if (route === "/__api/asset" && method === "POST") {
651
+ const { rootId, name, base64 } = JSON.parse(await readBody(req));
652
+ const resolved = await resolveRoot(String(rootId ?? ""));
653
+ if (!resolved) return send(res, 404, { error: "Docs root not found" });
654
+ const ext = (path.extname(String(name ?? "")) || ".png").toLowerCase();
655
+ const safeBase = String(name ?? "image")
656
+ .replace(/\.[^.]*$/, "")
657
+ .replace(/[^a-z0-9-_]/gi, "-")
658
+ .slice(0, 40);
659
+ const unique = `${safeBase || "image"}-${Date.now().toString(36)}${ext}`;
660
+ const rel = `assets/${unique}`;
661
+ const abs = safeResolve(resolved.root.absPath, rel);
662
+ if (!abs) return send(res, 400, { error: "Invalid path" });
663
+ await fs.mkdir(path.dirname(abs), { recursive: true });
664
+ await fs.writeFile(abs, Buffer.from(String(base64), "base64"));
665
+ return send(res, 200, { ok: true, path: rel });
666
+ }
667
+
668
+ if (route === "/__api/workspace-file" && method === "GET") {
669
+ await ensureWorkspaceDir();
670
+ const manifest = await loadManifest();
671
+ return sendText(res, 200, JSON.stringify(manifest, null, 2) + "\n", "application/json; charset=utf-8");
672
+ }
673
+
674
+ return send(res, 404, { error: "Unknown endpoint" });
675
+ } catch (err) {
676
+ return send(res, 500, { error: (err as Error).message });
677
+ }
678
+ })();
679
+ };
680
+
681
+ return {
682
+ name: "viteboard-content-api",
683
+ configureServer(server) {
684
+ server.middlewares.use(handler);
685
+ },
686
+ configurePreviewServer(server) {
687
+ server.middlewares.use(handler);
688
+ },
689
+ };
690
+ }
691
+
692
+ export default defineConfig({
693
+ plugins: [contentApiPlugin()],
694
+ });