@peaske7/readit 0.2.0 → 0.3.0-rc.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/.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 -2
- package/biome.json +18 -8
- package/bun.lock +426 -568
- package/bunfig.toml +2 -0
- package/docs/perf-baseline.md +56 -1
- 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 +9 -11
- package/e2e/perf/fixtures/generate.ts +1 -5
- package/e2e/perf/screenshot-final.png +0 -0
- package/e2e/perf/utils/metrics.ts +73 -9
- 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 +152 -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 +20 -28
- package/shell/_readit +158 -0
- package/shell/readit.zsh +87 -0
- package/src/App.svelte +890 -0
- package/src/cli.ts +183 -21
- 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 +233 -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/MermaidEnhancer.svelte +218 -0
- package/src/components/MermaidModal.svelte +67 -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.tsx → Button.svelte} +19 -20
- 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.tsx → Text.svelte} +18 -23
- package/src/env.d.ts +6 -0
- package/src/index.css +141 -166
- package/src/lib/__fixtures__/bench-data.ts +0 -13
- package/src/lib/anchor.bench.ts +1 -12
- package/src/lib/anchor.test.ts +0 -8
- package/src/lib/anchor.ts +0 -4
- package/src/lib/comment-storage.bench.ts +49 -0
- package/src/lib/comment-storage.test.ts +103 -33
- package/src/lib/comment-storage.ts +25 -18
- package/src/lib/export.bench.ts +21 -0
- package/src/lib/export.ts +0 -1
- package/src/lib/fetch-or-throw.test.ts +59 -0
- package/src/lib/fetch-or-throw.ts +12 -0
- package/src/{hooks/useHeadings.test.ts → lib/headings.test.ts} +10 -24
- package/src/{hooks/useHeadings.ts → lib/headings.ts} +11 -13
- package/src/lib/highlight/core.test.ts +0 -5
- package/src/lib/highlight/dom.ts +52 -216
- package/src/lib/highlight/highlight-registry.ts +221 -0
- package/src/lib/highlight/highlight.bench.ts +92 -0
- package/src/lib/highlight/highlighter.ts +112 -132
- package/src/lib/highlight/resolver.ts +5 -79
- package/src/lib/highlight/types.ts +0 -5
- package/src/lib/html-text.test.ts +162 -0
- package/src/lib/html-text.ts +161 -0
- package/src/lib/i18n/en.ts +34 -0
- package/src/lib/i18n/ja.ts +34 -0
- package/src/lib/i18n/types.ts +33 -0
- package/src/lib/key-lock.test.ts +104 -0
- package/src/lib/key-lock.ts +23 -0
- package/src/lib/margin-layout.bench.ts +61 -0
- package/src/lib/margin-layout.ts +0 -7
- package/src/lib/markdown-renderer.test.ts +154 -0
- package/src/lib/markdown-renderer.ts +178 -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 +31 -24
- package/src/lib/shortcut-registry.ts +244 -0
- package/src/lib/utils.ts +0 -29
- package/src/main.ts +16 -0
- package/src/schema.ts +16 -5
- package/src/server.ts +355 -95
- 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 +23 -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 -368
- package/src/components/ActionsMenu.tsx +0 -91
- package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
- package/src/components/DocumentViewer/DocumentViewer.tsx +0 -230
- package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -136
- package/src/components/Header.tsx +0 -54
- package/src/components/InlineEditor.tsx +0 -74
- package/src/components/MarginNote.tsx +0 -185
- package/src/components/MarginNotes.tsx +0 -23
- package/src/components/RawModal.tsx +0 -144
- package/src/components/ReanchorConfirm.tsx +0 -36
- package/src/components/SettingsModal.tsx +0 -232
- 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 -86
- package/src/components/comments/CommentListItem.tsx +0 -90
- package/src/components/comments/CommentManager.tsx +0 -129
- package/src/components/comments/CommentNav.tsx +0 -109
- package/src/components/ui/ActionLink.tsx +0 -28
- package/src/components/ui/Dialog.tsx +0 -116
- package/src/components/ui/DropdownMenu.tsx +0 -158
- package/src/contexts/CommentContext.tsx +0 -198
- package/src/contexts/LocaleContext.tsx +0 -76
- package/src/contexts/PositionsContext.tsx +0 -16
- package/src/contexts/SettingsContext.tsx +0 -133
- package/src/hooks/useClickOutside.ts +0 -31
- package/src/hooks/useCommentNavigation.ts +0 -107
- package/src/hooks/useComments.ts +0 -311
- package/src/hooks/useDocument.ts +0 -157
- package/src/hooks/useScrollSpy.ts +0 -77
- package/src/hooks/useTextSelection.ts +0 -86
- package/src/lib/highlight/worker.ts +0 -45
- package/src/main.tsx +0 -13
- package/src/store.ts +0 -222
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import type * as os from "node:os";
|
|
2
1
|
import { describe, expect, it, vi } from "vitest";
|
|
3
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
|
|
@@ -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,15 +327,15 @@ 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",
|
|
331
|
+
createdAt: "2026-05-09T00:00:00Z",
|
|
344
332
|
startOffset: 100,
|
|
345
333
|
endOffset: 113,
|
|
346
334
|
},
|
|
347
335
|
],
|
|
348
336
|
};
|
|
349
337
|
const result = serializeComments(file);
|
|
350
|
-
expect(result).toContain("<!-- c:12345678|L42|
|
|
338
|
+
expect(result).toContain("<!-- c:12345678|L42|2026-05-09T00:00:00Z -->");
|
|
351
339
|
expect(result).toContain("> selected text");
|
|
352
340
|
expect(result).toContain("My comment");
|
|
353
341
|
});
|
|
@@ -362,7 +350,6 @@ describe("serializeComments", () => {
|
|
|
362
350
|
id: "12345678",
|
|
363
351
|
selectedText: "line one\nline two",
|
|
364
352
|
comment: "Comment",
|
|
365
|
-
createdAt: "2024-12-24T10:30:00Z",
|
|
366
353
|
lineHint: "L42-L43",
|
|
367
354
|
startOffset: 100,
|
|
368
355
|
endOffset: 120,
|
|
@@ -384,7 +371,6 @@ describe("serializeComments", () => {
|
|
|
384
371
|
id: "12345678",
|
|
385
372
|
selectedText: "truncated text...",
|
|
386
373
|
comment: "Comment",
|
|
387
|
-
createdAt: "2024-12-24T10:30:00Z",
|
|
388
374
|
lineHint: "L42",
|
|
389
375
|
startOffset: 100,
|
|
390
376
|
endOffset: 500,
|
|
@@ -408,7 +394,6 @@ describe("serializeComments", () => {
|
|
|
408
394
|
id: "12345678",
|
|
409
395
|
selectedText: "short text",
|
|
410
396
|
comment: "Comment",
|
|
411
|
-
createdAt: "2024-12-24T10:30:00Z",
|
|
412
397
|
lineHint: "L42",
|
|
413
398
|
startOffset: 100,
|
|
414
399
|
endOffset: 110,
|
|
@@ -429,7 +414,6 @@ describe("serializeComments", () => {
|
|
|
429
414
|
id: "12345678",
|
|
430
415
|
selectedText: "selected text",
|
|
431
416
|
comment: "My comment",
|
|
432
|
-
createdAt: "2024-12-24T10:30:00Z",
|
|
433
417
|
lineHint: "L42",
|
|
434
418
|
startOffset: 100,
|
|
435
419
|
endOffset: 113,
|
|
@@ -438,7 +422,6 @@ describe("serializeComments", () => {
|
|
|
438
422
|
id: "87654321",
|
|
439
423
|
selectedText: "another\nmultiline\nselection",
|
|
440
424
|
comment: "Another comment with\n\nmultiple paragraphs.",
|
|
441
|
-
createdAt: "2024-12-24T11:00:00Z",
|
|
442
425
|
lineHint: "L50-L52",
|
|
443
426
|
startOffset: 200,
|
|
444
427
|
endOffset: 230,
|
|
@@ -460,10 +443,42 @@ describe("serializeComments", () => {
|
|
|
460
443
|
original.comments[i].selectedText,
|
|
461
444
|
);
|
|
462
445
|
expect(parsed.comments[i].lineHint).toBe(original.comments[i].lineHint);
|
|
463
|
-
expect(parsed.comments[i].createdAt).toBe(original.comments[i].createdAt);
|
|
464
446
|
}
|
|
465
447
|
});
|
|
466
448
|
|
|
449
|
+
it("roundtrip: survives a markdown horizontal rule inside a comment body", () => {
|
|
450
|
+
const original: CommentFile = {
|
|
451
|
+
source: "/test.md",
|
|
452
|
+
hash: "abc123",
|
|
453
|
+
version: 1,
|
|
454
|
+
comments: [
|
|
455
|
+
{
|
|
456
|
+
id: "aaaaaaaa",
|
|
457
|
+
selectedText: "first selected",
|
|
458
|
+
comment: "before\n\n---\n\nafter",
|
|
459
|
+
lineHint: "L1",
|
|
460
|
+
startOffset: 0,
|
|
461
|
+
endOffset: 14,
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
id: "bbbbbbbb",
|
|
465
|
+
selectedText: "second selected",
|
|
466
|
+
comment: "second body",
|
|
467
|
+
lineHint: "L5",
|
|
468
|
+
startOffset: 50,
|
|
469
|
+
endOffset: 65,
|
|
470
|
+
},
|
|
471
|
+
],
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const serialized = serializeComments(original);
|
|
475
|
+
const parsed = parseCommentFile(serialized);
|
|
476
|
+
|
|
477
|
+
expect(parsed.comments).toHaveLength(2);
|
|
478
|
+
expect(parsed.comments[0].id).toBe("aaaaaaaa");
|
|
479
|
+
expect(parsed.comments[1].id).toBe("bbbbbbbb");
|
|
480
|
+
});
|
|
481
|
+
|
|
467
482
|
it("roundtrip: preserves anchorPrefix through serialize/parse", () => {
|
|
468
483
|
const original: CommentFile = {
|
|
469
484
|
source: "/test.md",
|
|
@@ -474,7 +489,6 @@ describe("serializeComments", () => {
|
|
|
474
489
|
id: "12345678",
|
|
475
490
|
selectedText: "truncated text\n...\nend of text",
|
|
476
491
|
comment: "Comment on long selection",
|
|
477
|
-
createdAt: "2024-12-24T10:30:00Z",
|
|
478
492
|
lineHint: "L42",
|
|
479
493
|
startOffset: 100,
|
|
480
494
|
endOffset: 2000,
|
|
@@ -514,11 +528,6 @@ describe("createComment", () => {
|
|
|
514
528
|
expect(comment.lineHint).toBe("L2");
|
|
515
529
|
});
|
|
516
530
|
|
|
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
531
|
it("truncates very long selections", () => {
|
|
523
532
|
const longText = "a".repeat(2000);
|
|
524
533
|
const comment = createComment(longText, "comment", 0, 2000, longText);
|
|
@@ -583,6 +592,67 @@ describe("truncateSelection", () => {
|
|
|
583
592
|
});
|
|
584
593
|
});
|
|
585
594
|
|
|
595
|
+
describe("parseCommentFile cross-runtime format", () => {
|
|
596
|
+
it("parses 2-field marker (legacy TS-written files)", () => {
|
|
597
|
+
const content = `---
|
|
598
|
+
source: /test.md
|
|
599
|
+
hash: abc
|
|
600
|
+
version: 1
|
|
601
|
+
---
|
|
602
|
+
|
|
603
|
+
<!-- c:abcd1234|L5 -->
|
|
604
|
+
> selected text
|
|
605
|
+
some comment
|
|
606
|
+
|
|
607
|
+
---
|
|
608
|
+
`;
|
|
609
|
+
const parsed = parseCommentFile(content);
|
|
610
|
+
expect(parsed.comments).toHaveLength(1);
|
|
611
|
+
expect(parsed.comments[0].id).toBe("abcd1234");
|
|
612
|
+
expect(parsed.comments[0].lineHint).toBe("L5");
|
|
613
|
+
expect(parsed.comments[0].createdAt).toBeUndefined();
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it("parses 3-field marker (Go-written files)", () => {
|
|
617
|
+
const content = `---
|
|
618
|
+
source: /test.md
|
|
619
|
+
hash: abc
|
|
620
|
+
version: 1
|
|
621
|
+
---
|
|
622
|
+
|
|
623
|
+
<!-- c:abcd1234|L5|2026-05-09T12:34:56.789Z -->
|
|
624
|
+
> selected text
|
|
625
|
+
some comment
|
|
626
|
+
|
|
627
|
+
---
|
|
628
|
+
`;
|
|
629
|
+
const parsed = parseCommentFile(content);
|
|
630
|
+
expect(parsed.comments).toHaveLength(1);
|
|
631
|
+
expect(parsed.comments[0].createdAt).toBe("2026-05-09T12:34:56.789Z");
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it("serializeComments writes 3-field marker", () => {
|
|
635
|
+
const file: CommentFile = {
|
|
636
|
+
source: "/test.md",
|
|
637
|
+
hash: "abc",
|
|
638
|
+
version: 1,
|
|
639
|
+
comments: [
|
|
640
|
+
{
|
|
641
|
+
id: "abcd1234",
|
|
642
|
+
selectedText: "selected",
|
|
643
|
+
comment: "body",
|
|
644
|
+
lineHint: "L1",
|
|
645
|
+
createdAt: "2026-05-09T00:00:00Z",
|
|
646
|
+
startOffset: 0,
|
|
647
|
+
endOffset: 8,
|
|
648
|
+
},
|
|
649
|
+
],
|
|
650
|
+
};
|
|
651
|
+
const serialized = serializeComments(file);
|
|
652
|
+
expect(serialized).toContain("<!-- c:abcd1234|L1|2026-05-09T00:00:00Z -->");
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
|
|
586
656
|
describe("parseCommentFile version check", () => {
|
|
587
657
|
it("accepts current version", () => {
|
|
588
658
|
const content = `---
|
|
@@ -7,7 +7,7 @@ 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
12
|
export function truncateSelection(text: string): string {
|
|
13
13
|
if (text.length <= MAX_SELECTION_LENGTH) {
|
|
@@ -19,13 +19,8 @@ export function truncateSelection(text: string): string {
|
|
|
19
19
|
return text.slice(0, half) + TRUNCATION_MARKER + text.slice(-half);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
/**
|
|
23
|
-
* Compute the path where comments for a source file should be stored.
|
|
24
|
-
* Comments are stored in ~/.readit/comments/{absolute-path-structure}/{filename}.comments.md
|
|
25
|
-
*/
|
|
26
22
|
export function getCommentPath(sourcePath: string): string {
|
|
27
23
|
const absolute = path.resolve(sourcePath);
|
|
28
|
-
// Remove leading slash and drive letter (Windows)
|
|
29
24
|
const normalized = absolute.replace(/^\//, "").replace(/^[A-Z]:[\\/]/, "");
|
|
30
25
|
const ext = path.extname(normalized);
|
|
31
26
|
const withoutExt = normalized.slice(0, -ext.length || undefined);
|
|
@@ -94,9 +89,22 @@ export function parseCommentFile(content: string): CommentFile {
|
|
|
94
89
|
}
|
|
95
90
|
|
|
96
91
|
const bodyContent = content.replace(/^---\n[\s\S]*?\n---\n*/, "");
|
|
97
|
-
const blocks = bodyContent.split(/\n---\n/).filter((block) => block.trim());
|
|
98
92
|
|
|
99
|
-
|
|
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;
|
|
100
108
|
const comment = parseCommentBlock(block);
|
|
101
109
|
if (comment) {
|
|
102
110
|
result.comments.push(comment);
|
|
@@ -107,15 +115,16 @@ export function parseCommentFile(content: string): CommentFile {
|
|
|
107
115
|
}
|
|
108
116
|
|
|
109
117
|
function parseCommentBlock(block: string): Comment | undefined {
|
|
110
|
-
|
|
111
|
-
|
|
118
|
+
const metadataMatch = block.match(
|
|
119
|
+
/<!--\s*c:([^|]+)\|([^|>\s]+)(?:\|([^>]*))?\s*-->/,
|
|
120
|
+
);
|
|
112
121
|
if (!metadataMatch) {
|
|
113
122
|
return undefined;
|
|
114
123
|
}
|
|
115
124
|
|
|
116
|
-
const [, id, lineHint,
|
|
125
|
+
const [, id, lineHint, createdAtRaw] = metadataMatch;
|
|
126
|
+
const createdAt = createdAtRaw?.trim() || undefined;
|
|
117
127
|
|
|
118
|
-
// Extract anchor prefix if present: <!-- anchor:{prefix} -->
|
|
119
128
|
const anchorMatch = block.match(/<!--\s*anchor:(.+?)\s*-->/);
|
|
120
129
|
const anchorPrefix = anchorMatch ? anchorMatch[1] : undefined;
|
|
121
130
|
|
|
@@ -138,10 +147,9 @@ function parseCommentBlock(block: string): Comment | undefined {
|
|
|
138
147
|
id,
|
|
139
148
|
selectedText,
|
|
140
149
|
comment: commentBody,
|
|
141
|
-
createdAt: createdAt.trim(),
|
|
142
150
|
lineHint,
|
|
151
|
+
createdAt,
|
|
143
152
|
anchorPrefix,
|
|
144
|
-
// Offsets will be resolved by anchor matching when loading
|
|
145
153
|
startOffset: 0,
|
|
146
154
|
endOffset: 0,
|
|
147
155
|
};
|
|
@@ -171,7 +179,8 @@ function serializeComment(comment: Comment): string {
|
|
|
171
179
|
const lines: string[] = [];
|
|
172
180
|
|
|
173
181
|
const lineHint = comment.lineHint || "L0";
|
|
174
|
-
|
|
182
|
+
const createdAt = comment.createdAt || new Date().toISOString();
|
|
183
|
+
lines.push(`<!-- c:${comment.id}|${lineHint}|${createdAt} -->`);
|
|
175
184
|
|
|
176
185
|
if (comment.anchorPrefix) {
|
|
177
186
|
lines.push(`<!-- anchor:${comment.anchorPrefix} -->`);
|
|
@@ -199,8 +208,6 @@ export function createComment(
|
|
|
199
208
|
): Comment {
|
|
200
209
|
const id = crypto.randomUUID().slice(0, 8);
|
|
201
210
|
const lineHint = getLineHint(sourceContent, startOffset, endOffset);
|
|
202
|
-
const now = new Date();
|
|
203
|
-
const createdAt = now.toISOString();
|
|
204
211
|
|
|
205
212
|
const needsTruncation = selectedText.length > MAX_SELECTION_LENGTH;
|
|
206
213
|
|
|
@@ -208,10 +215,10 @@ export function createComment(
|
|
|
208
215
|
id,
|
|
209
216
|
selectedText: truncateSelection(selectedText),
|
|
210
217
|
comment: commentText,
|
|
211
|
-
createdAt,
|
|
212
218
|
startOffset,
|
|
213
219
|
endOffset,
|
|
214
220
|
lineHint,
|
|
221
|
+
createdAt: new Date().toISOString(),
|
|
215
222
|
anchorPrefix: needsTruncation
|
|
216
223
|
? selectedText.slice(0, ANCHOR_PREFIX_LENGTH)
|
|
217
224
|
: undefined,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { bench, describe } from "vitest";
|
|
2
|
+
import { COMMENTS_10, COMMENTS_50 } from "./__fixtures__/bench-data";
|
|
3
|
+
import { formatComment, generatePrompt } from "./export";
|
|
4
|
+
|
|
5
|
+
describe("formatComment", () => {
|
|
6
|
+
const comment = COMMENTS_10[0];
|
|
7
|
+
|
|
8
|
+
bench("single comment", () => {
|
|
9
|
+
formatComment(comment);
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("generatePrompt", () => {
|
|
14
|
+
bench("10 comments", () => {
|
|
15
|
+
generatePrompt(COMMENTS_10, "benchmark.md");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
bench("50 comments", () => {
|
|
19
|
+
generatePrompt(COMMENTS_50, "benchmark.md");
|
|
20
|
+
});
|
|
21
|
+
});
|
package/src/lib/export.ts
CHANGED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { fetchOrThrow } from "./fetch-or-throw";
|
|
3
|
+
|
|
4
|
+
function mockFetch(response: Response) {
|
|
5
|
+
return vi.spyOn(globalThis, "fetch").mockResolvedValue(response);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
vi.restoreAllMocks();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("fetchOrThrow", () => {
|
|
13
|
+
it("returns response on 2xx", async () => {
|
|
14
|
+
mockFetch(new Response("ok", { status: 200 }));
|
|
15
|
+
const res = await fetchOrThrow("/api", {}, "fallback");
|
|
16
|
+
expect(res.ok).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("throws body.error when response is not ok and body has error field", async () => {
|
|
20
|
+
mockFetch(
|
|
21
|
+
new Response(JSON.stringify({ error: "EACCES: permission denied" }), {
|
|
22
|
+
status: 500,
|
|
23
|
+
headers: { "Content-Type": "application/json" },
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
await expect(fetchOrThrow("/api", {}, "fallback")).rejects.toThrow(
|
|
27
|
+
"EACCES: permission denied",
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("falls back to statusText when body is not JSON", async () => {
|
|
32
|
+
mockFetch(
|
|
33
|
+
new Response("not json", { status: 500, statusText: "Server Error" }),
|
|
34
|
+
);
|
|
35
|
+
await expect(fetchOrThrow("/api", {}, "fallback")).rejects.toThrow(
|
|
36
|
+
"Server Error",
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("falls back to fallback message when statusText is empty", async () => {
|
|
41
|
+
mockFetch(new Response("", { status: 500, statusText: "" }));
|
|
42
|
+
await expect(fetchOrThrow("/api", {}, "fallback message")).rejects.toThrow(
|
|
43
|
+
"fallback message",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("falls back to statusText when body is JSON but lacks error field", async () => {
|
|
48
|
+
mockFetch(
|
|
49
|
+
new Response(JSON.stringify({ ok: false }), {
|
|
50
|
+
status: 500,
|
|
51
|
+
statusText: "Internal Error",
|
|
52
|
+
headers: { "Content-Type": "application/json" },
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
await expect(fetchOrThrow("/api", {}, "fallback")).rejects.toThrow(
|
|
56
|
+
"Internal Error",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export async function fetchOrThrow(
|
|
2
|
+
url: string,
|
|
3
|
+
init: RequestInit,
|
|
4
|
+
fallback: string,
|
|
5
|
+
): Promise<Response> {
|
|
6
|
+
const response = await fetch(url, init);
|
|
7
|
+
if (!response.ok) {
|
|
8
|
+
const body = await response.json().catch(() => null);
|
|
9
|
+
throw new Error(body?.error || response.statusText || fallback);
|
|
10
|
+
}
|
|
11
|
+
return response;
|
|
12
|
+
}
|
|
@@ -1,16 +1,13 @@
|
|
|
1
|
-
import { renderHook } from "@testing-library/react";
|
|
2
1
|
import { describe, expect, it } from "vitest";
|
|
3
|
-
import {
|
|
2
|
+
import { parseMarkdownHeadings } from "./headings";
|
|
4
3
|
|
|
5
|
-
describe("
|
|
4
|
+
describe("parseMarkdownHeadings", () => {
|
|
6
5
|
it("extracts basic headings", () => {
|
|
7
6
|
const content = `# Heading 1
|
|
8
7
|
## Heading 2
|
|
9
8
|
### Heading 3`;
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
expect(result.current).toEqual([
|
|
10
|
+
expect(parseMarkdownHeadings(content)).toEqual([
|
|
14
11
|
{ id: "heading-1", text: "Heading 1", level: 1 },
|
|
15
12
|
{ id: "heading-2", text: "Heading 2", level: 2 },
|
|
16
13
|
{ id: "heading-3", text: "Heading 3", level: 3 },
|
|
@@ -22,9 +19,7 @@ describe("useHeadings", () => {
|
|
|
22
19
|
## Section
|
|
23
20
|
## Section`;
|
|
24
21
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
expect(result.current).toEqual([
|
|
22
|
+
expect(parseMarkdownHeadings(content)).toEqual([
|
|
28
23
|
{ id: "section", text: "Section", level: 2 },
|
|
29
24
|
{ id: "section-1", text: "Section", level: 2 },
|
|
30
25
|
{ id: "section-2", text: "Section", level: 2 },
|
|
@@ -41,9 +36,7 @@ echo "hello"
|
|
|
41
36
|
|
|
42
37
|
## Another Real Heading`;
|
|
43
38
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
expect(result.current).toEqual([
|
|
39
|
+
expect(parseMarkdownHeadings(content)).toEqual([
|
|
47
40
|
{ id: "real-heading", text: "Real Heading", level: 1 },
|
|
48
41
|
{ id: "another-real-heading", text: "Another Real Heading", level: 2 },
|
|
49
42
|
]);
|
|
@@ -60,9 +53,7 @@ def foo():
|
|
|
60
53
|
|
|
61
54
|
## Another Real Heading`;
|
|
62
55
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
expect(result.current).toEqual([
|
|
56
|
+
expect(parseMarkdownHeadings(content)).toEqual([
|
|
66
57
|
{ id: "real-heading", text: "Real Heading", level: 1 },
|
|
67
58
|
{ id: "another-real-heading", text: "Another Real Heading", level: 2 },
|
|
68
59
|
]);
|
|
@@ -83,9 +74,7 @@ def foo():
|
|
|
83
74
|
|
|
84
75
|
## Results`;
|
|
85
76
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
expect(result.current).toEqual([
|
|
77
|
+
expect(parseMarkdownHeadings(content)).toEqual([
|
|
89
78
|
{ id: "introduction", text: "Introduction", level: 1 },
|
|
90
79
|
{ id: "methods", text: "Methods", level: 2 },
|
|
91
80
|
{ id: "results", text: "Results", level: 2 },
|
|
@@ -102,16 +91,13 @@ npx readit document.md --port 3000
|
|
|
102
91
|
|
|
103
92
|
## Usage`;
|
|
104
93
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
expect(result.current).toEqual([
|
|
94
|
+
expect(parseMarkdownHeadings(content)).toEqual([
|
|
108
95
|
{ id: "setup", text: "Setup", level: 1 },
|
|
109
96
|
{ id: "usage", text: "Usage", level: 2 },
|
|
110
97
|
]);
|
|
111
98
|
});
|
|
112
99
|
|
|
113
|
-
it("returns empty array for
|
|
114
|
-
|
|
115
|
-
expect(result.current).toEqual([]);
|
|
100
|
+
it("returns empty array for empty content", () => {
|
|
101
|
+
expect(parseMarkdownHeadings("")).toEqual([]);
|
|
116
102
|
});
|
|
117
103
|
});
|
|
@@ -1,21 +1,26 @@
|
|
|
1
|
-
import { useMemo } from "react";
|
|
2
|
-
import { slugify } from "../lib/utils";
|
|
3
|
-
|
|
4
1
|
export interface Heading {
|
|
5
2
|
id: string;
|
|
6
3
|
text: string;
|
|
7
4
|
level: 1 | 2 | 3 | 4 | 5 | 6;
|
|
8
5
|
}
|
|
9
6
|
|
|
10
|
-
function
|
|
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 {
|
|
11
17
|
let result = content.replace(/^(`{3,}|~{3,}).*$[\s\S]*?^\1\s*$/gm, "");
|
|
12
|
-
// Only remove indented blocks preceded by a blank line (avoids removing list items)
|
|
13
18
|
result = result.replace(/(?:^|\n\n)((?:(?:[ ]{4}|\t).+\n?)+)/g, "\n\n");
|
|
14
19
|
|
|
15
20
|
return result;
|
|
16
21
|
}
|
|
17
22
|
|
|
18
|
-
function parseMarkdownHeadings(content: string): Heading[] {
|
|
23
|
+
export function parseMarkdownHeadings(content: string): Heading[] {
|
|
19
24
|
const headings: Heading[] = [];
|
|
20
25
|
const seenIds = new Map<string, number>();
|
|
21
26
|
const contentWithoutCode = stripCodeBlocks(content);
|
|
@@ -37,10 +42,3 @@ function parseMarkdownHeadings(content: string): Heading[] {
|
|
|
37
42
|
|
|
38
43
|
return headings;
|
|
39
44
|
}
|
|
40
|
-
|
|
41
|
-
export function useHeadings(content: string | null): Heading[] {
|
|
42
|
-
return useMemo(() => {
|
|
43
|
-
if (!content) return [];
|
|
44
|
-
return parseMarkdownHeadings(content);
|
|
45
|
-
}, [content]);
|
|
46
|
-
}
|
|
@@ -31,18 +31,14 @@ describe("findTextPosition", () => {
|
|
|
31
31
|
describe("multiple occurrences", () => {
|
|
32
32
|
it("finds closest occurrence to hint (before)", () => {
|
|
33
33
|
const text = "the cat and the dog and the bird";
|
|
34
|
-
// "the" occurs at: 0, 12, 24
|
|
35
34
|
|
|
36
|
-
// Hint at 10 should find "the" at 12 (closest)
|
|
37
35
|
const result = findTextPosition(text, "the", 10);
|
|
38
36
|
expect(result?.start).toBe(12);
|
|
39
37
|
});
|
|
40
38
|
|
|
41
39
|
it("finds closest occurrence to hint (after)", () => {
|
|
42
40
|
const text = "the cat and the dog and the bird";
|
|
43
|
-
// "the" occurs at: 0, 12, 24
|
|
44
41
|
|
|
45
|
-
// Hint at 30 should find "the" at 24 (closest)
|
|
46
42
|
const result = findTextPosition(text, "the", 30);
|
|
47
43
|
expect(result?.start).toBe(24);
|
|
48
44
|
});
|
|
@@ -61,7 +57,6 @@ describe("findTextPosition", () => {
|
|
|
61
57
|
|
|
62
58
|
it("handles exact match at hint position", () => {
|
|
63
59
|
const text = "abc abc abc";
|
|
64
|
-
// "abc" occurs at: 0, 4, 8
|
|
65
60
|
const result = findTextPosition(text, "abc", 4);
|
|
66
61
|
expect(result?.start).toBe(4);
|
|
67
62
|
});
|