@peaske7/readit 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.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 +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 +20 -28
- package/shell/_readit +158 -0
- package/shell/readit.zsh +87 -0
- package/src/App.svelte +881 -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 +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.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 +36 -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 +41 -33
- package/src/lib/comment-storage.ts +21 -18
- package/src/lib/export.bench.ts +21 -0
- package/src/lib/export.ts +0 -1
- 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 +26 -0
- package/src/lib/i18n/ja.ts +26 -0
- package/src/lib/i18n/types.ts +25 -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 +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 +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 -91
- 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,10 @@ 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 { getShiki, renderMarkdown } from "./lib/markdown-renderer.js";
|
|
19
|
+
import { disposeMermaidWorker } from "./lib/mermaid-renderer.js";
|
|
16
20
|
import { isMarkdownFile } from "./lib/utils.js";
|
|
17
21
|
import {
|
|
18
22
|
AnchorConfidences,
|
|
@@ -21,6 +25,7 @@ import {
|
|
|
21
25
|
FontFamilies,
|
|
22
26
|
type FontFamily,
|
|
23
27
|
} from "./schema.js";
|
|
28
|
+
import { renderTemplate } from "./template.js";
|
|
24
29
|
|
|
25
30
|
function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
|
|
26
31
|
return err instanceof Error && "code" in err;
|
|
@@ -56,6 +61,21 @@ function invalidateResolvedComments(filePath: string): void {
|
|
|
56
61
|
resolvedCommentsCache.delete(filePath);
|
|
57
62
|
}
|
|
58
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
|
+
|
|
59
79
|
async function canonicalPath(filePath: string): Promise<string> {
|
|
60
80
|
return fs.realpath(path.resolve(filePath));
|
|
61
81
|
}
|
|
@@ -63,6 +83,7 @@ async function canonicalPath(filePath: string): Promise<string> {
|
|
|
63
83
|
async function readCommentsFromFile(
|
|
64
84
|
filePath: string,
|
|
65
85
|
sourceContent: string,
|
|
86
|
+
renderedHtml?: string,
|
|
66
87
|
): Promise<Comment[]> {
|
|
67
88
|
const commentPath = getCommentPath(filePath);
|
|
68
89
|
const sourceHash = computeHash(sourceContent);
|
|
@@ -80,27 +101,46 @@ async function readCommentsFromFile(
|
|
|
80
101
|
|
|
81
102
|
const content = await fs.readFile(commentPath, "utf-8");
|
|
82
103
|
const file = parseCommentFile(content);
|
|
104
|
+
|
|
105
|
+
const domText = renderedHtml ? extractTextFromHtml(renderedHtml) : null;
|
|
106
|
+
|
|
83
107
|
const resolvedComments = file.comments.map((comment) => {
|
|
84
108
|
const textForMatching = comment.anchorPrefix || comment.selectedText;
|
|
109
|
+
|
|
85
110
|
const anchor = findAnchorWithFallback({
|
|
86
111
|
source: sourceContent,
|
|
87
112
|
selectedText: textForMatching,
|
|
88
113
|
lineHint: comment.lineHint || "L1",
|
|
89
114
|
});
|
|
90
115
|
|
|
91
|
-
if (anchor) {
|
|
116
|
+
if (!anchor) {
|
|
92
117
|
return {
|
|
93
118
|
...comment,
|
|
94
|
-
|
|
95
|
-
endOffset: anchor.end,
|
|
96
|
-
lineHint: `L${anchor.line}`,
|
|
97
|
-
anchorConfidence: anchor.confidence,
|
|
119
|
+
anchorConfidence: AnchorConfidences.UNRESOLVED,
|
|
98
120
|
};
|
|
99
121
|
}
|
|
100
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
|
+
|
|
101
138
|
return {
|
|
102
139
|
...comment,
|
|
103
|
-
|
|
140
|
+
startOffset,
|
|
141
|
+
endOffset,
|
|
142
|
+
lineHint: `L${anchor.line}`,
|
|
143
|
+
anchorConfidence: anchor.confidence,
|
|
104
144
|
};
|
|
105
145
|
});
|
|
106
146
|
|
|
@@ -226,10 +266,17 @@ interface RouteContext {
|
|
|
226
266
|
getCurrentContent: () => Promise<string>;
|
|
227
267
|
}
|
|
228
268
|
|
|
229
|
-
async function getComments(
|
|
269
|
+
async function getComments(
|
|
270
|
+
ctx: RouteContext,
|
|
271
|
+
renderedHtml?: string,
|
|
272
|
+
): Promise<Response> {
|
|
230
273
|
try {
|
|
231
274
|
const currentContent = await ctx.getCurrentContent();
|
|
232
|
-
const comments = await readCommentsFromFile(
|
|
275
|
+
const comments = await readCommentsFromFile(
|
|
276
|
+
ctx.filePath,
|
|
277
|
+
currentContent,
|
|
278
|
+
renderedHtml,
|
|
279
|
+
);
|
|
233
280
|
return json({ comments });
|
|
234
281
|
} catch (err) {
|
|
235
282
|
console.error("Failed to read comments:", err);
|
|
@@ -264,18 +311,20 @@ async function addComment(ctx: RouteContext, req: Request): Promise<Response> {
|
|
|
264
311
|
currentContent,
|
|
265
312
|
);
|
|
266
313
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
+
});
|
|
274
322
|
|
|
275
323
|
return json({ comment: newComment }, 201);
|
|
276
324
|
} catch (err) {
|
|
277
325
|
console.error("Failed to add comment:", err);
|
|
278
|
-
|
|
326
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
327
|
+
return errorResponse(`Failed to add comment: ${detail}`, 500);
|
|
279
328
|
}
|
|
280
329
|
}
|
|
281
330
|
|
|
@@ -292,23 +341,22 @@ async function updateComment(
|
|
|
292
341
|
}
|
|
293
342
|
|
|
294
343
|
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);
|
|
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
|
+
});
|
|
310
357
|
|
|
311
|
-
return
|
|
358
|
+
if (!result) return errorResponse("Comment not found", 404);
|
|
359
|
+
return json({ comment: result });
|
|
312
360
|
} catch (err) {
|
|
313
361
|
console.error("Failed to update comment:", err);
|
|
314
362
|
return errorResponse("Failed to update comment", 500);
|
|
@@ -318,22 +366,26 @@ async function updateComment(
|
|
|
318
366
|
async function deleteComment(ctx: RouteContext, id: string): Promise<Response> {
|
|
319
367
|
try {
|
|
320
368
|
const currentContent = await ctx.getCurrentContent();
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
+
});
|
|
336
387
|
|
|
388
|
+
if (!found) return errorResponse("Comment not found", 404);
|
|
337
389
|
return json({ success: true });
|
|
338
390
|
} catch (err) {
|
|
339
391
|
console.error("Failed to delete comment:", err);
|
|
@@ -343,7 +395,7 @@ async function deleteComment(ctx: RouteContext, id: string): Promise<Response> {
|
|
|
343
395
|
|
|
344
396
|
async function clearComments(ctx: RouteContext): Promise<Response> {
|
|
345
397
|
try {
|
|
346
|
-
await deleteCommentFile(ctx.filePath);
|
|
398
|
+
await withCommentLock(ctx.filePath, () => deleteCommentFile(ctx.filePath));
|
|
347
399
|
return json({ success: true });
|
|
348
400
|
} catch (err) {
|
|
349
401
|
console.error("Failed to clear comments:", err);
|
|
@@ -378,37 +430,35 @@ async function reanchorComment(
|
|
|
378
430
|
}
|
|
379
431
|
|
|
380
432
|
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);
|
|
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
|
+
});
|
|
410
459
|
|
|
411
|
-
return
|
|
460
|
+
if (!result) return errorResponse("Comment not found", 404);
|
|
461
|
+
return json({ comment: result });
|
|
412
462
|
} catch (err) {
|
|
413
463
|
console.error("Failed to re-anchor comment:", err);
|
|
414
464
|
return errorResponse("Failed to re-anchor comment", 500);
|
|
@@ -527,10 +577,13 @@ async function serveStaticFile(
|
|
|
527
577
|
const file = Bun.file(filePath);
|
|
528
578
|
|
|
529
579
|
if (await file.exists()) {
|
|
530
|
-
|
|
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 });
|
|
531
585
|
}
|
|
532
586
|
|
|
533
|
-
// SPA fallback: serve index.html for non-API routes
|
|
534
587
|
const indexFile = Bun.file(join(distPath, "index.html"));
|
|
535
588
|
if (await indexFile.exists()) {
|
|
536
589
|
return new Response(indexFile);
|
|
@@ -572,7 +625,6 @@ async function isViteReady(): Promise<boolean> {
|
|
|
572
625
|
}
|
|
573
626
|
|
|
574
627
|
async function spawnViteDev(): Promise<() => void> {
|
|
575
|
-
// If Vite is already running (e.g. after bun --watch restart), reuse it
|
|
576
628
|
if (await isViteReady()) {
|
|
577
629
|
return () => {};
|
|
578
630
|
}
|
|
@@ -601,6 +653,8 @@ function extractCommentId(pathname: string): string | undefined {
|
|
|
601
653
|
|
|
602
654
|
interface FileState {
|
|
603
655
|
content: string | null;
|
|
656
|
+
renderedHtml: string | null;
|
|
657
|
+
headings: import("./lib/headings").Heading[] | null;
|
|
604
658
|
isLoaded: boolean;
|
|
605
659
|
debounceTimer: ReturnType<typeof setTimeout> | null;
|
|
606
660
|
}
|
|
@@ -617,10 +671,18 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
617
671
|
for (const entry of options.files) {
|
|
618
672
|
fileMap.set(entry.filePath, {
|
|
619
673
|
content: entry.content ?? null,
|
|
674
|
+
renderedHtml: null,
|
|
675
|
+
headings: null,
|
|
620
676
|
isLoaded: entry.content !== undefined,
|
|
621
677
|
debounceTimer: null,
|
|
622
678
|
});
|
|
623
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
|
+
}
|
|
624
686
|
}
|
|
625
687
|
|
|
626
688
|
const defaultPath = fileOrder[0];
|
|
@@ -680,6 +742,23 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
680
742
|
return content;
|
|
681
743
|
}
|
|
682
744
|
|
|
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
|
+
|
|
683
762
|
function resolveContext(url: URL): RouteContext | null {
|
|
684
763
|
const requestedPath = url.searchParams.get("path") ?? defaultPath;
|
|
685
764
|
const state = fileMap.get(requestedPath);
|
|
@@ -701,10 +780,131 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
701
780
|
const isDev = process.env.NODE_ENV === "development";
|
|
702
781
|
const distPath = import.meta.dir;
|
|
703
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
|
+
|
|
704
899
|
function watchFile(targetPath: string): FSWatcher | null {
|
|
705
900
|
try {
|
|
706
901
|
const watcher = watch(targetPath, async (eventType) => {
|
|
707
|
-
|
|
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;
|
|
708
908
|
|
|
709
909
|
const state = fileMap.get(targetPath);
|
|
710
910
|
if (!state) return;
|
|
@@ -715,16 +915,67 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
715
915
|
const newContent = await fs.readFile(targetPath, "utf-8");
|
|
716
916
|
if (!state.isLoaded || newContent !== state.content) {
|
|
717
917
|
state.content = newContent;
|
|
918
|
+
state.renderedHtml = null;
|
|
919
|
+
state.headings = null;
|
|
718
920
|
state.isLoaded = true;
|
|
719
921
|
invalidateResolvedComments(targetPath);
|
|
922
|
+
invalidatePageCache();
|
|
720
923
|
console.log(`File changed: ${basename(targetPath)}`);
|
|
721
924
|
sendEvent({ type: "document-updated", path: targetPath });
|
|
722
925
|
}
|
|
723
926
|
} catch (err) {
|
|
724
|
-
|
|
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
|
+
}
|
|
725
934
|
}
|
|
726
935
|
}, 100);
|
|
727
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
|
+
|
|
728
979
|
return watcher;
|
|
729
980
|
} catch (err) {
|
|
730
981
|
console.warn(`File watching not available for ${targetPath}:`, err);
|
|
@@ -732,12 +983,14 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
732
983
|
}
|
|
733
984
|
}
|
|
734
985
|
|
|
986
|
+
const watchers: FSWatcher[] = [];
|
|
987
|
+
|
|
735
988
|
const server = Bun.serve({
|
|
736
989
|
port: options.port,
|
|
737
990
|
hostname: options.host,
|
|
738
|
-
idleTimeout: 255,
|
|
991
|
+
idleTimeout: 255,
|
|
739
992
|
|
|
740
|
-
async fetch(req) {
|
|
993
|
+
async fetch(req: Request) {
|
|
741
994
|
const url = new URL(req.url);
|
|
742
995
|
const { pathname } = url;
|
|
743
996
|
const method = req.method;
|
|
@@ -789,6 +1042,8 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
789
1042
|
} else {
|
|
790
1043
|
fileMap.set(filePath, {
|
|
791
1044
|
content: null,
|
|
1045
|
+
renderedHtml: null,
|
|
1046
|
+
headings: null,
|
|
792
1047
|
isLoaded: false,
|
|
793
1048
|
debounceTimer: null,
|
|
794
1049
|
});
|
|
@@ -818,9 +1073,10 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
818
1073
|
if (pathname === "/api/document" && method === "GET") {
|
|
819
1074
|
const ctxOrRes = requireContext(url);
|
|
820
1075
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
|
821
|
-
const
|
|
1076
|
+
const { html, headings } = await ensureRenderedHtml(ctxOrRes.filePath);
|
|
822
1077
|
return json({
|
|
823
|
-
|
|
1078
|
+
html,
|
|
1079
|
+
headings,
|
|
824
1080
|
filePath: ctxOrRes.filePath,
|
|
825
1081
|
fileName: basename(ctxOrRes.filePath),
|
|
826
1082
|
clean: options.clean || false,
|
|
@@ -842,7 +1098,8 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
842
1098
|
if (pathname === "/api/comments" && method === "GET") {
|
|
843
1099
|
const ctxOrRes = requireContext(url);
|
|
844
1100
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
|
845
|
-
|
|
1101
|
+
const rendered = await ensureRenderedHtml(ctxOrRes.filePath);
|
|
1102
|
+
return getComments(ctxOrRes, rendered.html);
|
|
846
1103
|
}
|
|
847
1104
|
|
|
848
1105
|
if (pathname === "/api/comments/raw" && method === "GET") {
|
|
@@ -854,12 +1111,14 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
854
1111
|
if (pathname === "/api/comments" && method === "POST") {
|
|
855
1112
|
const ctxOrRes = requireContext(url);
|
|
856
1113
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
|
1114
|
+
invalidatePageCache();
|
|
857
1115
|
return addComment(ctxOrRes, req);
|
|
858
1116
|
}
|
|
859
1117
|
|
|
860
1118
|
if (pathname === "/api/comments" && method === "DELETE") {
|
|
861
1119
|
const ctxOrRes = requireContext(url);
|
|
862
1120
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
|
1121
|
+
invalidatePageCache();
|
|
863
1122
|
return clearComments(ctxOrRes);
|
|
864
1123
|
}
|
|
865
1124
|
|
|
@@ -867,6 +1126,7 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
867
1126
|
if (commentId) {
|
|
868
1127
|
const ctxOrRes = requireContext(url);
|
|
869
1128
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
|
1129
|
+
invalidatePageCache();
|
|
870
1130
|
|
|
871
1131
|
if (pathname.endsWith("/reanchor") && method === "PUT") {
|
|
872
1132
|
return reanchorComment(ctxOrRes, req, commentId);
|
|
@@ -887,6 +1147,10 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
887
1147
|
return updateSettingsRoute(req);
|
|
888
1148
|
}
|
|
889
1149
|
|
|
1150
|
+
if (pathname === "/") {
|
|
1151
|
+
return serveAppPage(req);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
890
1154
|
if (isDev) {
|
|
891
1155
|
return proxyToVite(req, pathname, url.search);
|
|
892
1156
|
}
|
|
@@ -894,9 +1158,6 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
894
1158
|
},
|
|
895
1159
|
});
|
|
896
1160
|
|
|
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
1161
|
for (const fp of fileOrder) {
|
|
901
1162
|
const watcher = watchFile(fp);
|
|
902
1163
|
if (watcher) watchers.push(watcher);
|
|
@@ -908,6 +1169,8 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
908
1169
|
export async function startServer(
|
|
909
1170
|
options: ServerOptions,
|
|
910
1171
|
): Promise<ServerResult> {
|
|
1172
|
+
getShiki();
|
|
1173
|
+
|
|
911
1174
|
const MAX_PORT = 65535;
|
|
912
1175
|
|
|
913
1176
|
for (let port = options.port; port <= MAX_PORT; port++) {
|
|
@@ -925,6 +1188,7 @@ export async function startServer(
|
|
|
925
1188
|
const originalStop = server.stop.bind(server);
|
|
926
1189
|
const wrappedServer = {
|
|
927
1190
|
stop() {
|
|
1191
|
+
disposeMermaidWorker();
|
|
928
1192
|
stopVite?.();
|
|
929
1193
|
for (const w of watchers) w.close();
|
|
930
1194
|
originalStop();
|