@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.
Files changed (221) 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 -5
  8. package/biome.json +18 -8
  9. package/bun.lock +426 -710
  10. package/bunfig.toml +2 -0
  11. package/docs/perf-baseline.md +130 -0
  12. package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
  13. package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
  14. package/e2e/comments.spec.ts +14 -58
  15. package/e2e/document-load.spec.ts +1 -23
  16. package/e2e/export.spec.ts +4 -4
  17. package/e2e/perf/add-comment.spec.ts +116 -0
  18. package/e2e/perf/fixtures/generate.ts +327 -0
  19. package/e2e/perf/initial-load.spec.ts +49 -0
  20. package/e2e/perf/perf.setup.ts +23 -0
  21. package/e2e/perf/perf.teardown.ts +9 -0
  22. package/e2e/perf/screenshot-final.png +0 -0
  23. package/e2e/perf/scroll.spec.ts +39 -0
  24. package/e2e/perf/tab-switch.spec.ts +69 -0
  25. package/e2e/perf/text-selection.spec.ts +119 -0
  26. package/e2e/perf/utils/metrics.ts +350 -0
  27. package/e2e/perf/utils/perf-cli.ts +86 -0
  28. package/e2e/persistence-file.spec.ts +41 -26
  29. package/e2e/utils/selection.ts +17 -73
  30. package/go/cmd/readit/main.go +416 -0
  31. package/go/go.mod +20 -0
  32. package/go/go.sum +41 -0
  33. package/go/internal/server/anchor.go +302 -0
  34. package/go/internal/server/anchor_test.go +111 -0
  35. package/go/internal/server/comments.go +390 -0
  36. package/go/internal/server/documents.go +113 -0
  37. package/go/internal/server/embed.go +17 -0
  38. package/go/internal/server/headings.go +33 -0
  39. package/go/internal/server/headings_test.go +75 -0
  40. package/go/internal/server/htmltext.go +123 -0
  41. package/go/internal/server/markdown.go +157 -0
  42. package/go/internal/server/markdown_bench_test.go +42 -0
  43. package/go/internal/server/markdown_test.go +79 -0
  44. package/go/internal/server/server.go +453 -0
  45. package/go/internal/server/server_bench_test.go +122 -0
  46. package/go/internal/server/settings.go +110 -0
  47. package/go/internal/server/sse.go +140 -0
  48. package/go/internal/server/storage.go +275 -0
  49. package/go/internal/server/storage_test.go +118 -0
  50. package/go/internal/server/template.go +66 -0
  51. package/go/internal/server/types.go +101 -0
  52. package/go/internal/server/watcher.go +74 -0
  53. package/index.html +4 -14
  54. package/nvim-readit/lua/readit/health.lua +64 -0
  55. package/nvim-readit/lua/readit/init.lua +463 -0
  56. package/nvim-readit/plugin/readit.lua +19 -0
  57. package/package.json +24 -41
  58. package/playwright.config.ts +12 -0
  59. package/shell/_readit +158 -0
  60. package/shell/readit.zsh +87 -0
  61. package/src/App.svelte +881 -0
  62. package/src/{cli/index.ts → cli.ts} +216 -70
  63. package/src/components/ActionsMenu.svelte +95 -0
  64. package/src/components/CommentBadge.svelte +67 -0
  65. package/src/components/CommentErrorBanner.svelte +33 -0
  66. package/src/components/CommentInput.svelte +75 -0
  67. package/src/components/CommentListItem.svelte +95 -0
  68. package/src/components/CommentManager.svelte +129 -0
  69. package/src/components/CommentNav.svelte +109 -0
  70. package/src/components/DocumentViewer.svelte +218 -0
  71. package/src/components/FloatingComment.svelte +107 -0
  72. package/src/components/Header.svelte +76 -0
  73. package/src/components/InlineEditor.svelte +72 -0
  74. package/src/components/MarginNote.svelte +167 -0
  75. package/src/components/MarginNotesContainer.svelte +33 -0
  76. package/src/components/RawModal.svelte +126 -0
  77. package/src/components/ReanchorConfirm.svelte +30 -0
  78. package/src/components/SettingsModal.svelte +220 -0
  79. package/src/components/ShortcutCapture.svelte +82 -0
  80. package/src/components/ShortcutList.svelte +145 -0
  81. package/src/components/TabBar.svelte +52 -0
  82. package/src/components/TableOfContents.svelte +125 -0
  83. package/src/components/ui/ActionLink.svelte +40 -0
  84. package/src/components/ui/Button.svelte +53 -0
  85. package/src/components/ui/Dialog.svelte +97 -0
  86. package/src/components/ui/DropdownMenu.svelte +85 -0
  87. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  88. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  89. package/src/components/ui/Text.svelte +42 -0
  90. package/src/env.d.ts +6 -0
  91. package/src/index.css +36 -166
  92. package/src/lib/__fixtures__/bench-data.ts +1 -54
  93. package/src/lib/anchor.bench.ts +47 -68
  94. package/src/lib/anchor.test.ts +5 -9
  95. package/src/lib/anchor.ts +9 -93
  96. package/src/lib/comment-storage.bench.ts +6 -20
  97. package/src/lib/comment-storage.test.ts +45 -37
  98. package/src/lib/comment-storage.ts +23 -64
  99. package/src/lib/export.bench.ts +9 -23
  100. package/src/lib/export.ts +7 -14
  101. package/src/lib/headings.test.ts +103 -0
  102. package/src/lib/headings.ts +44 -0
  103. package/src/lib/highlight/core.test.ts +1 -6
  104. package/src/lib/highlight/dom.ts +53 -280
  105. package/src/lib/highlight/highlight-registry.ts +221 -0
  106. package/src/lib/highlight/highlight.bench.ts +92 -0
  107. package/src/lib/highlight/highlighter.ts +122 -302
  108. package/src/lib/highlight/{core.ts → resolver.ts} +3 -19
  109. package/src/lib/highlight/types.ts +0 -40
  110. package/src/lib/html-text.test.ts +162 -0
  111. package/src/lib/html-text.ts +161 -0
  112. package/src/lib/i18n/en.ts +13 -36
  113. package/src/lib/i18n/ja.ts +14 -37
  114. package/src/lib/i18n/types.ts +13 -36
  115. package/src/lib/margin-layout.bench.ts +48 -15
  116. package/src/lib/margin-layout.ts +2 -31
  117. package/src/lib/markdown-renderer.test.ts +154 -0
  118. package/src/lib/markdown-renderer.ts +177 -0
  119. package/src/lib/mermaid-config.ts +38 -0
  120. package/src/lib/mermaid-renderer.ts +162 -0
  121. package/src/lib/mermaid-worker.ts +60 -0
  122. package/src/lib/positions.ts +157 -0
  123. package/src/lib/shortcut-registry.ts +138 -103
  124. package/src/lib/utils.ts +2 -48
  125. package/src/main.ts +16 -0
  126. package/src/schema.ts +92 -0
  127. package/src/{server/index.ts → server.ts} +427 -163
  128. package/src/stores/app.svelte.ts +231 -0
  129. package/src/stores/locale.svelte.ts +46 -0
  130. package/src/stores/settings.svelte.ts +90 -0
  131. package/src/stores/shortcuts.svelte.ts +104 -0
  132. package/src/stores/ui.svelte.ts +12 -0
  133. package/src/template.ts +104 -0
  134. package/src/test-setup.ts +47 -0
  135. package/svelte.config.js +5 -0
  136. package/tsconfig.json +2 -2
  137. package/vite.config.ts +31 -3
  138. package/vscode-readit/.mcp.json +7 -0
  139. package/vscode-readit/.vscodeignore +7 -0
  140. package/vscode-readit/bun.lock +78 -0
  141. package/vscode-readit/icon.svg +10 -0
  142. package/vscode-readit/package.json +110 -0
  143. package/vscode-readit/src/extension.ts +117 -0
  144. package/vscode-readit/src/server-manager.ts +272 -0
  145. package/vscode-readit/src/webview-provider.ts +204 -0
  146. package/vscode-readit/tsconfig.json +20 -0
  147. package/e2e/fixtures/sample.html +0 -13
  148. package/src/App.tsx +0 -416
  149. package/src/components/ActionsMenu.tsx +0 -112
  150. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  151. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -259
  152. package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
  153. package/src/components/DocumentViewer/InlineCode.tsx +0 -60
  154. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -137
  155. package/src/components/DocumentViewer/index.ts +0 -1
  156. package/src/components/FloatingTOC.tsx +0 -61
  157. package/src/components/Header.tsx +0 -65
  158. package/src/components/InlineEditor.tsx +0 -74
  159. package/src/components/MarginNote.tsx +0 -207
  160. package/src/components/MarginNotes.tsx +0 -50
  161. package/src/components/RawModal.tsx +0 -143
  162. package/src/components/ReanchorConfirm.tsx +0 -36
  163. package/src/components/SettingsModal.tsx +0 -310
  164. package/src/components/ShortcutCapture.tsx +0 -48
  165. package/src/components/ShortcutList.tsx +0 -198
  166. package/src/components/TabBar.tsx +0 -60
  167. package/src/components/TableOfContents.tsx +0 -108
  168. package/src/components/comments/CommentBadge.tsx +0 -49
  169. package/src/components/comments/CommentInput.tsx +0 -114
  170. package/src/components/comments/CommentListItem.tsx +0 -92
  171. package/src/components/comments/CommentManager.tsx +0 -113
  172. package/src/components/comments/CommentMinimap.tsx +0 -62
  173. package/src/components/comments/CommentNav.tsx +0 -109
  174. package/src/components/ui/ActionBar.tsx +0 -16
  175. package/src/components/ui/ActionLink.tsx +0 -32
  176. package/src/components/ui/Button.tsx +0 -55
  177. package/src/components/ui/Dialog.tsx +0 -156
  178. package/src/components/ui/DropdownMenu.tsx +0 -114
  179. package/src/components/ui/SeparatorDot.tsx +0 -9
  180. package/src/components/ui/Text.tsx +0 -54
  181. package/src/contexts/CommentContext.tsx +0 -229
  182. package/src/contexts/LayoutContext.tsx +0 -88
  183. package/src/contexts/LocaleContext.tsx +0 -35
  184. package/src/hooks/useClickOutside.ts +0 -35
  185. package/src/hooks/useClipboard.ts +0 -82
  186. package/src/hooks/useCommentNavigation.ts +0 -130
  187. package/src/hooks/useComments.ts +0 -323
  188. package/src/hooks/useDocument.ts +0 -156
  189. package/src/hooks/useEditorScheme.ts +0 -51
  190. package/src/hooks/useFontPreference.ts +0 -59
  191. package/src/hooks/useHeadings.test.ts +0 -159
  192. package/src/hooks/useHeadings.ts +0 -129
  193. package/src/hooks/useKeybindings.ts +0 -108
  194. package/src/hooks/useKeyboardShortcuts.ts +0 -63
  195. package/src/hooks/useLayoutMode.ts +0 -44
  196. package/src/hooks/useLocalePreference.ts +0 -42
  197. package/src/hooks/useReanchorMode.ts +0 -33
  198. package/src/hooks/useScrollMetrics.ts +0 -56
  199. package/src/hooks/useScrollSpy.ts +0 -81
  200. package/src/hooks/useTextSelection.ts +0 -123
  201. package/src/hooks/useThemePreference.ts +0 -66
  202. package/src/lib/context.bench.ts +0 -41
  203. package/src/lib/context.test.ts +0 -224
  204. package/src/lib/context.ts +0 -193
  205. package/src/lib/editor-links.ts +0 -59
  206. package/src/lib/highlight/colors.ts +0 -37
  207. package/src/lib/highlight/index.ts +0 -23
  208. package/src/lib/highlight/script-builder.ts +0 -485
  209. package/src/lib/html-processor.test.tsx +0 -170
  210. package/src/lib/html-processor.tsx +0 -95
  211. package/src/lib/i18n/completeness.test.ts +0 -51
  212. package/src/lib/i18n/translations.test.ts +0 -39
  213. package/src/lib/layout-constants.ts +0 -12
  214. package/src/lib/scroll.test.ts +0 -118
  215. package/src/lib/scroll.ts +0 -47
  216. package/src/lib/shortcut-registry.test.ts +0 -173
  217. package/src/lib/utils.test.ts +0 -110
  218. package/src/main.tsx +0 -13
  219. package/src/store/index.test.ts +0 -242
  220. package/src/store/index.ts +0 -254
  221. 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 "../types";
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
@@ -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-3");
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|2024-12-24T10:30:00Z -->");
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
- createdAt: "2024-12-24T10:30:00Z",
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
- createdAt: "2024-12-24T11:00:00Z",
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 "../types";
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; // chars stored for anchor matching when text is truncated
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}-${endLine}`;
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
- 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;
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
- // Extract metadata from HTML comment: <!-- c:{id}|{lineHint}|{timestamp} -->
136
- const metadataMatch = block.match(/<!--\s*c:([^|]+)\|([^|]+)\|([^>]+)\s*-->/);
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, createdAt] = metadataMatch;
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}|${comment.createdAt} -->`);
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,
@@ -1,35 +1,21 @@
1
1
  import { bench, describe } from "vitest";
2
- import {
3
- COMMENTS_1,
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("generatePrompt", () => {
10
- bench("1 comment", () => {
11
- generatePrompt(COMMENTS_1, "test.md");
12
- });
5
+ describe("formatComment", () => {
6
+ const comment = COMMENTS_10[0];
13
7
 
14
- bench("10 comments", () => {
15
- generatePrompt(COMMENTS_10, "test.md");
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("generateRawText", () => {
24
- bench("1 comment", () => {
25
- generateRawText(COMMENTS_1);
26
- });
27
-
13
+ describe("generatePrompt", () => {
28
14
  bench("10 comments", () => {
29
- generateRawText(COMMENTS_10);
15
+ generatePrompt(COMMENTS_10, "benchmark.md");
30
16
  });
31
17
 
32
18
  bench("50 comments", () => {
33
- generateRawText(COMMENTS_50);
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 "../types";
1
+ import type { Comment, Document } from "../schema";
2
2
 
3
- export function generatePrompt(comments: Comment[], fileName: string): string {
4
- const prompt = comments
5
- .map((c) => {
6
- return `---\nSelected text: "${c.selectedText}"\nComment: ${c.comment}`;
7
- })
8
- .join("\n\n");
9
-
10
- return `# Review Comments for ${fileName}\n\n${prompt}`;
3
+ export function formatComment(c: Comment): string {
4
+ const line = c.lineHint ? `[${c.lineHint}] ` : "";
5
+ return `${line}"${c.selectedText}"\n${c.comment}`;
11
6
  }
12
7
 
13
- export function generateRawText(comments: Comment[]): string {
14
- return comments
15
- .map((c) => `${c.selectedText}\n\n${c.comment}`)
16
- .join("\n\n---\n\n");
8
+ export function generatePrompt(comments: Comment[], fileName: string): string {
9
+ return `# Review Comments for ${fileName}\n\n${comments.map(formatComment).join("\n\n---\n\n")}`;
17
10
  }
18
11
 
19
12
  export function exportCommentsAsJson(
@@ -27,7 +20,7 @@ export function exportCommentsAsJson(
27
20
  comments: comments.map((c) => ({
28
21
  selectedText: c.selectedText,
29
22
  comment: c.comment,
30
- createdAt: c.createdAt,
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
+ }