@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.
Files changed (179) 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 +152 -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 +890 -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 +233 -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/MermaidEnhancer.svelte +218 -0
  68. package/src/components/MermaidModal.svelte +67 -0
  69. package/src/components/RawModal.svelte +126 -0
  70. package/src/components/ReanchorConfirm.svelte +30 -0
  71. package/src/components/SettingsModal.svelte +220 -0
  72. package/src/components/ShortcutCapture.svelte +82 -0
  73. package/src/components/ShortcutList.svelte +145 -0
  74. package/src/components/TabBar.svelte +52 -0
  75. package/src/components/TableOfContents.svelte +125 -0
  76. package/src/components/ui/ActionLink.svelte +40 -0
  77. package/src/components/ui/{Button.tsx → Button.svelte} +19 -20
  78. package/src/components/ui/Dialog.svelte +97 -0
  79. package/src/components/ui/DropdownMenu.svelte +85 -0
  80. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  81. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  82. package/src/components/ui/{Text.tsx → Text.svelte} +18 -23
  83. package/src/env.d.ts +6 -0
  84. package/src/index.css +141 -166
  85. package/src/lib/__fixtures__/bench-data.ts +0 -13
  86. package/src/lib/anchor.bench.ts +1 -12
  87. package/src/lib/anchor.test.ts +0 -8
  88. package/src/lib/anchor.ts +0 -4
  89. package/src/lib/comment-storage.bench.ts +49 -0
  90. package/src/lib/comment-storage.test.ts +103 -33
  91. package/src/lib/comment-storage.ts +25 -18
  92. package/src/lib/export.bench.ts +21 -0
  93. package/src/lib/export.ts +0 -1
  94. package/src/lib/fetch-or-throw.test.ts +59 -0
  95. package/src/lib/fetch-or-throw.ts +12 -0
  96. package/src/{hooks/useHeadings.test.ts → lib/headings.test.ts} +10 -24
  97. package/src/{hooks/useHeadings.ts → lib/headings.ts} +11 -13
  98. package/src/lib/highlight/core.test.ts +0 -5
  99. package/src/lib/highlight/dom.ts +52 -216
  100. package/src/lib/highlight/highlight-registry.ts +221 -0
  101. package/src/lib/highlight/highlight.bench.ts +92 -0
  102. package/src/lib/highlight/highlighter.ts +112 -132
  103. package/src/lib/highlight/resolver.ts +5 -79
  104. package/src/lib/highlight/types.ts +0 -5
  105. package/src/lib/html-text.test.ts +162 -0
  106. package/src/lib/html-text.ts +161 -0
  107. package/src/lib/i18n/en.ts +34 -0
  108. package/src/lib/i18n/ja.ts +34 -0
  109. package/src/lib/i18n/types.ts +33 -0
  110. package/src/lib/key-lock.test.ts +104 -0
  111. package/src/lib/key-lock.ts +23 -0
  112. package/src/lib/margin-layout.bench.ts +61 -0
  113. package/src/lib/margin-layout.ts +0 -7
  114. package/src/lib/markdown-renderer.test.ts +154 -0
  115. package/src/lib/markdown-renderer.ts +178 -0
  116. package/src/lib/mermaid-config.ts +38 -0
  117. package/src/lib/mermaid-renderer.ts +162 -0
  118. package/src/lib/mermaid-worker.ts +60 -0
  119. package/src/lib/positions.ts +31 -24
  120. package/src/lib/shortcut-registry.ts +244 -0
  121. package/src/lib/utils.ts +0 -29
  122. package/src/main.ts +16 -0
  123. package/src/schema.ts +16 -5
  124. package/src/server.ts +355 -95
  125. package/src/stores/app.svelte.ts +231 -0
  126. package/src/stores/locale.svelte.ts +46 -0
  127. package/src/stores/settings.svelte.ts +90 -0
  128. package/src/stores/shortcuts.svelte.ts +104 -0
  129. package/src/stores/ui.svelte.ts +12 -0
  130. package/src/template.ts +104 -0
  131. package/src/test-setup.ts +47 -0
  132. package/svelte.config.js +5 -0
  133. package/tsconfig.json +2 -2
  134. package/vite.config.ts +23 -3
  135. package/vscode-readit/.mcp.json +7 -0
  136. package/vscode-readit/.vscodeignore +7 -0
  137. package/vscode-readit/bun.lock +78 -0
  138. package/vscode-readit/icon.svg +10 -0
  139. package/vscode-readit/package.json +110 -0
  140. package/vscode-readit/src/extension.ts +117 -0
  141. package/vscode-readit/src/server-manager.ts +272 -0
  142. package/vscode-readit/src/webview-provider.ts +204 -0
  143. package/vscode-readit/tsconfig.json +20 -0
  144. package/e2e/fixtures/sample.html +0 -13
  145. package/src/App.tsx +0 -368
  146. package/src/components/ActionsMenu.tsx +0 -91
  147. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  148. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -230
  149. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -136
  150. package/src/components/Header.tsx +0 -54
  151. package/src/components/InlineEditor.tsx +0 -74
  152. package/src/components/MarginNote.tsx +0 -185
  153. package/src/components/MarginNotes.tsx +0 -23
  154. package/src/components/RawModal.tsx +0 -144
  155. package/src/components/ReanchorConfirm.tsx +0 -36
  156. package/src/components/SettingsModal.tsx +0 -232
  157. package/src/components/TabBar.tsx +0 -60
  158. package/src/components/TableOfContents.tsx +0 -108
  159. package/src/components/comments/CommentBadge.tsx +0 -49
  160. package/src/components/comments/CommentInput.tsx +0 -86
  161. package/src/components/comments/CommentListItem.tsx +0 -90
  162. package/src/components/comments/CommentManager.tsx +0 -129
  163. package/src/components/comments/CommentNav.tsx +0 -109
  164. package/src/components/ui/ActionLink.tsx +0 -28
  165. package/src/components/ui/Dialog.tsx +0 -116
  166. package/src/components/ui/DropdownMenu.tsx +0 -158
  167. package/src/contexts/CommentContext.tsx +0 -198
  168. package/src/contexts/LocaleContext.tsx +0 -76
  169. package/src/contexts/PositionsContext.tsx +0 -16
  170. package/src/contexts/SettingsContext.tsx +0 -133
  171. package/src/hooks/useClickOutside.ts +0 -31
  172. package/src/hooks/useCommentNavigation.ts +0 -107
  173. package/src/hooks/useComments.ts +0 -311
  174. package/src/hooks/useDocument.ts +0 -157
  175. package/src/hooks/useScrollSpy.ts +0 -77
  176. package/src/hooks/useTextSelection.ts +0 -86
  177. package/src/lib/highlight/worker.ts +0 -45
  178. package/src/main.tsx +0 -13
  179. 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
- startOffset: anchor.start,
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
- anchorConfidence: AnchorConfidences.UNRESOLVED,
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(ctx: RouteContext): Promise<Response> {
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(ctx.filePath, currentContent);
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
- const existingComments = await readCommentsFromFile(
268
- ctx.filePath,
269
- currentContent,
270
- );
271
- const allComments = [...existingComments, newComment];
272
-
273
- await writeCommentsToFile(ctx.filePath, currentContent, allComments);
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 errorResponse("Failed to add comment", 500);
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 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);
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 json({ comment: updatedComments[commentIndex] });
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 errorResponse("Failed to update comment", 500);
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 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
- }
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 errorResponse("Failed to delete comment", 500);
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 errorResponse("Failed to clear comments", 500);
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 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);
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 json({ comment: updatedComment });
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 errorResponse("Failed to re-anchor comment", 500);
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
- return new Response(file);
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
- if (eventType !== "change") return;
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
- console.error(`Failed to read updated file ${targetPath}:`, err);
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, // max value (seconds) — SSE streams stay open long
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 content = await ctxOrRes.getCurrentContent();
1072
+ const { html, headings } = await ensureRenderedHtml(ctxOrRes.filePath);
822
1073
  return json({
823
- content,
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
- return getComments(ctxOrRes);
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();