@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.
Files changed (179) hide show
  1. package/.claude/CLAUDE.md +118 -76
  2. package/.claude/commands/review.md +1 -1
  3. package/.claude/roadmap.md +32 -9
  4. package/.claude/user-stories.md +100 -15
  5. package/AGENTS.md +30 -26
  6. package/Makefile +32 -0
  7. package/README.md +90 -2
  8. package/biome.json +18 -8
  9. package/bun.lock +426 -568
  10. package/bunfig.toml +2 -0
  11. package/docs/perf-baseline.md +56 -1
  12. package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
  13. package/e2e/comments.spec.ts +14 -58
  14. package/e2e/document-load.spec.ts +1 -23
  15. package/e2e/export.spec.ts +4 -4
  16. package/e2e/perf/add-comment.spec.ts +9 -11
  17. package/e2e/perf/fixtures/generate.ts +1 -5
  18. package/e2e/perf/screenshot-final.png +0 -0
  19. package/e2e/perf/utils/metrics.ts +73 -9
  20. package/e2e/persistence-file.spec.ts +41 -26
  21. package/e2e/utils/selection.ts +17 -73
  22. package/go/cmd/readit/main.go +416 -0
  23. package/go/go.mod +20 -0
  24. package/go/go.sum +41 -0
  25. package/go/internal/server/anchor.go +302 -0
  26. package/go/internal/server/anchor_test.go +111 -0
  27. package/go/internal/server/comments.go +390 -0
  28. package/go/internal/server/documents.go +113 -0
  29. package/go/internal/server/embed.go +17 -0
  30. package/go/internal/server/headings.go +33 -0
  31. package/go/internal/server/headings_test.go +75 -0
  32. package/go/internal/server/htmltext.go +123 -0
  33. package/go/internal/server/markdown.go +157 -0
  34. package/go/internal/server/markdown_bench_test.go +42 -0
  35. package/go/internal/server/markdown_test.go +79 -0
  36. package/go/internal/server/server.go +453 -0
  37. package/go/internal/server/server_bench_test.go +122 -0
  38. package/go/internal/server/settings.go +110 -0
  39. package/go/internal/server/sse.go +140 -0
  40. package/go/internal/server/storage.go +275 -0
  41. package/go/internal/server/storage_test.go +152 -0
  42. package/go/internal/server/template.go +66 -0
  43. package/go/internal/server/types.go +101 -0
  44. package/go/internal/server/watcher.go +74 -0
  45. package/index.html +4 -14
  46. package/nvim-readit/lua/readit/health.lua +64 -0
  47. package/nvim-readit/lua/readit/init.lua +463 -0
  48. package/nvim-readit/plugin/readit.lua +19 -0
  49. package/package.json +20 -28
  50. package/shell/_readit +158 -0
  51. package/shell/readit.zsh +87 -0
  52. package/src/App.svelte +890 -0
  53. package/src/cli.ts +183 -21
  54. package/src/components/ActionsMenu.svelte +95 -0
  55. package/src/components/CommentBadge.svelte +67 -0
  56. package/src/components/CommentErrorBanner.svelte +33 -0
  57. package/src/components/CommentInput.svelte +75 -0
  58. package/src/components/CommentListItem.svelte +95 -0
  59. package/src/components/CommentManager.svelte +129 -0
  60. package/src/components/CommentNav.svelte +109 -0
  61. package/src/components/DocumentViewer.svelte +233 -0
  62. package/src/components/FloatingComment.svelte +107 -0
  63. package/src/components/Header.svelte +76 -0
  64. package/src/components/InlineEditor.svelte +72 -0
  65. package/src/components/MarginNote.svelte +167 -0
  66. package/src/components/MarginNotesContainer.svelte +33 -0
  67. package/src/components/MermaidEnhancer.svelte +218 -0
  68. package/src/components/MermaidModal.svelte +67 -0
  69. package/src/components/RawModal.svelte +126 -0
  70. package/src/components/ReanchorConfirm.svelte +30 -0
  71. package/src/components/SettingsModal.svelte +220 -0
  72. package/src/components/ShortcutCapture.svelte +82 -0
  73. package/src/components/ShortcutList.svelte +145 -0
  74. package/src/components/TabBar.svelte +52 -0
  75. package/src/components/TableOfContents.svelte +125 -0
  76. package/src/components/ui/ActionLink.svelte +40 -0
  77. package/src/components/ui/{Button.tsx → Button.svelte} +19 -20
  78. package/src/components/ui/Dialog.svelte +97 -0
  79. package/src/components/ui/DropdownMenu.svelte +85 -0
  80. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  81. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  82. package/src/components/ui/{Text.tsx → Text.svelte} +18 -23
  83. package/src/env.d.ts +6 -0
  84. package/src/index.css +141 -166
  85. package/src/lib/__fixtures__/bench-data.ts +0 -13
  86. package/src/lib/anchor.bench.ts +1 -12
  87. package/src/lib/anchor.test.ts +0 -8
  88. package/src/lib/anchor.ts +0 -4
  89. package/src/lib/comment-storage.bench.ts +49 -0
  90. package/src/lib/comment-storage.test.ts +103 -33
  91. package/src/lib/comment-storage.ts +25 -18
  92. package/src/lib/export.bench.ts +21 -0
  93. package/src/lib/export.ts +0 -1
  94. package/src/lib/fetch-or-throw.test.ts +59 -0
  95. package/src/lib/fetch-or-throw.ts +12 -0
  96. package/src/{hooks/useHeadings.test.ts → lib/headings.test.ts} +10 -24
  97. package/src/{hooks/useHeadings.ts → lib/headings.ts} +11 -13
  98. package/src/lib/highlight/core.test.ts +0 -5
  99. package/src/lib/highlight/dom.ts +52 -216
  100. package/src/lib/highlight/highlight-registry.ts +221 -0
  101. package/src/lib/highlight/highlight.bench.ts +92 -0
  102. package/src/lib/highlight/highlighter.ts +112 -132
  103. package/src/lib/highlight/resolver.ts +5 -79
  104. package/src/lib/highlight/types.ts +0 -5
  105. package/src/lib/html-text.test.ts +162 -0
  106. package/src/lib/html-text.ts +161 -0
  107. package/src/lib/i18n/en.ts +34 -0
  108. package/src/lib/i18n/ja.ts +34 -0
  109. package/src/lib/i18n/types.ts +33 -0
  110. package/src/lib/key-lock.test.ts +104 -0
  111. package/src/lib/key-lock.ts +23 -0
  112. package/src/lib/margin-layout.bench.ts +61 -0
  113. package/src/lib/margin-layout.ts +0 -7
  114. package/src/lib/markdown-renderer.test.ts +154 -0
  115. package/src/lib/markdown-renderer.ts +178 -0
  116. package/src/lib/mermaid-config.ts +38 -0
  117. package/src/lib/mermaid-renderer.ts +162 -0
  118. package/src/lib/mermaid-worker.ts +60 -0
  119. package/src/lib/positions.ts +31 -24
  120. package/src/lib/shortcut-registry.ts +244 -0
  121. package/src/lib/utils.ts +0 -29
  122. package/src/main.ts +16 -0
  123. package/src/schema.ts +16 -5
  124. package/src/server.ts +355 -95
  125. package/src/stores/app.svelte.ts +231 -0
  126. package/src/stores/locale.svelte.ts +46 -0
  127. package/src/stores/settings.svelte.ts +90 -0
  128. package/src/stores/shortcuts.svelte.ts +104 -0
  129. package/src/stores/ui.svelte.ts +12 -0
  130. package/src/template.ts +104 -0
  131. package/src/test-setup.ts +47 -0
  132. package/svelte.config.js +5 -0
  133. package/tsconfig.json +2 -2
  134. package/vite.config.ts +23 -3
  135. package/vscode-readit/.mcp.json +7 -0
  136. package/vscode-readit/.vscodeignore +7 -0
  137. package/vscode-readit/bun.lock +78 -0
  138. package/vscode-readit/icon.svg +10 -0
  139. package/vscode-readit/package.json +110 -0
  140. package/vscode-readit/src/extension.ts +117 -0
  141. package/vscode-readit/src/server-manager.ts +272 -0
  142. package/vscode-readit/src/webview-provider.ts +204 -0
  143. package/vscode-readit/tsconfig.json +20 -0
  144. package/e2e/fixtures/sample.html +0 -13
  145. package/src/App.tsx +0 -368
  146. package/src/components/ActionsMenu.tsx +0 -91
  147. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  148. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -230
  149. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -136
  150. package/src/components/Header.tsx +0 -54
  151. package/src/components/InlineEditor.tsx +0 -74
  152. package/src/components/MarginNote.tsx +0 -185
  153. package/src/components/MarginNotes.tsx +0 -23
  154. package/src/components/RawModal.tsx +0 -144
  155. package/src/components/ReanchorConfirm.tsx +0 -36
  156. package/src/components/SettingsModal.tsx +0 -232
  157. package/src/components/TabBar.tsx +0 -60
  158. package/src/components/TableOfContents.tsx +0 -108
  159. package/src/components/comments/CommentBadge.tsx +0 -49
  160. package/src/components/comments/CommentInput.tsx +0 -86
  161. package/src/components/comments/CommentListItem.tsx +0 -90
  162. package/src/components/comments/CommentManager.tsx +0 -129
  163. package/src/components/comments/CommentNav.tsx +0 -109
  164. package/src/components/ui/ActionLink.tsx +0 -28
  165. package/src/components/ui/Dialog.tsx +0 -116
  166. package/src/components/ui/DropdownMenu.tsx +0 -158
  167. package/src/contexts/CommentContext.tsx +0 -198
  168. package/src/contexts/LocaleContext.tsx +0 -76
  169. package/src/contexts/PositionsContext.tsx +0 -16
  170. package/src/contexts/SettingsContext.tsx +0 -133
  171. package/src/hooks/useClickOutside.ts +0 -31
  172. package/src/hooks/useCommentNavigation.ts +0 -107
  173. package/src/hooks/useComments.ts +0 -311
  174. package/src/hooks/useDocument.ts +0 -157
  175. package/src/hooks/useScrollSpy.ts +0 -77
  176. package/src/hooks/useTextSelection.ts +0 -86
  177. package/src/lib/highlight/worker.ts +0 -45
  178. package/src/main.tsx +0 -13
  179. 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
- import {
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
- } from "./comment-storage";
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|2024-12-24T10:30:00Z -->");
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; // chars stored for anchor matching when text is truncated
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
- for (const block of blocks) {
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
- // Extract metadata from HTML comment: <!-- c:{id}|{lineHint}|{timestamp} -->
111
- const metadataMatch = block.match(/<!--\s*c:([^|]+)\|([^|]+)\|([^>]+)\s*-->/);
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, createdAt] = metadataMatch;
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
- lines.push(`<!-- c:${comment.id}|${lineHint}|${comment.createdAt} -->`);
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
@@ -21,7 +21,6 @@ export function exportCommentsAsJson(
21
21
  selectedText: c.selectedText,
22
22
  comment: c.comment,
23
23
  lineHint: c.lineHint,
24
- createdAt: c.createdAt,
25
24
  })),
26
25
  };
27
26
 
@@ -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 { useHeadings } from "./useHeadings";
2
+ import { parseMarkdownHeadings } from "./headings";
4
3
 
5
- describe("useHeadings", () => {
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
- const { result } = renderHook(() => useHeadings(content));
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
- const { result } = renderHook(() => useHeadings(content));
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
- const { result } = renderHook(() => useHeadings(content));
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
- const { result } = renderHook(() => useHeadings(content));
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
- const { result } = renderHook(() => useHeadings(content));
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
- const { result } = renderHook(() => useHeadings(content));
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 null content", () => {
114
- const { result } = renderHook(() => useHeadings(null));
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 stripCodeBlocks(content: string): string {
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
  });