@peaske7/readit 0.1.8 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/CLAUDE.md +118 -76
- package/.claude/commands/review.md +1 -1
- package/.claude/roadmap.md +32 -9
- package/.claude/user-stories.md +100 -15
- package/AGENTS.md +30 -26
- package/Makefile +32 -0
- package/README.md +90 -5
- package/biome.json +18 -8
- package/bun.lock +426 -710
- package/bunfig.toml +2 -0
- package/docs/perf-baseline.md +130 -0
- package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
- package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
- package/e2e/comments.spec.ts +14 -58
- package/e2e/document-load.spec.ts +1 -23
- package/e2e/export.spec.ts +4 -4
- package/e2e/perf/add-comment.spec.ts +116 -0
- package/e2e/perf/fixtures/generate.ts +327 -0
- package/e2e/perf/initial-load.spec.ts +49 -0
- package/e2e/perf/perf.setup.ts +23 -0
- package/e2e/perf/perf.teardown.ts +9 -0
- package/e2e/perf/screenshot-final.png +0 -0
- package/e2e/perf/scroll.spec.ts +39 -0
- package/e2e/perf/tab-switch.spec.ts +69 -0
- package/e2e/perf/text-selection.spec.ts +119 -0
- package/e2e/perf/utils/metrics.ts +350 -0
- package/e2e/perf/utils/perf-cli.ts +86 -0
- package/e2e/persistence-file.spec.ts +41 -26
- package/e2e/utils/selection.ts +17 -73
- package/go/cmd/readit/main.go +416 -0
- package/go/go.mod +20 -0
- package/go/go.sum +41 -0
- package/go/internal/server/anchor.go +302 -0
- package/go/internal/server/anchor_test.go +111 -0
- package/go/internal/server/comments.go +390 -0
- package/go/internal/server/documents.go +113 -0
- package/go/internal/server/embed.go +17 -0
- package/go/internal/server/headings.go +33 -0
- package/go/internal/server/headings_test.go +75 -0
- package/go/internal/server/htmltext.go +123 -0
- package/go/internal/server/markdown.go +157 -0
- package/go/internal/server/markdown_bench_test.go +42 -0
- package/go/internal/server/markdown_test.go +79 -0
- package/go/internal/server/server.go +453 -0
- package/go/internal/server/server_bench_test.go +122 -0
- package/go/internal/server/settings.go +110 -0
- package/go/internal/server/sse.go +140 -0
- package/go/internal/server/storage.go +275 -0
- package/go/internal/server/storage_test.go +118 -0
- package/go/internal/server/template.go +66 -0
- package/go/internal/server/types.go +101 -0
- package/go/internal/server/watcher.go +74 -0
- package/index.html +4 -14
- package/nvim-readit/lua/readit/health.lua +64 -0
- package/nvim-readit/lua/readit/init.lua +463 -0
- package/nvim-readit/plugin/readit.lua +19 -0
- package/package.json +24 -41
- package/playwright.config.ts +12 -0
- package/shell/_readit +158 -0
- package/shell/readit.zsh +87 -0
- package/src/App.svelte +881 -0
- package/src/{cli/index.ts → cli.ts} +216 -70
- package/src/components/ActionsMenu.svelte +95 -0
- package/src/components/CommentBadge.svelte +67 -0
- package/src/components/CommentErrorBanner.svelte +33 -0
- package/src/components/CommentInput.svelte +75 -0
- package/src/components/CommentListItem.svelte +95 -0
- package/src/components/CommentManager.svelte +129 -0
- package/src/components/CommentNav.svelte +109 -0
- package/src/components/DocumentViewer.svelte +218 -0
- package/src/components/FloatingComment.svelte +107 -0
- package/src/components/Header.svelte +76 -0
- package/src/components/InlineEditor.svelte +72 -0
- package/src/components/MarginNote.svelte +167 -0
- package/src/components/MarginNotesContainer.svelte +33 -0
- package/src/components/RawModal.svelte +126 -0
- package/src/components/ReanchorConfirm.svelte +30 -0
- package/src/components/SettingsModal.svelte +220 -0
- package/src/components/ShortcutCapture.svelte +82 -0
- package/src/components/ShortcutList.svelte +145 -0
- package/src/components/TabBar.svelte +52 -0
- package/src/components/TableOfContents.svelte +125 -0
- package/src/components/ui/ActionLink.svelte +40 -0
- package/src/components/ui/Button.svelte +53 -0
- package/src/components/ui/Dialog.svelte +97 -0
- package/src/components/ui/DropdownMenu.svelte +85 -0
- package/src/components/ui/DropdownMenuItem.svelte +38 -0
- package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
- package/src/components/ui/Text.svelte +42 -0
- package/src/env.d.ts +6 -0
- package/src/index.css +36 -166
- package/src/lib/__fixtures__/bench-data.ts +1 -54
- package/src/lib/anchor.bench.ts +47 -68
- package/src/lib/anchor.test.ts +5 -9
- package/src/lib/anchor.ts +9 -93
- package/src/lib/comment-storage.bench.ts +6 -20
- package/src/lib/comment-storage.test.ts +45 -37
- package/src/lib/comment-storage.ts +23 -64
- package/src/lib/export.bench.ts +9 -23
- package/src/lib/export.ts +7 -14
- package/src/lib/headings.test.ts +103 -0
- package/src/lib/headings.ts +44 -0
- package/src/lib/highlight/core.test.ts +1 -6
- package/src/lib/highlight/dom.ts +53 -280
- package/src/lib/highlight/highlight-registry.ts +221 -0
- package/src/lib/highlight/highlight.bench.ts +92 -0
- package/src/lib/highlight/highlighter.ts +122 -302
- package/src/lib/highlight/{core.ts → resolver.ts} +3 -19
- package/src/lib/highlight/types.ts +0 -40
- package/src/lib/html-text.test.ts +162 -0
- package/src/lib/html-text.ts +161 -0
- package/src/lib/i18n/en.ts +13 -36
- package/src/lib/i18n/ja.ts +14 -37
- package/src/lib/i18n/types.ts +13 -36
- package/src/lib/margin-layout.bench.ts +48 -15
- package/src/lib/margin-layout.ts +2 -31
- package/src/lib/markdown-renderer.test.ts +154 -0
- package/src/lib/markdown-renderer.ts +177 -0
- package/src/lib/mermaid-config.ts +38 -0
- package/src/lib/mermaid-renderer.ts +162 -0
- package/src/lib/mermaid-worker.ts +60 -0
- package/src/lib/positions.ts +157 -0
- package/src/lib/shortcut-registry.ts +138 -103
- package/src/lib/utils.ts +2 -48
- package/src/main.ts +16 -0
- package/src/schema.ts +92 -0
- package/src/{server/index.ts → server.ts} +427 -163
- package/src/stores/app.svelte.ts +231 -0
- package/src/stores/locale.svelte.ts +46 -0
- package/src/stores/settings.svelte.ts +90 -0
- package/src/stores/shortcuts.svelte.ts +104 -0
- package/src/stores/ui.svelte.ts +12 -0
- package/src/template.ts +104 -0
- package/src/test-setup.ts +47 -0
- package/svelte.config.js +5 -0
- package/tsconfig.json +2 -2
- package/vite.config.ts +31 -3
- package/vscode-readit/.mcp.json +7 -0
- package/vscode-readit/.vscodeignore +7 -0
- package/vscode-readit/bun.lock +78 -0
- package/vscode-readit/icon.svg +10 -0
- package/vscode-readit/package.json +110 -0
- package/vscode-readit/src/extension.ts +117 -0
- package/vscode-readit/src/server-manager.ts +272 -0
- package/vscode-readit/src/webview-provider.ts +204 -0
- package/vscode-readit/tsconfig.json +20 -0
- package/e2e/fixtures/sample.html +0 -13
- package/src/App.tsx +0 -416
- package/src/components/ActionsMenu.tsx +0 -112
- package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
- package/src/components/DocumentViewer/DocumentViewer.tsx +0 -259
- package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
- package/src/components/DocumentViewer/InlineCode.tsx +0 -60
- package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -137
- package/src/components/DocumentViewer/index.ts +0 -1
- package/src/components/FloatingTOC.tsx +0 -61
- package/src/components/Header.tsx +0 -65
- package/src/components/InlineEditor.tsx +0 -74
- package/src/components/MarginNote.tsx +0 -207
- package/src/components/MarginNotes.tsx +0 -50
- package/src/components/RawModal.tsx +0 -143
- package/src/components/ReanchorConfirm.tsx +0 -36
- package/src/components/SettingsModal.tsx +0 -310
- package/src/components/ShortcutCapture.tsx +0 -48
- package/src/components/ShortcutList.tsx +0 -198
- package/src/components/TabBar.tsx +0 -60
- package/src/components/TableOfContents.tsx +0 -108
- package/src/components/comments/CommentBadge.tsx +0 -49
- package/src/components/comments/CommentInput.tsx +0 -114
- package/src/components/comments/CommentListItem.tsx +0 -92
- package/src/components/comments/CommentManager.tsx +0 -113
- package/src/components/comments/CommentMinimap.tsx +0 -62
- package/src/components/comments/CommentNav.tsx +0 -109
- package/src/components/ui/ActionBar.tsx +0 -16
- package/src/components/ui/ActionLink.tsx +0 -32
- package/src/components/ui/Button.tsx +0 -55
- package/src/components/ui/Dialog.tsx +0 -156
- package/src/components/ui/DropdownMenu.tsx +0 -114
- package/src/components/ui/SeparatorDot.tsx +0 -9
- package/src/components/ui/Text.tsx +0 -54
- package/src/contexts/CommentContext.tsx +0 -229
- package/src/contexts/LayoutContext.tsx +0 -88
- package/src/contexts/LocaleContext.tsx +0 -35
- package/src/hooks/useClickOutside.ts +0 -35
- package/src/hooks/useClipboard.ts +0 -82
- package/src/hooks/useCommentNavigation.ts +0 -130
- package/src/hooks/useComments.ts +0 -323
- package/src/hooks/useDocument.ts +0 -156
- package/src/hooks/useEditorScheme.ts +0 -51
- package/src/hooks/useFontPreference.ts +0 -59
- package/src/hooks/useHeadings.test.ts +0 -159
- package/src/hooks/useHeadings.ts +0 -129
- package/src/hooks/useKeybindings.ts +0 -108
- package/src/hooks/useKeyboardShortcuts.ts +0 -63
- package/src/hooks/useLayoutMode.ts +0 -44
- package/src/hooks/useLocalePreference.ts +0 -42
- package/src/hooks/useReanchorMode.ts +0 -33
- package/src/hooks/useScrollMetrics.ts +0 -56
- package/src/hooks/useScrollSpy.ts +0 -81
- package/src/hooks/useTextSelection.ts +0 -123
- package/src/hooks/useThemePreference.ts +0 -66
- package/src/lib/context.bench.ts +0 -41
- package/src/lib/context.test.ts +0 -224
- package/src/lib/context.ts +0 -193
- package/src/lib/editor-links.ts +0 -59
- package/src/lib/highlight/colors.ts +0 -37
- package/src/lib/highlight/index.ts +0 -23
- package/src/lib/highlight/script-builder.ts +0 -485
- package/src/lib/html-processor.test.tsx +0 -170
- package/src/lib/html-processor.tsx +0 -95
- package/src/lib/i18n/completeness.test.ts +0 -51
- package/src/lib/i18n/translations.test.ts +0 -39
- package/src/lib/layout-constants.ts +0 -12
- package/src/lib/scroll.test.ts +0 -118
- package/src/lib/scroll.ts +0 -47
- package/src/lib/shortcut-registry.test.ts +0 -173
- package/src/lib/utils.test.ts +0 -110
- package/src/main.tsx +0 -13
- package/src/store/index.test.ts +0 -242
- package/src/store/index.ts +0 -254
- package/src/types/index.ts +0 -127
|
@@ -3,7 +3,7 @@ import * as fs from "node:fs/promises";
|
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { basename, dirname, join } from "node:path";
|
|
6
|
-
import { findAnchorWithFallback } from "
|
|
6
|
+
import { findAnchorWithFallback } from "./lib/anchor.js";
|
|
7
7
|
import {
|
|
8
8
|
computeHash,
|
|
9
9
|
createComment,
|
|
@@ -12,20 +12,20 @@ import {
|
|
|
12
12
|
parseCommentFile,
|
|
13
13
|
serializeComments,
|
|
14
14
|
truncateSelection,
|
|
15
|
-
} from "
|
|
16
|
-
import {
|
|
15
|
+
} from "./lib/comment-storage.js";
|
|
16
|
+
import { findTextPosition } from "./lib/highlight/resolver.js";
|
|
17
|
+
import { extractTextFromHtml } from "./lib/html-text.js";
|
|
18
|
+
import { getShiki, renderMarkdown } from "./lib/markdown-renderer.js";
|
|
19
|
+
import { disposeMermaidWorker } from "./lib/mermaid-renderer.js";
|
|
20
|
+
import { isMarkdownFile } from "./lib/utils.js";
|
|
17
21
|
import {
|
|
18
22
|
AnchorConfidences,
|
|
19
23
|
type Comment,
|
|
20
24
|
type DocumentSettings,
|
|
21
|
-
type DocumentType,
|
|
22
|
-
type EditorScheme,
|
|
23
|
-
EditorSchemes,
|
|
24
25
|
FontFamilies,
|
|
25
26
|
type FontFamily,
|
|
26
|
-
} from "
|
|
27
|
-
|
|
28
|
-
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
27
|
+
} from "./schema.js";
|
|
28
|
+
import { renderTemplate } from "./template.js";
|
|
29
29
|
|
|
30
30
|
function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
|
|
31
31
|
return err instanceof Error && "code" in err;
|
|
@@ -33,7 +33,6 @@ function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
|
|
|
33
33
|
|
|
34
34
|
export interface FileEntry {
|
|
35
35
|
content?: string;
|
|
36
|
-
type: DocumentType;
|
|
37
36
|
filePath: string;
|
|
38
37
|
}
|
|
39
38
|
|
|
@@ -62,6 +61,21 @@ function invalidateResolvedComments(filePath: string): void {
|
|
|
62
61
|
resolvedCommentsCache.delete(filePath);
|
|
63
62
|
}
|
|
64
63
|
|
|
64
|
+
const commentWriteLocks = new Map<string, Promise<unknown>>();
|
|
65
|
+
|
|
66
|
+
function withCommentLock<T>(
|
|
67
|
+
filePath: string,
|
|
68
|
+
fn: () => Promise<T>,
|
|
69
|
+
): Promise<T> {
|
|
70
|
+
const prev = commentWriteLocks.get(filePath) ?? Promise.resolve();
|
|
71
|
+
const next = prev.catch(() => {}).then(fn);
|
|
72
|
+
commentWriteLocks.set(
|
|
73
|
+
filePath,
|
|
74
|
+
next.catch(() => {}),
|
|
75
|
+
);
|
|
76
|
+
return next;
|
|
77
|
+
}
|
|
78
|
+
|
|
65
79
|
async function canonicalPath(filePath: string): Promise<string> {
|
|
66
80
|
return fs.realpath(path.resolve(filePath));
|
|
67
81
|
}
|
|
@@ -69,6 +83,7 @@ async function canonicalPath(filePath: string): Promise<string> {
|
|
|
69
83
|
async function readCommentsFromFile(
|
|
70
84
|
filePath: string,
|
|
71
85
|
sourceContent: string,
|
|
86
|
+
renderedHtml?: string,
|
|
72
87
|
): Promise<Comment[]> {
|
|
73
88
|
const commentPath = getCommentPath(filePath);
|
|
74
89
|
const sourceHash = computeHash(sourceContent);
|
|
@@ -86,27 +101,46 @@ async function readCommentsFromFile(
|
|
|
86
101
|
|
|
87
102
|
const content = await fs.readFile(commentPath, "utf-8");
|
|
88
103
|
const file = parseCommentFile(content);
|
|
104
|
+
|
|
105
|
+
const domText = renderedHtml ? extractTextFromHtml(renderedHtml) : null;
|
|
106
|
+
|
|
89
107
|
const resolvedComments = file.comments.map((comment) => {
|
|
90
108
|
const textForMatching = comment.anchorPrefix || comment.selectedText;
|
|
109
|
+
|
|
91
110
|
const anchor = findAnchorWithFallback({
|
|
92
111
|
source: sourceContent,
|
|
93
112
|
selectedText: textForMatching,
|
|
94
113
|
lineHint: comment.lineHint || "L1",
|
|
95
114
|
});
|
|
96
115
|
|
|
97
|
-
if (anchor) {
|
|
116
|
+
if (!anchor) {
|
|
98
117
|
return {
|
|
99
118
|
...comment,
|
|
100
|
-
|
|
101
|
-
endOffset: anchor.end,
|
|
102
|
-
lineHint: `L${anchor.line}`,
|
|
103
|
-
anchorConfidence: anchor.confidence,
|
|
119
|
+
anchorConfidence: AnchorConfidences.UNRESOLVED,
|
|
104
120
|
};
|
|
105
121
|
}
|
|
106
122
|
|
|
123
|
+
let startOffset = anchor.start;
|
|
124
|
+
let endOffset = anchor.end;
|
|
125
|
+
|
|
126
|
+
if (domText) {
|
|
127
|
+
const domPos = findTextPosition(
|
|
128
|
+
domText,
|
|
129
|
+
comment.selectedText,
|
|
130
|
+
anchor.start,
|
|
131
|
+
);
|
|
132
|
+
if (domPos) {
|
|
133
|
+
startOffset = domPos.start;
|
|
134
|
+
endOffset = domPos.end;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
107
138
|
return {
|
|
108
139
|
...comment,
|
|
109
|
-
|
|
140
|
+
startOffset,
|
|
141
|
+
endOffset,
|
|
142
|
+
lineHint: `L${anchor.line}`,
|
|
143
|
+
anchorConfidence: anchor.confidence,
|
|
110
144
|
};
|
|
111
145
|
});
|
|
112
146
|
|
|
@@ -194,12 +228,6 @@ function isValidFontFamily(value: unknown): value is FontFamily {
|
|
|
194
228
|
return value === FontFamilies.SERIF || value === FontFamilies.SANS_SERIF;
|
|
195
229
|
}
|
|
196
230
|
|
|
197
|
-
function isValidEditorScheme(value: unknown): value is EditorScheme {
|
|
198
|
-
return Object.values(EditorSchemes).includes(value as EditorScheme);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// ─── PID file helpers ───────────────────────────────────────────────
|
|
202
|
-
|
|
203
231
|
export const SERVER_INFO_PATH = path.join(
|
|
204
232
|
os.homedir(),
|
|
205
233
|
".readit",
|
|
@@ -225,8 +253,6 @@ export async function removeServerInfo(): Promise<void> {
|
|
|
225
253
|
}
|
|
226
254
|
}
|
|
227
255
|
|
|
228
|
-
// ─── Response helpers ───────────────────────────────────────────────
|
|
229
|
-
|
|
230
256
|
function json(data: unknown, status = 200): Response {
|
|
231
257
|
return Response.json(data, { status });
|
|
232
258
|
}
|
|
@@ -235,19 +261,22 @@ function errorResponse(message: string, status: number): Response {
|
|
|
235
261
|
return Response.json({ error: message }, { status });
|
|
236
262
|
}
|
|
237
263
|
|
|
238
|
-
// ─── Route context ──────────────────────────────────────────────────
|
|
239
|
-
|
|
240
264
|
interface RouteContext {
|
|
241
265
|
filePath: string;
|
|
242
266
|
getCurrentContent: () => Promise<string>;
|
|
243
267
|
}
|
|
244
268
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
269
|
+
async function getComments(
|
|
270
|
+
ctx: RouteContext,
|
|
271
|
+
renderedHtml?: string,
|
|
272
|
+
): Promise<Response> {
|
|
248
273
|
try {
|
|
249
274
|
const currentContent = await ctx.getCurrentContent();
|
|
250
|
-
const comments = await readCommentsFromFile(
|
|
275
|
+
const comments = await readCommentsFromFile(
|
|
276
|
+
ctx.filePath,
|
|
277
|
+
currentContent,
|
|
278
|
+
renderedHtml,
|
|
279
|
+
);
|
|
251
280
|
return json({ comments });
|
|
252
281
|
} catch (err) {
|
|
253
282
|
console.error("Failed to read comments:", err);
|
|
@@ -282,18 +311,20 @@ async function addComment(ctx: RouteContext, req: Request): Promise<Response> {
|
|
|
282
311
|
currentContent,
|
|
283
312
|
);
|
|
284
313
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
314
|
+
await withCommentLock(ctx.filePath, async () => {
|
|
315
|
+
const existingComments = await readCommentsFromFile(
|
|
316
|
+
ctx.filePath,
|
|
317
|
+
currentContent,
|
|
318
|
+
);
|
|
319
|
+
const allComments = [...existingComments, newComment];
|
|
320
|
+
await writeCommentsToFile(ctx.filePath, currentContent, allComments);
|
|
321
|
+
});
|
|
292
322
|
|
|
293
323
|
return json({ comment: newComment }, 201);
|
|
294
324
|
} catch (err) {
|
|
295
325
|
console.error("Failed to add comment:", err);
|
|
296
|
-
|
|
326
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
327
|
+
return errorResponse(`Failed to add comment: ${detail}`, 500);
|
|
297
328
|
}
|
|
298
329
|
}
|
|
299
330
|
|
|
@@ -310,23 +341,22 @@ async function updateComment(
|
|
|
310
341
|
}
|
|
311
342
|
|
|
312
343
|
const currentContent = await ctx.getCurrentContent();
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
);
|
|
326
|
-
|
|
327
|
-
await writeCommentsToFile(ctx.filePath, currentContent, updatedComments);
|
|
344
|
+
const result = await withCommentLock(ctx.filePath, async () => {
|
|
345
|
+
const existingComments = await readCommentsFromFile(
|
|
346
|
+
ctx.filePath,
|
|
347
|
+
currentContent,
|
|
348
|
+
);
|
|
349
|
+
const commentIndex = existingComments.findIndex((c) => c.id === id);
|
|
350
|
+
if (commentIndex === -1) return null;
|
|
351
|
+
const updatedComments = existingComments.map((c, i) =>
|
|
352
|
+
i === commentIndex ? { ...c, comment: commentText.trim() } : c,
|
|
353
|
+
);
|
|
354
|
+
await writeCommentsToFile(ctx.filePath, currentContent, updatedComments);
|
|
355
|
+
return updatedComments[commentIndex];
|
|
356
|
+
});
|
|
328
357
|
|
|
329
|
-
return
|
|
358
|
+
if (!result) return errorResponse("Comment not found", 404);
|
|
359
|
+
return json({ comment: result });
|
|
330
360
|
} catch (err) {
|
|
331
361
|
console.error("Failed to update comment:", err);
|
|
332
362
|
return errorResponse("Failed to update comment", 500);
|
|
@@ -336,22 +366,26 @@ async function updateComment(
|
|
|
336
366
|
async function deleteComment(ctx: RouteContext, id: string): Promise<Response> {
|
|
337
367
|
try {
|
|
338
368
|
const currentContent = await ctx.getCurrentContent();
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
369
|
+
const found = await withCommentLock(ctx.filePath, async () => {
|
|
370
|
+
const existingComments = await readCommentsFromFile(
|
|
371
|
+
ctx.filePath,
|
|
372
|
+
currentContent,
|
|
373
|
+
);
|
|
374
|
+
const filteredComments = existingComments.filter((c) => c.id !== id);
|
|
375
|
+
if (filteredComments.length === existingComments.length) return false;
|
|
376
|
+
if (filteredComments.length === 0) {
|
|
377
|
+
await deleteCommentFile(ctx.filePath);
|
|
378
|
+
} else {
|
|
379
|
+
await writeCommentsToFile(
|
|
380
|
+
ctx.filePath,
|
|
381
|
+
currentContent,
|
|
382
|
+
filteredComments,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
return true;
|
|
386
|
+
});
|
|
354
387
|
|
|
388
|
+
if (!found) return errorResponse("Comment not found", 404);
|
|
355
389
|
return json({ success: true });
|
|
356
390
|
} catch (err) {
|
|
357
391
|
console.error("Failed to delete comment:", err);
|
|
@@ -361,7 +395,7 @@ async function deleteComment(ctx: RouteContext, id: string): Promise<Response> {
|
|
|
361
395
|
|
|
362
396
|
async function clearComments(ctx: RouteContext): Promise<Response> {
|
|
363
397
|
try {
|
|
364
|
-
await deleteCommentFile(ctx.filePath);
|
|
398
|
+
await withCommentLock(ctx.filePath, () => deleteCommentFile(ctx.filePath));
|
|
365
399
|
return json({ success: true });
|
|
366
400
|
} catch (err) {
|
|
367
401
|
console.error("Failed to clear comments:", err);
|
|
@@ -396,37 +430,35 @@ async function reanchorComment(
|
|
|
396
430
|
}
|
|
397
431
|
|
|
398
432
|
const currentContent = await ctx.getCurrentContent();
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
);
|
|
426
|
-
|
|
427
|
-
await writeCommentsToFile(ctx.filePath, currentContent, updatedComments);
|
|
433
|
+
const result = await withCommentLock(ctx.filePath, async () => {
|
|
434
|
+
const existingComments = await readCommentsFromFile(
|
|
435
|
+
ctx.filePath,
|
|
436
|
+
currentContent,
|
|
437
|
+
);
|
|
438
|
+
const commentIndex = existingComments.findIndex((c) => c.id === id);
|
|
439
|
+
if (commentIndex === -1) return null;
|
|
440
|
+
|
|
441
|
+
const lineHint = getLineHint(currentContent, startOffset, endOffset);
|
|
442
|
+
const truncatedText = truncateSelection(selectedText);
|
|
443
|
+
const updatedComment: Comment = {
|
|
444
|
+
...existingComments[commentIndex],
|
|
445
|
+
selectedText: truncatedText,
|
|
446
|
+
startOffset,
|
|
447
|
+
endOffset,
|
|
448
|
+
lineHint,
|
|
449
|
+
anchorConfidence: AnchorConfidences.EXACT,
|
|
450
|
+
anchorPrefix:
|
|
451
|
+
selectedText.length > 1000 ? selectedText.slice(0, 200) : undefined,
|
|
452
|
+
};
|
|
453
|
+
const updatedComments = existingComments.map((c, i) =>
|
|
454
|
+
i === commentIndex ? updatedComment : c,
|
|
455
|
+
);
|
|
456
|
+
await writeCommentsToFile(ctx.filePath, currentContent, updatedComments);
|
|
457
|
+
return updatedComment;
|
|
458
|
+
});
|
|
428
459
|
|
|
429
|
-
return
|
|
460
|
+
if (!result) return errorResponse("Comment not found", 404);
|
|
461
|
+
return json({ comment: result });
|
|
430
462
|
} catch (err) {
|
|
431
463
|
console.error("Failed to re-anchor comment:", err);
|
|
432
464
|
return errorResponse("Failed to re-anchor comment", 500);
|
|
@@ -446,22 +478,16 @@ async function getSettingsRoute(): Promise<Response> {
|
|
|
446
478
|
async function updateSettingsRoute(req: Request): Promise<Response> {
|
|
447
479
|
try {
|
|
448
480
|
const body = await req.json();
|
|
449
|
-
const { fontFamily
|
|
481
|
+
const { fontFamily } = body;
|
|
450
482
|
|
|
451
483
|
if (fontFamily !== undefined && !isValidFontFamily(fontFamily)) {
|
|
452
484
|
return errorResponse("Invalid font family", 400);
|
|
453
485
|
}
|
|
454
486
|
|
|
455
|
-
if (editorScheme !== undefined && !isValidEditorScheme(editorScheme)) {
|
|
456
|
-
return errorResponse("Invalid editor scheme", 400);
|
|
457
|
-
}
|
|
458
|
-
|
|
459
487
|
const current = await readSettings();
|
|
460
488
|
const settings: DocumentSettings = {
|
|
461
489
|
...current,
|
|
462
490
|
...(fontFamily !== undefined && { fontFamily }),
|
|
463
|
-
...(editorScheme !== undefined && { editorScheme }),
|
|
464
|
-
...(keybindings !== undefined && { keybindings }),
|
|
465
491
|
};
|
|
466
492
|
|
|
467
493
|
await writeSettings(settings);
|
|
@@ -472,8 +498,6 @@ async function updateSettingsRoute(req: Request): Promise<Response> {
|
|
|
472
498
|
}
|
|
473
499
|
}
|
|
474
500
|
|
|
475
|
-
// ─── SSE helpers ────────────────────────────────────────────────────
|
|
476
|
-
|
|
477
501
|
function createDocumentStream(
|
|
478
502
|
sseClients: Set<ReadableStreamDefaultController>,
|
|
479
503
|
): Response {
|
|
@@ -545,8 +569,6 @@ function createHeartbeat(
|
|
|
545
569
|
});
|
|
546
570
|
}
|
|
547
571
|
|
|
548
|
-
// ─── Static file serving ────────────────────────────────────────────
|
|
549
|
-
|
|
550
572
|
async function serveStaticFile(
|
|
551
573
|
distPath: string,
|
|
552
574
|
pathname: string,
|
|
@@ -555,10 +577,13 @@ async function serveStaticFile(
|
|
|
555
577
|
const file = Bun.file(filePath);
|
|
556
578
|
|
|
557
579
|
if (await file.exists()) {
|
|
558
|
-
|
|
580
|
+
const isHashed = pathname.startsWith("/assets/");
|
|
581
|
+
const headers: Record<string, string> = isHashed
|
|
582
|
+
? { "Cache-Control": "public, max-age=31536000, immutable" }
|
|
583
|
+
: {};
|
|
584
|
+
return new Response(file, { headers });
|
|
559
585
|
}
|
|
560
586
|
|
|
561
|
-
// SPA fallback: serve index.html for non-API routes
|
|
562
587
|
const indexFile = Bun.file(join(distPath, "index.html"));
|
|
563
588
|
if (await indexFile.exists()) {
|
|
564
589
|
return new Response(indexFile);
|
|
@@ -567,43 +592,97 @@ async function serveStaticFile(
|
|
|
567
592
|
return new Response("Not Found", { status: 404 });
|
|
568
593
|
}
|
|
569
594
|
|
|
570
|
-
|
|
595
|
+
const VITE_DEV_PORT = 24678;
|
|
596
|
+
const VITE_DEV_ORIGIN = `http://127.0.0.1:${VITE_DEV_PORT}`;
|
|
597
|
+
|
|
598
|
+
async function proxyToVite(
|
|
599
|
+
req: Request,
|
|
600
|
+
pathname: string,
|
|
601
|
+
search: string,
|
|
602
|
+
): Promise<Response> {
|
|
603
|
+
const target = `${VITE_DEV_ORIGIN}${pathname}${search}`;
|
|
604
|
+
try {
|
|
605
|
+
return await fetch(
|
|
606
|
+
new Request(target, {
|
|
607
|
+
method: req.method,
|
|
608
|
+
headers: req.headers,
|
|
609
|
+
body: req.body,
|
|
610
|
+
redirect: "manual",
|
|
611
|
+
}),
|
|
612
|
+
);
|
|
613
|
+
} catch {
|
|
614
|
+
return new Response("Vite dev server not available", { status: 502 });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async function isViteReady(): Promise<boolean> {
|
|
619
|
+
try {
|
|
620
|
+
const res = await fetch(`${VITE_DEV_ORIGIN}/`);
|
|
621
|
+
return res.ok;
|
|
622
|
+
} catch {
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function spawnViteDev(): Promise<() => void> {
|
|
628
|
+
if (await isViteReady()) {
|
|
629
|
+
return () => {};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const child = Bun.spawn(
|
|
633
|
+
["bunx", "vite", "--port", String(VITE_DEV_PORT), "--strictPort"],
|
|
634
|
+
{ stdout: "ignore", stderr: "inherit" },
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
const maxWaitMs = 10_000;
|
|
638
|
+
const start = Date.now();
|
|
639
|
+
while (Date.now() - start < maxWaitMs) {
|
|
640
|
+
if (await isViteReady()) break;
|
|
641
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return () => {
|
|
645
|
+
child.kill();
|
|
646
|
+
};
|
|
647
|
+
}
|
|
571
648
|
|
|
572
649
|
function extractCommentId(pathname: string): string | undefined {
|
|
573
650
|
const match = pathname.match(/^\/api\/comments\/([^/]+)/);
|
|
574
651
|
return match?.[1];
|
|
575
652
|
}
|
|
576
653
|
|
|
577
|
-
// ─── Multi-file state ───────────────────────────────────────────────
|
|
578
|
-
|
|
579
654
|
interface FileState {
|
|
580
655
|
content: string | null;
|
|
656
|
+
renderedHtml: string | null;
|
|
657
|
+
headings: import("./lib/headings").Heading[] | null;
|
|
581
658
|
isLoaded: boolean;
|
|
582
|
-
type: DocumentType;
|
|
583
659
|
debounceTimer: ReturnType<typeof setTimeout> | null;
|
|
584
660
|
}
|
|
585
661
|
|
|
586
|
-
// ─── Server creation ────────────────────────────────────────────────
|
|
587
|
-
|
|
588
662
|
interface ServerWithWatchers {
|
|
589
663
|
server: ReturnType<typeof Bun.serve>;
|
|
590
664
|
watchers: FSWatcher[];
|
|
591
665
|
}
|
|
592
666
|
|
|
593
667
|
function createServer(options: ServerOptions): ServerWithWatchers {
|
|
594
|
-
// Map of absolute path → mutable file state
|
|
595
668
|
const fileMap = new Map<string, FileState>();
|
|
596
|
-
// Ordered list of file paths (insertion order for tab display)
|
|
597
669
|
const fileOrder: string[] = [];
|
|
598
670
|
|
|
599
671
|
for (const entry of options.files) {
|
|
600
672
|
fileMap.set(entry.filePath, {
|
|
601
673
|
content: entry.content ?? null,
|
|
674
|
+
renderedHtml: null,
|
|
675
|
+
headings: null,
|
|
602
676
|
isLoaded: entry.content !== undefined,
|
|
603
|
-
type: entry.type,
|
|
604
677
|
debounceTimer: null,
|
|
605
678
|
});
|
|
606
679
|
fileOrder.push(entry.filePath);
|
|
680
|
+
|
|
681
|
+
if (options.clean) {
|
|
682
|
+
const commentPath = getCommentPath(entry.filePath);
|
|
683
|
+
fs.unlink(commentPath).catch(() => {});
|
|
684
|
+
invalidateResolvedComments(entry.filePath);
|
|
685
|
+
}
|
|
607
686
|
}
|
|
608
687
|
|
|
609
688
|
const defaultPath = fileOrder[0];
|
|
@@ -663,7 +742,23 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
663
742
|
return content;
|
|
664
743
|
}
|
|
665
744
|
|
|
666
|
-
|
|
745
|
+
async function ensureRenderedHtml(
|
|
746
|
+
filePath: string,
|
|
747
|
+
): Promise<{ html: string; headings: import("./lib/headings").Heading[] }> {
|
|
748
|
+
const state = fileMap.get(filePath);
|
|
749
|
+
if (!state) throw new Error(`File not found: ${filePath}`);
|
|
750
|
+
|
|
751
|
+
if (state.renderedHtml !== null && state.headings !== null) {
|
|
752
|
+
return { html: state.renderedHtml, headings: state.headings };
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const content = await ensureFileContent(filePath);
|
|
756
|
+
const result = await renderMarkdown(content);
|
|
757
|
+
state.renderedHtml = result.html;
|
|
758
|
+
state.headings = result.headings;
|
|
759
|
+
return result;
|
|
760
|
+
}
|
|
761
|
+
|
|
667
762
|
function resolveContext(url: URL): RouteContext | null {
|
|
668
763
|
const requestedPath = url.searchParams.get("path") ?? defaultPath;
|
|
669
764
|
const state = fileMap.get(requestedPath);
|
|
@@ -685,10 +780,131 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
685
780
|
const isDev = process.env.NODE_ENV === "development";
|
|
686
781
|
const distPath = import.meta.dir;
|
|
687
782
|
|
|
783
|
+
let manifestCache: Record<string, { file: string; css?: string[] }> | null =
|
|
784
|
+
null;
|
|
785
|
+
|
|
786
|
+
async function getManifest(): Promise<typeof manifestCache> {
|
|
787
|
+
if (manifestCache) return manifestCache;
|
|
788
|
+
try {
|
|
789
|
+
const manifestPath = join(distPath, ".vite", "manifest.json");
|
|
790
|
+
const content = await fs.readFile(manifestPath, "utf-8");
|
|
791
|
+
manifestCache = JSON.parse(content);
|
|
792
|
+
return manifestCache;
|
|
793
|
+
} catch {
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
let pageCache: string | null = null;
|
|
799
|
+
let pageCacheGz: Uint8Array<ArrayBuffer> | null = null;
|
|
800
|
+
|
|
801
|
+
function invalidatePageCache(): void {
|
|
802
|
+
pageCache = null;
|
|
803
|
+
pageCacheGz = null;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
async function serveAppPage(req: Request): Promise<Response> {
|
|
807
|
+
const acceptGzip =
|
|
808
|
+
req.headers.get("accept-encoding")?.includes("gzip") ?? false;
|
|
809
|
+
|
|
810
|
+
try {
|
|
811
|
+
if (pageCache) {
|
|
812
|
+
if (acceptGzip && pageCacheGz) {
|
|
813
|
+
return new Response(pageCacheGz, {
|
|
814
|
+
headers: {
|
|
815
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
816
|
+
"Content-Encoding": "gzip",
|
|
817
|
+
},
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
return new Response(pageCache, {
|
|
821
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const { html, headings } = await ensureRenderedHtml(defaultPath);
|
|
826
|
+
const content = await ensureFileContent(defaultPath);
|
|
827
|
+
const comments = await readCommentsFromFile(defaultPath, content, html);
|
|
828
|
+
const settings = await readSettings();
|
|
829
|
+
|
|
830
|
+
const files = fileOrder.map((fp) => ({
|
|
831
|
+
path: fp,
|
|
832
|
+
fileName: basename(fp),
|
|
833
|
+
}));
|
|
834
|
+
|
|
835
|
+
const inlineData = {
|
|
836
|
+
files,
|
|
837
|
+
activeFile: defaultPath,
|
|
838
|
+
settings,
|
|
839
|
+
documents: {
|
|
840
|
+
[defaultPath]: {
|
|
841
|
+
headings,
|
|
842
|
+
comments,
|
|
843
|
+
},
|
|
844
|
+
},
|
|
845
|
+
clean: options.clean || false,
|
|
846
|
+
workingDirectory: process.cwd(),
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
let cssPath = "";
|
|
850
|
+
let jsPath: string;
|
|
851
|
+
|
|
852
|
+
if (isDev) {
|
|
853
|
+
jsPath = `http://127.0.0.1:${VITE_DEV_PORT}/src/main.ts`;
|
|
854
|
+
} else {
|
|
855
|
+
const manifest = await getManifest();
|
|
856
|
+
const entry = manifest?.["index.html"];
|
|
857
|
+
jsPath = entry ? `/${entry.file}` : "/assets/index.js";
|
|
858
|
+
if (entry?.css?.[0]) {
|
|
859
|
+
cssPath = `/${entry.css[0]}`;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const body = renderTemplate({
|
|
864
|
+
title: basename(defaultPath),
|
|
865
|
+
cssPath,
|
|
866
|
+
jsPath,
|
|
867
|
+
documentHtml: html,
|
|
868
|
+
inlineData,
|
|
869
|
+
isDev,
|
|
870
|
+
fontFamily: settings.fontFamily,
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
if (!isDev) {
|
|
874
|
+
pageCache = body;
|
|
875
|
+
pageCacheGz = Bun.gzipSync(
|
|
876
|
+
new TextEncoder().encode(body),
|
|
877
|
+
) as Uint8Array<ArrayBuffer>;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if (acceptGzip) {
|
|
881
|
+
const gz = pageCacheGz ?? Bun.gzipSync(new TextEncoder().encode(body));
|
|
882
|
+
return new Response(gz, {
|
|
883
|
+
headers: {
|
|
884
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
885
|
+
"Content-Encoding": "gzip",
|
|
886
|
+
},
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
return new Response(body, {
|
|
891
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
892
|
+
});
|
|
893
|
+
} catch (err) {
|
|
894
|
+
console.error("Failed to serve app page:", err);
|
|
895
|
+
return new Response("Internal Server Error", { status: 500 });
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
688
899
|
function watchFile(targetPath: string): FSWatcher | null {
|
|
689
900
|
try {
|
|
690
901
|
const watcher = watch(targetPath, async (eventType) => {
|
|
691
|
-
|
|
902
|
+
// Handle both "change" and "rename" events.
|
|
903
|
+
// Many editors (Vim, Neovim, Emacs) save files by writing to a temp
|
|
904
|
+
// file and then renaming it over the original. This triggers a
|
|
905
|
+
// "rename" event rather than "change". After a rename the original
|
|
906
|
+
// watcher may become invalid, so we re-establish it.
|
|
907
|
+
if (eventType !== "change" && eventType !== "rename") return;
|
|
692
908
|
|
|
693
909
|
const state = fileMap.get(targetPath);
|
|
694
910
|
if (!state) return;
|
|
@@ -699,16 +915,67 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
699
915
|
const newContent = await fs.readFile(targetPath, "utf-8");
|
|
700
916
|
if (!state.isLoaded || newContent !== state.content) {
|
|
701
917
|
state.content = newContent;
|
|
918
|
+
state.renderedHtml = null;
|
|
919
|
+
state.headings = null;
|
|
702
920
|
state.isLoaded = true;
|
|
703
921
|
invalidateResolvedComments(targetPath);
|
|
922
|
+
invalidatePageCache();
|
|
704
923
|
console.log(`File changed: ${basename(targetPath)}`);
|
|
705
924
|
sendEvent({ type: "document-updated", path: targetPath });
|
|
706
925
|
}
|
|
707
926
|
} catch (err) {
|
|
708
|
-
|
|
927
|
+
// File may have been temporarily removed during a rename-save.
|
|
928
|
+
// If it reappears, re-establish the watcher.
|
|
929
|
+
if (isErrnoException(err) && err.code === "ENOENT") {
|
|
930
|
+
await rewatch(targetPath);
|
|
931
|
+
} else {
|
|
932
|
+
console.error(`Failed to read updated file ${targetPath}:`, err);
|
|
933
|
+
}
|
|
709
934
|
}
|
|
710
935
|
}, 100);
|
|
711
936
|
});
|
|
937
|
+
|
|
938
|
+
// Re-establish file watch after a rename-style save
|
|
939
|
+
async function rewatch(filePath: string) {
|
|
940
|
+
const maxRetries = 10;
|
|
941
|
+
const retryInterval = 200;
|
|
942
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
943
|
+
await new Promise((r) => setTimeout(r, retryInterval));
|
|
944
|
+
try {
|
|
945
|
+
await fs.access(filePath);
|
|
946
|
+
// File exists again — close old watcher, create new one
|
|
947
|
+
try {
|
|
948
|
+
watcher.close();
|
|
949
|
+
} catch {}
|
|
950
|
+
const idx = watchers.indexOf(watcher);
|
|
951
|
+
const newWatcher = watchFile(filePath);
|
|
952
|
+
if (newWatcher) {
|
|
953
|
+
if (idx >= 0) watchers[idx] = newWatcher;
|
|
954
|
+
else watchers.push(newWatcher);
|
|
955
|
+
}
|
|
956
|
+
// Read the new content and emit update
|
|
957
|
+
const state = fileMap.get(filePath);
|
|
958
|
+
if (state) {
|
|
959
|
+
const newContent = await fs.readFile(filePath, "utf-8");
|
|
960
|
+
if (!state.isLoaded || newContent !== state.content) {
|
|
961
|
+
state.content = newContent;
|
|
962
|
+
state.renderedHtml = null;
|
|
963
|
+
state.headings = null;
|
|
964
|
+
state.isLoaded = true;
|
|
965
|
+
invalidateResolvedComments(filePath);
|
|
966
|
+
invalidatePageCache();
|
|
967
|
+
console.log(`File changed: ${basename(filePath)}`);
|
|
968
|
+
sendEvent({ type: "document-updated", path: filePath });
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
return;
|
|
972
|
+
} catch {
|
|
973
|
+
// File not yet recreated, keep retrying
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
console.warn(`File did not reappear after rename: ${filePath}`);
|
|
977
|
+
}
|
|
978
|
+
|
|
712
979
|
return watcher;
|
|
713
980
|
} catch (err) {
|
|
714
981
|
console.warn(`File watching not available for ${targetPath}:`, err);
|
|
@@ -716,28 +983,23 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
716
983
|
}
|
|
717
984
|
}
|
|
718
985
|
|
|
986
|
+
const watchers: FSWatcher[] = [];
|
|
987
|
+
|
|
719
988
|
const server = Bun.serve({
|
|
720
989
|
port: options.port,
|
|
721
990
|
hostname: options.host,
|
|
722
|
-
idleTimeout: 255,
|
|
991
|
+
idleTimeout: 255,
|
|
723
992
|
|
|
724
|
-
async fetch(req) {
|
|
993
|
+
async fetch(req: Request) {
|
|
725
994
|
const url = new URL(req.url);
|
|
726
995
|
const { pathname } = url;
|
|
727
996
|
const method = req.method;
|
|
728
997
|
|
|
729
|
-
// ── API routes ──────────────────────────────────────────
|
|
730
|
-
|
|
731
|
-
// Document list (multi-file)
|
|
732
998
|
if (pathname === "/api/documents" && method === "GET") {
|
|
733
|
-
const files = fileOrder.map((fp) => {
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
fileName: basename(fp),
|
|
738
|
-
type: state.type,
|
|
739
|
-
};
|
|
740
|
-
});
|
|
999
|
+
const files = fileOrder.map((fp) => ({
|
|
1000
|
+
path: fp,
|
|
1001
|
+
fileName: basename(fp),
|
|
1002
|
+
}));
|
|
741
1003
|
return json({
|
|
742
1004
|
files,
|
|
743
1005
|
clean: options.clean || false,
|
|
@@ -745,7 +1007,6 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
745
1007
|
});
|
|
746
1008
|
}
|
|
747
1009
|
|
|
748
|
-
// Register a document for this session without forcing focus
|
|
749
1010
|
if (pathname === "/api/documents" && method === "POST") {
|
|
750
1011
|
try {
|
|
751
1012
|
const { path: requestedPath } = await req.json();
|
|
@@ -763,11 +1024,9 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
763
1024
|
}
|
|
764
1025
|
throw err;
|
|
765
1026
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
if (!fileType) {
|
|
1027
|
+
if (!isMarkdownFile(filePath)) {
|
|
769
1028
|
return errorResponse(
|
|
770
|
-
`Unsupported file type: ${filePath} (expected .md
|
|
1029
|
+
`Unsupported file type: ${filePath} (expected .md or .markdown)`,
|
|
771
1030
|
400,
|
|
772
1031
|
);
|
|
773
1032
|
}
|
|
@@ -778,15 +1037,14 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
778
1037
|
return json({
|
|
779
1038
|
path: filePath,
|
|
780
1039
|
fileName: basename(filePath),
|
|
781
|
-
type: fileType,
|
|
782
1040
|
status: "present",
|
|
783
1041
|
});
|
|
784
1042
|
} else {
|
|
785
|
-
// New document — register metadata only, load content on demand
|
|
786
1043
|
fileMap.set(filePath, {
|
|
787
1044
|
content: null,
|
|
1045
|
+
renderedHtml: null,
|
|
1046
|
+
headings: null,
|
|
788
1047
|
isLoaded: false,
|
|
789
|
-
type: fileType,
|
|
790
1048
|
debounceTimer: null,
|
|
791
1049
|
});
|
|
792
1050
|
fileOrder.push(filePath);
|
|
@@ -798,14 +1056,12 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
798
1056
|
type: "document-added",
|
|
799
1057
|
path: filePath,
|
|
800
1058
|
fileName: basename(filePath),
|
|
801
|
-
fileType,
|
|
802
1059
|
});
|
|
803
1060
|
}
|
|
804
1061
|
|
|
805
1062
|
return json({
|
|
806
1063
|
path: filePath,
|
|
807
1064
|
fileName: basename(filePath),
|
|
808
|
-
type: fileType,
|
|
809
1065
|
status: "added",
|
|
810
1066
|
});
|
|
811
1067
|
} catch (err) {
|
|
@@ -814,15 +1070,13 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
814
1070
|
}
|
|
815
1071
|
}
|
|
816
1072
|
|
|
817
|
-
// Single document (backward compat + path-aware)
|
|
818
1073
|
if (pathname === "/api/document" && method === "GET") {
|
|
819
1074
|
const ctxOrRes = requireContext(url);
|
|
820
1075
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
|
821
|
-
const
|
|
822
|
-
const content = await ctxOrRes.getCurrentContent();
|
|
1076
|
+
const { html, headings } = await ensureRenderedHtml(ctxOrRes.filePath);
|
|
823
1077
|
return json({
|
|
824
|
-
|
|
825
|
-
|
|
1078
|
+
html,
|
|
1079
|
+
headings,
|
|
826
1080
|
filePath: ctxOrRes.filePath,
|
|
827
1081
|
fileName: basename(ctxOrRes.filePath),
|
|
828
1082
|
clean: options.clean || false,
|
|
@@ -841,11 +1095,11 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
841
1095
|
return createHeartbeat(onHeartbeatOpen, onHeartbeatClose);
|
|
842
1096
|
}
|
|
843
1097
|
|
|
844
|
-
// Comments routes
|
|
845
1098
|
if (pathname === "/api/comments" && method === "GET") {
|
|
846
1099
|
const ctxOrRes = requireContext(url);
|
|
847
1100
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
|
848
|
-
|
|
1101
|
+
const rendered = await ensureRenderedHtml(ctxOrRes.filePath);
|
|
1102
|
+
return getComments(ctxOrRes, rendered.html);
|
|
849
1103
|
}
|
|
850
1104
|
|
|
851
1105
|
if (pathname === "/api/comments/raw" && method === "GET") {
|
|
@@ -857,20 +1111,22 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
857
1111
|
if (pathname === "/api/comments" && method === "POST") {
|
|
858
1112
|
const ctxOrRes = requireContext(url);
|
|
859
1113
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
|
1114
|
+
invalidatePageCache();
|
|
860
1115
|
return addComment(ctxOrRes, req);
|
|
861
1116
|
}
|
|
862
1117
|
|
|
863
1118
|
if (pathname === "/api/comments" && method === "DELETE") {
|
|
864
1119
|
const ctxOrRes = requireContext(url);
|
|
865
1120
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
|
1121
|
+
invalidatePageCache();
|
|
866
1122
|
return clearComments(ctxOrRes);
|
|
867
1123
|
}
|
|
868
1124
|
|
|
869
|
-
// Parameterized comment routes
|
|
870
1125
|
const commentId = extractCommentId(pathname);
|
|
871
1126
|
if (commentId) {
|
|
872
1127
|
const ctxOrRes = requireContext(url);
|
|
873
1128
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
|
1129
|
+
invalidatePageCache();
|
|
874
1130
|
|
|
875
1131
|
if (pathname.endsWith("/reanchor") && method === "PUT") {
|
|
876
1132
|
return reanchorComment(ctxOrRes, req, commentId);
|
|
@@ -883,7 +1139,6 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
883
1139
|
}
|
|
884
1140
|
}
|
|
885
1141
|
|
|
886
|
-
// Settings routes (global, not per-document)
|
|
887
1142
|
if (pathname === "/api/settings" && method === "GET") {
|
|
888
1143
|
return getSettingsRoute();
|
|
889
1144
|
}
|
|
@@ -892,15 +1147,17 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
892
1147
|
return updateSettingsRoute(req);
|
|
893
1148
|
}
|
|
894
1149
|
|
|
895
|
-
|
|
1150
|
+
if (pathname === "/") {
|
|
1151
|
+
return serveAppPage(req);
|
|
1152
|
+
}
|
|
896
1153
|
|
|
1154
|
+
if (isDev) {
|
|
1155
|
+
return proxyToVite(req, pathname, url.search);
|
|
1156
|
+
}
|
|
897
1157
|
return serveStaticFile(distPath, pathname);
|
|
898
1158
|
},
|
|
899
1159
|
});
|
|
900
1160
|
|
|
901
|
-
// Set up per-file watchers after Bun.serve() succeeds to avoid
|
|
902
|
-
// leaking FSWatcher handles if the server fails to bind.
|
|
903
|
-
const watchers: FSWatcher[] = [];
|
|
904
1161
|
for (const fp of fileOrder) {
|
|
905
1162
|
const watcher = watchFile(fp);
|
|
906
1163
|
if (watcher) watchers.push(watcher);
|
|
@@ -909,11 +1166,11 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
909
1166
|
return { server, watchers };
|
|
910
1167
|
}
|
|
911
1168
|
|
|
912
|
-
// ─── Port fallback + start ──────────────────────────────────────────
|
|
913
|
-
|
|
914
1169
|
export async function startServer(
|
|
915
1170
|
options: ServerOptions,
|
|
916
1171
|
): Promise<ServerResult> {
|
|
1172
|
+
getShiki();
|
|
1173
|
+
|
|
917
1174
|
const MAX_PORT = 65535;
|
|
918
1175
|
|
|
919
1176
|
for (let port = options.port; port <= MAX_PORT; port++) {
|
|
@@ -923,9 +1180,16 @@ export async function startServer(
|
|
|
923
1180
|
const displayHost =
|
|
924
1181
|
options.host === "0.0.0.0" ? "localhost" : options.host;
|
|
925
1182
|
|
|
1183
|
+
let stopVite: (() => void) | undefined;
|
|
1184
|
+
if (process.env.NODE_ENV === "development") {
|
|
1185
|
+
stopVite = await spawnViteDev();
|
|
1186
|
+
}
|
|
1187
|
+
|
|
926
1188
|
const originalStop = server.stop.bind(server);
|
|
927
1189
|
const wrappedServer = {
|
|
928
1190
|
stop() {
|
|
1191
|
+
disposeMermaidWorker();
|
|
1192
|
+
stopVite?.();
|
|
929
1193
|
for (const w of watchers) w.close();
|
|
930
1194
|
originalStop();
|
|
931
1195
|
},
|