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,1021 @@
1
+ import { getEffectiveStatus, type MdComment, type ParseResult, type CommentReply } from '../types';
2
+
3
+ // Match <!-- @comment{...JSON...} --> — use dotall flag so JSON with
4
+ // newlines in string values is matched correctly.
5
+ const COMMENT_PATTERN = /<!-- @comment(\{.*?\}) -->/gs;
6
+
7
+ /** Shared regex for matching comment markers (without capture group). Reset lastIndex before use. */
8
+ export const COMMENT_MARKER_RE = /<!-- @comment\{.*?\} -->/gs;
9
+
10
+ interface CodeBlockRange {
11
+ start: number;
12
+ end: number;
13
+ }
14
+
15
+ interface CommentMarkerRegion {
16
+ rawStart: number;
17
+ markerEnd: number;
18
+ stripEnd: number;
19
+ parsedComment: MdComment | null;
20
+ }
21
+
22
+ type CommentTransform =
23
+ | { type: 'keep' }
24
+ | { type: 'remove' }
25
+ | { type: 'replace'; comment: MdComment };
26
+
27
+ function getCodeBlockRanges(rawMarkdown: string): CodeBlockRange[] {
28
+ const codeBlockRanges: CodeBlockRange[] = [];
29
+ const fenceRegex = /^ {0,3}(`{3,}|~{3,}).*$/gm;
30
+ let fenceMatch: RegExpExecArray | null;
31
+ let openFence: { marker: string; start: number } | null = null;
32
+
33
+ while ((fenceMatch = fenceRegex.exec(rawMarkdown)) !== null) {
34
+ const marker = fenceMatch[1];
35
+ if (!openFence) {
36
+ openFence = { marker: marker[0].repeat(marker.length), start: fenceMatch.index };
37
+ } else if (marker[0] === openFence.marker[0] && marker.length >= openFence.marker.length) {
38
+ codeBlockRanges.push({
39
+ start: openFence.start,
40
+ end: fenceMatch.index + fenceMatch[0].length,
41
+ });
42
+ openFence = null;
43
+ }
44
+ }
45
+
46
+ return codeBlockRanges;
47
+ }
48
+
49
+ function isInsideCodeBlock(offset: number, codeBlockRanges: CodeBlockRange[]): boolean {
50
+ for (const range of codeBlockRanges) {
51
+ if (offset >= range.start && offset < range.end) return true;
52
+ }
53
+ return false;
54
+ }
55
+
56
+ function getStandaloneStripEnd(
57
+ rawMarkdown: string,
58
+ markerStart: number,
59
+ markerEnd: number,
60
+ ): number {
61
+ const isStartOfLine = markerStart === 0 || rawMarkdown[markerStart - 1] === '\n';
62
+ return isStartOfLine && rawMarkdown[markerEnd] === '\n' ? markerEnd + 1 : markerEnd;
63
+ }
64
+
65
+ function collectCommentRegions(rawMarkdown: string): CommentMarkerRegion[] {
66
+ const codeBlockRanges = getCodeBlockRanges(rawMarkdown);
67
+ const regions: CommentMarkerRegion[] = [];
68
+ const regex = new RegExp(COMMENT_PATTERN);
69
+ let match: RegExpExecArray | null;
70
+
71
+ while ((match = regex.exec(rawMarkdown)) !== null) {
72
+ if (isInsideCodeBlock(match.index, codeBlockRanges)) continue;
73
+
74
+ let parsedComment: MdComment | null = null;
75
+ try {
76
+ const data = JSON.parse(match[1]) as MdComment;
77
+ if (
78
+ typeof data.id === 'string' &&
79
+ typeof data.anchor === 'string' &&
80
+ typeof data.text === 'string' &&
81
+ typeof data.author === 'string' &&
82
+ (!data.replies || Array.isArray(data.replies))
83
+ ) {
84
+ parsedComment = data;
85
+ }
86
+ } catch {
87
+ // Malformed markers are still considered removable outside code blocks.
88
+ }
89
+
90
+ const markerEnd = match.index + match[0].length;
91
+ regions.push({
92
+ rawStart: match.index,
93
+ markerEnd,
94
+ stripEnd: getStandaloneStripEnd(rawMarkdown, match.index, markerEnd),
95
+ parsedComment,
96
+ });
97
+ }
98
+
99
+ return regions;
100
+ }
101
+
102
+ function transformCommentMarkers(
103
+ rawMarkdown: string,
104
+ transform: (comment: MdComment | null) => CommentTransform,
105
+ ): string {
106
+ const regions = collectCommentRegions(rawMarkdown);
107
+ if (regions.length === 0) return rawMarkdown;
108
+
109
+ let nextRaw = '';
110
+ let lastEnd = 0;
111
+
112
+ for (const region of regions) {
113
+ nextRaw += rawMarkdown.slice(lastEnd, region.rawStart);
114
+ const action = transform(region.parsedComment);
115
+
116
+ if (action.type === 'keep') {
117
+ nextRaw += rawMarkdown.slice(region.rawStart, region.markerEnd);
118
+ lastEnd = region.markerEnd;
119
+ continue;
120
+ }
121
+
122
+ if (action.type === 'replace') {
123
+ nextRaw += serializeComment(action.comment);
124
+ lastEnd = region.markerEnd;
125
+ continue;
126
+ }
127
+
128
+ lastEnd = region.stripEnd;
129
+ }
130
+
131
+ nextRaw += rawMarkdown.slice(lastEnd);
132
+ return nextRaw;
133
+ }
134
+
135
+ export function parseComments(rawMarkdown: string): ParseResult {
136
+ const comments: MdComment[] = [];
137
+ const strippedRegions: { rawStart: number; rawEnd: number; parsed: boolean }[] = [];
138
+
139
+ for (const region of collectCommentRegions(rawMarkdown)) {
140
+ if (region.parsedComment) {
141
+ comments.push(region.parsedComment);
142
+ }
143
+ strippedRegions.push({
144
+ rawStart: region.rawStart,
145
+ rawEnd: region.stripEnd,
146
+ parsed: region.parsedComment !== null,
147
+ });
148
+ }
149
+
150
+ // Build clean markdown by stripping comment markers
151
+ let cleanMarkdown = '';
152
+ let lastEnd = 0;
153
+ for (const region of strippedRegions) {
154
+ cleanMarkdown += rawMarkdown.slice(lastEnd, region.rawStart);
155
+ lastEnd = region.rawEnd;
156
+ }
157
+ cleanMarkdown += rawMarkdown.slice(lastEnd);
158
+
159
+ // Compute each comment's cleanOffset — the position in clean markdown
160
+ // where the marker was. Since markers are placed BEFORE the anchor text,
161
+ // this is the start of the anchor text in the clean content.
162
+ let cumShift = 0;
163
+ let commentIdx = 0;
164
+ for (let i = 0; i < strippedRegions.length; i++) {
165
+ const region = strippedRegions[i];
166
+ const cleanPos = region.rawStart - cumShift;
167
+ if (region.parsed && comments[commentIdx]) {
168
+ comments[commentIdx].cleanOffset = cleanPos;
169
+ commentIdx++;
170
+ }
171
+ cumShift += region.rawEnd - region.rawStart;
172
+ }
173
+
174
+ // Fuzzy re-matching: for comments whose anchor is no longer found at their
175
+ // cleanOffset, use contextBefore/contextAfter to locate the new position.
176
+ for (const comment of comments) {
177
+ if (comment.cleanOffset === undefined) continue;
178
+ // Check if anchor is found at its expected position
179
+ const atOffset = cleanMarkdown.slice(
180
+ comment.cleanOffset,
181
+ comment.cleanOffset + comment.anchor.length,
182
+ );
183
+ if (atOffset === comment.anchor) continue; // exact match — no need to re-match
184
+ // Also check if anchor exists anywhere
185
+ if (cleanMarkdown.includes(comment.anchor)) continue;
186
+ // Anchor is missing — try fuzzy re-match using context
187
+ if (!comment.contextBefore && !comment.contextAfter) continue;
188
+ const newOffset = fuzzyReMatch(cleanMarkdown, comment);
189
+ if (newOffset !== null) {
190
+ comment.cleanOffset = newOffset;
191
+ }
192
+ }
193
+
194
+ // Offset mapping: clean position → raw position
195
+ function cleanToRawOffset(cleanOffset: number): number {
196
+ let shift = 0;
197
+ for (const region of strippedRegions) {
198
+ const regionCleanStart = region.rawStart - shift;
199
+ if (cleanOffset < regionCleanStart) {
200
+ return cleanOffset + shift;
201
+ }
202
+ shift += region.rawEnd - region.rawStart;
203
+ }
204
+ return cleanOffset + shift;
205
+ }
206
+
207
+ return { cleanMarkdown, comments, cleanToRawOffset };
208
+ }
209
+
210
+ /**
211
+ * Fuzzy re-match: use contextBefore and contextAfter to find where a comment's
212
+ * anchor region now sits in the clean markdown, even if the anchor text has been rewritten.
213
+ * Returns the new cleanOffset or null if not found.
214
+ */
215
+ function fuzzyReMatch(cleanMarkdown: string, comment: MdComment): number | null {
216
+ const { contextBefore, contextAfter } = comment;
217
+
218
+ // Try matching with both context strings
219
+ if (contextBefore && contextAfter) {
220
+ const beforeIdx = cleanMarkdown.indexOf(contextBefore);
221
+ if (beforeIdx !== -1) {
222
+ const anchorStart = beforeIdx + contextBefore.length;
223
+ const afterIdx = cleanMarkdown.indexOf(contextAfter, anchorStart);
224
+ if (afterIdx !== -1) {
225
+ // The region between contextBefore and contextAfter is the new anchor area
226
+ const gap = afterIdx - anchorStart;
227
+ if (gap > 0 && gap < 500) return anchorStart;
228
+ }
229
+ }
230
+ }
231
+
232
+ // Fallback: try contextBefore only (only if it appears exactly once)
233
+ if (contextBefore && contextBefore.length >= 10) {
234
+ const firstIdx = cleanMarkdown.indexOf(contextBefore);
235
+ if (firstIdx !== -1 && cleanMarkdown.indexOf(contextBefore, firstIdx + 1) === -1) {
236
+ return firstIdx + contextBefore.length;
237
+ }
238
+ }
239
+
240
+ // Fallback: try contextAfter only (only if it appears exactly once)
241
+ // The anchor text should end right where contextAfter begins, so
242
+ // subtract the anchor length to find where the anchor starts.
243
+ if (contextAfter && contextAfter.length >= 10) {
244
+ const firstIdx = cleanMarkdown.indexOf(contextAfter);
245
+ if (
246
+ firstIdx !== -1 &&
247
+ firstIdx > 0 &&
248
+ cleanMarkdown.indexOf(contextAfter, firstIdx + 1) === -1
249
+ ) {
250
+ return Math.max(0, firstIdx - comment.anchor.length);
251
+ }
252
+ }
253
+
254
+ return null;
255
+ }
256
+
257
+ export function serializeComment(comment: MdComment): string {
258
+ // Strip cleanOffset — it's computed at parse time, not persisted
259
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
260
+ const { cleanOffset, ...data } = comment;
261
+ // Escape --> in the JSON to prevent it from closing the HTML comment
262
+ // prematurely. \u003e is the Unicode escape for >, which JSON.parse
263
+ // decodes back to > automatically — no manual unescaping needed.
264
+ const json = JSON.stringify(data).replace(/-->/g, '--\\u003e');
265
+ return `<!-- @comment${json} -->`;
266
+ }
267
+
268
+ /**
269
+ * Among multiple occurrences of an anchor in plain text, pick the one that
270
+ * best matches the user's original selection using whitespace-normalized
271
+ * context matching, with hintOffset proximity as tiebreaker.
272
+ *
273
+ * Context strings come from container.textContent (DOM space) while the
274
+ * plain text comes from stripInlineFormatting (markdown space). These can
275
+ * differ in whitespace around block boundaries (\n\n vs \n) and unhandled
276
+ * constructs (links, images). Whitespace normalization makes the comparison
277
+ * robust against this drift.
278
+ */
279
+ export function pickBestOccurrence(
280
+ plain: string,
281
+ occurrences: number[],
282
+ anchor: string,
283
+ hintOffset: number,
284
+ contextBefore?: string,
285
+ contextAfter?: string,
286
+ ): number {
287
+ if (occurrences.length <= 1) return occurrences[0];
288
+
289
+ // When no context is available, fall back to nearest hintOffset
290
+ if (!contextBefore && !contextAfter) {
291
+ return occurrences.reduce((b, idx) =>
292
+ Math.abs(idx - hintOffset) < Math.abs(b - hintOffset) ? idx : b,
293
+ );
294
+ }
295
+
296
+ // Normalize whitespace: collapse runs into single spaces to handle
297
+ // blank-line drift (\n\n in markdown vs \n in rendered HTML)
298
+ const normCtxBefore = contextBefore?.replace(/\s+/g, ' ') ?? '';
299
+ const normCtxAfter = contextAfter?.replace(/\s+/g, ' ') ?? '';
300
+
301
+ let bestOcc = occurrences[0];
302
+ let bestScore = -1;
303
+ let bestDist = Infinity;
304
+
305
+ for (const occ of occurrences) {
306
+ let score = 0;
307
+
308
+ // Score by matching suffix of contextBefore (working backwards from anchor start)
309
+ if (normCtxBefore) {
310
+ const windowSize = normCtxBefore.length * 2; // extra room for pre-normalization whitespace
311
+ const rawBefore = plain.slice(Math.max(0, occ - windowSize), occ);
312
+ const normBefore = rawBefore.replace(/\s+/g, ' ');
313
+ for (let j = 1; j <= Math.min(normBefore.length, normCtxBefore.length); j++) {
314
+ if (normBefore[normBefore.length - j] === normCtxBefore[normCtxBefore.length - j]) {
315
+ score++;
316
+ } else {
317
+ break;
318
+ }
319
+ }
320
+ }
321
+
322
+ // Score by matching prefix of contextAfter (working forwards from anchor end)
323
+ if (normCtxAfter) {
324
+ const afterStart = occ + anchor.length;
325
+ const windowSize = normCtxAfter.length * 2;
326
+ const rawAfter = plain.slice(afterStart, afterStart + windowSize);
327
+ const normAfter = rawAfter.replace(/\s+/g, ' ');
328
+ for (let j = 0; j < Math.min(normAfter.length, normCtxAfter.length); j++) {
329
+ if (normAfter[j] === normCtxAfter[j]) {
330
+ score++;
331
+ } else {
332
+ break;
333
+ }
334
+ }
335
+ }
336
+
337
+ const dist = Math.abs(occ - hintOffset);
338
+ if (score > bestScore || (score === bestScore && dist < bestDist)) {
339
+ bestScore = score;
340
+ bestOcc = occ;
341
+ bestDist = dist;
342
+ }
343
+ }
344
+
345
+ return bestOcc;
346
+ }
347
+
348
+ export function insertComment(
349
+ rawMarkdown: string,
350
+ anchor: string,
351
+ commentText: string,
352
+ author: string = 'User',
353
+ contextBefore?: string,
354
+ contextAfter?: string,
355
+ hintOffset?: number,
356
+ commentId: string = crypto.randomUUID(),
357
+ ): string {
358
+ const comment: MdComment = {
359
+ id: commentId,
360
+ anchor,
361
+ text: commentText,
362
+ author,
363
+ timestamp: new Date().toISOString(),
364
+ ...(contextBefore ? { contextBefore } : {}),
365
+ ...(contextAfter ? { contextAfter } : {}),
366
+ };
367
+
368
+ // Find the anchor text in the CLEAN markdown (no comment markers),
369
+ // then map the position back to the raw markdown.
370
+ const { cleanMarkdown, cleanToRawOffset } = parseComments(rawMarkdown);
371
+
372
+ let insertionCleanOffset: number | null = null;
373
+
374
+ // When hintOffset is provided (from DOM selection), search in plain-text space
375
+ // first. This is the same coordinate space as hintOffset and sees through
376
+ // markdown formatting, so it correctly handles duplicates where one occurrence
377
+ // is formatted (e.g. **foo**) and another is not (foo).
378
+ if (hintOffset !== undefined) {
379
+ const { plain, toCleanOffset } = stripInlineFormatting(cleanMarkdown);
380
+
381
+ // Direct search for anchor in plain text (flexible whitespace matching
382
+ // so browser-collapsed newlines in sel.toString() match source newlines)
383
+ const plainOccs: number[] = [];
384
+ let pSearch = 0;
385
+ while (true) {
386
+ const fm = flexibleIndexOf(plain, anchor, pSearch);
387
+ if (!fm) break;
388
+ plainOccs.push(fm.start);
389
+ pSearch = fm.start + 1;
390
+ }
391
+
392
+ // Also try segment-based search for cross-element selections (newlines/tabs)
393
+ if (plainOccs.length === 0) {
394
+ const segments = anchor
395
+ .split(/[\n\t]+/)
396
+ .map((s) => s.trim())
397
+ .filter(Boolean);
398
+ if (segments.length > 0) {
399
+ for (const r of findAllSegments(plain, segments)) {
400
+ plainOccs.push(r.start);
401
+ }
402
+ }
403
+ }
404
+
405
+ if (plainOccs.length > 0) {
406
+ const best =
407
+ plainOccs.length === 1
408
+ ? plainOccs[0]
409
+ : pickBestOccurrence(plain, plainOccs, anchor, hintOffset, contextBefore, contextAfter);
410
+ insertionCleanOffset = toCleanOffset(best);
411
+ }
412
+ }
413
+
414
+ // Fallback when no hintOffset: use exact match in clean markdown (first occurrence)
415
+ if (insertionCleanOffset === null) {
416
+ const cleanIdx = cleanMarkdown.indexOf(anchor);
417
+ if (cleanIdx !== -1) {
418
+ insertionCleanOffset = cleanIdx;
419
+ }
420
+ }
421
+
422
+ // Fallback: cross-element selections with newlines/tabs — segment-based search
423
+ if (insertionCleanOffset === null) {
424
+ const segments = anchor
425
+ .split(/[\n\t]+/)
426
+ .map((s) => s.trim())
427
+ .filter(Boolean);
428
+ if (segments.length > 0) {
429
+ const segResult = findSegments(cleanMarkdown, segments);
430
+ if (segResult !== null) {
431
+ insertionCleanOffset = segResult.start;
432
+ }
433
+
434
+ // If that fails, try in formatting-stripped text and map back
435
+ if (insertionCleanOffset === null) {
436
+ const { plain, toCleanOffset } = stripInlineFormatting(cleanMarkdown);
437
+ const fm = flexibleIndexOf(plain, segments[0]);
438
+ if (fm) {
439
+ insertionCleanOffset = toCleanOffset(fm.start);
440
+ }
441
+ }
442
+ }
443
+ }
444
+
445
+ if (insertionCleanOffset === null) return rawMarkdown;
446
+
447
+ // If the insertion point falls inside a fenced code block, move it before the block.
448
+ // HTML comment markers are literal text inside code blocks, so the marker must go outside.
449
+ let movedBeforeFence = false;
450
+ {
451
+ const fenceRegex = /^ {0,3}(`{3,}|~{3,}).*$/gm;
452
+ let fm: RegExpExecArray | null;
453
+ let openF: { marker: string; start: number } | null = null;
454
+ while ((fm = fenceRegex.exec(cleanMarkdown)) !== null) {
455
+ const marker = fm[1];
456
+ if (!openF) {
457
+ openF = { marker: marker[0].repeat(marker.length), start: fm.index };
458
+ } else if (marker[0] === openF.marker[0] && marker.length >= openF.marker.length) {
459
+ if (
460
+ insertionCleanOffset >= openF.start &&
461
+ insertionCleanOffset <= fm.index + fm[0].length
462
+ ) {
463
+ insertionCleanOffset = openF.start;
464
+ movedBeforeFence = true;
465
+ }
466
+ openF = null;
467
+ }
468
+ }
469
+ }
470
+
471
+ // Insert marker BEFORE the anchor text in the raw markdown.
472
+ // When moved before a code fence, place the marker on its own line so the
473
+ // opening fence stays at column 0 (otherwise other renderers won't parse it).
474
+ const rawInsertionPoint = cleanToRawOffset(insertionCleanOffset);
475
+
476
+ const marker = serializeComment(comment);
477
+ if (movedBeforeFence) {
478
+ return (
479
+ rawMarkdown.slice(0, rawInsertionPoint) + marker + '\n' + rawMarkdown.slice(rawInsertionPoint)
480
+ );
481
+ }
482
+ return rawMarkdown.slice(0, rawInsertionPoint) + marker + rawMarkdown.slice(rawInsertionPoint);
483
+ }
484
+
485
+ export function removeComment(rawMarkdown: string, commentId: string): string {
486
+ return transformCommentMarkers(rawMarkdown, (comment) => {
487
+ if (comment?.id === commentId) return { type: 'remove' };
488
+ return { type: 'keep' };
489
+ });
490
+ }
491
+
492
+ export function resolveComment(rawMarkdown: string, commentId: string): string {
493
+ return transformCommentMarkers(rawMarkdown, (comment) => {
494
+ if (comment?.id === commentId) {
495
+ return {
496
+ type: 'replace',
497
+ comment: {
498
+ ...comment,
499
+ resolved: true,
500
+ status: 'resolved',
501
+ },
502
+ };
503
+ }
504
+ return { type: 'keep' };
505
+ });
506
+ }
507
+
508
+ export function unresolveComment(rawMarkdown: string, commentId: string): string {
509
+ return transformCommentMarkers(rawMarkdown, (comment) => {
510
+ if (comment?.id === commentId) {
511
+ return {
512
+ type: 'replace',
513
+ comment: {
514
+ ...comment,
515
+ resolved: false,
516
+ status: 'open',
517
+ },
518
+ };
519
+ }
520
+ return { type: 'keep' };
521
+ });
522
+ }
523
+
524
+ export function editComment(rawMarkdown: string, commentId: string, newText: string): string {
525
+ return transformCommentMarkers(rawMarkdown, (comment) => {
526
+ if (comment?.id === commentId) {
527
+ return {
528
+ type: 'replace',
529
+ comment: {
530
+ ...comment,
531
+ text: newText,
532
+ },
533
+ };
534
+ }
535
+ return { type: 'keep' };
536
+ });
537
+ }
538
+
539
+ function updateReplies(
540
+ rawMarkdown: string,
541
+ commentId: string,
542
+ updater: (replies: CommentReply[]) => CommentReply[] | null,
543
+ ): string {
544
+ return transformCommentMarkers(rawMarkdown, (comment) => {
545
+ if (comment?.id !== commentId) return { type: 'keep' };
546
+
547
+ const nextReplies = updater(comment.replies ?? []);
548
+ if (nextReplies === null) return { type: 'keep' };
549
+
550
+ if (nextReplies.length === 0) {
551
+ const nextComment = { ...comment };
552
+ delete nextComment.replies;
553
+ return {
554
+ type: 'replace',
555
+ comment: nextComment,
556
+ };
557
+ }
558
+
559
+ return {
560
+ type: 'replace',
561
+ comment: { ...comment, replies: nextReplies },
562
+ };
563
+ });
564
+ }
565
+
566
+ export function updateCommentAnchor(
567
+ rawMarkdown: string,
568
+ commentId: string,
569
+ newAnchor: string,
570
+ ): string {
571
+ // Recompute contextBefore/contextAfter from the clean markdown so they stay
572
+ // consistent with the new anchor. Without this, later fuzzy re-matching can
573
+ // attach the comment to the wrong text after document edits.
574
+ // Use the comment's cleanOffset to find the right occurrence when the anchor
575
+ // text appears multiple times.
576
+ const { cleanMarkdown, comments } = parseComments(rawMarkdown);
577
+ const target = comments.find((c) => c.id === commentId);
578
+ let newContextBefore: string | undefined;
579
+ let newContextAfter: string | undefined;
580
+ if (target?.cleanOffset !== undefined) {
581
+ // The new anchor starts at the comment's existing position in clean markdown
582
+ const anchorIdx = target.cleanOffset;
583
+ const CONTEXT_LEN = 30;
584
+ const beforeStart = Math.max(0, anchorIdx - CONTEXT_LEN);
585
+ newContextBefore = cleanMarkdown.slice(beforeStart, anchorIdx);
586
+ const afterEnd = Math.min(cleanMarkdown.length, anchorIdx + newAnchor.length + CONTEXT_LEN);
587
+ newContextAfter = cleanMarkdown.slice(anchorIdx + newAnchor.length, afterEnd);
588
+ }
589
+
590
+ return transformCommentMarkers(rawMarkdown, (comment) => {
591
+ if (comment?.id === commentId) {
592
+ return {
593
+ type: 'replace',
594
+ comment: {
595
+ ...comment,
596
+ anchor: newAnchor,
597
+ ...(newContextBefore !== undefined ? { contextBefore: newContextBefore } : {}),
598
+ ...(newContextAfter !== undefined ? { contextAfter: newContextAfter } : {}),
599
+ },
600
+ };
601
+ }
602
+ return { type: 'keep' };
603
+ });
604
+ }
605
+
606
+ export function addReply(
607
+ rawMarkdown: string,
608
+ commentId: string,
609
+ text: string,
610
+ author: string = 'User',
611
+ ): string {
612
+ const reply: CommentReply = {
613
+ id: crypto.randomUUID(),
614
+ text,
615
+ author,
616
+ timestamp: new Date().toISOString(),
617
+ };
618
+
619
+ return updateReplies(rawMarkdown, commentId, (replies) => [...replies, reply]);
620
+ }
621
+
622
+ export function editReply(
623
+ rawMarkdown: string,
624
+ commentId: string,
625
+ replyId: string,
626
+ newText: string,
627
+ ): string {
628
+ return updateReplies(rawMarkdown, commentId, (replies) => {
629
+ const replyIndex = replies.findIndex((reply) => reply.id === replyId);
630
+ if (replyIndex === -1) return null;
631
+
632
+ return replies.map((reply) =>
633
+ reply.id === replyId
634
+ ? {
635
+ ...reply,
636
+ text: newText,
637
+ }
638
+ : reply,
639
+ );
640
+ });
641
+ }
642
+
643
+ export function removeReply(rawMarkdown: string, commentId: string, replyId: string): string {
644
+ return updateReplies(rawMarkdown, commentId, (replies) => {
645
+ const nextReplies = replies.filter((reply) => reply.id !== replyId);
646
+ return nextReplies.length === replies.length ? null : nextReplies;
647
+ });
648
+ }
649
+
650
+ export function removeAllComments(rawMarkdown: string): string {
651
+ return transformCommentMarkers(rawMarkdown, () => ({ type: 'remove' }));
652
+ }
653
+
654
+ export function resolveAllComments(rawMarkdown: string): string {
655
+ return transformCommentMarkers(rawMarkdown, (comment) => {
656
+ if (!comment || getEffectiveStatus(comment) === 'resolved') {
657
+ return { type: 'keep' };
658
+ }
659
+ return {
660
+ type: 'replace',
661
+ comment: {
662
+ ...comment,
663
+ resolved: true,
664
+ status: 'resolved',
665
+ },
666
+ };
667
+ });
668
+ }
669
+
670
+ export function removeResolvedComments(rawMarkdown: string): string {
671
+ return transformCommentMarkers(rawMarkdown, (comment) => {
672
+ if (comment && getEffectiveStatus(comment) === 'resolved') {
673
+ return { type: 'remove' };
674
+ }
675
+ return { type: 'keep' };
676
+ });
677
+ }
678
+
679
+ /** Search for ordered segments in text starting from a given offset, return the start and end offsets or null. */
680
+ /**
681
+ * Whitespace-flexible indexOf: find `needle` in `haystack` starting from `startFrom`,
682
+ * allowing any single whitespace char in the needle to match any single whitespace char
683
+ * in the haystack (e.g. space matches newline). Returns {start, end} in haystack coords
684
+ * or null if not found.
685
+ */
686
+ function flexibleIndexOf(
687
+ haystack: string,
688
+ needle: string,
689
+ startFrom = 0,
690
+ ): { start: number; end: number } | null {
691
+ // Fast path: exact match
692
+ const exact = haystack.indexOf(needle, startFrom);
693
+ if (exact !== -1) return { start: exact, end: exact + needle.length };
694
+
695
+ // Split needle on whitespace runs, then search for the parts in order
696
+ // with flexible whitespace between them
697
+ const parts = needle.split(/\s+/).filter(Boolean);
698
+ if (parts.length === 0) return null;
699
+ if (parts.length === 1) {
700
+ const idx = haystack.indexOf(parts[0], startFrom);
701
+ return idx === -1 ? null : { start: idx, end: idx + parts[0].length };
702
+ }
703
+
704
+ let search = startFrom;
705
+ while (search < haystack.length) {
706
+ const firstIdx = haystack.indexOf(parts[0], search);
707
+ if (firstIdx === -1) return null;
708
+
709
+ let pos = firstIdx + parts[0].length;
710
+ let matched = true;
711
+ for (let i = 1; i < parts.length; i++) {
712
+ // Must have at least one whitespace char between parts
713
+ if (pos >= haystack.length || !/\s/.test(haystack[pos])) {
714
+ matched = false;
715
+ break;
716
+ }
717
+ while (pos < haystack.length && /\s/.test(haystack[pos])) pos++;
718
+ if (haystack.startsWith(parts[i], pos)) {
719
+ pos += parts[i].length;
720
+ } else {
721
+ matched = false;
722
+ break;
723
+ }
724
+ }
725
+ if (matched) return { start: firstIdx, end: pos };
726
+ search = firstIdx + 1;
727
+ }
728
+ return null;
729
+ }
730
+
731
+ function findSegments(
732
+ text: string,
733
+ segments: string[],
734
+ startFrom = 0,
735
+ ): { start: number; end: number } | null {
736
+ let searchFrom = startFrom;
737
+ let firstStart = -1;
738
+ let lastEnd = -1;
739
+ for (let i = 0; i < segments.length; i++) {
740
+ const match = flexibleIndexOf(text, segments[i], searchFrom);
741
+ if (!match) return null;
742
+ if (i === 0) firstStart = match.start;
743
+ lastEnd = match.end;
744
+ searchFrom = lastEnd;
745
+ }
746
+ return firstStart === -1 ? null : { start: firstStart, end: lastEnd };
747
+ }
748
+
749
+ /** Find ALL occurrences of ordered segments in text. */
750
+ function findAllSegments(text: string, segments: string[]): { start: number; end: number }[] {
751
+ const results: { start: number; end: number }[] = [];
752
+ let startFrom = 0;
753
+ while (true) {
754
+ const result = findSegments(text, segments, startFrom);
755
+ if (result === null) break;
756
+ results.push(result);
757
+ startFrom = result.start + 1;
758
+ }
759
+ return results;
760
+ }
761
+
762
+ /**
763
+ * Strip inline markdown formatting (**, *, __, `, ~~) and block-level markers
764
+ * (# headings, - lists, 1. lists) to produce plain text that matches rendered output.
765
+ * Returns a position map to convert plain-text offsets back to clean-markdown offsets.
766
+ */
767
+ export function stripInlineFormatting(md: string): {
768
+ plain: string;
769
+ toCleanOffset: (off: number) => number;
770
+ toPlainOffset: (cleanOff: number) => number;
771
+ } {
772
+ const map: number[] = [];
773
+ let plain = '';
774
+ let i = 0;
775
+ const len = md.length;
776
+ const atLineStart = (pos: number) => pos === 0 || md[pos - 1] === '\n';
777
+
778
+ // Track pending link URL skip: when we see [text](url), we skip [,
779
+ // process text normally, then skip ](url) when we reach the ].
780
+ let pendingLinkSkipAt = -1;
781
+ let pendingLinkSkipTo = -1;
782
+
783
+ while (i < len) {
784
+ // Handle pending link URL skip: we reached ], jump past ](url)
785
+ if (pendingLinkSkipAt !== -1 && i >= pendingLinkSkipAt) {
786
+ i = pendingLinkSkipTo;
787
+ pendingLinkSkipAt = -1;
788
+ pendingLinkSkipTo = -1;
789
+ continue;
790
+ }
791
+
792
+ // Fenced code blocks: skip fence lines, keep content as-is
793
+ if (atLineStart(i) && (md[i] === '`' || md[i] === '~')) {
794
+ const fenceChar = md[i];
795
+ let fenceEnd = i;
796
+ while (fenceEnd < len && md[fenceEnd] === fenceChar) fenceEnd++;
797
+ const fenceLen = fenceEnd - i;
798
+ if (fenceLen >= 3) {
799
+ // Skip the opening fence line (markers + info string + newline)
800
+ while (fenceEnd < len && md[fenceEnd] !== '\n') fenceEnd++;
801
+ if (fenceEnd < len) fenceEnd++;
802
+ i = fenceEnd;
803
+ // Process content until closing fence — add as-is (no formatting stripping)
804
+ while (i < len) {
805
+ if (atLineStart(i) && md[i] === fenceChar) {
806
+ let closeEnd = i;
807
+ while (closeEnd < len && md[closeEnd] === fenceChar) closeEnd++;
808
+ if (closeEnd - i >= fenceLen) {
809
+ // Skip closing fence line
810
+ while (closeEnd < len && md[closeEnd] !== '\n') closeEnd++;
811
+ if (closeEnd < len) closeEnd++;
812
+ i = closeEnd;
813
+ break;
814
+ }
815
+ }
816
+ map.push(i);
817
+ plain += md[i];
818
+ i++;
819
+ }
820
+ continue;
821
+ }
822
+ }
823
+
824
+ // Markdown images: ![alt](url) → skip entirely (no text content in rendered DOM)
825
+ if (md[i] === '!' && i + 1 < len && md[i + 1] === '[') {
826
+ const cb = md.indexOf(']', i + 2);
827
+ if (cb !== -1 && cb + 1 < len && md[cb + 1] === '(') {
828
+ const cp = md.indexOf(')', cb + 2);
829
+ if (cp !== -1) {
830
+ i = cp + 1;
831
+ continue;
832
+ }
833
+ }
834
+ }
835
+
836
+ // Markdown links: [text](url) → skip [ and ](url), keep text
837
+ if (md[i] === '[' && pendingLinkSkipAt === -1) {
838
+ const cb = md.indexOf(']', i + 1);
839
+ if (cb !== -1 && cb + 1 < len && md[cb + 1] === '(') {
840
+ const cp = md.indexOf(')', cb + 2);
841
+ if (cp !== -1) {
842
+ pendingLinkSkipAt = cb;
843
+ pendingLinkSkipTo = cp + 1;
844
+ i++; // skip '['
845
+ continue;
846
+ }
847
+ }
848
+ }
849
+
850
+ // Heading markers at line start
851
+ if (atLineStart(i) && md[i] === '#') {
852
+ while (i < len && md[i] === '#') i++;
853
+ if (i < len && md[i] === ' ') i++;
854
+ continue;
855
+ }
856
+
857
+ // List markers at line start: - item, * item, N. item
858
+ if (atLineStart(i)) {
859
+ if ((md[i] === '-' || md[i] === '*') && i + 1 < len && md[i + 1] === ' ') {
860
+ i += 2;
861
+ continue;
862
+ }
863
+ if (/\d/.test(md[i])) {
864
+ let j = i;
865
+ while (j < len && /\d/.test(md[j])) j++;
866
+ if (j < len && md[j] === '.' && j + 1 < len && md[j + 1] === ' ') {
867
+ i = j + 2;
868
+ continue;
869
+ }
870
+ }
871
+ }
872
+
873
+ // Inline formatting: * _ — skip unless flanked by spaces on both sides
874
+ // (space-flanked * and _ are literal, not formatting)
875
+ if (md[i] === '*' || md[i] === '_') {
876
+ const prev = i > 0 ? md[i - 1] : ' ';
877
+ const next = i < len - 1 ? md[i + 1] : ' ';
878
+ if (!(/\s/.test(prev) && /\s/.test(next))) {
879
+ i++;
880
+ continue;
881
+ }
882
+ }
883
+
884
+ // Backticks are always formatting.
885
+ if (md[i] === '`') {
886
+ i++;
887
+ continue;
888
+ }
889
+
890
+ // Single tildes are literal text (for example ~/docs paths); only paired
891
+ // tildes represent strikethrough formatting.
892
+ if (md[i] === '~' && md[i + 1] === '~') {
893
+ i += 2;
894
+ continue;
895
+ }
896
+
897
+ map.push(i);
898
+ plain += md[i];
899
+ i++;
900
+ }
901
+
902
+ return {
903
+ plain,
904
+ toCleanOffset: (off: number) => (off >= map.length ? md.length : map[off]),
905
+ toPlainOffset: (cleanOff: number) => {
906
+ // Binary search: find the plain index whose map entry is closest to cleanOff
907
+ let lo = 0;
908
+ let hi = map.length - 1;
909
+ if (hi < 0) return 0;
910
+ while (lo < hi) {
911
+ const mid = (lo + hi) >> 1;
912
+ if (map[mid] < cleanOff) lo = mid + 1;
913
+ else hi = mid;
914
+ }
915
+ // lo is now the first plain index where map[lo] >= cleanOff
916
+ return lo;
917
+ },
918
+ };
919
+ }
920
+
921
+ /**
922
+ * Check if ordered parts appear contiguously in text, with only whitespace between them.
923
+ * Used for flexible anchor detection when exact string match fails.
924
+ */
925
+ function partsAppearContiguously(text: string, parts: string[]): boolean {
926
+ let searchFrom = 0;
927
+ while (searchFrom < text.length) {
928
+ const firstIdx = text.indexOf(parts[0], searchFrom);
929
+ if (firstIdx === -1) return false;
930
+ let pos = firstIdx + parts[0].length;
931
+ let matched = true;
932
+ for (let i = 1; i < parts.length; i++) {
933
+ // Skip whitespace so cross-line selections still match
934
+ while (pos < text.length && /\s/.test(text[pos])) pos++;
935
+ // After whitespace, skip optional block-level markers at line start (list bullets, blockquote)
936
+ if (pos < text.length && /[-*+>]/.test(text[pos]) && (pos === 0 || text[pos - 1] === '\n')) {
937
+ pos++;
938
+ while (pos < text.length && text[pos] === ' ') pos++;
939
+ }
940
+ if (text.startsWith(parts[i], pos)) {
941
+ pos += parts[i].length;
942
+ } else {
943
+ matched = false;
944
+ break;
945
+ }
946
+ }
947
+ if (matched) return true;
948
+ searchFrom = firstIdx + 1;
949
+ }
950
+ return false;
951
+ }
952
+
953
+ /**
954
+ * Extract visible text labels from mermaid code blocks.
955
+ * Mermaid nodes use shapes like A[text], A(text), A{text}, A([text]), A((text)).
956
+ * Edge labels use |text| or -->|text|. We concatenate all labels so anchor text
957
+ * from rendered SVG can be matched against them.
958
+ *
959
+ * Note: labels are joined with spaces, so `partsAppearContiguously` can match
960
+ * anchors that span adjacent node labels (e.g. "Add Admin" across two nodes).
961
+ * This is intentional — rendered SVG text flows continuously.
962
+ */
963
+ function extractMermaidText(cleanMarkdown: string): string {
964
+ if (!/^```mermaid\s*$/m.test(cleanMarkdown)) return '';
965
+ const mermaidRegex = /^```mermaid\s*\n([\s\S]*?)^```\s*$/gm;
966
+ const labels: string[] = [];
967
+ let match;
968
+ while ((match = mermaidRegex.exec(cleanMarkdown)) !== null) {
969
+ const source = match[1];
970
+ // Node labels: text inside [...], (...), {...}
971
+ // eslint-disable-next-line no-useless-escape
972
+ const nodeRegex = /[\[({]([^\])}]+)[\])}]/g;
973
+ let nodeMatch;
974
+ while ((nodeMatch = nodeRegex.exec(source)) !== null) {
975
+ labels.push(nodeMatch[1].trim());
976
+ }
977
+ // Edge labels: |text|
978
+ const edgeRegex = /\|([^|]+)\|/g;
979
+ let edgeMatch;
980
+ while ((edgeMatch = edgeRegex.exec(source)) !== null) {
981
+ labels.push(edgeMatch[1].trim());
982
+ }
983
+ }
984
+ return labels.join(' ');
985
+ }
986
+
987
+ /**
988
+ * Detect comments whose anchor text can no longer be found in the clean markdown.
989
+ * Returns a set of comment IDs with missing anchors.
990
+ * Parts must appear contiguously (with only whitespace between them) to count as found.
991
+ */
992
+ export function detectMissingAnchors(cleanMarkdown: string, comments: MdComment[]): Set<string> {
993
+ const missing = new Set<string>();
994
+ if (!cleanMarkdown) return missing;
995
+ // Compare against plain text (formatting stripped) since anchors come from
996
+ // DOM textContent which doesn't include markdown formatting markers like
997
+ // **, _, `, ~~, etc. Without this, anchors spanning formatted text would
998
+ // always be flagged as "changed".
999
+ const { plain } = stripInlineFormatting(cleanMarkdown);
1000
+ // Also extract rendered text from mermaid blocks — anchors from mermaid SVG
1001
+ // won't match the raw source syntax, but will match the extracted labels.
1002
+ const mermaidText = extractMermaidText(cleanMarkdown);
1003
+ for (const c of comments) {
1004
+ if (getEffectiveStatus(c) === 'resolved') continue;
1005
+ if (!plain.includes(c.anchor)) {
1006
+ const parts = c.anchor.split(/\s+/).filter(Boolean);
1007
+ if (parts.length === 0) continue;
1008
+ if (!partsAppearContiguously(plain, parts)) {
1009
+ // Check mermaid rendered text as fallback
1010
+ if (
1011
+ mermaidText &&
1012
+ (mermaidText.includes(c.anchor) || partsAppearContiguously(mermaidText, parts))
1013
+ ) {
1014
+ continue;
1015
+ }
1016
+ missing.add(c.id);
1017
+ }
1018
+ }
1019
+ }
1020
+ return missing;
1021
+ }