@peaske7/readit 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/.claude/CLAUDE.md +118 -76
  2. package/.claude/commands/review.md +1 -1
  3. package/.claude/roadmap.md +32 -9
  4. package/.claude/user-stories.md +100 -15
  5. package/AGENTS.md +30 -26
  6. package/Makefile +32 -0
  7. package/README.md +90 -2
  8. package/biome.json +18 -8
  9. package/bun.lock +426 -568
  10. package/bunfig.toml +2 -0
  11. package/docs/perf-baseline.md +56 -1
  12. package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
  13. package/e2e/comments.spec.ts +14 -58
  14. package/e2e/document-load.spec.ts +1 -23
  15. package/e2e/export.spec.ts +4 -4
  16. package/e2e/perf/add-comment.spec.ts +9 -11
  17. package/e2e/perf/fixtures/generate.ts +1 -5
  18. package/e2e/perf/screenshot-final.png +0 -0
  19. package/e2e/perf/utils/metrics.ts +73 -9
  20. package/e2e/persistence-file.spec.ts +41 -26
  21. package/e2e/utils/selection.ts +17 -73
  22. package/go/cmd/readit/main.go +416 -0
  23. package/go/go.mod +20 -0
  24. package/go/go.sum +41 -0
  25. package/go/internal/server/anchor.go +302 -0
  26. package/go/internal/server/anchor_test.go +111 -0
  27. package/go/internal/server/comments.go +390 -0
  28. package/go/internal/server/documents.go +113 -0
  29. package/go/internal/server/embed.go +17 -0
  30. package/go/internal/server/headings.go +33 -0
  31. package/go/internal/server/headings_test.go +75 -0
  32. package/go/internal/server/htmltext.go +123 -0
  33. package/go/internal/server/markdown.go +157 -0
  34. package/go/internal/server/markdown_bench_test.go +42 -0
  35. package/go/internal/server/markdown_test.go +79 -0
  36. package/go/internal/server/server.go +453 -0
  37. package/go/internal/server/server_bench_test.go +122 -0
  38. package/go/internal/server/settings.go +110 -0
  39. package/go/internal/server/sse.go +140 -0
  40. package/go/internal/server/storage.go +275 -0
  41. package/go/internal/server/storage_test.go +118 -0
  42. package/go/internal/server/template.go +66 -0
  43. package/go/internal/server/types.go +101 -0
  44. package/go/internal/server/watcher.go +74 -0
  45. package/index.html +4 -14
  46. package/nvim-readit/lua/readit/health.lua +64 -0
  47. package/nvim-readit/lua/readit/init.lua +463 -0
  48. package/nvim-readit/plugin/readit.lua +19 -0
  49. package/package.json +20 -28
  50. package/shell/_readit +158 -0
  51. package/shell/readit.zsh +87 -0
  52. package/src/App.svelte +881 -0
  53. package/src/cli.ts +183 -21
  54. package/src/components/ActionsMenu.svelte +95 -0
  55. package/src/components/CommentBadge.svelte +67 -0
  56. package/src/components/CommentErrorBanner.svelte +33 -0
  57. package/src/components/CommentInput.svelte +75 -0
  58. package/src/components/CommentListItem.svelte +95 -0
  59. package/src/components/CommentManager.svelte +129 -0
  60. package/src/components/CommentNav.svelte +109 -0
  61. package/src/components/DocumentViewer.svelte +218 -0
  62. package/src/components/FloatingComment.svelte +107 -0
  63. package/src/components/Header.svelte +76 -0
  64. package/src/components/InlineEditor.svelte +72 -0
  65. package/src/components/MarginNote.svelte +167 -0
  66. package/src/components/MarginNotesContainer.svelte +33 -0
  67. package/src/components/RawModal.svelte +126 -0
  68. package/src/components/ReanchorConfirm.svelte +30 -0
  69. package/src/components/SettingsModal.svelte +220 -0
  70. package/src/components/ShortcutCapture.svelte +82 -0
  71. package/src/components/ShortcutList.svelte +145 -0
  72. package/src/components/TabBar.svelte +52 -0
  73. package/src/components/TableOfContents.svelte +125 -0
  74. package/src/components/ui/ActionLink.svelte +40 -0
  75. package/src/components/ui/{Button.tsx → Button.svelte} +19 -20
  76. package/src/components/ui/Dialog.svelte +97 -0
  77. package/src/components/ui/DropdownMenu.svelte +85 -0
  78. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  79. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  80. package/src/components/ui/{Text.tsx → Text.svelte} +18 -23
  81. package/src/env.d.ts +6 -0
  82. package/src/index.css +36 -166
  83. package/src/lib/__fixtures__/bench-data.ts +0 -13
  84. package/src/lib/anchor.bench.ts +1 -12
  85. package/src/lib/anchor.test.ts +0 -8
  86. package/src/lib/anchor.ts +0 -4
  87. package/src/lib/comment-storage.bench.ts +49 -0
  88. package/src/lib/comment-storage.test.ts +41 -33
  89. package/src/lib/comment-storage.ts +21 -18
  90. package/src/lib/export.bench.ts +21 -0
  91. package/src/lib/export.ts +0 -1
  92. package/src/{hooks/useHeadings.test.ts → lib/headings.test.ts} +10 -24
  93. package/src/{hooks/useHeadings.ts → lib/headings.ts} +11 -13
  94. package/src/lib/highlight/core.test.ts +0 -5
  95. package/src/lib/highlight/dom.ts +52 -216
  96. package/src/lib/highlight/highlight-registry.ts +221 -0
  97. package/src/lib/highlight/highlight.bench.ts +92 -0
  98. package/src/lib/highlight/highlighter.ts +112 -132
  99. package/src/lib/highlight/resolver.ts +5 -79
  100. package/src/lib/highlight/types.ts +0 -5
  101. package/src/lib/html-text.test.ts +162 -0
  102. package/src/lib/html-text.ts +161 -0
  103. package/src/lib/i18n/en.ts +26 -0
  104. package/src/lib/i18n/ja.ts +26 -0
  105. package/src/lib/i18n/types.ts +25 -0
  106. package/src/lib/margin-layout.bench.ts +61 -0
  107. package/src/lib/margin-layout.ts +0 -7
  108. package/src/lib/markdown-renderer.test.ts +154 -0
  109. package/src/lib/markdown-renderer.ts +177 -0
  110. package/src/lib/mermaid-config.ts +38 -0
  111. package/src/lib/mermaid-renderer.ts +162 -0
  112. package/src/lib/mermaid-worker.ts +60 -0
  113. package/src/lib/positions.ts +31 -24
  114. package/src/lib/shortcut-registry.ts +244 -0
  115. package/src/lib/utils.ts +0 -29
  116. package/src/main.ts +16 -0
  117. package/src/schema.ts +16 -5
  118. package/src/server.ts +355 -91
  119. package/src/stores/app.svelte.ts +231 -0
  120. package/src/stores/locale.svelte.ts +46 -0
  121. package/src/stores/settings.svelte.ts +90 -0
  122. package/src/stores/shortcuts.svelte.ts +104 -0
  123. package/src/stores/ui.svelte.ts +12 -0
  124. package/src/template.ts +104 -0
  125. package/src/test-setup.ts +47 -0
  126. package/svelte.config.js +5 -0
  127. package/tsconfig.json +2 -2
  128. package/vite.config.ts +23 -3
  129. package/vscode-readit/.mcp.json +7 -0
  130. package/vscode-readit/.vscodeignore +7 -0
  131. package/vscode-readit/bun.lock +78 -0
  132. package/vscode-readit/icon.svg +10 -0
  133. package/vscode-readit/package.json +110 -0
  134. package/vscode-readit/src/extension.ts +117 -0
  135. package/vscode-readit/src/server-manager.ts +272 -0
  136. package/vscode-readit/src/webview-provider.ts +204 -0
  137. package/vscode-readit/tsconfig.json +20 -0
  138. package/e2e/fixtures/sample.html +0 -13
  139. package/src/App.tsx +0 -368
  140. package/src/components/ActionsMenu.tsx +0 -91
  141. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  142. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -230
  143. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -136
  144. package/src/components/Header.tsx +0 -54
  145. package/src/components/InlineEditor.tsx +0 -74
  146. package/src/components/MarginNote.tsx +0 -185
  147. package/src/components/MarginNotes.tsx +0 -23
  148. package/src/components/RawModal.tsx +0 -144
  149. package/src/components/ReanchorConfirm.tsx +0 -36
  150. package/src/components/SettingsModal.tsx +0 -232
  151. package/src/components/TabBar.tsx +0 -60
  152. package/src/components/TableOfContents.tsx +0 -108
  153. package/src/components/comments/CommentBadge.tsx +0 -49
  154. package/src/components/comments/CommentInput.tsx +0 -86
  155. package/src/components/comments/CommentListItem.tsx +0 -90
  156. package/src/components/comments/CommentManager.tsx +0 -129
  157. package/src/components/comments/CommentNav.tsx +0 -109
  158. package/src/components/ui/ActionLink.tsx +0 -28
  159. package/src/components/ui/Dialog.tsx +0 -116
  160. package/src/components/ui/DropdownMenu.tsx +0 -158
  161. package/src/contexts/CommentContext.tsx +0 -198
  162. package/src/contexts/LocaleContext.tsx +0 -76
  163. package/src/contexts/PositionsContext.tsx +0 -16
  164. package/src/contexts/SettingsContext.tsx +0 -133
  165. package/src/hooks/useClickOutside.ts +0 -31
  166. package/src/hooks/useCommentNavigation.ts +0 -107
  167. package/src/hooks/useComments.ts +0 -311
  168. package/src/hooks/useDocument.ts +0 -157
  169. package/src/hooks/useScrollSpy.ts +0 -77
  170. package/src/hooks/useTextSelection.ts +0 -86
  171. package/src/lib/highlight/worker.ts +0 -45
  172. package/src/main.tsx +0 -13
  173. package/src/store.ts +0 -222
@@ -1,12 +1,11 @@
1
1
  import {
2
- applyHighlightBatch,
3
- applyHighlightToRange,
4
- clearHighlights,
5
- getDOMTextContent,
2
+ collectTextNodesWithContent,
3
+ createRangesForHighlight,
4
+ createRangesFromNodes,
6
5
  getTextOffset,
7
6
  } from "./dom";
8
- import { Resolver } from "./resolver";
9
- import type { HighlightComment } from "./types";
7
+ import { HighlightRegistry } from "./highlight-registry";
8
+ import type { HighlightComment, TextNodeInfo } from "./types";
10
9
 
11
10
  export type SelectionHandler = (
12
11
  text: string,
@@ -16,11 +15,22 @@ export type SelectionHandler = (
16
15
  ) => void;
17
16
  export type HoverHandler = (commentId: string | undefined) => void;
18
17
  export type ClickHandler = (commentId: string) => void;
18
+ export type CacheHandler = () => void;
19
+
19
20
  export interface Highlighter {
20
21
  applyHighlights(comments: HighlightComment[]): void;
21
22
  clearHighlights(): void;
22
23
  onHighlightHover(callback: HoverHandler): () => void;
23
24
  onHighlightClick(callback: ClickHandler): () => void;
25
+
26
+ setFocused(commentId: string | undefined): void;
27
+ scrollToComment(commentId: string): void;
28
+ getPositions(containerRect: DOMRect): Map<string, number>;
29
+ getHighlightedIds(): string[];
30
+ isPointInHighlight(x: number, y: number): boolean;
31
+
32
+ onCacheInvalidated(callback: CacheHandler): () => void;
33
+
24
34
  dispose(): void;
25
35
  }
26
36
 
@@ -35,14 +45,14 @@ export function createHighlighter(options: HighlighterOptions): Highlighter {
35
45
 
36
46
  let hoverCallback: HoverHandler | undefined;
37
47
  let clickCallback: ClickHandler | undefined;
48
+ let cacheCallback: CacheHandler | undefined;
38
49
 
39
- // Incremental diffing state avoids full clear+rebuild on every comment change
40
- const activeHighlights = new Map<string, { start: number; end: number }>();
50
+ const activePositions = new Map<string, { start: number; end: number }>();
41
51
  let lastTextContent = "";
42
52
 
43
- // Web Worker for anchor resolution (offloads indexOf from main thread)
44
- const resolver = new Resolver();
45
- let resolveGeneration = 0;
53
+ const registry = new HighlightRegistry();
54
+
55
+ let lastHoveredId: string | undefined;
46
56
 
47
57
  const handleMouseUp = () => {
48
58
  const selection = window.getSelection();
@@ -56,7 +66,7 @@ export function createHighlighter(options: HighlighterOptions): Highlighter {
56
66
 
57
67
  const range = selection.getRangeAt(0);
58
68
 
59
- // Reject erroneous whole-document selections (caused by DOM mutation during interaction)
69
+ // Reject whole-document selections caused by DOM mutation
60
70
  if (
61
71
  range.startContainer === root &&
62
72
  range.startOffset === 0 &&
@@ -79,164 +89,107 @@ export function createHighlighter(options: HighlighterOptions): Highlighter {
79
89
 
80
90
  onSelect(text, startOffset, endOffset, selectionTop);
81
91
 
82
- // Apply pending highlight directly (not through applyHighlights cycle)
83
- // so it persists when native ::selection clears on textarea focus
84
- clearHighlights(root, "mark[data-pending]");
85
- applyHighlightToRange(root, startOffset, endOffset, {
86
- attribute: "data-pending",
87
- attributeValue: "true",
92
+ requestAnimationFrame(() => {
93
+ const pendingRanges = createRangesForHighlight(
94
+ root,
95
+ startOffset,
96
+ endOffset,
97
+ );
98
+ registry.setPending(pendingRanges);
88
99
  });
89
100
  };
90
101
 
91
- const handleMouseOver = (e: Event) => {
102
+ const handleMouseMove = (e: MouseEvent) => {
92
103
  if (!hoverCallback) return;
93
- const target = e.target as HTMLElement;
94
- const mark = target.closest("mark[data-comment-id]");
95
- if (mark) {
96
- hoverCallback(mark.getAttribute("data-comment-id") ?? undefined);
97
- }
98
- };
99
104
 
100
- const handleMouseOut = (e: Event) => {
101
- if (!hoverCallback) return;
102
- const target = e.target as HTMLElement;
103
- const relatedTarget = (e as MouseEvent).relatedTarget as HTMLElement | null;
104
- const mark = target.closest("mark[data-comment-id]");
105
- if (mark) {
106
- const relatedMark = relatedTarget?.closest("mark[data-comment-id]");
107
- if (
108
- !relatedMark ||
109
- relatedMark.getAttribute("data-comment-id") !==
110
- mark.getAttribute("data-comment-id")
111
- ) {
112
- hoverCallback(undefined);
113
- }
105
+ const id = registry.hitTest(e.clientX, e.clientY);
106
+
107
+ if (id !== lastHoveredId) {
108
+ lastHoveredId = id;
109
+ hoverCallback(id);
114
110
  }
115
111
  };
116
112
 
117
- const handleClick = (e: Event) => {
113
+ const handleClick = (e: MouseEvent) => {
118
114
  if (!clickCallback) return;
119
- const target = e.target as HTMLElement;
120
- const mark = target.closest("mark[data-comment-id]");
121
- if (mark) {
122
- const commentId = mark.getAttribute("data-comment-id");
123
- if (commentId) {
124
- clickCallback(commentId);
125
- }
115
+
116
+ const commentId = registry.hitTest(e.clientX, e.clientY);
117
+ if (commentId) {
118
+ clickCallback(commentId);
126
119
  }
127
120
  };
128
121
 
129
122
  const applyDiff = (
130
- textContent: string,
131
- resolved: Map<
132
- string,
133
- { start: number; end: number; comment: HighlightComment }
134
- >,
123
+ resolved: Map<string, { start: number; end: number; colorIndex: number }>,
124
+ textNodes: TextNodeInfo[],
135
125
  ) => {
136
- const toRemove: string[] = [];
137
- const toAdd: string[] = [];
126
+ let changed = false;
138
127
 
139
- for (const [id, prev] of activeHighlights) {
128
+ for (const [id, prev] of activePositions) {
140
129
  const next = resolved.get(id);
141
- if (!next) {
142
- toRemove.push(id);
143
- } else if (prev.start !== next.start || prev.end !== next.end) {
144
- toRemove.push(id);
145
- toAdd.push(id);
130
+ if (!next || prev.start !== next.start || prev.end !== next.end) {
131
+ registry.removeComment(id);
132
+ activePositions.delete(id);
133
+ changed = true;
146
134
  }
147
135
  }
148
136
 
149
- for (const id of resolved.keys()) {
150
- if (!activeHighlights.has(id)) {
151
- toAdd.push(id);
137
+ for (const [id, entry] of resolved) {
138
+ if (!activePositions.has(id)) {
139
+ const ranges = createRangesFromNodes(textNodes, entry.start, entry.end);
140
+ if (ranges.length > 0) {
141
+ registry.updateComment(id, ranges, entry.colorIndex);
142
+ activePositions.set(id, { start: entry.start, end: entry.end });
143
+ changed = true;
144
+ }
152
145
  }
153
146
  }
154
147
 
155
- if (toRemove.length === 0 && toAdd.length === 0) return;
156
-
157
- for (const id of toRemove) {
158
- clearHighlights(root, `mark[data-comment-id="${id}"]`);
159
- activeHighlights.delete(id);
160
- }
161
-
162
- if (toAdd.length > 0) {
163
- const newHighlights = toAdd
164
- .map((id) => resolved.get(id)!)
165
- .sort((a, b) => a.start - b.start);
166
-
167
- applyHighlightBatch(
168
- root,
169
- textContent,
170
- newHighlights.map((h) => ({
171
- startOffset: h.start,
172
- endOffset: h.end,
173
- style: {
174
- attribute: "data-comment-id",
175
- attributeValue: h.comment.id,
176
- colorIndex: 0,
177
- },
178
- })),
179
- );
180
-
181
- for (const id of toAdd) {
182
- const range = resolved.get(id)!;
183
- activeHighlights.set(id, { start: range.start, end: range.end });
184
- }
148
+ if (changed) {
149
+ cacheCallback?.();
185
150
  }
186
151
  };
187
152
 
188
153
  root.addEventListener("mouseup", handleMouseUp);
189
- root.addEventListener("mouseover", handleMouseOver);
190
- root.addEventListener("mouseout", handleMouseOut);
154
+ root.addEventListener("mousemove", handleMouseMove);
191
155
  root.addEventListener("click", handleClick);
192
156
 
193
157
  return {
194
158
  applyHighlights(comments: HighlightComment[]) {
195
- const textContent = getDOMTextContent(root);
159
+ const { text: textContent, nodes: textNodes } =
160
+ collectTextNodesWithContent(root);
196
161
 
197
- // If DOM content changed (e.g. document reload), full rebuild is required
198
162
  const contentChanged = textContent !== lastTextContent;
199
163
  if (contentChanged) {
200
- clearHighlights(root);
201
- activeHighlights.clear();
164
+ registry.clearAll();
165
+ activePositions.clear();
202
166
  lastTextContent = textContent;
203
167
  }
204
168
 
205
- // Bump generation so stale Worker responses are discarded
206
- const generation = ++resolveGeneration;
207
-
208
- // Resolve anchors off the main thread, then diff and apply
209
- resolver.resolve(textContent, comments).then((anchorMap) => {
210
- // Discard if a newer applyHighlights call has started
211
- if (generation !== resolveGeneration) return;
212
-
213
- const resolved = new Map<
214
- string,
215
- { start: number; end: number; comment: HighlightComment }
216
- >();
217
- for (const c of comments) {
218
- const anchor = anchorMap.get(c.id);
219
- if (anchor) {
220
- resolved.set(c.id, {
221
- start: anchor.start,
222
- end: anchor.end,
223
- comment: {
224
- ...c,
225
- startOffset: anchor.start,
226
- endOffset: anchor.end,
227
- },
228
- });
229
- }
169
+ const resolved = new Map<
170
+ string,
171
+ { start: number; end: number; colorIndex: number }
172
+ >();
173
+ let colorIdx = 0;
174
+ for (const c of comments) {
175
+ if (c.startOffset >= 0 && c.endOffset > c.startOffset) {
176
+ resolved.set(c.id, {
177
+ start: c.startOffset,
178
+ end: c.endOffset,
179
+ colorIndex: colorIdx % 4,
180
+ });
181
+ colorIdx++;
230
182
  }
183
+ }
231
184
 
232
- applyDiff(textContent, resolved);
233
- });
185
+ applyDiff(resolved, textNodes);
234
186
  },
235
187
 
236
188
  clearHighlights() {
237
- clearHighlights(root);
238
- activeHighlights.clear();
189
+ registry.clearAll();
190
+ activePositions.clear();
239
191
  lastTextContent = "";
192
+ cacheCallback?.();
240
193
  },
241
194
 
242
195
  onHighlightHover(callback: HoverHandler) {
@@ -253,15 +206,42 @@ export function createHighlighter(options: HighlighterOptions): Highlighter {
253
206
  };
254
207
  },
255
208
 
209
+ setFocused(commentId: string | undefined) {
210
+ registry.setFocused(commentId);
211
+ },
212
+
213
+ scrollToComment(commentId: string) {
214
+ registry.scrollToComment(commentId);
215
+ },
216
+
217
+ getPositions(containerRect: DOMRect): Map<string, number> {
218
+ return registry.getPositions(containerRect);
219
+ },
220
+
221
+ getHighlightedIds(): string[] {
222
+ return registry.getHighlightedIds();
223
+ },
224
+
225
+ isPointInHighlight(x: number, y: number): boolean {
226
+ return registry.isPointInHighlight(x, y);
227
+ },
228
+
229
+ onCacheInvalidated(callback: CacheHandler) {
230
+ cacheCallback = callback;
231
+ return () => {
232
+ cacheCallback = undefined;
233
+ };
234
+ },
235
+
256
236
  dispose() {
257
- resolveGeneration++;
258
- resolver.dispose();
237
+ registry.dispose();
259
238
  root.removeEventListener("mouseup", handleMouseUp);
260
- root.removeEventListener("mouseover", handleMouseOver);
261
- root.removeEventListener("mouseout", handleMouseOut);
239
+ root.removeEventListener("mousemove", handleMouseMove);
262
240
  root.removeEventListener("click", handleClick);
263
241
  hoverCallback = undefined;
264
242
  clickCallback = undefined;
243
+ cacheCallback = undefined;
244
+ lastHoveredId = undefined;
265
245
  },
266
246
  };
267
247
  }
@@ -1,4 +1,4 @@
1
- import type { HighlightComment, TextPosition } from "./types";
1
+ import type { TextPosition } from "./types";
2
2
 
3
3
  export function findTextPosition(
4
4
  textContent: string,
@@ -18,7 +18,10 @@ export function findTextPosition(
18
18
 
19
19
  if (occurrences.length === 0) return undefined;
20
20
  if (occurrences.length === 1) {
21
- return { start: occurrences[0], end: occurrences[0] + selectedText.length };
21
+ return {
22
+ start: occurrences[0],
23
+ end: occurrences[0] + selectedText.length,
24
+ };
22
25
  }
23
26
 
24
27
  const target = hintOffset ?? 0;
@@ -33,80 +36,3 @@ export function findTextPosition(
33
36
  }
34
37
  return { start: closest, end: closest + selectedText.length };
35
38
  }
36
-
37
- export class Resolver {
38
- private worker: Worker | null = null;
39
- private seq = 0;
40
- private pending = new Map<
41
- number,
42
- (results: Map<string, TextPosition>) => void
43
- >();
44
-
45
- constructor() {
46
- try {
47
- this.worker = new Worker(new URL("./worker.ts", import.meta.url), {
48
- type: "module",
49
- });
50
- this.worker.onmessage = this.onMessage;
51
- this.worker.onerror = () => {
52
- this.worker?.terminate();
53
- this.worker = null;
54
- };
55
- } catch {
56
- this.worker = null;
57
- }
58
- }
59
-
60
- resolve(
61
- text: string,
62
- comments: HighlightComment[],
63
- ): Promise<Map<string, TextPosition>> {
64
- if (!this.worker || comments.length === 0) {
65
- return Promise.resolve(this.sync(text, comments));
66
- }
67
-
68
- return new Promise((resolve) => {
69
- const id = this.seq++;
70
- this.pending.set(id, resolve);
71
- this.worker!.postMessage({
72
- id,
73
- textContent: text,
74
- comments: comments.map((c) => ({
75
- id: c.id,
76
- selectedText: c.selectedText,
77
- startOffset: c.startOffset,
78
- })),
79
- });
80
- });
81
- }
82
-
83
- dispose() {
84
- this.worker?.terminate();
85
- this.worker = null;
86
- for (const resolve of this.pending.values()) resolve(new Map());
87
- this.pending.clear();
88
- }
89
-
90
- private onMessage = (e: MessageEvent) => {
91
- const { id, results } = e.data;
92
- const resolve = this.pending.get(id);
93
- if (!resolve) return;
94
-
95
- this.pending.delete(id);
96
- const map = new Map<string, TextPosition>();
97
- for (const r of results) map.set(r.id, { start: r.start, end: r.end });
98
- resolve(map);
99
- };
100
-
101
- private sync(
102
- text: string,
103
- comments: HighlightComment[],
104
- ): Map<string, TextPosition> {
105
- const map = new Map<string, TextPosition>();
106
- for (const c of comments) {
107
- const pos = findTextPosition(text, c.selectedText, c.startOffset);
108
- if (pos) map.set(c.id, pos);
109
- }
110
- return map;
111
- }
112
- }
@@ -1,8 +1,3 @@
1
- export interface HighlightStyle {
2
- attribute: string;
3
- attributeValue: string;
4
- }
5
-
6
1
  export interface TextPosition {
7
2
  start: number;
8
3
  end: number;
@@ -0,0 +1,162 @@
1
+ import { JSDOM } from "jsdom";
2
+ import { describe, expect, it } from "vitest";
3
+ import { getDOMTextContent } from "./highlight/dom";
4
+ import { extractTextFromHtml } from "./html-text";
5
+ import { renderMarkdown } from "./markdown-renderer";
6
+
7
+ const DOM_GLOBALS = ["document", "NodeFilter"] as const;
8
+
9
+ function browserExtract(html: string): string {
10
+ const dom = new JSDOM(
11
+ `<!DOCTYPE html><body><article>${html}</article></body>`,
12
+ );
13
+ const saved = Object.fromEntries(
14
+ DOM_GLOBALS.map((k) => [k, (globalThis as Record<string, unknown>)[k]]),
15
+ );
16
+ try {
17
+ for (const k of DOM_GLOBALS) {
18
+ (globalThis as Record<string, unknown>)[k] = dom.window[k];
19
+ }
20
+ const container = dom.window.document.querySelector("article")!;
21
+ return getDOMTextContent(container);
22
+ } finally {
23
+ for (const k of DOM_GLOBALS) {
24
+ if (saved[k] !== undefined) {
25
+ (globalThis as Record<string, unknown>)[k] = saved[k];
26
+ } else {
27
+ delete (globalThis as Record<string, unknown>)[k];
28
+ }
29
+ }
30
+ }
31
+ }
32
+
33
+ describe("extractTextFromHtml", () => {
34
+ it("extracts plain text from paragraphs", () => {
35
+ const html = "<p>Hello world</p><p>Second paragraph</p>";
36
+ expect(extractTextFromHtml(html)).toBe("Hello world\nSecond paragraph");
37
+ });
38
+
39
+ it("handles headings", () => {
40
+ const html = "<h1>Title</h1><p>Content</p>";
41
+ expect(extractTextFromHtml(html)).toBe("Title\nContent");
42
+ });
43
+
44
+ it("handles nested block elements (no extra newline)", () => {
45
+ const html = "<blockquote><p>Quoted text</p></blockquote><p>After</p>";
46
+ const result = extractTextFromHtml(html);
47
+ expect(result).toContain("Quoted text");
48
+ expect(result).toContain("After");
49
+ });
50
+
51
+ it("handles lists", () => {
52
+ const html = "<ul><li>Item 1</li><li>Item 2</li></ul>";
53
+ expect(extractTextFromHtml(html)).toBe("Item 1\nItem 2");
54
+ });
55
+
56
+ it("handles inline elements within blocks", () => {
57
+ const html = "<p>Text with <strong>bold</strong> and <em>italic</em></p>";
58
+ expect(extractTextFromHtml(html)).toBe("Text with bold and italic");
59
+ });
60
+
61
+ it("decodes HTML entities", () => {
62
+ const html = "<p>&lt;div&gt; &amp; &quot;quotes&quot;</p>";
63
+ expect(extractTextFromHtml(html)).toBe('<div> & "quotes"');
64
+ });
65
+
66
+ it("handles code blocks", () => {
67
+ const html =
68
+ '<pre><code>function hello() {\n return "world";\n}</code></pre>';
69
+ expect(extractTextFromHtml(html)).toContain("function hello()");
70
+ });
71
+ });
72
+
73
+ describe("extractTextFromHtml conformance with getDOMTextContent", () => {
74
+ it("matches browser extraction for simple markdown", async () => {
75
+ const md = `# Hello
76
+
77
+ This is a paragraph.
78
+
79
+ ## Section
80
+
81
+ Another paragraph here.
82
+ `;
83
+ const { html } = await renderMarkdown(md);
84
+ const serverText = extractTextFromHtml(html);
85
+ const browserText = browserExtract(html);
86
+ expect(serverText).toBe(browserText);
87
+ });
88
+
89
+ it("matches browser extraction for lists", async () => {
90
+ const md = `- Item 1
91
+ - Item 2
92
+ - Item 3
93
+ `;
94
+ const { html } = await renderMarkdown(md);
95
+ const serverText = extractTextFromHtml(html);
96
+ const browserText = browserExtract(html);
97
+ expect(serverText).toBe(browserText);
98
+ });
99
+
100
+ it("matches browser extraction for code blocks", async () => {
101
+ const md = `# Code
102
+
103
+ \`\`\`typescript
104
+ function hello() {
105
+ return "world";
106
+ }
107
+ \`\`\`
108
+
109
+ After code.
110
+ `;
111
+ const { html } = await renderMarkdown(md);
112
+ const serverText = extractTextFromHtml(html);
113
+ const browserText = browserExtract(html);
114
+ expect(serverText).toBe(browserText);
115
+ });
116
+
117
+ it("matches browser extraction for tables", async () => {
118
+ const md = `| A | B |
119
+ |---|---|
120
+ | 1 | 2 |
121
+ | 3 | 4 |
122
+ `;
123
+ const { html } = await renderMarkdown(md);
124
+ const serverText = extractTextFromHtml(html);
125
+ const browserText = browserExtract(html);
126
+ expect(serverText).toBe(browserText);
127
+ });
128
+
129
+ it("matches browser extraction for complex document", async () => {
130
+ const md = `# Performance Test Document
131
+
132
+ This section covers topic 1 in detail. It contains various formatting including **bold**, *italic*, and \`inline code\`.
133
+
134
+ ## Section 2
135
+
136
+ - Item 1 in section 2
137
+ - Item 2 in section 2
138
+ - Item 3 in section 2
139
+
140
+ \`\`\`typescript
141
+ function section2() {
142
+ const value = 2 * 42;
143
+ return "result from section 2: " + value;
144
+ }
145
+ \`\`\`
146
+
147
+ The conclusion of section 2 summarizes the key findings.
148
+
149
+ | Column A | Column B | Column C |
150
+ |----------|----------|----------|
151
+ | Cell 1 | Cell 2 | Cell 3 |
152
+
153
+ > A blockquote with some text.
154
+
155
+ Final paragraph.
156
+ `;
157
+ const { html } = await renderMarkdown(md);
158
+ const serverText = extractTextFromHtml(html);
159
+ const browserText = browserExtract(html);
160
+ expect(serverText).toBe(browserText);
161
+ });
162
+ });