@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.
- package/README.md +0 -3
- package/biome.json +1 -1
- package/bun.lock +43 -185
- package/docs/perf-baseline.md +75 -0
- package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
- package/e2e/perf/add-comment.spec.ts +118 -0
- package/e2e/perf/fixtures/generate.ts +331 -0
- package/e2e/perf/initial-load.spec.ts +49 -0
- package/e2e/perf/perf.setup.ts +23 -0
- package/e2e/perf/perf.teardown.ts +9 -0
- package/e2e/perf/scroll.spec.ts +39 -0
- package/e2e/perf/tab-switch.spec.ts +69 -0
- package/e2e/perf/text-selection.spec.ts +119 -0
- package/e2e/perf/utils/metrics.ts +286 -0
- package/e2e/perf/utils/perf-cli.ts +86 -0
- package/package.json +9 -18
- package/playwright.config.ts +12 -0
- package/src/App.tsx +124 -172
- package/src/{cli/index.ts → cli.ts} +37 -53
- package/src/components/ActionsMenu.tsx +6 -27
- package/src/components/DocumentViewer/DocumentViewer.tsx +77 -106
- package/src/components/DocumentViewer/MermaidDiagram.tsx +6 -7
- package/src/components/Header.tsx +9 -20
- package/src/components/InlineEditor.tsx +5 -5
- package/src/components/MarginNote.tsx +71 -93
- package/src/components/MarginNotes.tsx +7 -34
- package/src/components/RawModal.tsx +9 -8
- package/src/components/ReanchorConfirm.tsx +2 -2
- package/src/components/SettingsModal.tsx +11 -89
- package/src/components/TabBar.tsx +4 -4
- package/src/components/TableOfContents.tsx +5 -5
- package/src/components/comments/CommentInput.tsx +7 -35
- package/src/components/comments/CommentListItem.tsx +9 -11
- package/src/components/comments/CommentManager.tsx +53 -37
- package/src/components/comments/CommentNav.tsx +14 -14
- package/src/components/ui/ActionLink.tsx +14 -18
- package/src/components/ui/Button.tsx +42 -43
- package/src/components/ui/Dialog.tsx +73 -113
- package/src/components/ui/DropdownMenu.tsx +113 -69
- package/src/components/ui/Text.tsx +30 -37
- package/src/contexts/CommentContext.tsx +75 -106
- package/src/contexts/LocaleContext.tsx +45 -4
- package/src/contexts/PositionsContext.tsx +16 -0
- package/src/contexts/SettingsContext.tsx +133 -0
- package/src/hooks/useClickOutside.ts +0 -4
- package/src/hooks/useCommentNavigation.ts +6 -29
- package/src/hooks/useComments.ts +6 -18
- package/src/hooks/useDocument.ts +35 -34
- package/src/hooks/useHeadings.test.ts +8 -50
- package/src/hooks/useHeadings.ts +5 -88
- package/src/hooks/useScrollSpy.ts +10 -14
- package/src/hooks/useTextSelection.ts +1 -38
- package/src/lib/__fixtures__/bench-data.ts +1 -41
- package/src/lib/anchor.bench.ts +57 -67
- package/src/lib/anchor.test.ts +5 -1
- package/src/lib/anchor.ts +13 -93
- package/src/lib/comment-storage.test.ts +4 -4
- package/src/lib/comment-storage.ts +2 -46
- package/src/lib/export.ts +7 -13
- package/src/lib/highlight/core.test.ts +1 -1
- package/src/lib/highlight/dom.ts +5 -68
- package/src/lib/highlight/highlighter.ts +102 -262
- package/src/lib/highlight/resolver.ts +112 -0
- package/src/lib/highlight/types.ts +0 -35
- package/src/lib/highlight/worker.ts +45 -0
- package/src/lib/i18n/en.ts +1 -50
- package/src/lib/i18n/ja.ts +1 -50
- package/src/lib/i18n/types.ts +1 -49
- package/src/lib/margin-layout.ts +5 -27
- package/src/lib/positions.ts +150 -0
- package/src/lib/utils.ts +2 -19
- package/src/schema.ts +81 -0
- package/src/{server/index.ts → server.ts} +74 -74
- package/src/{store/index.ts → store.ts} +14 -46
- package/vite.config.ts +8 -0
- package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
- package/src/components/DocumentViewer/InlineCode.tsx +0 -60
- package/src/components/DocumentViewer/index.ts +0 -1
- package/src/components/FloatingTOC.tsx +0 -61
- package/src/components/ShortcutCapture.tsx +0 -48
- package/src/components/ShortcutList.tsx +0 -198
- package/src/components/comments/CommentMinimap.tsx +0 -62
- package/src/components/ui/ActionBar.tsx +0 -16
- package/src/components/ui/SeparatorDot.tsx +0 -9
- package/src/contexts/LayoutContext.tsx +0 -88
- package/src/hooks/useClipboard.ts +0 -82
- package/src/hooks/useEditorScheme.ts +0 -51
- package/src/hooks/useFontPreference.ts +0 -59
- package/src/hooks/useKeybindings.ts +0 -108
- package/src/hooks/useKeyboardShortcuts.ts +0 -63
- package/src/hooks/useLayoutMode.ts +0 -44
- package/src/hooks/useLocalePreference.ts +0 -42
- package/src/hooks/useReanchorMode.ts +0 -33
- package/src/hooks/useScrollMetrics.ts +0 -56
- package/src/hooks/useThemePreference.ts +0 -66
- package/src/lib/comment-storage.bench.ts +0 -63
- package/src/lib/context.bench.ts +0 -41
- package/src/lib/context.test.ts +0 -224
- package/src/lib/context.ts +0 -193
- package/src/lib/editor-links.ts +0 -59
- package/src/lib/export.bench.ts +0 -35
- package/src/lib/highlight/colors.ts +0 -37
- package/src/lib/highlight/core.ts +0 -54
- package/src/lib/highlight/index.ts +0 -23
- package/src/lib/highlight/script-builder.ts +0 -485
- package/src/lib/html-processor.test.tsx +0 -170
- package/src/lib/html-processor.tsx +0 -95
- package/src/lib/i18n/completeness.test.ts +0 -51
- package/src/lib/i18n/translations.test.ts +0 -39
- package/src/lib/layout-constants.ts +0 -12
- package/src/lib/margin-layout.bench.ts +0 -28
- package/src/lib/scroll.test.ts +0 -118
- package/src/lib/scroll.ts +0 -47
- package/src/lib/shortcut-registry.test.ts +0 -173
- package/src/lib/shortcut-registry.ts +0 -209
- package/src/lib/utils.test.ts +0 -110
- package/src/store/index.test.ts +0 -242
- package/src/types/index.ts +0 -127
package/src/lib/anchor.ts
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
|
-
import { type Anchor, AnchorConfidences } from "../
|
|
1
|
+
import { type Anchor, AnchorConfidences } from "../schema";
|
|
2
2
|
import { getLineNumber } from "./comment-storage";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
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,
|
|
91
|
-
currRow[i - 1] + 1,
|
|
92
|
-
prevRow[i - 1] + cost,
|
|
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
|
|
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 "../
|
|
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-
|
|
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-
|
|
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-
|
|
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 "../
|
|
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}
|
|
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 "../
|
|
1
|
+
import type { Comment, Document } from "../schema";
|
|
2
2
|
|
|
3
|
-
export function
|
|
4
|
-
const
|
|
5
|
-
|
|
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
|
|
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
|
};
|