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.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/bin/md-redline +255 -0
- package/bin/test-windows.ps1 +70 -0
- package/dist/assets/_baseFor-Ck08IaSF.js +1 -0
- package/dist/assets/arc-DI2g9LXK.js +1 -0
- package/dist/assets/architecture-YZFGNWBL-BDgMfc-b.js +1 -0
- package/dist/assets/architectureDiagram-Q4EWVU46-Dg1hcUEa.js +36 -0
- package/dist/assets/array-DOVTz2Mq.js +1 -0
- package/dist/assets/blockDiagram-DXYQGD6D-BAXkTCAk.js +132 -0
- package/dist/assets/c4Diagram-AHTNJAMY-BIkgwQSx.js +10 -0
- package/dist/assets/channel-DPCihw7y.js +1 -0
- package/dist/assets/chunk-2KRD3SAO-Dc_tBGsw.js +1 -0
- package/dist/assets/chunk-336JU56O-Dhi-ID9Y.js +2 -0
- package/dist/assets/chunk-426QAEUC-DnFdrNMW.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-Z63FkGov.js +1 -0
- package/dist/assets/chunk-4TB4RGXK-BAiBlfyy.js +206 -0
- package/dist/assets/chunk-55IACEB6-BXDWXbxy.js +1 -0
- package/dist/assets/chunk-5FUZZQ4R-C72e1c_O.js +62 -0
- package/dist/assets/chunk-5PVQY5BW-BBHW_uCu.js +2 -0
- package/dist/assets/chunk-67CJDMHE-3Cf_D9m6.js +1 -0
- package/dist/assets/chunk-7N4EOEYR-DAXUXJ2c.js +1 -0
- package/dist/assets/chunk-AA7GKIK3-Dr7fOryc.js +1 -0
- package/dist/assets/chunk-BSJP7CBP-BmsSs1Nt.js +1 -0
- package/dist/assets/chunk-CIAEETIT-QDzV-X_Y.js +1 -0
- package/dist/assets/chunk-EDXVE4YY-C25WFHxY.js +1 -0
- package/dist/assets/chunk-ENJZ2VHE-_OzxcZOU.js +10 -0
- package/dist/assets/chunk-FMBD7UC4-CjsTKY4u.js +15 -0
- package/dist/assets/chunk-FOC6F5B3-g-xaH5nc.js +1 -0
- package/dist/assets/chunk-ICPOFSXX-iKiUSjDK.js +121 -0
- package/dist/assets/chunk-K5T4RW27-CKR-lPBN.js +94 -0
- package/dist/assets/chunk-KGLVRYIC-DRccT-B_.js +1 -0
- package/dist/assets/chunk-LIHQZDEY-DTbMwMXj.js +1 -0
- package/dist/assets/chunk-ORNJ4GCN-DlerdcWX.js +1 -0
- package/dist/assets/chunk-OYMX7WX6-Dekv1on2.js +231 -0
- package/dist/assets/chunk-QZHKN3VN-BHu0RdKl.js +1 -0
- package/dist/assets/chunk-U2HBQHQK-BvtlVHAg.js +70 -0
- package/dist/assets/chunk-X2U36JSP-BI_g8mub.js +1 -0
- package/dist/assets/chunk-XPW4576I-B39JkmSE.js +32 -0
- package/dist/assets/chunk-YZCP3GAM-BfPcXRm2.js +1 -0
- package/dist/assets/chunk-ZZ45TVLE-Bg4q68wZ.js +1 -0
- package/dist/assets/classDiagram-6PBFFD2Q-p73p727_.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-C4Ftpivp.js +1 -0
- package/dist/assets/clone-CI9aUwHe.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-7BpAeDh5.js +1 -0
- package/dist/assets/cytoscape.esm-DoTFyJaN.js +321 -0
- package/dist/assets/dagre-CilMRazv.js +1 -0
- package/dist/assets/dagre-KV5264BT-DDMqpjkB.js +4 -0
- package/dist/assets/defaultLocale-Ck2Xxk-C.js +1 -0
- package/dist/assets/diagram-5BDNPKRD-BFeyfnCx.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-DoqT-PtF.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-BPV6KADk.js +43 -0
- package/dist/assets/diagram-TYMM5635-okvcTBtl.js +24 -0
- package/dist/assets/dist-C_eddq6m.js +1 -0
- package/dist/assets/erDiagram-SMLLAGMA-Dl-Ixy8n.js +85 -0
- package/dist/assets/flatten-B8XIuT0x.js +1 -0
- package/dist/assets/flowDiagram-DWJPFMVM-CsqWAx5r.js +162 -0
- package/dist/assets/ganttDiagram-T4ZO3ILL-mIt6zVeF.js +292 -0
- package/dist/assets/gitGraph-7Q5UKJZL-COXHGMvj.js +1 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-syVqZJX_.js +106 -0
- package/dist/assets/graphlib-Bpd0q3yO.js +1 -0
- package/dist/assets/index-BoggyWS0.css +2 -0
- package/dist/assets/index-aLvjHQW4.js +104 -0
- package/dist/assets/info-OMHHGYJF-B-0wfxwL.js +1 -0
- package/dist/assets/infoDiagram-42DDH7IO-C0_uqsVa.js +2 -0
- package/dist/assets/init-Bft5Ffpj.js +1 -0
- package/dist/assets/isEmpty-BrFi5AqV.js +1 -0
- package/dist/assets/ishikawaDiagram-UXIWVN3A-CTjFbDBV.js +70 -0
- package/dist/assets/journeyDiagram-VCZTEJTY-BDBcej1q.js +139 -0
- package/dist/assets/kanban-definition-6JOO6SKY-Ylgzakw7.js +89 -0
- package/dist/assets/katex-Uj9wLT16.js +265 -0
- package/dist/assets/line-CRxEwpOv.js +1 -0
- package/dist/assets/linear-PDPfFByd.js +1 -0
- package/dist/assets/mermaid-parser.core-CY-XNOOy.js +4 -0
- package/dist/assets/mermaid.core-BPlTADIX.js +11 -0
- package/dist/assets/mindmap-definition-QFDTVHPH-TefzJnBM.js +96 -0
- package/dist/assets/ordinal-DIg8h6NI.js +1 -0
- package/dist/assets/packet-4T2RLAQJ-BW1T_A-C.js +1 -0
- package/dist/assets/path-DfRbCp9y.js +1 -0
- package/dist/assets/pie-ZZUOXDRM-DkKU-SFu.js +1 -0
- package/dist/assets/pieDiagram-DEJITSTG-BCXuaeEy.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-VSBAicWL.js +7 -0
- package/dist/assets/radar-PYXPWWZC-CYvTacKJ.js +1 -0
- package/dist/assets/reduce-CV2X8n1a.js +1 -0
- package/dist/assets/requirementDiagram-MS252O5E-4NeL9Z6J.js +84 -0
- package/dist/assets/rough.esm-Bbn_-PMU.js +1 -0
- package/dist/assets/sankeyDiagram-XADWPNL6-DMBSDnrH.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-DVpzcZUi.js +157 -0
- package/dist/assets/src-PKe5NtkK.js +1 -0
- package/dist/assets/stateDiagram-FHFEXIEX-BkHTlCjL.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-nMeWu9fP.js +1 -0
- package/dist/assets/timeline-definition-GMOUNBTQ-CyLt92nf.js +120 -0
- package/dist/assets/treeView-SZITEDCU-BUgcJ4eR.js +1 -0
- package/dist/assets/treemap-W4RFUUIX-BIWGQ4Pw.js +1 -0
- package/dist/assets/vennDiagram-DHZGUBPP-BCK0xB_m.js +34 -0
- package/dist/assets/wardley-RL74JXVD-DMZZRlby.js +1 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-BisBgfsF.js +20 -0
- package/dist/assets/xychartDiagram-5P7HB3ND-D_REDciv.js +7 -0
- package/dist/favicon.svg +15 -0
- package/dist/index.html +14 -0
- package/dist/screenshot.png +0 -0
- package/index.html +13 -0
- package/package.json +105 -0
- package/public/favicon.svg +15 -0
- package/public/screenshot.png +0 -0
- package/server/index.test.ts +814 -0
- package/server/index.ts +736 -0
- package/server/preferences.test.ts +126 -0
- package/server/preferences.ts +76 -0
- package/src/App.tsx +1620 -0
- package/src/components/ActionButton.tsx +41 -0
- package/src/components/CommandPalette.tsx +191 -0
- package/src/components/CommentCard.tsx +556 -0
- package/src/components/CommentForm.tsx +285 -0
- package/src/components/CommentSidebar.tsx +428 -0
- package/src/components/ConfirmDialog.tsx +64 -0
- package/src/components/ContextMenu.tsx +220 -0
- package/src/components/DragHandles.tsx +48 -0
- package/src/components/FileExplorer.tsx +251 -0
- package/src/components/FileOpener.tsx +304 -0
- package/src/components/IconButton.tsx +32 -0
- package/src/components/KeyboardShortcutsPanel.tsx +136 -0
- package/src/components/MarkdownViewer.tsx +682 -0
- package/src/components/RawView.tsx +798 -0
- package/src/components/SearchBar.tsx +129 -0
- package/src/components/Separator.tsx +7 -0
- package/src/components/SettingsPanel.tsx +813 -0
- package/src/components/SplitIconButton.tsx +133 -0
- package/src/components/TabBar.tsx +594 -0
- package/src/components/TableOfContents.tsx +70 -0
- package/src/components/ThemeSelector.tsx +159 -0
- package/src/components/Toast.tsx +99 -0
- package/src/components/Toolbar.tsx +161 -0
- package/src/components/iconButtonVariants.ts +19 -0
- package/src/components/rawView.test.ts +291 -0
- package/src/contexts/SettingsContext.tsx +120 -0
- package/src/hooks/useAuthor.test.ts +58 -0
- package/src/hooks/useAuthor.ts +69 -0
- package/src/hooks/useAutoResize.ts +20 -0
- package/src/hooks/useCommentCardTriggers.ts +20 -0
- package/src/hooks/useComments.test.ts +773 -0
- package/src/hooks/useComments.ts +332 -0
- package/src/hooks/useContextMenu.ts +48 -0
- package/src/hooks/useContextMenuItems.ts +392 -0
- package/src/hooks/useDiffSnapshot.test.ts +130 -0
- package/src/hooks/useDiffSnapshot.ts +67 -0
- package/src/hooks/useDragHandles.ts +417 -0
- package/src/hooks/useFileWatcher.ts +45 -0
- package/src/hooks/useHeadingTracking.ts +84 -0
- package/src/hooks/useMermaidRenderer.ts +75 -0
- package/src/hooks/useModalState.ts +22 -0
- package/src/hooks/usePageVisible.test.ts +69 -0
- package/src/hooks/usePageVisible.ts +19 -0
- package/src/hooks/usePaneLayout.test.ts +108 -0
- package/src/hooks/usePaneLayout.ts +102 -0
- package/src/hooks/useRecentFiles.test.ts +103 -0
- package/src/hooks/useRecentFiles.ts +99 -0
- package/src/hooks/useResizablePanel.test.ts +84 -0
- package/src/hooks/useResizablePanel.ts +118 -0
- package/src/hooks/useSearch.test.ts +72 -0
- package/src/hooks/useSearch.ts +53 -0
- package/src/hooks/useSelection.ts +48 -0
- package/src/hooks/useSessionPersistence.test.ts +59 -0
- package/src/hooks/useSessionPersistence.ts +43 -0
- package/src/hooks/useTabs.test.ts +127 -0
- package/src/hooks/useTabs.ts +561 -0
- package/src/hooks/useThemePersistence.ts +41 -0
- package/src/hooks/useToast.ts +27 -0
- package/src/index.css +1047 -0
- package/src/lib/agent-prompts.test.ts +34 -0
- package/src/lib/agent-prompts.ts +68 -0
- package/src/lib/comment-editor-state.ts +6 -0
- package/src/lib/comment-parser.test.ts +1959 -0
- package/src/lib/comment-parser.ts +1021 -0
- package/src/lib/diff.test.ts +164 -0
- package/src/lib/diff.ts +139 -0
- package/src/lib/heading-slugs.test.ts +85 -0
- package/src/lib/heading-slugs.ts +44 -0
- package/src/lib/http.test.ts +43 -0
- package/src/lib/http.ts +29 -0
- package/src/lib/mermaid-highlights.test.ts +517 -0
- package/src/lib/mermaid-highlights.ts +936 -0
- package/src/lib/mermaid-renderer.test.ts +114 -0
- package/src/lib/mermaid-renderer.ts +89 -0
- package/src/lib/path-utils.test.ts +17 -0
- package/src/lib/path-utils.ts +7 -0
- package/src/lib/platform.test.ts +58 -0
- package/src/lib/platform.ts +14 -0
- package/src/lib/preferences-client.test.ts +177 -0
- package/src/lib/preferences-client.ts +94 -0
- package/src/lib/selection-resolver.test.ts +118 -0
- package/src/lib/selection-resolver.ts +37 -0
- package/src/lib/settings.test.ts +152 -0
- package/src/lib/settings.ts +78 -0
- package/src/lib/shortcut-label.tsx +18 -0
- package/src/lib/themes.ts +21 -0
- package/src/lib/visible-text.test.ts +86 -0
- package/src/lib/visible-text.ts +77 -0
- package/src/main.tsx +22 -0
- package/src/markdown/pipeline.test.ts +82 -0
- package/src/markdown/pipeline.ts +33 -0
- package/src/types.test.ts +43 -0
- package/src/types.ts +46 -0
- package/tsconfig.app.json +28 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- 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:  → 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
|
+
}
|