@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
@@ -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,30 @@ 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",
90
116
  };
@@ -89,4 +89,30 @@ 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": "テキスト選択を解除",
92
118
  };
@@ -92,6 +92,31 @@ 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;
95
120
  }
96
121
 
97
122
  export type TranslationKey = keyof Translations;
@@ -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
+ });
@@ -0,0 +1,177 @@
1
+ import MarkdownIt from "markdown-it";
2
+ import type { BundledLanguage, BundledTheme, HighlighterGeneric } from "shiki";
3
+ import { type Heading, parseMarkdownHeadings } from "./headings";
4
+ import { renderMermaidBlocks } from "./mermaid-renderer";
5
+
6
+ const SHIKI_LANGUAGES: BundledLanguage[] = [
7
+ "bash",
8
+ "css",
9
+ "diff",
10
+ "go",
11
+ "graphql",
12
+ "javascript",
13
+ "json",
14
+ "jsx",
15
+ "markdown",
16
+ "python",
17
+ "rust",
18
+ "sql",
19
+ "tsx",
20
+ "typescript",
21
+ "yaml",
22
+ ];
23
+
24
+ const SHIKI_THEME: BundledTheme = "one-dark-pro";
25
+
26
+ let shikiInstance: HighlighterGeneric<BundledLanguage, BundledTheme> | null =
27
+ null;
28
+ let shikiPromise: Promise<
29
+ HighlighterGeneric<BundledLanguage, BundledTheme>
30
+ > | null = null;
31
+
32
+ export async function getShiki(): Promise<
33
+ HighlighterGeneric<BundledLanguage, BundledTheme>
34
+ > {
35
+ if (shikiInstance) return shikiInstance;
36
+ if (shikiPromise) return shikiPromise;
37
+
38
+ shikiPromise = import("shiki").then(async ({ createHighlighter }) => {
39
+ const highlighter = await createHighlighter({
40
+ themes: [SHIKI_THEME],
41
+ langs: SHIKI_LANGUAGES,
42
+ });
43
+ shikiInstance = highlighter;
44
+ return highlighter;
45
+ });
46
+
47
+ return shikiPromise;
48
+ }
49
+
50
+ function createMarkdownRenderer(
51
+ shiki: HighlighterGeneric<BundledLanguage, BundledTheme>,
52
+ ): MarkdownIt {
53
+ const md = new MarkdownIt({
54
+ html: true, // Allow raw HTML (matches rehype-raw behavior)
55
+ linkify: true,
56
+ typographer: false,
57
+ highlight(code: string, lang: string): string {
58
+ if (lang === "mermaid") {
59
+ return `<pre><code class="language-mermaid">${md.utils.escapeHtml(code)}</code></pre>`;
60
+ }
61
+
62
+ const language = normalizeLanguage(lang);
63
+
64
+ if (language && shiki.getLoadedLanguages().includes(language)) {
65
+ return shiki.codeToHtml(code, {
66
+ lang: language,
67
+ theme: SHIKI_THEME,
68
+ });
69
+ }
70
+
71
+ return `<pre class="shiki"><code>${md.utils.escapeHtml(code)}</code></pre>`;
72
+ },
73
+ });
74
+
75
+ return md;
76
+ }
77
+
78
+ function normalizeLanguage(lang: string): BundledLanguage | undefined {
79
+ const aliases: Record<string, BundledLanguage> = {
80
+ sh: "bash",
81
+ shell: "bash",
82
+ js: "javascript",
83
+ ts: "typescript",
84
+ py: "python",
85
+ rs: "rust",
86
+ yml: "yaml",
87
+ md: "markdown",
88
+ };
89
+ const normalized = lang.toLowerCase().trim();
90
+ if (SHIKI_LANGUAGES.includes(normalized as BundledLanguage)) {
91
+ return normalized as BundledLanguage;
92
+ }
93
+ return aliases[normalized];
94
+ }
95
+
96
+ function injectHeadingIds(html: string, headings: Heading[]): string {
97
+ let headingIdx = 0;
98
+
99
+ return html.replace(
100
+ /<(h[1-6])([^>]*)>([\s\S]*?)<\/\1>/gi,
101
+ (match, tag: string, attrs: string, content: string) => {
102
+ if (headingIdx >= headings.length) return match;
103
+
104
+ const heading = headings[headingIdx];
105
+ headingIdx++;
106
+
107
+ if (/\bid\s*=/.test(attrs)) return match;
108
+
109
+ return `<${tag} id="${heading.id}"${attrs}>${content}</${tag}>`;
110
+ },
111
+ );
112
+ }
113
+
114
+ interface RenderResult {
115
+ html: string;
116
+ headings: Heading[];
117
+ }
118
+
119
+ const MERMAID_BLOCK_RE =
120
+ /<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/g;
121
+
122
+ function unescapeHtml(str: string): string {
123
+ return str
124
+ .replace(/&amp;/g, "&")
125
+ .replace(/&lt;/g, "<")
126
+ .replace(/&gt;/g, ">")
127
+ .replace(/&quot;/g, '"');
128
+ }
129
+
130
+ async function replaceMermaidBlocks(html: string): Promise<string> {
131
+ MERMAID_BLOCK_RE.lastIndex = 0;
132
+ const matches: { fullMatch: string; code: string; index: number }[] = [];
133
+
134
+ let match: RegExpExecArray | null = MERMAID_BLOCK_RE.exec(html);
135
+ while (match !== null) {
136
+ matches.push({
137
+ fullMatch: match[0],
138
+ code: unescapeHtml(match[1]),
139
+ index: match.index,
140
+ });
141
+ match = MERMAID_BLOCK_RE.exec(html);
142
+ }
143
+
144
+ if (matches.length === 0) return html;
145
+
146
+ const codes = matches.map((m) => m.code);
147
+ const svgs = await renderMermaidBlocks(codes);
148
+
149
+ // Replace in reverse order to preserve string indices
150
+ for (let i = matches.length - 1; i >= 0; i--) {
151
+ const svg = svgs[i];
152
+ if (svg !== null) {
153
+ const { fullMatch, index } = matches[i];
154
+ const replacement = `<div class="mermaid-container">${svg}</div>`;
155
+ html =
156
+ html.slice(0, index) +
157
+ replacement +
158
+ html.slice(index + fullMatch.length);
159
+ }
160
+ }
161
+
162
+ return html;
163
+ }
164
+
165
+ export async function renderMarkdown(content: string): Promise<RenderResult> {
166
+ const shiki = await getShiki();
167
+ const md = createMarkdownRenderer(shiki);
168
+
169
+ const headings = parseMarkdownHeadings(content);
170
+
171
+ let html = md.render(content);
172
+
173
+ html = injectHeadingIds(html, headings);
174
+ html = await replaceMermaidBlocks(html);
175
+
176
+ return { html, headings };
177
+ }