@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
@@ -0,0 +1,161 @@
1
+ const BLOCK_ELEMENTS = new Set([
2
+ "p",
3
+ "div",
4
+ "h1",
5
+ "h2",
6
+ "h3",
7
+ "h4",
8
+ "h5",
9
+ "h6",
10
+ "pre",
11
+ "blockquote",
12
+ "li",
13
+ "tr",
14
+ "br",
15
+ ]);
16
+
17
+ interface TextNode {
18
+ text: string;
19
+ blockAncestorPath: string[];
20
+ }
21
+
22
+ export function extractTextFromHtml(html: string): string {
23
+ const textNodes = collectTextNodesFromHtml(html);
24
+ if (textNodes.length === 0) return "";
25
+
26
+ let result = "";
27
+ let lastBlockPath: string[] | null = null;
28
+
29
+ for (const node of textNodes) {
30
+ if (lastBlockPath) {
31
+ const lastBlock = lastBlockPath;
32
+ const currBlock = node.blockAncestorPath;
33
+
34
+ if (
35
+ lastBlock.length > 0 &&
36
+ currBlock.length > 0 &&
37
+ !isNested(lastBlock, currBlock)
38
+ ) {
39
+ if (
40
+ lastBlock[lastBlock.length - 1] !== currBlock[currBlock.length - 1]
41
+ ) {
42
+ result += "\n";
43
+ }
44
+ }
45
+ }
46
+
47
+ result += node.text;
48
+ lastBlockPath = node.blockAncestorPath;
49
+ }
50
+
51
+ return result;
52
+ }
53
+
54
+ function isNested(pathA: string[], pathB: string[]): boolean {
55
+ const blockA = pathA[pathA.length - 1];
56
+ const blockB = pathB[pathB.length - 1];
57
+
58
+ if (pathB.includes(blockA)) return true;
59
+ if (pathA.includes(blockB)) return true;
60
+ return false;
61
+ }
62
+
63
+ function collectTextNodesFromHtml(html: string): TextNode[] {
64
+ const nodes: TextNode[] = [];
65
+ const stack: { tag: string; id: string }[] = [];
66
+ let idCounter = 0;
67
+
68
+ const tagPattern = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*\/?>/g;
69
+ let lastIndex = 0;
70
+ let match: RegExpExecArray | null;
71
+
72
+ match = tagPattern.exec(html);
73
+ while (match !== null) {
74
+ if (match.index > lastIndex) {
75
+ const text = decodeEntities(html.slice(lastIndex, match.index));
76
+ if (text) {
77
+ nodes.push({
78
+ text,
79
+ blockAncestorPath: getBlockAncestorPath(stack),
80
+ });
81
+ }
82
+ }
83
+
84
+ const fullTag = match[0];
85
+ const tagName = match[1].toLowerCase();
86
+ const isClosing = fullTag.startsWith("</");
87
+ const isSelfClosing = fullTag.endsWith("/>") || isVoidElement(tagName);
88
+
89
+ if (isClosing) {
90
+ for (let i = stack.length - 1; i >= 0; i--) {
91
+ if (stack[i].tag === tagName) {
92
+ stack.splice(i);
93
+ break;
94
+ }
95
+ }
96
+ } else if (!isSelfClosing) {
97
+ stack.push({ tag: tagName, id: `e${idCounter++}` });
98
+ }
99
+
100
+ lastIndex = match.index + fullTag.length;
101
+ match = tagPattern.exec(html);
102
+ }
103
+
104
+ if (lastIndex < html.length) {
105
+ const text = decodeEntities(html.slice(lastIndex));
106
+ if (text) {
107
+ nodes.push({
108
+ text,
109
+ blockAncestorPath: getBlockAncestorPath(stack),
110
+ });
111
+ }
112
+ }
113
+
114
+ return nodes;
115
+ }
116
+
117
+ function getBlockAncestorPath(stack: { tag: string; id: string }[]): string[] {
118
+ const path: string[] = [];
119
+ for (const entry of stack) {
120
+ if (BLOCK_ELEMENTS.has(entry.tag)) {
121
+ path.push(entry.id);
122
+ }
123
+ }
124
+ return path;
125
+ }
126
+
127
+ const VOID_ELEMENTS = new Set([
128
+ "area",
129
+ "base",
130
+ "br",
131
+ "col",
132
+ "embed",
133
+ "hr",
134
+ "img",
135
+ "input",
136
+ "link",
137
+ "meta",
138
+ "param",
139
+ "source",
140
+ "track",
141
+ "wbr",
142
+ ]);
143
+
144
+ function isVoidElement(tag: string): boolean {
145
+ return VOID_ELEMENTS.has(tag);
146
+ }
147
+
148
+ function decodeEntities(text: string): string {
149
+ return text
150
+ .replace(/&amp;/g, "&")
151
+ .replace(/&lt;/g, "<")
152
+ .replace(/&gt;/g, ">")
153
+ .replace(/&quot;/g, '"')
154
+ .replace(/&#39;/g, "'")
155
+ .replace(/&#x27;/g, "'")
156
+ .replace(/&nbsp;/g, "\u00A0")
157
+ .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
158
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) =>
159
+ String.fromCharCode(Number.parseInt(hex, 16)),
160
+ );
161
+ }
@@ -87,4 +87,38 @@ export const en: Translations = {
87
87
  // Comment badge
88
88
  "commentBadge.title": "{{count}} comment",
89
89
  "commentBadge.titlePlural": "{{count}} comments",
90
+
91
+ // Keyboard shortcuts
92
+ "shortcuts.title": "Keyboard Shortcuts",
93
+ "shortcutGroup.copy": "Copy",
94
+ "shortcutGroup.navigate": "Navigate",
95
+ "shortcutGroup.other": "Other",
96
+ "shortcuts.resetToDefaults": "Reset to defaults",
97
+ "shortcuts.enableDisable": "Enable/disable shortcut",
98
+ "shortcutCapture.pressKeys": "Press keys...",
99
+ "shortcutCapture.reserved": "{{binding}} is reserved",
100
+ "shortcutCapture.ariaLabel": "Press a key combination",
101
+ "shortcut.copyAll.label": "Copy All (AI)",
102
+ "shortcut.copyAll.description": "Copy all comments in AI prompt format",
103
+ "shortcut.copyAllRaw.label": "Copy All (Raw)",
104
+ "shortcut.copyAllRaw.description": "Copy all comments as raw text",
105
+ "shortcut.navigateNext.label": "Next Comment",
106
+ "shortcut.navigateNext.description": "Navigate to next comment",
107
+ "shortcut.navigatePrevious.label": "Previous Comment",
108
+ "shortcut.navigatePrevious.description": "Navigate to previous comment",
109
+ "shortcut.copySelectionRaw.label": "Copy Selection",
110
+ "shortcut.copySelectionRaw.description": "Copy selected text",
111
+ "shortcut.copySelectionLLM.label": "Copy Selection (LLM)",
112
+ "shortcut.copySelectionLLM.description":
113
+ "Copy selected text with context for LLM",
114
+ "shortcut.clearSelection.label": "Clear Selection",
115
+ "shortcut.clearSelection.description": "Clear text selection",
116
+
117
+ // Mermaid diagram
118
+ "mermaid.modalTitle": "Mermaid diagram",
119
+ "mermaid.viewGraph": "Graph",
120
+ "mermaid.viewCode": "Code",
121
+ "mermaid.showSource": "Show source",
122
+ "mermaid.showDiagram": "Show diagram",
123
+ "mermaid.expand": "Expand diagram",
90
124
  };
@@ -89,4 +89,38 @@ export const ja: Translations = {
89
89
  // Comment badge
90
90
  "commentBadge.title": "{{count}}件のコメント",
91
91
  "commentBadge.titlePlural": "{{count}}件のコメント",
92
+
93
+ // Keyboard shortcuts
94
+ "shortcuts.title": "キーボードショートカット",
95
+ "shortcutGroup.copy": "コピー",
96
+ "shortcutGroup.navigate": "ナビゲーション",
97
+ "shortcutGroup.other": "その他",
98
+ "shortcuts.resetToDefaults": "初期設定に戻す",
99
+ "shortcuts.enableDisable": "ショートカットの有効/無効",
100
+ "shortcutCapture.pressKeys": "キーを入力...",
101
+ "shortcutCapture.reserved": "{{binding}} は予約されています",
102
+ "shortcutCapture.ariaLabel": "キーの組み合わせを入力",
103
+ "shortcut.copyAll.label": "全てコピー (AI)",
104
+ "shortcut.copyAll.description": "全コメントをAIプロンプト形式でコピー",
105
+ "shortcut.copyAllRaw.label": "全てコピー (テキスト)",
106
+ "shortcut.copyAllRaw.description": "全コメントをテキストとしてコピー",
107
+ "shortcut.navigateNext.label": "次のコメント",
108
+ "shortcut.navigateNext.description": "次のコメントに移動",
109
+ "shortcut.navigatePrevious.label": "前のコメント",
110
+ "shortcut.navigatePrevious.description": "前のコメントに移動",
111
+ "shortcut.copySelectionRaw.label": "選択をコピー",
112
+ "shortcut.copySelectionRaw.description": "選択テキストをコピー",
113
+ "shortcut.copySelectionLLM.label": "選択をコピー (LLM)",
114
+ "shortcut.copySelectionLLM.description":
115
+ "選択テキストをLLMコンテキスト付きでコピー",
116
+ "shortcut.clearSelection.label": "選択を解除",
117
+ "shortcut.clearSelection.description": "テキスト選択を解除",
118
+
119
+ // Mermaid diagram
120
+ "mermaid.modalTitle": "Mermaid ダイアグラム",
121
+ "mermaid.viewGraph": "図",
122
+ "mermaid.viewCode": "コード",
123
+ "mermaid.showSource": "ソースを表示",
124
+ "mermaid.showDiagram": "図を表示",
125
+ "mermaid.expand": "拡大表示",
92
126
  };
@@ -92,6 +92,39 @@ export interface Translations {
92
92
  // Comment badge
93
93
  "commentBadge.title": string;
94
94
  "commentBadge.titlePlural": string;
95
+
96
+ // Keyboard shortcuts
97
+ "shortcuts.title": string;
98
+ "shortcutGroup.copy": string;
99
+ "shortcutGroup.navigate": string;
100
+ "shortcutGroup.other": string;
101
+ "shortcuts.resetToDefaults": string;
102
+ "shortcuts.enableDisable": string;
103
+ "shortcutCapture.pressKeys": string;
104
+ "shortcutCapture.reserved": string;
105
+ "shortcutCapture.ariaLabel": string;
106
+ "shortcut.copyAll.label": string;
107
+ "shortcut.copyAll.description": string;
108
+ "shortcut.copyAllRaw.label": string;
109
+ "shortcut.copyAllRaw.description": string;
110
+ "shortcut.navigateNext.label": string;
111
+ "shortcut.navigateNext.description": string;
112
+ "shortcut.navigatePrevious.label": string;
113
+ "shortcut.navigatePrevious.description": string;
114
+ "shortcut.copySelectionRaw.label": string;
115
+ "shortcut.copySelectionRaw.description": string;
116
+ "shortcut.copySelectionLLM.label": string;
117
+ "shortcut.copySelectionLLM.description": string;
118
+ "shortcut.clearSelection.label": string;
119
+ "shortcut.clearSelection.description": string;
120
+
121
+ // Mermaid diagram
122
+ "mermaid.modalTitle": string;
123
+ "mermaid.viewGraph": string;
124
+ "mermaid.viewCode": string;
125
+ "mermaid.showSource": string;
126
+ "mermaid.showDiagram": string;
127
+ "mermaid.expand": string;
95
128
  }
96
129
 
97
130
  export type TranslationKey = keyof Translations;
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createKeyLock } from "./key-lock";
3
+
4
+ function deferred<T>(): {
5
+ promise: Promise<T>;
6
+ resolve: (v: T) => void;
7
+ reject: (e: unknown) => void;
8
+ } {
9
+ let resolve!: (v: T) => void;
10
+ let reject!: (e: unknown) => void;
11
+ const promise = new Promise<T>((res, rej) => {
12
+ resolve = res;
13
+ reject = rej;
14
+ });
15
+ return { promise, resolve, reject };
16
+ }
17
+
18
+ describe("createKeyLock", () => {
19
+ it("serializes operations on the same key", async () => {
20
+ const withLock = createKeyLock("serial");
21
+ const order: number[] = [];
22
+ const a = deferred<void>();
23
+ const b = deferred<void>();
24
+
25
+ const p1 = withLock("k", async () => {
26
+ await a.promise;
27
+ order.push(1);
28
+ });
29
+ const p2 = withLock("k", async () => {
30
+ await b.promise;
31
+ order.push(2);
32
+ });
33
+
34
+ // p2 must NOT have started yet because p1 hasn't resolved
35
+ b.resolve();
36
+ await Promise.resolve();
37
+ await Promise.resolve();
38
+ expect(order).toEqual([]);
39
+
40
+ a.resolve();
41
+ await p1;
42
+ await p2;
43
+ expect(order).toEqual([1, 2]);
44
+ });
45
+
46
+ it("runs different keys concurrently", async () => {
47
+ const withLock = createKeyLock("concurrent");
48
+ const order: string[] = [];
49
+ const a = deferred<void>();
50
+
51
+ const p1 = withLock("alpha", async () => {
52
+ await a.promise;
53
+ order.push("alpha");
54
+ });
55
+ const p2 = withLock("beta", async () => {
56
+ order.push("beta");
57
+ });
58
+
59
+ await p2;
60
+ expect(order).toEqual(["beta"]);
61
+
62
+ a.resolve();
63
+ await p1;
64
+ expect(order).toEqual(["beta", "alpha"]);
65
+ });
66
+
67
+ it("survives a thrown error in a previous holder", async () => {
68
+ const withLock = createKeyLock("error-recovery");
69
+ const p1 = withLock("k", async () => {
70
+ throw new Error("boom");
71
+ });
72
+ await expect(p1).rejects.toThrow("boom");
73
+
74
+ const p2 = withLock("k", async () => 42);
75
+ await expect(p2).resolves.toBe(42);
76
+ });
77
+
78
+ it("preserves resolved return values", async () => {
79
+ const withLock = createKeyLock("return-value");
80
+ const v = await withLock("k", async () => "value");
81
+ expect(v).toBe("value");
82
+ });
83
+
84
+ it("namespaces are independent", async () => {
85
+ const a = createKeyLock("ns-a");
86
+ const b = createKeyLock("ns-b");
87
+ const order: string[] = [];
88
+ const block = deferred<void>();
89
+
90
+ const pa = a("shared-key", async () => {
91
+ await block.promise;
92
+ order.push("a");
93
+ });
94
+ const pb = b("shared-key", async () => {
95
+ order.push("b");
96
+ });
97
+
98
+ await pb;
99
+ expect(order).toEqual(["b"]);
100
+ block.resolve();
101
+ await pa;
102
+ expect(order).toEqual(["b", "a"]);
103
+ });
104
+ });
@@ -0,0 +1,23 @@
1
+ const locks = new Map<string, Map<string, Promise<unknown>>>();
2
+
3
+ function namespace(name: string): Map<string, Promise<unknown>> {
4
+ let n = locks.get(name);
5
+ if (!n) {
6
+ n = new Map();
7
+ locks.set(name, n);
8
+ }
9
+ return n;
10
+ }
11
+
12
+ export function createKeyLock(name: string) {
13
+ const map = namespace(name);
14
+ return function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
15
+ const prev = map.get(key) ?? Promise.resolve();
16
+ const next = prev.catch(() => {}).then(fn);
17
+ map.set(
18
+ key,
19
+ next.catch(() => {}),
20
+ );
21
+ return next;
22
+ };
23
+ }
@@ -0,0 +1,61 @@
1
+ import { bench, describe } from "vitest";
2
+ import {
3
+ COMMENTS_1,
4
+ COMMENTS_10,
5
+ COMMENTS_50,
6
+ } from "./__fixtures__/bench-data";
7
+ import { resolveMarginNotePositions } from "./margin-layout";
8
+
9
+ function makeHighlightPositions(
10
+ commentIds: string[],
11
+ spacing: number,
12
+ ): Record<string, number> {
13
+ const positions: Record<string, number> = {};
14
+ for (let i = 0; i < commentIds.length; i++) {
15
+ positions[commentIds[i]] = i * spacing;
16
+ }
17
+ return positions;
18
+ }
19
+
20
+ describe("resolveMarginNotePositions — well-spaced", () => {
21
+ bench("1 comment", () => {
22
+ const ids = COMMENTS_1.map((c) => c.id);
23
+ const positions = makeHighlightPositions(ids, 300);
24
+ resolveMarginNotePositions(ids, positions, undefined);
25
+ });
26
+
27
+ bench("10 comments", () => {
28
+ const ids = COMMENTS_10.map((c) => c.id);
29
+ const positions = makeHighlightPositions(ids, 300);
30
+ resolveMarginNotePositions(ids, positions, undefined);
31
+ });
32
+
33
+ bench("50 comments", () => {
34
+ const ids = COMMENTS_50.map((c) => c.id);
35
+ const positions = makeHighlightPositions(ids, 300);
36
+ resolveMarginNotePositions(ids, positions, undefined);
37
+ });
38
+ });
39
+
40
+ describe("resolveMarginNotePositions — clustered", () => {
41
+ bench("10 comments, 10px apart", () => {
42
+ const ids = COMMENTS_10.map((c) => c.id);
43
+ const positions = makeHighlightPositions(ids, 10);
44
+ resolveMarginNotePositions(ids, positions, undefined);
45
+ });
46
+
47
+ bench("50 comments, 10px apart", () => {
48
+ const ids = COMMENTS_50.map((c) => c.id);
49
+ const positions = makeHighlightPositions(ids, 10);
50
+ resolveMarginNotePositions(ids, positions, undefined);
51
+ });
52
+ });
53
+
54
+ describe("resolveMarginNotePositions — with input zone", () => {
55
+ bench("50 comments, input at middle", () => {
56
+ const ids = COMMENTS_50.map((c) => c.id);
57
+ const positions = makeHighlightPositions(ids, 100);
58
+ const midpoint = 25 * 100;
59
+ resolveMarginNotePositions(ids, positions, midpoint);
60
+ });
61
+ });
@@ -6,16 +6,11 @@ interface NotePosition {
6
6
  top: number;
7
7
  }
8
8
 
9
- /**
10
- * Resolves margin note positions to avoid overlaps.
11
- * Pass 1: push notes above input zone UP. Pass 2: push notes at/below DOWN.
12
- */
13
9
  export function resolveMarginNotePositions(
14
10
  commentIds: string[],
15
11
  highlightPositions: Record<string, number>,
16
12
  pendingSelectionTop: number | undefined,
17
13
  ): Map<string, number> {
18
- // Only include comments with known positions (avoids jolt to position 0)
19
14
  const positions: NotePosition[] = commentIds
20
15
  .filter((id) => id in highlightPositions)
21
16
  .map((id) => ({
@@ -51,7 +46,6 @@ export function resolveMarginNotePositions(
51
46
  ? pendingSelectionTop + COMMENT_INPUT_HEIGHT_PX
52
47
  : Infinity;
53
48
 
54
- // Pass 1: push notes above input UP (bottom to top)
55
49
  for (let i = positions.length - 2; i >= 0; i--) {
56
50
  const curr = positions[i];
57
51
  const next = positions[i + 1];
@@ -61,7 +55,6 @@ export function resolveMarginNotePositions(
61
55
  }
62
56
  }
63
57
 
64
- // Pass 2: push notes at/below input DOWN (top to bottom)
65
58
  for (let i = 1; i < positions.length; i++) {
66
59
  const prev = positions[i - 1];
67
60
  const curr = positions[i];
@@ -0,0 +1,154 @@
1
+ import { JSDOM } from "jsdom";
2
+ import { describe, expect, it } from "vitest";
3
+ import { renderMarkdown } from "./markdown-renderer";
4
+
5
+ const SAMPLE_MARKDOWN = `# Hello World
6
+
7
+ This is a paragraph with **bold** and *italic* text.
8
+
9
+ ## Code Example
10
+
11
+ \`\`\`typescript
12
+ function hello() {
13
+ return "world";
14
+ }
15
+ \`\`\`
16
+
17
+ Some text after the code block.
18
+
19
+ - Item 1
20
+ - Item 2
21
+ - Item 3
22
+
23
+ ### Table
24
+
25
+ | Name | Value |
26
+ |------|-------|
27
+ | foo | bar |
28
+ | baz | qux |
29
+
30
+ > A blockquote with some text.
31
+
32
+ Final paragraph.
33
+ `;
34
+
35
+ const SAMPLE_WITH_MERMAID = `# Diagrams
36
+
37
+ \`\`\`mermaid
38
+ graph TD
39
+ A --> B
40
+ \`\`\`
41
+
42
+ After mermaid.
43
+ `;
44
+
45
+ describe("renderMarkdown", () => {
46
+ it("renders basic markdown to HTML", async () => {
47
+ const { html } = await renderMarkdown(SAMPLE_MARKDOWN);
48
+
49
+ expect(html).toContain("<h1");
50
+ expect(html).toContain("Hello World");
51
+ expect(html).toContain("<strong>bold</strong>");
52
+ expect(html).toContain("<em>italic</em>");
53
+ expect(html).toContain("<h2");
54
+ expect(html).toContain("Code Example");
55
+ expect(html).toContain("<h3");
56
+ expect(html).toContain("Table");
57
+ });
58
+
59
+ it("extracts headings with correct IDs", async () => {
60
+ const { headings } = await renderMarkdown(SAMPLE_MARKDOWN);
61
+
62
+ expect(headings).toEqual([
63
+ { id: "hello-world", text: "Hello World", level: 1 },
64
+ { id: "code-example", text: "Code Example", level: 2 },
65
+ { id: "table", text: "Table", level: 3 },
66
+ ]);
67
+ });
68
+
69
+ it("injects heading IDs into HTML", async () => {
70
+ const { html } = await renderMarkdown(SAMPLE_MARKDOWN);
71
+
72
+ expect(html).toContain('id="hello-world"');
73
+ expect(html).toContain('id="code-example"');
74
+ expect(html).toContain('id="table"');
75
+ });
76
+
77
+ it("syntax-highlights code blocks with shiki", async () => {
78
+ const { html } = await renderMarkdown(SAMPLE_MARKDOWN);
79
+
80
+ expect(html).toContain("shiki");
81
+ expect(html).toContain("hello");
82
+ expect(html).toContain("world");
83
+ });
84
+
85
+ it("leaves mermaid blocks for client-side hydration", async () => {
86
+ const { html } = await renderMarkdown(SAMPLE_WITH_MERMAID);
87
+
88
+ expect(html).toContain('class="language-mermaid"');
89
+ expect(html).toContain("graph TD");
90
+ expect(html).not.toMatch(/class="shiki[^"]*"[^>]*>.*graph TD/s);
91
+ });
92
+
93
+ it("renders GFM tables", async () => {
94
+ const { html } = await renderMarkdown(SAMPLE_MARKDOWN);
95
+
96
+ expect(html).toContain("<table>");
97
+ expect(html).toContain("<th>");
98
+ expect(html).toContain("foo");
99
+ });
100
+
101
+ it("renders lists", async () => {
102
+ const { html } = await renderMarkdown(SAMPLE_MARKDOWN);
103
+
104
+ expect(html).toContain("<ul>");
105
+ expect(html).toContain("<li>");
106
+ expect(html).toContain("Item 1");
107
+ });
108
+
109
+ it("renders blockquotes", async () => {
110
+ const { html } = await renderMarkdown(SAMPLE_MARKDOWN);
111
+
112
+ expect(html).toContain("<blockquote>");
113
+ expect(html).toContain("A blockquote with some text.");
114
+ });
115
+
116
+ it("handles duplicate headings", async () => {
117
+ const md = `## Section\n\n## Section\n\n## Section\n`;
118
+ const { headings } = await renderMarkdown(md);
119
+
120
+ expect(headings).toEqual([
121
+ { id: "section", text: "Section", level: 2 },
122
+ { id: "section-1", text: "Section", level: 2 },
123
+ { id: "section-2", text: "Section", level: 2 },
124
+ ]);
125
+ });
126
+ });
127
+
128
+ describe("text offset conformance", () => {
129
+ it("rendered HTML has proper block structure for text offset extraction", async () => {
130
+ const { html } = await renderMarkdown(SAMPLE_MARKDOWN);
131
+
132
+ const dom = new JSDOM(html);
133
+ const doc = dom.window.document;
134
+
135
+ const paragraphs = doc.querySelectorAll("p");
136
+ expect(paragraphs.length).toBeGreaterThan(0);
137
+
138
+ const h1 = doc.querySelector("h1");
139
+ expect(h1?.id).toBe("hello-world");
140
+ expect(h1?.textContent).toBe("Hello World");
141
+
142
+ const pres = doc.querySelectorAll("pre");
143
+ expect(pres.length).toBeGreaterThan(0);
144
+
145
+ const lis = doc.querySelectorAll("li");
146
+ expect(lis.length).toBe(3);
147
+
148
+ const trs = doc.querySelectorAll("tr");
149
+ expect(trs.length).toBeGreaterThan(0);
150
+
151
+ const blockquote = doc.querySelector("blockquote");
152
+ expect(blockquote).not.toBeNull();
153
+ });
154
+ });