@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
package/src/lib/anchor.ts CHANGED
@@ -1,16 +1,11 @@
1
- import { type Anchor, AnchorConfidences } from "../types";
1
+ import { type Anchor, AnchorConfidences } from "../schema";
2
2
  import { getLineNumber } from "./comment-storage";
3
3
 
4
- // Anchor matching configuration
5
- const DEFAULT_SEARCH_WINDOW = 500; // chars before/after line hint for exact match
6
- const DEFAULT_FUZZY_THRESHOLD = 5; // max Levenshtein distance for fuzzy match
7
- const MAX_FUZZY_TEXT_LENGTH = 200; // skip fuzzy matching for texts longer than this
8
- const FUZZY_SEARCH_WINDOW = 2000; // larger window for fuzzy search near line hint
4
+ const DEFAULT_SEARCH_WINDOW = 500;
5
+ const DEFAULT_FUZZY_THRESHOLD = 5;
6
+ const MAX_FUZZY_TEXT_LENGTH = 200;
7
+ const FUZZY_SEARCH_WINDOW = 2000;
9
8
 
10
- /**
11
- * Common parameters for anchor finding functions.
12
- * Using object destructuring per style guide §3.5 for multiple string parameters.
13
- */
14
9
  export interface FindAnchorParams {
15
10
  source: string;
16
11
  selectedText: string;
@@ -32,27 +27,16 @@ export interface FindAnchorWithFallbackParams {
32
27
  fuzzyThreshold?: number;
33
28
  }
34
29
 
35
- /**
36
- * Normalize whitespace for comparison: collapse runs of whitespace to single space.
37
- * This allows matching text that was reformatted (line breaks, indentation changes).
38
- */
39
30
  export function normalizeWhitespace(text: string): string {
40
31
  return text.replace(/\s+/g, " ").trim();
41
32
  }
42
33
 
43
- /**
44
- * Calculate Levenshtein distance between two strings.
45
- * Uses Wagner-Fischer algorithm with O(min(m,n)) space.
46
- *
47
- * @param maxDistance Optional early exit threshold. If set, returns Infinity
48
- * when distance is guaranteed to exceed this value.
49
- */
34
+ /** Wagner-Fischer with O(min(m,n)) space. Returns Infinity when > maxDistance. */
50
35
  export function levenshteinDistance(
51
36
  a: string,
52
37
  b: string,
53
38
  maxDistance?: number,
54
39
  ): number {
55
- // Ensure a is the shorter string for space optimization
56
40
  if (a.length > b.length) {
57
41
  [a, b] = [b, a];
58
42
  }
@@ -60,20 +44,16 @@ export function levenshteinDistance(
60
44
  const m = a.length;
61
45
  const n = b.length;
62
46
 
63
- // Early termination for empty strings
64
47
  if (m === 0) return n;
65
48
  if (n === 0) return m;
66
49
 
67
- // Early exit: length difference alone exceeds threshold
68
50
  if (maxDistance !== undefined && Math.abs(m - n) > maxDistance) {
69
51
  return Number.POSITIVE_INFINITY;
70
52
  }
71
53
 
72
- // Use single row for space optimization
73
54
  let prevRow = new Array(m + 1);
74
55
  let currRow = new Array(m + 1);
75
56
 
76
- // Initialize first row
77
57
  for (let i = 0; i <= m; i++) {
78
58
  prevRow[i] = i;
79
59
  }
@@ -81,20 +61,18 @@ export function levenshteinDistance(
81
61
  for (let j = 1; j <= n; j++) {
82
62
  currRow[0] = j;
83
63
 
84
- // Track minimum value in this row for early exit
85
64
  let rowMin = currRow[0];
86
65
 
87
66
  for (let i = 1; i <= m; i++) {
88
67
  const cost = a[i - 1] === b[j - 1] ? 0 : 1;
89
68
  currRow[i] = Math.min(
90
- prevRow[i] + 1, // deletion
91
- currRow[i - 1] + 1, // insertion
92
- prevRow[i - 1] + cost, // substitution
69
+ prevRow[i] + 1,
70
+ currRow[i - 1] + 1,
71
+ prevRow[i - 1] + cost,
93
72
  );
94
73
  rowMin = Math.min(rowMin, currRow[i]);
95
74
  }
96
75
 
97
- // Early exit: minimum possible distance exceeds threshold
98
76
  if (maxDistance !== undefined && rowMin > maxDistance) {
99
77
  return Number.POSITIVE_INFINITY;
100
78
  }
@@ -105,9 +83,6 @@ export function levenshteinDistance(
105
83
  return prevRow[m];
106
84
  }
107
85
 
108
- /**
109
- * Get character offset for the start of a line number (1-indexed).
110
- */
111
86
  export function getLineOffset(content: string, lineNumber: number): number {
112
87
  if (lineNumber <= 1) return 0;
113
88
 
@@ -125,15 +100,12 @@ export function getLineOffset(content: string, lineNumber: number): number {
125
100
  return content.length;
126
101
  }
127
102
 
128
- /**
129
- * Parse line hint string to get line number(s).
130
- * Supports "L42" and "L42-45" formats.
131
- */
103
+ /** Supports "L42", "L42-L55", and legacy "L42-45" format. */
132
104
  export function parseLineHint(lineHint: string): {
133
105
  start: number;
134
106
  end: number;
135
107
  } {
136
- const match = lineHint.match(/^L(\d+)(?:-(\d+))?$/);
108
+ const match = lineHint.match(/^L(\d+)(?:-L?(\d+))?$/);
137
109
  if (!match) {
138
110
  return { start: 1, end: 1 };
139
111
  }
@@ -143,10 +115,6 @@ export function parseLineHint(lineHint: string): {
143
115
  return { start, end };
144
116
  }
145
117
 
146
- /**
147
- * Find anchor position for selected text in source content.
148
- * Uses line hint for fast lookup, falls back to global search.
149
- */
150
118
  export function findAnchor({
151
119
  source,
152
120
  selectedText,
@@ -158,7 +126,6 @@ export function findAnchor({
158
126
  }
159
127
  const { start: hintLine } = parseLineHint(lineHint);
160
128
 
161
- // Fast path: search near line hint
162
129
  const lineOffset = getLineOffset(source, hintLine);
163
130
  const windowStart = Math.max(0, lineOffset - searchWindow);
164
131
  const windowEnd = Math.min(source.length, lineOffset + searchWindow);
@@ -176,7 +143,6 @@ export function findAnchor({
176
143
  };
177
144
  }
178
145
 
179
- // Fallback: global search
180
146
  const globalIndex = source.indexOf(selectedText);
181
147
  if (globalIndex !== -1) {
182
148
  return {
@@ -190,10 +156,6 @@ export function findAnchor({
190
156
  return undefined;
191
157
  }
192
158
 
193
- /**
194
- * Build a position map from normalized string positions back to original positions.
195
- * Returns array where normalizedToOriginal[i] = original position for normalized char i.
196
- */
197
159
  function buildNormalizedPositionMap(text: string): {
198
160
  normalized: string;
199
161
  toOriginal: number[];
@@ -208,7 +170,6 @@ function buildNormalizedPositionMap(text: string): {
208
170
 
209
171
  if (isSpace) {
210
172
  if (!inWhitespace && normalized.length > 0) {
211
- // First whitespace after content - emit single space
212
173
  normalized += " ";
213
174
  toOriginal.push(i);
214
175
  }
@@ -220,7 +181,6 @@ function buildNormalizedPositionMap(text: string): {
220
181
  }
221
182
  }
222
183
 
223
- // Trim trailing space
224
184
  if (normalized.endsWith(" ")) {
225
185
  normalized = normalized.slice(0, -1);
226
186
  toOriginal.pop();
@@ -229,16 +189,6 @@ function buildNormalizedPositionMap(text: string): {
229
189
  return { normalized, toOriginal };
230
190
  }
231
191
 
232
- /**
233
- * Find anchor using whitespace-normalized matching.
234
- * Useful when document was reformatted but content is unchanged.
235
- * Returns "normalized" confidence level.
236
- *
237
- * Algorithm:
238
- * 1. Normalize source and build position map
239
- * 2. Find normalized text in normalized source (fast substring search)
240
- * 3. Map positions back to original source
241
- */
242
192
  export function findAnchorNormalized({
243
193
  source,
244
194
  selectedText,
@@ -254,31 +204,25 @@ export function findAnchorNormalized({
254
204
  return undefined;
255
205
  }
256
206
 
257
- // Skip if text has no collapsible whitespace (exact match would have worked)
258
207
  if (normalizedText === selectedText) {
259
208
  return undefined;
260
209
  }
261
210
  const { start: hintLine } = parseLineHint(lineHint);
262
211
  const lineOffset = getLineOffset(source, hintLine);
263
212
 
264
- // Define search window
265
213
  const windowStart = Math.max(0, lineOffset - searchWindow);
266
214
  const windowEnd = Math.min(source.length, lineOffset + searchWindow);
267
215
  const window = source.slice(windowStart, windowEnd);
268
216
 
269
- // Build normalized version with position mapping
270
217
  const { normalized: normalizedWindow, toOriginal } =
271
218
  buildNormalizedPositionMap(window);
272
219
 
273
- // Fast substring search on normalized text
274
220
  const normalizedIndex = normalizedWindow.indexOf(normalizedText);
275
221
  if (normalizedIndex !== -1) {
276
- // Map back to original positions
277
222
  const originalStart = windowStart + toOriginal[normalizedIndex];
278
223
  const endNormIndex = normalizedIndex + normalizedText.length - 1;
279
- // Find original end: scan forward from mapped position to include trailing whitespace
280
224
  let originalEnd = windowStart + toOriginal[endNormIndex] + 1;
281
- // Extend to include any trailing whitespace that was collapsed
225
+ // Extend past trailing whitespace that was collapsed during normalization
282
226
  while (originalEnd < source.length && /\s/.test(source[originalEnd])) {
283
227
  originalEnd++;
284
228
  }
@@ -291,7 +235,6 @@ export function findAnchorNormalized({
291
235
  };
292
236
  }
293
237
 
294
- // Global fallback (outside hint window)
295
238
  const { normalized: fullNormalized, toOriginal: fullToOriginal } =
296
239
  buildNormalizedPositionMap(source);
297
240
  const globalIndex = fullNormalized.indexOf(normalizedText);
@@ -314,10 +257,6 @@ export function findAnchorNormalized({
314
257
  return undefined;
315
258
  }
316
259
 
317
- /**
318
- * Find anchor using fuzzy matching with Levenshtein distance.
319
- * Scans the source for substrings similar to the selected text.
320
- */
321
260
  export function findAnchorFuzzy({
322
261
  source,
323
262
  selectedText,
@@ -330,7 +269,6 @@ export function findAnchorFuzzy({
330
269
 
331
270
  const textLen = selectedText.length;
332
271
 
333
- // For very long texts, skip fuzzy matching (too expensive)
334
272
  if (textLen > MAX_FUZZY_TEXT_LENGTH) {
335
273
  return undefined;
336
274
  }
@@ -338,26 +276,22 @@ export function findAnchorFuzzy({
338
276
  let bestMatch: Anchor | undefined;
339
277
  let bestDistance = threshold + 1;
340
278
 
341
- // Determine search range based on line hint
342
279
  let searchStart = 0;
343
280
  let searchEnd = source.length;
344
281
 
345
282
  if (lineHint) {
346
283
  const { start: hintLine } = parseLineHint(lineHint);
347
284
  const lineOffset = getLineOffset(source, hintLine);
348
- // Search in a larger window for fuzzy matching
349
285
  searchStart = Math.max(0, lineOffset - FUZZY_SEARCH_WINDOW);
350
286
  searchEnd = Math.min(source.length, lineOffset + FUZZY_SEARCH_WINDOW);
351
287
  }
352
288
 
353
- // Slide window of similar length through the search range
354
289
  const minLen = Math.max(1, textLen - threshold);
355
290
  const maxLen = textLen + threshold;
356
291
 
357
292
  for (let len = minLen; len <= maxLen; len++) {
358
293
  for (let i = searchStart; i <= searchEnd - len; i++) {
359
294
  const candidate = source.slice(i, i + len);
360
- // Use early exit: only compute if distance could improve on current best
361
295
  const distance = levenshteinDistance(
362
296
  selectedText,
363
297
  candidate,
@@ -374,7 +308,6 @@ export function findAnchorFuzzy({
374
308
  distance,
375
309
  };
376
310
 
377
- // Early exit if we found an exact match
378
311
  if (distance === 0) {
379
312
  return bestMatch;
380
313
  }
@@ -385,27 +318,18 @@ export function findAnchorFuzzy({
385
318
  return bestMatch;
386
319
  }
387
320
 
388
- /**
389
- * Find anchor with fallback chain: exact → normalized → fuzzy.
390
- *
391
- * Matching strategies in order of preference:
392
- * 1. Exact: Fast substring match (O(n))
393
- * 2. Normalized: Whitespace-collapsed match for reformatted text (O(n))
394
- * 3. Fuzzy: Levenshtein distance for small edits (O(n × m × threshold))
395
- */
321
+ /** Fallback chain: exact → normalized → fuzzy. */
396
322
  export function findAnchorWithFallback({
397
323
  source,
398
324
  selectedText,
399
325
  lineHint,
400
326
  fuzzyThreshold,
401
327
  }: FindAnchorWithFallbackParams): Anchor | undefined {
402
- // Try exact match first (fastest)
403
328
  const exactMatch = findAnchor({ source, selectedText, lineHint });
404
329
  if (exactMatch) {
405
330
  return exactMatch;
406
331
  }
407
332
 
408
- // Try normalized match (handles reformatting)
409
333
  const normalizedMatch = findAnchorNormalized({
410
334
  source,
411
335
  selectedText,
@@ -415,7 +339,6 @@ export function findAnchorWithFallback({
415
339
  return normalizedMatch;
416
340
  }
417
341
 
418
- // Fall back to fuzzy matching (handles small edits)
419
342
  return findAnchorFuzzy({
420
343
  source,
421
344
  selectedText,
@@ -424,9 +347,6 @@ export function findAnchorWithFallback({
424
347
  });
425
348
  }
426
349
 
427
- /**
428
- * Find the closest match when multiple occurrences exist.
429
- */
430
350
  export function findClosestOccurrence({
431
351
  source,
432
352
  selectedText,
@@ -1,6 +1,6 @@
1
1
  import type * as os from "node:os";
2
2
  import { describe, expect, it, vi } from "vitest";
3
- import type { CommentFile } from "../types";
3
+ import type { CommentFile } from "../schema";
4
4
  import { COMMENT_FILE_LARGE } from "./__fixtures__/bench-data";
5
5
  import {
6
6
  computeHash,
@@ -124,7 +124,7 @@ describe("getLineHint", () => {
124
124
 
125
125
  it("returns range hint for multiple lines", () => {
126
126
  const content = "line one\nline two\nline three";
127
- expect(getLineHint(content, 0, 20)).toBe("L1-3");
127
+ expect(getLineHint(content, 0, 20)).toBe("L1-L3");
128
128
  });
129
129
  });
130
130
 
@@ -363,7 +363,7 @@ describe("serializeComments", () => {
363
363
  selectedText: "line one\nline two",
364
364
  comment: "Comment",
365
365
  createdAt: "2024-12-24T10:30:00Z",
366
- lineHint: "L42-43",
366
+ lineHint: "L42-L43",
367
367
  startOffset: 100,
368
368
  endOffset: 120,
369
369
  },
@@ -439,7 +439,7 @@ describe("serializeComments", () => {
439
439
  selectedText: "another\nmultiline\nselection",
440
440
  comment: "Another comment with\n\nmultiple paragraphs.",
441
441
  createdAt: "2024-12-24T11:00:00Z",
442
- lineHint: "L50-52",
442
+ lineHint: "L50-L52",
443
443
  startOffset: 200,
444
444
  endOffset: 230,
445
445
  },
@@ -1,7 +1,7 @@
1
1
  import * as crypto from "node:crypto";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import type { Comment, CommentFile } from "../types";
4
+ import type { Comment, CommentFile } from "../schema";
5
5
 
6
6
  const FORMAT_VERSION = 1;
7
7
  const HASH_LENGTH = 16;
@@ -9,9 +9,6 @@ const MAX_SELECTION_LENGTH = 1000;
9
9
  const TRUNCATION_MARKER = "\n...\n";
10
10
  const ANCHOR_PREFIX_LENGTH = 200; // chars stored for anchor matching when text is truncated
11
11
 
12
- /**
13
- * Truncate very long selections to first ~500 + ... + last ~500 chars.
14
- */
15
12
  export function truncateSelection(text: string): string {
16
13
  if (text.length <= MAX_SELECTION_LENGTH) {
17
14
  return text;
@@ -27,13 +24,9 @@ export function truncateSelection(text: string): string {
27
24
  * Comments are stored in ~/.readit/comments/{absolute-path-structure}/{filename}.comments.md
28
25
  */
29
26
  export function getCommentPath(sourcePath: string): string {
30
- // Resolve to absolute path
31
27
  const absolute = path.resolve(sourcePath);
32
-
33
28
  // Remove leading slash and drive letter (Windows)
34
29
  const normalized = absolute.replace(/^\//, "").replace(/^[A-Z]:[\\/]/, "");
35
-
36
- // Get filename without extension, add .comments.md
37
30
  const ext = path.extname(normalized);
38
31
  const withoutExt = normalized.slice(0, -ext.length || undefined);
39
32
 
@@ -45,9 +38,6 @@ export function getCommentPath(sourcePath: string): string {
45
38
  );
46
39
  }
47
40
 
48
- /**
49
- * Compute SHA-256 hash of content, returning first 16 characters.
50
- */
51
41
  export function computeHash(content: string): string {
52
42
  return crypto
53
43
  .createHash("sha256")
@@ -56,18 +46,12 @@ export function computeHash(content: string): string {
56
46
  .slice(0, HASH_LENGTH);
57
47
  }
58
48
 
59
- /**
60
- * Get line number (1-indexed) for a character offset in content.
61
- */
62
49
  export function getLineNumber(content: string, offset: number): number {
63
50
  if (offset <= 0 || content.length === 0) return 1;
64
51
  const clampedOffset = Math.min(offset, content.length);
65
52
  return content.slice(0, clampedOffset).split("\n").length;
66
53
  }
67
54
 
68
- /**
69
- * Get line range string for a selection (e.g., "L42" or "L42-45").
70
- */
71
55
  export function getLineHint(
72
56
  content: string,
73
57
  startOffset: number,
@@ -75,12 +59,9 @@ export function getLineHint(
75
59
  ): string {
76
60
  const startLine = getLineNumber(content, startOffset);
77
61
  const endLine = getLineNumber(content, endOffset);
78
- return startLine === endLine ? `L${startLine}` : `L${startLine}-${endLine}`;
62
+ return startLine === endLine ? `L${startLine}` : `L${startLine}-L${endLine}`;
79
63
  }
80
64
 
81
- /**
82
- * Parse a comment file's markdown content into a CommentFile structure.
83
- */
84
65
  export function parseCommentFile(content: string): CommentFile {
85
66
  const result: CommentFile = {
86
67
  source: "",
@@ -93,7 +74,6 @@ export function parseCommentFile(content: string): CommentFile {
93
74
  return result;
94
75
  }
95
76
 
96
- // Parse YAML front matter
97
77
  const frontMatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
98
78
  if (frontMatterMatch) {
99
79
  const frontMatter = frontMatterMatch[1];
@@ -105,7 +85,6 @@ export function parseCommentFile(content: string): CommentFile {
105
85
  if (hashMatch) result.hash = hashMatch[1].trim();
106
86
  if (versionMatch) result.version = Number.parseInt(versionMatch[1], 10);
107
87
 
108
- // Validate version compatibility
109
88
  if (result.version > FORMAT_VERSION) {
110
89
  throw new Error(
111
90
  `Comment file requires readit v${result.version} or higher. ` +
@@ -114,7 +93,6 @@ export function parseCommentFile(content: string): CommentFile {
114
93
  }
115
94
  }
116
95
 
117
- // Remove front matter and split by separator
118
96
  const bodyContent = content.replace(/^---\n[\s\S]*?\n---\n*/, "");
119
97
  const blocks = bodyContent.split(/\n---\n/).filter((block) => block.trim());
120
98
 
@@ -128,9 +106,6 @@ export function parseCommentFile(content: string): CommentFile {
128
106
  return result;
129
107
  }
130
108
 
131
- /**
132
- * Parse a single comment block.
133
- */
134
109
  function parseCommentBlock(block: string): Comment | undefined {
135
110
  // Extract metadata from HTML comment: <!-- c:{id}|{lineHint}|{timestamp} -->
136
111
  const metadataMatch = block.match(/<!--\s*c:([^|]+)\|([^|]+)\|([^>]+)\s*-->/);
@@ -144,19 +119,16 @@ function parseCommentBlock(block: string): Comment | undefined {
144
119
  const anchorMatch = block.match(/<!--\s*anchor:(.+?)\s*-->/);
145
120
  const anchorPrefix = anchorMatch ? anchorMatch[1] : undefined;
146
121
 
147
- // Extract selected text from blockquote
148
122
  const blockquoteMatch = block.match(/^>\s*(.+(?:\n>\s*.+)*)$/m);
149
123
  if (!blockquoteMatch) {
150
124
  return undefined;
151
125
  }
152
126
 
153
- // Remove the "> " prefix from each line
154
127
  const selectedText = blockquoteMatch[1]
155
128
  .split("\n")
156
129
  .map((line) => line.replace(/^>\s*/, ""))
157
130
  .join("\n");
158
131
 
159
- // Extract comment body (everything after blockquote)
160
132
  const afterBlockquote = block.slice(
161
133
  block.indexOf(blockquoteMatch[0]) + blockquoteMatch[0].length,
162
134
  );
@@ -175,13 +147,9 @@ function parseCommentBlock(block: string): Comment | undefined {
175
147
  };
176
148
  }
177
149
 
178
- /**
179
- * Serialize a CommentFile structure to markdown content.
180
- */
181
150
  export function serializeComments(file: CommentFile): string {
182
151
  const lines: string[] = [];
183
152
 
184
- // YAML front matter
185
153
  lines.push("---");
186
154
  lines.push(`source: ${file.source}`);
187
155
  lines.push(`hash: ${file.hash}`);
@@ -189,7 +157,6 @@ export function serializeComments(file: CommentFile): string {
189
157
  lines.push("---");
190
158
  lines.push("");
191
159
 
192
- // Comments
193
160
  for (const comment of file.comments) {
194
161
  lines.push(serializeComment(comment));
195
162
  lines.push("");
@@ -200,28 +167,21 @@ export function serializeComments(file: CommentFile): string {
200
167
  return lines.join("\n");
201
168
  }
202
169
 
203
- /**
204
- * Serialize a single comment to markdown block.
205
- */
206
170
  function serializeComment(comment: Comment): string {
207
171
  const lines: string[] = [];
208
172
 
209
- // Metadata as HTML comment
210
173
  const lineHint = comment.lineHint || "L0";
211
174
  lines.push(`<!-- c:${comment.id}|${lineHint}|${comment.createdAt} -->`);
212
175
 
213
- // Anchor prefix for long selections (used for anchor matching when text is truncated)
214
176
  if (comment.anchorPrefix) {
215
177
  lines.push(`<!-- anchor:${comment.anchorPrefix} -->`);
216
178
  }
217
179
 
218
- // Selected text as blockquote
219
180
  const quotedLines = comment.selectedText
220
181
  .split("\n")
221
182
  .map((line) => `> ${line}`);
222
183
  lines.push(...quotedLines);
223
184
 
224
- // Comment body
225
185
  if (comment.comment) {
226
186
  lines.push("");
227
187
  lines.push(comment.comment);
@@ -230,9 +190,6 @@ function serializeComment(comment: Comment): string {
230
190
  return lines.join("\n");
231
191
  }
232
192
 
233
- /**
234
- * Create a new comment with a generated ID and current timestamp.
235
- */
236
193
  export function createComment(
237
194
  selectedText: string,
238
195
  commentText: string,
@@ -255,7 +212,6 @@ export function createComment(
255
212
  startOffset,
256
213
  endOffset,
257
214
  lineHint,
258
- // Store first N chars for anchor matching when text is truncated
259
215
  anchorPrefix: needsTruncation
260
216
  ? selectedText.slice(0, ANCHOR_PREFIX_LENGTH)
261
217
  : undefined,
package/src/lib/export.ts CHANGED
@@ -1,19 +1,12 @@
1
- import type { Comment, Document } from "../types";
1
+ import type { Comment, Document } from "../schema";
2
2
 
3
- export function generatePrompt(comments: Comment[], fileName: string): string {
4
- const prompt = comments
5
- .map((c) => {
6
- return `---\nSelected text: "${c.selectedText}"\nComment: ${c.comment}`;
7
- })
8
- .join("\n\n");
9
-
10
- return `# Review Comments for ${fileName}\n\n${prompt}`;
3
+ export function formatComment(c: Comment): string {
4
+ const line = c.lineHint ? `[${c.lineHint}] ` : "";
5
+ return `${line}"${c.selectedText}"\n${c.comment}`;
11
6
  }
12
7
 
13
- export function generateRawText(comments: Comment[]): string {
14
- return comments
15
- .map((c) => `${c.selectedText}\n\n${c.comment}`)
16
- .join("\n\n---\n\n");
8
+ export function generatePrompt(comments: Comment[], fileName: string): string {
9
+ return `# Review Comments for ${fileName}\n\n${comments.map(formatComment).join("\n\n---\n\n")}`;
17
10
  }
18
11
 
19
12
  export function exportCommentsAsJson(
@@ -27,6 +20,7 @@ export function exportCommentsAsJson(
27
20
  comments: comments.map((c) => ({
28
21
  selectedText: c.selectedText,
29
22
  comment: c.comment,
23
+ lineHint: c.lineHint,
30
24
  createdAt: c.createdAt,
31
25
  })),
32
26
  };
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { findTextPosition } from "./core";
2
+ import { findTextPosition } from "./resolver";
3
3
 
4
4
  describe("findTextPosition", () => {
5
5
  it("finds single occurrence", () => {