@peaske7/readit 0.1.8 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/CLAUDE.md +118 -76
- package/.claude/commands/review.md +1 -1
- package/.claude/roadmap.md +32 -9
- package/.claude/user-stories.md +100 -15
- package/AGENTS.md +30 -26
- package/Makefile +32 -0
- package/README.md +90 -5
- package/biome.json +18 -8
- package/bun.lock +426 -710
- package/bunfig.toml +2 -0
- package/docs/perf-baseline.md +130 -0
- package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
- package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
- package/e2e/comments.spec.ts +14 -58
- package/e2e/document-load.spec.ts +1 -23
- package/e2e/export.spec.ts +4 -4
- package/e2e/perf/add-comment.spec.ts +116 -0
- package/e2e/perf/fixtures/generate.ts +327 -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/screenshot-final.png +0 -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 +350 -0
- package/e2e/perf/utils/perf-cli.ts +86 -0
- package/e2e/persistence-file.spec.ts +41 -26
- package/e2e/utils/selection.ts +17 -73
- package/go/cmd/readit/main.go +416 -0
- package/go/go.mod +20 -0
- package/go/go.sum +41 -0
- package/go/internal/server/anchor.go +302 -0
- package/go/internal/server/anchor_test.go +111 -0
- package/go/internal/server/comments.go +390 -0
- package/go/internal/server/documents.go +113 -0
- package/go/internal/server/embed.go +17 -0
- package/go/internal/server/headings.go +33 -0
- package/go/internal/server/headings_test.go +75 -0
- package/go/internal/server/htmltext.go +123 -0
- package/go/internal/server/markdown.go +157 -0
- package/go/internal/server/markdown_bench_test.go +42 -0
- package/go/internal/server/markdown_test.go +79 -0
- package/go/internal/server/server.go +453 -0
- package/go/internal/server/server_bench_test.go +122 -0
- package/go/internal/server/settings.go +110 -0
- package/go/internal/server/sse.go +140 -0
- package/go/internal/server/storage.go +275 -0
- package/go/internal/server/storage_test.go +118 -0
- package/go/internal/server/template.go +66 -0
- package/go/internal/server/types.go +101 -0
- package/go/internal/server/watcher.go +74 -0
- package/index.html +4 -14
- package/nvim-readit/lua/readit/health.lua +64 -0
- package/nvim-readit/lua/readit/init.lua +463 -0
- package/nvim-readit/plugin/readit.lua +19 -0
- package/package.json +24 -41
- package/playwright.config.ts +12 -0
- package/shell/_readit +158 -0
- package/shell/readit.zsh +87 -0
- package/src/App.svelte +881 -0
- package/src/{cli/index.ts → cli.ts} +216 -70
- package/src/components/ActionsMenu.svelte +95 -0
- package/src/components/CommentBadge.svelte +67 -0
- package/src/components/CommentErrorBanner.svelte +33 -0
- package/src/components/CommentInput.svelte +75 -0
- package/src/components/CommentListItem.svelte +95 -0
- package/src/components/CommentManager.svelte +129 -0
- package/src/components/CommentNav.svelte +109 -0
- package/src/components/DocumentViewer.svelte +218 -0
- package/src/components/FloatingComment.svelte +107 -0
- package/src/components/Header.svelte +76 -0
- package/src/components/InlineEditor.svelte +72 -0
- package/src/components/MarginNote.svelte +167 -0
- package/src/components/MarginNotesContainer.svelte +33 -0
- package/src/components/RawModal.svelte +126 -0
- package/src/components/ReanchorConfirm.svelte +30 -0
- package/src/components/SettingsModal.svelte +220 -0
- package/src/components/ShortcutCapture.svelte +82 -0
- package/src/components/ShortcutList.svelte +145 -0
- package/src/components/TabBar.svelte +52 -0
- package/src/components/TableOfContents.svelte +125 -0
- package/src/components/ui/ActionLink.svelte +40 -0
- package/src/components/ui/Button.svelte +53 -0
- package/src/components/ui/Dialog.svelte +97 -0
- package/src/components/ui/DropdownMenu.svelte +85 -0
- package/src/components/ui/DropdownMenuItem.svelte +38 -0
- package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
- package/src/components/ui/Text.svelte +42 -0
- package/src/env.d.ts +6 -0
- package/src/index.css +36 -166
- package/src/lib/__fixtures__/bench-data.ts +1 -54
- package/src/lib/anchor.bench.ts +47 -68
- package/src/lib/anchor.test.ts +5 -9
- package/src/lib/anchor.ts +9 -93
- package/src/lib/comment-storage.bench.ts +6 -20
- package/src/lib/comment-storage.test.ts +45 -37
- package/src/lib/comment-storage.ts +23 -64
- package/src/lib/export.bench.ts +9 -23
- package/src/lib/export.ts +7 -14
- package/src/lib/headings.test.ts +103 -0
- package/src/lib/headings.ts +44 -0
- package/src/lib/highlight/core.test.ts +1 -6
- package/src/lib/highlight/dom.ts +53 -280
- package/src/lib/highlight/highlight-registry.ts +221 -0
- package/src/lib/highlight/highlight.bench.ts +92 -0
- package/src/lib/highlight/highlighter.ts +122 -302
- package/src/lib/highlight/{core.ts → resolver.ts} +3 -19
- package/src/lib/highlight/types.ts +0 -40
- package/src/lib/html-text.test.ts +162 -0
- package/src/lib/html-text.ts +161 -0
- package/src/lib/i18n/en.ts +13 -36
- package/src/lib/i18n/ja.ts +14 -37
- package/src/lib/i18n/types.ts +13 -36
- package/src/lib/margin-layout.bench.ts +48 -15
- package/src/lib/margin-layout.ts +2 -31
- package/src/lib/markdown-renderer.test.ts +154 -0
- package/src/lib/markdown-renderer.ts +177 -0
- package/src/lib/mermaid-config.ts +38 -0
- package/src/lib/mermaid-renderer.ts +162 -0
- package/src/lib/mermaid-worker.ts +60 -0
- package/src/lib/positions.ts +157 -0
- package/src/lib/shortcut-registry.ts +138 -103
- package/src/lib/utils.ts +2 -48
- package/src/main.ts +16 -0
- package/src/schema.ts +92 -0
- package/src/{server/index.ts → server.ts} +427 -163
- package/src/stores/app.svelte.ts +231 -0
- package/src/stores/locale.svelte.ts +46 -0
- package/src/stores/settings.svelte.ts +90 -0
- package/src/stores/shortcuts.svelte.ts +104 -0
- package/src/stores/ui.svelte.ts +12 -0
- package/src/template.ts +104 -0
- package/src/test-setup.ts +47 -0
- package/svelte.config.js +5 -0
- package/tsconfig.json +2 -2
- package/vite.config.ts +31 -3
- package/vscode-readit/.mcp.json +7 -0
- package/vscode-readit/.vscodeignore +7 -0
- package/vscode-readit/bun.lock +78 -0
- package/vscode-readit/icon.svg +10 -0
- package/vscode-readit/package.json +110 -0
- package/vscode-readit/src/extension.ts +117 -0
- package/vscode-readit/src/server-manager.ts +272 -0
- package/vscode-readit/src/webview-provider.ts +204 -0
- package/vscode-readit/tsconfig.json +20 -0
- package/e2e/fixtures/sample.html +0 -13
- package/src/App.tsx +0 -416
- package/src/components/ActionsMenu.tsx +0 -112
- package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
- package/src/components/DocumentViewer/DocumentViewer.tsx +0 -259
- package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
- package/src/components/DocumentViewer/InlineCode.tsx +0 -60
- package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -137
- package/src/components/DocumentViewer/index.ts +0 -1
- package/src/components/FloatingTOC.tsx +0 -61
- package/src/components/Header.tsx +0 -65
- package/src/components/InlineEditor.tsx +0 -74
- package/src/components/MarginNote.tsx +0 -207
- package/src/components/MarginNotes.tsx +0 -50
- package/src/components/RawModal.tsx +0 -143
- package/src/components/ReanchorConfirm.tsx +0 -36
- package/src/components/SettingsModal.tsx +0 -310
- package/src/components/ShortcutCapture.tsx +0 -48
- package/src/components/ShortcutList.tsx +0 -198
- package/src/components/TabBar.tsx +0 -60
- package/src/components/TableOfContents.tsx +0 -108
- package/src/components/comments/CommentBadge.tsx +0 -49
- package/src/components/comments/CommentInput.tsx +0 -114
- package/src/components/comments/CommentListItem.tsx +0 -92
- package/src/components/comments/CommentManager.tsx +0 -113
- package/src/components/comments/CommentMinimap.tsx +0 -62
- package/src/components/comments/CommentNav.tsx +0 -109
- package/src/components/ui/ActionBar.tsx +0 -16
- package/src/components/ui/ActionLink.tsx +0 -32
- package/src/components/ui/Button.tsx +0 -55
- package/src/components/ui/Dialog.tsx +0 -156
- package/src/components/ui/DropdownMenu.tsx +0 -114
- package/src/components/ui/SeparatorDot.tsx +0 -9
- package/src/components/ui/Text.tsx +0 -54
- package/src/contexts/CommentContext.tsx +0 -229
- package/src/contexts/LayoutContext.tsx +0 -88
- package/src/contexts/LocaleContext.tsx +0 -35
- package/src/hooks/useClickOutside.ts +0 -35
- package/src/hooks/useClipboard.ts +0 -82
- package/src/hooks/useCommentNavigation.ts +0 -130
- package/src/hooks/useComments.ts +0 -323
- package/src/hooks/useDocument.ts +0 -156
- package/src/hooks/useEditorScheme.ts +0 -51
- package/src/hooks/useFontPreference.ts +0 -59
- package/src/hooks/useHeadings.test.ts +0 -159
- package/src/hooks/useHeadings.ts +0 -129
- 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/useScrollSpy.ts +0 -81
- package/src/hooks/useTextSelection.ts +0 -123
- package/src/hooks/useThemePreference.ts +0 -66
- 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/highlight/colors.ts +0 -37
- 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/scroll.test.ts +0 -118
- package/src/lib/scroll.ts +0 -47
- package/src/lib/shortcut-registry.test.ts +0 -173
- package/src/lib/utils.test.ts +0 -110
- package/src/main.tsx +0 -13
- package/src/store/index.test.ts +0 -242
- package/src/store/index.ts +0 -254
- package/src/types/index.ts +0 -127
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import type * as os from "node:os";
|
|
2
1
|
import { describe, expect, it, vi } from "vitest";
|
|
3
|
-
import type { CommentFile } from "../
|
|
2
|
+
import type { CommentFile } from "../schema";
|
|
4
3
|
import { COMMENT_FILE_LARGE } from "./__fixtures__/bench-data";
|
|
5
|
-
|
|
4
|
+
|
|
5
|
+
vi.mock("node:os", () => ({
|
|
6
|
+
homedir: () => "/home/testuser",
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
const {
|
|
6
10
|
computeHash,
|
|
7
11
|
createComment,
|
|
8
12
|
getCommentPath,
|
|
@@ -11,16 +15,7 @@ import {
|
|
|
11
15
|
parseCommentFile,
|
|
12
16
|
serializeComments,
|
|
13
17
|
truncateSelection,
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
// Mock os.homedir for consistent test results
|
|
17
|
-
vi.mock("node:os", async () => {
|
|
18
|
-
const actual = await vi.importActual<typeof os>("node:os");
|
|
19
|
-
return {
|
|
20
|
-
...actual,
|
|
21
|
-
homedir: vi.fn(() => "/home/testuser"),
|
|
22
|
-
};
|
|
23
|
-
});
|
|
18
|
+
} = await import("./comment-storage");
|
|
24
19
|
|
|
25
20
|
describe("getCommentPath", () => {
|
|
26
21
|
it("handles absolute path", () => {
|
|
@@ -37,11 +32,6 @@ describe("getCommentPath", () => {
|
|
|
37
32
|
);
|
|
38
33
|
});
|
|
39
34
|
|
|
40
|
-
it("handles HTML files", () => {
|
|
41
|
-
const result = getCommentPath("/docs/api.html");
|
|
42
|
-
expect(result).toBe("/home/testuser/.readit/comments/docs/api.comments.md");
|
|
43
|
-
});
|
|
44
|
-
|
|
45
35
|
it("handles root path", () => {
|
|
46
36
|
const result = getCommentPath("/file.md");
|
|
47
37
|
expect(result).toBe("/home/testuser/.readit/comments/file.comments.md");
|
|
@@ -103,7 +93,6 @@ describe("getLineNumber", () => {
|
|
|
103
93
|
});
|
|
104
94
|
|
|
105
95
|
it("handles many lines", () => {
|
|
106
|
-
// "line\n" repeated 100 times, each segment is 5 chars
|
|
107
96
|
const content = Array(100).fill("line").join("\n");
|
|
108
97
|
expect(getLineNumber(content, 0)).toBe(1); // Start of line 1
|
|
109
98
|
expect(getLineNumber(content, 250)).toBe(51); // After 50 newlines = line 51
|
|
@@ -124,7 +113,7 @@ describe("getLineHint", () => {
|
|
|
124
113
|
|
|
125
114
|
it("returns range hint for multiple lines", () => {
|
|
126
115
|
const content = "line one\nline two\nline three";
|
|
127
|
-
expect(getLineHint(content, 0, 20)).toBe("L1-
|
|
116
|
+
expect(getLineHint(content, 0, 20)).toBe("L1-L3");
|
|
128
117
|
});
|
|
129
118
|
});
|
|
130
119
|
|
|
@@ -169,7 +158,6 @@ This is my comment.
|
|
|
169
158
|
expect(result.comments[0].selectedText).toBe("selected text here");
|
|
170
159
|
expect(result.comments[0].comment).toBe("This is my comment.");
|
|
171
160
|
expect(result.comments[0].lineHint).toBe("L42");
|
|
172
|
-
expect(result.comments[0].createdAt).toBe("2024-12-24T10:30:00Z");
|
|
173
161
|
});
|
|
174
162
|
|
|
175
163
|
it("parses multiple comments", () => {
|
|
@@ -339,7 +327,6 @@ describe("serializeComments", () => {
|
|
|
339
327
|
id: "12345678",
|
|
340
328
|
selectedText: "selected text",
|
|
341
329
|
comment: "My comment",
|
|
342
|
-
createdAt: "2024-12-24T10:30:00Z",
|
|
343
330
|
lineHint: "L42",
|
|
344
331
|
startOffset: 100,
|
|
345
332
|
endOffset: 113,
|
|
@@ -347,7 +334,7 @@ describe("serializeComments", () => {
|
|
|
347
334
|
],
|
|
348
335
|
};
|
|
349
336
|
const result = serializeComments(file);
|
|
350
|
-
expect(result).toContain("<!-- c:12345678|L42
|
|
337
|
+
expect(result).toContain("<!-- c:12345678|L42 -->");
|
|
351
338
|
expect(result).toContain("> selected text");
|
|
352
339
|
expect(result).toContain("My comment");
|
|
353
340
|
});
|
|
@@ -362,8 +349,7 @@ describe("serializeComments", () => {
|
|
|
362
349
|
id: "12345678",
|
|
363
350
|
selectedText: "line one\nline two",
|
|
364
351
|
comment: "Comment",
|
|
365
|
-
|
|
366
|
-
lineHint: "L42-43",
|
|
352
|
+
lineHint: "L42-L43",
|
|
367
353
|
startOffset: 100,
|
|
368
354
|
endOffset: 120,
|
|
369
355
|
},
|
|
@@ -384,7 +370,6 @@ describe("serializeComments", () => {
|
|
|
384
370
|
id: "12345678",
|
|
385
371
|
selectedText: "truncated text...",
|
|
386
372
|
comment: "Comment",
|
|
387
|
-
createdAt: "2024-12-24T10:30:00Z",
|
|
388
373
|
lineHint: "L42",
|
|
389
374
|
startOffset: 100,
|
|
390
375
|
endOffset: 500,
|
|
@@ -408,7 +393,6 @@ describe("serializeComments", () => {
|
|
|
408
393
|
id: "12345678",
|
|
409
394
|
selectedText: "short text",
|
|
410
395
|
comment: "Comment",
|
|
411
|
-
createdAt: "2024-12-24T10:30:00Z",
|
|
412
396
|
lineHint: "L42",
|
|
413
397
|
startOffset: 100,
|
|
414
398
|
endOffset: 110,
|
|
@@ -429,7 +413,6 @@ describe("serializeComments", () => {
|
|
|
429
413
|
id: "12345678",
|
|
430
414
|
selectedText: "selected text",
|
|
431
415
|
comment: "My comment",
|
|
432
|
-
createdAt: "2024-12-24T10:30:00Z",
|
|
433
416
|
lineHint: "L42",
|
|
434
417
|
startOffset: 100,
|
|
435
418
|
endOffset: 113,
|
|
@@ -438,8 +421,7 @@ describe("serializeComments", () => {
|
|
|
438
421
|
id: "87654321",
|
|
439
422
|
selectedText: "another\nmultiline\nselection",
|
|
440
423
|
comment: "Another comment with\n\nmultiple paragraphs.",
|
|
441
|
-
|
|
442
|
-
lineHint: "L50-52",
|
|
424
|
+
lineHint: "L50-L52",
|
|
443
425
|
startOffset: 200,
|
|
444
426
|
endOffset: 230,
|
|
445
427
|
},
|
|
@@ -460,10 +442,42 @@ describe("serializeComments", () => {
|
|
|
460
442
|
original.comments[i].selectedText,
|
|
461
443
|
);
|
|
462
444
|
expect(parsed.comments[i].lineHint).toBe(original.comments[i].lineHint);
|
|
463
|
-
expect(parsed.comments[i].createdAt).toBe(original.comments[i].createdAt);
|
|
464
445
|
}
|
|
465
446
|
});
|
|
466
447
|
|
|
448
|
+
it("roundtrip: survives a markdown horizontal rule inside a comment body", () => {
|
|
449
|
+
const original: CommentFile = {
|
|
450
|
+
source: "/test.md",
|
|
451
|
+
hash: "abc123",
|
|
452
|
+
version: 1,
|
|
453
|
+
comments: [
|
|
454
|
+
{
|
|
455
|
+
id: "aaaaaaaa",
|
|
456
|
+
selectedText: "first selected",
|
|
457
|
+
comment: "before\n\n---\n\nafter",
|
|
458
|
+
lineHint: "L1",
|
|
459
|
+
startOffset: 0,
|
|
460
|
+
endOffset: 14,
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
id: "bbbbbbbb",
|
|
464
|
+
selectedText: "second selected",
|
|
465
|
+
comment: "second body",
|
|
466
|
+
lineHint: "L5",
|
|
467
|
+
startOffset: 50,
|
|
468
|
+
endOffset: 65,
|
|
469
|
+
},
|
|
470
|
+
],
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
const serialized = serializeComments(original);
|
|
474
|
+
const parsed = parseCommentFile(serialized);
|
|
475
|
+
|
|
476
|
+
expect(parsed.comments).toHaveLength(2);
|
|
477
|
+
expect(parsed.comments[0].id).toBe("aaaaaaaa");
|
|
478
|
+
expect(parsed.comments[1].id).toBe("bbbbbbbb");
|
|
479
|
+
});
|
|
480
|
+
|
|
467
481
|
it("roundtrip: preserves anchorPrefix through serialize/parse", () => {
|
|
468
482
|
const original: CommentFile = {
|
|
469
483
|
source: "/test.md",
|
|
@@ -474,7 +488,6 @@ describe("serializeComments", () => {
|
|
|
474
488
|
id: "12345678",
|
|
475
489
|
selectedText: "truncated text\n...\nend of text",
|
|
476
490
|
comment: "Comment on long selection",
|
|
477
|
-
createdAt: "2024-12-24T10:30:00Z",
|
|
478
491
|
lineHint: "L42",
|
|
479
492
|
startOffset: 100,
|
|
480
493
|
endOffset: 2000,
|
|
@@ -514,11 +527,6 @@ describe("createComment", () => {
|
|
|
514
527
|
expect(comment.lineHint).toBe("L2");
|
|
515
528
|
});
|
|
516
529
|
|
|
517
|
-
it("generates ISO timestamp", () => {
|
|
518
|
-
const comment = createComment("text", "comment", 0, 4, "text");
|
|
519
|
-
expect(comment.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
|
520
|
-
});
|
|
521
|
-
|
|
522
530
|
it("truncates very long selections", () => {
|
|
523
531
|
const longText = "a".repeat(2000);
|
|
524
532
|
const comment = createComment(longText, "comment", 0, 2000, longText);
|
|
@@ -1,17 +1,14 @@
|
|
|
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;
|
|
8
8
|
const MAX_SELECTION_LENGTH = 1000;
|
|
9
9
|
const TRUNCATION_MARKER = "\n...\n";
|
|
10
|
-
const ANCHOR_PREFIX_LENGTH = 200;
|
|
10
|
+
const ANCHOR_PREFIX_LENGTH = 200;
|
|
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;
|
|
@@ -22,18 +19,9 @@ export function truncateSelection(text: string): string {
|
|
|
22
19
|
return text.slice(0, half) + TRUNCATION_MARKER + text.slice(-half);
|
|
23
20
|
}
|
|
24
21
|
|
|
25
|
-
/**
|
|
26
|
-
* Compute the path where comments for a source file should be stored.
|
|
27
|
-
* Comments are stored in ~/.readit/comments/{absolute-path-structure}/{filename}.comments.md
|
|
28
|
-
*/
|
|
29
22
|
export function getCommentPath(sourcePath: string): string {
|
|
30
|
-
// Resolve to absolute path
|
|
31
23
|
const absolute = path.resolve(sourcePath);
|
|
32
|
-
|
|
33
|
-
// Remove leading slash and drive letter (Windows)
|
|
34
24
|
const normalized = absolute.replace(/^\//, "").replace(/^[A-Z]:[\\/]/, "");
|
|
35
|
-
|
|
36
|
-
// Get filename without extension, add .comments.md
|
|
37
25
|
const ext = path.extname(normalized);
|
|
38
26
|
const withoutExt = normalized.slice(0, -ext.length || undefined);
|
|
39
27
|
|
|
@@ -45,9 +33,6 @@ export function getCommentPath(sourcePath: string): string {
|
|
|
45
33
|
);
|
|
46
34
|
}
|
|
47
35
|
|
|
48
|
-
/**
|
|
49
|
-
* Compute SHA-256 hash of content, returning first 16 characters.
|
|
50
|
-
*/
|
|
51
36
|
export function computeHash(content: string): string {
|
|
52
37
|
return crypto
|
|
53
38
|
.createHash("sha256")
|
|
@@ -56,18 +41,12 @@ export function computeHash(content: string): string {
|
|
|
56
41
|
.slice(0, HASH_LENGTH);
|
|
57
42
|
}
|
|
58
43
|
|
|
59
|
-
/**
|
|
60
|
-
* Get line number (1-indexed) for a character offset in content.
|
|
61
|
-
*/
|
|
62
44
|
export function getLineNumber(content: string, offset: number): number {
|
|
63
45
|
if (offset <= 0 || content.length === 0) return 1;
|
|
64
46
|
const clampedOffset = Math.min(offset, content.length);
|
|
65
47
|
return content.slice(0, clampedOffset).split("\n").length;
|
|
66
48
|
}
|
|
67
49
|
|
|
68
|
-
/**
|
|
69
|
-
* Get line range string for a selection (e.g., "L42" or "L42-45").
|
|
70
|
-
*/
|
|
71
50
|
export function getLineHint(
|
|
72
51
|
content: string,
|
|
73
52
|
startOffset: number,
|
|
@@ -75,12 +54,9 @@ export function getLineHint(
|
|
|
75
54
|
): string {
|
|
76
55
|
const startLine = getLineNumber(content, startOffset);
|
|
77
56
|
const endLine = getLineNumber(content, endOffset);
|
|
78
|
-
return startLine === endLine ? `L${startLine}` : `L${startLine}
|
|
57
|
+
return startLine === endLine ? `L${startLine}` : `L${startLine}-L${endLine}`;
|
|
79
58
|
}
|
|
80
59
|
|
|
81
|
-
/**
|
|
82
|
-
* Parse a comment file's markdown content into a CommentFile structure.
|
|
83
|
-
*/
|
|
84
60
|
export function parseCommentFile(content: string): CommentFile {
|
|
85
61
|
const result: CommentFile = {
|
|
86
62
|
source: "",
|
|
@@ -93,7 +69,6 @@ export function parseCommentFile(content: string): CommentFile {
|
|
|
93
69
|
return result;
|
|
94
70
|
}
|
|
95
71
|
|
|
96
|
-
// Parse YAML front matter
|
|
97
72
|
const frontMatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
98
73
|
if (frontMatterMatch) {
|
|
99
74
|
const frontMatter = frontMatterMatch[1];
|
|
@@ -105,7 +80,6 @@ export function parseCommentFile(content: string): CommentFile {
|
|
|
105
80
|
if (hashMatch) result.hash = hashMatch[1].trim();
|
|
106
81
|
if (versionMatch) result.version = Number.parseInt(versionMatch[1], 10);
|
|
107
82
|
|
|
108
|
-
// Validate version compatibility
|
|
109
83
|
if (result.version > FORMAT_VERSION) {
|
|
110
84
|
throw new Error(
|
|
111
85
|
`Comment file requires readit v${result.version} or higher. ` +
|
|
@@ -114,11 +88,23 @@ export function parseCommentFile(content: string): CommentFile {
|
|
|
114
88
|
}
|
|
115
89
|
}
|
|
116
90
|
|
|
117
|
-
// Remove front matter and split by separator
|
|
118
91
|
const bodyContent = content.replace(/^---\n[\s\S]*?\n---\n*/, "");
|
|
119
|
-
const blocks = bodyContent.split(/\n---\n/).filter((block) => block.trim());
|
|
120
92
|
|
|
121
|
-
|
|
93
|
+
const markerRe = /<!--\s*c:[^|]+\|[^|>\s]+(?:\|[^>]*)?\s*-->/g;
|
|
94
|
+
const markerStarts: number[] = [];
|
|
95
|
+
for (const m of bodyContent.matchAll(markerRe)) {
|
|
96
|
+
if (m.index !== undefined) markerStarts.push(m.index);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (let i = 0; i < markerStarts.length; i++) {
|
|
100
|
+
const start = markerStarts[i];
|
|
101
|
+
const end =
|
|
102
|
+
i + 1 < markerStarts.length ? markerStarts[i + 1] : bodyContent.length;
|
|
103
|
+
const block = bodyContent
|
|
104
|
+
.slice(start, end)
|
|
105
|
+
.replace(/\n+---\s*$/, "")
|
|
106
|
+
.trim();
|
|
107
|
+
if (!block) continue;
|
|
122
108
|
const comment = parseCommentBlock(block);
|
|
123
109
|
if (comment) {
|
|
124
110
|
result.comments.push(comment);
|
|
@@ -128,35 +114,29 @@ export function parseCommentFile(content: string): CommentFile {
|
|
|
128
114
|
return result;
|
|
129
115
|
}
|
|
130
116
|
|
|
131
|
-
/**
|
|
132
|
-
* Parse a single comment block.
|
|
133
|
-
*/
|
|
134
117
|
function parseCommentBlock(block: string): Comment | undefined {
|
|
135
|
-
|
|
136
|
-
|
|
118
|
+
const metadataMatch = block.match(
|
|
119
|
+
/<!--\s*c:([^|]+)\|([^|>\s]+)(?:\|[^>]*)?\s*-->/,
|
|
120
|
+
);
|
|
137
121
|
if (!metadataMatch) {
|
|
138
122
|
return undefined;
|
|
139
123
|
}
|
|
140
124
|
|
|
141
|
-
const [, id, lineHint
|
|
125
|
+
const [, id, lineHint] = metadataMatch;
|
|
142
126
|
|
|
143
|
-
// Extract anchor prefix if present: <!-- anchor:{prefix} -->
|
|
144
127
|
const anchorMatch = block.match(/<!--\s*anchor:(.+?)\s*-->/);
|
|
145
128
|
const anchorPrefix = anchorMatch ? anchorMatch[1] : undefined;
|
|
146
129
|
|
|
147
|
-
// Extract selected text from blockquote
|
|
148
130
|
const blockquoteMatch = block.match(/^>\s*(.+(?:\n>\s*.+)*)$/m);
|
|
149
131
|
if (!blockquoteMatch) {
|
|
150
132
|
return undefined;
|
|
151
133
|
}
|
|
152
134
|
|
|
153
|
-
// Remove the "> " prefix from each line
|
|
154
135
|
const selectedText = blockquoteMatch[1]
|
|
155
136
|
.split("\n")
|
|
156
137
|
.map((line) => line.replace(/^>\s*/, ""))
|
|
157
138
|
.join("\n");
|
|
158
139
|
|
|
159
|
-
// Extract comment body (everything after blockquote)
|
|
160
140
|
const afterBlockquote = block.slice(
|
|
161
141
|
block.indexOf(blockquoteMatch[0]) + blockquoteMatch[0].length,
|
|
162
142
|
);
|
|
@@ -166,22 +146,16 @@ function parseCommentBlock(block: string): Comment | undefined {
|
|
|
166
146
|
id,
|
|
167
147
|
selectedText,
|
|
168
148
|
comment: commentBody,
|
|
169
|
-
createdAt: createdAt.trim(),
|
|
170
149
|
lineHint,
|
|
171
150
|
anchorPrefix,
|
|
172
|
-
// Offsets will be resolved by anchor matching when loading
|
|
173
151
|
startOffset: 0,
|
|
174
152
|
endOffset: 0,
|
|
175
153
|
};
|
|
176
154
|
}
|
|
177
155
|
|
|
178
|
-
/**
|
|
179
|
-
* Serialize a CommentFile structure to markdown content.
|
|
180
|
-
*/
|
|
181
156
|
export function serializeComments(file: CommentFile): string {
|
|
182
157
|
const lines: string[] = [];
|
|
183
158
|
|
|
184
|
-
// YAML front matter
|
|
185
159
|
lines.push("---");
|
|
186
160
|
lines.push(`source: ${file.source}`);
|
|
187
161
|
lines.push(`hash: ${file.hash}`);
|
|
@@ -189,7 +163,6 @@ export function serializeComments(file: CommentFile): string {
|
|
|
189
163
|
lines.push("---");
|
|
190
164
|
lines.push("");
|
|
191
165
|
|
|
192
|
-
// Comments
|
|
193
166
|
for (const comment of file.comments) {
|
|
194
167
|
lines.push(serializeComment(comment));
|
|
195
168
|
lines.push("");
|
|
@@ -200,28 +173,21 @@ export function serializeComments(file: CommentFile): string {
|
|
|
200
173
|
return lines.join("\n");
|
|
201
174
|
}
|
|
202
175
|
|
|
203
|
-
/**
|
|
204
|
-
* Serialize a single comment to markdown block.
|
|
205
|
-
*/
|
|
206
176
|
function serializeComment(comment: Comment): string {
|
|
207
177
|
const lines: string[] = [];
|
|
208
178
|
|
|
209
|
-
// Metadata as HTML comment
|
|
210
179
|
const lineHint = comment.lineHint || "L0";
|
|
211
|
-
lines.push(`<!-- c:${comment.id}|${lineHint}
|
|
180
|
+
lines.push(`<!-- c:${comment.id}|${lineHint} -->`);
|
|
212
181
|
|
|
213
|
-
// Anchor prefix for long selections (used for anchor matching when text is truncated)
|
|
214
182
|
if (comment.anchorPrefix) {
|
|
215
183
|
lines.push(`<!-- anchor:${comment.anchorPrefix} -->`);
|
|
216
184
|
}
|
|
217
185
|
|
|
218
|
-
// Selected text as blockquote
|
|
219
186
|
const quotedLines = comment.selectedText
|
|
220
187
|
.split("\n")
|
|
221
188
|
.map((line) => `> ${line}`);
|
|
222
189
|
lines.push(...quotedLines);
|
|
223
190
|
|
|
224
|
-
// Comment body
|
|
225
191
|
if (comment.comment) {
|
|
226
192
|
lines.push("");
|
|
227
193
|
lines.push(comment.comment);
|
|
@@ -230,9 +196,6 @@ function serializeComment(comment: Comment): string {
|
|
|
230
196
|
return lines.join("\n");
|
|
231
197
|
}
|
|
232
198
|
|
|
233
|
-
/**
|
|
234
|
-
* Create a new comment with a generated ID and current timestamp.
|
|
235
|
-
*/
|
|
236
199
|
export function createComment(
|
|
237
200
|
selectedText: string,
|
|
238
201
|
commentText: string,
|
|
@@ -242,8 +205,6 @@ export function createComment(
|
|
|
242
205
|
): Comment {
|
|
243
206
|
const id = crypto.randomUUID().slice(0, 8);
|
|
244
207
|
const lineHint = getLineHint(sourceContent, startOffset, endOffset);
|
|
245
|
-
const now = new Date();
|
|
246
|
-
const createdAt = now.toISOString();
|
|
247
208
|
|
|
248
209
|
const needsTruncation = selectedText.length > MAX_SELECTION_LENGTH;
|
|
249
210
|
|
|
@@ -251,11 +212,9 @@ export function createComment(
|
|
|
251
212
|
id,
|
|
252
213
|
selectedText: truncateSelection(selectedText),
|
|
253
214
|
comment: commentText,
|
|
254
|
-
createdAt,
|
|
255
215
|
startOffset,
|
|
256
216
|
endOffset,
|
|
257
217
|
lineHint,
|
|
258
|
-
// Store first N chars for anchor matching when text is truncated
|
|
259
218
|
anchorPrefix: needsTruncation
|
|
260
219
|
? selectedText.slice(0, ANCHOR_PREFIX_LENGTH)
|
|
261
220
|
: undefined,
|
package/src/lib/export.bench.ts
CHANGED
|
@@ -1,35 +1,21 @@
|
|
|
1
1
|
import { bench, describe } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
COMMENTS_10,
|
|
5
|
-
COMMENTS_50,
|
|
6
|
-
} from "./__fixtures__/bench-data";
|
|
7
|
-
import { generatePrompt, generateRawText } from "./export";
|
|
2
|
+
import { COMMENTS_10, COMMENTS_50 } from "./__fixtures__/bench-data";
|
|
3
|
+
import { formatComment, generatePrompt } from "./export";
|
|
8
4
|
|
|
9
|
-
describe("
|
|
10
|
-
|
|
11
|
-
generatePrompt(COMMENTS_1, "test.md");
|
|
12
|
-
});
|
|
5
|
+
describe("formatComment", () => {
|
|
6
|
+
const comment = COMMENTS_10[0];
|
|
13
7
|
|
|
14
|
-
bench("
|
|
15
|
-
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
bench("50 comments", () => {
|
|
19
|
-
generatePrompt(COMMENTS_50, "test.md");
|
|
8
|
+
bench("single comment", () => {
|
|
9
|
+
formatComment(comment);
|
|
20
10
|
});
|
|
21
11
|
});
|
|
22
12
|
|
|
23
|
-
describe("
|
|
24
|
-
bench("1 comment", () => {
|
|
25
|
-
generateRawText(COMMENTS_1);
|
|
26
|
-
});
|
|
27
|
-
|
|
13
|
+
describe("generatePrompt", () => {
|
|
28
14
|
bench("10 comments", () => {
|
|
29
|
-
|
|
15
|
+
generatePrompt(COMMENTS_10, "benchmark.md");
|
|
30
16
|
});
|
|
31
17
|
|
|
32
18
|
bench("50 comments", () => {
|
|
33
|
-
|
|
19
|
+
generatePrompt(COMMENTS_50, "benchmark.md");
|
|
34
20
|
});
|
|
35
21
|
});
|
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,7 +20,7 @@ export function exportCommentsAsJson(
|
|
|
27
20
|
comments: comments.map((c) => ({
|
|
28
21
|
selectedText: c.selectedText,
|
|
29
22
|
comment: c.comment,
|
|
30
|
-
|
|
23
|
+
lineHint: c.lineHint,
|
|
31
24
|
})),
|
|
32
25
|
};
|
|
33
26
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseMarkdownHeadings } from "./headings";
|
|
3
|
+
|
|
4
|
+
describe("parseMarkdownHeadings", () => {
|
|
5
|
+
it("extracts basic headings", () => {
|
|
6
|
+
const content = `# Heading 1
|
|
7
|
+
## Heading 2
|
|
8
|
+
### Heading 3`;
|
|
9
|
+
|
|
10
|
+
expect(parseMarkdownHeadings(content)).toEqual([
|
|
11
|
+
{ id: "heading-1", text: "Heading 1", level: 1 },
|
|
12
|
+
{ id: "heading-2", text: "Heading 2", level: 2 },
|
|
13
|
+
{ id: "heading-3", text: "Heading 3", level: 3 },
|
|
14
|
+
]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("handles duplicate headings", () => {
|
|
18
|
+
const content = `## Section
|
|
19
|
+
## Section
|
|
20
|
+
## Section`;
|
|
21
|
+
|
|
22
|
+
expect(parseMarkdownHeadings(content)).toEqual([
|
|
23
|
+
{ id: "section", text: "Section", level: 2 },
|
|
24
|
+
{ id: "section-1", text: "Section", level: 2 },
|
|
25
|
+
{ id: "section-2", text: "Section", level: 2 },
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("ignores headings inside fenced code blocks", () => {
|
|
30
|
+
const content = `# Real Heading
|
|
31
|
+
|
|
32
|
+
\`\`\`bash
|
|
33
|
+
# This is a comment, not a heading
|
|
34
|
+
echo "hello"
|
|
35
|
+
\`\`\`
|
|
36
|
+
|
|
37
|
+
## Another Real Heading`;
|
|
38
|
+
|
|
39
|
+
expect(parseMarkdownHeadings(content)).toEqual([
|
|
40
|
+
{ id: "real-heading", text: "Real Heading", level: 1 },
|
|
41
|
+
{ id: "another-real-heading", text: "Another Real Heading", level: 2 },
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("ignores headings inside triple-tilde code blocks", () => {
|
|
46
|
+
const content = `# Real Heading
|
|
47
|
+
|
|
48
|
+
~~~python
|
|
49
|
+
# Python comment
|
|
50
|
+
def foo():
|
|
51
|
+
pass
|
|
52
|
+
~~~
|
|
53
|
+
|
|
54
|
+
## Another Real Heading`;
|
|
55
|
+
|
|
56
|
+
expect(parseMarkdownHeadings(content)).toEqual([
|
|
57
|
+
{ id: "real-heading", text: "Real Heading", level: 1 },
|
|
58
|
+
{ id: "another-real-heading", text: "Another Real Heading", level: 2 },
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("handles multiple code blocks", () => {
|
|
63
|
+
const content = `# Introduction
|
|
64
|
+
|
|
65
|
+
\`\`\`bash
|
|
66
|
+
# Comment 1
|
|
67
|
+
\`\`\`
|
|
68
|
+
|
|
69
|
+
## Methods
|
|
70
|
+
|
|
71
|
+
\`\`\`python
|
|
72
|
+
# Comment 2
|
|
73
|
+
\`\`\`
|
|
74
|
+
|
|
75
|
+
## Results`;
|
|
76
|
+
|
|
77
|
+
expect(parseMarkdownHeadings(content)).toEqual([
|
|
78
|
+
{ id: "introduction", text: "Introduction", level: 1 },
|
|
79
|
+
{ id: "methods", text: "Methods", level: 2 },
|
|
80
|
+
{ id: "results", text: "Results", level: 2 },
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("handles code block with language specifier", () => {
|
|
85
|
+
const content = `# Setup
|
|
86
|
+
|
|
87
|
+
\`\`\`bash
|
|
88
|
+
# Use a custom port
|
|
89
|
+
npx readit document.md --port 3000
|
|
90
|
+
\`\`\`
|
|
91
|
+
|
|
92
|
+
## Usage`;
|
|
93
|
+
|
|
94
|
+
expect(parseMarkdownHeadings(content)).toEqual([
|
|
95
|
+
{ id: "setup", text: "Setup", level: 1 },
|
|
96
|
+
{ id: "usage", text: "Usage", level: 2 },
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("returns empty array for empty content", () => {
|
|
101
|
+
expect(parseMarkdownHeadings("")).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface Heading {
|
|
2
|
+
id: string;
|
|
3
|
+
text: string;
|
|
4
|
+
level: 1 | 2 | 3 | 4 | 5 | 6;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function slugify(text: string): string {
|
|
8
|
+
return text
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.trim()
|
|
11
|
+
.replace(/[^\w\s-]/g, "")
|
|
12
|
+
.replace(/\s+/g, "-")
|
|
13
|
+
.replace(/-+/g, "-");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function stripCodeBlocks(content: string): string {
|
|
17
|
+
let result = content.replace(/^(`{3,}|~{3,}).*$[\s\S]*?^\1\s*$/gm, "");
|
|
18
|
+
result = result.replace(/(?:^|\n\n)((?:(?:[ ]{4}|\t).+\n?)+)/g, "\n\n");
|
|
19
|
+
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseMarkdownHeadings(content: string): Heading[] {
|
|
24
|
+
const headings: Heading[] = [];
|
|
25
|
+
const seenIds = new Map<string, number>();
|
|
26
|
+
const contentWithoutCode = stripCodeBlocks(content);
|
|
27
|
+
|
|
28
|
+
const regex = /^(#{1,6})\s+(.+)$/gm;
|
|
29
|
+
let match = regex.exec(contentWithoutCode);
|
|
30
|
+
|
|
31
|
+
while (match !== null) {
|
|
32
|
+
const level = match[1].length as 1 | 2 | 3 | 4 | 5 | 6;
|
|
33
|
+
const text = match[2].trim();
|
|
34
|
+
const baseId = slugify(text);
|
|
35
|
+
const count = seenIds.get(baseId) ?? 0;
|
|
36
|
+
const id = count > 0 ? `${baseId}-${count}` : baseId;
|
|
37
|
+
seenIds.set(baseId, count + 1);
|
|
38
|
+
|
|
39
|
+
headings.push({ id, text, level });
|
|
40
|
+
match = regex.exec(contentWithoutCode);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return headings;
|
|
44
|
+
}
|