@peaske7/readit 0.1.8 → 0.2.1

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