@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.
Files changed (173) hide show
  1. package/.claude/CLAUDE.md +118 -76
  2. package/.claude/commands/review.md +1 -1
  3. package/.claude/roadmap.md +32 -9
  4. package/.claude/user-stories.md +100 -15
  5. package/AGENTS.md +30 -26
  6. package/Makefile +32 -0
  7. package/README.md +90 -2
  8. package/biome.json +18 -8
  9. package/bun.lock +426 -568
  10. package/bunfig.toml +2 -0
  11. package/docs/perf-baseline.md +56 -1
  12. package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
  13. package/e2e/comments.spec.ts +14 -58
  14. package/e2e/document-load.spec.ts +1 -23
  15. package/e2e/export.spec.ts +4 -4
  16. package/e2e/perf/add-comment.spec.ts +9 -11
  17. package/e2e/perf/fixtures/generate.ts +1 -5
  18. package/e2e/perf/screenshot-final.png +0 -0
  19. package/e2e/perf/utils/metrics.ts +73 -9
  20. package/e2e/persistence-file.spec.ts +41 -26
  21. package/e2e/utils/selection.ts +17 -73
  22. package/go/cmd/readit/main.go +416 -0
  23. package/go/go.mod +20 -0
  24. package/go/go.sum +41 -0
  25. package/go/internal/server/anchor.go +302 -0
  26. package/go/internal/server/anchor_test.go +111 -0
  27. package/go/internal/server/comments.go +390 -0
  28. package/go/internal/server/documents.go +113 -0
  29. package/go/internal/server/embed.go +17 -0
  30. package/go/internal/server/headings.go +33 -0
  31. package/go/internal/server/headings_test.go +75 -0
  32. package/go/internal/server/htmltext.go +123 -0
  33. package/go/internal/server/markdown.go +157 -0
  34. package/go/internal/server/markdown_bench_test.go +42 -0
  35. package/go/internal/server/markdown_test.go +79 -0
  36. package/go/internal/server/server.go +453 -0
  37. package/go/internal/server/server_bench_test.go +122 -0
  38. package/go/internal/server/settings.go +110 -0
  39. package/go/internal/server/sse.go +140 -0
  40. package/go/internal/server/storage.go +275 -0
  41. package/go/internal/server/storage_test.go +118 -0
  42. package/go/internal/server/template.go +66 -0
  43. package/go/internal/server/types.go +101 -0
  44. package/go/internal/server/watcher.go +74 -0
  45. package/index.html +4 -14
  46. package/nvim-readit/lua/readit/health.lua +64 -0
  47. package/nvim-readit/lua/readit/init.lua +463 -0
  48. package/nvim-readit/plugin/readit.lua +19 -0
  49. package/package.json +20 -28
  50. package/shell/_readit +158 -0
  51. package/shell/readit.zsh +87 -0
  52. package/src/App.svelte +881 -0
  53. package/src/cli.ts +183 -21
  54. package/src/components/ActionsMenu.svelte +95 -0
  55. package/src/components/CommentBadge.svelte +67 -0
  56. package/src/components/CommentErrorBanner.svelte +33 -0
  57. package/src/components/CommentInput.svelte +75 -0
  58. package/src/components/CommentListItem.svelte +95 -0
  59. package/src/components/CommentManager.svelte +129 -0
  60. package/src/components/CommentNav.svelte +109 -0
  61. package/src/components/DocumentViewer.svelte +218 -0
  62. package/src/components/FloatingComment.svelte +107 -0
  63. package/src/components/Header.svelte +76 -0
  64. package/src/components/InlineEditor.svelte +72 -0
  65. package/src/components/MarginNote.svelte +167 -0
  66. package/src/components/MarginNotesContainer.svelte +33 -0
  67. package/src/components/RawModal.svelte +126 -0
  68. package/src/components/ReanchorConfirm.svelte +30 -0
  69. package/src/components/SettingsModal.svelte +220 -0
  70. package/src/components/ShortcutCapture.svelte +82 -0
  71. package/src/components/ShortcutList.svelte +145 -0
  72. package/src/components/TabBar.svelte +52 -0
  73. package/src/components/TableOfContents.svelte +125 -0
  74. package/src/components/ui/ActionLink.svelte +40 -0
  75. package/src/components/ui/{Button.tsx → Button.svelte} +19 -20
  76. package/src/components/ui/Dialog.svelte +97 -0
  77. package/src/components/ui/DropdownMenu.svelte +85 -0
  78. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  79. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  80. package/src/components/ui/{Text.tsx → Text.svelte} +18 -23
  81. package/src/env.d.ts +6 -0
  82. package/src/index.css +36 -166
  83. package/src/lib/__fixtures__/bench-data.ts +0 -13
  84. package/src/lib/anchor.bench.ts +1 -12
  85. package/src/lib/anchor.test.ts +0 -8
  86. package/src/lib/anchor.ts +0 -4
  87. package/src/lib/comment-storage.bench.ts +49 -0
  88. package/src/lib/comment-storage.test.ts +41 -33
  89. package/src/lib/comment-storage.ts +21 -18
  90. package/src/lib/export.bench.ts +21 -0
  91. package/src/lib/export.ts +0 -1
  92. package/src/{hooks/useHeadings.test.ts → lib/headings.test.ts} +10 -24
  93. package/src/{hooks/useHeadings.ts → lib/headings.ts} +11 -13
  94. package/src/lib/highlight/core.test.ts +0 -5
  95. package/src/lib/highlight/dom.ts +52 -216
  96. package/src/lib/highlight/highlight-registry.ts +221 -0
  97. package/src/lib/highlight/highlight.bench.ts +92 -0
  98. package/src/lib/highlight/highlighter.ts +112 -132
  99. package/src/lib/highlight/resolver.ts +5 -79
  100. package/src/lib/highlight/types.ts +0 -5
  101. package/src/lib/html-text.test.ts +162 -0
  102. package/src/lib/html-text.ts +161 -0
  103. package/src/lib/i18n/en.ts +26 -0
  104. package/src/lib/i18n/ja.ts +26 -0
  105. package/src/lib/i18n/types.ts +25 -0
  106. package/src/lib/margin-layout.bench.ts +61 -0
  107. package/src/lib/margin-layout.ts +0 -7
  108. package/src/lib/markdown-renderer.test.ts +154 -0
  109. package/src/lib/markdown-renderer.ts +177 -0
  110. package/src/lib/mermaid-config.ts +38 -0
  111. package/src/lib/mermaid-renderer.ts +162 -0
  112. package/src/lib/mermaid-worker.ts +60 -0
  113. package/src/lib/positions.ts +31 -24
  114. package/src/lib/shortcut-registry.ts +244 -0
  115. package/src/lib/utils.ts +0 -29
  116. package/src/main.ts +16 -0
  117. package/src/schema.ts +16 -5
  118. package/src/server.ts +355 -91
  119. package/src/stores/app.svelte.ts +231 -0
  120. package/src/stores/locale.svelte.ts +46 -0
  121. package/src/stores/settings.svelte.ts +90 -0
  122. package/src/stores/shortcuts.svelte.ts +104 -0
  123. package/src/stores/ui.svelte.ts +12 -0
  124. package/src/template.ts +104 -0
  125. package/src/test-setup.ts +47 -0
  126. package/svelte.config.js +5 -0
  127. package/tsconfig.json +2 -2
  128. package/vite.config.ts +23 -3
  129. package/vscode-readit/.mcp.json +7 -0
  130. package/vscode-readit/.vscodeignore +7 -0
  131. package/vscode-readit/bun.lock +78 -0
  132. package/vscode-readit/icon.svg +10 -0
  133. package/vscode-readit/package.json +110 -0
  134. package/vscode-readit/src/extension.ts +117 -0
  135. package/vscode-readit/src/server-manager.ts +272 -0
  136. package/vscode-readit/src/webview-provider.ts +204 -0
  137. package/vscode-readit/tsconfig.json +20 -0
  138. package/e2e/fixtures/sample.html +0 -13
  139. package/src/App.tsx +0 -368
  140. package/src/components/ActionsMenu.tsx +0 -91
  141. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  142. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -230
  143. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -136
  144. package/src/components/Header.tsx +0 -54
  145. package/src/components/InlineEditor.tsx +0 -74
  146. package/src/components/MarginNote.tsx +0 -185
  147. package/src/components/MarginNotes.tsx +0 -23
  148. package/src/components/RawModal.tsx +0 -144
  149. package/src/components/ReanchorConfirm.tsx +0 -36
  150. package/src/components/SettingsModal.tsx +0 -232
  151. package/src/components/TabBar.tsx +0 -60
  152. package/src/components/TableOfContents.tsx +0 -108
  153. package/src/components/comments/CommentBadge.tsx +0 -49
  154. package/src/components/comments/CommentInput.tsx +0 -86
  155. package/src/components/comments/CommentListItem.tsx +0 -90
  156. package/src/components/comments/CommentManager.tsx +0 -129
  157. package/src/components/comments/CommentNav.tsx +0 -109
  158. package/src/components/ui/ActionLink.tsx +0 -28
  159. package/src/components/ui/Dialog.tsx +0 -116
  160. package/src/components/ui/DropdownMenu.tsx +0 -158
  161. package/src/contexts/CommentContext.tsx +0 -198
  162. package/src/contexts/LocaleContext.tsx +0 -76
  163. package/src/contexts/PositionsContext.tsx +0 -16
  164. package/src/contexts/SettingsContext.tsx +0 -133
  165. package/src/hooks/useClickOutside.ts +0 -31
  166. package/src/hooks/useCommentNavigation.ts +0 -107
  167. package/src/hooks/useComments.ts +0 -311
  168. package/src/hooks/useDocument.ts +0 -157
  169. package/src/hooks/useScrollSpy.ts +0 -77
  170. package/src/hooks/useTextSelection.ts +0 -86
  171. package/src/lib/highlight/worker.ts +0 -45
  172. package/src/main.tsx +0 -13
  173. 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
- startOffset: anchor.start,
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
- anchorConfidence: AnchorConfidences.UNRESOLVED,
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(ctx: RouteContext): Promise<Response> {
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(ctx.filePath, currentContent);
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
- const existingComments = await readCommentsFromFile(
268
- ctx.filePath,
269
- currentContent,
270
- );
271
- const allComments = [...existingComments, newComment];
272
-
273
- await writeCommentsToFile(ctx.filePath, currentContent, allComments);
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
- return errorResponse("Failed to add comment", 500);
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 existingComments = await readCommentsFromFile(
296
- ctx.filePath,
297
- currentContent,
298
- );
299
- const commentIndex = existingComments.findIndex((c) => c.id === id);
300
-
301
- if (commentIndex === -1) {
302
- return errorResponse("Comment not found", 404);
303
- }
304
-
305
- const updatedComments = existingComments.map((c, i) =>
306
- i === commentIndex ? { ...c, comment: commentText.trim() } : c,
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 json({ comment: updatedComments[commentIndex] });
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 existingComments = await readCommentsFromFile(
322
- ctx.filePath,
323
- currentContent,
324
- );
325
- const filteredComments = existingComments.filter((c) => c.id !== id);
326
-
327
- if (filteredComments.length === existingComments.length) {
328
- return errorResponse("Comment not found", 404);
329
- }
330
-
331
- if (filteredComments.length === 0) {
332
- await deleteCommentFile(ctx.filePath);
333
- } else {
334
- await writeCommentsToFile(ctx.filePath, currentContent, filteredComments);
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 existingComments = await readCommentsFromFile(
382
- ctx.filePath,
383
- currentContent,
384
- );
385
- const commentIndex = existingComments.findIndex((c) => c.id === id);
386
-
387
- if (commentIndex === -1) {
388
- return errorResponse("Comment not found", 404);
389
- }
390
-
391
- const lineHint = getLineHint(currentContent, startOffset, endOffset);
392
- const truncatedText = truncateSelection(selectedText);
393
-
394
- const updatedComment: Comment = {
395
- ...existingComments[commentIndex],
396
- selectedText: truncatedText,
397
- startOffset,
398
- endOffset,
399
- lineHint,
400
- anchorConfidence: AnchorConfidences.EXACT,
401
- anchorPrefix:
402
- selectedText.length > 1000 ? selectedText.slice(0, 200) : undefined,
403
- };
404
-
405
- const updatedComments = existingComments.map((c, i) =>
406
- i === commentIndex ? updatedComment : c,
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 json({ comment: updatedComment });
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
- return new Response(file);
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
- if (eventType !== "change") return;
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
- console.error(`Failed to read updated file ${targetPath}:`, err);
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, // max value (seconds) — SSE streams stay open long
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 content = await ctxOrRes.getCurrentContent();
1076
+ const { html, headings } = await ensureRenderedHtml(ctxOrRes.filePath);
822
1077
  return json({
823
- content,
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
- return getComments(ctxOrRes);
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();