@peaske7/readit 0.2.0 → 0.3.0-rc.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/.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 -2
- package/biome.json +18 -8
- package/bun.lock +426 -568
- package/bunfig.toml +2 -0
- package/docs/perf-baseline.md +56 -1
- 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 +9 -11
- package/e2e/perf/fixtures/generate.ts +1 -5
- package/e2e/perf/screenshot-final.png +0 -0
- package/e2e/perf/utils/metrics.ts +73 -9
- 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 +152 -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 +20 -28
- package/shell/_readit +158 -0
- package/shell/readit.zsh +87 -0
- package/src/App.svelte +890 -0
- package/src/cli.ts +183 -21
- 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 +233 -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/MermaidEnhancer.svelte +218 -0
- package/src/components/MermaidModal.svelte +67 -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.tsx → Button.svelte} +19 -20
- 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.tsx → Text.svelte} +18 -23
- package/src/env.d.ts +6 -0
- package/src/index.css +141 -166
- package/src/lib/__fixtures__/bench-data.ts +0 -13
- package/src/lib/anchor.bench.ts +1 -12
- package/src/lib/anchor.test.ts +0 -8
- package/src/lib/anchor.ts +0 -4
- package/src/lib/comment-storage.bench.ts +49 -0
- package/src/lib/comment-storage.test.ts +103 -33
- package/src/lib/comment-storage.ts +25 -18
- package/src/lib/export.bench.ts +21 -0
- package/src/lib/export.ts +0 -1
- package/src/lib/fetch-or-throw.test.ts +59 -0
- package/src/lib/fetch-or-throw.ts +12 -0
- package/src/{hooks/useHeadings.test.ts → lib/headings.test.ts} +10 -24
- package/src/{hooks/useHeadings.ts → lib/headings.ts} +11 -13
- package/src/lib/highlight/core.test.ts +0 -5
- package/src/lib/highlight/dom.ts +52 -216
- package/src/lib/highlight/highlight-registry.ts +221 -0
- package/src/lib/highlight/highlight.bench.ts +92 -0
- package/src/lib/highlight/highlighter.ts +112 -132
- package/src/lib/highlight/resolver.ts +5 -79
- package/src/lib/highlight/types.ts +0 -5
- package/src/lib/html-text.test.ts +162 -0
- package/src/lib/html-text.ts +161 -0
- package/src/lib/i18n/en.ts +34 -0
- package/src/lib/i18n/ja.ts +34 -0
- package/src/lib/i18n/types.ts +33 -0
- package/src/lib/key-lock.test.ts +104 -0
- package/src/lib/key-lock.ts +23 -0
- package/src/lib/margin-layout.bench.ts +61 -0
- package/src/lib/margin-layout.ts +0 -7
- package/src/lib/markdown-renderer.test.ts +154 -0
- package/src/lib/markdown-renderer.ts +178 -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 +31 -24
- package/src/lib/shortcut-registry.ts +244 -0
- package/src/lib/utils.ts +0 -29
- package/src/main.ts +16 -0
- package/src/schema.ts +16 -5
- package/src/server.ts +355 -95
- 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 +23 -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 -368
- package/src/components/ActionsMenu.tsx +0 -91
- package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
- package/src/components/DocumentViewer/DocumentViewer.tsx +0 -230
- package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -136
- package/src/components/Header.tsx +0 -54
- package/src/components/InlineEditor.tsx +0 -74
- package/src/components/MarginNote.tsx +0 -185
- package/src/components/MarginNotes.tsx +0 -23
- package/src/components/RawModal.tsx +0 -144
- package/src/components/ReanchorConfirm.tsx +0 -36
- package/src/components/SettingsModal.tsx +0 -232
- 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 -86
- package/src/components/comments/CommentListItem.tsx +0 -90
- package/src/components/comments/CommentManager.tsx +0 -129
- package/src/components/comments/CommentNav.tsx +0 -109
- package/src/components/ui/ActionLink.tsx +0 -28
- package/src/components/ui/Dialog.tsx +0 -116
- package/src/components/ui/DropdownMenu.tsx +0 -158
- package/src/contexts/CommentContext.tsx +0 -198
- package/src/contexts/LocaleContext.tsx +0 -76
- package/src/contexts/PositionsContext.tsx +0 -16
- package/src/contexts/SettingsContext.tsx +0 -133
- package/src/hooks/useClickOutside.ts +0 -31
- package/src/hooks/useCommentNavigation.ts +0 -107
- package/src/hooks/useComments.ts +0 -311
- package/src/hooks/useDocument.ts +0 -157
- package/src/hooks/useScrollSpy.ts +0 -77
- package/src/hooks/useTextSelection.ts +0 -86
- package/src/lib/highlight/worker.ts +0 -45
- package/src/main.tsx +0 -13
- package/src/store.ts +0 -222
package/src/server.ts
CHANGED
|
@@ -13,6 +13,11 @@ import {
|
|
|
13
13
|
serializeComments,
|
|
14
14
|
truncateSelection,
|
|
15
15
|
} from "./lib/comment-storage.js";
|
|
16
|
+
import { findTextPosition } from "./lib/highlight/resolver.js";
|
|
17
|
+
import { extractTextFromHtml } from "./lib/html-text.js";
|
|
18
|
+
import { createKeyLock } from "./lib/key-lock.js";
|
|
19
|
+
import { getShiki, renderMarkdown } from "./lib/markdown-renderer.js";
|
|
20
|
+
import { disposeMermaidWorker } from "./lib/mermaid-renderer.js";
|
|
16
21
|
import { isMarkdownFile } from "./lib/utils.js";
|
|
17
22
|
import {
|
|
18
23
|
AnchorConfidences,
|
|
@@ -21,6 +26,7 @@ import {
|
|
|
21
26
|
FontFamilies,
|
|
22
27
|
type FontFamily,
|
|
23
28
|
} from "./schema.js";
|
|
29
|
+
import { renderTemplate } from "./template.js";
|
|
24
30
|
|
|
25
31
|
function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
|
|
26
32
|
return err instanceof Error && "code" in err;
|
|
@@ -56,6 +62,8 @@ function invalidateResolvedComments(filePath: string): void {
|
|
|
56
62
|
resolvedCommentsCache.delete(filePath);
|
|
57
63
|
}
|
|
58
64
|
|
|
65
|
+
const withCommentLock = createKeyLock("comments");
|
|
66
|
+
|
|
59
67
|
async function canonicalPath(filePath: string): Promise<string> {
|
|
60
68
|
return fs.realpath(path.resolve(filePath));
|
|
61
69
|
}
|
|
@@ -63,6 +71,7 @@ async function canonicalPath(filePath: string): Promise<string> {
|
|
|
63
71
|
async function readCommentsFromFile(
|
|
64
72
|
filePath: string,
|
|
65
73
|
sourceContent: string,
|
|
74
|
+
renderedHtml?: string,
|
|
66
75
|
): Promise<Comment[]> {
|
|
67
76
|
const commentPath = getCommentPath(filePath);
|
|
68
77
|
const sourceHash = computeHash(sourceContent);
|
|
@@ -80,27 +89,46 @@ async function readCommentsFromFile(
|
|
|
80
89
|
|
|
81
90
|
const content = await fs.readFile(commentPath, "utf-8");
|
|
82
91
|
const file = parseCommentFile(content);
|
|
92
|
+
|
|
93
|
+
const domText = renderedHtml ? extractTextFromHtml(renderedHtml) : null;
|
|
94
|
+
|
|
83
95
|
const resolvedComments = file.comments.map((comment) => {
|
|
84
96
|
const textForMatching = comment.anchorPrefix || comment.selectedText;
|
|
97
|
+
|
|
85
98
|
const anchor = findAnchorWithFallback({
|
|
86
99
|
source: sourceContent,
|
|
87
100
|
selectedText: textForMatching,
|
|
88
101
|
lineHint: comment.lineHint || "L1",
|
|
89
102
|
});
|
|
90
103
|
|
|
91
|
-
if (anchor) {
|
|
104
|
+
if (!anchor) {
|
|
92
105
|
return {
|
|
93
106
|
...comment,
|
|
94
|
-
|
|
95
|
-
endOffset: anchor.end,
|
|
96
|
-
lineHint: `L${anchor.line}`,
|
|
97
|
-
anchorConfidence: anchor.confidence,
|
|
107
|
+
anchorConfidence: AnchorConfidences.UNRESOLVED,
|
|
98
108
|
};
|
|
99
109
|
}
|
|
100
110
|
|
|
111
|
+
let startOffset = anchor.start;
|
|
112
|
+
let endOffset = anchor.end;
|
|
113
|
+
|
|
114
|
+
if (domText) {
|
|
115
|
+
const domPos = findTextPosition(
|
|
116
|
+
domText,
|
|
117
|
+
comment.selectedText,
|
|
118
|
+
anchor.start,
|
|
119
|
+
);
|
|
120
|
+
if (domPos) {
|
|
121
|
+
startOffset = domPos.start;
|
|
122
|
+
endOffset = domPos.end;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
101
126
|
return {
|
|
102
127
|
...comment,
|
|
103
|
-
|
|
128
|
+
startOffset,
|
|
129
|
+
endOffset,
|
|
130
|
+
lineHint: `L${anchor.line}`,
|
|
131
|
+
anchorConfidence: anchor.confidence,
|
|
104
132
|
};
|
|
105
133
|
});
|
|
106
134
|
|
|
@@ -221,15 +249,31 @@ function errorResponse(message: string, status: number): Response {
|
|
|
221
249
|
return Response.json({ error: message }, { status });
|
|
222
250
|
}
|
|
223
251
|
|
|
252
|
+
function errorWithDetail(
|
|
253
|
+
message: string,
|
|
254
|
+
err: unknown,
|
|
255
|
+
status = 500,
|
|
256
|
+
): Response {
|
|
257
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
258
|
+
return errorResponse(`${message}: ${detail}`, status);
|
|
259
|
+
}
|
|
260
|
+
|
|
224
261
|
interface RouteContext {
|
|
225
262
|
filePath: string;
|
|
226
263
|
getCurrentContent: () => Promise<string>;
|
|
227
264
|
}
|
|
228
265
|
|
|
229
|
-
async function getComments(
|
|
266
|
+
async function getComments(
|
|
267
|
+
ctx: RouteContext,
|
|
268
|
+
renderedHtml?: string,
|
|
269
|
+
): Promise<Response> {
|
|
230
270
|
try {
|
|
231
271
|
const currentContent = await ctx.getCurrentContent();
|
|
232
|
-
const comments = await readCommentsFromFile(
|
|
272
|
+
const comments = await readCommentsFromFile(
|
|
273
|
+
ctx.filePath,
|
|
274
|
+
currentContent,
|
|
275
|
+
renderedHtml,
|
|
276
|
+
);
|
|
233
277
|
return json({ comments });
|
|
234
278
|
} catch (err) {
|
|
235
279
|
console.error("Failed to read comments:", err);
|
|
@@ -264,18 +308,19 @@ async function addComment(ctx: RouteContext, req: Request): Promise<Response> {
|
|
|
264
308
|
currentContent,
|
|
265
309
|
);
|
|
266
310
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
311
|
+
await withCommentLock(ctx.filePath, async () => {
|
|
312
|
+
const existingComments = await readCommentsFromFile(
|
|
313
|
+
ctx.filePath,
|
|
314
|
+
currentContent,
|
|
315
|
+
);
|
|
316
|
+
const allComments = [...existingComments, newComment];
|
|
317
|
+
await writeCommentsToFile(ctx.filePath, currentContent, allComments);
|
|
318
|
+
});
|
|
274
319
|
|
|
275
320
|
return json({ comment: newComment }, 201);
|
|
276
321
|
} catch (err) {
|
|
277
322
|
console.error("Failed to add comment:", err);
|
|
278
|
-
return
|
|
323
|
+
return errorWithDetail("Failed to add comment", err);
|
|
279
324
|
}
|
|
280
325
|
}
|
|
281
326
|
|
|
@@ -292,62 +337,65 @@ async function updateComment(
|
|
|
292
337
|
}
|
|
293
338
|
|
|
294
339
|
const currentContent = await ctx.getCurrentContent();
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
);
|
|
308
|
-
|
|
309
|
-
await writeCommentsToFile(ctx.filePath, currentContent, updatedComments);
|
|
340
|
+
const result = await withCommentLock(ctx.filePath, async () => {
|
|
341
|
+
const existingComments = await readCommentsFromFile(
|
|
342
|
+
ctx.filePath,
|
|
343
|
+
currentContent,
|
|
344
|
+
);
|
|
345
|
+
const commentIndex = existingComments.findIndex((c) => c.id === id);
|
|
346
|
+
if (commentIndex === -1) return null;
|
|
347
|
+
const updatedComments = existingComments.map((c, i) =>
|
|
348
|
+
i === commentIndex ? { ...c, comment: commentText.trim() } : c,
|
|
349
|
+
);
|
|
350
|
+
await writeCommentsToFile(ctx.filePath, currentContent, updatedComments);
|
|
351
|
+
return updatedComments[commentIndex];
|
|
352
|
+
});
|
|
310
353
|
|
|
311
|
-
return
|
|
354
|
+
if (!result) return errorResponse("Comment not found", 404);
|
|
355
|
+
return json({ comment: result });
|
|
312
356
|
} catch (err) {
|
|
313
357
|
console.error("Failed to update comment:", err);
|
|
314
|
-
return
|
|
358
|
+
return errorWithDetail("Failed to update comment", err);
|
|
315
359
|
}
|
|
316
360
|
}
|
|
317
361
|
|
|
318
362
|
async function deleteComment(ctx: RouteContext, id: string): Promise<Response> {
|
|
319
363
|
try {
|
|
320
364
|
const currentContent = await ctx.getCurrentContent();
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
365
|
+
const found = await withCommentLock(ctx.filePath, async () => {
|
|
366
|
+
const existingComments = await readCommentsFromFile(
|
|
367
|
+
ctx.filePath,
|
|
368
|
+
currentContent,
|
|
369
|
+
);
|
|
370
|
+
const filteredComments = existingComments.filter((c) => c.id !== id);
|
|
371
|
+
if (filteredComments.length === existingComments.length) return false;
|
|
372
|
+
if (filteredComments.length === 0) {
|
|
373
|
+
await deleteCommentFile(ctx.filePath);
|
|
374
|
+
} else {
|
|
375
|
+
await writeCommentsToFile(
|
|
376
|
+
ctx.filePath,
|
|
377
|
+
currentContent,
|
|
378
|
+
filteredComments,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
return true;
|
|
382
|
+
});
|
|
336
383
|
|
|
384
|
+
if (!found) return errorResponse("Comment not found", 404);
|
|
337
385
|
return json({ success: true });
|
|
338
386
|
} catch (err) {
|
|
339
387
|
console.error("Failed to delete comment:", err);
|
|
340
|
-
return
|
|
388
|
+
return errorWithDetail("Failed to delete comment", err);
|
|
341
389
|
}
|
|
342
390
|
}
|
|
343
391
|
|
|
344
392
|
async function clearComments(ctx: RouteContext): Promise<Response> {
|
|
345
393
|
try {
|
|
346
|
-
await deleteCommentFile(ctx.filePath);
|
|
394
|
+
await withCommentLock(ctx.filePath, () => deleteCommentFile(ctx.filePath));
|
|
347
395
|
return json({ success: true });
|
|
348
396
|
} catch (err) {
|
|
349
397
|
console.error("Failed to clear comments:", err);
|
|
350
|
-
return
|
|
398
|
+
return errorWithDetail("Failed to clear comments", err);
|
|
351
399
|
}
|
|
352
400
|
}
|
|
353
401
|
|
|
@@ -378,40 +426,38 @@ async function reanchorComment(
|
|
|
378
426
|
}
|
|
379
427
|
|
|
380
428
|
const currentContent = await ctx.getCurrentContent();
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
);
|
|
408
|
-
|
|
409
|
-
await writeCommentsToFile(ctx.filePath, currentContent, updatedComments);
|
|
429
|
+
const result = await withCommentLock(ctx.filePath, async () => {
|
|
430
|
+
const existingComments = await readCommentsFromFile(
|
|
431
|
+
ctx.filePath,
|
|
432
|
+
currentContent,
|
|
433
|
+
);
|
|
434
|
+
const commentIndex = existingComments.findIndex((c) => c.id === id);
|
|
435
|
+
if (commentIndex === -1) return null;
|
|
436
|
+
|
|
437
|
+
const lineHint = getLineHint(currentContent, startOffset, endOffset);
|
|
438
|
+
const truncatedText = truncateSelection(selectedText);
|
|
439
|
+
const updatedComment: Comment = {
|
|
440
|
+
...existingComments[commentIndex],
|
|
441
|
+
selectedText: truncatedText,
|
|
442
|
+
startOffset,
|
|
443
|
+
endOffset,
|
|
444
|
+
lineHint,
|
|
445
|
+
anchorConfidence: AnchorConfidences.EXACT,
|
|
446
|
+
anchorPrefix:
|
|
447
|
+
selectedText.length > 1000 ? selectedText.slice(0, 200) : undefined,
|
|
448
|
+
};
|
|
449
|
+
const updatedComments = existingComments.map((c, i) =>
|
|
450
|
+
i === commentIndex ? updatedComment : c,
|
|
451
|
+
);
|
|
452
|
+
await writeCommentsToFile(ctx.filePath, currentContent, updatedComments);
|
|
453
|
+
return updatedComment;
|
|
454
|
+
});
|
|
410
455
|
|
|
411
|
-
return
|
|
456
|
+
if (!result) return errorResponse("Comment not found", 404);
|
|
457
|
+
return json({ comment: result });
|
|
412
458
|
} catch (err) {
|
|
413
459
|
console.error("Failed to re-anchor comment:", err);
|
|
414
|
-
return
|
|
460
|
+
return errorWithDetail("Failed to re-anchor comment", err);
|
|
415
461
|
}
|
|
416
462
|
}
|
|
417
463
|
|
|
@@ -527,10 +573,13 @@ async function serveStaticFile(
|
|
|
527
573
|
const file = Bun.file(filePath);
|
|
528
574
|
|
|
529
575
|
if (await file.exists()) {
|
|
530
|
-
|
|
576
|
+
const isHashed = pathname.startsWith("/assets/");
|
|
577
|
+
const headers: Record<string, string> = isHashed
|
|
578
|
+
? { "Cache-Control": "public, max-age=31536000, immutable" }
|
|
579
|
+
: {};
|
|
580
|
+
return new Response(file, { headers });
|
|
531
581
|
}
|
|
532
582
|
|
|
533
|
-
// SPA fallback: serve index.html for non-API routes
|
|
534
583
|
const indexFile = Bun.file(join(distPath, "index.html"));
|
|
535
584
|
if (await indexFile.exists()) {
|
|
536
585
|
return new Response(indexFile);
|
|
@@ -572,7 +621,6 @@ async function isViteReady(): Promise<boolean> {
|
|
|
572
621
|
}
|
|
573
622
|
|
|
574
623
|
async function spawnViteDev(): Promise<() => void> {
|
|
575
|
-
// If Vite is already running (e.g. after bun --watch restart), reuse it
|
|
576
624
|
if (await isViteReady()) {
|
|
577
625
|
return () => {};
|
|
578
626
|
}
|
|
@@ -601,6 +649,8 @@ function extractCommentId(pathname: string): string | undefined {
|
|
|
601
649
|
|
|
602
650
|
interface FileState {
|
|
603
651
|
content: string | null;
|
|
652
|
+
renderedHtml: string | null;
|
|
653
|
+
headings: import("./lib/headings").Heading[] | null;
|
|
604
654
|
isLoaded: boolean;
|
|
605
655
|
debounceTimer: ReturnType<typeof setTimeout> | null;
|
|
606
656
|
}
|
|
@@ -617,10 +667,18 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
617
667
|
for (const entry of options.files) {
|
|
618
668
|
fileMap.set(entry.filePath, {
|
|
619
669
|
content: entry.content ?? null,
|
|
670
|
+
renderedHtml: null,
|
|
671
|
+
headings: null,
|
|
620
672
|
isLoaded: entry.content !== undefined,
|
|
621
673
|
debounceTimer: null,
|
|
622
674
|
});
|
|
623
675
|
fileOrder.push(entry.filePath);
|
|
676
|
+
|
|
677
|
+
if (options.clean) {
|
|
678
|
+
const commentPath = getCommentPath(entry.filePath);
|
|
679
|
+
fs.unlink(commentPath).catch(() => {});
|
|
680
|
+
invalidateResolvedComments(entry.filePath);
|
|
681
|
+
}
|
|
624
682
|
}
|
|
625
683
|
|
|
626
684
|
const defaultPath = fileOrder[0];
|
|
@@ -680,6 +738,23 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
680
738
|
return content;
|
|
681
739
|
}
|
|
682
740
|
|
|
741
|
+
async function ensureRenderedHtml(
|
|
742
|
+
filePath: string,
|
|
743
|
+
): Promise<{ html: string; headings: import("./lib/headings").Heading[] }> {
|
|
744
|
+
const state = fileMap.get(filePath);
|
|
745
|
+
if (!state) throw new Error(`File not found: ${filePath}`);
|
|
746
|
+
|
|
747
|
+
if (state.renderedHtml !== null && state.headings !== null) {
|
|
748
|
+
return { html: state.renderedHtml, headings: state.headings };
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const content = await ensureFileContent(filePath);
|
|
752
|
+
const result = await renderMarkdown(content);
|
|
753
|
+
state.renderedHtml = result.html;
|
|
754
|
+
state.headings = result.headings;
|
|
755
|
+
return result;
|
|
756
|
+
}
|
|
757
|
+
|
|
683
758
|
function resolveContext(url: URL): RouteContext | null {
|
|
684
759
|
const requestedPath = url.searchParams.get("path") ?? defaultPath;
|
|
685
760
|
const state = fileMap.get(requestedPath);
|
|
@@ -701,10 +776,131 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
701
776
|
const isDev = process.env.NODE_ENV === "development";
|
|
702
777
|
const distPath = import.meta.dir;
|
|
703
778
|
|
|
779
|
+
let manifestCache: Record<string, { file: string; css?: string[] }> | null =
|
|
780
|
+
null;
|
|
781
|
+
|
|
782
|
+
async function getManifest(): Promise<typeof manifestCache> {
|
|
783
|
+
if (manifestCache) return manifestCache;
|
|
784
|
+
try {
|
|
785
|
+
const manifestPath = join(distPath, ".vite", "manifest.json");
|
|
786
|
+
const content = await fs.readFile(manifestPath, "utf-8");
|
|
787
|
+
manifestCache = JSON.parse(content);
|
|
788
|
+
return manifestCache;
|
|
789
|
+
} catch {
|
|
790
|
+
return null;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
let pageCache: string | null = null;
|
|
795
|
+
let pageCacheGz: Uint8Array<ArrayBuffer> | null = null;
|
|
796
|
+
|
|
797
|
+
function invalidatePageCache(): void {
|
|
798
|
+
pageCache = null;
|
|
799
|
+
pageCacheGz = null;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async function serveAppPage(req: Request): Promise<Response> {
|
|
803
|
+
const acceptGzip =
|
|
804
|
+
req.headers.get("accept-encoding")?.includes("gzip") ?? false;
|
|
805
|
+
|
|
806
|
+
try {
|
|
807
|
+
if (pageCache) {
|
|
808
|
+
if (acceptGzip && pageCacheGz) {
|
|
809
|
+
return new Response(pageCacheGz, {
|
|
810
|
+
headers: {
|
|
811
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
812
|
+
"Content-Encoding": "gzip",
|
|
813
|
+
},
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
return new Response(pageCache, {
|
|
817
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const { html, headings } = await ensureRenderedHtml(defaultPath);
|
|
822
|
+
const content = await ensureFileContent(defaultPath);
|
|
823
|
+
const comments = await readCommentsFromFile(defaultPath, content, html);
|
|
824
|
+
const settings = await readSettings();
|
|
825
|
+
|
|
826
|
+
const files = fileOrder.map((fp) => ({
|
|
827
|
+
path: fp,
|
|
828
|
+
fileName: basename(fp),
|
|
829
|
+
}));
|
|
830
|
+
|
|
831
|
+
const inlineData = {
|
|
832
|
+
files,
|
|
833
|
+
activeFile: defaultPath,
|
|
834
|
+
settings,
|
|
835
|
+
documents: {
|
|
836
|
+
[defaultPath]: {
|
|
837
|
+
headings,
|
|
838
|
+
comments,
|
|
839
|
+
},
|
|
840
|
+
},
|
|
841
|
+
clean: options.clean || false,
|
|
842
|
+
workingDirectory: process.cwd(),
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
let cssPath = "";
|
|
846
|
+
let jsPath: string;
|
|
847
|
+
|
|
848
|
+
if (isDev) {
|
|
849
|
+
jsPath = `http://127.0.0.1:${VITE_DEV_PORT}/src/main.ts`;
|
|
850
|
+
} else {
|
|
851
|
+
const manifest = await getManifest();
|
|
852
|
+
const entry = manifest?.["index.html"];
|
|
853
|
+
jsPath = entry ? `/${entry.file}` : "/assets/index.js";
|
|
854
|
+
if (entry?.css?.[0]) {
|
|
855
|
+
cssPath = `/${entry.css[0]}`;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const body = renderTemplate({
|
|
860
|
+
title: basename(defaultPath),
|
|
861
|
+
cssPath,
|
|
862
|
+
jsPath,
|
|
863
|
+
documentHtml: html,
|
|
864
|
+
inlineData,
|
|
865
|
+
isDev,
|
|
866
|
+
fontFamily: settings.fontFamily,
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
if (!isDev) {
|
|
870
|
+
pageCache = body;
|
|
871
|
+
pageCacheGz = Bun.gzipSync(
|
|
872
|
+
new TextEncoder().encode(body),
|
|
873
|
+
) as Uint8Array<ArrayBuffer>;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (acceptGzip) {
|
|
877
|
+
const gz = pageCacheGz ?? Bun.gzipSync(new TextEncoder().encode(body));
|
|
878
|
+
return new Response(gz, {
|
|
879
|
+
headers: {
|
|
880
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
881
|
+
"Content-Encoding": "gzip",
|
|
882
|
+
},
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
return new Response(body, {
|
|
887
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
888
|
+
});
|
|
889
|
+
} catch (err) {
|
|
890
|
+
console.error("Failed to serve app page:", err);
|
|
891
|
+
return new Response("Internal Server Error", { status: 500 });
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
704
895
|
function watchFile(targetPath: string): FSWatcher | null {
|
|
705
896
|
try {
|
|
706
897
|
const watcher = watch(targetPath, async (eventType) => {
|
|
707
|
-
|
|
898
|
+
// Handle both "change" and "rename" events.
|
|
899
|
+
// Many editors (Vim, Neovim, Emacs) save files by writing to a temp
|
|
900
|
+
// file and then renaming it over the original. This triggers a
|
|
901
|
+
// "rename" event rather than "change". After a rename the original
|
|
902
|
+
// watcher may become invalid, so we re-establish it.
|
|
903
|
+
if (eventType !== "change" && eventType !== "rename") return;
|
|
708
904
|
|
|
709
905
|
const state = fileMap.get(targetPath);
|
|
710
906
|
if (!state) return;
|
|
@@ -715,16 +911,67 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
715
911
|
const newContent = await fs.readFile(targetPath, "utf-8");
|
|
716
912
|
if (!state.isLoaded || newContent !== state.content) {
|
|
717
913
|
state.content = newContent;
|
|
914
|
+
state.renderedHtml = null;
|
|
915
|
+
state.headings = null;
|
|
718
916
|
state.isLoaded = true;
|
|
719
917
|
invalidateResolvedComments(targetPath);
|
|
918
|
+
invalidatePageCache();
|
|
720
919
|
console.log(`File changed: ${basename(targetPath)}`);
|
|
721
920
|
sendEvent({ type: "document-updated", path: targetPath });
|
|
722
921
|
}
|
|
723
922
|
} catch (err) {
|
|
724
|
-
|
|
923
|
+
// File may have been temporarily removed during a rename-save.
|
|
924
|
+
// If it reappears, re-establish the watcher.
|
|
925
|
+
if (isErrnoException(err) && err.code === "ENOENT") {
|
|
926
|
+
await rewatch(targetPath);
|
|
927
|
+
} else {
|
|
928
|
+
console.error(`Failed to read updated file ${targetPath}:`, err);
|
|
929
|
+
}
|
|
725
930
|
}
|
|
726
931
|
}, 100);
|
|
727
932
|
});
|
|
933
|
+
|
|
934
|
+
// Re-establish file watch after a rename-style save
|
|
935
|
+
async function rewatch(filePath: string) {
|
|
936
|
+
const maxRetries = 10;
|
|
937
|
+
const retryInterval = 200;
|
|
938
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
939
|
+
await new Promise((r) => setTimeout(r, retryInterval));
|
|
940
|
+
try {
|
|
941
|
+
await fs.access(filePath);
|
|
942
|
+
// File exists again — close old watcher, create new one
|
|
943
|
+
try {
|
|
944
|
+
watcher.close();
|
|
945
|
+
} catch {}
|
|
946
|
+
const idx = watchers.indexOf(watcher);
|
|
947
|
+
const newWatcher = watchFile(filePath);
|
|
948
|
+
if (newWatcher) {
|
|
949
|
+
if (idx >= 0) watchers[idx] = newWatcher;
|
|
950
|
+
else watchers.push(newWatcher);
|
|
951
|
+
}
|
|
952
|
+
// Read the new content and emit update
|
|
953
|
+
const state = fileMap.get(filePath);
|
|
954
|
+
if (state) {
|
|
955
|
+
const newContent = await fs.readFile(filePath, "utf-8");
|
|
956
|
+
if (!state.isLoaded || newContent !== state.content) {
|
|
957
|
+
state.content = newContent;
|
|
958
|
+
state.renderedHtml = null;
|
|
959
|
+
state.headings = null;
|
|
960
|
+
state.isLoaded = true;
|
|
961
|
+
invalidateResolvedComments(filePath);
|
|
962
|
+
invalidatePageCache();
|
|
963
|
+
console.log(`File changed: ${basename(filePath)}`);
|
|
964
|
+
sendEvent({ type: "document-updated", path: filePath });
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
return;
|
|
968
|
+
} catch {
|
|
969
|
+
// File not yet recreated, keep retrying
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
console.warn(`File did not reappear after rename: ${filePath}`);
|
|
973
|
+
}
|
|
974
|
+
|
|
728
975
|
return watcher;
|
|
729
976
|
} catch (err) {
|
|
730
977
|
console.warn(`File watching not available for ${targetPath}:`, err);
|
|
@@ -732,12 +979,14 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
732
979
|
}
|
|
733
980
|
}
|
|
734
981
|
|
|
982
|
+
const watchers: FSWatcher[] = [];
|
|
983
|
+
|
|
735
984
|
const server = Bun.serve({
|
|
736
985
|
port: options.port,
|
|
737
986
|
hostname: options.host,
|
|
738
|
-
idleTimeout: 255,
|
|
987
|
+
idleTimeout: 255,
|
|
739
988
|
|
|
740
|
-
async fetch(req) {
|
|
989
|
+
async fetch(req: Request) {
|
|
741
990
|
const url = new URL(req.url);
|
|
742
991
|
const { pathname } = url;
|
|
743
992
|
const method = req.method;
|
|
@@ -789,6 +1038,8 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
789
1038
|
} else {
|
|
790
1039
|
fileMap.set(filePath, {
|
|
791
1040
|
content: null,
|
|
1041
|
+
renderedHtml: null,
|
|
1042
|
+
headings: null,
|
|
792
1043
|
isLoaded: false,
|
|
793
1044
|
debounceTimer: null,
|
|
794
1045
|
});
|
|
@@ -818,9 +1069,10 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
818
1069
|
if (pathname === "/api/document" && method === "GET") {
|
|
819
1070
|
const ctxOrRes = requireContext(url);
|
|
820
1071
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
|
821
|
-
const
|
|
1072
|
+
const { html, headings } = await ensureRenderedHtml(ctxOrRes.filePath);
|
|
822
1073
|
return json({
|
|
823
|
-
|
|
1074
|
+
html,
|
|
1075
|
+
headings,
|
|
824
1076
|
filePath: ctxOrRes.filePath,
|
|
825
1077
|
fileName: basename(ctxOrRes.filePath),
|
|
826
1078
|
clean: options.clean || false,
|
|
@@ -842,7 +1094,8 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
842
1094
|
if (pathname === "/api/comments" && method === "GET") {
|
|
843
1095
|
const ctxOrRes = requireContext(url);
|
|
844
1096
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
|
845
|
-
|
|
1097
|
+
const rendered = await ensureRenderedHtml(ctxOrRes.filePath);
|
|
1098
|
+
return getComments(ctxOrRes, rendered.html);
|
|
846
1099
|
}
|
|
847
1100
|
|
|
848
1101
|
if (pathname === "/api/comments/raw" && method === "GET") {
|
|
@@ -854,12 +1107,14 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
854
1107
|
if (pathname === "/api/comments" && method === "POST") {
|
|
855
1108
|
const ctxOrRes = requireContext(url);
|
|
856
1109
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
|
1110
|
+
invalidatePageCache();
|
|
857
1111
|
return addComment(ctxOrRes, req);
|
|
858
1112
|
}
|
|
859
1113
|
|
|
860
1114
|
if (pathname === "/api/comments" && method === "DELETE") {
|
|
861
1115
|
const ctxOrRes = requireContext(url);
|
|
862
1116
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
|
1117
|
+
invalidatePageCache();
|
|
863
1118
|
return clearComments(ctxOrRes);
|
|
864
1119
|
}
|
|
865
1120
|
|
|
@@ -867,6 +1122,7 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
867
1122
|
if (commentId) {
|
|
868
1123
|
const ctxOrRes = requireContext(url);
|
|
869
1124
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
|
1125
|
+
invalidatePageCache();
|
|
870
1126
|
|
|
871
1127
|
if (pathname.endsWith("/reanchor") && method === "PUT") {
|
|
872
1128
|
return reanchorComment(ctxOrRes, req, commentId);
|
|
@@ -887,6 +1143,10 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
887
1143
|
return updateSettingsRoute(req);
|
|
888
1144
|
}
|
|
889
1145
|
|
|
1146
|
+
if (pathname === "/") {
|
|
1147
|
+
return serveAppPage(req);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
890
1150
|
if (isDev) {
|
|
891
1151
|
return proxyToVite(req, pathname, url.search);
|
|
892
1152
|
}
|
|
@@ -894,9 +1154,6 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
894
1154
|
},
|
|
895
1155
|
});
|
|
896
1156
|
|
|
897
|
-
// Set up per-file watchers after Bun.serve() succeeds to avoid
|
|
898
|
-
// leaking FSWatcher handles if the server fails to bind.
|
|
899
|
-
const watchers: FSWatcher[] = [];
|
|
900
1157
|
for (const fp of fileOrder) {
|
|
901
1158
|
const watcher = watchFile(fp);
|
|
902
1159
|
if (watcher) watchers.push(watcher);
|
|
@@ -908,6 +1165,8 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
908
1165
|
export async function startServer(
|
|
909
1166
|
options: ServerOptions,
|
|
910
1167
|
): Promise<ServerResult> {
|
|
1168
|
+
getShiki();
|
|
1169
|
+
|
|
911
1170
|
const MAX_PORT = 65535;
|
|
912
1171
|
|
|
913
1172
|
for (let port = options.port; port <= MAX_PORT; port++) {
|
|
@@ -925,6 +1184,7 @@ export async function startServer(
|
|
|
925
1184
|
const originalStop = server.stop.bind(server);
|
|
926
1185
|
const wrappedServer = {
|
|
927
1186
|
stop() {
|
|
1187
|
+
disposeMermaidWorker();
|
|
928
1188
|
stopVite?.();
|
|
929
1189
|
for (const w of watchers) w.close();
|
|
930
1190
|
originalStop();
|