@peaske7/readit 0.2.0 → 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 (173) 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 +118 -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 +881 -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 +218 -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/RawModal.svelte +126 -0
  68. package/src/components/ReanchorConfirm.svelte +30 -0
  69. package/src/components/SettingsModal.svelte +220 -0
  70. package/src/components/ShortcutCapture.svelte +82 -0
  71. package/src/components/ShortcutList.svelte +145 -0
  72. package/src/components/TabBar.svelte +52 -0
  73. package/src/components/TableOfContents.svelte +125 -0
  74. package/src/components/ui/ActionLink.svelte +40 -0
  75. package/src/components/ui/{Button.tsx → Button.svelte} +19 -20
  76. package/src/components/ui/Dialog.svelte +97 -0
  77. package/src/components/ui/DropdownMenu.svelte +85 -0
  78. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  79. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  80. package/src/components/ui/{Text.tsx → Text.svelte} +18 -23
  81. package/src/env.d.ts +6 -0
  82. package/src/index.css +36 -166
  83. package/src/lib/__fixtures__/bench-data.ts +0 -13
  84. package/src/lib/anchor.bench.ts +1 -12
  85. package/src/lib/anchor.test.ts +0 -8
  86. package/src/lib/anchor.ts +0 -4
  87. package/src/lib/comment-storage.bench.ts +49 -0
  88. package/src/lib/comment-storage.test.ts +41 -33
  89. package/src/lib/comment-storage.ts +21 -18
  90. package/src/lib/export.bench.ts +21 -0
  91. package/src/lib/export.ts +0 -1
  92. package/src/{hooks/useHeadings.test.ts → lib/headings.test.ts} +10 -24
  93. package/src/{hooks/useHeadings.ts → lib/headings.ts} +11 -13
  94. package/src/lib/highlight/core.test.ts +0 -5
  95. package/src/lib/highlight/dom.ts +52 -216
  96. package/src/lib/highlight/highlight-registry.ts +221 -0
  97. package/src/lib/highlight/highlight.bench.ts +92 -0
  98. package/src/lib/highlight/highlighter.ts +112 -132
  99. package/src/lib/highlight/resolver.ts +5 -79
  100. package/src/lib/highlight/types.ts +0 -5
  101. package/src/lib/html-text.test.ts +162 -0
  102. package/src/lib/html-text.ts +161 -0
  103. package/src/lib/i18n/en.ts +26 -0
  104. package/src/lib/i18n/ja.ts +26 -0
  105. package/src/lib/i18n/types.ts +25 -0
  106. package/src/lib/margin-layout.bench.ts +61 -0
  107. package/src/lib/margin-layout.ts +0 -7
  108. package/src/lib/markdown-renderer.test.ts +154 -0
  109. package/src/lib/markdown-renderer.ts +177 -0
  110. package/src/lib/mermaid-config.ts +38 -0
  111. package/src/lib/mermaid-renderer.ts +162 -0
  112. package/src/lib/mermaid-worker.ts +60 -0
  113. package/src/lib/positions.ts +31 -24
  114. package/src/lib/shortcut-registry.ts +244 -0
  115. package/src/lib/utils.ts +0 -29
  116. package/src/main.ts +16 -0
  117. package/src/schema.ts +16 -5
  118. package/src/server.ts +355 -91
  119. package/src/stores/app.svelte.ts +231 -0
  120. package/src/stores/locale.svelte.ts +46 -0
  121. package/src/stores/settings.svelte.ts +90 -0
  122. package/src/stores/shortcuts.svelte.ts +104 -0
  123. package/src/stores/ui.svelte.ts +12 -0
  124. package/src/template.ts +104 -0
  125. package/src/test-setup.ts +47 -0
  126. package/svelte.config.js +5 -0
  127. package/tsconfig.json +2 -2
  128. package/vite.config.ts +23 -3
  129. package/vscode-readit/.mcp.json +7 -0
  130. package/vscode-readit/.vscodeignore +7 -0
  131. package/vscode-readit/bun.lock +78 -0
  132. package/vscode-readit/icon.svg +10 -0
  133. package/vscode-readit/package.json +110 -0
  134. package/vscode-readit/src/extension.ts +117 -0
  135. package/vscode-readit/src/server-manager.ts +272 -0
  136. package/vscode-readit/src/webview-provider.ts +204 -0
  137. package/vscode-readit/tsconfig.json +20 -0
  138. package/e2e/fixtures/sample.html +0 -13
  139. package/src/App.tsx +0 -368
  140. package/src/components/ActionsMenu.tsx +0 -91
  141. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  142. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -230
  143. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -136
  144. package/src/components/Header.tsx +0 -54
  145. package/src/components/InlineEditor.tsx +0 -74
  146. package/src/components/MarginNote.tsx +0 -185
  147. package/src/components/MarginNotes.tsx +0 -23
  148. package/src/components/RawModal.tsx +0 -144
  149. package/src/components/ReanchorConfirm.tsx +0 -36
  150. package/src/components/SettingsModal.tsx +0 -232
  151. package/src/components/TabBar.tsx +0 -60
  152. package/src/components/TableOfContents.tsx +0 -108
  153. package/src/components/comments/CommentBadge.tsx +0 -49
  154. package/src/components/comments/CommentInput.tsx +0 -86
  155. package/src/components/comments/CommentListItem.tsx +0 -90
  156. package/src/components/comments/CommentManager.tsx +0 -129
  157. package/src/components/comments/CommentNav.tsx +0 -109
  158. package/src/components/ui/ActionLink.tsx +0 -28
  159. package/src/components/ui/Dialog.tsx +0 -116
  160. package/src/components/ui/DropdownMenu.tsx +0 -158
  161. package/src/contexts/CommentContext.tsx +0 -198
  162. package/src/contexts/LocaleContext.tsx +0 -76
  163. package/src/contexts/PositionsContext.tsx +0 -16
  164. package/src/contexts/SettingsContext.tsx +0 -133
  165. package/src/hooks/useClickOutside.ts +0 -31
  166. package/src/hooks/useCommentNavigation.ts +0 -107
  167. package/src/hooks/useComments.ts +0 -311
  168. package/src/hooks/useDocument.ts +0 -157
  169. package/src/hooks/useScrollSpy.ts +0 -77
  170. package/src/hooks/useTextSelection.ts +0 -86
  171. package/src/lib/highlight/worker.ts +0 -45
  172. package/src/main.tsx +0 -13
  173. 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,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,7 +349,6 @@ 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
352
  lineHint: "L42-L43",
367
353
  startOffset: 100,
368
354
  endOffset: 120,
@@ -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,7 +421,6 @@ 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
424
  lineHint: "L50-L52",
443
425
  startOffset: 200,
444
426
  endOffset: 230,
@@ -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);
@@ -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,15 @@ 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] = metadataMatch;
117
126
 
118
- // Extract anchor prefix if present: <!-- anchor:{prefix} -->
119
127
  const anchorMatch = block.match(/<!--\s*anchor:(.+?)\s*-->/);
120
128
  const anchorPrefix = anchorMatch ? anchorMatch[1] : undefined;
121
129
 
@@ -138,10 +146,8 @@ function parseCommentBlock(block: string): Comment | undefined {
138
146
  id,
139
147
  selectedText,
140
148
  comment: commentBody,
141
- createdAt: createdAt.trim(),
142
149
  lineHint,
143
150
  anchorPrefix,
144
- // Offsets will be resolved by anchor matching when loading
145
151
  startOffset: 0,
146
152
  endOffset: 0,
147
153
  };
@@ -171,7 +177,7 @@ function serializeComment(comment: Comment): string {
171
177
  const lines: string[] = [];
172
178
 
173
179
  const lineHint = comment.lineHint || "L0";
174
- lines.push(`<!-- c:${comment.id}|${lineHint}|${comment.createdAt} -->`);
180
+ lines.push(`<!-- c:${comment.id}|${lineHint} -->`);
175
181
 
176
182
  if (comment.anchorPrefix) {
177
183
  lines.push(`<!-- anchor:${comment.anchorPrefix} -->`);
@@ -199,8 +205,6 @@ export function createComment(
199
205
  ): Comment {
200
206
  const id = crypto.randomUUID().slice(0, 8);
201
207
  const lineHint = getLineHint(sourceContent, startOffset, endOffset);
202
- const now = new Date();
203
- const createdAt = now.toISOString();
204
208
 
205
209
  const needsTruncation = selectedText.length > MAX_SELECTION_LENGTH;
206
210
 
@@ -208,7 +212,6 @@ export function createComment(
208
212
  id,
209
213
  selectedText: truncateSelection(selectedText),
210
214
  comment: commentText,
211
- createdAt,
212
215
  startOffset,
213
216
  endOffset,
214
217
  lineHint,
@@ -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
 
@@ -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
  });