@peaske7/readit 0.1.8 → 0.2.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 (118) hide show
  1. package/README.md +0 -3
  2. package/biome.json +1 -1
  3. package/bun.lock +43 -185
  4. package/docs/perf-baseline.md +75 -0
  5. package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
  6. package/e2e/perf/add-comment.spec.ts +118 -0
  7. package/e2e/perf/fixtures/generate.ts +331 -0
  8. package/e2e/perf/initial-load.spec.ts +49 -0
  9. package/e2e/perf/perf.setup.ts +23 -0
  10. package/e2e/perf/perf.teardown.ts +9 -0
  11. package/e2e/perf/scroll.spec.ts +39 -0
  12. package/e2e/perf/tab-switch.spec.ts +69 -0
  13. package/e2e/perf/text-selection.spec.ts +119 -0
  14. package/e2e/perf/utils/metrics.ts +286 -0
  15. package/e2e/perf/utils/perf-cli.ts +86 -0
  16. package/package.json +9 -18
  17. package/playwright.config.ts +12 -0
  18. package/src/App.tsx +124 -172
  19. package/src/{cli/index.ts → cli.ts} +37 -53
  20. package/src/components/ActionsMenu.tsx +6 -27
  21. package/src/components/DocumentViewer/DocumentViewer.tsx +77 -106
  22. package/src/components/DocumentViewer/MermaidDiagram.tsx +6 -7
  23. package/src/components/Header.tsx +9 -20
  24. package/src/components/InlineEditor.tsx +5 -5
  25. package/src/components/MarginNote.tsx +71 -93
  26. package/src/components/MarginNotes.tsx +7 -34
  27. package/src/components/RawModal.tsx +9 -8
  28. package/src/components/ReanchorConfirm.tsx +2 -2
  29. package/src/components/SettingsModal.tsx +11 -89
  30. package/src/components/TabBar.tsx +4 -4
  31. package/src/components/TableOfContents.tsx +5 -5
  32. package/src/components/comments/CommentInput.tsx +7 -35
  33. package/src/components/comments/CommentListItem.tsx +9 -11
  34. package/src/components/comments/CommentManager.tsx +53 -37
  35. package/src/components/comments/CommentNav.tsx +14 -14
  36. package/src/components/ui/ActionLink.tsx +14 -18
  37. package/src/components/ui/Button.tsx +42 -43
  38. package/src/components/ui/Dialog.tsx +73 -113
  39. package/src/components/ui/DropdownMenu.tsx +113 -69
  40. package/src/components/ui/Text.tsx +30 -37
  41. package/src/contexts/CommentContext.tsx +75 -106
  42. package/src/contexts/LocaleContext.tsx +45 -4
  43. package/src/contexts/PositionsContext.tsx +16 -0
  44. package/src/contexts/SettingsContext.tsx +133 -0
  45. package/src/hooks/useClickOutside.ts +0 -4
  46. package/src/hooks/useCommentNavigation.ts +6 -29
  47. package/src/hooks/useComments.ts +6 -18
  48. package/src/hooks/useDocument.ts +35 -34
  49. package/src/hooks/useHeadings.test.ts +8 -50
  50. package/src/hooks/useHeadings.ts +5 -88
  51. package/src/hooks/useScrollSpy.ts +10 -14
  52. package/src/hooks/useTextSelection.ts +1 -38
  53. package/src/lib/__fixtures__/bench-data.ts +1 -41
  54. package/src/lib/anchor.bench.ts +57 -67
  55. package/src/lib/anchor.test.ts +5 -1
  56. package/src/lib/anchor.ts +13 -93
  57. package/src/lib/comment-storage.test.ts +4 -4
  58. package/src/lib/comment-storage.ts +2 -46
  59. package/src/lib/export.ts +7 -13
  60. package/src/lib/highlight/core.test.ts +1 -1
  61. package/src/lib/highlight/dom.ts +5 -68
  62. package/src/lib/highlight/highlighter.ts +102 -262
  63. package/src/lib/highlight/resolver.ts +112 -0
  64. package/src/lib/highlight/types.ts +0 -35
  65. package/src/lib/highlight/worker.ts +45 -0
  66. package/src/lib/i18n/en.ts +1 -50
  67. package/src/lib/i18n/ja.ts +1 -50
  68. package/src/lib/i18n/types.ts +1 -49
  69. package/src/lib/margin-layout.ts +5 -27
  70. package/src/lib/positions.ts +150 -0
  71. package/src/lib/utils.ts +2 -19
  72. package/src/schema.ts +81 -0
  73. package/src/{server/index.ts → server.ts} +74 -74
  74. package/src/{store/index.ts → store.ts} +14 -46
  75. package/vite.config.ts +8 -0
  76. package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
  77. package/src/components/DocumentViewer/InlineCode.tsx +0 -60
  78. package/src/components/DocumentViewer/index.ts +0 -1
  79. package/src/components/FloatingTOC.tsx +0 -61
  80. package/src/components/ShortcutCapture.tsx +0 -48
  81. package/src/components/ShortcutList.tsx +0 -198
  82. package/src/components/comments/CommentMinimap.tsx +0 -62
  83. package/src/components/ui/ActionBar.tsx +0 -16
  84. package/src/components/ui/SeparatorDot.tsx +0 -9
  85. package/src/contexts/LayoutContext.tsx +0 -88
  86. package/src/hooks/useClipboard.ts +0 -82
  87. package/src/hooks/useEditorScheme.ts +0 -51
  88. package/src/hooks/useFontPreference.ts +0 -59
  89. package/src/hooks/useKeybindings.ts +0 -108
  90. package/src/hooks/useKeyboardShortcuts.ts +0 -63
  91. package/src/hooks/useLayoutMode.ts +0 -44
  92. package/src/hooks/useLocalePreference.ts +0 -42
  93. package/src/hooks/useReanchorMode.ts +0 -33
  94. package/src/hooks/useScrollMetrics.ts +0 -56
  95. package/src/hooks/useThemePreference.ts +0 -66
  96. package/src/lib/comment-storage.bench.ts +0 -63
  97. package/src/lib/context.bench.ts +0 -41
  98. package/src/lib/context.test.ts +0 -224
  99. package/src/lib/context.ts +0 -193
  100. package/src/lib/editor-links.ts +0 -59
  101. package/src/lib/export.bench.ts +0 -35
  102. package/src/lib/highlight/colors.ts +0 -37
  103. package/src/lib/highlight/core.ts +0 -54
  104. package/src/lib/highlight/index.ts +0 -23
  105. package/src/lib/highlight/script-builder.ts +0 -485
  106. package/src/lib/html-processor.test.tsx +0 -170
  107. package/src/lib/html-processor.tsx +0 -95
  108. package/src/lib/i18n/completeness.test.ts +0 -51
  109. package/src/lib/i18n/translations.test.ts +0 -39
  110. package/src/lib/layout-constants.ts +0 -12
  111. package/src/lib/margin-layout.bench.ts +0 -28
  112. package/src/lib/scroll.test.ts +0 -118
  113. package/src/lib/scroll.ts +0 -47
  114. package/src/lib/shortcut-registry.test.ts +0 -173
  115. package/src/lib/shortcut-registry.ts +0 -209
  116. package/src/lib/utils.test.ts +0 -110
  117. package/src/store/index.test.ts +0 -242
  118. package/src/types/index.ts +0 -127
@@ -0,0 +1,150 @@
1
+ import { resolveMarginNotePositions } from "./margin-layout";
2
+
3
+ type Listener = () => void;
4
+
5
+ /**
6
+ * Positions managed outside React. Scroll-invariant — only recalculates
7
+ * on highlight mutation (MutationObserver) and resize.
8
+ */
9
+ export class Positions {
10
+ private relative = new Map<string, number>();
11
+ private absolute = new Map<string, number>();
12
+ private snapshot: Record<string, number> = {};
13
+ private notes = new Map<string, HTMLElement>();
14
+ private ids: string[] = [];
15
+ private pendingTop: number | undefined;
16
+ private listeners = new Set<Listener>();
17
+ private root: HTMLElement | null = null;
18
+ private container: HTMLElement | null = null;
19
+ private resizeRaf: number | null = null;
20
+ private mutationRaf: number | null = null;
21
+ private observer: MutationObserver | null = null;
22
+
23
+ attach(root: HTMLElement, container: HTMLElement) {
24
+ this.root = root;
25
+ this.container = container;
26
+ window.addEventListener("resize", this.onResize);
27
+
28
+ this.observer = new MutationObserver(() => {
29
+ if (this.mutationRaf !== null) return;
30
+ this.mutationRaf = requestAnimationFrame(() => {
31
+ this.mutationRaf = null;
32
+ this.cache();
33
+ });
34
+ });
35
+ this.observer.observe(root, { childList: true, subtree: true });
36
+ }
37
+
38
+ detach() {
39
+ window.removeEventListener("resize", this.onResize);
40
+ if (this.resizeRaf !== null) cancelAnimationFrame(this.resizeRaf);
41
+ if (this.mutationRaf !== null) cancelAnimationFrame(this.mutationRaf);
42
+ this.resizeRaf = null;
43
+ this.mutationRaf = null;
44
+ this.observer?.disconnect();
45
+ this.observer = null;
46
+ this.root = null;
47
+ this.container = null;
48
+ }
49
+
50
+ cache() {
51
+ if (!this.root || !this.container) return;
52
+
53
+ const ref = this.container.getBoundingClientRect();
54
+ const scrollY = window.scrollY;
55
+
56
+ this.relative.clear();
57
+ this.absolute.clear();
58
+
59
+ for (const mark of this.root.querySelectorAll("mark[data-comment-id]")) {
60
+ const id = mark.getAttribute("data-comment-id");
61
+ if (!id || this.relative.has(id)) continue;
62
+
63
+ const rect = mark.getBoundingClientRect();
64
+ this.relative.set(id, rect.top - ref.top);
65
+ this.absolute.set(id, rect.top + scrollY);
66
+ }
67
+
68
+ const snap: Record<string, number> = {};
69
+ for (const [id, top] of this.absolute) snap[id] = top;
70
+ this.snapshot = snap;
71
+
72
+ this.apply();
73
+ this.notify();
74
+ }
75
+
76
+ setIds(ids: string[]) {
77
+ this.ids = ids;
78
+ }
79
+
80
+ setPending(top: number | undefined) {
81
+ if (this.pendingTop === top) return;
82
+ this.pendingTop = top;
83
+ this.apply();
84
+ }
85
+
86
+ register(id: string, el: HTMLElement) {
87
+ this.notes.set(id, el);
88
+ const top = this.resolve().get(id);
89
+ if (top !== undefined) {
90
+ el.style.top = `${top}px`;
91
+ el.style.visibility = "visible";
92
+ } else {
93
+ el.style.visibility = "hidden";
94
+ }
95
+ }
96
+
97
+ unregister(id: string) {
98
+ this.notes.delete(id);
99
+ }
100
+
101
+ getAbsolute(): Record<string, number> {
102
+ return this.snapshot;
103
+ }
104
+
105
+ subscribe(fn: Listener): () => void {
106
+ this.listeners.add(fn);
107
+ return () => this.listeners.delete(fn);
108
+ }
109
+
110
+ dispose() {
111
+ this.detach();
112
+ this.relative.clear();
113
+ this.absolute.clear();
114
+ this.snapshot = {};
115
+ this.notes.clear();
116
+ this.listeners.clear();
117
+ this.ids = [];
118
+ }
119
+
120
+ private resolve(): Map<string, number> {
121
+ const pos: Record<string, number> = {};
122
+ for (const [id, top] of this.relative) pos[id] = top;
123
+ return resolveMarginNotePositions(this.ids, pos, this.pendingTop);
124
+ }
125
+
126
+ private apply() {
127
+ const resolved = this.resolve();
128
+ for (const [id, el] of this.notes) {
129
+ const top = resolved.get(id);
130
+ if (top !== undefined) {
131
+ el.style.top = `${top}px`;
132
+ el.style.visibility = "visible";
133
+ } else {
134
+ el.style.visibility = "hidden";
135
+ }
136
+ }
137
+ }
138
+
139
+ private onResize = () => {
140
+ if (this.resizeRaf !== null) return;
141
+ this.resizeRaf = requestAnimationFrame(() => {
142
+ this.resizeRaf = null;
143
+ this.cache();
144
+ });
145
+ };
146
+
147
+ private notify() {
148
+ for (const fn of this.listeners) fn();
149
+ }
150
+ }
package/src/lib/utils.ts CHANGED
@@ -1,34 +1,20 @@
1
1
  import { type ClassValue, clsx } from "clsx";
2
2
  import type { ReactNode } from "react";
3
3
  import { twMerge } from "tailwind-merge";
4
- import type { DocumentType } from "../types";
5
4
 
6
- export function getFileType(filePath: string): DocumentType | null {
7
- if (filePath.endsWith(".md") || filePath.endsWith(".markdown")) {
8
- return "markdown";
9
- }
10
- if (filePath.endsWith(".html") || filePath.endsWith(".htm")) {
11
- return "html";
12
- }
13
- return null;
5
+ export function isMarkdownFile(filePath: string): boolean {
6
+ return filePath.endsWith(".md") || filePath.endsWith(".markdown");
14
7
  }
15
8
 
16
9
  export function cn(...inputs: ReadonlyArray<ClassValue>) {
17
10
  return twMerge(clsx(inputs));
18
11
  }
19
12
 
20
- /**
21
- * Truncate text with ellipsis for toast notifications.
22
- */
23
13
  export function truncate(text: string, maxLength = 30): string {
24
14
  if (text.length <= maxLength) return text;
25
15
  return `${text.slice(0, maxLength)}…`;
26
16
  }
27
17
 
28
- /**
29
- * Recursively extract text content from React children.
30
- * Handles strings, numbers, arrays, and React elements.
31
- */
32
18
  export function getTextContent(children: ReactNode): string {
33
19
  if (typeof children === "string" || typeof children === "number") {
34
20
  return String(children);
@@ -48,9 +34,6 @@ export function getTextContent(children: ReactNode): string {
48
34
  return "";
49
35
  }
50
36
 
51
- /**
52
- * Slugify text to create URL-friendly IDs
53
- */
54
37
  export function slugify(text: string): string {
55
38
  return text
56
39
  .toLowerCase()
package/src/schema.ts ADDED
@@ -0,0 +1,81 @@
1
+ export const AnchorConfidences = {
2
+ EXACT: "exact",
3
+ NORMALIZED: "normalized",
4
+ FUZZY: "fuzzy",
5
+ UNRESOLVED: "unresolved",
6
+ } as const;
7
+
8
+ export type AnchorConfidence =
9
+ (typeof AnchorConfidences)[keyof typeof AnchorConfidences];
10
+
11
+ export type ResolvedAnchorConfidence = Exclude<
12
+ AnchorConfidence,
13
+ typeof AnchorConfidences.UNRESOLVED
14
+ >;
15
+
16
+ export interface Comment {
17
+ id: string;
18
+ selectedText: string;
19
+ comment: string;
20
+ createdAt: string;
21
+ startOffset: number;
22
+ endOffset: number;
23
+ /** e.g. "L42" or "L42-L55" */
24
+ lineHint?: string;
25
+ anchorConfidence?: AnchorConfidence;
26
+ /** First N chars of original text for anchor matching when selectedText is truncated */
27
+ anchorPrefix?: string;
28
+ }
29
+
30
+ export interface CommentFile {
31
+ source: string;
32
+ /** SHA-256 prefix (16 chars) */
33
+ hash: string;
34
+ version: number;
35
+ comments: Comment[];
36
+ }
37
+
38
+ export interface Anchor {
39
+ start: number;
40
+ end: number;
41
+ line: number;
42
+ confidence: ResolvedAnchorConfidence;
43
+ distance?: number;
44
+ }
45
+
46
+ export interface SelectionRange {
47
+ startOffset: number;
48
+ endOffset: number;
49
+ }
50
+
51
+ export interface Selection extends SelectionRange {
52
+ text: string;
53
+ }
54
+
55
+ export interface Document {
56
+ content: string;
57
+ filePath: string;
58
+ fileName: string;
59
+ clean: boolean;
60
+ }
61
+
62
+ export const FontFamilies = {
63
+ SERIF: "serif",
64
+ SANS_SERIF: "sans-serif",
65
+ } as const;
66
+
67
+ export type FontFamily = (typeof FontFamilies)[keyof typeof FontFamilies];
68
+
69
+ export const ThemeModes = {
70
+ LIGHT: "light",
71
+ DARK: "dark",
72
+ SYSTEM: "system",
73
+ } as const;
74
+
75
+ export type ThemeMode = (typeof ThemeModes)[keyof typeof ThemeModes];
76
+
77
+ export interface DocumentSettings {
78
+ version: number;
79
+ fontFamily: FontFamily;
80
+ onboarded?: boolean;
81
+ }
@@ -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,15 @@ 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 { isMarkdownFile } from "./lib/utils.js";
17
17
  import {
18
18
  AnchorConfidences,
19
19
  type Comment,
20
20
  type DocumentSettings,
21
- type DocumentType,
22
- type EditorScheme,
23
- EditorSchemes,
24
21
  FontFamilies,
25
22
  type FontFamily,
26
- } from "../types/index.js";
27
-
28
- // ─── Helpers ─────────────────────────────────────────────────────────
23
+ } from "./schema.js";
29
24
 
30
25
  function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
31
26
  return err instanceof Error && "code" in err;
@@ -33,7 +28,6 @@ function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
33
28
 
34
29
  export interface FileEntry {
35
30
  content?: string;
36
- type: DocumentType;
37
31
  filePath: string;
38
32
  }
39
33
 
@@ -194,12 +188,6 @@ function isValidFontFamily(value: unknown): value is FontFamily {
194
188
  return value === FontFamilies.SERIF || value === FontFamilies.SANS_SERIF;
195
189
  }
196
190
 
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
191
  export const SERVER_INFO_PATH = path.join(
204
192
  os.homedir(),
205
193
  ".readit",
@@ -225,8 +213,6 @@ export async function removeServerInfo(): Promise<void> {
225
213
  }
226
214
  }
227
215
 
228
- // ─── Response helpers ───────────────────────────────────────────────
229
-
230
216
  function json(data: unknown, status = 200): Response {
231
217
  return Response.json(data, { status });
232
218
  }
@@ -235,15 +221,11 @@ function errorResponse(message: string, status: number): Response {
235
221
  return Response.json({ error: message }, { status });
236
222
  }
237
223
 
238
- // ─── Route context ──────────────────────────────────────────────────
239
-
240
224
  interface RouteContext {
241
225
  filePath: string;
242
226
  getCurrentContent: () => Promise<string>;
243
227
  }
244
228
 
245
- // ─── Route handlers ─────────────────────────────────────────────────
246
-
247
229
  async function getComments(ctx: RouteContext): Promise<Response> {
248
230
  try {
249
231
  const currentContent = await ctx.getCurrentContent();
@@ -446,22 +428,16 @@ async function getSettingsRoute(): Promise<Response> {
446
428
  async function updateSettingsRoute(req: Request): Promise<Response> {
447
429
  try {
448
430
  const body = await req.json();
449
- const { fontFamily, editorScheme, keybindings } = body;
431
+ const { fontFamily } = body;
450
432
 
451
433
  if (fontFamily !== undefined && !isValidFontFamily(fontFamily)) {
452
434
  return errorResponse("Invalid font family", 400);
453
435
  }
454
436
 
455
- if (editorScheme !== undefined && !isValidEditorScheme(editorScheme)) {
456
- return errorResponse("Invalid editor scheme", 400);
457
- }
458
-
459
437
  const current = await readSettings();
460
438
  const settings: DocumentSettings = {
461
439
  ...current,
462
440
  ...(fontFamily !== undefined && { fontFamily }),
463
- ...(editorScheme !== undefined && { editorScheme }),
464
- ...(keybindings !== undefined && { keybindings }),
465
441
  };
466
442
 
467
443
  await writeSettings(settings);
@@ -472,8 +448,6 @@ async function updateSettingsRoute(req: Request): Promise<Response> {
472
448
  }
473
449
  }
474
450
 
475
- // ─── SSE helpers ────────────────────────────────────────────────────
476
-
477
451
  function createDocumentStream(
478
452
  sseClients: Set<ReadableStreamDefaultController>,
479
453
  ): Response {
@@ -545,8 +519,6 @@ function createHeartbeat(
545
519
  });
546
520
  }
547
521
 
548
- // ─── Static file serving ────────────────────────────────────────────
549
-
550
522
  async function serveStaticFile(
551
523
  distPath: string,
552
524
  pathname: string,
@@ -567,40 +539,85 @@ async function serveStaticFile(
567
539
  return new Response("Not Found", { status: 404 });
568
540
  }
569
541
 
570
- // ─── Extract route param ────────────────────────────────────────────
542
+ const VITE_DEV_PORT = 24678;
543
+ const VITE_DEV_ORIGIN = `http://127.0.0.1:${VITE_DEV_PORT}`;
544
+
545
+ async function proxyToVite(
546
+ req: Request,
547
+ pathname: string,
548
+ search: string,
549
+ ): Promise<Response> {
550
+ const target = `${VITE_DEV_ORIGIN}${pathname}${search}`;
551
+ try {
552
+ return await fetch(
553
+ new Request(target, {
554
+ method: req.method,
555
+ headers: req.headers,
556
+ body: req.body,
557
+ redirect: "manual",
558
+ }),
559
+ );
560
+ } catch {
561
+ return new Response("Vite dev server not available", { status: 502 });
562
+ }
563
+ }
564
+
565
+ async function isViteReady(): Promise<boolean> {
566
+ try {
567
+ const res = await fetch(`${VITE_DEV_ORIGIN}/`);
568
+ return res.ok;
569
+ } catch {
570
+ return false;
571
+ }
572
+ }
573
+
574
+ async function spawnViteDev(): Promise<() => void> {
575
+ // If Vite is already running (e.g. after bun --watch restart), reuse it
576
+ if (await isViteReady()) {
577
+ return () => {};
578
+ }
579
+
580
+ const child = Bun.spawn(
581
+ ["bunx", "vite", "--port", String(VITE_DEV_PORT), "--strictPort"],
582
+ { stdout: "ignore", stderr: "inherit" },
583
+ );
584
+
585
+ const maxWaitMs = 10_000;
586
+ const start = Date.now();
587
+ while (Date.now() - start < maxWaitMs) {
588
+ if (await isViteReady()) break;
589
+ await new Promise((r) => setTimeout(r, 200));
590
+ }
591
+
592
+ return () => {
593
+ child.kill();
594
+ };
595
+ }
571
596
 
572
597
  function extractCommentId(pathname: string): string | undefined {
573
598
  const match = pathname.match(/^\/api\/comments\/([^/]+)/);
574
599
  return match?.[1];
575
600
  }
576
601
 
577
- // ─── Multi-file state ───────────────────────────────────────────────
578
-
579
602
  interface FileState {
580
603
  content: string | null;
581
604
  isLoaded: boolean;
582
- type: DocumentType;
583
605
  debounceTimer: ReturnType<typeof setTimeout> | null;
584
606
  }
585
607
 
586
- // ─── Server creation ────────────────────────────────────────────────
587
-
588
608
  interface ServerWithWatchers {
589
609
  server: ReturnType<typeof Bun.serve>;
590
610
  watchers: FSWatcher[];
591
611
  }
592
612
 
593
613
  function createServer(options: ServerOptions): ServerWithWatchers {
594
- // Map of absolute path → mutable file state
595
614
  const fileMap = new Map<string, FileState>();
596
- // Ordered list of file paths (insertion order for tab display)
597
615
  const fileOrder: string[] = [];
598
616
 
599
617
  for (const entry of options.files) {
600
618
  fileMap.set(entry.filePath, {
601
619
  content: entry.content ?? null,
602
620
  isLoaded: entry.content !== undefined,
603
- type: entry.type,
604
621
  debounceTimer: null,
605
622
  });
606
623
  fileOrder.push(entry.filePath);
@@ -663,7 +680,6 @@ function createServer(options: ServerOptions): ServerWithWatchers {
663
680
  return content;
664
681
  }
665
682
 
666
- // Resolve the target file from ?path= query param, falling back to first file
667
683
  function resolveContext(url: URL): RouteContext | null {
668
684
  const requestedPath = url.searchParams.get("path") ?? defaultPath;
669
685
  const state = fileMap.get(requestedPath);
@@ -726,18 +742,11 @@ function createServer(options: ServerOptions): ServerWithWatchers {
726
742
  const { pathname } = url;
727
743
  const method = req.method;
728
744
 
729
- // ── API routes ──────────────────────────────────────────
730
-
731
- // Document list (multi-file)
732
745
  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
- });
746
+ const files = fileOrder.map((fp) => ({
747
+ path: fp,
748
+ fileName: basename(fp),
749
+ }));
741
750
  return json({
742
751
  files,
743
752
  clean: options.clean || false,
@@ -745,7 +754,6 @@ function createServer(options: ServerOptions): ServerWithWatchers {
745
754
  });
746
755
  }
747
756
 
748
- // Register a document for this session without forcing focus
749
757
  if (pathname === "/api/documents" && method === "POST") {
750
758
  try {
751
759
  const { path: requestedPath } = await req.json();
@@ -763,11 +771,9 @@ function createServer(options: ServerOptions): ServerWithWatchers {
763
771
  }
764
772
  throw err;
765
773
  }
766
- const fileType = getFileType(filePath);
767
-
768
- if (!fileType) {
774
+ if (!isMarkdownFile(filePath)) {
769
775
  return errorResponse(
770
- `Unsupported file type: ${filePath} (expected .md, .markdown, .html, or .htm)`,
776
+ `Unsupported file type: ${filePath} (expected .md or .markdown)`,
771
777
  400,
772
778
  );
773
779
  }
@@ -778,15 +784,12 @@ function createServer(options: ServerOptions): ServerWithWatchers {
778
784
  return json({
779
785
  path: filePath,
780
786
  fileName: basename(filePath),
781
- type: fileType,
782
787
  status: "present",
783
788
  });
784
789
  } else {
785
- // New document — register metadata only, load content on demand
786
790
  fileMap.set(filePath, {
787
791
  content: null,
788
792
  isLoaded: false,
789
- type: fileType,
790
793
  debounceTimer: null,
791
794
  });
792
795
  fileOrder.push(filePath);
@@ -798,14 +801,12 @@ function createServer(options: ServerOptions): ServerWithWatchers {
798
801
  type: "document-added",
799
802
  path: filePath,
800
803
  fileName: basename(filePath),
801
- fileType,
802
804
  });
803
805
  }
804
806
 
805
807
  return json({
806
808
  path: filePath,
807
809
  fileName: basename(filePath),
808
- type: fileType,
809
810
  status: "added",
810
811
  });
811
812
  } catch (err) {
@@ -814,15 +815,12 @@ function createServer(options: ServerOptions): ServerWithWatchers {
814
815
  }
815
816
  }
816
817
 
817
- // Single document (backward compat + path-aware)
818
818
  if (pathname === "/api/document" && method === "GET") {
819
819
  const ctxOrRes = requireContext(url);
820
820
  if (ctxOrRes instanceof Response) return ctxOrRes;
821
- const state = fileMap.get(ctxOrRes.filePath)!;
822
821
  const content = await ctxOrRes.getCurrentContent();
823
822
  return json({
824
823
  content,
825
- type: state.type,
826
824
  filePath: ctxOrRes.filePath,
827
825
  fileName: basename(ctxOrRes.filePath),
828
826
  clean: options.clean || false,
@@ -841,7 +839,6 @@ function createServer(options: ServerOptions): ServerWithWatchers {
841
839
  return createHeartbeat(onHeartbeatOpen, onHeartbeatClose);
842
840
  }
843
841
 
844
- // Comments routes
845
842
  if (pathname === "/api/comments" && method === "GET") {
846
843
  const ctxOrRes = requireContext(url);
847
844
  if (ctxOrRes instanceof Response) return ctxOrRes;
@@ -866,7 +863,6 @@ function createServer(options: ServerOptions): ServerWithWatchers {
866
863
  return clearComments(ctxOrRes);
867
864
  }
868
865
 
869
- // Parameterized comment routes
870
866
  const commentId = extractCommentId(pathname);
871
867
  if (commentId) {
872
868
  const ctxOrRes = requireContext(url);
@@ -883,7 +879,6 @@ function createServer(options: ServerOptions): ServerWithWatchers {
883
879
  }
884
880
  }
885
881
 
886
- // Settings routes (global, not per-document)
887
882
  if (pathname === "/api/settings" && method === "GET") {
888
883
  return getSettingsRoute();
889
884
  }
@@ -892,8 +887,9 @@ function createServer(options: ServerOptions): ServerWithWatchers {
892
887
  return updateSettingsRoute(req);
893
888
  }
894
889
 
895
- // ── Static / SPA serving ────────────────────────────────
896
-
890
+ if (isDev) {
891
+ return proxyToVite(req, pathname, url.search);
892
+ }
897
893
  return serveStaticFile(distPath, pathname);
898
894
  },
899
895
  });
@@ -909,8 +905,6 @@ function createServer(options: ServerOptions): ServerWithWatchers {
909
905
  return { server, watchers };
910
906
  }
911
907
 
912
- // ─── Port fallback + start ──────────────────────────────────────────
913
-
914
908
  export async function startServer(
915
909
  options: ServerOptions,
916
910
  ): Promise<ServerResult> {
@@ -923,9 +917,15 @@ export async function startServer(
923
917
  const displayHost =
924
918
  options.host === "0.0.0.0" ? "localhost" : options.host;
925
919
 
920
+ let stopVite: (() => void) | undefined;
921
+ if (process.env.NODE_ENV === "development") {
922
+ stopVite = await spawnViteDev();
923
+ }
924
+
926
925
  const originalStop = server.stop.bind(server);
927
926
  const wrappedServer = {
928
927
  stop() {
928
+ stopVite?.();
929
929
  for (const w of watchers) w.close();
930
930
  originalStop();
931
931
  },