md-redline 0.1.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 (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/bin/md-redline +255 -0
  4. package/bin/test-windows.ps1 +70 -0
  5. package/dist/assets/_baseFor-Ck08IaSF.js +1 -0
  6. package/dist/assets/arc-DI2g9LXK.js +1 -0
  7. package/dist/assets/architecture-YZFGNWBL-BDgMfc-b.js +1 -0
  8. package/dist/assets/architectureDiagram-Q4EWVU46-Dg1hcUEa.js +36 -0
  9. package/dist/assets/array-DOVTz2Mq.js +1 -0
  10. package/dist/assets/blockDiagram-DXYQGD6D-BAXkTCAk.js +132 -0
  11. package/dist/assets/c4Diagram-AHTNJAMY-BIkgwQSx.js +10 -0
  12. package/dist/assets/channel-DPCihw7y.js +1 -0
  13. package/dist/assets/chunk-2KRD3SAO-Dc_tBGsw.js +1 -0
  14. package/dist/assets/chunk-336JU56O-Dhi-ID9Y.js +2 -0
  15. package/dist/assets/chunk-426QAEUC-DnFdrNMW.js +1 -0
  16. package/dist/assets/chunk-4BX2VUAB-Z63FkGov.js +1 -0
  17. package/dist/assets/chunk-4TB4RGXK-BAiBlfyy.js +206 -0
  18. package/dist/assets/chunk-55IACEB6-BXDWXbxy.js +1 -0
  19. package/dist/assets/chunk-5FUZZQ4R-C72e1c_O.js +62 -0
  20. package/dist/assets/chunk-5PVQY5BW-BBHW_uCu.js +2 -0
  21. package/dist/assets/chunk-67CJDMHE-3Cf_D9m6.js +1 -0
  22. package/dist/assets/chunk-7N4EOEYR-DAXUXJ2c.js +1 -0
  23. package/dist/assets/chunk-AA7GKIK3-Dr7fOryc.js +1 -0
  24. package/dist/assets/chunk-BSJP7CBP-BmsSs1Nt.js +1 -0
  25. package/dist/assets/chunk-CIAEETIT-QDzV-X_Y.js +1 -0
  26. package/dist/assets/chunk-EDXVE4YY-C25WFHxY.js +1 -0
  27. package/dist/assets/chunk-ENJZ2VHE-_OzxcZOU.js +10 -0
  28. package/dist/assets/chunk-FMBD7UC4-CjsTKY4u.js +15 -0
  29. package/dist/assets/chunk-FOC6F5B3-g-xaH5nc.js +1 -0
  30. package/dist/assets/chunk-ICPOFSXX-iKiUSjDK.js +121 -0
  31. package/dist/assets/chunk-K5T4RW27-CKR-lPBN.js +94 -0
  32. package/dist/assets/chunk-KGLVRYIC-DRccT-B_.js +1 -0
  33. package/dist/assets/chunk-LIHQZDEY-DTbMwMXj.js +1 -0
  34. package/dist/assets/chunk-ORNJ4GCN-DlerdcWX.js +1 -0
  35. package/dist/assets/chunk-OYMX7WX6-Dekv1on2.js +231 -0
  36. package/dist/assets/chunk-QZHKN3VN-BHu0RdKl.js +1 -0
  37. package/dist/assets/chunk-U2HBQHQK-BvtlVHAg.js +70 -0
  38. package/dist/assets/chunk-X2U36JSP-BI_g8mub.js +1 -0
  39. package/dist/assets/chunk-XPW4576I-B39JkmSE.js +32 -0
  40. package/dist/assets/chunk-YZCP3GAM-BfPcXRm2.js +1 -0
  41. package/dist/assets/chunk-ZZ45TVLE-Bg4q68wZ.js +1 -0
  42. package/dist/assets/classDiagram-6PBFFD2Q-p73p727_.js +1 -0
  43. package/dist/assets/classDiagram-v2-HSJHXN6E-C4Ftpivp.js +1 -0
  44. package/dist/assets/clone-CI9aUwHe.js +1 -0
  45. package/dist/assets/cose-bilkent-S5V4N54A-7BpAeDh5.js +1 -0
  46. package/dist/assets/cytoscape.esm-DoTFyJaN.js +321 -0
  47. package/dist/assets/dagre-CilMRazv.js +1 -0
  48. package/dist/assets/dagre-KV5264BT-DDMqpjkB.js +4 -0
  49. package/dist/assets/defaultLocale-Ck2Xxk-C.js +1 -0
  50. package/dist/assets/diagram-5BDNPKRD-BFeyfnCx.js +10 -0
  51. package/dist/assets/diagram-G4DWMVQ6-DoqT-PtF.js +24 -0
  52. package/dist/assets/diagram-MMDJMWI5-BPV6KADk.js +43 -0
  53. package/dist/assets/diagram-TYMM5635-okvcTBtl.js +24 -0
  54. package/dist/assets/dist-C_eddq6m.js +1 -0
  55. package/dist/assets/erDiagram-SMLLAGMA-Dl-Ixy8n.js +85 -0
  56. package/dist/assets/flatten-B8XIuT0x.js +1 -0
  57. package/dist/assets/flowDiagram-DWJPFMVM-CsqWAx5r.js +162 -0
  58. package/dist/assets/ganttDiagram-T4ZO3ILL-mIt6zVeF.js +292 -0
  59. package/dist/assets/gitGraph-7Q5UKJZL-COXHGMvj.js +1 -0
  60. package/dist/assets/gitGraphDiagram-UUTBAWPF-syVqZJX_.js +106 -0
  61. package/dist/assets/graphlib-Bpd0q3yO.js +1 -0
  62. package/dist/assets/index-BoggyWS0.css +2 -0
  63. package/dist/assets/index-aLvjHQW4.js +104 -0
  64. package/dist/assets/info-OMHHGYJF-B-0wfxwL.js +1 -0
  65. package/dist/assets/infoDiagram-42DDH7IO-C0_uqsVa.js +2 -0
  66. package/dist/assets/init-Bft5Ffpj.js +1 -0
  67. package/dist/assets/isEmpty-BrFi5AqV.js +1 -0
  68. package/dist/assets/ishikawaDiagram-UXIWVN3A-CTjFbDBV.js +70 -0
  69. package/dist/assets/journeyDiagram-VCZTEJTY-BDBcej1q.js +139 -0
  70. package/dist/assets/kanban-definition-6JOO6SKY-Ylgzakw7.js +89 -0
  71. package/dist/assets/katex-Uj9wLT16.js +265 -0
  72. package/dist/assets/line-CRxEwpOv.js +1 -0
  73. package/dist/assets/linear-PDPfFByd.js +1 -0
  74. package/dist/assets/mermaid-parser.core-CY-XNOOy.js +4 -0
  75. package/dist/assets/mermaid.core-BPlTADIX.js +11 -0
  76. package/dist/assets/mindmap-definition-QFDTVHPH-TefzJnBM.js +96 -0
  77. package/dist/assets/ordinal-DIg8h6NI.js +1 -0
  78. package/dist/assets/packet-4T2RLAQJ-BW1T_A-C.js +1 -0
  79. package/dist/assets/path-DfRbCp9y.js +1 -0
  80. package/dist/assets/pie-ZZUOXDRM-DkKU-SFu.js +1 -0
  81. package/dist/assets/pieDiagram-DEJITSTG-BCXuaeEy.js +30 -0
  82. package/dist/assets/quadrantDiagram-34T5L4WZ-VSBAicWL.js +7 -0
  83. package/dist/assets/radar-PYXPWWZC-CYvTacKJ.js +1 -0
  84. package/dist/assets/reduce-CV2X8n1a.js +1 -0
  85. package/dist/assets/requirementDiagram-MS252O5E-4NeL9Z6J.js +84 -0
  86. package/dist/assets/rough.esm-Bbn_-PMU.js +1 -0
  87. package/dist/assets/sankeyDiagram-XADWPNL6-DMBSDnrH.js +10 -0
  88. package/dist/assets/sequenceDiagram-FGHM5R23-DVpzcZUi.js +157 -0
  89. package/dist/assets/src-PKe5NtkK.js +1 -0
  90. package/dist/assets/stateDiagram-FHFEXIEX-BkHTlCjL.js +1 -0
  91. package/dist/assets/stateDiagram-v2-QKLJ7IA2-nMeWu9fP.js +1 -0
  92. package/dist/assets/timeline-definition-GMOUNBTQ-CyLt92nf.js +120 -0
  93. package/dist/assets/treeView-SZITEDCU-BUgcJ4eR.js +1 -0
  94. package/dist/assets/treemap-W4RFUUIX-BIWGQ4Pw.js +1 -0
  95. package/dist/assets/vennDiagram-DHZGUBPP-BCK0xB_m.js +34 -0
  96. package/dist/assets/wardley-RL74JXVD-DMZZRlby.js +1 -0
  97. package/dist/assets/wardleyDiagram-NUSXRM2D-BisBgfsF.js +20 -0
  98. package/dist/assets/xychartDiagram-5P7HB3ND-D_REDciv.js +7 -0
  99. package/dist/favicon.svg +15 -0
  100. package/dist/index.html +14 -0
  101. package/dist/screenshot.png +0 -0
  102. package/index.html +13 -0
  103. package/package.json +105 -0
  104. package/public/favicon.svg +15 -0
  105. package/public/screenshot.png +0 -0
  106. package/server/index.test.ts +814 -0
  107. package/server/index.ts +736 -0
  108. package/server/preferences.test.ts +126 -0
  109. package/server/preferences.ts +76 -0
  110. package/src/App.tsx +1620 -0
  111. package/src/components/ActionButton.tsx +41 -0
  112. package/src/components/CommandPalette.tsx +191 -0
  113. package/src/components/CommentCard.tsx +556 -0
  114. package/src/components/CommentForm.tsx +285 -0
  115. package/src/components/CommentSidebar.tsx +428 -0
  116. package/src/components/ConfirmDialog.tsx +64 -0
  117. package/src/components/ContextMenu.tsx +220 -0
  118. package/src/components/DragHandles.tsx +48 -0
  119. package/src/components/FileExplorer.tsx +251 -0
  120. package/src/components/FileOpener.tsx +304 -0
  121. package/src/components/IconButton.tsx +32 -0
  122. package/src/components/KeyboardShortcutsPanel.tsx +136 -0
  123. package/src/components/MarkdownViewer.tsx +682 -0
  124. package/src/components/RawView.tsx +798 -0
  125. package/src/components/SearchBar.tsx +129 -0
  126. package/src/components/Separator.tsx +7 -0
  127. package/src/components/SettingsPanel.tsx +813 -0
  128. package/src/components/SplitIconButton.tsx +133 -0
  129. package/src/components/TabBar.tsx +594 -0
  130. package/src/components/TableOfContents.tsx +70 -0
  131. package/src/components/ThemeSelector.tsx +159 -0
  132. package/src/components/Toast.tsx +99 -0
  133. package/src/components/Toolbar.tsx +161 -0
  134. package/src/components/iconButtonVariants.ts +19 -0
  135. package/src/components/rawView.test.ts +291 -0
  136. package/src/contexts/SettingsContext.tsx +120 -0
  137. package/src/hooks/useAuthor.test.ts +58 -0
  138. package/src/hooks/useAuthor.ts +69 -0
  139. package/src/hooks/useAutoResize.ts +20 -0
  140. package/src/hooks/useCommentCardTriggers.ts +20 -0
  141. package/src/hooks/useComments.test.ts +773 -0
  142. package/src/hooks/useComments.ts +332 -0
  143. package/src/hooks/useContextMenu.ts +48 -0
  144. package/src/hooks/useContextMenuItems.ts +392 -0
  145. package/src/hooks/useDiffSnapshot.test.ts +130 -0
  146. package/src/hooks/useDiffSnapshot.ts +67 -0
  147. package/src/hooks/useDragHandles.ts +417 -0
  148. package/src/hooks/useFileWatcher.ts +45 -0
  149. package/src/hooks/useHeadingTracking.ts +84 -0
  150. package/src/hooks/useMermaidRenderer.ts +75 -0
  151. package/src/hooks/useModalState.ts +22 -0
  152. package/src/hooks/usePageVisible.test.ts +69 -0
  153. package/src/hooks/usePageVisible.ts +19 -0
  154. package/src/hooks/usePaneLayout.test.ts +108 -0
  155. package/src/hooks/usePaneLayout.ts +102 -0
  156. package/src/hooks/useRecentFiles.test.ts +103 -0
  157. package/src/hooks/useRecentFiles.ts +99 -0
  158. package/src/hooks/useResizablePanel.test.ts +84 -0
  159. package/src/hooks/useResizablePanel.ts +118 -0
  160. package/src/hooks/useSearch.test.ts +72 -0
  161. package/src/hooks/useSearch.ts +53 -0
  162. package/src/hooks/useSelection.ts +48 -0
  163. package/src/hooks/useSessionPersistence.test.ts +59 -0
  164. package/src/hooks/useSessionPersistence.ts +43 -0
  165. package/src/hooks/useTabs.test.ts +127 -0
  166. package/src/hooks/useTabs.ts +561 -0
  167. package/src/hooks/useThemePersistence.ts +41 -0
  168. package/src/hooks/useToast.ts +27 -0
  169. package/src/index.css +1047 -0
  170. package/src/lib/agent-prompts.test.ts +34 -0
  171. package/src/lib/agent-prompts.ts +68 -0
  172. package/src/lib/comment-editor-state.ts +6 -0
  173. package/src/lib/comment-parser.test.ts +1959 -0
  174. package/src/lib/comment-parser.ts +1021 -0
  175. package/src/lib/diff.test.ts +164 -0
  176. package/src/lib/diff.ts +139 -0
  177. package/src/lib/heading-slugs.test.ts +85 -0
  178. package/src/lib/heading-slugs.ts +44 -0
  179. package/src/lib/http.test.ts +43 -0
  180. package/src/lib/http.ts +29 -0
  181. package/src/lib/mermaid-highlights.test.ts +517 -0
  182. package/src/lib/mermaid-highlights.ts +936 -0
  183. package/src/lib/mermaid-renderer.test.ts +114 -0
  184. package/src/lib/mermaid-renderer.ts +89 -0
  185. package/src/lib/path-utils.test.ts +17 -0
  186. package/src/lib/path-utils.ts +7 -0
  187. package/src/lib/platform.test.ts +58 -0
  188. package/src/lib/platform.ts +14 -0
  189. package/src/lib/preferences-client.test.ts +177 -0
  190. package/src/lib/preferences-client.ts +94 -0
  191. package/src/lib/selection-resolver.test.ts +118 -0
  192. package/src/lib/selection-resolver.ts +37 -0
  193. package/src/lib/settings.test.ts +152 -0
  194. package/src/lib/settings.ts +78 -0
  195. package/src/lib/shortcut-label.tsx +18 -0
  196. package/src/lib/themes.ts +21 -0
  197. package/src/lib/visible-text.test.ts +86 -0
  198. package/src/lib/visible-text.ts +77 -0
  199. package/src/main.tsx +22 -0
  200. package/src/markdown/pipeline.test.ts +82 -0
  201. package/src/markdown/pipeline.ts +33 -0
  202. package/src/types.test.ts +43 -0
  203. package/src/types.ts +46 -0
  204. package/tsconfig.app.json +28 -0
  205. package/tsconfig.json +7 -0
  206. package/tsconfig.node.json +26 -0
  207. package/vite.config.ts +50 -0
@@ -0,0 +1,1959 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ parseComments,
4
+ insertComment,
5
+ removeComment,
6
+ editComment,
7
+ editReply,
8
+ updateCommentAnchor,
9
+ addReply,
10
+ removeReply,
11
+ serializeComment,
12
+ detectMissingAnchors,
13
+ stripInlineFormatting,
14
+ pickBestOccurrence,
15
+ resolveComment,
16
+ unresolveComment,
17
+ removeAllComments,
18
+ resolveAllComments,
19
+ removeResolvedComments,
20
+ } from './comment-parser';
21
+ import type { MdComment } from '../types';
22
+
23
+ // Helper to make a comment marker
24
+ function marker(overrides: Partial<MdComment> = {}): string {
25
+ const comment: MdComment = {
26
+ id: overrides.id ?? 'test-id',
27
+ anchor: overrides.anchor ?? 'hello',
28
+ text: overrides.text ?? 'my comment',
29
+ author: overrides.author ?? 'User',
30
+ timestamp: overrides.timestamp ?? '2024-01-01T00:00:00.000Z',
31
+ replies: overrides.replies,
32
+ ...overrides,
33
+ };
34
+ return serializeComment(comment);
35
+ }
36
+
37
+ describe('parseComments', () => {
38
+ it('returns empty results for plain markdown', () => {
39
+ const result = parseComments('# Hello\n\nSome text');
40
+ expect(result.comments).toHaveLength(0);
41
+ expect(result.cleanMarkdown).toBe('# Hello\n\nSome text');
42
+ });
43
+
44
+ it('extracts a single comment and strips its marker', () => {
45
+ const raw = `Some ${marker({ anchor: 'text' })}text here`;
46
+ const result = parseComments(raw);
47
+ expect(result.comments).toHaveLength(1);
48
+ expect(result.comments[0].id).toBe('test-id');
49
+ expect(result.comments[0].anchor).toBe('text');
50
+ expect(result.cleanMarkdown).toBe('Some text here');
51
+ });
52
+
53
+ it('extracts multiple comments', () => {
54
+ const raw = `${marker({ id: 'a', anchor: 'Hello' })}Hello ${marker({ id: 'b', anchor: 'world' })}world`;
55
+ const result = parseComments(raw);
56
+ expect(result.comments).toHaveLength(2);
57
+ expect(result.comments[0].id).toBe('a');
58
+ expect(result.comments[1].id).toBe('b');
59
+ expect(result.cleanMarkdown).toBe('Hello world');
60
+ });
61
+
62
+ it('computes correct cleanOffset for each comment', () => {
63
+ const raw = `${marker({ id: 'a', anchor: 'Hello' })}Hello ${marker({ id: 'b', anchor: 'world' })}world`;
64
+ const result = parseComments(raw);
65
+ expect(result.comments[0].cleanOffset).toBe(0);
66
+ expect(result.comments[1].cleanOffset).toBe(6); // "Hello " is 6 chars
67
+ });
68
+
69
+ it('skips malformed comment markers', () => {
70
+ const raw = '<!-- @comment{invalid json} -->Some text';
71
+ const result = parseComments(raw);
72
+ expect(result.comments).toHaveLength(0);
73
+ expect(result.cleanMarkdown).toBe('Some text');
74
+ });
75
+
76
+ it('handles empty markdown', () => {
77
+ const result = parseComments('');
78
+ expect(result.comments).toHaveLength(0);
79
+ expect(result.cleanMarkdown).toBe('');
80
+ });
81
+
82
+ it('cleanToRawOffset maps positions correctly', () => {
83
+ const m = marker({ anchor: 'Hello' });
84
+ const raw = `${m}Hello world`;
85
+ const result = parseComments(raw);
86
+ // Clean offset 0 should map to raw offset = marker length
87
+ expect(result.cleanToRawOffset(0)).toBe(m.length);
88
+ // Clean offset 5 ("Hello") should map to raw offset = marker length + 5
89
+ expect(result.cleanToRawOffset(5)).toBe(m.length + 5);
90
+ });
91
+
92
+ it('handles comments with newlines in text (dotall regex)', () => {
93
+ const comment: MdComment = {
94
+ id: 'nl-test',
95
+ anchor: 'hello',
96
+ text: 'line one\nline two',
97
+ author: 'User',
98
+ timestamp: '2024-01-01T00:00:00.000Z',
99
+ };
100
+ const m = serializeComment(comment);
101
+ const raw = `${m}hello world`;
102
+ const result = parseComments(raw);
103
+ expect(result.comments).toHaveLength(1);
104
+ expect(result.comments[0].text).toBe('line one\nline two');
105
+ });
106
+ });
107
+
108
+ describe('insertComment', () => {
109
+ it('inserts a comment marker before the anchor text', () => {
110
+ const raw = 'Hello world';
111
+ const result = insertComment(raw, 'world', 'fix this');
112
+ expect(result).toContain('<!-- @comment');
113
+ expect(result).toContain('"anchor":"world"');
114
+ expect(result).toContain('"text":"fix this"');
115
+ // The marker should appear before "world"
116
+ const markerIdx = result.indexOf('<!-- @comment');
117
+ const worldIdx = result.indexOf('world', markerIdx);
118
+ expect(markerIdx).toBeLessThan(worldIdx);
119
+ });
120
+
121
+ it('returns raw markdown unchanged when anchor not found', () => {
122
+ const raw = 'Hello world';
123
+ const result = insertComment(raw, 'nonexistent', 'comment');
124
+ expect(result).toBe(raw);
125
+ });
126
+
127
+ it('works with anchor text containing markdown formatting', () => {
128
+ const raw = '# Heading\n\nSome **bold** text';
129
+ const result = insertComment(raw, 'bold', 'needs italic');
130
+ expect(result).toContain('"anchor":"bold"');
131
+ });
132
+
133
+ it('does not break existing comments', () => {
134
+ const raw = `${marker({ id: 'existing' })}hello world`;
135
+ const result = insertComment(raw, 'world', 'new comment');
136
+ const parsed = parseComments(result);
137
+ expect(parsed.comments).toHaveLength(2);
138
+ expect(parsed.comments.find((c) => c.id === 'existing')).toBeTruthy();
139
+ });
140
+
141
+ it('handles duplicate anchor text (first match)', () => {
142
+ const raw = 'hello hello hello';
143
+ const result = insertComment(raw, 'hello', 'which one?');
144
+ const parsed = parseComments(result);
145
+ expect(parsed.comments).toHaveLength(1);
146
+ expect(parsed.cleanMarkdown).toBe('hello hello hello');
147
+ });
148
+
149
+ it('uses hintOffset to disambiguate duplicate anchor text', () => {
150
+ const raw = 'foo bar foo bar foo';
151
+ // "foo" appears at clean offsets 0, 8, 16; hintOffset=15 is closest to 16
152
+ const result = insertComment(raw, 'foo', 'third one', 'User', undefined, undefined, 15);
153
+ // The marker should be inserted before the third "foo", not the first
154
+ const markerIdx = result.indexOf('<!-- @comment');
155
+ const textBefore = result.slice(0, markerIdx);
156
+ expect(textBefore).toBe('foo bar foo bar ');
157
+ });
158
+
159
+ it('uses hintOffset to select second occurrence of duplicate text', () => {
160
+ const raw = 'The cat sat. The cat played.';
161
+ // "The cat" appears at offsets 0 and 13; hintOffset=13 should pick the second
162
+ const result = insertComment(raw, 'The cat', 'second one', 'User', undefined, undefined, 13);
163
+ const markerIdx = result.indexOf('<!-- @comment');
164
+ const textBefore = result.slice(0, markerIdx);
165
+ expect(textBefore).toBe('The cat sat. ');
166
+ });
167
+
168
+ it('uses hintOffset to disambiguate duplicate cross-element selections', () => {
169
+ // Anchor spans a newline, which triggers the segment-based fallback.
170
+ // The same two-line pattern repeats, so hintOffset must disambiguate.
171
+ const raw = 'hello\nworld\n\nhello\nworld';
172
+ // Segments: ["hello", "world"]. Appear at clean offsets 0 and 13.
173
+ // In plain text (same as clean here, no formatting), also at 0 and 13.
174
+ // hintOffset=13 should pick the second occurrence.
175
+ const result = insertComment(raw, 'hello\nworld', 'second', 'User', undefined, undefined, 13);
176
+ const markerIdx = result.indexOf('<!-- @comment');
177
+ const textBefore = result.slice(0, markerIdx);
178
+ expect(textBefore).toBe('hello\nworld\n\n');
179
+ });
180
+
181
+ it('uses hintOffset when one occurrence is formatted and the other is not', () => {
182
+ // **foo**\nbar\n\nfoo\nbar — only "foo\nbar" at position 13 is an exact match
183
+ // in clean markdown; the first occurrence is inside **foo** so indexOf misses it.
184
+ // hintOffset=0 (plain text position of first "foo") should pick the formatted one.
185
+ const raw = '**foo**\nbar\n\nfoo\nbar';
186
+ const result = insertComment(raw, 'foo', 'first one', 'User', undefined, undefined, 0);
187
+ const markerIdx = result.indexOf('<!-- @comment');
188
+ const textBefore = result.slice(0, markerIdx);
189
+ // Marker should be before "foo" inside **foo** (after the ** formatting prefix)
190
+ expect(textBefore).toBe('**');
191
+ });
192
+
193
+ it('uses hintOffset in plain-text space to handle formatted duplicates', () => {
194
+ // **foo** x foo — clean markdown has "foo" at offsets 2 and 10,
195
+ // but rendered/plain text has "foo" at offsets 0 and 6.
196
+ // Selecting second "foo" gives hintOffset=6 (plain-text space).
197
+ const raw = '**foo** x foo';
198
+ const result = insertComment(raw, 'foo', 'second one', 'User', undefined, undefined, 6);
199
+ const markerIdx = result.indexOf('<!-- @comment');
200
+ const textBefore = result.slice(0, markerIdx);
201
+ // Marker should be before the second "foo" (after "**foo** x ")
202
+ expect(textBefore).toBe('**foo** x ');
203
+ });
204
+ });
205
+
206
+ describe('removeComment', () => {
207
+ it('removes a comment by id', () => {
208
+ const raw = `${marker({ id: 'del-me' })}hello world`;
209
+ const result = removeComment(raw, 'del-me');
210
+ expect(result).toBe('hello world');
211
+ });
212
+
213
+ it('leaves other comments intact', () => {
214
+ const raw = `${marker({ id: 'a' })}hello ${marker({ id: 'b' })}world`;
215
+ const result = removeComment(raw, 'a');
216
+ const parsed = parseComments(result);
217
+ expect(parsed.comments).toHaveLength(1);
218
+ expect(parsed.comments[0].id).toBe('b');
219
+ });
220
+
221
+ it('does nothing when id not found', () => {
222
+ const raw = `${marker({ id: 'a' })}hello`;
223
+ const result = removeComment(raw, 'nonexistent');
224
+ expect(result).toBe(raw);
225
+ });
226
+
227
+ it('preserves fenced code blocks when removing a standalone marker before them', () => {
228
+ const original = '```mermaid\nflowchart LR\nA-->B\n```';
229
+ const raw = `${marker({ id: 'code-1', anchor: 'A-->B' })}\n${original}`;
230
+ expect(removeComment(raw, 'code-1')).toBe(original);
231
+ });
232
+ });
233
+
234
+ describe('editComment', () => {
235
+ it('updates the comment text', () => {
236
+ const raw = `${marker({ id: 'e1', text: 'old text' })}hello`;
237
+ const result = editComment(raw, 'e1', 'new text');
238
+ const parsed = parseComments(result);
239
+ expect(parsed.comments[0].text).toBe('new text');
240
+ });
241
+
242
+ it('does not change other fields', () => {
243
+ const raw = `${marker({ id: 'e1', text: 'old', author: 'Alice' })}hello`;
244
+ const result = editComment(raw, 'e1', 'new');
245
+ const parsed = parseComments(result);
246
+ expect(parsed.comments[0].author).toBe('Alice');
247
+ expect(parsed.comments[0].id).toBe('e1');
248
+ });
249
+ });
250
+
251
+ describe('updateCommentAnchor', () => {
252
+ it('updates the anchor text', () => {
253
+ const raw = `${marker({ id: 'a1', anchor: 'old anchor' })}old anchor text`;
254
+ const result = updateCommentAnchor(raw, 'a1', 'new anchor');
255
+ const parsed = parseComments(result);
256
+ expect(parsed.comments[0].anchor).toBe('new anchor');
257
+ });
258
+ });
259
+
260
+ describe('addReply', () => {
261
+ it('adds a reply to an existing comment', () => {
262
+ const raw = `${marker({ id: 'rp1' })}hello`;
263
+ const result = addReply(raw, 'rp1', 'reply text', 'Bob');
264
+ const parsed = parseComments(result);
265
+ expect(parsed.comments[0].replies).toHaveLength(1);
266
+ expect(parsed.comments[0].replies![0].text).toBe('reply text');
267
+ expect(parsed.comments[0].replies![0].author).toBe('Bob');
268
+ });
269
+
270
+ it('appends to existing replies', () => {
271
+ const raw = `${marker({ id: 'rp1', replies: [{ id: 'r1', text: 'first', author: 'A', timestamp: '2024-01-01T00:00:00.000Z' }] })}hello`;
272
+ const result = addReply(raw, 'rp1', 'second', 'B');
273
+ const parsed = parseComments(result);
274
+ expect(parsed.comments[0].replies).toHaveLength(2);
275
+ expect(parsed.comments[0].replies![1].text).toBe('second');
276
+ });
277
+
278
+ it('returns unchanged text when comment ID does not exist', () => {
279
+ const raw = `${marker({ id: 'rp1' })}hello`;
280
+ const result = addReply(raw, 'nonexistent', 'reply text', 'Bob');
281
+ expect(result).toBe(raw);
282
+ });
283
+ });
284
+
285
+ describe('editReply', () => {
286
+ it('updates an existing reply', () => {
287
+ const raw = `${marker({
288
+ id: 'rp1',
289
+ replies: [
290
+ { id: 'r1', text: 'old reply', author: 'A', timestamp: '2024-01-01T00:00:00.000Z' },
291
+ ],
292
+ })}hello`;
293
+ const result = editReply(raw, 'rp1', 'r1', 'new reply');
294
+ const parsed = parseComments(result);
295
+ expect(parsed.comments[0].replies).toHaveLength(1);
296
+ expect(parsed.comments[0].replies![0].text).toBe('new reply');
297
+ expect(parsed.comments[0].replies![0].author).toBe('A');
298
+ });
299
+
300
+ it('returns unchanged text when reply ID does not exist', () => {
301
+ const raw = `${marker({
302
+ id: 'rp1',
303
+ replies: [
304
+ { id: 'r1', text: 'old reply', author: 'A', timestamp: '2024-01-01T00:00:00.000Z' },
305
+ ],
306
+ })}hello`;
307
+ const result = editReply(raw, 'rp1', 'missing', 'new reply');
308
+ expect(result).toBe(raw);
309
+ });
310
+ });
311
+
312
+ describe('removeReply', () => {
313
+ it('removes a reply from an existing comment', () => {
314
+ const raw = `${marker({
315
+ id: 'rp1',
316
+ replies: [
317
+ { id: 'r1', text: 'first', author: 'A', timestamp: '2024-01-01T00:00:00.000Z' },
318
+ { id: 'r2', text: 'second', author: 'B', timestamp: '2024-01-02T00:00:00.000Z' },
319
+ ],
320
+ })}hello`;
321
+ const result = removeReply(raw, 'rp1', 'r1');
322
+ const parsed = parseComments(result);
323
+ expect(parsed.comments[0].replies).toHaveLength(1);
324
+ expect(parsed.comments[0].replies![0].id).toBe('r2');
325
+ });
326
+
327
+ it('removes the replies field when deleting the last reply', () => {
328
+ const raw = `${marker({
329
+ id: 'rp1',
330
+ replies: [
331
+ { id: 'r1', text: 'only reply', author: 'A', timestamp: '2024-01-01T00:00:00.000Z' },
332
+ ],
333
+ })}hello`;
334
+ const result = removeReply(raw, 'rp1', 'r1');
335
+ const parsed = parseComments(result);
336
+ expect(parsed.comments[0].replies).toBeUndefined();
337
+ });
338
+ });
339
+
340
+ describe('serializeComment', () => {
341
+ it('produces a valid comment marker', () => {
342
+ const comment: MdComment = {
343
+ id: 'ser-1',
344
+ anchor: 'test',
345
+ text: 'a comment',
346
+ author: 'User',
347
+ timestamp: '2024-01-01T00:00:00.000Z',
348
+ };
349
+ const result = serializeComment(comment);
350
+ expect(result).toMatch(/^<!-- @comment\{.*\} -->$/);
351
+ const parsed = parseComments(result + 'test');
352
+ expect(parsed.comments[0].id).toBe('ser-1');
353
+ });
354
+
355
+ it('strips cleanOffset from serialization', () => {
356
+ const comment: MdComment = {
357
+ id: 'ser-2',
358
+ anchor: 'test',
359
+ text: 'comment',
360
+ author: 'User',
361
+ timestamp: '2024-01-01T00:00:00.000Z',
362
+ cleanOffset: 42,
363
+ };
364
+ const result = serializeComment(comment);
365
+ expect(result).not.toContain('cleanOffset');
366
+ });
367
+ });
368
+
369
+ describe('edge cases', () => {
370
+ it('handles special characters in comment text', () => {
371
+ const raw = insertComment('Hello world', 'world', 'has "quotes" and <angle> brackets');
372
+ const parsed = parseComments(raw);
373
+ expect(parsed.comments[0].text).toBe('has "quotes" and <angle> brackets');
374
+ });
375
+
376
+ it('handles overlapping anchors (multiple comments on same text)', () => {
377
+ let raw = 'Hello world';
378
+ raw = insertComment(raw, 'Hello', 'comment 1');
379
+ raw = insertComment(raw, 'Hello', 'comment 2');
380
+ const parsed = parseComments(raw);
381
+ expect(parsed.comments).toHaveLength(2);
382
+ expect(parsed.cleanMarkdown).toBe('Hello world');
383
+ });
384
+
385
+ it('uses a provided comment ID when inserting a comment', () => {
386
+ const result = insertComment(
387
+ 'Hello world',
388
+ 'world',
389
+ 'track me',
390
+ 'User',
391
+ undefined,
392
+ undefined,
393
+ undefined,
394
+ 'comment-123',
395
+ );
396
+ const parsed = parseComments(result);
397
+
398
+ expect(parsed.comments[0]?.id).toBe('comment-123');
399
+ });
400
+
401
+ it('handles very long anchor text', () => {
402
+ const longText = 'a'.repeat(10000);
403
+ const raw = `Start ${longText} end`;
404
+ const result = insertComment(raw, longText, 'comment on long text');
405
+ const parsed = parseComments(result);
406
+ expect(parsed.comments).toHaveLength(1);
407
+ expect(parsed.cleanMarkdown).toBe(`Start ${longText} end`);
408
+ });
409
+
410
+ it('uses hintOffset to place repeated punctuation-heavy anchors in a large document', () => {
411
+ const anchor = 'GET /api/files?dir=~/docs&mode=full';
412
+ const raw = Array.from(
413
+ { length: 220 },
414
+ (_, index) =>
415
+ `Section ${index + 1}\nRequest: ${anchor}\nNotes: keep section ${index + 1} in sync.\n`,
416
+ ).join('\n');
417
+ const occurrences: number[] = [];
418
+ let searchFrom = 0;
419
+ while (true) {
420
+ const idx = raw.indexOf(anchor, searchFrom);
421
+ if (idx === -1) break;
422
+ occurrences.push(idx);
423
+ searchFrom = idx + 1;
424
+ }
425
+
426
+ const targetOccurrence = 173;
427
+ const result = insertComment(
428
+ raw,
429
+ anchor,
430
+ 'late duplicate anchor',
431
+ 'User',
432
+ undefined,
433
+ undefined,
434
+ occurrences[targetOccurrence - 1],
435
+ );
436
+ const parsed = parseComments(result);
437
+
438
+ expect(result.indexOf('<!-- @comment')).toBe(occurrences[targetOccurrence - 1]);
439
+ expect(parsed.comments).toHaveLength(1);
440
+ expect(parsed.comments[0].cleanOffset).toBe(occurrences[targetOccurrence - 1]);
441
+ });
442
+
443
+ it('round-trips: parse then re-serialize preserves comments', () => {
444
+ const raw = `${marker({ id: 'rt1', text: 'hello' })}some text ${marker({ id: 'rt2', text: 'world' })}more text`;
445
+ const { comments, cleanMarkdown } = parseComments(raw);
446
+ // Re-insert comments back
447
+ let rebuilt = cleanMarkdown;
448
+ for (const c of comments.reverse()) {
449
+ const m = serializeComment(c);
450
+ const offset = c.cleanOffset ?? 0;
451
+ rebuilt = rebuilt.slice(0, offset) + m + rebuilt.slice(offset);
452
+ }
453
+ const reparsed = parseComments(rebuilt);
454
+ expect(reparsed.comments).toHaveLength(2);
455
+ expect(reparsed.cleanMarkdown).toBe(cleanMarkdown);
456
+ });
457
+ });
458
+
459
+ describe('detectMissingAnchors', () => {
460
+ it('returns empty set when all anchors are present', () => {
461
+ const clean = 'Hello world, this is a test';
462
+ const comments = [
463
+ { id: 'a', anchor: 'Hello world' },
464
+ { id: 'b', anchor: 'this is a test' },
465
+ ] as MdComment[];
466
+ expect(detectMissingAnchors(clean, comments).size).toBe(0);
467
+ });
468
+
469
+ it('detects when anchor text is completely removed', () => {
470
+ const clean = 'Hello world';
471
+ const comments = [{ id: 'a', anchor: 'deleted text' }] as MdComment[];
472
+ const missing = detectMissingAnchors(clean, comments);
473
+ expect(missing.has('a')).toBe(true);
474
+ });
475
+
476
+ it('does not flag anchors with flexible whitespace match', () => {
477
+ const clean = 'Hello\nworld';
478
+ const comments = [{ id: 'a', anchor: 'Hello world' }] as MdComment[];
479
+ const missing = detectMissingAnchors(clean, comments);
480
+ expect(missing.has('a')).toBe(false);
481
+ });
482
+
483
+ it('detects partially modified anchor', () => {
484
+ const clean = 'Hello universe';
485
+ const comments = [{ id: 'a', anchor: 'Hello world' }] as MdComment[];
486
+ const missing = detectMissingAnchors(clean, comments);
487
+ expect(missing.has('a')).toBe(true);
488
+ });
489
+
490
+ it('handles empty anchor gracefully', () => {
491
+ const clean = 'Hello world';
492
+ const comments = [{ id: 'a', anchor: '' }] as MdComment[];
493
+ const missing = detectMissingAnchors(clean, comments);
494
+ expect(missing.has('a')).toBe(false);
495
+ });
496
+
497
+ it('handles whitespace-only anchor gracefully', () => {
498
+ const clean = 'Hello world';
499
+ const comments = [{ id: 'a', anchor: ' ' }] as MdComment[];
500
+ const missing = detectMissingAnchors(clean, comments);
501
+ expect(missing.has('a')).toBe(false);
502
+ });
503
+
504
+ it('identifies multiple missing anchors', () => {
505
+ const clean = 'Only this remains';
506
+ const comments = [
507
+ { id: 'a', anchor: 'gone text' },
508
+ { id: 'b', anchor: 'also gone' },
509
+ { id: 'c', anchor: 'Only this remains' },
510
+ ] as MdComment[];
511
+ const missing = detectMissingAnchors(clean, comments);
512
+ expect(missing.size).toBe(2);
513
+ expect(missing.has('a')).toBe(true);
514
+ expect(missing.has('b')).toBe(true);
515
+ expect(missing.has('c')).toBe(false);
516
+ });
517
+
518
+ it('flags anchor when words appear non-contiguously in different sections', () => {
519
+ const clean = 'API is great\n\nSome other stuff\n\ndesign guidelines here';
520
+ const comments = [{ id: 'a', anchor: 'API design guidelines' }] as MdComment[];
521
+ const missing = detectMissingAnchors(clean, comments);
522
+ expect(missing.has('a')).toBe(true);
523
+ });
524
+
525
+ it('does not flag anchor when words appear contiguously with extra whitespace', () => {
526
+ const clean = 'API design\n\tguidelines';
527
+ const comments = [{ id: 'a', anchor: 'API design guidelines' }] as MdComment[];
528
+ const missing = detectMissingAnchors(clean, comments);
529
+ expect(missing.has('a')).toBe(false);
530
+ });
531
+
532
+ it('returns empty set for empty markdown', () => {
533
+ const comments = [{ id: 'a', anchor: 'test' }] as MdComment[];
534
+ expect(detectMissingAnchors('', comments).size).toBe(0);
535
+ });
536
+
537
+ it('handles contiguous match at second occurrence', () => {
538
+ const clean = 'API stuff\n\nAPI design guidelines here';
539
+ const comments = [{ id: 'a', anchor: 'API design guidelines' }] as MdComment[];
540
+ const missing = detectMissingAnchors(clean, comments);
541
+ expect(missing.has('a')).toBe(false);
542
+ });
543
+
544
+ it('does not flag anchor spanning across list items', () => {
545
+ const clean =
546
+ '- System sends verification email within 30 seconds\n- User cannot access protected routes';
547
+ const comments = [
548
+ { id: 'a', anchor: 'System sends verification email within 30 seconds\nUser cannot acce' },
549
+ ] as MdComment[];
550
+ const missing = detectMissingAnchors(clean, comments);
551
+ expect(missing.has('a')).toBe(false);
552
+ });
553
+
554
+ it('does not flag anchor spanning across blockquote lines', () => {
555
+ const clean = '> First line of quote\n> Second line continues';
556
+ const comments = [
557
+ { id: 'a', anchor: 'First line of quote\nSecond line continues' },
558
+ ] as MdComment[];
559
+ const missing = detectMissingAnchors(clean, comments);
560
+ expect(missing.has('a')).toBe(false);
561
+ });
562
+
563
+ it('does not flag anchor spanning bold formatting', () => {
564
+ const clean = 'the **initial** implementation is ready';
565
+ const comments = [{ id: 'a', anchor: 'initial implementation' }] as MdComment[];
566
+ const missing = detectMissingAnchors(clean, comments);
567
+ expect(missing.has('a')).toBe(false);
568
+ });
569
+
570
+ it('does not flag anchor spanning italic with underscores', () => {
571
+ const clean = 'the _initial_ implementation is ready';
572
+ const comments = [{ id: 'a', anchor: 'initial implementation' }] as MdComment[];
573
+ const missing = detectMissingAnchors(clean, comments);
574
+ expect(missing.has('a')).toBe(false);
575
+ });
576
+
577
+ it('does not flag anchor spanning inline code', () => {
578
+ const clean = 'use `myFunction` to process data';
579
+ const comments = [{ id: 'a', anchor: 'use myFunction to process' }] as MdComment[];
580
+ const missing = detectMissingAnchors(clean, comments);
581
+ expect(missing.has('a')).toBe(false);
582
+ });
583
+
584
+ it('does not flag anchor spanning strikethrough', () => {
585
+ const clean = 'the ~~old~~ new approach works';
586
+ const comments = [{ id: 'a', anchor: 'old new approach' }] as MdComment[];
587
+ const missing = detectMissingAnchors(clean, comments);
588
+ expect(missing.has('a')).toBe(false);
589
+ });
590
+
591
+ it('does not flag anchor spanning mixed formatting', () => {
592
+ const clean = '> *Note: This is **important** and _critical_ for the `release` process.*';
593
+ const comments = [
594
+ { id: 'a', anchor: 'Note: This is important and critical for the release process.' },
595
+ ] as MdComment[];
596
+ const missing = detectMissingAnchors(clean, comments);
597
+ expect(missing.has('a')).toBe(false);
598
+ });
599
+
600
+ it('does not flag anchor spanning link syntax', () => {
601
+ const clean = 'See [the docs](https://example.com) for details';
602
+ const comments = [{ id: 'a', anchor: 'See the docs for details' }] as MdComment[];
603
+ const missing = detectMissingAnchors(clean, comments);
604
+ expect(missing.has('a')).toBe(false);
605
+ });
606
+
607
+ it('does not flag punctuation-heavy anchors spanning links and inline code', () => {
608
+ const clean =
609
+ 'Review [the beta spec](https://example.com/spec) at `C:\\docs\\review?.md` before release.';
610
+ const comments = [
611
+ { id: 'a', anchor: 'Review the beta spec at C:\\docs\\review?.md before release.' },
612
+ ] as MdComment[];
613
+ const missing = detectMissingAnchors(clean, comments);
614
+ expect(missing.has('a')).toBe(false);
615
+ });
616
+
617
+ it('does not flag anchor from rendered mermaid diagram text', () => {
618
+ const clean =
619
+ '# Flow\n\n```mermaid\ngraph TD\n A[Admin clicks Add] --> B[Admin enters name]\n```\n';
620
+ const comments = [{ id: 'a', anchor: 'clicks Add Admin enters' }] as MdComment[];
621
+ const missing = detectMissingAnchors(clean, comments);
622
+ expect(missing.has('a')).toBe(false);
623
+ });
624
+
625
+ it('does not flag single-node mermaid anchor', () => {
626
+ const clean = '```mermaid\ngraph TD\n A[User submits form]\n```\n';
627
+ const comments = [{ id: 'a', anchor: 'User submits form' }] as MdComment[];
628
+ const missing = detectMissingAnchors(clean, comments);
629
+ expect(missing.has('a')).toBe(false);
630
+ });
631
+
632
+ it('does not flag anchor from mermaid edge label', () => {
633
+ const clean = '```mermaid\ngraph TD\n A -->|Yes| B -->|No| C\n```\n';
634
+ const comments = [{ id: 'a', anchor: 'Yes' }] as MdComment[];
635
+ const missing = detectMissingAnchors(clean, comments);
636
+ expect(missing.has('a')).toBe(false);
637
+ });
638
+
639
+ it('does not flag anchor in mermaid when file also has regular markdown', () => {
640
+ const clean =
641
+ '# Title\n\nSome **bold** paragraph.\n\n```mermaid\ngraph TD\n A[User clicks button]\n```\n\nMore text here.\n';
642
+ const comments = [{ id: 'a', anchor: 'User clicks button' }] as MdComment[];
643
+ const missing = detectMissingAnchors(clean, comments);
644
+ expect(missing.has('a')).toBe(false);
645
+ });
646
+
647
+ it('matches across multiple mermaid blocks', () => {
648
+ const clean =
649
+ '```mermaid\ngraph TD\n A[First]\n```\n\nText\n\n```mermaid\ngraph TD\n B[Second]\n```\n';
650
+ const comments = [
651
+ { id: 'a', anchor: 'First' },
652
+ { id: 'b', anchor: 'Second' },
653
+ ] as MdComment[];
654
+ const missing = detectMissingAnchors(clean, comments);
655
+ expect(missing.has('a')).toBe(false);
656
+ expect(missing.has('b')).toBe(false);
657
+ });
658
+
659
+ it('matches anchors in mermaid-heavy documents with many diagrams', () => {
660
+ const clean = `${Array.from(
661
+ { length: 18 },
662
+ (_, index) => `## Flow ${index + 1}
663
+
664
+ \`\`\`mermaid
665
+ flowchart TD
666
+ A${index}[Stage ${index + 1} intake] --> B${index}[Stage ${index + 1} approved]
667
+ \`\`\`
668
+ `,
669
+ ).join('\n')}
670
+ Final checklist is complete.
671
+ `;
672
+ const comments = [
673
+ { id: 'a', anchor: 'Stage 17 intake Stage 17 approved' },
674
+ { id: 'b', anchor: 'Final checklist is complete.' },
675
+ ] as MdComment[];
676
+ const missing = detectMissingAnchors(clean, comments);
677
+
678
+ expect(missing.size).toBe(0);
679
+ });
680
+
681
+ it('flags truly missing anchor even with mermaid blocks present', () => {
682
+ const clean = '```mermaid\ngraph TD\n A[Step one]\n```\n';
683
+ const comments = [{ id: 'a', anchor: 'completely unrelated text' }] as MdComment[];
684
+ const missing = detectMissingAnchors(clean, comments);
685
+ expect(missing.has('a')).toBe(true);
686
+ });
687
+ });
688
+
689
+ describe('insertComment with formatted markdown (stripInlineFormatting)', () => {
690
+ it('finds anchor inside bold formatting', () => {
691
+ const raw = 'This has **important** details';
692
+ const result = insertComment(raw, 'important', 'why bold?');
693
+ const parsed = parseComments(result);
694
+ expect(parsed.comments).toHaveLength(1);
695
+ expect(parsed.comments[0].anchor).toBe('important');
696
+ });
697
+
698
+ it('finds anchor inside italic formatting', () => {
699
+ const raw = 'This has *emphasized* text';
700
+ const result = insertComment(raw, 'emphasized', 'noted');
701
+ const parsed = parseComments(result);
702
+ expect(parsed.comments).toHaveLength(1);
703
+ });
704
+
705
+ it('finds anchor inside heading', () => {
706
+ const raw = '## My Heading\n\nSome body text';
707
+ const result = insertComment(raw, 'My Heading', 'rename this');
708
+ const parsed = parseComments(result);
709
+ expect(parsed.comments).toHaveLength(1);
710
+ });
711
+
712
+ it('finds anchor in list items', () => {
713
+ const raw = '- first item\n- second item\n- third item';
714
+ const result = insertComment(raw, 'second item', 'check this');
715
+ const parsed = parseComments(result);
716
+ expect(parsed.comments).toHaveLength(1);
717
+ });
718
+
719
+ it('finds anchor in numbered list', () => {
720
+ const raw = '1. alpha\n2. beta\n3. gamma';
721
+ const result = insertComment(raw, 'beta', 'review');
722
+ const parsed = parseComments(result);
723
+ expect(parsed.comments).toHaveLength(1);
724
+ });
725
+
726
+ it('finds anchor with backtick code', () => {
727
+ const raw = 'Use `myFunction` here';
728
+ const result = insertComment(raw, 'myFunction', 'rename');
729
+ const parsed = parseComments(result);
730
+ expect(parsed.comments).toHaveLength(1);
731
+ });
732
+
733
+ it('handles cross-element anchor with newlines', () => {
734
+ const raw = 'First line\nSecond line\nThird line';
735
+ const result = insertComment(raw, 'First line\nSecond line', 'spans lines');
736
+ const parsed = parseComments(result);
737
+ expect(parsed.comments).toHaveLength(1);
738
+ });
739
+ });
740
+
741
+ describe('insertComment with context', () => {
742
+ it('stores contextBefore and contextAfter in the comment marker', () => {
743
+ const raw = 'Some text before the anchor text and after text.';
744
+ const result = insertComment(
745
+ raw,
746
+ 'anchor text',
747
+ 'my note',
748
+ 'User',
749
+ 'before the ',
750
+ ' and after',
751
+ );
752
+ const parsed = parseComments(result);
753
+ expect(parsed.comments).toHaveLength(1);
754
+ expect(parsed.comments[0].contextBefore).toBe('before the ');
755
+ expect(parsed.comments[0].contextAfter).toBe(' and after');
756
+ });
757
+
758
+ it('works without context (backward compat)', () => {
759
+ const raw = 'Hello world';
760
+ const result = insertComment(raw, 'world', 'note');
761
+ const parsed = parseComments(result);
762
+ expect(parsed.comments).toHaveLength(1);
763
+ expect(parsed.comments[0].contextBefore).toBeUndefined();
764
+ expect(parsed.comments[0].contextAfter).toBeUndefined();
765
+ });
766
+ });
767
+
768
+ describe('fuzzy re-matching', () => {
769
+ it('re-matches when anchor text has been rewritten but context remains', () => {
770
+ // Simulate: comment was created on "old anchor" with context, then the text was changed
771
+ const comment: MdComment = {
772
+ id: 'fuzzy-1',
773
+ anchor: 'old anchor text',
774
+ text: 'rewrite this',
775
+ author: 'User',
776
+ timestamp: '2024-01-01T00:00:00.000Z',
777
+ contextBefore: 'before the ',
778
+ contextAfter: ' and after',
779
+ };
780
+ // The raw markdown has the comment marker, but the anchor text has changed
781
+ const raw = `before the ${serializeComment(comment)}new rewritten text and after that.`;
782
+ const parsed = parseComments(raw);
783
+ expect(parsed.comments).toHaveLength(1);
784
+ // The cleanOffset should point to where "new rewritten text" starts
785
+ const clean = parsed.cleanMarkdown;
786
+ expect(clean).toBe('before the new rewritten text and after that.');
787
+ // cleanOffset should be at position of "new rewritten text" (after "before the ")
788
+ expect(parsed.comments[0].cleanOffset).toBe('before the '.length);
789
+ });
790
+
791
+ it('does not re-match when anchor is still found exactly', () => {
792
+ const comment: MdComment = {
793
+ id: 'exact-1',
794
+ anchor: 'exact text',
795
+ text: 'note',
796
+ author: 'User',
797
+ timestamp: '2024-01-01T00:00:00.000Z',
798
+ contextBefore: 'before ',
799
+ contextAfter: ' after',
800
+ };
801
+ const raw = `before ${serializeComment(comment)}exact text after end.`;
802
+ const parsed = parseComments(raw);
803
+ expect(parsed.comments).toHaveLength(1);
804
+ // cleanOffset should be at the exact position of "exact text"
805
+ const clean = parsed.cleanMarkdown;
806
+ expect(clean).toBe('before exact text after end.');
807
+ expect(parsed.comments[0].cleanOffset).toBe('before '.length);
808
+ });
809
+
810
+ it('falls back gracefully when no context is stored (legacy comments)', () => {
811
+ const comment: MdComment = {
812
+ id: 'legacy-1',
813
+ anchor: 'deleted text',
814
+ text: 'note',
815
+ author: 'User',
816
+ timestamp: '2024-01-01T00:00:00.000Z',
817
+ };
818
+ const raw = `completely different content ${serializeComment(comment)}here now.`;
819
+ const parsed = parseComments(raw);
820
+ expect(parsed.comments).toHaveLength(1);
821
+ // cleanOffset should remain at the marker's original position
822
+ expect(parsed.comments[0].cleanOffset).toBe('completely different content '.length);
823
+ });
824
+
825
+ it('falls back when context is also gone', () => {
826
+ const comment: MdComment = {
827
+ id: 'gone-1',
828
+ anchor: 'vanished text',
829
+ text: 'note',
830
+ author: 'User',
831
+ timestamp: '2024-01-01T00:00:00.000Z',
832
+ contextBefore: 'nonexistent before',
833
+ contextAfter: 'nonexistent after',
834
+ };
835
+ const raw = `totally new content ${serializeComment(comment)}here.`;
836
+ const parsed = parseComments(raw);
837
+ expect(parsed.comments).toHaveLength(1);
838
+ // cleanOffset stays at original position since fuzzy match failed
839
+ expect(parsed.comments[0].cleanOffset).toBe('totally new content '.length);
840
+ });
841
+
842
+ it('uses contextBefore-only fallback when contextAfter is missing', () => {
843
+ const comment: MdComment = {
844
+ id: 'before-only-1',
845
+ anchor: 'old text',
846
+ text: 'note',
847
+ author: 'User',
848
+ timestamp: '2024-01-01T00:00:00.000Z',
849
+ contextBefore: 'the beginning of ',
850
+ };
851
+ const raw = `the beginning of ${serializeComment(comment)}new text here.`;
852
+ const parsed = parseComments(raw);
853
+ expect(parsed.comments).toHaveLength(1);
854
+ expect(parsed.comments[0].cleanOffset).toBe('the beginning of '.length);
855
+ });
856
+
857
+ it('uses contextAfter-only fallback when contextBefore is missing', () => {
858
+ const comment: MdComment = {
859
+ id: 'after-only-1',
860
+ anchor: 'old text',
861
+ text: 'note',
862
+ author: 'User',
863
+ timestamp: '2024-01-01T00:00:00.000Z',
864
+ contextAfter: ' and the rest follows',
865
+ };
866
+ const raw = `prefix text ${serializeComment(comment)}changed text and the rest follows here.`;
867
+ const parsed = parseComments(raw);
868
+ expect(parsed.comments).toHaveLength(1);
869
+ const clean = parsed.cleanMarkdown;
870
+ expect(clean).toBe('prefix text changed text and the rest follows here.');
871
+ const afterIdx = clean.indexOf(' and the rest follows');
872
+ // cleanOffset is positioned anchor.length chars before contextAfter
873
+ // so the marker re-attaches to the right region
874
+ expect(parsed.comments[0].cleanOffset).toBe(Math.max(0, afterIdx - comment.anchor.length));
875
+ });
876
+
877
+ it('rejects fuzzy match when gap between contexts is too large (>500 chars)', () => {
878
+ const filler = 'x'.repeat(501);
879
+ const comment: MdComment = {
880
+ id: 'gap-1',
881
+ anchor: 'old anchor',
882
+ text: 'note',
883
+ author: 'User',
884
+ timestamp: '2024-01-01T00:00:00.000Z',
885
+ contextBefore: 'context before ',
886
+ contextAfter: ' context after',
887
+ };
888
+ const raw = `context before ${serializeComment(comment)}${filler} context after end.`;
889
+ const parsed = parseComments(raw);
890
+ // Both-context match fails (gap > 500), contextBefore-only fallback succeeds
891
+ expect(parsed.comments[0].cleanOffset).toBe('context before '.length);
892
+ });
893
+
894
+ it('does not re-match when context exists but gap is zero', () => {
895
+ const comment: MdComment = {
896
+ id: 'zero-gap-1',
897
+ anchor: 'old anchor',
898
+ text: 'note',
899
+ author: 'User',
900
+ timestamp: '2024-01-01T00:00:00.000Z',
901
+ contextBefore: 'before',
902
+ contextAfter: 'after',
903
+ };
904
+ // Context is adjacent (gap = 0), so the both-context check fails (gap > 0 required)
905
+ const raw = `before${serializeComment(comment)}after end.`;
906
+ const parsed = parseComments(raw);
907
+ // Falls through to contextBefore-only (length 6 < 10, skipped), then contextAfter-only (length 5 < 10, skipped)
908
+ // cleanOffset stays at original marker position
909
+ expect(parsed.comments[0].cleanOffset).toBe('before'.length);
910
+ });
911
+ });
912
+
913
+ describe('insertComment with duplicate anchors', () => {
914
+ it('inserts at the first occurrence when anchor appears multiple times', () => {
915
+ const raw = 'foo bar foo baz foo';
916
+ const result = insertComment(raw, 'foo', 'which foo?');
917
+ const parsed = parseComments(result);
918
+ expect(parsed.comments).toHaveLength(1);
919
+ expect(parsed.cleanMarkdown).toBe('foo bar foo baz foo');
920
+ expect(parsed.comments[0].cleanOffset).toBe(0);
921
+ });
922
+ });
923
+
924
+ describe('insertComment cross-element segments', () => {
925
+ it('handles cross-element anchor with duplicate segment text', () => {
926
+ const raw = 'item one\nitem two\nitem three';
927
+ const result = insertComment(raw, 'item one\nitem two', 'spans items');
928
+ const parsed = parseComments(result);
929
+ expect(parsed.comments).toHaveLength(1);
930
+ expect(parsed.cleanMarkdown).toBe('item one\nitem two\nitem three');
931
+ expect(parsed.comments[0].cleanOffset).toBe(0);
932
+ });
933
+
934
+ it('handles cross-paragraph selection where browser collapses newline to space', () => {
935
+ // In markdown, bold header and body text are on separate lines (joined by \n).
936
+ // The browser renders this as a space, so sel.toString() gives a space where
937
+ // the source has a newline. The anchor must still be found.
938
+ const raw =
939
+ '**Primary: Builder**\nThe admin builds apps.\n\n**Secondary: User**\nThe user interacts.';
940
+ // sel.toString() would give space (not \n) between "Secondary: User" and "The user"
941
+ // because the browser collapses the line break. hintOffset is always provided by CommentForm.
942
+ const result = insertComment(
943
+ raw,
944
+ 'Secondary: User The user interacts',
945
+ 'test',
946
+ 'User',
947
+ undefined,
948
+ undefined,
949
+ 40,
950
+ );
951
+ const parsed = parseComments(result);
952
+ expect(parsed.comments).toHaveLength(1);
953
+ expect(parsed.comments[0].anchor).toBe('Secondary: User The user interacts');
954
+ });
955
+
956
+ it('finds segments with tabs (table selections)', () => {
957
+ const raw = 'Cell A\tCell B\tCell C';
958
+ const result = insertComment(raw, 'Cell A\tCell B', 'spans cells');
959
+ const parsed = parseComments(result);
960
+ expect(parsed.comments).toHaveLength(1);
961
+ expect(parsed.comments[0].cleanOffset).toBe(0);
962
+ });
963
+ });
964
+
965
+ describe('comments with nested JSON', () => {
966
+ it('parses comments with replies containing braces in text', () => {
967
+ const comment: MdComment = {
968
+ id: 'nested-1',
969
+ anchor: 'test',
970
+ text: 'use {} syntax',
971
+ author: 'User',
972
+ timestamp: '2024-01-01T00:00:00.000Z',
973
+ replies: [
974
+ {
975
+ id: 'r1',
976
+ text: 'try {value: true}',
977
+ author: 'Bob',
978
+ timestamp: '2024-01-01T00:00:00.000Z',
979
+ },
980
+ ],
981
+ };
982
+ const raw = `${serializeComment(comment)}test content`;
983
+ const parsed = parseComments(raw);
984
+ expect(parsed.comments).toHaveLength(1);
985
+ expect(parsed.comments[0].text).toBe('use {} syntax');
986
+ expect(parsed.comments[0].replies).toHaveLength(1);
987
+ expect(parsed.comments[0].replies![0].text).toBe('try {value: true}');
988
+ });
989
+
990
+ it('parses comments with deeply nested reply objects', () => {
991
+ const comment: MdComment = {
992
+ id: 'deep-1',
993
+ anchor: 'hello',
994
+ text: 'comment',
995
+ author: 'User',
996
+ timestamp: '2024-01-01T00:00:00.000Z',
997
+ replies: [
998
+ { id: 'r1', text: 'first reply', author: 'A', timestamp: '2024-01-01T00:00:00.000Z' },
999
+ { id: 'r2', text: 'second reply', author: 'B', timestamp: '2024-01-02T00:00:00.000Z' },
1000
+ { id: 'r3', text: 'third reply', author: 'C', timestamp: '2024-01-03T00:00:00.000Z' },
1001
+ ],
1002
+ };
1003
+ const raw = `${serializeComment(comment)}hello world`;
1004
+ const parsed = parseComments(raw);
1005
+ expect(parsed.comments).toHaveLength(1);
1006
+ expect(parsed.comments[0].replies).toHaveLength(3);
1007
+ });
1008
+ });
1009
+
1010
+ describe('updateCommentAnchor and cleanOffset after backward drag', () => {
1011
+ it('marker stays in place after anchor expansion — cleanOffset unchanged', () => {
1012
+ const raw = 'Some text before ' + `${marker({ id: 'drag-1', anchor: 'target' })}target end.`;
1013
+
1014
+ const expanded = updateCommentAnchor(raw, 'drag-1', 'text before target');
1015
+ const parsed = parseComments(expanded);
1016
+ const comment = parsed.comments[0];
1017
+ const clean = parsed.cleanMarkdown;
1018
+
1019
+ // Marker hasn't moved, so cleanOffset stays at the marker's position
1020
+ expect(comment.cleanOffset).toBe('Some text before '.length);
1021
+ // But the new anchor starts BEFORE cleanOffset
1022
+ expect(comment.anchor).toBe('text before target');
1023
+ expect(clean.indexOf(comment.anchor)).toBeLessThan(comment.cleanOffset!);
1024
+ });
1025
+
1026
+ it('cleanOffset gap grows with formatting characters between anchor start and marker', () => {
1027
+ // In rendered HTML, formatting markers (**, *, ##) are stripped, so the
1028
+ // rendered-text position of "target" is closer to the start than
1029
+ // cleanOffset suggests. When the anchor expands backwards, the gap
1030
+ // between cleanOffset and the anchor's rendered position grows further.
1031
+ const raw =
1032
+ '## Heading\n\n' +
1033
+ 'Has **bold** and *italic* and **more bold** ' +
1034
+ `${marker({ id: 'gap-1', anchor: 'target' })}target end.`;
1035
+
1036
+ const parsed = parseComments(raw);
1037
+ const comment = parsed.comments[0];
1038
+ const clean = parsed.cleanMarkdown;
1039
+
1040
+ // cleanOffset includes ## \n\n ** ** * * ** ** characters
1041
+ const targetInClean = clean.indexOf('target');
1042
+ // cleanOffset == targetInClean because marker is right before "target"
1043
+ expect(comment.cleanOffset).toBe(targetInClean);
1044
+
1045
+ // In rendered text (no ##, **, *), "target" would be at a LOWER position.
1046
+ // The formatting chars add ~20+ chars of offset. wrapText must account
1047
+ // for this when searching, especially after anchor expansion.
1048
+ const formattingChars = clean.slice(0, targetInClean).replace(/[^#*_\n]/g, '').length;
1049
+ expect(formattingChars).toBeGreaterThan(10);
1050
+ });
1051
+ });
1052
+
1053
+ describe('stripInlineFormatting via insertComment', () => {
1054
+ it('finds anchor inside strikethrough formatting', () => {
1055
+ const raw = 'This has ~~deleted~~ text';
1056
+ const result = insertComment(raw, 'deleted', 'why struck?');
1057
+ const parsed = parseComments(result);
1058
+ expect(parsed.comments).toHaveLength(1);
1059
+ expect(parsed.comments[0].anchor).toBe('deleted');
1060
+ });
1061
+
1062
+ it('finds anchor with multiple formatting markers', () => {
1063
+ const raw = 'This is **_really_ important** stuff';
1064
+ const result = insertComment(raw, 'really important', 'noted');
1065
+ const parsed = parseComments(result);
1066
+ expect(parsed.comments).toHaveLength(1);
1067
+ });
1068
+
1069
+ it('finds anchor in deeply nested list', () => {
1070
+ const raw = '1. first\n2. second\n3. third';
1071
+ const result = insertComment(raw, 'second', 'check');
1072
+ const parsed = parseComments(result);
1073
+ expect(parsed.comments).toHaveLength(1);
1074
+ });
1075
+
1076
+ it('preserves literal asterisks flanked by spaces', () => {
1077
+ const raw = 'Use a * as wildcard here';
1078
+ const result = insertComment(raw, 'a * as', 'clarify');
1079
+ const parsed = parseComments(result);
1080
+ expect(parsed.comments).toHaveLength(1);
1081
+ });
1082
+ });
1083
+
1084
+ describe('stripInlineFormatting with fenced code blocks', () => {
1085
+ it('strips fence markers but keeps code block content', () => {
1086
+ const md = '```\nhello\n```';
1087
+ const { plain } = stripInlineFormatting(md);
1088
+ expect(plain).toContain('hello');
1089
+ expect(plain).not.toContain('```');
1090
+ });
1091
+
1092
+ it('strips info string along with opening fence', () => {
1093
+ const md = '```mermaid\ngraph TD\n```';
1094
+ const { plain } = stripInlineFormatting(md);
1095
+ expect(plain).toContain('graph TD');
1096
+ expect(plain).not.toContain('mermaid');
1097
+ });
1098
+
1099
+ it('preserves backticks inside code blocks as literal text', () => {
1100
+ const md = '```\nuse `backticks` here\n```';
1101
+ const { plain } = stripInlineFormatting(md);
1102
+ expect(plain).toContain('`backticks`');
1103
+ });
1104
+
1105
+ it('preserves asterisks inside code blocks as literal text', () => {
1106
+ const md = '```\n**not bold** and *not italic*\n```';
1107
+ const { plain } = stripInlineFormatting(md);
1108
+ expect(plain).toContain('**not bold**');
1109
+ expect(plain).toContain('*not italic*');
1110
+ });
1111
+
1112
+ it('handles tilde-fenced code blocks', () => {
1113
+ const md = '~~~\ncode here\n~~~';
1114
+ const { plain } = stripInlineFormatting(md);
1115
+ expect(plain).toContain('code here');
1116
+ expect(plain).not.toContain('~~~');
1117
+ });
1118
+
1119
+ it('handles text before and after code blocks', () => {
1120
+ const md = 'before\n\n```\ncode\n```\n\nafter';
1121
+ const { plain } = stripInlineFormatting(md);
1122
+ expect(plain).toContain('before');
1123
+ expect(plain).toContain('code');
1124
+ expect(plain).toContain('after');
1125
+ expect(plain).not.toContain('```');
1126
+ });
1127
+
1128
+ it('maps code block content offsets back to correct clean-markdown positions', () => {
1129
+ const md = 'abc\n```\nxyz\n```\ndef';
1130
+ const { plain, toCleanOffset } = stripInlineFormatting(md);
1131
+ const xyzInPlain = plain.indexOf('xyz');
1132
+ expect(xyzInPlain).toBeGreaterThan(-1);
1133
+ const cleanOff = toCleanOffset(xyzInPlain);
1134
+ expect(md.slice(cleanOff, cleanOff + 3)).toBe('xyz');
1135
+ });
1136
+
1137
+ it('handles multiple code blocks', () => {
1138
+ const md = '```\nfirst\n```\n\n```\nsecond\n```';
1139
+ const { plain } = stripInlineFormatting(md);
1140
+ expect(plain).toContain('first');
1141
+ expect(plain).toContain('second');
1142
+ expect(plain).not.toContain('```');
1143
+ });
1144
+
1145
+ it('requires closing fence to match opening fence character', () => {
1146
+ // Backtick open should not be closed by tilde
1147
+ const md = '```\ncontent\n~~~\nmore\n```';
1148
+ const { plain } = stripInlineFormatting(md);
1149
+ // ~~~ inside backtick block is content, not a closing fence
1150
+ expect(plain).toContain('~~~');
1151
+ expect(plain).toContain('content');
1152
+ expect(plain).toContain('more');
1153
+ });
1154
+
1155
+ it('requires closing fence length >= opening fence length', () => {
1156
+ const md = '````\nshort ``` not closing\n````';
1157
+ const { plain } = stripInlineFormatting(md);
1158
+ expect(plain).toContain('short ``` not closing');
1159
+ });
1160
+
1161
+ it('still strips inline backticks outside code blocks', () => {
1162
+ const md = 'Use `code` here';
1163
+ const { plain } = stripInlineFormatting(md);
1164
+ expect(plain).toBe('Use code here');
1165
+ });
1166
+
1167
+ it('handles code block at end of file without trailing newline', () => {
1168
+ const md = '```\ncode\n```';
1169
+ const { plain } = stripInlineFormatting(md);
1170
+ expect(plain).toContain('code');
1171
+ expect(plain).not.toContain('```');
1172
+ });
1173
+ });
1174
+
1175
+ describe('insertComment inside fenced code blocks', () => {
1176
+ it('places marker before a mermaid code block, not inside it', () => {
1177
+ const raw = '# Diagram\n\n```mermaid\ngraph TD\n A --> B\n```\n\nEnd.';
1178
+ const result = insertComment(raw, 'graph TD', 'fix diagram');
1179
+ const parsed = parseComments(result);
1180
+ expect(parsed.comments).toHaveLength(1);
1181
+ expect(parsed.comments[0].anchor).toBe('graph TD');
1182
+ // The marker must NOT appear inside the code block (would be literal text)
1183
+ const codeBlockMatch = result.match(/```mermaid\n[\s\S]*?```/);
1184
+ expect(codeBlockMatch).toBeTruthy();
1185
+ expect(codeBlockMatch![0]).not.toContain('@comment');
1186
+ });
1187
+
1188
+ it('places marker before a generic fenced code block', () => {
1189
+ const raw = 'Text before\n\n```js\nconst x = 1;\n```\n\nText after';
1190
+ const result = insertComment(raw, 'const x = 1;', 'refactor');
1191
+ const parsed = parseComments(result);
1192
+ expect(parsed.comments).toHaveLength(1);
1193
+ const codeBlockMatch = result.match(/```js\n[\s\S]*?```/);
1194
+ expect(codeBlockMatch).toBeTruthy();
1195
+ expect(codeBlockMatch![0]).not.toContain('@comment');
1196
+ });
1197
+
1198
+ it('places marker before a tilde-fenced code block', () => {
1199
+ const raw = 'Before\n\n~~~\nsome code\n~~~\n\nAfter';
1200
+ const result = insertComment(raw, 'some code', 'review');
1201
+ const parsed = parseComments(result);
1202
+ expect(parsed.comments).toHaveLength(1);
1203
+ const codeBlockMatch = result.match(/~~~\n[\s\S]*?~~~/);
1204
+ expect(codeBlockMatch).toBeTruthy();
1205
+ expect(codeBlockMatch![0]).not.toContain('@comment');
1206
+ });
1207
+
1208
+ it('places marker on its own line so the code fence stays at column 0', () => {
1209
+ const raw = '# Diagram\n\n```mermaid\ngraph TD\n A[Start] --> B\n```\n\nEnd.';
1210
+ const result = insertComment(raw, 'graph TD', 'fix diagram');
1211
+ // The fence must still start at column 0 — the marker gets its own line
1212
+ expect(result).toMatch(/\n```mermaid\n/);
1213
+ // Marker should NOT be on the same line as the fence
1214
+ expect(result).not.toMatch(/@comment.*```mermaid/);
1215
+ });
1216
+
1217
+ it('marker before fence preserves valid markdown for other renderers', () => {
1218
+ const raw = 'Intro\n\n```mermaid\ngraph TD\n A[Start] --> B\n```\n\nEnd.';
1219
+ const result = insertComment(raw, 'A[Start]', 'rename node');
1220
+ // Every opening fence in the result should be at column 0
1221
+ for (const line of result.split('\n')) {
1222
+ if (line.includes('```') && !line.startsWith('<!--')) {
1223
+ expect(line).toMatch(/^`{3}/);
1224
+ }
1225
+ }
1226
+ });
1227
+
1228
+ it('round-trips through insert+parse for tilde-fenced code blocks', () => {
1229
+ const raw = 'Before\n\n~~~python\nprint("hi")\n~~~\n\nAfter';
1230
+ const result = insertComment(raw, 'print("hi")', 'log instead');
1231
+ const parsed = parseComments(result);
1232
+ expect(parsed.comments).toHaveLength(1);
1233
+ expect(parsed.cleanMarkdown).toBe(raw);
1234
+ });
1235
+
1236
+ it('round-trips through insert+parse for mermaid blocks', () => {
1237
+ const raw = 'Title\n\n```mermaid\nsequenceDiagram\n A->>B: Hello\n```\n\nDone.';
1238
+ const result = insertComment(raw, 'A->>B: Hello', 'wrong direction');
1239
+ const parsed = parseComments(result);
1240
+ expect(parsed.comments).toHaveLength(1);
1241
+ expect(parsed.cleanMarkdown).toBe(raw);
1242
+ });
1243
+
1244
+ it('multiple comments on different code blocks all round-trip', () => {
1245
+ const raw = '```js\nfoo()\n```\n\nText\n\n```py\nbar()\n```';
1246
+ let result = insertComment(raw, 'foo()', 'remove this');
1247
+ result = insertComment(result, 'bar()', 'rename');
1248
+ const parsed = parseComments(result);
1249
+ expect(parsed.comments).toHaveLength(2);
1250
+ expect(parsed.cleanMarkdown).toBe(raw);
1251
+ });
1252
+
1253
+ it('does not consume trailing newline for inline markers (not before a fence)', () => {
1254
+ const raw = 'Hello world\nSecond line';
1255
+ const result = insertComment(raw, 'Hello world', 'rewrite');
1256
+ const parsed = parseComments(result);
1257
+ expect(parsed.comments).toHaveLength(1);
1258
+ // The marker is inline (not on its own line before a fence), so no extra newline handling
1259
+ expect(parsed.cleanMarkdown).toBe(raw);
1260
+ });
1261
+
1262
+ it('code block at the very start of the document round-trips', () => {
1263
+ const raw = '```js\nconst x = 1;\n```\n\nEnd.';
1264
+ const result = insertComment(raw, 'const x = 1;', 'use let');
1265
+ const parsed = parseComments(result);
1266
+ expect(parsed.comments).toHaveLength(1);
1267
+ expect(parsed.cleanMarkdown).toBe(raw);
1268
+ });
1269
+
1270
+ it('still handles inline backticks correctly', () => {
1271
+ const raw = 'Use `foo` for this';
1272
+ const result = insertComment(raw, 'foo', 'rename');
1273
+ const parsed = parseComments(result);
1274
+ expect(parsed.comments).toHaveLength(1);
1275
+ expect(parsed.comments[0].anchor).toBe('foo');
1276
+ });
1277
+
1278
+ it('comment on code block round-trips through parse', () => {
1279
+ const raw = 'Intro\n\n```python\ndef hello():\n pass\n```\n\nOutro';
1280
+ const result = insertComment(raw, 'def hello():', 'add docstring');
1281
+ const parsed = parseComments(result);
1282
+ expect(parsed.comments).toHaveLength(1);
1283
+ // Clean markdown should restore original content
1284
+ expect(parsed.cleanMarkdown).toBe(raw);
1285
+ });
1286
+
1287
+ it('does not break fence syntax when placing marker before code block', () => {
1288
+ const raw = 'Text before\n\n```mermaid\ngraph TD\n A --> B\n```\n\nText after';
1289
+ const result = insertComment(raw, 'graph TD', 'fix diagram');
1290
+ // The fence must remain at line start — the marker must NOT be on the same line as ```
1291
+ const lines = result.split('\n');
1292
+ const fenceLine = lines.find((l) => l.startsWith('```mermaid'));
1293
+ expect(fenceLine).toBe('```mermaid');
1294
+ // Marker should be on a separate line
1295
+ const markerLine = lines.find((l) => l.includes('@comment'));
1296
+ expect(markerLine).toBeTruthy();
1297
+ expect(markerLine).not.toContain('```');
1298
+ });
1299
+
1300
+ it('preserves fence syntax with tilde code blocks', () => {
1301
+ const raw = 'Before\n\n~~~\nsome code\n~~~\n\nAfter';
1302
+ const result = insertComment(raw, 'some code', 'review');
1303
+ const lines = result.split('\n');
1304
+ const fenceLine = lines.find((l) => l.startsWith('~~~'));
1305
+ expect(fenceLine).toBe('~~~');
1306
+ });
1307
+
1308
+ it('preserves fence when code block is at document start', () => {
1309
+ const raw = '```js\nconst x = 1;\n```\n\nEnd.';
1310
+ const result = insertComment(raw, 'const x = 1;', 'refactor');
1311
+ // Fence must be at line start (possibly after a marker line)
1312
+ const parsed = parseComments(result);
1313
+ expect(parsed.comments).toHaveLength(1);
1314
+ // The code block should still render correctly
1315
+ const fenceMatch = result.match(/^```js$/m);
1316
+ expect(fenceMatch).toBeTruthy();
1317
+ });
1318
+
1319
+ it('preserves fence when adding second comment on duplicate text inside code block', () => {
1320
+ // Simulates the user's scenario: "user account" appears both outside and
1321
+ // inside a mermaid code block. First comment is on the outside occurrence,
1322
+ // second comment targets the code block occurrence.
1323
+ const outsideText = 'System creates user account and assigns the User Type.';
1324
+ const mermaidBlock = '```mermaid\ngraph TD\n A[creates user account] --> B\n```';
1325
+ const raw = `${outsideText}\n\n${mermaidBlock}\n\nEnd.`;
1326
+
1327
+ // First comment on the outside "user account"
1328
+ let result = insertComment(raw, 'user account', 'first comment');
1329
+ // Second comment targeting code block "user account"
1330
+ const { plain } = stripInlineFormatting(parseComments(result).cleanMarkdown);
1331
+ const secondOcc = plain.indexOf('user account', plain.indexOf('user account') + 1);
1332
+ result = insertComment(
1333
+ result,
1334
+ 'user account',
1335
+ 'second comment',
1336
+ 'User',
1337
+ undefined,
1338
+ undefined,
1339
+ secondOcc,
1340
+ );
1341
+
1342
+ const parsed = parseComments(result);
1343
+ expect(parsed.comments).toHaveLength(2);
1344
+ // The mermaid fence must still be at line start
1345
+ const fenceMatch = result.match(/^```mermaid$/m);
1346
+ expect(fenceMatch).toBeTruthy();
1347
+ // Neither marker should be inside the code block
1348
+ const codeBlockMatch = result.match(/```mermaid\n[\s\S]*?```/);
1349
+ expect(codeBlockMatch).toBeTruthy();
1350
+ expect(codeBlockMatch![0]).not.toContain('@comment');
1351
+ });
1352
+
1353
+ it('handles anchor that appears both in and outside a code block with hintOffset', () => {
1354
+ const raw = 'hello world\n\n```\nhello world\n```';
1355
+ // hintOffset pointing to the code block occurrence (past the first "hello world")
1356
+ const { plain } = stripInlineFormatting(parseComments(raw).cleanMarkdown);
1357
+ const secondOccurrence = plain.indexOf('hello world', plain.indexOf('hello world') + 1);
1358
+ const result = insertComment(
1359
+ raw,
1360
+ 'hello world',
1361
+ 'inside code',
1362
+ 'User',
1363
+ undefined,
1364
+ undefined,
1365
+ secondOccurrence,
1366
+ );
1367
+ const parsed = parseComments(result);
1368
+ expect(parsed.comments).toHaveLength(1);
1369
+ // Marker should be placed before the code block, not inside it
1370
+ const codeBlockMatch = result.match(/```\n[\s\S]*?```/);
1371
+ expect(codeBlockMatch![0]).not.toContain('@comment');
1372
+ });
1373
+
1374
+ it('does not interfere with comments on text outside code blocks', () => {
1375
+ const raw = 'Normal text\n\n```\ncode\n```\n\nMore text';
1376
+ const result = insertComment(raw, 'Normal text', 'edit this');
1377
+ const parsed = parseComments(result);
1378
+ expect(parsed.comments).toHaveLength(1);
1379
+ // Marker should be before "Normal text", not near the code block
1380
+ expect(result.indexOf('@comment')).toBeLessThan(result.indexOf('Normal text'));
1381
+ });
1382
+
1383
+ it('handles multiple code blocks in the same document', () => {
1384
+ const raw = '```\nfirst\n```\n\nMiddle\n\n```\nsecond\n```';
1385
+ const result = insertComment(raw, 'second', 'check this');
1386
+ const parsed = parseComments(result);
1387
+ expect(parsed.comments).toHaveLength(1);
1388
+ // Marker should be before the second code block
1389
+ const markerIdx = result.indexOf('@comment');
1390
+ const secondFenceIdx = result.indexOf('```', result.indexOf('```\n\nMiddle') + 1);
1391
+ expect(markerIdx).toBeLessThanOrEqual(secondFenceIdx);
1392
+ // First code block should be untouched
1393
+ expect(result.slice(0, result.indexOf('\n\nMiddle'))).not.toContain('@comment');
1394
+ });
1395
+ });
1396
+
1397
+ describe('comment text containing -->', () => {
1398
+ it('round-trips comment text with --> without breaking the marker', () => {
1399
+ const raw = 'Some text here';
1400
+ const result = insertComment(raw, 'text', 'Use <!-- summary{} --> for this');
1401
+ const parsed = parseComments(result);
1402
+ expect(parsed.comments).toHaveLength(1);
1403
+ expect(parsed.comments[0].text).toBe('Use <!-- summary{} --> for this');
1404
+ expect(parsed.cleanMarkdown).toBe('Some text here');
1405
+ });
1406
+
1407
+ it('handles multiple --> occurrences in comment text', () => {
1408
+ const raw = 'Hello world';
1409
+ const result = insertComment(raw, 'world', 'a --> b --> c');
1410
+ const parsed = parseComments(result);
1411
+ expect(parsed.comments).toHaveLength(1);
1412
+ expect(parsed.comments[0].text).toBe('a --> b --> c');
1413
+ });
1414
+
1415
+ it('handles --> in anchor text', () => {
1416
+ const raw = 'Use --> to indicate flow';
1417
+ const result = insertComment(raw, '--> to indicate', 'clarify arrow');
1418
+ const parsed = parseComments(result);
1419
+ expect(parsed.comments).toHaveLength(1);
1420
+ expect(parsed.comments[0].anchor).toBe('--> to indicate');
1421
+ });
1422
+
1423
+ it('handles } --> pattern that could trick the regex', () => {
1424
+ const raw = 'Some text here';
1425
+ const result = insertComment(raw, 'text', 'see <!-- @comment{fake} --> above');
1426
+ const parsed = parseComments(result);
1427
+ expect(parsed.comments).toHaveLength(1);
1428
+ expect(parsed.comments[0].text).toBe('see <!-- @comment{fake} --> above');
1429
+ expect(parsed.cleanMarkdown).toBe('Some text here');
1430
+ });
1431
+
1432
+ it('serializes --> as unicode escape in the marker', () => {
1433
+ const comment: MdComment = {
1434
+ id: 'test',
1435
+ anchor: 'text',
1436
+ text: 'has --> in it',
1437
+ author: 'User',
1438
+ timestamp: '2024-01-01T00:00:00.000Z',
1439
+ };
1440
+ const serialized = serializeComment(comment);
1441
+ expect(serialized).not.toContain('"has -->');
1442
+ expect(serialized).toContain('--\\u003e');
1443
+ });
1444
+ });
1445
+
1446
+ describe('pickBestOccurrence', () => {
1447
+ it('returns the only occurrence when there is just one', () => {
1448
+ expect(pickBestOccurrence('hello world', [6], 'world', 6)).toBe(6);
1449
+ });
1450
+
1451
+ it('falls back to nearest hintOffset when no context is provided', () => {
1452
+ // "foo" at 0, 8, 16; hintOffset=15 → pick 16
1453
+ expect(pickBestOccurrence('foo bar foo bar foo', [0, 8, 16], 'foo', 15)).toBe(16);
1454
+ });
1455
+
1456
+ it('uses context to pick correct occurrence even when hintOffset is wrong', () => {
1457
+ const plain = 'alpha foo beta gamma foo delta';
1458
+ // "foo" at 6 and 21. Suppose hintOffset is wrong (say 5, closer to 1st "foo")
1459
+ // but context uniquely identifies the 2nd "foo"
1460
+ const result = pickBestOccurrence(plain, [6, 21], 'foo', 5, 'gamma ', ' delta');
1461
+ expect(result).toBe(21);
1462
+ });
1463
+
1464
+ it('uses context to pick correct occurrence with many duplicates', () => {
1465
+ const plain = 'x foo y foo z foo w';
1466
+ // "foo" at 2, 8, 14. Context identifies 2nd one.
1467
+ const result = pickBestOccurrence(plain, [2, 8, 14], 'foo', 0, 'y ', ' z');
1468
+ expect(result).toBe(8);
1469
+ });
1470
+
1471
+ it('handles whitespace-normalized context matching (blank line drift)', () => {
1472
+ // Plain text has \n\n between paragraphs (markdown), DOM has \n (rendered HTML).
1473
+ // The whitespace normalization in pickBestOccurrence should handle this drift.
1474
+ const plain = 'alpha foo beta\n\ngamma\n\ndelta foo epsilon';
1475
+ // "foo" at positions 6 and 29
1476
+ // In DOM text, 2nd "foo" would be preceded by "delta " (same after normalization)
1477
+ const result = pickBestOccurrence(
1478
+ plain,
1479
+ [6, 29],
1480
+ 'foo',
1481
+ 5, // hintOffset=5 is closer to 1st foo
1482
+ 'delta ', // contextBefore from DOM (uniquely identifies 2nd occurrence)
1483
+ ' epsilon', // contextAfter
1484
+ );
1485
+ // Should pick 2nd "foo" despite wrong hintOffset, thanks to context matching
1486
+ expect(result).toBe(29);
1487
+ });
1488
+
1489
+ it('uses hintOffset as tiebreaker when context scores are equal', () => {
1490
+ // Identical surrounding context — context can't disambiguate
1491
+ const plain = 'x foo y x foo y';
1492
+ const result = pickBestOccurrence(plain, [2, 10], 'foo', 9, 'x ', ' y');
1493
+ // Both have identical context ("x " before, " y" after), so hintOffset breaks tie
1494
+ expect(result).toBe(10);
1495
+ });
1496
+
1497
+ it('handles empty context strings gracefully', () => {
1498
+ const plain = 'foo bar foo';
1499
+ const result = pickBestOccurrence(plain, [0, 8], 'foo', 7, '', '');
1500
+ // Empty context → fall back to hintOffset
1501
+ expect(result).toBe(8);
1502
+ });
1503
+ });
1504
+
1505
+ describe('context-based disambiguation in insertComment', () => {
1506
+ it('uses context to pick correct duplicate when hintOffset has drift', () => {
1507
+ // Document with a link that causes offset drift between DOM and plain text.
1508
+ // "foo" appears twice. The link adds chars to plain text that aren't in DOM text.
1509
+ const raw = 'See [details](https://example.com/long-url) for foo info.\n\nAnother foo here.';
1510
+ // In DOM text: "See details for foo info.\n\nAnother foo here." (no link syntax)
1511
+ // hintOffset from DOM for 2nd "foo" ≈ 35 (in DOM text)
1512
+ // But in plain text (with link stripping), 2nd "foo" is also around 35
1513
+ // Without link stripping, 2nd "foo" would be at ~65 (much further from hintOffset=35)
1514
+ // Context uniquely identifies the 2nd "foo" regardless
1515
+ const result = insertComment(
1516
+ raw,
1517
+ 'foo',
1518
+ 'check this',
1519
+ 'User',
1520
+ 'Another ', // contextBefore (from DOM, last few chars before 2nd "foo")
1521
+ ' here.', // contextAfter
1522
+ 35, // hintOffset in DOM space (may not match plain space exactly)
1523
+ );
1524
+ const parsed = parseComments(result);
1525
+ expect(parsed.comments).toHaveLength(1);
1526
+ // The marker should be before the SECOND "foo" (in "Another foo here")
1527
+ const markerIdx = result.indexOf('<!-- @comment');
1528
+ const textBefore = result.slice(0, markerIdx);
1529
+ expect(textBefore).toContain('Another ');
1530
+ expect(textBefore).not.toContain('<!-- @comment');
1531
+ });
1532
+
1533
+ it('uses context to pick correct duplicate across multiple paragraphs', () => {
1534
+ const raw =
1535
+ 'First paragraph with target word.\n\nSecond paragraph.\n\nThird paragraph with target word.';
1536
+ // User selected "target" in the third paragraph
1537
+ const result = insertComment(
1538
+ raw,
1539
+ 'target',
1540
+ 'fix',
1541
+ 'User',
1542
+ 'with ', // contextBefore
1543
+ ' word.', // contextAfter
1544
+ 70, // hintOffset (approximate, may have drift)
1545
+ );
1546
+ const parsed = parseComments(result);
1547
+ expect(parsed.comments).toHaveLength(1);
1548
+ // Both "target" have "with " before and " word." after — identical local context!
1549
+ // So hintOffset breaks the tie. With hintOffset=70 (closer to 2nd occurrence):
1550
+ const markerIdx = result.indexOf('<!-- @comment');
1551
+ const textBefore = result.slice(0, markerIdx);
1552
+ expect(textBefore).toContain('Third paragraph with ');
1553
+ });
1554
+
1555
+ it('context disambiguates when surrounding text differs', () => {
1556
+ // "the cat" appears at offsets 0 (as "The cat") and 38 (as "the cat")
1557
+ // But the clean text search is case-sensitive, so "the cat" only matches at 38
1558
+ // Let's use a proper duplicate:
1559
+ const raw2 = 'the cat sat on the mat.\nthe dog saw the cat run.';
1560
+ const result = insertComment(
1561
+ raw2,
1562
+ 'the cat',
1563
+ 'which one',
1564
+ 'User',
1565
+ 'saw ', // contextBefore: chars before 2nd "the cat"
1566
+ ' run.', // contextAfter
1567
+ 39,
1568
+ );
1569
+ const parsed = parseComments(result);
1570
+ expect(parsed.comments).toHaveLength(1);
1571
+ const markerIdx = result.indexOf('<!-- @comment');
1572
+ const textBefore = result.slice(0, markerIdx);
1573
+ expect(textBefore).toBe('the cat sat on the mat.\nthe dog saw ');
1574
+ });
1575
+
1576
+ it('still works when only contextBefore is available', () => {
1577
+ const raw = 'start foo end\nstart foo end';
1578
+ const result = insertComment(
1579
+ raw,
1580
+ 'foo',
1581
+ 'note',
1582
+ 'User',
1583
+ '\nstart ', // contextBefore identifies 2nd occurrence (has newline prefix)
1584
+ undefined,
1585
+ 15,
1586
+ );
1587
+ const parsed = parseComments(result);
1588
+ expect(parsed.comments).toHaveLength(1);
1589
+ const markerIdx = result.indexOf('<!-- @comment');
1590
+ const textBefore = result.slice(0, markerIdx);
1591
+ expect(textBefore).toBe('start foo end\nstart ');
1592
+ });
1593
+
1594
+ it('still works when only contextAfter is available', () => {
1595
+ const raw = 'foo alpha\nfoo beta';
1596
+ const result = insertComment(
1597
+ raw,
1598
+ 'foo',
1599
+ 'note',
1600
+ 'User',
1601
+ undefined,
1602
+ ' beta', // contextAfter identifies 2nd occurrence
1603
+ 10,
1604
+ );
1605
+ const parsed = parseComments(result);
1606
+ expect(parsed.comments).toHaveLength(1);
1607
+ const markerIdx = result.indexOf('<!-- @comment');
1608
+ const textBefore = result.slice(0, markerIdx);
1609
+ expect(textBefore).toBe('foo alpha\n');
1610
+ });
1611
+ });
1612
+
1613
+ describe('stripInlineFormatting with links and images', () => {
1614
+ it('strips link syntax, keeping only text', () => {
1615
+ const md = 'See [click here](https://example.com) for details';
1616
+ const { plain } = stripInlineFormatting(md);
1617
+ expect(plain).toBe('See click here for details');
1618
+ expect(plain).not.toContain('[');
1619
+ expect(plain).not.toContain('](');
1620
+ expect(plain).not.toContain('example.com');
1621
+ });
1622
+
1623
+ it('strips image syntax entirely', () => {
1624
+ const md = 'Before ![alt text](image.png) after';
1625
+ const { plain } = stripInlineFormatting(md);
1626
+ expect(plain).toBe('Before after');
1627
+ expect(plain).not.toContain('alt text');
1628
+ expect(plain).not.toContain('image.png');
1629
+ });
1630
+
1631
+ it('handles multiple links', () => {
1632
+ const md = '[a](u1) and [b](u2) and [c](u3)';
1633
+ const { plain } = stripInlineFormatting(md);
1634
+ expect(plain).toBe('a and b and c');
1635
+ });
1636
+
1637
+ it('handles link with formatting inside', () => {
1638
+ const md = 'See [**bold link**](url) here';
1639
+ const { plain } = stripInlineFormatting(md);
1640
+ expect(plain).toBe('See bold link here');
1641
+ });
1642
+
1643
+ it('maps link text offsets back correctly', () => {
1644
+ const md = 'before [link text](url) after';
1645
+ const { plain, toCleanOffset } = stripInlineFormatting(md);
1646
+ const linkIdx = plain.indexOf('link text');
1647
+ expect(linkIdx).toBeGreaterThan(-1);
1648
+ // "link text" in clean markdown is at position 8 (after "before [")
1649
+ const cleanOff = toCleanOffset(linkIdx);
1650
+ expect(md.slice(cleanOff, cleanOff + 9)).toBe('link text');
1651
+ });
1652
+
1653
+ it('maps offsets correctly after image removal', () => {
1654
+ const md = 'before ![img](url) after';
1655
+ const { plain, toCleanOffset } = stripInlineFormatting(md);
1656
+ const afterIdx = plain.indexOf('after');
1657
+ expect(afterIdx).toBeGreaterThan(-1);
1658
+ const cleanOff = toCleanOffset(afterIdx);
1659
+ expect(md.slice(cleanOff, cleanOff + 5)).toBe('after');
1660
+ });
1661
+
1662
+ it('does not strip brackets that are not links', () => {
1663
+ const md = 'array[0] is valid';
1664
+ const { plain } = stripInlineFormatting(md);
1665
+ // [0] is not followed by (...), so it stays as-is
1666
+ expect(plain).toBe('array[0] is valid');
1667
+ });
1668
+
1669
+ it('handles link at start of document', () => {
1670
+ const md = '[first](url) rest';
1671
+ const { plain } = stripInlineFormatting(md);
1672
+ expect(plain).toBe('first rest');
1673
+ });
1674
+
1675
+ it('handles link at end of document', () => {
1676
+ const md = 'start [last](url)';
1677
+ const { plain } = stripInlineFormatting(md);
1678
+ expect(plain).toBe('start last');
1679
+ });
1680
+
1681
+ it('handles image next to link', () => {
1682
+ const md = '![img](pic.png)[link](url)';
1683
+ const { plain } = stripInlineFormatting(md);
1684
+ expect(plain).toBe('link');
1685
+ });
1686
+
1687
+ it('preserves links inside fenced code blocks', () => {
1688
+ const md = '```\n[not a link](url)\n```';
1689
+ const { plain } = stripInlineFormatting(md);
1690
+ // Inside code blocks, content is preserved as-is
1691
+ expect(plain).toContain('[not a link](url)');
1692
+ });
1693
+
1694
+ it('toPlainOffset handles link-adjusted positions', () => {
1695
+ const md = 'aa [link](url) bb';
1696
+ const { toPlainOffset } = stripInlineFormatting(md);
1697
+ // "bb" is at clean offset 15 (after "aa [link](url) ")
1698
+ // In plain text, it's at offset 8 (after "aa link " = 8 chars)
1699
+ const plainOff = toPlainOffset(15);
1700
+ expect(plainOff).toBe(8);
1701
+ });
1702
+ });
1703
+
1704
+ describe('insertComment with links causing offset drift', () => {
1705
+ it('places marker correctly when links shift offsets', () => {
1706
+ // Without link stripping, "foo" positions in plain text would be wrong
1707
+ // because [text](url) keeps the full syntax. With link stripping,
1708
+ // the plain text more closely matches DOM textContent.
1709
+ const raw = '[intro](url1) has foo. Then [outro](url2) has foo too.';
1710
+ // In DOM text: "intro has foo. Then outro has foo too."
1711
+ // User selects 2nd "foo" — hintOffset in DOM is ~33
1712
+ const result = insertComment(
1713
+ raw,
1714
+ 'foo',
1715
+ 'second',
1716
+ 'User',
1717
+ 'has ', // contextBefore
1718
+ ' too.', // contextAfter
1719
+ 33,
1720
+ );
1721
+ const parsed = parseComments(result);
1722
+ expect(parsed.comments).toHaveLength(1);
1723
+ const markerIdx = result.indexOf('<!-- @comment');
1724
+ const textBefore = result.slice(0, markerIdx);
1725
+ expect(textBefore).toContain('has ');
1726
+ expect(textBefore).toContain('[outro]');
1727
+ });
1728
+
1729
+ it('handles document with many links and duplicate text', () => {
1730
+ const raw = [
1731
+ '# [Project](url1) Overview',
1732
+ '',
1733
+ 'The [system](url2) has a key component.',
1734
+ '',
1735
+ '## [Details](url3) Section',
1736
+ '',
1737
+ 'The [framework](url4) has a key component.',
1738
+ ].join('\n');
1739
+ // "key" appears twice. User selects 2nd one.
1740
+ // Context: "has a " before, " component" after
1741
+ const result = insertComment(
1742
+ raw,
1743
+ 'key',
1744
+ 'review',
1745
+ 'User',
1746
+ 'has a ',
1747
+ ' component.',
1748
+ 80, // approximate DOM offset
1749
+ );
1750
+ const parsed = parseComments(result);
1751
+ expect(parsed.comments).toHaveLength(1);
1752
+ const markerIdx = result.indexOf('<!-- @comment');
1753
+ const textBefore = result.slice(0, markerIdx);
1754
+ // Should be in the second paragraph (after "Details Section")
1755
+ expect(textBefore).toContain('[framework]');
1756
+ });
1757
+ });
1758
+
1759
+ describe('resolveComment', () => {
1760
+ it('sets resolved and status on a comment', () => {
1761
+ const raw = `${marker({ id: 'c1', anchor: 'hello' })}hello world`;
1762
+ const result = resolveComment(raw, 'c1');
1763
+ const parsed = parseComments(result);
1764
+ expect(parsed.comments).toHaveLength(1);
1765
+ expect(parsed.comments[0].resolved).toBe(true);
1766
+ expect(parsed.comments[0].status).toBe('resolved');
1767
+ expect(parsed.cleanMarkdown).toBe('hello world');
1768
+ });
1769
+
1770
+ it('does not modify other comments', () => {
1771
+ const raw =
1772
+ `${marker({ id: 'c1', anchor: 'hello' })}hello ` +
1773
+ `${marker({ id: 'c2', anchor: 'world' })}world`;
1774
+ const result = resolveComment(raw, 'c1');
1775
+ const parsed = parseComments(result);
1776
+ expect(parsed.comments).toHaveLength(2);
1777
+ expect(parsed.comments[0].resolved).toBe(true);
1778
+ expect(parsed.comments[1].resolved).toBeUndefined();
1779
+ });
1780
+
1781
+ it('is idempotent — resolving an already resolved comment keeps it resolved', () => {
1782
+ const raw = `${marker({ id: 'c1', anchor: 'hi', resolved: true, status: 'resolved' })}hi`;
1783
+ const result = resolveComment(raw, 'c1');
1784
+ const parsed = parseComments(result);
1785
+ expect(parsed.comments[0].resolved).toBe(true);
1786
+ expect(parsed.comments[0].status).toBe('resolved');
1787
+ });
1788
+ });
1789
+
1790
+ describe('unresolveComment', () => {
1791
+ it('sets resolved to false and status to open', () => {
1792
+ const raw = `${marker({ id: 'c1', anchor: 'hello', resolved: true, status: 'resolved' })}hello world`;
1793
+ const result = unresolveComment(raw, 'c1');
1794
+ const parsed = parseComments(result);
1795
+ expect(parsed.comments).toHaveLength(1);
1796
+ expect(parsed.comments[0].resolved).toBe(false);
1797
+ expect(parsed.comments[0].status).toBe('open');
1798
+ });
1799
+
1800
+ it('round-trips: resolve then unresolve restores open state', () => {
1801
+ const raw = `${marker({ id: 'c1', anchor: 'hello' })}hello world`;
1802
+ const resolved = resolveComment(raw, 'c1');
1803
+ const unresolved = unresolveComment(resolved, 'c1');
1804
+ const parsed = parseComments(unresolved);
1805
+ expect(parsed.comments[0].resolved).toBe(false);
1806
+ expect(parsed.comments[0].status).toBe('open');
1807
+ });
1808
+ });
1809
+
1810
+ describe('removeAllComments', () => {
1811
+ it('removes all comment markers, leaving clean content', () => {
1812
+ const raw =
1813
+ `${marker({ id: 'c1', anchor: 'hello' })}hello ` +
1814
+ `${marker({ id: 'c2', anchor: 'world' })}world`;
1815
+ const result = removeAllComments(raw);
1816
+ expect(result).toBe('hello world');
1817
+ expect(result).not.toContain('<!-- @comment');
1818
+ });
1819
+
1820
+ it('returns unchanged text when there are no comments', () => {
1821
+ const raw = '# Hello\n\nSome text';
1822
+ expect(removeAllComments(raw)).toBe(raw);
1823
+ });
1824
+
1825
+ it('handles a single comment', () => {
1826
+ const raw = `${marker({ id: 'c1', anchor: 'test' })}test content`;
1827
+ const result = removeAllComments(raw);
1828
+ expect(result).toBe('test content');
1829
+ });
1830
+
1831
+ it('keeps comment-looking examples inside fenced code blocks', () => {
1832
+ const codeExample =
1833
+ '<!-- @comment{"id":"example","anchor":"demo","text":"example","author":"Doc","timestamp":"2024-01-01T00:00:00.000Z"} -->demo';
1834
+ const raw = `\`\`\`md\n${codeExample}\n\`\`\`\n${marker({ id: 'c1', anchor: 'live' })}live comment`;
1835
+ const result = removeAllComments(raw);
1836
+ expect(result).toContain(codeExample);
1837
+ expect(result).toContain('```md');
1838
+ expect(result).not.toContain('"id":"c1"');
1839
+ });
1840
+ });
1841
+
1842
+ describe('resolveAllComments', () => {
1843
+ it('resolves all open comments', () => {
1844
+ const raw =
1845
+ `${marker({ id: 'c1', anchor: 'hello' })}hello ` +
1846
+ `${marker({ id: 'c2', anchor: 'world' })}world`;
1847
+ const result = resolveAllComments(raw);
1848
+ const parsed = parseComments(result);
1849
+ expect(parsed.comments).toHaveLength(2);
1850
+ expect(parsed.comments[0].resolved).toBe(true);
1851
+ expect(parsed.comments[0].status).toBe('resolved');
1852
+ expect(parsed.comments[1].resolved).toBe(true);
1853
+ expect(parsed.comments[1].status).toBe('resolved');
1854
+ });
1855
+
1856
+ it('does not double-resolve already resolved comments', () => {
1857
+ const raw = `${marker({ id: 'c1', anchor: 'hi', resolved: true, status: 'resolved' })}hi`;
1858
+ const result = resolveAllComments(raw);
1859
+ const parsed = parseComments(result);
1860
+ expect(parsed.comments[0].resolved).toBe(true);
1861
+ });
1862
+
1863
+ it('preserves clean markdown content', () => {
1864
+ const raw = `${marker({ id: 'c1', anchor: 'hello' })}hello world`;
1865
+ const result = resolveAllComments(raw);
1866
+ const parsed = parseComments(result);
1867
+ expect(parsed.cleanMarkdown).toBe('hello world');
1868
+ });
1869
+
1870
+ it('ignores comment-looking examples inside fenced code blocks', () => {
1871
+ const codeExample =
1872
+ '<!-- @comment{"id":"example","anchor":"demo","text":"example","author":"Doc","timestamp":"2024-01-01T00:00:00.000Z"} -->demo';
1873
+ const raw = `\`\`\`md\n${codeExample}\n\`\`\`\n${marker({ id: 'c1', anchor: 'hello' })}hello`;
1874
+ const result = resolveAllComments(raw);
1875
+ expect(result).toContain(codeExample);
1876
+ const parsed = parseComments(result);
1877
+ expect(parsed.comments[0].status).toBe('resolved');
1878
+ });
1879
+ });
1880
+
1881
+ describe('removeResolvedComments', () => {
1882
+ it('removes only resolved comments', () => {
1883
+ const raw =
1884
+ `${marker({ id: 'c1', anchor: 'hello', resolved: true, status: 'resolved' })}hello ` +
1885
+ `${marker({ id: 'c2', anchor: 'world' })}world`;
1886
+ const result = removeResolvedComments(raw);
1887
+ const parsed = parseComments(result);
1888
+ expect(parsed.comments).toHaveLength(1);
1889
+ expect(parsed.comments[0].id).toBe('c2');
1890
+ expect(parsed.cleanMarkdown).toBe('hello world');
1891
+ });
1892
+
1893
+ it('does nothing when there are no resolved comments', () => {
1894
+ const raw = `${marker({ id: 'c1', anchor: 'hello' })}hello world`;
1895
+ const result = removeResolvedComments(raw);
1896
+ const parsed = parseComments(result);
1897
+ expect(parsed.comments).toHaveLength(1);
1898
+ });
1899
+
1900
+ it('removes all comments when all are resolved', () => {
1901
+ const raw =
1902
+ `${marker({ id: 'c1', anchor: 'a', resolved: true, status: 'resolved' })}a ` +
1903
+ `${marker({ id: 'c2', anchor: 'b', resolved: true, status: 'resolved' })}b`;
1904
+ const result = removeResolvedComments(raw);
1905
+ expect(result).toBe('a b');
1906
+ expect(result).not.toContain('<!-- @comment');
1907
+ });
1908
+
1909
+ it('treats status-only resolved comments as resolved', () => {
1910
+ const raw =
1911
+ `${marker({ id: 'c1', anchor: 'hello', status: 'resolved' })}hello ` +
1912
+ `${marker({ id: 'c2', anchor: 'world' })}world`;
1913
+ const result = removeResolvedComments(raw);
1914
+ const parsed = parseComments(result);
1915
+ expect(parsed.comments).toHaveLength(1);
1916
+ expect(parsed.comments[0].id).toBe('c2');
1917
+ expect(parsed.cleanMarkdown).toBe('hello world');
1918
+ });
1919
+ });
1920
+
1921
+ describe('isInsideCodeBlock boundary', () => {
1922
+ it('comment marker immediately after closing fence is NOT inside code block', () => {
1923
+ const raw = '```\ncode\n```\n' + `${marker({ id: 'c1', anchor: 'after' })}after the fence`;
1924
+ const parsed = parseComments(raw);
1925
+ expect(parsed.comments).toHaveLength(1);
1926
+ expect(parsed.comments[0].id).toBe('c1');
1927
+ expect(parsed.cleanMarkdown).toBe('```\ncode\n```\nafter the fence');
1928
+ });
1929
+
1930
+ it('comment marker inside code block is ignored', () => {
1931
+ const raw = '```\n' + `${marker({ id: 'c1', anchor: 'inside' })}inside\n` + '```\nafter';
1932
+ const parsed = parseComments(raw);
1933
+ expect(parsed.comments).toHaveLength(0);
1934
+ });
1935
+ });
1936
+
1937
+ describe('cleanToRawOffset multi-marker', () => {
1938
+ it('maps offsets correctly with multiple markers', () => {
1939
+ const m1 = marker({ id: 'c1', anchor: 'hello' });
1940
+ const m2 = marker({ id: 'c2', anchor: 'world' });
1941
+ const raw = `${m1}hello ${m2}world end`;
1942
+ const parsed = parseComments(raw);
1943
+ expect(parsed.comments).toHaveLength(2);
1944
+ // Clean text is "hello world end"
1945
+ expect(parsed.cleanMarkdown).toBe('hello world end');
1946
+ // cleanOffset 0 → start of "hello" → rawOffset should be after m1
1947
+ expect(parsed.comments[0].cleanOffset).toBe(0);
1948
+ expect(parsed.comments[1].cleanOffset).toBe(6); // "hello " = 6 chars
1949
+ });
1950
+
1951
+ it('maps offsets correctly with trailing marker', () => {
1952
+ const m1 = marker({ id: 'c1', anchor: 'end' });
1953
+ const raw = `Some text at the ${m1}end`;
1954
+ const parsed = parseComments(raw);
1955
+ expect(parsed.comments).toHaveLength(1);
1956
+ expect(parsed.cleanMarkdown).toBe('Some text at the end');
1957
+ expect(parsed.comments[0].cleanOffset).toBe(17); // "Some text at the " = 17
1958
+ });
1959
+ });