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.
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/bin/viteboard.mjs +59 -0
- package/content/docs/assets/image-mqjpqlyw.png +0 -0
- package/content/docs/assets/image-mqjsmenr.png +0 -0
- package/content/docs/boards/product-roadmap.board.json +2130 -0
- package/content/docs/capabilities.md +89 -0
- package/content/docs/docs.md +84 -0
- package/content/docs/guide/boards-in-markdown.md +48 -0
- package/content/docs/guide/boards.md +92 -0
- package/content/docs/guide/getting-started.md +71 -0
- package/content/docs/guide/test.md +6 -0
- package/content/docs/index.md +47 -0
- package/content/docs/product-roadmap.board.json +2130 -0
- package/content/docs/test.md +219 -0
- package/content/docs/tetst-dir/test-board.board.json +15 -0
- package/content/docs/tetst-dir/test-test.md +1 -0
- package/content/docs/workspace.md +73 -0
- package/dist/assets/index-CEpxLM2o.css +1 -0
- package/dist/assets/index-D4xvJdEQ.js +60 -0
- package/dist/index.html +13 -0
- package/index.html +13 -0
- package/package.json +52 -0
- package/src/app/AppController.ts +955 -0
- package/src/app/BoardState.ts +130 -0
- package/src/app/ClipboardService.ts +159 -0
- package/src/app/CommandManager.ts +66 -0
- package/src/app/ElementActions.ts +152 -0
- package/src/app/EmbedRegionSelector.ts +103 -0
- package/src/app/ExportPng.ts +107 -0
- package/src/app/ImageInsertService.ts +141 -0
- package/src/app/KeyboardShortcuts.ts +124 -0
- package/src/app/main.ts +6 -0
- package/src/board/BoardPreview.ts +189 -0
- package/src/board/BoardService.ts +222 -0
- package/src/canvas/CanvasRenderer.ts +253 -0
- package/src/canvas/CanvasSurface.ts +70 -0
- package/src/canvas/HitTester.ts +110 -0
- package/src/canvas/ImageCache.ts +123 -0
- package/src/canvas/PerformanceMonitor.ts +31 -0
- package/src/canvas/RenderScheduler.ts +26 -0
- package/src/canvas/Viewport.ts +77 -0
- package/src/content/ContentApi.ts +258 -0
- package/src/content/Markdown.ts +431 -0
- package/src/content/MarkdownView.ts +70 -0
- package/src/editor/DocEditor.ts +799 -0
- package/src/editor/htmlToMarkdown.ts +333 -0
- package/src/elements/renderElement.ts +509 -0
- package/src/elements/types.ts +118 -0
- package/src/shell/Shell.ts +2950 -0
- package/src/shell/Sidebar.ts +1352 -0
- package/src/storage/AssetStore.ts +86 -0
- package/src/storage/BoardSerializer.ts +114 -0
- package/src/storage/ImportExportService.ts +153 -0
- package/src/storage/IndexedDbStore.ts +92 -0
- package/src/storage/LocalBoardStore.ts +104 -0
- package/src/styles.css +3257 -0
- package/src/templates/helpers.ts +124 -0
- package/src/templates/index.ts +65 -0
- package/src/templates/journeyMapTemplate.ts +52 -0
- package/src/templates/opportunityTreeTemplate.ts +45 -0
- package/src/templates/personaTemplate.ts +41 -0
- package/src/templates/prioritizationMatrixTemplate.ts +44 -0
- package/src/templates/requirementsFlowTemplate.ts +45 -0
- package/src/templates/screenshotReviewTemplate.ts +52 -0
- package/src/templates/systemDiagramTemplate.ts +41 -0
- package/src/testing/StressTestGenerator.ts +134 -0
- package/src/testing/StressTestPanel.ts +64 -0
- package/src/tools/ArrowTool.ts +87 -0
- package/src/tools/ImageTool.ts +39 -0
- package/src/tools/PanTool.ts +34 -0
- package/src/tools/SelectTool.ts +377 -0
- package/src/tools/ShapeTool.ts +106 -0
- package/src/tools/StickyTool.ts +69 -0
- package/src/tools/TaskTool.ts +52 -0
- package/src/tools/TextTool.ts +45 -0
- package/src/tools/ToolContext.ts +44 -0
- package/src/tools/ToolController.ts +178 -0
- package/src/ui/Modal.ts +58 -0
- package/src/ui/PerformanceOverlay.ts +75 -0
- package/src/ui/StorageManager.ts +94 -0
- package/src/ui/TemplatePicker.ts +67 -0
- package/src/ui/TextEditorOverlay.ts +137 -0
- package/src/ui/Toolbar.ts +151 -0
- package/src/ui/TopControls.ts +137 -0
- package/src/ui/icons.ts +50 -0
- package/tsconfig.json +19 -0
- package/vite.config.ts +694 -0
|
@@ -0,0 +1,2950 @@
|
|
|
1
|
+
import { Sidebar, type SidebarCallbacks } from "./Sidebar";
|
|
2
|
+
import {
|
|
3
|
+
ContentApi,
|
|
4
|
+
filesUnder,
|
|
5
|
+
firstMarkdownPath,
|
|
6
|
+
relativePathBetween,
|
|
7
|
+
type DocsRootSummary,
|
|
8
|
+
type TreeNode,
|
|
9
|
+
type WorkspaceState,
|
|
10
|
+
} from "../content/ContentApi";
|
|
11
|
+
import { renderMarkdownInto, currentTheme } from "../content/MarkdownView";
|
|
12
|
+
import { renderMarkdown } from "../content/Markdown";
|
|
13
|
+
import {
|
|
14
|
+
BoardService,
|
|
15
|
+
boardIdFromFilePath,
|
|
16
|
+
resolveBoardIdCandidatesFromDoc,
|
|
17
|
+
resolveBoardIdFromDoc,
|
|
18
|
+
type BoardSummary,
|
|
19
|
+
} from "../board/BoardService";
|
|
20
|
+
import { AppController } from "../app/AppController";
|
|
21
|
+
import { DocEditor } from "../editor/DocEditor";
|
|
22
|
+
import { openModal, showToast } from "../ui/Modal";
|
|
23
|
+
import { ICONS } from "../ui/icons";
|
|
24
|
+
|
|
25
|
+
const THEME_KEY = "docs:theme";
|
|
26
|
+
const SIDEBAR_KEY = "docs:sidebar-collapsed";
|
|
27
|
+
|
|
28
|
+
type SearchEntry = {
|
|
29
|
+
title: string;
|
|
30
|
+
path: string;
|
|
31
|
+
href: string;
|
|
32
|
+
section: string;
|
|
33
|
+
summary: string;
|
|
34
|
+
body: string;
|
|
35
|
+
chunks: SearchChunk[];
|
|
36
|
+
rootId?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type EditableFileRef = {
|
|
40
|
+
rootId: string;
|
|
41
|
+
path: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type BoardRegion = [number, number, number, number];
|
|
45
|
+
type SearchChunk = {
|
|
46
|
+
heading: string;
|
|
47
|
+
text: string;
|
|
48
|
+
normalized: string;
|
|
49
|
+
kind: "heading" | "text" | "code" | "board";
|
|
50
|
+
};
|
|
51
|
+
type SearchResult = {
|
|
52
|
+
entry: SearchEntry;
|
|
53
|
+
score: number;
|
|
54
|
+
preview: string;
|
|
55
|
+
heading: string;
|
|
56
|
+
};
|
|
57
|
+
type CapabilityCartItem = {
|
|
58
|
+
path: string;
|
|
59
|
+
displayPath: string;
|
|
60
|
+
title: string;
|
|
61
|
+
summary: string;
|
|
62
|
+
tags: string[];
|
|
63
|
+
md: string;
|
|
64
|
+
};
|
|
65
|
+
type DesignToken = {
|
|
66
|
+
group: "Brand Colors" | "Neutral Colors" | "Semantic Colors";
|
|
67
|
+
name: string;
|
|
68
|
+
variable: string;
|
|
69
|
+
value: string;
|
|
70
|
+
description: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const DESIGN_TOKENS: DesignToken[] = [
|
|
74
|
+
{ group: "Brand Colors", name: "100", variable: "--brand-100", value: "#FFFFFF", description: "lightest brand tone" },
|
|
75
|
+
{ group: "Brand Colors", name: "200", variable: "--brand-200", value: "#4DABF7", description: "light azure accent" },
|
|
76
|
+
{ group: "Brand Colors", name: "300", variable: "--brand-300", value: "#0078D4", description: "primary azure blue" },
|
|
77
|
+
{ group: "Brand Colors", name: "400", variable: "--brand-400", value: "#495057", description: "deep slate gray" },
|
|
78
|
+
{ group: "Brand Colors", name: "500", variable: "--brand-500", value: "#0078D4", description: "primary azure blue" },
|
|
79
|
+
{ group: "Brand Colors", name: "600", variable: "--brand-600", value: "#005A9E", description: "darker azure blue" },
|
|
80
|
+
{ group: "Neutral Colors", name: "100", variable: "--neutral-100", value: "#F3F2F1", description: "page background" },
|
|
81
|
+
{ group: "Neutral Colors", name: "200", variable: "--neutral-200", value: "#EAEAEA", description: "subtle surface" },
|
|
82
|
+
{ group: "Neutral Colors", name: "300", variable: "--neutral-300", value: "#D1D1D1", description: "soft border" },
|
|
83
|
+
{ group: "Neutral Colors", name: "400", variable: "--neutral-400", value: "#C8C8C8", description: "medium gray" },
|
|
84
|
+
{ group: "Neutral Colors", name: "500", variable: "--neutral-500", value: "#6E6E6E", description: "neutral mid-tone" },
|
|
85
|
+
{ group: "Neutral Colors", name: "600", variable: "--neutral-600", value: "#7A7A7A", description: "secondary text" },
|
|
86
|
+
{ group: "Neutral Colors", name: "700", variable: "--neutral-700", value: "#495057", description: "deep slate gray" },
|
|
87
|
+
{ group: "Neutral Colors", name: "800", variable: "--neutral-800", value: "#4A4A4A", description: "strong text" },
|
|
88
|
+
{ group: "Neutral Colors", name: "900", variable: "--neutral-900", value: "#0F0F0F", description: "primary text" },
|
|
89
|
+
{ group: "Neutral Colors", name: "000", variable: "--neutral-000", value: "#FFFFFF", description: "white" },
|
|
90
|
+
{ group: "Semantic Colors", name: "Success Main", variable: "--success-main", value: "#28A745", description: "positive actions" },
|
|
91
|
+
{ group: "Semantic Colors", name: "Success Background", variable: "--success-bg", value: "#E6F4EA", description: "positive surface" },
|
|
92
|
+
{ group: "Semantic Colors", name: "Success Text", variable: "--success-text", value: "#1E7E34", description: "positive text" },
|
|
93
|
+
{ group: "Semantic Colors", name: "Warning Main", variable: "--warning-main", value: "#DC2626", description: "warning actions" },
|
|
94
|
+
{ group: "Semantic Colors", name: "Warning Background", variable: "--warning-bg", value: "#FEF2F2", description: "warning surface" },
|
|
95
|
+
{ group: "Semantic Colors", name: "Warning Text", variable: "--warning-text", value: "#FFFFFF", description: "warning text" },
|
|
96
|
+
{ group: "Semantic Colors", name: "Error Main", variable: "--error-main", value: "#CD0000", description: "destructive actions" },
|
|
97
|
+
{ group: "Semantic Colors", name: "Error Background", variable: "--error-bg", value: "#FFE5E5", description: "destructive surface" },
|
|
98
|
+
{ group: "Semantic Colors", name: "Error Text", variable: "--error-text", value: "#B91C1C", description: "destructive text" },
|
|
99
|
+
{ group: "Semantic Colors", name: "Info Main", variable: "--info-main", value: "#1080A6", description: "informational actions" },
|
|
100
|
+
{ group: "Semantic Colors", name: "Info Background", variable: "--info-bg", value: "#E0F7FA", description: "informational surface" },
|
|
101
|
+
{ group: "Semantic Colors", name: "Info Text", variable: "--info-text", value: "#0369A1", description: "informational text" },
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Top-level application frame: a VitePress-style sidebar + content pane that
|
|
106
|
+
* renders docs, the board list, and the capability library, plus a full-screen
|
|
107
|
+
* board editor mode. Routing is hash-based so the app works as static files.
|
|
108
|
+
*/
|
|
109
|
+
export class Shell {
|
|
110
|
+
private root: HTMLElement;
|
|
111
|
+
private layout: HTMLElement;
|
|
112
|
+
private main: HTMLElement;
|
|
113
|
+
private topbar: HTMLElement;
|
|
114
|
+
private contentScroll: HTMLElement;
|
|
115
|
+
private content: HTMLElement;
|
|
116
|
+
private sidebar: Sidebar;
|
|
117
|
+
private searchBtn!: HTMLButtonElement;
|
|
118
|
+
private cartBtn!: HTMLButtonElement;
|
|
119
|
+
private editBtn!: HTMLButtonElement;
|
|
120
|
+
private themeBtn!: HTMLButtonElement;
|
|
121
|
+
private palette?: HTMLElement;
|
|
122
|
+
private paletteInput?: HTMLInputElement;
|
|
123
|
+
private paletteResults?: HTMLElement;
|
|
124
|
+
private cartPopover?: HTMLElement;
|
|
125
|
+
private searchIndex: SearchEntry[] = [];
|
|
126
|
+
private capabilityCart = new Map<string, CapabilityCartItem>();
|
|
127
|
+
private selectedTokens = new Set<string>();
|
|
128
|
+
private currentEditableFile?: EditableFileRef;
|
|
129
|
+
private workspace: WorkspaceState = { projects: [] };
|
|
130
|
+
private treesByRoot = new Map<string, TreeNode[]>();
|
|
131
|
+
private treesByProject = new Map<string, TreeNode[]>();
|
|
132
|
+
|
|
133
|
+
private cleanup?: () => void;
|
|
134
|
+
private editor?: AppController;
|
|
135
|
+
private editorHost?: HTMLElement;
|
|
136
|
+
private editingId?: string;
|
|
137
|
+
private lastNonBoardHash = "#/";
|
|
138
|
+
private lastNonBoardScrollTop = 0;
|
|
139
|
+
private pendingScrollRestore?: number;
|
|
140
|
+
|
|
141
|
+
constructor(root: HTMLElement) {
|
|
142
|
+
this.root = root;
|
|
143
|
+
this.applyInitialTheme();
|
|
144
|
+
|
|
145
|
+
this.layout = el("div", "shell");
|
|
146
|
+
const sidebarCallbacks: SidebarCallbacks = {
|
|
147
|
+
onAddRepo: () => void this.addRepoDocs(),
|
|
148
|
+
onRemoveProject: (projectId, label) => void this.promptRemoveProject(projectId, label),
|
|
149
|
+
onCreateDirectory: (rootId, dirPath) => this.promptNewDirectory(rootId, dirPath),
|
|
150
|
+
onCreateDoc: (rootId, dirPath) => this.promptNewDoc(rootId, dirPath),
|
|
151
|
+
onCreateBoard: (rootId, dirPath) => this.promptNewBoard(rootId, dirPath),
|
|
152
|
+
onCreateCapability: (rootId, dirPath) => this.promptNewCapability(rootId, dirPath),
|
|
153
|
+
onCreateDocsFolder: (projectId, parentPath) => void this.createDocsFolder(projectId, parentPath),
|
|
154
|
+
onCreateProjectDirectory: (projectId) => void this.promptNewProjectDirectory(projectId),
|
|
155
|
+
onCreateProjectDoc: (projectId) => void this.promptNewProjectDoc(projectId),
|
|
156
|
+
onCreateProjectBoard: (projectId) => void this.promptNewProjectBoard(projectId),
|
|
157
|
+
onRenameDirectory: (rootId, dirPath) => void this.promptRenameDirectory(rootId, dirPath),
|
|
158
|
+
onDeleteDirectory: (rootId, dirPath) => void this.promptDeleteDirectory(rootId, dirPath),
|
|
159
|
+
onOpenDoc: (rootId, filePath) => { location.hash = rootDocHash(rootId, filePath); },
|
|
160
|
+
onEditDoc: (rootId, filePath) => this.openDocEditor({ rootId, path: filePath }),
|
|
161
|
+
onRenameDoc: (rootId, filePath) => void this.promptRenameDoc(rootId, filePath),
|
|
162
|
+
onDeleteDoc: (rootId, filePath) => void this.promptDeleteDoc(rootId, filePath),
|
|
163
|
+
onOpenBoard: (rootId, boardId) => { location.hash = rootBoardHash(rootId, boardId); },
|
|
164
|
+
onRenameBoard: (rootId, boardId) => void this.promptRenameBoard(rootId, boardId),
|
|
165
|
+
onDeleteBoard: (rootId, boardId) => void this.promptDeleteBoard(rootId, boardId),
|
|
166
|
+
onMoveDoc: (rootId, filePath, targetDir) => void this.moveDoc(rootId, filePath, targetDir),
|
|
167
|
+
onMoveBoard: (rootId, boardId, targetDir) => void this.moveBoard(rootId, boardId, targetDir),
|
|
168
|
+
onMoveDirectory: (rootId, dirPath, targetDir) => void this.moveDirectory(rootId, dirPath, targetDir),
|
|
169
|
+
};
|
|
170
|
+
this.sidebar = new Sidebar(this.layout, sidebarCallbacks);
|
|
171
|
+
this.main = el("div", "shell-main");
|
|
172
|
+
this.topbar = this.createTopbar();
|
|
173
|
+
|
|
174
|
+
this.contentScroll = el("div", "content-scroll");
|
|
175
|
+
this.content = el("div", "content");
|
|
176
|
+
this.contentScroll.appendChild(this.content);
|
|
177
|
+
this.main.append(this.topbar, this.contentScroll);
|
|
178
|
+
this.layout.appendChild(this.main);
|
|
179
|
+
this.root.appendChild(this.layout);
|
|
180
|
+
|
|
181
|
+
window.addEventListener("hashchange", () => this.route());
|
|
182
|
+
window.addEventListener("keydown", (e) => this.handleGlobalKeydown(e));
|
|
183
|
+
if (localStorage.getItem(SIDEBAR_KEY) === "true") this.setSidebarCollapsed(true);
|
|
184
|
+
void this.refreshContentIndex().then(() => this.route());
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------- routing ----------
|
|
188
|
+
|
|
189
|
+
private route(opts: { preserveScroll?: boolean } = {}): void {
|
|
190
|
+
const scrollTop = opts.preserveScroll ? this.contentScroll.scrollTop : this.pendingScrollRestore;
|
|
191
|
+
this.pendingScrollRestore = undefined;
|
|
192
|
+
const hash = location.hash || "#/";
|
|
193
|
+
this.sidebar.setActive(hash);
|
|
194
|
+
const path = hash.replace(/^#/, "");
|
|
195
|
+
this.setEditableFile(undefined);
|
|
196
|
+
|
|
197
|
+
const boardMatch = path.match(/^\/root\/([^/]+)\/boards\/([^?]+)(?:\?(.*))?$/);
|
|
198
|
+
if (boardMatch) {
|
|
199
|
+
this.lastNonBoardScrollTop = this.contentScroll.scrollTop;
|
|
200
|
+
this.updateTopbarCart(true);
|
|
201
|
+
this.closeCartPopover();
|
|
202
|
+
void this.openEditor(
|
|
203
|
+
decodeURIComponent(boardMatch[1]),
|
|
204
|
+
decodeURIComponent(boardMatch[2]),
|
|
205
|
+
parseBoardRegion(boardMatch[3])
|
|
206
|
+
);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
this.lastNonBoardHash = hash;
|
|
211
|
+
this.exitEditor();
|
|
212
|
+
this.layout.style.display = "";
|
|
213
|
+
this.updateTopbarCart(/\/root\/[^/]+\/boards/.test(path));
|
|
214
|
+
if (/\/root\/[^/]+\/boards/.test(path)) this.closeCartPopover();
|
|
215
|
+
|
|
216
|
+
if (path === "/" || path === "") return void this.renderWorkspaceHome(scrollTop);
|
|
217
|
+
|
|
218
|
+
// All /root/ routes need a valid docs root — check once and handle missing
|
|
219
|
+
const rootRouteMatch = path.match(/^\/root\/([^/]+)\//);
|
|
220
|
+
if (rootRouteMatch) {
|
|
221
|
+
const rootId = decodeURIComponent(rootRouteMatch[1]);
|
|
222
|
+
if (!this.docsRootById(rootId)) {
|
|
223
|
+
return void this.renderMessage(
|
|
224
|
+
"Project not found",
|
|
225
|
+
"This link points to a docs root that isn't in your workspace. Add the project and try again."
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const docMatch = path.match(/^\/root\/([^/]+)\/docs\/(.+)$/);
|
|
231
|
+
if (docMatch) {
|
|
232
|
+
return void this.renderDoc(decodeURIComponent(docMatch[1]), decodeURIComponent(docMatch[2]), scrollTop);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const boardListMatch = path.match(/^\/root\/([^/]+)\/boards$/);
|
|
236
|
+
if (boardListMatch) {
|
|
237
|
+
return void this.renderBoardList(decodeURIComponent(boardListMatch[1]), scrollTop);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const projectBoardListMatch = path.match(/^\/project\/([^/]+)\/boards$/);
|
|
241
|
+
if (projectBoardListMatch) {
|
|
242
|
+
return void this.renderProjectBoardList(decodeURIComponent(projectBoardListMatch[1]), scrollTop);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const capabilitiesOverviewMatch = path.match(/^\/root\/([^/]+)\/capabilities$/);
|
|
246
|
+
if (capabilitiesOverviewMatch) {
|
|
247
|
+
return void this.renderCapabilitiesIndex(decodeURIComponent(capabilitiesOverviewMatch[1]), scrollTop);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const capabilityDocMatch = path.match(/^\/root\/([^/]+)\/capabilities\/(.+)$/);
|
|
251
|
+
if (capabilityDocMatch) {
|
|
252
|
+
return void this.renderCapabilityDoc(
|
|
253
|
+
decodeURIComponent(capabilityDocMatch[1]),
|
|
254
|
+
decodeURIComponent(capabilityDocMatch[2]),
|
|
255
|
+
scrollTop
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const projectWikiMatch = path.match(/^\/project\/([^/]+)$/);
|
|
260
|
+
if (projectWikiMatch) {
|
|
261
|
+
return void this.renderProjectWiki(decodeURIComponent(projectWikiMatch[1]), scrollTop);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const directoryWikiMatch = path.match(/^\/root\/([^/]+)\/directory\/(.+)$/);
|
|
265
|
+
if (directoryWikiMatch) {
|
|
266
|
+
const rootId = decodeURIComponent(directoryWikiMatch[1]);
|
|
267
|
+
const dirPath = decodeURIComponent(directoryWikiMatch[2]);
|
|
268
|
+
const root = this.docsRootById(rootId);
|
|
269
|
+
if (root) {
|
|
270
|
+
// Always pass dirPath through. renderProjectWiki handles all levels.
|
|
271
|
+
return void this.renderProjectWiki(
|
|
272
|
+
root.projectId,
|
|
273
|
+
scrollTop,
|
|
274
|
+
rootId,
|
|
275
|
+
dirPath
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (path === "/tokens") return void this.renderTokenLibrary(scrollTop);
|
|
281
|
+
|
|
282
|
+
this.renderMessage("Not found", `No route for ${path}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------- docs ----------
|
|
286
|
+
|
|
287
|
+
private async renderWorkspaceHome(scrollTop?: number): Promise<void> {
|
|
288
|
+
this.disposeContent();
|
|
289
|
+
this.content.classList.add("content-wide");
|
|
290
|
+
|
|
291
|
+
const page = el("div", "page home-page");
|
|
292
|
+
const layout = el("div", "home-layout");
|
|
293
|
+
|
|
294
|
+
// ════════════════════════════════════════════════════
|
|
295
|
+
// LEFT — feature guide
|
|
296
|
+
// ════════════════════════════════════════════════════
|
|
297
|
+
const guide = el("div", "home-guide");
|
|
298
|
+
|
|
299
|
+
const heroTitle = el("h1", "home-hero-title");
|
|
300
|
+
heroTitle.textContent = "ViteBoard";
|
|
301
|
+
const heroSub = el("p", "home-hero-sub");
|
|
302
|
+
heroSub.textContent =
|
|
303
|
+
"A local-first documentation workspace and infinite-canvas whiteboard. Markdown files, boards, and structured capability specs — plain files on disk, tracked by git.";
|
|
304
|
+
guide.append(heroTitle, heroSub);
|
|
305
|
+
|
|
306
|
+
// Feature sections
|
|
307
|
+
type FeatureDef = { label: string; kind: string; body: string; items?: string[] };
|
|
308
|
+
const features: FeatureDef[] = [
|
|
309
|
+
{
|
|
310
|
+
label: "Docs", kind: "doc",
|
|
311
|
+
body: "Markdown files discovered inside any docs/ directory you register. The sidebar builds a live tree from the filesystem — create, rename, and delete docs by right-clicking any directory or file.",
|
|
312
|
+
items: [
|
|
313
|
+
"Standard markdown: headings, lists, tables, code blocks, blockquotes, images, links",
|
|
314
|
+
"Inline board previews via fenced board blocks",
|
|
315
|
+
"Full-text search across all docs — Cmd K / Ctrl K",
|
|
316
|
+
],
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
label: "Boards", kind: "board",
|
|
320
|
+
body: "Infinite-canvas editors saved as JSON files alongside your docs. Open from the sidebar, edit in full-screen, close to return. Boards are small, diffable, and git-native.",
|
|
321
|
+
items: [
|
|
322
|
+
"Elements: sticky notes, tasks, shapes, text, arrows, images, frames",
|
|
323
|
+
"Embed any board region into a doc with a fenced board block",
|
|
324
|
+
'Click "Copy embed" inside the board editor to get the exact region snippet',
|
|
325
|
+
"Cmd/Ctrl + scroll to zoom · Shift + scroll to pan",
|
|
326
|
+
],
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
label: "Capabilities", kind: "capability",
|
|
330
|
+
body: "Structured markdown specs with YAML frontmatter (title, summary, tags). Build a cart of specs you need for a task and copy them as a single combined prompt.",
|
|
331
|
+
items: [
|
|
332
|
+
"Add any spec to the component cart from the sidebar or the capabilities page",
|
|
333
|
+
"Cart lives in the topbar — click to open, review, or copy the prompt",
|
|
334
|
+
"Copied prompt includes all selected specs formatted for an LLM",
|
|
335
|
+
],
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
label: "Search", kind: "",
|
|
339
|
+
body: "Full-text search across every doc and capability in every registered project. Indexed at startup and refreshed after any content change.",
|
|
340
|
+
items: [
|
|
341
|
+
"Cmd K / Ctrl K to open from anywhere",
|
|
342
|
+
"Ranked by title, path, headings, and body text",
|
|
343
|
+
"Arrow keys to navigate · Enter to open · Escape to close",
|
|
344
|
+
],
|
|
345
|
+
},
|
|
346
|
+
];
|
|
347
|
+
|
|
348
|
+
for (const f of features) {
|
|
349
|
+
const section = el("div", "home-feature");
|
|
350
|
+
const header = el("div", "home-feature-header");
|
|
351
|
+
const badge = el("span", `home-feature-badge home-feature-badge-${f.kind || "neutral"}`);
|
|
352
|
+
badge.textContent = f.label;
|
|
353
|
+
header.appendChild(badge);
|
|
354
|
+
section.appendChild(header);
|
|
355
|
+
const body = el("p", "home-feature-body");
|
|
356
|
+
body.textContent = f.body;
|
|
357
|
+
section.appendChild(body);
|
|
358
|
+
if (f.items) {
|
|
359
|
+
const list = document.createElement("ul");
|
|
360
|
+
list.className = "home-feature-list";
|
|
361
|
+
for (const item of f.items) {
|
|
362
|
+
const li = document.createElement("li");
|
|
363
|
+
li.textContent = item;
|
|
364
|
+
list.appendChild(li);
|
|
365
|
+
}
|
|
366
|
+
section.appendChild(list);
|
|
367
|
+
}
|
|
368
|
+
guide.appendChild(section);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Keyboard shortcuts
|
|
372
|
+
const shortcutTitle = el("div", "home-shortcut-title");
|
|
373
|
+
shortcutTitle.textContent = "Shortcuts & interactions";
|
|
374
|
+
guide.appendChild(shortcutTitle);
|
|
375
|
+
|
|
376
|
+
const shortcuts: [string, string][] = [
|
|
377
|
+
["Cmd K / Ctrl K", "Open search"],
|
|
378
|
+
["Right-click project header", "Remove from workspace"],
|
|
379
|
+
["Right-click directory", "New doc · New board · New capability"],
|
|
380
|
+
["Right-click doc", "Open · Edit · Rename · Delete"],
|
|
381
|
+
["Right-click board", "Open · Rename · Delete"],
|
|
382
|
+
["Cmd/Ctrl + scroll on board", "Zoom"],
|
|
383
|
+
["Shift + scroll on board", "Pan"],
|
|
384
|
+
["Copy embed (board toolbar)", "Snippet for current view region"],
|
|
385
|
+
];
|
|
386
|
+
const scTable = el("div", "home-shortcut-table");
|
|
387
|
+
for (const [k, v] of shortcuts) {
|
|
388
|
+
const row = el("div", "home-shortcut-row");
|
|
389
|
+
const key = el("kbd", "home-shortcut-key");
|
|
390
|
+
key.textContent = k;
|
|
391
|
+
const desc = el("span", "home-shortcut-desc");
|
|
392
|
+
desc.textContent = v;
|
|
393
|
+
row.append(key, desc);
|
|
394
|
+
scTable.appendChild(row);
|
|
395
|
+
}
|
|
396
|
+
guide.appendChild(scTable);
|
|
397
|
+
|
|
398
|
+
layout.appendChild(guide);
|
|
399
|
+
|
|
400
|
+
// ════════════════════════════════════════════════════
|
|
401
|
+
// RIGHT — workspace panel
|
|
402
|
+
// ════════════════════════════════════════════════════
|
|
403
|
+
const panel = el("div", "home-panel");
|
|
404
|
+
|
|
405
|
+
const panelHead = el("div", "home-panel-head");
|
|
406
|
+
const panelTitle = el("div", "home-panel-title");
|
|
407
|
+
panelTitle.textContent = "Workspace";
|
|
408
|
+
const addBtn = el("button", "btn primary home-panel-add") as HTMLButtonElement;
|
|
409
|
+
addBtn.textContent = "+ Add repo";
|
|
410
|
+
addBtn.addEventListener("click", () => void this.addRepoDocs());
|
|
411
|
+
panelHead.append(panelTitle, addBtn);
|
|
412
|
+
panel.appendChild(panelHead);
|
|
413
|
+
|
|
414
|
+
if (this.workspace.projects.length === 0) {
|
|
415
|
+
const empty = el("div", "home-panel-empty");
|
|
416
|
+
empty.textContent = "No projects yet. Add a project root to discover docs folders.";
|
|
417
|
+
panel.appendChild(empty);
|
|
418
|
+
} else {
|
|
419
|
+
for (const project of this.workspace.projects) {
|
|
420
|
+
const pc = el("div", "home-panel-project");
|
|
421
|
+
|
|
422
|
+
// Project header — links to the project wiki
|
|
423
|
+
const pcHead = el("a", "home-panel-project-head") as HTMLAnchorElement;
|
|
424
|
+
pcHead.href = projectWikiHash(project.id);
|
|
425
|
+
const pcName = el("span", "home-panel-project-name");
|
|
426
|
+
pcName.textContent = project.label;
|
|
427
|
+
|
|
428
|
+
// Aggregate stats
|
|
429
|
+
const totalDocs = project.docsRoots.reduce((n, r) => {
|
|
430
|
+
const tree = this.treesByRoot.get(r.id) ?? [];
|
|
431
|
+
return n + visibleMarkdownFiles(tree).length;
|
|
432
|
+
}, 0);
|
|
433
|
+
const totalBoards = project.docsRoots.reduce((n, r) => {
|
|
434
|
+
const tree = this.treesByRoot.get(r.id) ?? [];
|
|
435
|
+
return n + allBoardFiles(tree).length;
|
|
436
|
+
}, 0);
|
|
437
|
+
const pcMeta = el("span", "home-panel-project-meta");
|
|
438
|
+
const metaParts: string[] = [`${project.docsRoots.length} root${project.docsRoots.length === 1 ? "" : "s"}`];
|
|
439
|
+
if (totalDocs) metaParts.push(`${totalDocs} doc${totalDocs === 1 ? "" : "s"}`);
|
|
440
|
+
if (totalBoards) metaParts.push(`${totalBoards} board${totalBoards === 1 ? "" : "s"}`);
|
|
441
|
+
pcMeta.textContent = metaParts.join(" · ");
|
|
442
|
+
pcHead.append(pcName, pcMeta);
|
|
443
|
+
pc.appendChild(pcHead);
|
|
444
|
+
|
|
445
|
+
// Root list — show up to 6, then a "view all" link
|
|
446
|
+
const MAX_ROOTS = 6;
|
|
447
|
+
const roots = project.docsRoots;
|
|
448
|
+
const shown = roots.slice(0, MAX_ROOTS);
|
|
449
|
+
|
|
450
|
+
const rootList = el("div", "home-panel-roots");
|
|
451
|
+
for (const root of shown) {
|
|
452
|
+
const tree = this.treesByRoot.get(root.id) ?? [];
|
|
453
|
+
const firstDoc = firstMarkdownPath(visibleDocsTree(tree));
|
|
454
|
+
const href = firstDoc ? rootDocHash(root.id, firstDoc) : rootBoardsHash(root.id);
|
|
455
|
+
const row = el("a", "home-panel-root") as HTMLAnchorElement;
|
|
456
|
+
row.href = href;
|
|
457
|
+
|
|
458
|
+
const path = el("span", "home-panel-root-path");
|
|
459
|
+
path.textContent = root.displayPath;
|
|
460
|
+
|
|
461
|
+
const mdCount = visibleMarkdownFiles(tree).length;
|
|
462
|
+
const boardCount = allBoardFiles(tree).length;
|
|
463
|
+
if (mdCount || boardCount) {
|
|
464
|
+
const meta = el("span", "home-panel-root-meta");
|
|
465
|
+
const p: string[] = [];
|
|
466
|
+
if (mdCount) p.push(String(mdCount));
|
|
467
|
+
if (boardCount) p.push(`${boardCount}▦`);
|
|
468
|
+
meta.textContent = p.join(" · ");
|
|
469
|
+
row.append(path, meta);
|
|
470
|
+
} else {
|
|
471
|
+
row.appendChild(path);
|
|
472
|
+
}
|
|
473
|
+
rootList.appendChild(row);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (roots.length > MAX_ROOTS) {
|
|
477
|
+
const more = el("a", "home-panel-more") as HTMLAnchorElement;
|
|
478
|
+
more.href = projectWikiHash(project.id);
|
|
479
|
+
more.textContent = `+ ${roots.length - MAX_ROOTS} more roots`;
|
|
480
|
+
rootList.appendChild(more);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
pc.appendChild(rootList);
|
|
484
|
+
panel.appendChild(pc);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
layout.appendChild(panel);
|
|
489
|
+
page.appendChild(layout);
|
|
490
|
+
|
|
491
|
+
this.content.replaceChildren(page);
|
|
492
|
+
this.contentScroll.scrollTop = scrollTop ?? 0;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private async renderProjectWiki(projectId: string, scrollTop?: number, filterRootId?: string, filterDirPath?: string): Promise<void> {
|
|
496
|
+
this.disposeContent();
|
|
497
|
+
this.content.classList.add("content-wide");
|
|
498
|
+
|
|
499
|
+
const project = this.workspace.projects.find(p => p.id === projectId);
|
|
500
|
+
if (!project) {
|
|
501
|
+
this.renderMessage("Project not found", "This project is no longer registered.");
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const page = el("div", "page wiki-page");
|
|
506
|
+
const isScoped = !!filterRootId;
|
|
507
|
+
const isFiltered = !!filterDirPath;
|
|
508
|
+
|
|
509
|
+
// Determine if filterDirPath is above/at the docs root or inside it
|
|
510
|
+
let isAboveDocsRoot = false;
|
|
511
|
+
if (isFiltered && filterRootId) {
|
|
512
|
+
const root = this.docsRootById(filterRootId);
|
|
513
|
+
if (root) {
|
|
514
|
+
isAboveDocsRoot = root.displayPath === filterDirPath
|
|
515
|
+
|| root.displayPath.startsWith(filterDirPath + "/");
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Header with breadcrumbs
|
|
520
|
+
const wikiHead = el("div", "wiki-head");
|
|
521
|
+
if (isFiltered && filterRootId) {
|
|
522
|
+
// Build breadcrumbs: Home > Project > path segments
|
|
523
|
+
const crumbs: Crumb[] = [{ label: "Home", href: "#/" }];
|
|
524
|
+
crumbs.push({ label: project.label, href: projectWikiHash(project.id) });
|
|
525
|
+
const segments = filterDirPath!.split("/").filter(Boolean);
|
|
526
|
+
for (let i = 0; i < segments.length; i++) {
|
|
527
|
+
const partial = segments.slice(0, i + 1).join("/");
|
|
528
|
+
const isLast = i === segments.length - 1;
|
|
529
|
+
crumbs.push({
|
|
530
|
+
label: segments[i],
|
|
531
|
+
href: isLast ? undefined : directoryWikiHash(filterRootId, partial),
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
wikiHead.appendChild(makeBreadcrumb(crumbs));
|
|
535
|
+
const wikiTitle = el("h1", "wiki-title");
|
|
536
|
+
wikiTitle.textContent = segments[segments.length - 1] || project.label;
|
|
537
|
+
wikiHead.appendChild(wikiTitle);
|
|
538
|
+
} else if (isScoped && filterRootId) {
|
|
539
|
+
const root = this.docsRootById(filterRootId);
|
|
540
|
+
const crumbs: Crumb[] = [{ label: "Home", href: "#/" }];
|
|
541
|
+
crumbs.push({ label: project.label, href: projectWikiHash(project.id) });
|
|
542
|
+
if (root) {
|
|
543
|
+
const parts = root.displayPath.split("/").filter(Boolean);
|
|
544
|
+
for (let i = 0; i < parts.length; i++) {
|
|
545
|
+
const partial = parts.slice(0, i + 1).join("/");
|
|
546
|
+
const isLast = i === parts.length - 1;
|
|
547
|
+
crumbs.push({
|
|
548
|
+
label: parts[i],
|
|
549
|
+
href: isLast ? undefined : directoryWikiHash(filterRootId, partial),
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
wikiHead.appendChild(makeBreadcrumb(crumbs));
|
|
554
|
+
const wikiTitle = el("h1", "wiki-title");
|
|
555
|
+
wikiTitle.textContent = root?.displayPath.split("/").pop() || project.label;
|
|
556
|
+
wikiHead.appendChild(wikiTitle);
|
|
557
|
+
} else {
|
|
558
|
+
wikiHead.appendChild(makeBreadcrumb([
|
|
559
|
+
{ label: "Home", href: "#/" },
|
|
560
|
+
{ label: project.label },
|
|
561
|
+
]));
|
|
562
|
+
const wikiTitle = el("h1", "wiki-title");
|
|
563
|
+
wikiTitle.textContent = project.label;
|
|
564
|
+
wikiHead.appendChild(wikiTitle);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Stats bar (only for the full project wiki)
|
|
568
|
+
if (!isScoped && !isFiltered) {
|
|
569
|
+
const totalDocs = project.docsRoots.reduce((n, r) => {
|
|
570
|
+
const tree = this.treesByRoot.get(r.id) ?? [];
|
|
571
|
+
return n + visibleMarkdownFiles(tree).length;
|
|
572
|
+
}, 0);
|
|
573
|
+
const totalBoards = project.docsRoots.reduce((n, r) => {
|
|
574
|
+
const tree = this.treesByRoot.get(r.id) ?? [];
|
|
575
|
+
return n + allBoardFiles(tree).length;
|
|
576
|
+
}, 0);
|
|
577
|
+
const totalCaps = project.docsRoots.reduce((n, r) => {
|
|
578
|
+
const tree = this.treesByRoot.get(r.id) ?? [];
|
|
579
|
+
const capsDir = tree.find(n2 => n2.type === "dir" && n2.name === "capabilities");
|
|
580
|
+
return n + (capsDir?.type === "dir" ? capsDir.children.filter(f => f.name.endsWith(".md")).length : 0);
|
|
581
|
+
}, 0);
|
|
582
|
+
|
|
583
|
+
const stats = el("div", "wiki-stats");
|
|
584
|
+
for (const [val, label] of [
|
|
585
|
+
[String(project.docsRoots.length), "docs roots"],
|
|
586
|
+
[String(totalDocs), "documents"],
|
|
587
|
+
[String(totalBoards), "boards"],
|
|
588
|
+
[String(totalCaps), "capabilities"],
|
|
589
|
+
] as [string, string][]) {
|
|
590
|
+
const stat = el("div", "wiki-stat");
|
|
591
|
+
const statVal = el("div", "wiki-stat-val");
|
|
592
|
+
statVal.textContent = val;
|
|
593
|
+
const statLabel = el("div", "wiki-stat-label");
|
|
594
|
+
statLabel.textContent = label;
|
|
595
|
+
stat.append(statVal, statLabel);
|
|
596
|
+
stats.appendChild(stat);
|
|
597
|
+
}
|
|
598
|
+
wikiHead.appendChild(stats);
|
|
599
|
+
}
|
|
600
|
+
page.appendChild(wikiHead);
|
|
601
|
+
|
|
602
|
+
// Filter roots if needed
|
|
603
|
+
const rootsToShow = filterRootId
|
|
604
|
+
? project.docsRoots.filter(r => r.id === filterRootId)
|
|
605
|
+
: project.docsRoots;
|
|
606
|
+
|
|
607
|
+
// One section per docs root
|
|
608
|
+
for (const root of rootsToShow) {
|
|
609
|
+
const tree = this.treesByRoot.get(root.id) ?? [];
|
|
610
|
+
|
|
611
|
+
// Filter to specific directory if requested (only for paths INSIDE the docs root)
|
|
612
|
+
let scopedTree = tree;
|
|
613
|
+
if (filterDirPath && !isAboveDocsRoot) {
|
|
614
|
+
const findDir = (nodes: TreeNode[], target: string): TreeNode | null => {
|
|
615
|
+
for (const n of nodes) {
|
|
616
|
+
if (n.type === "dir" && n.path === target) return n;
|
|
617
|
+
if (n.type === "dir") {
|
|
618
|
+
const found = findDir(n.children, target);
|
|
619
|
+
if (found) return found;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return null;
|
|
623
|
+
};
|
|
624
|
+
const dirNode = findDir(tree, filterDirPath);
|
|
625
|
+
scopedTree = dirNode?.type === "dir" ? dirNode.children : [];
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const mdFiles = visibleMarkdownFiles(scopedTree);
|
|
629
|
+
const boards = allBoardFiles(scopedTree);
|
|
630
|
+
const capsDir = scopedTree.find(n => n.type === "dir" && n.name === "capabilities");
|
|
631
|
+
const caps = capsDir?.type === "dir"
|
|
632
|
+
? capsDir.children.filter(f => f.name.endsWith(".md"))
|
|
633
|
+
: [];
|
|
634
|
+
|
|
635
|
+
const section = el("div", "wiki-root-section");
|
|
636
|
+
|
|
637
|
+
// Root header (skip when viewing a subdirectory inside the docs root)
|
|
638
|
+
if (!filterDirPath || isAboveDocsRoot) {
|
|
639
|
+
const rootHead = el("div", "wiki-root-head");
|
|
640
|
+
const rootPath = el("div", "wiki-root-path");
|
|
641
|
+
rootPath.textContent = root.displayPath;
|
|
642
|
+
rootHead.appendChild(rootPath);
|
|
643
|
+
section.appendChild(rootHead);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// If completely empty
|
|
647
|
+
if (!mdFiles.length && !boards.length && !caps.length) {
|
|
648
|
+
const empty = el("div", "wiki-root-empty");
|
|
649
|
+
empty.textContent = "No docs, boards, or capabilities yet. Right-click in the sidebar to add content.";
|
|
650
|
+
section.appendChild(empty);
|
|
651
|
+
page.appendChild(section);
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const cols = el("div", "wiki-root-cols");
|
|
656
|
+
|
|
657
|
+
// Docs column
|
|
658
|
+
if (mdFiles.length > 0) {
|
|
659
|
+
const col = el("div", "wiki-col");
|
|
660
|
+
const colHead = el("div", "wiki-col-head");
|
|
661
|
+
colHead.textContent = `Docs (${mdFiles.length})`;
|
|
662
|
+
col.appendChild(colHead);
|
|
663
|
+
const list = el("div", "wiki-col-list");
|
|
664
|
+
for (const file of mdFiles) {
|
|
665
|
+
const a = el("a", "wiki-col-item wiki-col-item-doc") as HTMLAnchorElement;
|
|
666
|
+
a.href = rootDocHash(root.id, file.path);
|
|
667
|
+
a.textContent = file.name === "index.md"
|
|
668
|
+
? "Overview"
|
|
669
|
+
: file.name.replace(/\.md$/, "").replace(/[-_]/g, " ");
|
|
670
|
+
const pathHint = el("span", "wiki-col-item-path");
|
|
671
|
+
const dir = file.path.includes("/") ? file.path.slice(0, file.path.lastIndexOf("/")) : "";
|
|
672
|
+
pathHint.textContent = dir;
|
|
673
|
+
if (dir) a.appendChild(pathHint);
|
|
674
|
+
list.appendChild(a);
|
|
675
|
+
}
|
|
676
|
+
col.appendChild(list);
|
|
677
|
+
cols.appendChild(col);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Boards column
|
|
681
|
+
if (boards.length > 0) {
|
|
682
|
+
const col = el("div", "wiki-col");
|
|
683
|
+
const colHead = el("div", "wiki-col-head");
|
|
684
|
+
colHead.textContent = `Boards (${boards.length})`;
|
|
685
|
+
col.appendChild(colHead);
|
|
686
|
+
const list = el("div", "wiki-col-list");
|
|
687
|
+
for (const f of boards) {
|
|
688
|
+
const boardId = boardIdFromFilePath(f.path);
|
|
689
|
+
const a = el("a", "wiki-col-item wiki-col-item-board") as HTMLAnchorElement;
|
|
690
|
+
a.href = rootBoardHash(root.id, boardId);
|
|
691
|
+
a.textContent = boardId.split("/").pop()!.replace(/[-_]/g, " ");
|
|
692
|
+
const pathHint = el("span", "wiki-col-item-path");
|
|
693
|
+
pathHint.textContent = boardId.includes("/") ? boardId.slice(0, boardId.lastIndexOf("/")) : root.displayPath;
|
|
694
|
+
a.appendChild(pathHint);
|
|
695
|
+
list.appendChild(a);
|
|
696
|
+
}
|
|
697
|
+
col.appendChild(list);
|
|
698
|
+
cols.appendChild(col);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Capabilities column
|
|
702
|
+
if (caps.length > 0) {
|
|
703
|
+
const col = el("div", "wiki-col");
|
|
704
|
+
const colHead = el("div", "wiki-col-head");
|
|
705
|
+
colHead.textContent = `Capabilities (${caps.length})`;
|
|
706
|
+
col.appendChild(colHead);
|
|
707
|
+
const list = el("div", "wiki-col-list");
|
|
708
|
+
for (const f of caps) {
|
|
709
|
+
const a = el("a", "wiki-col-item wiki-col-item-cap") as HTMLAnchorElement;
|
|
710
|
+
a.href = rootCapabilityHash(root.id, f.path);
|
|
711
|
+
a.textContent = f.name.replace(/\.md$/, "").replace(/[-_]/g, " ");
|
|
712
|
+
list.appendChild(a);
|
|
713
|
+
}
|
|
714
|
+
col.appendChild(list);
|
|
715
|
+
cols.appendChild(col);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
section.appendChild(cols);
|
|
719
|
+
page.appendChild(section);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
this.content.replaceChildren(page);
|
|
723
|
+
this.contentScroll.scrollTop = scrollTop ?? 0;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
private async renderDoc(rootId: string, filePath: string, scrollTop?: number): Promise<void> {
|
|
727
|
+
this.disposeContent();
|
|
728
|
+
if (!this.docsRootById(rootId)) {
|
|
729
|
+
this.renderMessage("Docs root unavailable", "This docs root is no longer registered. Re-add the project root if needed.");
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
try {
|
|
733
|
+
const md = await ContentApi.readFile(rootId, filePath);
|
|
734
|
+
this.setEditableFile({ rootId, path: filePath });
|
|
735
|
+
const page = el("div", "doc-page");
|
|
736
|
+
const docsRoot = this.docsRootById(rootId);
|
|
737
|
+
const crumbs = standardCrumbs(this.workspace, rootId, { filePath });
|
|
738
|
+
page.appendChild(makeBreadcrumb(crumbs));
|
|
739
|
+
const article = el("div", "");
|
|
740
|
+
page.appendChild(article);
|
|
741
|
+
this.content.replaceChildren(page);
|
|
742
|
+
const { destroy } = renderMarkdownInto(article, md, { rootId, docPath: filePath });
|
|
743
|
+
this.cleanup = destroy;
|
|
744
|
+
// Prev / next
|
|
745
|
+
const tree = await ContentApi.getTree(rootId);
|
|
746
|
+
const seq = buildNavSequence(rootId, tree);
|
|
747
|
+
const nav = makePageNav(seq, rootDocHash(rootId, filePath));
|
|
748
|
+
if (nav) page.appendChild(nav);
|
|
749
|
+
this.contentScroll.scrollTop = scrollTop ?? 0;
|
|
750
|
+
} catch {
|
|
751
|
+
this.renderMessage("Document not found", filePath);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
private openDocEditor(file: EditableFileRef): void {
|
|
756
|
+
this.topbar.classList.add("editing-doc");
|
|
757
|
+
this.closeCartPopover();
|
|
758
|
+
DocEditor.open({
|
|
759
|
+
rootId: file.rootId,
|
|
760
|
+
path: file.path,
|
|
761
|
+
onClose: () => {
|
|
762
|
+
this.topbar.classList.remove("editing-doc");
|
|
763
|
+
this.route();
|
|
764
|
+
},
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
private async renderCapabilityDoc(rootId: string, filePath: string, scrollTop?: number): Promise<void> {
|
|
769
|
+
this.disposeContent();
|
|
770
|
+
if (!this.docsRootById(rootId)) {
|
|
771
|
+
this.renderMessage("Docs root unavailable", "This docs root is no longer registered. Re-add the project root if needed.");
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
let md: string;
|
|
775
|
+
try {
|
|
776
|
+
md = await ContentApi.readFile(rootId, filePath);
|
|
777
|
+
this.setEditableFile({ rootId, path: filePath });
|
|
778
|
+
} catch {
|
|
779
|
+
this.renderMessage("Capability not found", filePath);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const page = el("div", "page");
|
|
784
|
+
const docsRoot = this.docsRootById(rootId);
|
|
785
|
+
const proj = docsRoot ? this.workspace.projects.find(p => p.id === docsRoot.projectId) : undefined;
|
|
786
|
+
const capName = prettify(filePath.split("/").pop()?.replace(/\.md$/, "") ?? filePath);
|
|
787
|
+
const crumbs = standardCrumbs(this.workspace, rootId, { filePath, currentLabel: capName });
|
|
788
|
+
page.appendChild(makeBreadcrumb(crumbs));
|
|
789
|
+
const bar = el("div", "page-bar");
|
|
790
|
+
const actions = el("div", "doc-actions capability-doc-actions");
|
|
791
|
+
const { frontmatter } = renderMarkdown(md);
|
|
792
|
+
const cartBtn = el("button", "btn") as HTMLButtonElement;
|
|
793
|
+
cartBtn.classList.add("capability-detail-btn");
|
|
794
|
+
const item = capabilityItemFrom(rootId, filePath, md, frontmatter);
|
|
795
|
+
this.updateCartButton(cartBtn, item.path);
|
|
796
|
+
cartBtn.addEventListener("click", () => {
|
|
797
|
+
this.toggleCapabilityCart(item);
|
|
798
|
+
this.updateCartButton(cartBtn, item.path);
|
|
799
|
+
});
|
|
800
|
+
const copyBtn = el("button", "btn primary") as HTMLButtonElement;
|
|
801
|
+
copyBtn.classList.add("capability-detail-btn");
|
|
802
|
+
copyBtn.textContent = "Copy as LLM prompt";
|
|
803
|
+
copyBtn.addEventListener("click", async () => {
|
|
804
|
+
try {
|
|
805
|
+
await navigator.clipboard.writeText(md);
|
|
806
|
+
showToast("Capability copied — paste into your AI assistant");
|
|
807
|
+
} catch {
|
|
808
|
+
showToast("Could not access clipboard");
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
actions.append(cartBtn, copyBtn);
|
|
812
|
+
bar.append(actions);
|
|
813
|
+
page.appendChild(bar);
|
|
814
|
+
|
|
815
|
+
const body = el("div", "");
|
|
816
|
+
page.appendChild(body);
|
|
817
|
+
this.content.replaceChildren(page);
|
|
818
|
+
const { destroy } = renderMarkdownInto(body, md, { rootId, docPath: filePath });
|
|
819
|
+
this.cleanup = destroy;
|
|
820
|
+
// Prev / next
|
|
821
|
+
const tree = this.treesByRoot.get(rootId) ?? [];
|
|
822
|
+
const seq = buildNavSequence(rootId, tree);
|
|
823
|
+
const nav = makePageNav(seq, rootCapabilityHash(rootId, filePath));
|
|
824
|
+
if (nav) page.appendChild(nav);
|
|
825
|
+
this.contentScroll.scrollTop = scrollTop ?? 0;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
private async renderCapabilitiesIndex(rootId: string, scrollTop?: number): Promise<void> {
|
|
829
|
+
this.disposeContent();
|
|
830
|
+
if (!this.docsRootById(rootId)) {
|
|
831
|
+
this.renderMessage("Docs root unavailable", "This docs root is no longer registered. Re-add the project root if needed.");
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
this.content.classList.add("content-wide");
|
|
835
|
+
const page = el("div", "page capability-library");
|
|
836
|
+
const capsCrumbs = standardCrumbs(this.workspace, rootId, { currentLabel: "Capabilities" });
|
|
837
|
+
page.appendChild(makeBreadcrumb(capsCrumbs));
|
|
838
|
+
const h1 = el("h1", "");
|
|
839
|
+
h1.textContent = "Capability library";
|
|
840
|
+
const lead = el("p", "page-lead");
|
|
841
|
+
lead.textContent =
|
|
842
|
+
"Select reusable specs into a component cart, then copy one combined prompt for your coding assistant.";
|
|
843
|
+
page.append(h1, lead);
|
|
844
|
+
|
|
845
|
+
const layout = el("div", "capability-layout");
|
|
846
|
+
const grid = el("div", "card-grid capability-grid");
|
|
847
|
+
const cart = this.renderCapabilityCart();
|
|
848
|
+
layout.append(grid, cart);
|
|
849
|
+
page.appendChild(layout);
|
|
850
|
+
this.content.replaceChildren(page);
|
|
851
|
+
|
|
852
|
+
try {
|
|
853
|
+
const tree = this.treesByRoot.get(rootId) ?? (await ContentApi.getTree(rootId));
|
|
854
|
+
const caps = tree.find((n) => n.type === "dir" && n.name === "capabilities");
|
|
855
|
+
const files =
|
|
856
|
+
caps && caps.type === "dir"
|
|
857
|
+
? caps.children.filter((n) => n.type === "file" && n.name.endsWith(".md"))
|
|
858
|
+
: [];
|
|
859
|
+
for (const f of files) {
|
|
860
|
+
const md = await ContentApi.readFile(rootId, f.path);
|
|
861
|
+
const { frontmatter } = renderMarkdown(md);
|
|
862
|
+
const item = capabilityItemFrom(rootId, f.path, md, frontmatter);
|
|
863
|
+
const card = document.createElement("article");
|
|
864
|
+
card.className = "card";
|
|
865
|
+
if (this.capabilityCart.has(item.path)) card.classList.add("selected");
|
|
866
|
+
const title = el("div", "card-title");
|
|
867
|
+
const link = document.createElement("a");
|
|
868
|
+
link.href = rootCapabilityHash(rootId, f.path);
|
|
869
|
+
link.textContent = item.title;
|
|
870
|
+
title.appendChild(link);
|
|
871
|
+
const desc = el("div", "card-desc");
|
|
872
|
+
desc.textContent = item.summary;
|
|
873
|
+
card.append(title, desc);
|
|
874
|
+
if (item.tags.length) {
|
|
875
|
+
const tags = el("div", "card-tags");
|
|
876
|
+
for (const t of item.tags) {
|
|
877
|
+
const tag = el("span", "tag");
|
|
878
|
+
tag.textContent = t;
|
|
879
|
+
tags.appendChild(tag);
|
|
880
|
+
}
|
|
881
|
+
card.appendChild(tags);
|
|
882
|
+
}
|
|
883
|
+
const actions = el("div", "card-actions");
|
|
884
|
+
const add = el("button", "btn") as HTMLButtonElement;
|
|
885
|
+
add.classList.add("capability-add-btn");
|
|
886
|
+
this.updateCartButton(add, item.path);
|
|
887
|
+
add.addEventListener("click", () => {
|
|
888
|
+
this.toggleCapabilityCart(item);
|
|
889
|
+
void this.renderCapabilitiesIndex(rootId, this.contentScroll.scrollTop);
|
|
890
|
+
});
|
|
891
|
+
actions.appendChild(add);
|
|
892
|
+
card.appendChild(actions);
|
|
893
|
+
grid.appendChild(card);
|
|
894
|
+
}
|
|
895
|
+
if (files.length === 0) grid.appendChild(emptyNote("No capabilities yet."));
|
|
896
|
+
// Prev / next
|
|
897
|
+
const seq = buildNavSequence(rootId, tree);
|
|
898
|
+
const nav = makePageNav(seq, rootCapabilitiesHash(rootId));
|
|
899
|
+
if (nav) page.appendChild(nav);
|
|
900
|
+
} catch {
|
|
901
|
+
grid.appendChild(emptyNote("Could not load capabilities."));
|
|
902
|
+
}
|
|
903
|
+
this.contentScroll.scrollTop = scrollTop ?? 0;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
private renderTokenLibrary(scrollTop?: number): void {
|
|
907
|
+
this.disposeContent();
|
|
908
|
+
this.content.classList.add("content-wide");
|
|
909
|
+
const page = el("div", "page token-library");
|
|
910
|
+
const head = el("div", "page-bar");
|
|
911
|
+
const title = el("h1", "");
|
|
912
|
+
title.textContent = "Tokens";
|
|
913
|
+
const actions = el("div", "token-actions");
|
|
914
|
+
const selectAll = el("button", "btn") as HTMLButtonElement;
|
|
915
|
+
selectAll.textContent = "Select all";
|
|
916
|
+
selectAll.addEventListener("click", () => {
|
|
917
|
+
this.selectedTokens = new Set(DESIGN_TOKENS.map((token) => token.variable));
|
|
918
|
+
this.renderTokenLibrary(this.contentScroll.scrollTop);
|
|
919
|
+
});
|
|
920
|
+
const clear = el("button", "btn") as HTMLButtonElement;
|
|
921
|
+
clear.textContent = "Clear";
|
|
922
|
+
clear.addEventListener("click", () => {
|
|
923
|
+
this.selectedTokens.clear();
|
|
924
|
+
this.renderTokenLibrary(this.contentScroll.scrollTop);
|
|
925
|
+
});
|
|
926
|
+
const copy = el("button", "btn primary") as HTMLButtonElement;
|
|
927
|
+
copy.textContent = `Copy selected (${this.selectedTokens.size})`;
|
|
928
|
+
copy.disabled = this.selectedTokens.size === 0;
|
|
929
|
+
copy.addEventListener("click", () => void this.copySelectedTokens());
|
|
930
|
+
actions.append(selectAll, clear, copy);
|
|
931
|
+
head.append(title, actions);
|
|
932
|
+
const lead = el("p", "page-lead");
|
|
933
|
+
lead.textContent =
|
|
934
|
+
"Brand, neutral, and semantic CSS variables used throughout the app. Select tokens and copy them as a ready-to-paste :root block.";
|
|
935
|
+
page.append(head, lead);
|
|
936
|
+
|
|
937
|
+
for (const group of ["Brand Colors", "Neutral Colors", "Semantic Colors"] as DesignToken["group"][]) {
|
|
938
|
+
const section = el("section", "token-section");
|
|
939
|
+
const h = el("h2", "");
|
|
940
|
+
h.textContent = group;
|
|
941
|
+
const grid = el("div", "token-grid");
|
|
942
|
+
for (const token of DESIGN_TOKENS.filter((t) => t.group === group)) {
|
|
943
|
+
const card = el("div", "token-card");
|
|
944
|
+
if (this.selectedTokens.has(token.variable)) card.classList.add("selected");
|
|
945
|
+
const selectZone = el("label", "token-select-zone") as HTMLLabelElement;
|
|
946
|
+
const input = document.createElement("input");
|
|
947
|
+
input.type = "checkbox";
|
|
948
|
+
input.checked = this.selectedTokens.has(token.variable);
|
|
949
|
+
input.addEventListener("change", () => {
|
|
950
|
+
if (input.checked) this.selectedTokens.add(token.variable);
|
|
951
|
+
else this.selectedTokens.delete(token.variable);
|
|
952
|
+
this.renderTokenLibrary(this.contentScroll.scrollTop);
|
|
953
|
+
});
|
|
954
|
+
const swatch = el("span", "token-swatch");
|
|
955
|
+
swatch.style.background = token.value;
|
|
956
|
+
const text = el("span", "token-copy");
|
|
957
|
+
const name = el("strong", "");
|
|
958
|
+
name.textContent = token.name;
|
|
959
|
+
const value = el("code", "");
|
|
960
|
+
value.textContent = token.value;
|
|
961
|
+
const desc = el("small", "");
|
|
962
|
+
desc.textContent = token.description;
|
|
963
|
+
text.append(name, value, desc);
|
|
964
|
+
selectZone.append(input, swatch, text);
|
|
965
|
+
const copyOne = el("button", "token-copy-btn") as HTMLButtonElement;
|
|
966
|
+
copyOne.type = "button";
|
|
967
|
+
copyOne.innerHTML = ICONS.copy;
|
|
968
|
+
copyOne.title = `Copy ${token.variable}`;
|
|
969
|
+
copyOne.setAttribute("aria-label", `Copy ${token.variable}`);
|
|
970
|
+
copyOne.addEventListener("click", (e) => {
|
|
971
|
+
e.stopPropagation();
|
|
972
|
+
void this.copyToken(token);
|
|
973
|
+
});
|
|
974
|
+
card.append(selectZone, copyOne);
|
|
975
|
+
grid.appendChild(card);
|
|
976
|
+
}
|
|
977
|
+
section.append(h, grid);
|
|
978
|
+
page.appendChild(section);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
this.content.replaceChildren(page);
|
|
982
|
+
this.contentScroll.scrollTop = scrollTop ?? 0;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
private async copySelectedTokens(): Promise<void> {
|
|
986
|
+
const tokens = DESIGN_TOKENS.filter((token) => this.selectedTokens.has(token.variable));
|
|
987
|
+
if (tokens.length === 0) return;
|
|
988
|
+
const css = [":root {", ...tokens.map((token) => ` ${token.variable}: ${token.value};`), "}"].join("\n");
|
|
989
|
+
try {
|
|
990
|
+
await navigator.clipboard.writeText(css);
|
|
991
|
+
showToast(`${tokens.length} token${tokens.length === 1 ? "" : "s"} copied`);
|
|
992
|
+
} catch {
|
|
993
|
+
showToast("Could not access clipboard");
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
private async copyToken(token: DesignToken): Promise<void> {
|
|
998
|
+
try {
|
|
999
|
+
await navigator.clipboard.writeText(`${token.variable}: ${token.value};`);
|
|
1000
|
+
showToast(`${token.variable} copied`);
|
|
1001
|
+
} catch {
|
|
1002
|
+
showToast("Could not access clipboard");
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
private renderCapabilityCart(onChange?: () => void): HTMLElement {
|
|
1007
|
+
const refresh =
|
|
1008
|
+
onChange ??
|
|
1009
|
+
(() => {
|
|
1010
|
+
const rootId = currentRootIdFromHash(location.hash);
|
|
1011
|
+
if (rootId) void this.renderCapabilitiesIndex(rootId, this.contentScroll.scrollTop);
|
|
1012
|
+
});
|
|
1013
|
+
const panel = el("aside", "capability-cart");
|
|
1014
|
+
const head = el("div", "capability-cart-head");
|
|
1015
|
+
const title = el("h2", "");
|
|
1016
|
+
title.textContent = "Component cart";
|
|
1017
|
+
const count = el("span", "capability-cart-count");
|
|
1018
|
+
count.textContent = String(this.capabilityCart.size);
|
|
1019
|
+
head.append(title, count);
|
|
1020
|
+
|
|
1021
|
+
const body = el("div", "capability-cart-body");
|
|
1022
|
+
if (this.capabilityCart.size === 0) {
|
|
1023
|
+
const empty = el("div", "capability-cart-empty");
|
|
1024
|
+
empty.textContent = "Select specs to build a combined prompt.";
|
|
1025
|
+
body.appendChild(empty);
|
|
1026
|
+
} else {
|
|
1027
|
+
for (const item of this.capabilityCart.values()) {
|
|
1028
|
+
const row = el("div", "capability-cart-item");
|
|
1029
|
+
const text = el("div", "");
|
|
1030
|
+
const name = el("div", "capability-cart-item-title");
|
|
1031
|
+
name.textContent = item.title;
|
|
1032
|
+
const meta = el("div", "capability-cart-item-meta");
|
|
1033
|
+
meta.textContent = item.displayPath;
|
|
1034
|
+
text.append(name, meta);
|
|
1035
|
+
const remove = el("button", "capability-cart-remove") as HTMLButtonElement;
|
|
1036
|
+
remove.type = "button";
|
|
1037
|
+
remove.textContent = "Remove";
|
|
1038
|
+
remove.addEventListener("click", () => {
|
|
1039
|
+
this.capabilityCart.delete(item.path);
|
|
1040
|
+
this.updateTopbarCart();
|
|
1041
|
+
refresh();
|
|
1042
|
+
});
|
|
1043
|
+
row.append(text, remove);
|
|
1044
|
+
body.appendChild(row);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const actions = el("div", "capability-cart-actions");
|
|
1049
|
+
const copy = el("button", "btn primary") as HTMLButtonElement;
|
|
1050
|
+
copy.textContent = "Copy prompt";
|
|
1051
|
+
copy.disabled = this.capabilityCart.size === 0;
|
|
1052
|
+
copy.addEventListener("click", () => void this.copyCapabilityCartPrompt());
|
|
1053
|
+
const clear = el("button", "btn") as HTMLButtonElement;
|
|
1054
|
+
clear.textContent = "Clear";
|
|
1055
|
+
clear.disabled = this.capabilityCart.size === 0;
|
|
1056
|
+
clear.addEventListener("click", () => {
|
|
1057
|
+
this.capabilityCart.clear();
|
|
1058
|
+
this.updateTopbarCart();
|
|
1059
|
+
refresh();
|
|
1060
|
+
});
|
|
1061
|
+
actions.append(copy, clear);
|
|
1062
|
+
panel.append(head, body, actions);
|
|
1063
|
+
return panel;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
private toggleCapabilityCart(item: CapabilityCartItem): void {
|
|
1067
|
+
if (this.capabilityCart.has(item.path)) this.capabilityCart.delete(item.path);
|
|
1068
|
+
else this.capabilityCart.set(item.path, item);
|
|
1069
|
+
this.updateTopbarCart();
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
private updateCartButton(button: HTMLButtonElement, path: string): void {
|
|
1073
|
+
const selected = this.capabilityCart.has(path);
|
|
1074
|
+
button.textContent = selected ? "Remove from cart" : "Add to cart";
|
|
1075
|
+
button.classList.toggle("primary", !selected);
|
|
1076
|
+
button.classList.toggle("is-selected", selected);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
private async copyCapabilityCartPrompt(): Promise<void> {
|
|
1080
|
+
if (this.capabilityCart.size === 0) return;
|
|
1081
|
+
const specs = [...this.capabilityCart.values()]
|
|
1082
|
+
.map((item, idx) => `## ${idx + 1}. ${item.title}\n\n${item.md.trim()}`)
|
|
1083
|
+
.join("\n\n---\n\n");
|
|
1084
|
+
const prompt =
|
|
1085
|
+
"Use these capability/component specs as implementation guidance for the next coding task.\n\n" +
|
|
1086
|
+
specs;
|
|
1087
|
+
try {
|
|
1088
|
+
await navigator.clipboard.writeText(prompt);
|
|
1089
|
+
showToast(`${this.capabilityCart.size} spec${this.capabilityCart.size === 1 ? "" : "s"} copied`);
|
|
1090
|
+
} catch {
|
|
1091
|
+
showToast("Could not access clipboard");
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// ---------- boards ----------
|
|
1096
|
+
|
|
1097
|
+
private async renderBoardList(rootId: string, scrollTop?: number): Promise<void> {
|
|
1098
|
+
this.disposeContent();
|
|
1099
|
+
if (!this.docsRootById(rootId)) {
|
|
1100
|
+
this.renderMessage("Docs root unavailable", "This docs root is no longer registered. Re-add the project root if needed.");
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
this.content.classList.add("content-wide");
|
|
1104
|
+
const page = el("div", "page board-library");
|
|
1105
|
+
|
|
1106
|
+
const crumbs = standardCrumbs(this.workspace, rootId, { currentLabel: "Boards" });
|
|
1107
|
+
page.appendChild(makeBreadcrumb(crumbs));
|
|
1108
|
+
const head = el("div", "page-bar");
|
|
1109
|
+
const title = el("h1", "");
|
|
1110
|
+
title.textContent = "Boards";
|
|
1111
|
+
const newBtn = el("button", "btn primary") as HTMLButtonElement;
|
|
1112
|
+
newBtn.textContent = "+ New board";
|
|
1113
|
+
newBtn.addEventListener("click", () => this.promptNewBoard(rootId));
|
|
1114
|
+
head.append(title, newBtn);
|
|
1115
|
+
page.appendChild(head);
|
|
1116
|
+
|
|
1117
|
+
const lead = el("p", "page-lead");
|
|
1118
|
+
lead.textContent =
|
|
1119
|
+
"Each board is a git-tracked JSON file. Open one to edit it, then use “Copy embed” to drop a framed preview into any doc.";
|
|
1120
|
+
page.appendChild(lead);
|
|
1121
|
+
|
|
1122
|
+
const grid = el("div", "card-grid board-grid");
|
|
1123
|
+
page.appendChild(grid);
|
|
1124
|
+
this.content.replaceChildren(page);
|
|
1125
|
+
|
|
1126
|
+
try {
|
|
1127
|
+
const boards = await BoardService.list(rootId);
|
|
1128
|
+
for (const b of boards) {
|
|
1129
|
+
const card = document.createElement("a");
|
|
1130
|
+
card.className = "card";
|
|
1131
|
+
card.href = rootBoardHash(rootId, b.id);
|
|
1132
|
+
const t = el("div", "card-title");
|
|
1133
|
+
t.textContent = b.title;
|
|
1134
|
+
const meta = el("div", "card-desc");
|
|
1135
|
+
meta.textContent = `${b.elementCount} element${b.elementCount === 1 ? "" : "s"} · ${b.filePath}`;
|
|
1136
|
+
card.append(t, meta);
|
|
1137
|
+
grid.appendChild(card);
|
|
1138
|
+
}
|
|
1139
|
+
if (boards.length === 0) grid.appendChild(emptyNote("No boards yet — create one."));
|
|
1140
|
+
// Prev / next
|
|
1141
|
+
const tree = await ContentApi.getTree(rootId);
|
|
1142
|
+
const seq = buildNavSequence(rootId, tree);
|
|
1143
|
+
const nav = makePageNav(seq, rootBoardsHash(rootId));
|
|
1144
|
+
if (nav) page.appendChild(nav);
|
|
1145
|
+
} catch {
|
|
1146
|
+
grid.appendChild(emptyNote("Could not load boards."));
|
|
1147
|
+
}
|
|
1148
|
+
this.contentScroll.scrollTop = scrollTop ?? 0;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
private async renderProjectBoardList(projectId: string, scrollTop?: number): Promise<void> {
|
|
1152
|
+
this.disposeContent();
|
|
1153
|
+
this.content.classList.add("content-wide");
|
|
1154
|
+
const project = this.workspace.projects.find((p) => p.id === projectId);
|
|
1155
|
+
if (!project) {
|
|
1156
|
+
this.renderMessage("Project unavailable", "This project is no longer registered. Re-add it if needed.");
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
const page = el("div", "page boards-page");
|
|
1161
|
+
const crumbs: Crumb[] = [
|
|
1162
|
+
{ label: "Home", href: "#/" },
|
|
1163
|
+
{ label: project.label, href: projectWikiHash(projectId) },
|
|
1164
|
+
{ label: "Boards" },
|
|
1165
|
+
];
|
|
1166
|
+
page.appendChild(makeBreadcrumb(crumbs));
|
|
1167
|
+
|
|
1168
|
+
const head = el("div", "page-bar");
|
|
1169
|
+
const title = el("h1", "");
|
|
1170
|
+
title.textContent = `${project.label} boards`;
|
|
1171
|
+
head.appendChild(title);
|
|
1172
|
+
page.appendChild(head);
|
|
1173
|
+
|
|
1174
|
+
const lead = el("p", "page-lead");
|
|
1175
|
+
lead.textContent = "Boards across every mounted docs root in this project. Each card shows the docs root and board file path.";
|
|
1176
|
+
page.appendChild(lead);
|
|
1177
|
+
|
|
1178
|
+
const grid = el("div", "card-grid board-grid");
|
|
1179
|
+
page.appendChild(grid);
|
|
1180
|
+
this.content.replaceChildren(page);
|
|
1181
|
+
|
|
1182
|
+
try {
|
|
1183
|
+
const boards = await this.listProjectBoards(project);
|
|
1184
|
+
for (const item of boards) {
|
|
1185
|
+
const card = document.createElement("a");
|
|
1186
|
+
card.className = "card";
|
|
1187
|
+
card.href = rootBoardHash(item.root.id, item.board.id);
|
|
1188
|
+
const t = el("div", "card-title");
|
|
1189
|
+
t.textContent = item.board.title;
|
|
1190
|
+
const meta = el("div", "card-desc");
|
|
1191
|
+
meta.textContent = `${item.board.elementCount} element${item.board.elementCount === 1 ? "" : "s"} · ${item.root.displayPath} · ${item.board.filePath}`;
|
|
1192
|
+
card.append(t, meta);
|
|
1193
|
+
grid.appendChild(card);
|
|
1194
|
+
}
|
|
1195
|
+
if (boards.length === 0) grid.appendChild(emptyNote("No boards yet — create one."));
|
|
1196
|
+
} catch {
|
|
1197
|
+
grid.appendChild(emptyNote("Could not load boards."));
|
|
1198
|
+
}
|
|
1199
|
+
this.contentScroll.scrollTop = scrollTop ?? 0;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
private async listProjectBoards(project: WorkspaceState["projects"][number]): Promise<Array<{ root: DocsRootSummary; board: BoardSummary }>> {
|
|
1203
|
+
const out: Array<{ root: DocsRootSummary; board: BoardSummary }> = [];
|
|
1204
|
+
for (const root of project.docsRoots) {
|
|
1205
|
+
const boards = await BoardService.list(root.id);
|
|
1206
|
+
for (const board of boards) out.push({ root, board });
|
|
1207
|
+
}
|
|
1208
|
+
out.sort((a, b) => {
|
|
1209
|
+
if (b.board.updatedAt !== a.board.updatedAt) return b.board.updatedAt - a.board.updatedAt;
|
|
1210
|
+
if (a.root.displayPath !== b.root.displayPath) return a.root.displayPath.localeCompare(b.root.displayPath);
|
|
1211
|
+
return a.board.filePath.localeCompare(b.board.filePath);
|
|
1212
|
+
});
|
|
1213
|
+
return out;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
private promptNewBoard(rootId: string, dirPath = ""): void {
|
|
1217
|
+
const containerKey = dirPath ? `doc:${rootId}:${dirPath}` : rootId;
|
|
1218
|
+
const collapsible = this.sidebar.collapsibles.get(containerKey);
|
|
1219
|
+
const container = collapsible?.body ?? this.sidebar.element.querySelector(".nav-repo-body");
|
|
1220
|
+
|
|
1221
|
+
if (container) {
|
|
1222
|
+
this.sidebar.startInlineCreate(container as HTMLElement, "board-name", async (raw) => {
|
|
1223
|
+
const name = validateNewItemName(raw, "board");
|
|
1224
|
+
if (!name.ok) { showToast(name.message); return; }
|
|
1225
|
+
try {
|
|
1226
|
+
const id = await BoardService.create(rootId, name.raw, currentTheme(), dirPath);
|
|
1227
|
+
await this.refreshContentIndex();
|
|
1228
|
+
location.hash = rootBoardHash(rootId, id);
|
|
1229
|
+
} catch { showToast("Could not create board"); }
|
|
1230
|
+
});
|
|
1231
|
+
} else {
|
|
1232
|
+
openModal("New board", (body, close) => {
|
|
1233
|
+
const input = document.createElement("input");
|
|
1234
|
+
input.className = "board-title";
|
|
1235
|
+
input.placeholder = "Board title";
|
|
1236
|
+
input.style.width = "100%";
|
|
1237
|
+
input.style.marginBottom = "12px";
|
|
1238
|
+
const actions = el("div", "modal-actions");
|
|
1239
|
+
const create = el("button", "btn primary") as HTMLButtonElement;
|
|
1240
|
+
create.textContent = "Create";
|
|
1241
|
+
const go = async () => {
|
|
1242
|
+
const name = validateNewItemName(input.value, "board");
|
|
1243
|
+
if (!name.ok) { showToast(name.message); input.focus(); return; }
|
|
1244
|
+
try {
|
|
1245
|
+
const id = await BoardService.create(rootId, name.raw, currentTheme(), dirPath);
|
|
1246
|
+
close();
|
|
1247
|
+
await this.refreshContentIndex();
|
|
1248
|
+
location.hash = rootBoardHash(rootId, id);
|
|
1249
|
+
} catch { showToast("Could not create board file"); }
|
|
1250
|
+
};
|
|
1251
|
+
create.addEventListener("click", () => void go());
|
|
1252
|
+
input.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") void go(); });
|
|
1253
|
+
actions.appendChild(create);
|
|
1254
|
+
body.append(input, actions);
|
|
1255
|
+
input.focus();
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
private promptNewDirectory(rootId: string, dirPath: string): void {
|
|
1261
|
+
const containerKey = dirPath ? `doc:${rootId}:${dirPath}` : rootId;
|
|
1262
|
+
const collapsible = this.sidebar.collapsibles.get(containerKey);
|
|
1263
|
+
const container = collapsible?.body ?? this.sidebar.element.querySelector(".nav-repo-body");
|
|
1264
|
+
|
|
1265
|
+
if (container) {
|
|
1266
|
+
this.sidebar.startInlineCreate(container as HTMLElement, "directory-name", async (raw) => {
|
|
1267
|
+
const name = validateNewItemName(raw, "directory");
|
|
1268
|
+
if (!name.ok) { showToast(name.message); return; }
|
|
1269
|
+
const directoryPath = dirPath ? `${dirPath}/${name.slug}` : name.slug;
|
|
1270
|
+
try {
|
|
1271
|
+
await ContentApi.createDirectory(rootId, directoryPath);
|
|
1272
|
+
await this.refreshContentIndex();
|
|
1273
|
+
} catch { showToast("Could not create directory"); }
|
|
1274
|
+
});
|
|
1275
|
+
} else {
|
|
1276
|
+
openModal("New directory", (body, close) => {
|
|
1277
|
+
const input = document.createElement("input");
|
|
1278
|
+
input.className = "board-title";
|
|
1279
|
+
input.placeholder = "Directory name";
|
|
1280
|
+
input.style.width = "100%";
|
|
1281
|
+
input.style.marginBottom = "12px";
|
|
1282
|
+
const actions = el("div", "modal-actions");
|
|
1283
|
+
const create = el("button", "btn primary") as HTMLButtonElement;
|
|
1284
|
+
create.textContent = "Create";
|
|
1285
|
+
const go = async () => {
|
|
1286
|
+
const name = validateNewItemName(input.value, "directory");
|
|
1287
|
+
if (!name.ok) { showToast(name.message); input.focus(); return; }
|
|
1288
|
+
const directoryPath = dirPath ? `${dirPath}/${name.slug}` : name.slug;
|
|
1289
|
+
try {
|
|
1290
|
+
await ContentApi.createDirectory(rootId, directoryPath);
|
|
1291
|
+
close();
|
|
1292
|
+
await this.refreshContentIndex();
|
|
1293
|
+
} catch { showToast("Could not create directory"); }
|
|
1294
|
+
};
|
|
1295
|
+
create.addEventListener("click", () => void go());
|
|
1296
|
+
input.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") void go(); });
|
|
1297
|
+
actions.appendChild(create);
|
|
1298
|
+
body.append(input, actions);
|
|
1299
|
+
input.focus();
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
private async ensureProjectDocsRoot(projectId: string): Promise<DocsRootSummary | undefined> {
|
|
1305
|
+
const existing = this.workspace.projects.find((project) => project.id === projectId)?.docsRoots[0];
|
|
1306
|
+
if (existing) return existing;
|
|
1307
|
+
try {
|
|
1308
|
+
const root = await ContentApi.ensureProjectDocsRoot(projectId);
|
|
1309
|
+
await this.refreshContentIndex();
|
|
1310
|
+
return this.docsRootById(root.id) ?? root;
|
|
1311
|
+
} catch {
|
|
1312
|
+
showToast("Could not create docs folder");
|
|
1313
|
+
return undefined;
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
private async createDocsFolder(projectId: string, parentPath: string): Promise<void> {
|
|
1318
|
+
try {
|
|
1319
|
+
await ContentApi.createDocsFolder(projectId, parentPath);
|
|
1320
|
+
await this.refreshContentIndex();
|
|
1321
|
+
// Auto-close browse mode so the new docs root is immediately visible
|
|
1322
|
+
this.sidebar.clearBrowseMode();
|
|
1323
|
+
showToast(`Created docs/ in ${parentPath || "project root"}`);
|
|
1324
|
+
} catch {
|
|
1325
|
+
showToast("Could not create docs folder");
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
private async promptNewProjectDirectory(projectId: string): Promise<void> {
|
|
1330
|
+
const root = await this.ensureProjectDocsRoot(projectId);
|
|
1331
|
+
if (root) this.promptNewDirectory(root.id, "");
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
private async promptNewProjectDoc(projectId: string): Promise<void> {
|
|
1335
|
+
const root = await this.ensureProjectDocsRoot(projectId);
|
|
1336
|
+
if (root) this.promptNewDoc(root.id, "");
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
private async promptNewProjectBoard(projectId: string): Promise<void> {
|
|
1340
|
+
const root = await this.ensureProjectDocsRoot(projectId);
|
|
1341
|
+
if (root) this.promptNewBoard(root.id, "");
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
private promptNewDoc(rootId: string, dirPath: string): void {
|
|
1345
|
+
// Find the container in the sidebar for this directory to insert inline input
|
|
1346
|
+
const containerKey = dirPath ? `doc:${rootId}:${dirPath}` : rootId;
|
|
1347
|
+
const collapsible = this.sidebar.collapsibles.get(containerKey);
|
|
1348
|
+
const container = collapsible?.body ?? this.sidebar.element.querySelector(".nav-repo-body");
|
|
1349
|
+
|
|
1350
|
+
if (container) {
|
|
1351
|
+
this.sidebar.startInlineCreate(container as HTMLElement, "document-name.md", async (raw) => {
|
|
1352
|
+
const name = validateNewItemName(raw.replace(/\.md$/i, ""), "doc");
|
|
1353
|
+
if (!name.ok) { showToast(name.message); return; }
|
|
1354
|
+
const filePath = dirPath ? `${dirPath}/${name.slug}.md` : `${name.slug}.md`;
|
|
1355
|
+
try {
|
|
1356
|
+
await ContentApi.writeFile(rootId, filePath, `# ${prettify(name.slug)}\n`);
|
|
1357
|
+
await this.refreshContentIndex();
|
|
1358
|
+
location.hash = rootDocHash(rootId, filePath);
|
|
1359
|
+
} catch { showToast("Could not create doc"); }
|
|
1360
|
+
});
|
|
1361
|
+
} else {
|
|
1362
|
+
// Fallback to modal if we can't find the container
|
|
1363
|
+
openModal("New doc", (body, close) => {
|
|
1364
|
+
const input = document.createElement("input");
|
|
1365
|
+
input.className = "board-title";
|
|
1366
|
+
input.placeholder = "Document name (e.g. overview)";
|
|
1367
|
+
input.style.width = "100%";
|
|
1368
|
+
input.style.marginBottom = "12px";
|
|
1369
|
+
const actions = el("div", "modal-actions");
|
|
1370
|
+
const create = el("button", "btn primary") as HTMLButtonElement;
|
|
1371
|
+
create.textContent = "Create";
|
|
1372
|
+
const go = async () => {
|
|
1373
|
+
const name = validateNewItemName(input.value.replace(/\.md$/i, ""), "doc");
|
|
1374
|
+
if (!name.ok) { showToast(name.message); input.focus(); return; }
|
|
1375
|
+
const filePath = dirPath ? `${dirPath}/${name.slug}.md` : `${name.slug}.md`;
|
|
1376
|
+
try {
|
|
1377
|
+
await ContentApi.writeFile(rootId, filePath, `# ${prettify(name.slug)}\n`);
|
|
1378
|
+
close();
|
|
1379
|
+
await this.refreshContentIndex();
|
|
1380
|
+
location.hash = rootDocHash(rootId, filePath);
|
|
1381
|
+
} catch { showToast("Could not create doc file"); }
|
|
1382
|
+
};
|
|
1383
|
+
create.addEventListener("click", () => void go());
|
|
1384
|
+
input.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") void go(); });
|
|
1385
|
+
actions.appendChild(create);
|
|
1386
|
+
body.append(input, actions);
|
|
1387
|
+
input.focus();
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
private promptNewCapability(rootId: string, dirPath: string): void {
|
|
1393
|
+
openModal("New capability", (body, close) => {
|
|
1394
|
+
const input = document.createElement("input");
|
|
1395
|
+
input.className = "board-title";
|
|
1396
|
+
input.placeholder = "Capability name (e.g. auth-flow)";
|
|
1397
|
+
input.style.width = "100%";
|
|
1398
|
+
input.style.marginBottom = "12px";
|
|
1399
|
+
|
|
1400
|
+
const actions = el("div", "modal-actions");
|
|
1401
|
+
const create = el("button", "btn primary") as HTMLButtonElement;
|
|
1402
|
+
create.textContent = "Create";
|
|
1403
|
+
const go = async () => {
|
|
1404
|
+
const name = (input.value.trim() || "untitled")
|
|
1405
|
+
.replace(/\.md$/, "")
|
|
1406
|
+
.replace(/\s+/g, "-")
|
|
1407
|
+
.toLowerCase();
|
|
1408
|
+
// Capabilities always live under the capabilities/ folder.
|
|
1409
|
+
// If dirPath is given we nest within it, otherwise root of capabilities/.
|
|
1410
|
+
const capPath = dirPath
|
|
1411
|
+
? `capabilities/${dirPath}/${name}.md`
|
|
1412
|
+
: `capabilities/${name}.md`;
|
|
1413
|
+
const title = prettify(name);
|
|
1414
|
+
const stub = `---\ntitle: ${title}\nsummary: \ntags: \n---\n\n# ${title}\n`;
|
|
1415
|
+
try {
|
|
1416
|
+
await ContentApi.writeFile(rootId, capPath, stub);
|
|
1417
|
+
close();
|
|
1418
|
+
await this.refreshContentIndex();
|
|
1419
|
+
location.hash = rootCapabilityHash(rootId, capPath);
|
|
1420
|
+
} catch {
|
|
1421
|
+
showToast("Could not create capability");
|
|
1422
|
+
}
|
|
1423
|
+
};
|
|
1424
|
+
create.addEventListener("click", () => void go());
|
|
1425
|
+
input.addEventListener("keydown", (e) => {
|
|
1426
|
+
e.stopPropagation();
|
|
1427
|
+
if (e.key === "Enter") void go();
|
|
1428
|
+
});
|
|
1429
|
+
actions.appendChild(create);
|
|
1430
|
+
body.append(input, actions);
|
|
1431
|
+
input.focus();
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
private promptRenameDoc(rootId: string, filePath: string): void {
|
|
1436
|
+
const dir = filePath.includes("/") ? filePath.slice(0, filePath.lastIndexOf("/") + 1) : "";
|
|
1437
|
+
const current = filePath.replace(/\.md$/, "").split("/").pop() ?? "";
|
|
1438
|
+
const linkKey = `doc:${rootId}:${filePath}`;
|
|
1439
|
+
const linkEl = this.sidebar.links.get(linkKey) as HTMLElement | undefined;
|
|
1440
|
+
|
|
1441
|
+
if (linkEl) {
|
|
1442
|
+
this.sidebar.startInlineRename(linkEl, current, async (newName) => {
|
|
1443
|
+
const name = newName.replace(/\.md$/, "").replace(/\s+/g, "-").toLowerCase();
|
|
1444
|
+
const newPath = `${dir}${name}.md`;
|
|
1445
|
+
if (newPath === filePath) return;
|
|
1446
|
+
try {
|
|
1447
|
+
await ContentApi.renameFile(rootId, filePath, newPath);
|
|
1448
|
+
await this.refreshContentIndex();
|
|
1449
|
+
location.hash = rootDocHash(rootId, newPath);
|
|
1450
|
+
} catch { showToast("Could not rename doc"); }
|
|
1451
|
+
});
|
|
1452
|
+
} else {
|
|
1453
|
+
// Fallback
|
|
1454
|
+
openModal("Rename doc", (body, close) => {
|
|
1455
|
+
const input = document.createElement("input");
|
|
1456
|
+
input.className = "board-title";
|
|
1457
|
+
input.value = current;
|
|
1458
|
+
input.style.width = "100%";
|
|
1459
|
+
input.style.marginBottom = "12px";
|
|
1460
|
+
input.select();
|
|
1461
|
+
const actions = el("div", "modal-actions");
|
|
1462
|
+
const save = el("button", "btn primary") as HTMLButtonElement;
|
|
1463
|
+
save.textContent = "Rename";
|
|
1464
|
+
const go = async () => {
|
|
1465
|
+
const name = (input.value.trim() || current).replace(/\.md$/, "").replace(/\s+/g, "-").toLowerCase();
|
|
1466
|
+
const newPath = `${dir}${name}.md`;
|
|
1467
|
+
if (newPath === filePath) { close(); return; }
|
|
1468
|
+
try {
|
|
1469
|
+
await ContentApi.renameFile(rootId, filePath, newPath);
|
|
1470
|
+
close();
|
|
1471
|
+
await this.refreshContentIndex();
|
|
1472
|
+
location.hash = rootDocHash(rootId, newPath);
|
|
1473
|
+
} catch { showToast("Could not rename doc"); }
|
|
1474
|
+
};
|
|
1475
|
+
save.addEventListener("click", () => void go());
|
|
1476
|
+
input.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") void go(); });
|
|
1477
|
+
actions.appendChild(save);
|
|
1478
|
+
body.append(input, actions);
|
|
1479
|
+
input.focus();
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
private async moveDoc(rootId: string, filePath: string, targetDir: string): Promise<void> {
|
|
1485
|
+
const newPath = joinPath(targetDir, pathBasename(filePath));
|
|
1486
|
+
if (newPath === filePath) return;
|
|
1487
|
+
try {
|
|
1488
|
+
const boardIds = new Set(allBoardFiles(await ContentApi.getTree(rootId)).map((node) => boardIdFromFilePath(node.path)));
|
|
1489
|
+
const md = await ContentApi.readFile(rootId, filePath);
|
|
1490
|
+
const rewritten = rewriteBoardEmbedsForDocMove(md, filePath, newPath, boardIds);
|
|
1491
|
+
await ContentApi.renameFile(rootId, filePath, newPath);
|
|
1492
|
+
if (rewritten !== md) await ContentApi.writeFile(rootId, newPath, rewritten);
|
|
1493
|
+
await this.refreshContentIndex();
|
|
1494
|
+
if (location.hash === rootDocHash(rootId, filePath)) {
|
|
1495
|
+
location.hash = rootDocHash(rootId, newPath);
|
|
1496
|
+
}
|
|
1497
|
+
showToast(`Moved ${pathBasename(filePath)}`);
|
|
1498
|
+
} catch {
|
|
1499
|
+
showToast("Could not move doc");
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
private async moveBoard(rootId: string, boardId: string, targetDir: string): Promise<void> {
|
|
1504
|
+
try {
|
|
1505
|
+
const newId = await BoardService.move(rootId, boardId, targetDir);
|
|
1506
|
+
const updatedEmbeds = await this.updateBoardEmbedReferences(rootId, boardId, newId);
|
|
1507
|
+
await this.refreshContentIndex();
|
|
1508
|
+
if (location.hash === rootBoardHash(rootId, boardId)) {
|
|
1509
|
+
location.hash = rootBoardHash(rootId, newId);
|
|
1510
|
+
}
|
|
1511
|
+
showToast(updatedEmbeds > 0
|
|
1512
|
+
? `Moved ${pathBasename(boardId)} and updated ${updatedEmbeds} embed${updatedEmbeds === 1 ? "" : "s"}`
|
|
1513
|
+
: `Moved ${pathBasename(boardId)}`);
|
|
1514
|
+
} catch (err) {
|
|
1515
|
+
showToast(err instanceof Error ? err.message : "Could not move board");
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
private async updateBoardEmbedReferences(rootId: string, oldBoardId: string, newBoardId: string): Promise<number> {
|
|
1520
|
+
if (oldBoardId === newBoardId) return 0;
|
|
1521
|
+
const tree = await ContentApi.getTree(rootId);
|
|
1522
|
+
const docs = allMarkdownFiles(tree);
|
|
1523
|
+
let updated = 0;
|
|
1524
|
+
for (const doc of docs) {
|
|
1525
|
+
const md = await ContentApi.readFile(rootId, doc.path);
|
|
1526
|
+
const next = rewriteBoardEmbedSources(md, doc.path, oldBoardId, newBoardId);
|
|
1527
|
+
if (next === md) continue;
|
|
1528
|
+
await ContentApi.writeFile(rootId, doc.path, next);
|
|
1529
|
+
updated++;
|
|
1530
|
+
}
|
|
1531
|
+
return updated;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
private async moveDirectory(rootId: string, dirPath: string, targetDir: string): Promise<void> {
|
|
1535
|
+
const name = pathBasename(dirPath);
|
|
1536
|
+
const newDirPath = joinPath(targetDir, name);
|
|
1537
|
+
if (newDirPath === dirPath) return;
|
|
1538
|
+
try {
|
|
1539
|
+
const tree = await ContentApi.getTree(rootId);
|
|
1540
|
+
const boardIds = new Set(allBoardFiles(tree).map((node) => boardIdFromFilePath(node.path)));
|
|
1541
|
+
const movedBoardIds = boardIdsUnderDirectory(tree, dirPath);
|
|
1542
|
+
const movedDocs = docsUnderDirectory(tree, dirPath);
|
|
1543
|
+
const movedDocContents = await Promise.all(
|
|
1544
|
+
movedDocs.map(async (doc) => [doc.path, await ContentApi.readFile(rootId, doc.path)] as const)
|
|
1545
|
+
);
|
|
1546
|
+
await ContentApi.renameFile(rootId, dirPath, newDirPath);
|
|
1547
|
+
let updatedEmbeds = 0;
|
|
1548
|
+
for (const [oldDocPath, md] of movedDocContents) {
|
|
1549
|
+
const newDocPath = `${newDirPath}${oldDocPath.slice(dirPath.length)}`;
|
|
1550
|
+
const rewritten = rewriteBoardEmbedsForDocMove(md, oldDocPath, newDocPath, boardIds);
|
|
1551
|
+
if (rewritten === md) continue;
|
|
1552
|
+
await ContentApi.writeFile(rootId, newDocPath, rewritten);
|
|
1553
|
+
updatedEmbeds++;
|
|
1554
|
+
}
|
|
1555
|
+
for (const oldBoardId of movedBoardIds) {
|
|
1556
|
+
const newBoardId = `${newDirPath}${oldBoardId.slice(dirPath.length)}`;
|
|
1557
|
+
await this.rewriteMovedBoardDocumentId(rootId, oldBoardId, newBoardId);
|
|
1558
|
+
updatedEmbeds += await this.updateBoardEmbedReferences(rootId, oldBoardId, newBoardId);
|
|
1559
|
+
}
|
|
1560
|
+
await this.refreshContentIndex();
|
|
1561
|
+
this.rewriteRouteAfterDirectoryMove(rootId, dirPath, newDirPath);
|
|
1562
|
+
showToast(updatedEmbeds > 0
|
|
1563
|
+
? `Moved ${name} and updated ${updatedEmbeds} embed${updatedEmbeds === 1 ? "" : "s"}`
|
|
1564
|
+
: `Moved ${name}`);
|
|
1565
|
+
} catch {
|
|
1566
|
+
showToast("Could not move directory");
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
private promptRenameDirectory(rootId: string, dirPath: string): void {
|
|
1571
|
+
const parentDir = dirPath.includes("/") ? dirPath.slice(0, dirPath.lastIndexOf("/") + 1) : "";
|
|
1572
|
+
const current = dirPath.split("/").pop() ?? "";
|
|
1573
|
+
openModal("Rename directory", (body, close) => {
|
|
1574
|
+
const input = document.createElement("input");
|
|
1575
|
+
input.className = "board-title";
|
|
1576
|
+
input.value = current;
|
|
1577
|
+
input.style.width = "100%";
|
|
1578
|
+
input.style.marginBottom = "12px";
|
|
1579
|
+
input.select();
|
|
1580
|
+
const actions = el("div", "modal-actions");
|
|
1581
|
+
const save = el("button", "btn primary") as HTMLButtonElement;
|
|
1582
|
+
save.textContent = "Rename";
|
|
1583
|
+
const go = async () => {
|
|
1584
|
+
const name = (input.value.trim() || current).replace(/\s+/g, "-").toLowerCase();
|
|
1585
|
+
const newPath = `${parentDir}${name}`;
|
|
1586
|
+
if (newPath === dirPath) { close(); return; }
|
|
1587
|
+
try {
|
|
1588
|
+
await ContentApi.renameFile(rootId, dirPath, newPath);
|
|
1589
|
+
close();
|
|
1590
|
+
await this.refreshContentIndex();
|
|
1591
|
+
showToast(`Renamed to ${name}`);
|
|
1592
|
+
} catch { showToast("Could not rename directory"); }
|
|
1593
|
+
};
|
|
1594
|
+
save.addEventListener("click", () => void go());
|
|
1595
|
+
input.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") void go(); });
|
|
1596
|
+
actions.appendChild(save);
|
|
1597
|
+
body.append(input, actions);
|
|
1598
|
+
input.focus();
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
private async promptDeleteDirectory(rootId: string, dirPath: string): Promise<void> {
|
|
1603
|
+
const name = dirPath.split("/").pop() ?? dirPath;
|
|
1604
|
+
const confirmed = confirm(`Delete "${name}" and all its contents? This cannot be undone.`);
|
|
1605
|
+
if (!confirmed) return;
|
|
1606
|
+
try {
|
|
1607
|
+
await ContentApi.deleteFile(rootId, dirPath);
|
|
1608
|
+
await this.refreshContentIndex();
|
|
1609
|
+
showToast(`Deleted ${name}`);
|
|
1610
|
+
} catch {
|
|
1611
|
+
showToast("Could not delete directory");
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
private async rewriteMovedBoardDocumentId(rootId: string, oldBoardId: string, newBoardId: string): Promise<void> {
|
|
1616
|
+
const moved = await ContentApi.getBoard(rootId, newBoardId);
|
|
1617
|
+
if (!moved) return;
|
|
1618
|
+
if (moved.id === newBoardId) return;
|
|
1619
|
+
await ContentApi.saveBoard(rootId, newBoardId, { ...moved, id: newBoardId, updatedAt: Date.now() });
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
private rewriteRouteAfterDirectoryMove(rootId: string, oldDir: string, newDir: string): void {
|
|
1623
|
+
const encodedRoot = encodeURIComponent(rootId);
|
|
1624
|
+
const docPrefix = `#/root/${encodedRoot}/docs/`;
|
|
1625
|
+
const boardPrefix = `#/root/${encodedRoot}/boards/`;
|
|
1626
|
+
const hash = location.hash;
|
|
1627
|
+
if (hash.startsWith(docPrefix)) {
|
|
1628
|
+
const current = decodeURIComponent(hash.slice(docPrefix.length));
|
|
1629
|
+
if (current === oldDir || current.startsWith(`${oldDir}/`)) {
|
|
1630
|
+
location.hash = rootDocHash(rootId, `${newDir}${current.slice(oldDir.length)}`);
|
|
1631
|
+
}
|
|
1632
|
+
} else if (hash.startsWith(boardPrefix)) {
|
|
1633
|
+
const current = decodeURIComponent(hash.slice(boardPrefix.length));
|
|
1634
|
+
if (current === oldDir || current.startsWith(`${oldDir}/`)) {
|
|
1635
|
+
location.hash = rootBoardHash(rootId, `${newDir}${current.slice(oldDir.length)}`);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
private async promptDeleteDoc(rootId: string, filePath: string): Promise<void> {
|
|
1641
|
+
const name = filePath.split("/").pop() ?? filePath;
|
|
1642
|
+
openModal("Delete doc", (body, close) => {
|
|
1643
|
+
const msg = el("p", "modal-copy");
|
|
1644
|
+
msg.textContent = `Delete "${name}"? This cannot be undone.`;
|
|
1645
|
+
const actions = el("div", "modal-actions");
|
|
1646
|
+
const del = el("button", "btn danger") as HTMLButtonElement;
|
|
1647
|
+
del.textContent = "Delete";
|
|
1648
|
+
del.addEventListener("click", async () => {
|
|
1649
|
+
try {
|
|
1650
|
+
await ContentApi.deleteFile(rootId, filePath);
|
|
1651
|
+
close();
|
|
1652
|
+
await this.refreshContentIndex();
|
|
1653
|
+
location.hash = "#/";
|
|
1654
|
+
} catch { showToast("Could not delete doc"); }
|
|
1655
|
+
});
|
|
1656
|
+
const cancel = el("button", "btn") as HTMLButtonElement;
|
|
1657
|
+
cancel.textContent = "Cancel";
|
|
1658
|
+
cancel.addEventListener("click", close);
|
|
1659
|
+
actions.append(del, cancel);
|
|
1660
|
+
body.append(msg, actions);
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
private async promptRenameBoard(rootId: string, boardId: string): Promise<void> {
|
|
1665
|
+
const doc = await BoardService.load(rootId, boardId);
|
|
1666
|
+
if (!doc) { showToast("Board not found"); return; }
|
|
1667
|
+
openModal("Rename board", (body, close) => {
|
|
1668
|
+
const input = document.createElement("input");
|
|
1669
|
+
input.className = "board-title";
|
|
1670
|
+
input.value = doc.title;
|
|
1671
|
+
input.style.width = "100%";
|
|
1672
|
+
input.style.marginBottom = "12px";
|
|
1673
|
+
input.select();
|
|
1674
|
+
const actions = el("div", "modal-actions");
|
|
1675
|
+
const save = el("button", "btn primary") as HTMLButtonElement;
|
|
1676
|
+
save.textContent = "Rename";
|
|
1677
|
+
const go = async () => {
|
|
1678
|
+
const name = validateNewItemName(input.value, "board");
|
|
1679
|
+
if (!name.ok) {
|
|
1680
|
+
showToast(name.message);
|
|
1681
|
+
input.focus();
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
try {
|
|
1685
|
+
const newId = await BoardService.rename(rootId, boardId, name.raw);
|
|
1686
|
+
const updatedEmbeds = await this.updateBoardEmbedReferences(rootId, boardId, newId);
|
|
1687
|
+
close();
|
|
1688
|
+
await this.refreshContentIndex();
|
|
1689
|
+
if (location.hash === rootBoardHash(rootId, boardId)) {
|
|
1690
|
+
location.hash = rootBoardHash(rootId, newId);
|
|
1691
|
+
}
|
|
1692
|
+
if (updatedEmbeds > 0) {
|
|
1693
|
+
showToast(`Renamed board and updated ${updatedEmbeds} embed${updatedEmbeds === 1 ? "" : "s"}`);
|
|
1694
|
+
}
|
|
1695
|
+
} catch (err) {
|
|
1696
|
+
showToast(err instanceof Error ? err.message : "Could not rename board");
|
|
1697
|
+
}
|
|
1698
|
+
};
|
|
1699
|
+
save.addEventListener("click", () => void go());
|
|
1700
|
+
input.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") void go(); });
|
|
1701
|
+
actions.appendChild(save);
|
|
1702
|
+
body.append(input, actions);
|
|
1703
|
+
input.focus();
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
private promptDeleteBoard(rootId: string, boardId: string): void {
|
|
1708
|
+
openModal("Delete board", (body, close) => {
|
|
1709
|
+
const msg = el("p", "modal-copy");
|
|
1710
|
+
msg.textContent = `Delete "${boardId}.board.json"? This cannot be undone.`;
|
|
1711
|
+
const actions = el("div", "modal-actions");
|
|
1712
|
+
const del = el("button", "btn danger") as HTMLButtonElement;
|
|
1713
|
+
del.textContent = "Delete";
|
|
1714
|
+
del.addEventListener("click", async () => {
|
|
1715
|
+
try {
|
|
1716
|
+
await BoardService.delete(rootId, boardId);
|
|
1717
|
+
close();
|
|
1718
|
+
await this.refreshContentIndex();
|
|
1719
|
+
location.hash = rootBoardsHash(rootId);
|
|
1720
|
+
} catch { showToast("Could not delete board"); }
|
|
1721
|
+
});
|
|
1722
|
+
const cancel = el("button", "btn") as HTMLButtonElement;
|
|
1723
|
+
cancel.textContent = "Cancel";
|
|
1724
|
+
cancel.addEventListener("click", close);
|
|
1725
|
+
actions.append(del, cancel);
|
|
1726
|
+
body.append(msg, actions);
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
private async openEditor(rootId: string, id: string, initialRegion?: BoardRegion): Promise<void> {
|
|
1731
|
+
if (!this.docsRootById(rootId)) {
|
|
1732
|
+
this.renderMessage("Docs root unavailable", "This docs root is no longer registered. Re-add the project root if needed.");
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
if (this.editor && this.editingId === `${rootId}:${id}`) return;
|
|
1736
|
+
this.exitEditor();
|
|
1737
|
+
this.disposeContent();
|
|
1738
|
+
this.layout.style.display = "none";
|
|
1739
|
+
|
|
1740
|
+
this.editorHost = el("div", "board-editor-host");
|
|
1741
|
+
this.root.appendChild(this.editorHost);
|
|
1742
|
+
this.editingId = `${rootId}:${id}`;
|
|
1743
|
+
this.editor = new AppController(this.editorHost, {
|
|
1744
|
+
boardId: id,
|
|
1745
|
+
loadBoard: (bid) => BoardService.load(rootId, bid),
|
|
1746
|
+
saveBoard: (doc) => BoardService.save(rootId, doc),
|
|
1747
|
+
deleteBoard: (bid) => BoardService.delete(rootId, bid),
|
|
1748
|
+
uploadImage: (blob, name) => BoardService.uploadImage(rootId, blob, name),
|
|
1749
|
+
resolveAssetUrl: (src) => ContentApi.assetUrl(rootId, src),
|
|
1750
|
+
initialRegion,
|
|
1751
|
+
onExit: () => {
|
|
1752
|
+
this.pendingScrollRestore = this.lastNonBoardScrollTop;
|
|
1753
|
+
location.hash = this.lastNonBoardHash;
|
|
1754
|
+
},
|
|
1755
|
+
onDeleted: () => {
|
|
1756
|
+
location.hash = rootBoardsHash(rootId);
|
|
1757
|
+
},
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
private exitEditor(): void {
|
|
1762
|
+
if (this.editor) {
|
|
1763
|
+
this.editor.destroy();
|
|
1764
|
+
this.editor = undefined;
|
|
1765
|
+
}
|
|
1766
|
+
if (this.editorHost) {
|
|
1767
|
+
this.editorHost.remove();
|
|
1768
|
+
this.editorHost = undefined;
|
|
1769
|
+
}
|
|
1770
|
+
this.editingId = undefined;
|
|
1771
|
+
// editor may have set data-theme from the board doc; restore shell theme
|
|
1772
|
+
this.applyInitialTheme();
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
// ---------- theme ----------
|
|
1776
|
+
|
|
1777
|
+
private applyInitialTheme(): void {
|
|
1778
|
+
const saved = localStorage.getItem(THEME_KEY);
|
|
1779
|
+
const theme =
|
|
1780
|
+
saved === "dark" || saved === "light"
|
|
1781
|
+
? saved
|
|
1782
|
+
: window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
1783
|
+
? "dark"
|
|
1784
|
+
: "light";
|
|
1785
|
+
document.documentElement.setAttribute("data-theme", theme);
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
private toggleTheme(): void {
|
|
1789
|
+
const next = currentTheme() === "dark" ? "light" : "dark";
|
|
1790
|
+
document.documentElement.setAttribute("data-theme", next);
|
|
1791
|
+
localStorage.setItem(THEME_KEY, next);
|
|
1792
|
+
this.updateThemeButton();
|
|
1793
|
+
this.route({ preserveScroll: true }); // re-render so board previews pick up the new theme
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
// ---------- top bar + command palette ----------
|
|
1797
|
+
|
|
1798
|
+
private createTopbar(): HTMLElement {
|
|
1799
|
+
const bar = el("div", "shell-topbar");
|
|
1800
|
+
|
|
1801
|
+
const navToggle = el("button", "topbar-icon-btn") as HTMLButtonElement;
|
|
1802
|
+
navToggle.innerHTML = ICONS.panelLeft;
|
|
1803
|
+
navToggle.title = "Toggle navigation";
|
|
1804
|
+
navToggle.setAttribute("aria-label", "Toggle navigation");
|
|
1805
|
+
navToggle.addEventListener("click", () =>
|
|
1806
|
+
this.setSidebarCollapsed(!this.layout.classList.contains("sidebar-collapsed"))
|
|
1807
|
+
);
|
|
1808
|
+
|
|
1809
|
+
this.searchBtn = el("button", "topbar-search") as HTMLButtonElement;
|
|
1810
|
+
this.searchBtn.type = "button";
|
|
1811
|
+
this.searchBtn.innerHTML = `${ICONS.search}<span>Search docs</span><kbd>${this.shortcutLabel()}</kbd>`;
|
|
1812
|
+
this.searchBtn.addEventListener("click", () => this.openCommandPalette());
|
|
1813
|
+
|
|
1814
|
+
this.cartBtn = el("button", "topbar-cart") as HTMLButtonElement;
|
|
1815
|
+
this.cartBtn.type = "button";
|
|
1816
|
+
this.cartBtn.addEventListener("click", () => this.toggleCartPopover());
|
|
1817
|
+
this.updateTopbarCart();
|
|
1818
|
+
|
|
1819
|
+
this.editBtn = el("button", "btn topbar-edit") as HTMLButtonElement;
|
|
1820
|
+
this.editBtn.type = "button";
|
|
1821
|
+
this.editBtn.innerHTML = `${ICONS.edit}<span>Edit</span>`;
|
|
1822
|
+
this.editBtn.title = "Edit this document";
|
|
1823
|
+
this.editBtn.addEventListener("click", () => {
|
|
1824
|
+
if (this.currentEditableFile) this.openDocEditor(this.currentEditableFile);
|
|
1825
|
+
});
|
|
1826
|
+
|
|
1827
|
+
this.themeBtn = el("button", "topbar-icon-btn topbar-theme") as HTMLButtonElement;
|
|
1828
|
+
this.themeBtn.type = "button";
|
|
1829
|
+
this.themeBtn.title = "Toggle theme";
|
|
1830
|
+
this.themeBtn.setAttribute("aria-label", "Toggle theme");
|
|
1831
|
+
this.themeBtn.addEventListener("click", () => this.toggleTheme());
|
|
1832
|
+
this.updateThemeButton();
|
|
1833
|
+
|
|
1834
|
+
bar.append(navToggle, this.searchBtn, this.cartBtn, this.editBtn, this.themeBtn);
|
|
1835
|
+
return bar;
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
private setSidebarCollapsed(collapsed: boolean): void {
|
|
1839
|
+
this.layout.classList.toggle("sidebar-collapsed", collapsed);
|
|
1840
|
+
localStorage.setItem(SIDEBAR_KEY, String(collapsed));
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
private setEditableFile(file: EditableFileRef | undefined): void {
|
|
1844
|
+
this.currentEditableFile = file;
|
|
1845
|
+
if (!this.editBtn) return;
|
|
1846
|
+
this.editBtn.hidden = !file;
|
|
1847
|
+
this.editBtn.disabled = !file;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
private promptRemoveProject(projectId: string, label: string): void {
|
|
1851
|
+
openModal("Remove from workspace", (body, close) => {
|
|
1852
|
+
const msg = el("p", "modal-copy");
|
|
1853
|
+
msg.textContent = `Remove "${label}" from the workspace? The files on disk are not deleted.`;
|
|
1854
|
+
const actions = el("div", "modal-actions");
|
|
1855
|
+
const remove = el("button", "btn danger") as HTMLButtonElement;
|
|
1856
|
+
remove.textContent = "Remove";
|
|
1857
|
+
remove.addEventListener("click", async () => {
|
|
1858
|
+
try {
|
|
1859
|
+
await ContentApi.removeProject(projectId);
|
|
1860
|
+
close();
|
|
1861
|
+
await this.refreshContentIndex();
|
|
1862
|
+
location.hash = "#/";
|
|
1863
|
+
} catch {
|
|
1864
|
+
showToast("Could not remove project");
|
|
1865
|
+
}
|
|
1866
|
+
});
|
|
1867
|
+
const cancel = el("button", "btn") as HTMLButtonElement;
|
|
1868
|
+
cancel.textContent = "Cancel";
|
|
1869
|
+
cancel.addEventListener("click", close);
|
|
1870
|
+
actions.append(remove, cancel);
|
|
1871
|
+
body.append(msg, actions);
|
|
1872
|
+
});
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
private async addRepoDocs(): Promise<void> {
|
|
1876
|
+
try {
|
|
1877
|
+
const before = new Set(this.allDocsRoots().map((root) => root.id));
|
|
1878
|
+
const result = await ContentApi.pickAndAddProject();
|
|
1879
|
+
if (result.cancelled) return;
|
|
1880
|
+
await this.refreshContentIndex();
|
|
1881
|
+
const roots = this.allDocsRoots();
|
|
1882
|
+
const preferred = result.addedProjectId
|
|
1883
|
+
? this.workspace.projects.find((project) => project.id === result.addedProjectId)?.docsRoots[0]
|
|
1884
|
+
: roots.find((root) => !before.has(root.id));
|
|
1885
|
+
const targetRoot = preferred ?? roots[0];
|
|
1886
|
+
if (!targetRoot) {
|
|
1887
|
+
showToast("No docs folders found under that project root.");
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
const tree = this.treesByRoot.get(targetRoot.id) ?? [];
|
|
1891
|
+
const firstDoc = firstMarkdownPath(visibleDocsTree(tree));
|
|
1892
|
+
if (firstDoc) location.hash = rootDocHash(targetRoot.id, firstDoc);
|
|
1893
|
+
else location.hash = rootBoardsHash(targetRoot.id);
|
|
1894
|
+
showToast(`Added ${targetRoot.projectLabel}.`);
|
|
1895
|
+
} catch (err) {
|
|
1896
|
+
showToast(err instanceof Error ? err.message : "Could not add project root.");
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
private updateTopbarCart(hidden = false): void {
|
|
1901
|
+
if (!this.cartBtn) return;
|
|
1902
|
+
const count = this.capabilityCart.size;
|
|
1903
|
+
this.cartBtn.hidden = hidden;
|
|
1904
|
+
this.cartBtn.innerHTML = `<span>Cart</span><strong>${count}</strong>`;
|
|
1905
|
+
this.cartBtn.title = `${count} item${count === 1 ? "" : "s"} in component cart`;
|
|
1906
|
+
this.cartBtn.setAttribute("aria-label", this.cartBtn.title);
|
|
1907
|
+
this.cartBtn.disabled = count === 0;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
private toggleCartPopover(): void {
|
|
1911
|
+
if (this.cartPopover?.classList.contains("visible")) {
|
|
1912
|
+
this.closeCartPopover();
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
this.openCartPopover();
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
private openCartPopover(): void {
|
|
1919
|
+
if (this.capabilityCart.size === 0) return;
|
|
1920
|
+
if (!this.cartPopover) {
|
|
1921
|
+
this.cartPopover = el("div", "topbar-cart-popover");
|
|
1922
|
+
this.topbar.appendChild(this.cartPopover);
|
|
1923
|
+
document.addEventListener("mousedown", (e) => {
|
|
1924
|
+
if (!this.cartPopover?.classList.contains("visible")) return;
|
|
1925
|
+
const target = e.target as Node;
|
|
1926
|
+
if (this.cartPopover.contains(target) || this.cartBtn.contains(target)) return;
|
|
1927
|
+
this.closeCartPopover();
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
this.cartPopover.replaceChildren(
|
|
1931
|
+
this.renderCapabilityCart(() => {
|
|
1932
|
+
this.updateTopbarCart();
|
|
1933
|
+
if (this.capabilityCart.size === 0) this.closeCartPopover();
|
|
1934
|
+
else this.openCartPopover();
|
|
1935
|
+
})
|
|
1936
|
+
);
|
|
1937
|
+
this.content.classList.add("cart-popover-open");
|
|
1938
|
+
this.cartPopover.classList.add("visible");
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
private closeCartPopover(): void {
|
|
1942
|
+
this.content.classList.remove("cart-popover-open");
|
|
1943
|
+
this.cartPopover?.classList.remove("visible");
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
private updateThemeButton(): void {
|
|
1947
|
+
if (!this.themeBtn) return;
|
|
1948
|
+
const isDark = currentTheme() === "dark";
|
|
1949
|
+
this.themeBtn.innerHTML = isDark ? ICONS.sun : ICONS.moon;
|
|
1950
|
+
this.themeBtn.title = isDark ? "Switch to light mode" : "Switch to dark mode";
|
|
1951
|
+
this.themeBtn.setAttribute("aria-label", this.themeBtn.title);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
private handleGlobalKeydown(e: KeyboardEvent): void {
|
|
1955
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
|
1956
|
+
const target = e.target as HTMLElement | null;
|
|
1957
|
+
if (target?.closest(".doc-editor")) return;
|
|
1958
|
+
e.preventDefault();
|
|
1959
|
+
this.openCommandPalette();
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
// F2 to rename the current item
|
|
1964
|
+
if (e.key === "F2") {
|
|
1965
|
+
const hash = location.hash;
|
|
1966
|
+
const target = e.target as HTMLElement | null;
|
|
1967
|
+
if (target?.closest(".doc-editor, input, textarea, [contenteditable]")) return;
|
|
1968
|
+
e.preventDefault();
|
|
1969
|
+
|
|
1970
|
+
// Check if we're viewing a doc
|
|
1971
|
+
const docMatch = hash.match(/^#\/root\/([^/]+)\/docs\/(.+)$/);
|
|
1972
|
+
if (docMatch) {
|
|
1973
|
+
const rootId = decodeURIComponent(docMatch[1]);
|
|
1974
|
+
const filePath = decodeURIComponent(docMatch[2]);
|
|
1975
|
+
this.promptRenameDoc(rootId, filePath);
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
// Check if we're viewing a board
|
|
1980
|
+
const boardMatch = hash.match(/^#\/root\/([^/]+)\/boards\/(.+)$/);
|
|
1981
|
+
if (boardMatch) {
|
|
1982
|
+
const rootId = decodeURIComponent(boardMatch[1]);
|
|
1983
|
+
const boardId = decodeURIComponent(boardMatch[2]);
|
|
1984
|
+
this.promptRenameBoard(rootId, boardId);
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
// Check if we're viewing a directory wiki
|
|
1989
|
+
const dirMatch = hash.match(/^#\/root\/([^/]+)\/directory\/(.+)$/);
|
|
1990
|
+
if (dirMatch) {
|
|
1991
|
+
const rootId = decodeURIComponent(dirMatch[1]);
|
|
1992
|
+
const dirPath = decodeURIComponent(dirMatch[2]);
|
|
1993
|
+
this.promptRenameDirectory(rootId, dirPath);
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
private allDocsRoots(): DocsRootSummary[] {
|
|
2000
|
+
return this.workspace.projects.flatMap((project) => project.docsRoots);
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
private docsRootById(rootId: string): DocsRootSummary | undefined {
|
|
2004
|
+
return this.allDocsRoots().find((root) => root.id === rootId);
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
private async refreshContentIndex(): Promise<void> {
|
|
2008
|
+
try {
|
|
2009
|
+
this.workspace = await ContentApi.getWorkspace();
|
|
2010
|
+
} catch {
|
|
2011
|
+
this.workspace = { projects: [] };
|
|
2012
|
+
this.treesByRoot = new Map();
|
|
2013
|
+
this.treesByProject = new Map();
|
|
2014
|
+
this.sidebar.refresh(this.workspace, this.treesByRoot);
|
|
2015
|
+
this.searchIndex = [];
|
|
2016
|
+
return;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
const roots = this.allDocsRoots();
|
|
2020
|
+
const trees = await Promise.all(
|
|
2021
|
+
roots.map(async (root) => {
|
|
2022
|
+
try {
|
|
2023
|
+
return [root.id, await ContentApi.getTree(root.id)] as const;
|
|
2024
|
+
} catch {
|
|
2025
|
+
return [root.id, [] as TreeNode[]] as const;
|
|
2026
|
+
}
|
|
2027
|
+
})
|
|
2028
|
+
);
|
|
2029
|
+
this.treesByRoot = new Map(trees);
|
|
2030
|
+
|
|
2031
|
+
// Also fetch full project trees for sidebar
|
|
2032
|
+
const projectTrees = await Promise.all(
|
|
2033
|
+
this.workspace.projects.map(async (project) => {
|
|
2034
|
+
try {
|
|
2035
|
+
return [project.id, await ContentApi.getProjectTree(project.id)] as const;
|
|
2036
|
+
} catch {
|
|
2037
|
+
return [project.id, [] as TreeNode[]] as const;
|
|
2038
|
+
}
|
|
2039
|
+
})
|
|
2040
|
+
);
|
|
2041
|
+
this.treesByProject = new Map(projectTrees);
|
|
2042
|
+
|
|
2043
|
+
this.sidebar.refresh(this.workspace, this.treesByRoot, this.treesByProject);
|
|
2044
|
+
|
|
2045
|
+
const entries: SearchEntry[] = [];
|
|
2046
|
+
for (const root of roots) {
|
|
2047
|
+
const tree = this.treesByRoot.get(root.id) ?? [];
|
|
2048
|
+
const files = [...visibleMarkdownFiles(tree), ...filesUnder(tree, "capabilities")].filter(
|
|
2049
|
+
(node) => node.type === "file" && node.name.endsWith(".md")
|
|
2050
|
+
);
|
|
2051
|
+
for (const node of files) {
|
|
2052
|
+
const path = "path" in node ? node.path : "";
|
|
2053
|
+
let md = "";
|
|
2054
|
+
try {
|
|
2055
|
+
md = await ContentApi.readFile(root.id, path);
|
|
2056
|
+
} catch {
|
|
2057
|
+
/* Skip unreadable files from search, but keep the shell usable. */
|
|
2058
|
+
}
|
|
2059
|
+
const { frontmatter } = renderMarkdown(md);
|
|
2060
|
+
const chunks = searchChunksFromMarkdown(md);
|
|
2061
|
+
const title = frontmatter.title ?? firstHeading(md) ?? prettify(node.name);
|
|
2062
|
+
const summary = frontmatter.summary ?? firstParagraph(chunks);
|
|
2063
|
+
const section = path.startsWith("capabilities/") ? "Capability" : "Doc";
|
|
2064
|
+
const href = path.startsWith("capabilities/")
|
|
2065
|
+
? rootCapabilityHash(root.id, path)
|
|
2066
|
+
: rootDocHash(root.id, path);
|
|
2067
|
+
entries.push({
|
|
2068
|
+
title,
|
|
2069
|
+
path: `${root.projectLabel}/${root.displayPath}/${path}`,
|
|
2070
|
+
href,
|
|
2071
|
+
section,
|
|
2072
|
+
summary,
|
|
2073
|
+
body: chunks.map((c) => c.text).join(" "),
|
|
2074
|
+
chunks,
|
|
2075
|
+
rootId: root.id,
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
this.searchIndex = entries.filter((entry) => entry.body || entry.title);
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
private openCommandPalette(): void {
|
|
2083
|
+
if (!this.palette) this.createCommandPalette();
|
|
2084
|
+
this.palette?.classList.add("visible");
|
|
2085
|
+
document.body.style.overflow = "hidden";
|
|
2086
|
+
this.paletteInput!.value = "";
|
|
2087
|
+
this.renderSearchResults("");
|
|
2088
|
+
requestAnimationFrame(() => this.paletteInput?.focus());
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
private closeCommandPalette(): void {
|
|
2092
|
+
this.palette?.classList.remove("visible");
|
|
2093
|
+
document.body.style.overflow = "";
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
private createCommandPalette(): void {
|
|
2097
|
+
this.palette = el("div", "command-palette");
|
|
2098
|
+
const panel = el("div", "command-panel");
|
|
2099
|
+
const search = el("div", "command-search");
|
|
2100
|
+
search.innerHTML = ICONS.search;
|
|
2101
|
+
this.paletteInput = document.createElement("input");
|
|
2102
|
+
this.paletteInput.type = "search";
|
|
2103
|
+
this.paletteInput.placeholder = "Search docs";
|
|
2104
|
+
this.paletteInput.setAttribute("aria-label", "Search docs");
|
|
2105
|
+
search.appendChild(this.paletteInput);
|
|
2106
|
+
this.paletteResults = el("div", "command-results");
|
|
2107
|
+
panel.append(search, this.paletteResults);
|
|
2108
|
+
this.palette.appendChild(panel);
|
|
2109
|
+
document.body.appendChild(this.palette);
|
|
2110
|
+
|
|
2111
|
+
this.palette.addEventListener("mousedown", (e) => {
|
|
2112
|
+
if (e.target === this.palette) this.closeCommandPalette();
|
|
2113
|
+
});
|
|
2114
|
+
this.paletteInput.addEventListener("input", () => this.renderSearchResults(this.paletteInput!.value));
|
|
2115
|
+
this.paletteInput.addEventListener("keydown", (e) => this.handlePaletteKeydown(e));
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
private handlePaletteKeydown(e: KeyboardEvent): void {
|
|
2119
|
+
if (e.key === "Escape") {
|
|
2120
|
+
e.preventDefault();
|
|
2121
|
+
this.closeCommandPalette();
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
const items = [...(this.paletteResults?.querySelectorAll<HTMLAnchorElement>(".command-result") ?? [])];
|
|
2125
|
+
const active = this.paletteResults?.querySelector<HTMLAnchorElement>(".command-result.active");
|
|
2126
|
+
const index = active ? items.indexOf(active) : -1;
|
|
2127
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
2128
|
+
e.preventDefault();
|
|
2129
|
+
const next =
|
|
2130
|
+
e.key === "ArrowDown"
|
|
2131
|
+
? Math.min(index + 1, items.length - 1)
|
|
2132
|
+
: Math.max(index - 1, 0);
|
|
2133
|
+
items.forEach((item) => item.classList.remove("active"));
|
|
2134
|
+
items[next]?.classList.add("active");
|
|
2135
|
+
items[next]?.scrollIntoView({ block: "nearest" });
|
|
2136
|
+
}
|
|
2137
|
+
if (e.key === "Enter") {
|
|
2138
|
+
e.preventDefault();
|
|
2139
|
+
(active ?? items[0])?.click();
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
private renderSearchResults(query: string): void {
|
|
2144
|
+
if (!this.paletteResults) return;
|
|
2145
|
+
const terms = searchTerms(query);
|
|
2146
|
+
const results = this.rankSearch(terms).slice(0, 8);
|
|
2147
|
+
this.paletteResults.replaceChildren();
|
|
2148
|
+
if (results.length === 0) {
|
|
2149
|
+
const empty = el("div", "command-empty");
|
|
2150
|
+
empty.textContent = this.searchIndex.length ? "No matching docs." : "No docs indexed yet.";
|
|
2151
|
+
this.paletteResults.appendChild(empty);
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
for (const [i, result] of results.entries()) {
|
|
2155
|
+
const { entry } = result;
|
|
2156
|
+
const item = document.createElement("a");
|
|
2157
|
+
item.className = "command-result";
|
|
2158
|
+
if (i === 0) item.classList.add("active");
|
|
2159
|
+
item.href = entry.href;
|
|
2160
|
+
item.addEventListener("click", () => this.closeCommandPalette());
|
|
2161
|
+
const title = el("div", "command-result-title");
|
|
2162
|
+
appendHighlightedText(title, entry.title, terms);
|
|
2163
|
+
const meta = el("div", "command-result-meta");
|
|
2164
|
+
meta.textContent = [entry.section, result.heading, entry.path].filter(Boolean).join(" · ");
|
|
2165
|
+
const summary = el("div", "command-result-summary");
|
|
2166
|
+
appendHighlightedText(summary, result.preview, terms);
|
|
2167
|
+
item.append(title, meta);
|
|
2168
|
+
if (summary.textContent) item.appendChild(summary);
|
|
2169
|
+
this.paletteResults.appendChild(item);
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
private rankSearch(terms: string[]): SearchResult[] {
|
|
2174
|
+
if (terms.length === 0) {
|
|
2175
|
+
return this.searchIndex.map((entry) => ({
|
|
2176
|
+
entry,
|
|
2177
|
+
score: 0,
|
|
2178
|
+
heading: "",
|
|
2179
|
+
preview: entry.summary || entry.body.slice(0, 220),
|
|
2180
|
+
}));
|
|
2181
|
+
}
|
|
2182
|
+
const normalizedTerms = terms.map(normalizeSearchText).filter(Boolean);
|
|
2183
|
+
const phrase = normalizeSearchText(terms.join(" "));
|
|
2184
|
+
return this.searchIndex
|
|
2185
|
+
.map((entry) => {
|
|
2186
|
+
const title = normalizeSearchText(entry.title);
|
|
2187
|
+
const path = normalizeSearchText(entry.path);
|
|
2188
|
+
const summary = normalizeSearchText(entry.summary);
|
|
2189
|
+
let docScore = scoreText(title, normalizedTerms, phrase, 10);
|
|
2190
|
+
docScore += scoreText(path, normalizedTerms, phrase, 4);
|
|
2191
|
+
docScore += scoreText(summary, normalizedTerms, phrase, 3);
|
|
2192
|
+
let bestChunk: SearchChunk | undefined;
|
|
2193
|
+
let bestChunkScore = 0;
|
|
2194
|
+
for (const chunk of entry.chunks) {
|
|
2195
|
+
const headingScore = scoreText(normalizeSearchText(chunk.heading), normalizedTerms, phrase, 5);
|
|
2196
|
+
const textScore = scoreText(chunk.normalized, normalizedTerms, phrase, chunk.kind === "heading" ? 8 : 2);
|
|
2197
|
+
const chunkScore = headingScore + textScore;
|
|
2198
|
+
if (chunkScore > bestChunkScore) {
|
|
2199
|
+
bestChunkScore = chunkScore;
|
|
2200
|
+
bestChunk = chunk;
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
const score = docScore + bestChunkScore;
|
|
2204
|
+
return {
|
|
2205
|
+
entry,
|
|
2206
|
+
score,
|
|
2207
|
+
heading: bestChunk?.heading && bestChunk.heading !== entry.title ? bestChunk.heading : "",
|
|
2208
|
+
preview: bestChunk ? chunkPreview(bestChunk.text, normalizedTerms) : entry.summary,
|
|
2209
|
+
};
|
|
2210
|
+
})
|
|
2211
|
+
.filter((r) => r.score > 0)
|
|
2212
|
+
.sort((a, b) => b.score - a.score || a.entry.title.localeCompare(b.entry.title));
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
private shortcutLabel(): string {
|
|
2216
|
+
return /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘K" : "Ctrl K";
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
// ---------- helpers ----------
|
|
2220
|
+
|
|
2221
|
+
private disposeContent(): void {
|
|
2222
|
+
if (this.cleanup) {
|
|
2223
|
+
this.cleanup();
|
|
2224
|
+
this.cleanup = undefined;
|
|
2225
|
+
}
|
|
2226
|
+
this.content.classList.remove("content-wide");
|
|
2227
|
+
this.content.replaceChildren();
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
private renderMessage(title: string, detail: string): void {
|
|
2231
|
+
this.disposeContent();
|
|
2232
|
+
const page = el("div", "page");
|
|
2233
|
+
const h = el("h1", "");
|
|
2234
|
+
h.textContent = title;
|
|
2235
|
+
const p = el("p", "page-lead");
|
|
2236
|
+
p.textContent = detail;
|
|
2237
|
+
page.append(h, p);
|
|
2238
|
+
this.content.replaceChildren(page);
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
// ---------- Breadcrumb ----------
|
|
2243
|
+
|
|
2244
|
+
type Crumb = { label: string; href?: string };
|
|
2245
|
+
|
|
2246
|
+
/**
|
|
2247
|
+
* Standard breadcrumb builder for docs content.
|
|
2248
|
+
*
|
|
2249
|
+
* Given a rootId (and optional filePath or dirPath), produces a consistent
|
|
2250
|
+
* breadcrumb array: Home / Project / directory… / currentPage
|
|
2251
|
+
*
|
|
2252
|
+
* The docs root label (e.g. "docs") is intentionally omitted — it's just
|
|
2253
|
+
* scaffolding. The project name already tells you which project you're in,
|
|
2254
|
+
* and the directory segments tell you where inside it.
|
|
2255
|
+
*/
|
|
2256
|
+
function standardCrumbs(
|
|
2257
|
+
workspace: { projects: { id: string; label: string; docsRoots: { id: string; projectId: string; displayPath: string }[] }[] },
|
|
2258
|
+
rootId: string,
|
|
2259
|
+
opts: { filePath?: string; dirPath?: string; currentLabel?: string } = {}
|
|
2260
|
+
): Crumb[] {
|
|
2261
|
+
const crumbs: Crumb[] = [{ label: "Home", href: "#/" }];
|
|
2262
|
+
|
|
2263
|
+
// Find project and docs root
|
|
2264
|
+
let docsRoot: { id: string; projectId: string; displayPath: string } | undefined;
|
|
2265
|
+
for (const p of workspace.projects) {
|
|
2266
|
+
const r = p.docsRoots.find(r => r.id === rootId);
|
|
2267
|
+
if (r) {
|
|
2268
|
+
crumbs.push({ label: p.label, href: projectWikiHash(p.id) });
|
|
2269
|
+
docsRoot = r;
|
|
2270
|
+
break;
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
// Add the docs root's display path segments (the directories BETWEEN
|
|
2275
|
+
// the project root and the /docs/ folder, e.g. "calwc-esc/docs").
|
|
2276
|
+
if (docsRoot) {
|
|
2277
|
+
const rootParts = docsRoot.displayPath.split("/").filter(Boolean);
|
|
2278
|
+
for (let i = 0; i < rootParts.length; i++) {
|
|
2279
|
+
const partial = rootParts.slice(0, i + 1).join("/");
|
|
2280
|
+
crumbs.push({
|
|
2281
|
+
label: rootParts[i],
|
|
2282
|
+
href: directoryWikiHash(rootId, partial),
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
// Add the path segments within the docs root (subdirectories of /docs/)
|
|
2288
|
+
const path = opts.filePath ?? opts.dirPath ?? "";
|
|
2289
|
+
const parts = path.split("/").filter(Boolean);
|
|
2290
|
+
const dirParts = opts.filePath ? parts.slice(0, -1) : parts;
|
|
2291
|
+
for (let i = 0; i < dirParts.length; i++) {
|
|
2292
|
+
const partial = dirParts.slice(0, i + 1).join("/");
|
|
2293
|
+
crumbs.push({
|
|
2294
|
+
label: prettify(dirParts[i]),
|
|
2295
|
+
href: directoryWikiHash(rootId, partial),
|
|
2296
|
+
});
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
// Current page label
|
|
2300
|
+
if (opts.currentLabel) {
|
|
2301
|
+
crumbs.push({ label: opts.currentLabel });
|
|
2302
|
+
} else if (opts.filePath) {
|
|
2303
|
+
const fileName = parts[parts.length - 1];
|
|
2304
|
+
crumbs.push({ label: fileName === "index.md" ? "Overview" : prettify(fileName) });
|
|
2305
|
+
}
|
|
2306
|
+
return crumbs;
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
/**
|
|
2310
|
+
* Build a breadcrumb bar element.
|
|
2311
|
+
* Segments with an href become <a> links; the last segment is always plain text.
|
|
2312
|
+
*/
|
|
2313
|
+
function makeBreadcrumb(crumbs: Crumb[]): HTMLElement {
|
|
2314
|
+
const nav = el("nav", "breadcrumb");
|
|
2315
|
+
nav.setAttribute("aria-label", "Breadcrumb");
|
|
2316
|
+
for (let i = 0; i < crumbs.length; i++) {
|
|
2317
|
+
const crumb = crumbs[i];
|
|
2318
|
+
const isLast = i === crumbs.length - 1;
|
|
2319
|
+
if (!isLast && crumb.href) {
|
|
2320
|
+
const a = document.createElement("a");
|
|
2321
|
+
a.className = "breadcrumb-link";
|
|
2322
|
+
a.href = crumb.href;
|
|
2323
|
+
a.textContent = crumb.label;
|
|
2324
|
+
nav.appendChild(a);
|
|
2325
|
+
} else {
|
|
2326
|
+
const span = el("span", "breadcrumb-current");
|
|
2327
|
+
span.textContent = crumb.label;
|
|
2328
|
+
nav.appendChild(span);
|
|
2329
|
+
}
|
|
2330
|
+
// Always add separator except after the last crumb
|
|
2331
|
+
if (!isLast) {
|
|
2332
|
+
const sep = el("span", "breadcrumb-sep");
|
|
2333
|
+
sep.textContent = "/";
|
|
2334
|
+
sep.setAttribute("aria-hidden", "true");
|
|
2335
|
+
nav.appendChild(sep);
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
return nav;
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
function el(tag: string, cls: string): HTMLElement {
|
|
2342
|
+
const node = document.createElement(tag);
|
|
2343
|
+
if (cls) node.className = cls;
|
|
2344
|
+
return node;
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
function emptyNote(text: string): HTMLElement {
|
|
2348
|
+
const note = el("div", "empty-note");
|
|
2349
|
+
note.textContent = text;
|
|
2350
|
+
return note;
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
function capabilityItemFrom(
|
|
2354
|
+
rootId: string,
|
|
2355
|
+
path: string,
|
|
2356
|
+
md: string,
|
|
2357
|
+
frontmatter: Record<string, string>
|
|
2358
|
+
): CapabilityCartItem {
|
|
2359
|
+
return {
|
|
2360
|
+
path: `${rootId}:${path}`,
|
|
2361
|
+
displayPath: path,
|
|
2362
|
+
title: frontmatter.title ?? firstHeading(md) ?? prettify(path.split("/").pop() ?? path),
|
|
2363
|
+
summary: frontmatter.summary ?? firstParagraph(searchChunksFromMarkdown(md)),
|
|
2364
|
+
tags: frontmatter.tags ? frontmatter.tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
|
|
2365
|
+
md,
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
// ---------- Nav sequence (prev/next) ----------
|
|
2370
|
+
|
|
2371
|
+
type NavItem = { label: string; href: string };
|
|
2372
|
+
|
|
2373
|
+
/**
|
|
2374
|
+
* Build the full ordered list of navigable pages for a single docs root.
|
|
2375
|
+
* Order: docs tree (index first, dirs before files, alpha) →
|
|
2376
|
+
* All Boards page → each individual board (alpha by id) →
|
|
2377
|
+
* All Capabilities page → each capability (alpha).
|
|
2378
|
+
* This mirrors the sidebar order so prev/next feels natural.
|
|
2379
|
+
*/
|
|
2380
|
+
function buildNavSequence(rootId: string, tree: TreeNode[]): NavItem[] {
|
|
2381
|
+
const items: NavItem[] = [];
|
|
2382
|
+
|
|
2383
|
+
// ── docs ──────────────────────────────────────────────────────────────
|
|
2384
|
+
function walkDocs(nodes: TreeNode[]): void {
|
|
2385
|
+
const visible = visibleDocsTree(nodes);
|
|
2386
|
+
const sorted = [...visible].sort((a, b) => {
|
|
2387
|
+
const ai = a.type === "file" && a.name === "index.md" ? -1 : 0;
|
|
2388
|
+
const bi = b.type === "file" && b.name === "index.md" ? -1 : 0;
|
|
2389
|
+
if (ai !== bi) return ai - bi;
|
|
2390
|
+
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
|
|
2391
|
+
return a.name.localeCompare(b.name);
|
|
2392
|
+
});
|
|
2393
|
+
for (const node of sorted) {
|
|
2394
|
+
if (node.type === "file") {
|
|
2395
|
+
const label = node.name === "index.md" ? "Overview" : prettify(node.name);
|
|
2396
|
+
items.push({ label, href: rootDocHash(rootId, node.path) });
|
|
2397
|
+
} else {
|
|
2398
|
+
walkDocs(node.children);
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
walkDocs(tree);
|
|
2403
|
+
|
|
2404
|
+
// ── boards ────────────────────────────────────────────────────────────
|
|
2405
|
+
const boardFiles = allBoardFiles(tree)
|
|
2406
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
2407
|
+
if (boardFiles.length > 0) {
|
|
2408
|
+
items.push({ label: "All Boards", href: rootBoardsHash(rootId) });
|
|
2409
|
+
for (const f of boardFiles) {
|
|
2410
|
+
const id = boardIdFromFilePath(f.path);
|
|
2411
|
+
items.push({ label: prettify(id.split("/").pop() ?? id), href: rootBoardHash(rootId, id) });
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
// ── capabilities ──────────────────────────────────────────────────────
|
|
2416
|
+
const capsDir = tree.find(n => n.type === "dir" && n.name === "capabilities");
|
|
2417
|
+
const capFiles = capsDir?.type === "dir"
|
|
2418
|
+
? capsDir.children.filter(f => f.type === "file" && f.name.endsWith(".md"))
|
|
2419
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
2420
|
+
: [];
|
|
2421
|
+
if (capFiles.length > 0) {
|
|
2422
|
+
items.push({ label: "All Capabilities", href: rootCapabilitiesHash(rootId) });
|
|
2423
|
+
for (const f of capFiles) {
|
|
2424
|
+
items.push({ label: prettify(f.name), href: rootCapabilityHash(rootId, f.path) });
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
return items;
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
/** Render a prev/next navigation bar given the full sequence and current href. */
|
|
2432
|
+
function makePageNav(sequence: NavItem[], currentHref: string): HTMLElement | null {
|
|
2433
|
+
const idx = sequence.findIndex(item => item.href === currentHref);
|
|
2434
|
+
if (idx === -1) return null;
|
|
2435
|
+
const prev = idx > 0 ? sequence[idx - 1] : null;
|
|
2436
|
+
const next = idx < sequence.length - 1 ? sequence[idx + 1] : null;
|
|
2437
|
+
if (!prev && !next) return null;
|
|
2438
|
+
|
|
2439
|
+
const nav = el("nav", "page-nav");
|
|
2440
|
+
if (prev) {
|
|
2441
|
+
const a = el("a", "page-nav-prev") as HTMLAnchorElement;
|
|
2442
|
+
(a as HTMLAnchorElement).href = prev.href;
|
|
2443
|
+
a.innerHTML =
|
|
2444
|
+
`<span class="page-nav-arrow">←</span>` +
|
|
2445
|
+
`<span class="page-nav-label">${prev.label}</span>`;
|
|
2446
|
+
nav.appendChild(a);
|
|
2447
|
+
} else {
|
|
2448
|
+
nav.appendChild(el("span", "page-nav-spacer"));
|
|
2449
|
+
}
|
|
2450
|
+
if (next) {
|
|
2451
|
+
const a = el("a", "page-nav-next") as HTMLAnchorElement;
|
|
2452
|
+
(a as HTMLAnchorElement).href = next.href;
|
|
2453
|
+
a.innerHTML =
|
|
2454
|
+
`<span class="page-nav-label">${next.label}</span>` +
|
|
2455
|
+
`<span class="page-nav-arrow">→</span>`;
|
|
2456
|
+
nav.appendChild(a);
|
|
2457
|
+
}
|
|
2458
|
+
return nav;
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
/** Compute rootCapabilitiesHash inline (mirrors the sidebar helper). */
|
|
2462
|
+
function rootCapabilitiesHash(rootId: string): string {
|
|
2463
|
+
return `#/root/${encodeURIComponent(rootId)}/capabilities`;
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
function visibleDocsTree(tree: TreeNode[]): TreeNode[] {
|
|
2467
|
+
return tree
|
|
2468
|
+
.filter((node) => {
|
|
2469
|
+
if (node.type === "file") return node.name.endsWith(".md");
|
|
2470
|
+
if (node.name === "capabilities" || node.name === "assets") return false;
|
|
2471
|
+
return visibleDocsTree(node.children).length > 0;
|
|
2472
|
+
})
|
|
2473
|
+
.map((node) => {
|
|
2474
|
+
if (node.type === "file") return node;
|
|
2475
|
+
return { ...node, children: visibleDocsTree(node.children) };
|
|
2476
|
+
});
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
function visibleMarkdownFiles(tree: TreeNode[]): TreeNode[] {
|
|
2480
|
+
const out: TreeNode[] = [];
|
|
2481
|
+
const walk = (nodes: TreeNode[]) => {
|
|
2482
|
+
for (const node of visibleDocsTree(nodes)) {
|
|
2483
|
+
if (node.type === "file") out.push(node);
|
|
2484
|
+
else walk(node.children);
|
|
2485
|
+
}
|
|
2486
|
+
};
|
|
2487
|
+
walk(tree);
|
|
2488
|
+
return out;
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
function rootDocHash(rootId: string, path: string): string {
|
|
2492
|
+
return `#/root/${encodeURIComponent(rootId)}/docs/${encodeURIComponent(path)}`;
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
function rootCapabilityHash(rootId: string, path: string): string {
|
|
2496
|
+
return `#/root/${encodeURIComponent(rootId)}/capabilities/${encodeURIComponent(path)}`;
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
function rootBoardsHash(rootId: string): string {
|
|
2500
|
+
return `#/root/${encodeURIComponent(rootId)}/boards`;
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
function rootBoardHash(rootId: string, boardId: string): string {
|
|
2504
|
+
return `#/root/${encodeURIComponent(rootId)}/boards/${encodeURIComponent(boardId)}`;
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
function projectWikiHash(projectId: string): string {
|
|
2508
|
+
return `#/project/${encodeURIComponent(projectId)}`;
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
function directoryWikiHash(rootId: string, dirPath: string): string {
|
|
2512
|
+
return `#/root/${encodeURIComponent(rootId)}/directory/${encodeURIComponent(dirPath)}`;
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
function currentRootIdFromHash(hash: string): string | null {
|
|
2516
|
+
const match = hash.match(/^#\/root\/([^/]+)/);
|
|
2517
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
function firstHeading(md: string): string | null {
|
|
2521
|
+
return md.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? null;
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
function searchTerms(query: string): string[] {
|
|
2525
|
+
return [...new Set(query.trim().split(/\s+/).map(normalizeSearchText).filter(Boolean))];
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
function appendHighlightedText(parent: HTMLElement, text: string, terms: string[]): void {
|
|
2529
|
+
const needles = terms.filter(Boolean).sort((a, b) => b.length - a.length);
|
|
2530
|
+
if (needles.length === 0) {
|
|
2531
|
+
parent.textContent = text;
|
|
2532
|
+
return;
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
const re = new RegExp(`(${needles.map(escapeRegExp).join("|")})`, "ig");
|
|
2536
|
+
let lastIndex = 0;
|
|
2537
|
+
for (const match of text.matchAll(re)) {
|
|
2538
|
+
const index = match.index ?? 0;
|
|
2539
|
+
if (index > lastIndex) parent.appendChild(document.createTextNode(text.slice(lastIndex, index)));
|
|
2540
|
+
const mark = document.createElement("mark");
|
|
2541
|
+
mark.textContent = match[0];
|
|
2542
|
+
parent.appendChild(mark);
|
|
2543
|
+
lastIndex = index + match[0].length;
|
|
2544
|
+
}
|
|
2545
|
+
if (lastIndex < text.length) parent.appendChild(document.createTextNode(text.slice(lastIndex)));
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
function escapeRegExp(text: string): string {
|
|
2549
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
function firstParagraph(chunks: SearchChunk[]): string {
|
|
2553
|
+
return chunks.find((chunk) => chunk.kind === "text" && chunk.text.length > 0)?.text.slice(0, 180) ?? "";
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
function searchChunksFromMarkdown(md: string): SearchChunk[] {
|
|
2557
|
+
const body = md.replace(/^---[\s\S]*?---\s*/, "");
|
|
2558
|
+
const lines = body.split("\n");
|
|
2559
|
+
const chunks: SearchChunk[] = [];
|
|
2560
|
+
let heading = "";
|
|
2561
|
+
let paragraph: string[] = [];
|
|
2562
|
+
|
|
2563
|
+
const push = (text: string, kind: SearchChunk["kind"] = "text", explicitHeading = heading) => {
|
|
2564
|
+
const clean = cleanMarkdownText(text);
|
|
2565
|
+
if (!clean) return;
|
|
2566
|
+
chunks.push({ heading: explicitHeading, text: clean, normalized: normalizeSearchText(clean), kind });
|
|
2567
|
+
};
|
|
2568
|
+
const flushParagraph = () => {
|
|
2569
|
+
if (paragraph.length === 0) return;
|
|
2570
|
+
push(paragraph.join(" "), "text");
|
|
2571
|
+
paragraph = [];
|
|
2572
|
+
};
|
|
2573
|
+
|
|
2574
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2575
|
+
const raw = lines[i];
|
|
2576
|
+
const fence = raw.match(/^(`{3,})\s*([\w-]+)?\s*$/);
|
|
2577
|
+
if (fence) {
|
|
2578
|
+
flushParagraph();
|
|
2579
|
+
const fenceLen = fence[1].length;
|
|
2580
|
+
const lang = (fence[2] ?? "").toLowerCase();
|
|
2581
|
+
const closeRe = new RegExp("^`{" + fenceLen + ",}\\s*$");
|
|
2582
|
+
const buf: string[] = [];
|
|
2583
|
+
i++;
|
|
2584
|
+
while (i < lines.length && !closeRe.test(lines[i])) {
|
|
2585
|
+
buf.push(lines[i]);
|
|
2586
|
+
i++;
|
|
2587
|
+
}
|
|
2588
|
+
push(lang === "board" ? boardBlockSearchText(buf.join("\n")) : buf.join("\n"), lang === "board" ? "board" : "code");
|
|
2589
|
+
continue;
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
const headingMatch = raw.match(/^(#{1,6})\s+(.+)$/);
|
|
2593
|
+
if (headingMatch) {
|
|
2594
|
+
flushParagraph();
|
|
2595
|
+
heading = cleanMarkdownText(headingMatch[2]);
|
|
2596
|
+
push(heading, "heading", heading);
|
|
2597
|
+
continue;
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
if (raw.trim() === "" || /^(-{3,}|\*{3,}|_{3,})\s*$/.test(raw)) {
|
|
2601
|
+
flushParagraph();
|
|
2602
|
+
continue;
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
if (isTableSeparator(raw)) continue;
|
|
2606
|
+
|
|
2607
|
+
const line = raw
|
|
2608
|
+
.replace(/^>\s?/, "")
|
|
2609
|
+
.replace(/^\s*([-*+]|\d+\.)\s+/, "")
|
|
2610
|
+
.replace(/^\[([ xX])\]\s+/, "")
|
|
2611
|
+
.replace(/\|/g, " ");
|
|
2612
|
+
paragraph.push(line);
|
|
2613
|
+
}
|
|
2614
|
+
flushParagraph();
|
|
2615
|
+
return chunks;
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
function boardBlockSearchText(block: string): string {
|
|
2619
|
+
return block
|
|
2620
|
+
.split("\n")
|
|
2621
|
+
.map((line) => line.replace(/#.*$/, "").trim())
|
|
2622
|
+
.filter(Boolean)
|
|
2623
|
+
.map((line) => line.replace(/^(\w+)\s*:\s*/, "$1 "))
|
|
2624
|
+
.join(" ");
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
function allMarkdownFiles(tree: TreeNode[]): Array<Extract<TreeNode, { type: "file" }>> {
|
|
2628
|
+
const out: Array<Extract<TreeNode, { type: "file" }>> = [];
|
|
2629
|
+
const walk = (nodes: TreeNode[]) => {
|
|
2630
|
+
for (const node of nodes) {
|
|
2631
|
+
if (node.type === "file") {
|
|
2632
|
+
if (node.name.endsWith(".md")) out.push(node);
|
|
2633
|
+
} else {
|
|
2634
|
+
walk(node.children);
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
};
|
|
2638
|
+
walk(tree);
|
|
2639
|
+
return out;
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
function allBoardFiles(tree: TreeNode[]): Array<Extract<TreeNode, { type: "file" }>> {
|
|
2643
|
+
const out: Array<Extract<TreeNode, { type: "file" }>> = [];
|
|
2644
|
+
const walk = (nodes: TreeNode[]) => {
|
|
2645
|
+
for (const node of nodes) {
|
|
2646
|
+
if (node.type === "file") {
|
|
2647
|
+
if (node.name.endsWith(".board.json")) out.push(node);
|
|
2648
|
+
} else {
|
|
2649
|
+
walk(node.children);
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
};
|
|
2653
|
+
walk(tree);
|
|
2654
|
+
return out;
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
function boardIdsUnderDirectory(tree: TreeNode[], dirPath: string): string[] {
|
|
2658
|
+
const normalized = dirPath.replace(/^\/+|\/+$/g, "");
|
|
2659
|
+
return allBoardFiles(tree)
|
|
2660
|
+
.map((node) => boardIdFromFilePath(node.path))
|
|
2661
|
+
.filter((id) => id === normalized || id.startsWith(`${normalized}/`));
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
function docsUnderDirectory(tree: TreeNode[], dirPath: string): Array<Extract<TreeNode, { type: "file" }>> {
|
|
2665
|
+
const normalized = dirPath.replace(/^\/+|\/+$/g, "");
|
|
2666
|
+
return allMarkdownFiles(tree).filter((doc) => doc.path.startsWith(`${normalized}/`));
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
function rewriteBoardEmbedsForDocMove(
|
|
2670
|
+
markdown: string,
|
|
2671
|
+
oldDocPath: string,
|
|
2672
|
+
newDocPath: string,
|
|
2673
|
+
boardIds: Set<string>
|
|
2674
|
+
): string {
|
|
2675
|
+
if (oldDocPath === newDocPath) return markdown;
|
|
2676
|
+
const lines = markdown.split("\n");
|
|
2677
|
+
let inBoardFence = false;
|
|
2678
|
+
let closeFence: RegExp | undefined;
|
|
2679
|
+
let changed = false;
|
|
2680
|
+
|
|
2681
|
+
const next = lines.map((line) => {
|
|
2682
|
+
if (!inBoardFence) {
|
|
2683
|
+
const open = line.match(/^(`{3,})\s*board\s*$/i);
|
|
2684
|
+
if (open) {
|
|
2685
|
+
inBoardFence = true;
|
|
2686
|
+
closeFence = new RegExp("^`{" + open[1].length + ",}\\s*$");
|
|
2687
|
+
}
|
|
2688
|
+
return line;
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
if (closeFence?.test(line)) {
|
|
2692
|
+
inBoardFence = false;
|
|
2693
|
+
closeFence = undefined;
|
|
2694
|
+
return line;
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
const src = line.match(/^(\s*src\s*:\s*)(.*)$/i);
|
|
2698
|
+
if (!src) return line;
|
|
2699
|
+
|
|
2700
|
+
const parsed = splitBoardSrcValue(src[2]);
|
|
2701
|
+
if (!parsed.value) return line;
|
|
2702
|
+
const moved = formatBoardSrcForDocMove(parsed.value, oldDocPath, newDocPath, boardIds);
|
|
2703
|
+
if (moved === parsed.value) return line;
|
|
2704
|
+
|
|
2705
|
+
changed = true;
|
|
2706
|
+
return `${src[1]}${moved}${parsed.suffix}`;
|
|
2707
|
+
});
|
|
2708
|
+
|
|
2709
|
+
return changed ? next.join("\n") : markdown;
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
function rewriteBoardEmbedSources(markdown: string, docPath: string, oldBoardId: string, newBoardId: string): string {
|
|
2713
|
+
const lines = markdown.split("\n");
|
|
2714
|
+
let inBoardFence = false;
|
|
2715
|
+
let closeFence: RegExp | undefined;
|
|
2716
|
+
let changed = false;
|
|
2717
|
+
|
|
2718
|
+
const next = lines.map((line) => {
|
|
2719
|
+
if (!inBoardFence) {
|
|
2720
|
+
const open = line.match(/^(`{3,})\s*board\s*$/i);
|
|
2721
|
+
if (open) {
|
|
2722
|
+
inBoardFence = true;
|
|
2723
|
+
closeFence = new RegExp("^`{" + open[1].length + ",}\\s*$");
|
|
2724
|
+
}
|
|
2725
|
+
return line;
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
if (closeFence?.test(line)) {
|
|
2729
|
+
inBoardFence = false;
|
|
2730
|
+
closeFence = undefined;
|
|
2731
|
+
return line;
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2734
|
+
const src = line.match(/^(\s*src\s*:\s*)(.*)$/i);
|
|
2735
|
+
if (!src) return line;
|
|
2736
|
+
|
|
2737
|
+
const parsed = splitBoardSrcValue(src[2]);
|
|
2738
|
+
if (!parsed.value) return line;
|
|
2739
|
+
const candidates = resolveBoardIdCandidatesFromDoc(parsed.value, docPath);
|
|
2740
|
+
if (!candidates.includes(oldBoardId)) return line;
|
|
2741
|
+
|
|
2742
|
+
changed = true;
|
|
2743
|
+
return `${src[1]}${formatMovedBoardSrc(parsed.value, docPath, oldBoardId, newBoardId)}${parsed.suffix}`;
|
|
2744
|
+
});
|
|
2745
|
+
|
|
2746
|
+
return changed ? next.join("\n") : markdown;
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
function splitBoardSrcValue(rest: string): { value: string; suffix: string } {
|
|
2750
|
+
const commentIndex = rest.indexOf("#");
|
|
2751
|
+
const beforeComment = commentIndex === -1 ? rest : rest.slice(0, commentIndex);
|
|
2752
|
+
const suffix = commentIndex === -1 ? "" : rest.slice(commentIndex);
|
|
2753
|
+
const trailing = beforeComment.match(/\s*$/)?.[0] ?? "";
|
|
2754
|
+
return {
|
|
2755
|
+
value: beforeComment.trim(),
|
|
2756
|
+
suffix: `${trailing}${suffix}`,
|
|
2757
|
+
};
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
function formatMovedBoardSrc(previousSrc: string, docPath: string, oldBoardId: string, newBoardId: string): string {
|
|
2761
|
+
const normalizedPrevious = previousSrc.trim();
|
|
2762
|
+
const explicitRootPrefix = normalizedPrevious.startsWith("/");
|
|
2763
|
+
const explicitRelative = normalizedPrevious.startsWith("./") || normalizedPrevious.startsWith("../");
|
|
2764
|
+
const hadExtension = normalizedPrevious.endsWith(".board.json");
|
|
2765
|
+
const candidates = resolveBoardIdCandidatesFromDoc(normalizedPrevious, docPath);
|
|
2766
|
+
const matchedAsFallbackRelative = !explicitRelative && candidates[0] !== oldBoardId && candidates.includes(oldBoardId);
|
|
2767
|
+
|
|
2768
|
+
if (explicitRootPrefix) {
|
|
2769
|
+
const next = `/${newBoardId}`;
|
|
2770
|
+
return hadExtension && !next.endsWith(".board.json") ? `${next}.board.json` : next;
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
let next = explicitRelative || matchedAsFallbackRelative
|
|
2774
|
+
? formatExplicitRelativeBoardSrc(relativePathBetween(docPath, newBoardId))
|
|
2775
|
+
: newBoardId;
|
|
2776
|
+
if (hadExtension && !next.endsWith(".board.json")) next = `${next}.board.json`;
|
|
2777
|
+
return next;
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
function formatBoardSrcForDocMove(
|
|
2781
|
+
previousSrc: string,
|
|
2782
|
+
oldDocPath: string,
|
|
2783
|
+
newDocPath: string,
|
|
2784
|
+
boardIds: Set<string>
|
|
2785
|
+
): string {
|
|
2786
|
+
const normalizedPrevious = previousSrc.trim();
|
|
2787
|
+
const explicitRelative = normalizedPrevious.startsWith("./") || normalizedPrevious.startsWith("../");
|
|
2788
|
+
const candidates = resolveBoardIdCandidatesFromDoc(normalizedPrevious, oldDocPath);
|
|
2789
|
+
const primary = candidates[0] ?? "";
|
|
2790
|
+
const fallback = candidates.find((id) => id !== primary && boardIds.has(id));
|
|
2791
|
+
if (!explicitRelative && (!fallback || boardIds.has(primary))) {
|
|
2792
|
+
return previousSrc;
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
const hadExtension = normalizedPrevious.endsWith(".board.json");
|
|
2796
|
+
const boardId = explicitRelative ? resolveBoardIdFromDoc(normalizedPrevious, oldDocPath) : fallback;
|
|
2797
|
+
if (!boardId) return previousSrc;
|
|
2798
|
+
|
|
2799
|
+
let next = formatExplicitRelativeBoardSrc(relativePathBetween(newDocPath, boardId));
|
|
2800
|
+
if (hadExtension && !next.endsWith(".board.json")) next = `${next}.board.json`;
|
|
2801
|
+
return next;
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
function formatExplicitRelativeBoardSrc(src: string): string {
|
|
2805
|
+
if (!src || src.startsWith("./") || src.startsWith("../")) return src;
|
|
2806
|
+
return `./${src}`;
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
function cleanMarkdownText(text: string): string {
|
|
2810
|
+
return text
|
|
2811
|
+
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, "$1 $2")
|
|
2812
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 $2")
|
|
2813
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
2814
|
+
.replace(/~~([\s\S]*?)~~/g, "$1")
|
|
2815
|
+
.replace(/\*\*\*([\s\S]*?)\*\*\*/g, "$1")
|
|
2816
|
+
.replace(/___([\s\S]*?)___/g, "$1")
|
|
2817
|
+
.replace(/\*\*([\s\S]*?)\*\*/g, "$1")
|
|
2818
|
+
.replace(/__([\s\S]*?)__/g, "$1")
|
|
2819
|
+
.replace(/(^|[\s([{])\*([^*\s][\s\S]*?[^*\s])\*(?=$|[\s)\]},.!?:;])/g, "$1$2")
|
|
2820
|
+
.replace(/(^|[\s([{])_([^_\s][\s\S]*?[^_\s])_(?=$|[\s)\]},.!?:;])/g, "$1$2")
|
|
2821
|
+
.replace(/\\([\\`*_[\]{}()#+\-.!|>])/g, "$1")
|
|
2822
|
+
.replace(/<[^>]+>/g, " ")
|
|
2823
|
+
.replace(/ /g, " ")
|
|
2824
|
+
.replace(/"/g, '"')
|
|
2825
|
+
.replace(/'/g, "'")
|
|
2826
|
+
.replace(/&/g, "&")
|
|
2827
|
+
.replace(/</g, "<")
|
|
2828
|
+
.replace(/>/g, ">")
|
|
2829
|
+
.replace(/(^|[\s([{])[*_]{1,3}(?=\S)/g, "$1")
|
|
2830
|
+
.replace(/(?<=\S)[*_]{1,3}(?=$|[\s)\]},.!?:;])/g, "")
|
|
2831
|
+
.replace(/\s+/g, " ")
|
|
2832
|
+
.trim();
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
function normalizeSearchText(text: string): string {
|
|
2836
|
+
return text
|
|
2837
|
+
.normalize("NFKD")
|
|
2838
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
2839
|
+
.toLowerCase()
|
|
2840
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
2841
|
+
.trim();
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
function scoreText(text: string, terms: string[], phrase: string, weight: number): number {
|
|
2845
|
+
if (!text) return 0;
|
|
2846
|
+
let score = phrase && text.includes(phrase) ? weight * 4 : 0;
|
|
2847
|
+
for (const term of terms) {
|
|
2848
|
+
if (text === term) score += weight * 5;
|
|
2849
|
+
if (text.startsWith(term)) score += weight * 3;
|
|
2850
|
+
if (text.includes(term)) score += weight;
|
|
2851
|
+
}
|
|
2852
|
+
return score;
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
function chunkPreview(text: string, terms: string[]): string {
|
|
2856
|
+
if (terms.length === 0) return text.slice(0, 220);
|
|
2857
|
+
const normalizedText = normalizeSearchText(text);
|
|
2858
|
+
const hit = terms
|
|
2859
|
+
.map((term) => normalizedText.indexOf(term))
|
|
2860
|
+
.filter((idx) => idx >= 0)
|
|
2861
|
+
.sort((a, b) => a - b)[0];
|
|
2862
|
+
if (hit === undefined) return text.slice(0, 220);
|
|
2863
|
+
|
|
2864
|
+
const ratio = normalizedText.length === 0 ? 0 : hit / normalizedText.length;
|
|
2865
|
+
const center = Math.round(text.length * ratio);
|
|
2866
|
+
const start = Math.max(0, center - 90);
|
|
2867
|
+
const end = Math.min(text.length, center + 170);
|
|
2868
|
+
const snippet = text.slice(start, end).trim();
|
|
2869
|
+
return `${start > 0 ? "... " : ""}${snippet}${end < text.length ? " ..." : ""}`;
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
function isTableSeparator(line: string): boolean {
|
|
2873
|
+
return /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/.test(line);
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
function prettify(name: string): string {
|
|
2877
|
+
const base = name.replace(/\.md$/, "").replace(/[-_]/g, " ");
|
|
2878
|
+
return base.charAt(0).toUpperCase() + base.slice(1);
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
function slugName(name: string): string {
|
|
2882
|
+
return name
|
|
2883
|
+
.trim()
|
|
2884
|
+
.replace(/[\\/]+/g, "-")
|
|
2885
|
+
.replace(/\s+/g, "-")
|
|
2886
|
+
.replace(/[^a-z0-9-_]/gi, "-")
|
|
2887
|
+
.replace(/-+/g, "-")
|
|
2888
|
+
.replace(/^-|-$/g, "")
|
|
2889
|
+
.toLowerCase();
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
type NameKind = "directory" | "doc" | "board";
|
|
2893
|
+
type NameValidationResult =
|
|
2894
|
+
| { ok: true; raw: string; slug: string }
|
|
2895
|
+
| { ok: false; message: string };
|
|
2896
|
+
|
|
2897
|
+
const RESERVED_NEW_ITEM_NAMES = new Set([
|
|
2898
|
+
".",
|
|
2899
|
+
"..",
|
|
2900
|
+
"assets",
|
|
2901
|
+
"boards",
|
|
2902
|
+
"capabilities",
|
|
2903
|
+
"docs",
|
|
2904
|
+
]);
|
|
2905
|
+
|
|
2906
|
+
function validateNewItemName(input: string, kind: NameKind): NameValidationResult {
|
|
2907
|
+
const label = kind === "doc" ? "doc" : kind;
|
|
2908
|
+
const raw = input.trim();
|
|
2909
|
+
if (!raw) return { ok: false, message: `Enter a ${label} name.` };
|
|
2910
|
+
if (raw.length > 80) return { ok: false, message: "Name must be 80 characters or fewer." };
|
|
2911
|
+
if (/[\\/]/.test(raw)) return { ok: false, message: "Name cannot contain slashes." };
|
|
2912
|
+
if (/^\.+$/.test(raw)) return { ok: false, message: "Name cannot contain only dots." };
|
|
2913
|
+
if (raw.startsWith(".")) return { ok: false, message: "Name cannot start with a dot." };
|
|
2914
|
+
if (/[<>:"|?*\x00-\x1F]/.test(raw)) return { ok: false, message: "Name contains characters that are not allowed in files." };
|
|
2915
|
+
|
|
2916
|
+
const slug = slugName(raw);
|
|
2917
|
+
if (!slug) return { ok: false, message: "Name must include letters or numbers." };
|
|
2918
|
+
if (slug.length > 64) return { ok: false, message: "Name is too long after formatting." };
|
|
2919
|
+
if (RESERVED_NEW_ITEM_NAMES.has(slug)) return { ok: false, message: `"${slug}" is reserved by the workspace.` };
|
|
2920
|
+
if (!/[a-z0-9]/.test(slug)) return { ok: false, message: "Name must include letters or numbers." };
|
|
2921
|
+
return { ok: true, raw, slug };
|
|
2922
|
+
}
|
|
2923
|
+
|
|
2924
|
+
function pathBasename(path: string): string {
|
|
2925
|
+
const clean = path.replace(/^\/+|\/+$/g, "");
|
|
2926
|
+
return clean.split("/").pop() ?? clean;
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
function parentPath(path: string): string {
|
|
2930
|
+
const clean = path.replace(/^\/+|\/+$/g, "");
|
|
2931
|
+
const idx = clean.lastIndexOf("/");
|
|
2932
|
+
return idx === -1 ? "" : clean.slice(0, idx);
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
function joinPath(dir: string, name: string): string {
|
|
2936
|
+
const cleanDir = dir.replace(/^\/+|\/+$/g, "");
|
|
2937
|
+
const cleanName = name.replace(/^\/+|\/+$/g, "");
|
|
2938
|
+
return cleanDir ? `${cleanDir}/${cleanName}` : cleanName;
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
function parseBoardRegion(query: string | undefined): BoardRegion | undefined {
|
|
2942
|
+
if (!query) return undefined;
|
|
2943
|
+
const raw = new URLSearchParams(query).get("region");
|
|
2944
|
+
if (!raw) return undefined;
|
|
2945
|
+
const nums = raw.split(",").map((n) => Number(n.trim()));
|
|
2946
|
+
if (nums.length !== 4 || nums.some((n) => !Number.isFinite(n)) || nums[2] <= 0 || nums[3] <= 0) {
|
|
2947
|
+
return undefined;
|
|
2948
|
+
}
|
|
2949
|
+
return [nums[0], nums[1], nums[2], nums[3]];
|
|
2950
|
+
}
|