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,164 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { computeDiff } from './diff';
3
+
4
+ describe('computeDiff', () => {
5
+ it('returns all same lines for identical inputs', () => {
6
+ const text = 'line 1\nline 2\nline 3';
7
+ const diff = computeDiff(text, text);
8
+ expect(diff.every((l) => l.type === 'same')).toBe(true);
9
+ expect(diff).toHaveLength(3);
10
+ });
11
+
12
+ it('returns empty array for two empty strings', () => {
13
+ const diff = computeDiff('', '');
14
+ // Empty string splits to [''], so 1 "same" line
15
+ expect(diff).toHaveLength(1);
16
+ expect(diff[0].type).toBe('same');
17
+ expect(diff[0].text).toBe('');
18
+ });
19
+
20
+ it('detects added lines', () => {
21
+ const diff = computeDiff('a\nb', 'a\nb\nc');
22
+ const added = diff.filter((l) => l.type === 'added');
23
+ expect(added).toHaveLength(1);
24
+ expect(added[0].text).toBe('c');
25
+ });
26
+
27
+ it('detects removed lines', () => {
28
+ const diff = computeDiff('a\nb\nc', 'a\nc');
29
+ const removed = diff.filter((l) => l.type === 'removed');
30
+ expect(removed).toHaveLength(1);
31
+ expect(removed[0].text).toBe('b');
32
+ });
33
+
34
+ it('handles completely different content', () => {
35
+ const diff = computeDiff('old line 1\nold line 2', 'new line 1\nnew line 2');
36
+ const removed = diff.filter((l) => l.type === 'removed');
37
+ const added = diff.filter((l) => l.type === 'added');
38
+ expect(removed).toHaveLength(2);
39
+ expect(added).toHaveLength(2);
40
+ });
41
+
42
+ it('handles new content from empty', () => {
43
+ const diff = computeDiff('', 'new line');
44
+ // '' -> [''], 'new line' -> ['new line']
45
+ const removed = diff.filter((l) => l.type === 'removed');
46
+ const added = diff.filter((l) => l.type === 'added');
47
+ expect(removed).toHaveLength(1);
48
+ expect(removed[0].text).toBe('');
49
+ expect(added).toHaveLength(1);
50
+ expect(added[0].text).toBe('new line');
51
+ });
52
+
53
+ it('handles removal to empty', () => {
54
+ const diff = computeDiff('old line', '');
55
+ const removed = diff.filter((l) => l.type === 'removed');
56
+ const added = diff.filter((l) => l.type === 'added');
57
+ expect(removed).toHaveLength(1);
58
+ expect(removed[0].text).toBe('old line');
59
+ expect(added).toHaveLength(1);
60
+ expect(added[0].text).toBe('');
61
+ });
62
+
63
+ it('preserves line numbers for same lines', () => {
64
+ const diff = computeDiff('a\nb\nc', 'a\nb\nc');
65
+ expect(diff[0]).toMatchObject({ type: 'same', text: 'a', oldLineNo: 1, newLineNo: 1 });
66
+ expect(diff[1]).toMatchObject({ type: 'same', text: 'b', oldLineNo: 2, newLineNo: 2 });
67
+ expect(diff[2]).toMatchObject({ type: 'same', text: 'c', oldLineNo: 3, newLineNo: 3 });
68
+ });
69
+
70
+ it('assigns correct line numbers for mixed changes', () => {
71
+ const diff = computeDiff('a\nb\nc', 'a\nx\nc');
72
+ // a = same, b = removed, x = added, c = same
73
+ const removed = diff.find((l) => l.type === 'removed');
74
+ const added = diff.find((l) => l.type === 'added');
75
+ expect(removed?.oldLineNo).toBe(2);
76
+ expect(added?.newLineNo).toBe(2);
77
+ });
78
+
79
+ it('handles multi-line insertions in the middle', () => {
80
+ const diff = computeDiff('a\nc', 'a\nb1\nb2\nc');
81
+ const added = diff.filter((l) => l.type === 'added');
82
+ expect(added).toHaveLength(2);
83
+ expect(added[0].text).toBe('b1');
84
+ expect(added[1].text).toBe('b2');
85
+ });
86
+
87
+ it('handles multi-line removals in the middle', () => {
88
+ const diff = computeDiff('a\nb1\nb2\nc', 'a\nc');
89
+ const removed = diff.filter((l) => l.type === 'removed');
90
+ expect(removed).toHaveLength(2);
91
+ expect(removed[0].text).toBe('b1');
92
+ expect(removed[1].text).toBe('b2');
93
+ });
94
+
95
+ it('produces correct output order: removed then added for replacements', () => {
96
+ const diff = computeDiff('a\nold\nc', 'a\nnew\nc');
97
+ const changeIdx = diff.findIndex((l) => l.type !== 'same');
98
+ // The removed line should come before the added line
99
+ expect(diff[changeIdx].type).toBe('removed');
100
+ expect(diff[changeIdx].text).toBe('old');
101
+ expect(diff[changeIdx + 1].type).toBe('added');
102
+ expect(diff[changeIdx + 1].text).toBe('new');
103
+ });
104
+
105
+ it('handles large files without hanging (prefix/suffix optimization)', () => {
106
+ // Create two 2000-line files with a small change in the middle
107
+ const lines = Array.from({ length: 2000 }, (_, i) => `line ${i + 1}`);
108
+ const oldText = lines.join('\n');
109
+ const newLines = [...lines];
110
+ newLines[1000] = 'CHANGED LINE';
111
+ const newText = newLines.join('\n');
112
+
113
+ const start = Date.now();
114
+ const diff = computeDiff(oldText, newText);
115
+ const elapsed = Date.now() - start;
116
+
117
+ // Should complete in well under 5 seconds (prefix/suffix optimization)
118
+ expect(elapsed).toBeLessThan(5000);
119
+ // Should detect the change
120
+ const removed = diff.filter((l) => l.type === 'removed');
121
+ const added = diff.filter((l) => l.type === 'added');
122
+ expect(removed).toHaveLength(1);
123
+ expect(removed[0].text).toBe('line 1001');
124
+ expect(added).toHaveLength(1);
125
+ expect(added[0].text).toBe('CHANGED LINE');
126
+ });
127
+
128
+ it('handles large files with completely different content', () => {
129
+ const oldLines = Array.from({ length: 1500 }, (_, i) => `old ${i}`);
130
+ const newLines = Array.from({ length: 1500 }, (_, i) => `new ${i}`);
131
+
132
+ const start = Date.now();
133
+ const diff = computeDiff(oldLines.join('\n'), newLines.join('\n'));
134
+ const elapsed = Date.now() - start;
135
+
136
+ expect(elapsed).toBeLessThan(5000);
137
+ expect(diff.filter((l) => l.type === 'removed').length).toBe(1500);
138
+ expect(diff.filter((l) => l.type === 'added').length).toBe(1500);
139
+ });
140
+
141
+ it('handles large files with shared prefix/suffix but large different middle', () => {
142
+ // Common prefix + 1200 different lines + common suffix
143
+ // Middle is 1200x1200 = 1.44M cells, exceeds threshold -> inner fallback
144
+ const prefix = Array.from({ length: 50 }, (_, i) => `shared-prefix-${i}`);
145
+ const suffix = Array.from({ length: 50 }, (_, i) => `shared-suffix-${i}`);
146
+ const oldMiddle = Array.from({ length: 1200 }, (_, i) => `old-middle-${i}`);
147
+ const newMiddle = Array.from({ length: 1200 }, (_, i) => `new-middle-${i}`);
148
+
149
+ const oldText = [...prefix, ...oldMiddle, ...suffix].join('\n');
150
+ const newText = [...prefix, ...newMiddle, ...suffix].join('\n');
151
+
152
+ const start = Date.now();
153
+ const diff = computeDiff(oldText, newText);
154
+ const elapsed = Date.now() - start;
155
+
156
+ expect(elapsed).toBeLessThan(5000);
157
+ // Prefix and suffix should be 'same'
158
+ const same = diff.filter((l) => l.type === 'same');
159
+ expect(same.length).toBe(100); // 50 prefix + 50 suffix
160
+ // Middle should be all removed + all added
161
+ expect(diff.filter((l) => l.type === 'removed').length).toBe(1200);
162
+ expect(diff.filter((l) => l.type === 'added').length).toBe(1200);
163
+ });
164
+ });
@@ -0,0 +1,139 @@
1
+ export interface DiffLine {
2
+ type: 'same' | 'added' | 'removed';
3
+ text: string;
4
+ oldLineNo?: number;
5
+ newLineNo?: number;
6
+ }
7
+
8
+ /**
9
+ * Simple line-based diff using the longest common subsequence algorithm.
10
+ * Returns an array of DiffLine objects for rendering.
11
+ *
12
+ * For large files (where the DP table would exceed 1M cells), uses a
13
+ * hash-based pre-filtering approach to keep memory usage bounded.
14
+ */
15
+ export function computeDiff(oldText: string, newText: string): DiffLine[] {
16
+ const oldLines = oldText.split('\n');
17
+ const newLines = newText.split('\n');
18
+
19
+ const m = oldLines.length;
20
+ const n = newLines.length;
21
+
22
+ // For large inputs, use a chunked approach to avoid O(m*n) memory
23
+ if (m * n > 1_000_000) {
24
+ return computeDiffLargeFile(oldLines, newLines);
25
+ }
26
+
27
+ return lcsBasedDiff(oldLines, newLines);
28
+ }
29
+
30
+ /** Standard LCS-based diff for reasonably sized inputs. */
31
+ function lcsBasedDiff(oldLines: string[], newLines: string[]): DiffLine[] {
32
+ const m = oldLines.length;
33
+ const n = newLines.length;
34
+
35
+ const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
36
+
37
+ for (let i = 1; i <= m; i++) {
38
+ for (let j = 1; j <= n; j++) {
39
+ if (oldLines[i - 1] === newLines[j - 1]) {
40
+ dp[i][j] = dp[i - 1][j - 1] + 1;
41
+ } else {
42
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
43
+ }
44
+ }
45
+ }
46
+
47
+ // Backtrack to produce diff
48
+ const stack: DiffLine[] = [];
49
+ let i = m;
50
+ let j = n;
51
+
52
+ while (i > 0 || j > 0) {
53
+ if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
54
+ stack.push({ type: 'same', text: oldLines[i - 1], oldLineNo: i, newLineNo: j });
55
+ i--;
56
+ j--;
57
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
58
+ stack.push({ type: 'added', text: newLines[j - 1], newLineNo: j });
59
+ j--;
60
+ } else {
61
+ stack.push({ type: 'removed', text: oldLines[i - 1], oldLineNo: i });
62
+ i--;
63
+ }
64
+ }
65
+
66
+ // Reverse since we built it backwards
67
+ const result: DiffLine[] = [];
68
+ for (let k = stack.length - 1; k >= 0; k--) {
69
+ result.push(stack[k]);
70
+ }
71
+ return result;
72
+ }
73
+
74
+ /**
75
+ * For large files: strip common prefix/suffix, then apply LCS to the
76
+ * remaining (typically much smaller) changed region.
77
+ */
78
+ function computeDiffLargeFile(oldLines: string[], newLines: string[]): DiffLine[] {
79
+ const result: DiffLine[] = [];
80
+
81
+ // Find common prefix
82
+ let prefixLen = 0;
83
+ const minLen = Math.min(oldLines.length, newLines.length);
84
+ while (prefixLen < minLen && oldLines[prefixLen] === newLines[prefixLen]) {
85
+ prefixLen++;
86
+ }
87
+
88
+ // Find common suffix (don't overlap with prefix)
89
+ let suffixLen = 0;
90
+ while (
91
+ suffixLen < minLen - prefixLen &&
92
+ oldLines[oldLines.length - 1 - suffixLen] === newLines[newLines.length - 1 - suffixLen]
93
+ ) {
94
+ suffixLen++;
95
+ }
96
+
97
+ // Emit common prefix
98
+ for (let i = 0; i < prefixLen; i++) {
99
+ result.push({ type: 'same', text: oldLines[i], oldLineNo: i + 1, newLineNo: i + 1 });
100
+ }
101
+
102
+ // Diff the middle section
103
+ const oldMiddle = oldLines.slice(prefixLen, oldLines.length - suffixLen);
104
+ const newMiddle = newLines.slice(prefixLen, newLines.length - suffixLen);
105
+
106
+ if (oldMiddle.length * newMiddle.length <= 1_000_000) {
107
+ // Middle is small enough for standard LCS
108
+ const middleDiff = lcsBasedDiff(oldMiddle, newMiddle);
109
+ for (const line of middleDiff) {
110
+ result.push({
111
+ ...line,
112
+ oldLineNo: line.oldLineNo ? line.oldLineNo + prefixLen : undefined,
113
+ newLineNo: line.newLineNo ? line.newLineNo + prefixLen : undefined,
114
+ });
115
+ }
116
+ } else {
117
+ // Still too large — fall back to simple remove-then-add
118
+ for (let i = 0; i < oldMiddle.length; i++) {
119
+ result.push({ type: 'removed', text: oldMiddle[i], oldLineNo: prefixLen + i + 1 });
120
+ }
121
+ for (let i = 0; i < newMiddle.length; i++) {
122
+ result.push({ type: 'added', text: newMiddle[i], newLineNo: prefixLen + i + 1 });
123
+ }
124
+ }
125
+
126
+ // Emit common suffix
127
+ for (let i = 0; i < suffixLen; i++) {
128
+ const oldIdx = oldLines.length - suffixLen + i;
129
+ const newIdx = newLines.length - suffixLen + i;
130
+ result.push({
131
+ type: 'same',
132
+ text: oldLines[oldIdx],
133
+ oldLineNo: oldIdx + 1,
134
+ newLineNo: newIdx + 1,
135
+ });
136
+ }
137
+
138
+ return result;
139
+ }
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { slugify, uniqueSlugs } from './heading-slugs';
3
+
4
+ describe('slugify', () => {
5
+ it('converts text to lowercase hyphenated slug', () => {
6
+ expect(slugify('Hello World')).toBe('hello-world');
7
+ });
8
+
9
+ it('strips special characters', () => {
10
+ expect(slugify("What's New?")).toBe('whats-new');
11
+ });
12
+
13
+ it('collapses multiple spaces to single hyphen', () => {
14
+ expect(slugify('Hello World')).toBe('hello-world');
15
+ });
16
+
17
+ it('trims whitespace', () => {
18
+ expect(slugify(' Hello ')).toBe('hello');
19
+ });
20
+
21
+ it('preserves hyphens', () => {
22
+ expect(slugify('pre-existing')).toBe('pre-existing');
23
+ });
24
+
25
+ it('preserves underscores', () => {
26
+ expect(slugify('my_variable')).toBe('my_variable');
27
+ });
28
+
29
+ it('handles numbers', () => {
30
+ expect(slugify('Section 1.2')).toBe('section-12');
31
+ });
32
+
33
+ it('falls back to "heading" for empty text', () => {
34
+ expect(slugify('')).toBe('heading');
35
+ expect(slugify(' ')).toBe('heading');
36
+ });
37
+
38
+ it('falls back to "heading" when all chars are stripped', () => {
39
+ expect(slugify('!@#$%')).toBe('heading');
40
+ });
41
+
42
+ it('handles tabs and newlines as whitespace', () => {
43
+ expect(slugify('Hello\tWorld\nFoo')).toBe('hello-world-foo');
44
+ });
45
+ });
46
+
47
+ describe('uniqueSlugs', () => {
48
+ it('returns unique slugs for distinct headings', () => {
49
+ expect(uniqueSlugs(['Introduction', 'Background', 'Conclusion'])).toEqual([
50
+ 'introduction',
51
+ 'background',
52
+ 'conclusion',
53
+ ]);
54
+ });
55
+
56
+ it('appends -1, -2 for duplicate headings', () => {
57
+ expect(uniqueSlugs(['Overview', 'Overview', 'Overview'])).toEqual([
58
+ 'overview',
59
+ 'overview-1',
60
+ 'overview-2',
61
+ ]);
62
+ });
63
+
64
+ it('handles mixed duplicates', () => {
65
+ expect(uniqueSlugs(['Title', 'Sub', 'Detail', 'Sub'])).toEqual([
66
+ 'title',
67
+ 'sub',
68
+ 'detail',
69
+ 'sub-1',
70
+ ]);
71
+ });
72
+
73
+ it('handles empty heading text', () => {
74
+ expect(uniqueSlugs(['', ''])).toEqual(['heading', 'heading-1']);
75
+ });
76
+
77
+ it('handles headings that become identical after slugification', () => {
78
+ // "Hello World!" and "Hello World?" both slugify to "hello-world"
79
+ expect(uniqueSlugs(['Hello World!', 'Hello World?'])).toEqual(['hello-world', 'hello-world-1']);
80
+ });
81
+
82
+ it('returns empty array for no headings', () => {
83
+ expect(uniqueSlugs([])).toEqual([]);
84
+ });
85
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Generate a URL-friendly slug from heading text.
3
+ * Strips non-word characters (except hyphens), collapses whitespace to hyphens,
4
+ * and lowercases everything.
5
+ */
6
+ export function slugify(text: string): string {
7
+ const slug = text
8
+ .trim()
9
+ .toLowerCase()
10
+ .replace(/[^\w\s-]/g, '')
11
+ .replace(/\s+/g, '-');
12
+ return slug || 'heading';
13
+ }
14
+
15
+ /**
16
+ * Generate unique slugs for an array of heading texts.
17
+ * Duplicate slugs get `-1`, `-2`, etc. suffixes.
18
+ */
19
+ export function uniqueSlugs(texts: string[]): string[] {
20
+ const used = new Set<string>();
21
+ return texts.map((text) => {
22
+ const slug = slugify(text);
23
+ let unique = slug;
24
+ let counter = 1;
25
+ while (used.has(unique)) {
26
+ unique = `${slug}-${counter++}`;
27
+ }
28
+ used.add(unique);
29
+ return unique;
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Assign unique slug IDs to heading elements in a container.
35
+ * Duplicate slugs get `-1`, `-2`, etc. suffixes.
36
+ */
37
+ export function assignHeadingIds(container: HTMLElement): void {
38
+ const headingEls = container.querySelectorAll('h1, h2, h3, h4, h5, h6');
39
+ const texts = Array.from(headingEls).map((el) => el.textContent || '');
40
+ const slugs = uniqueSlugs(texts);
41
+ headingEls.forEach((el, i) => {
42
+ el.id = slugs[i];
43
+ });
44
+ }
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getApiErrorMessage, readJsonResponse } from './http';
3
+
4
+ describe('readJsonResponse', () => {
5
+ it('parses valid JSON', async () => {
6
+ const res = new Response(JSON.stringify({ ok: true }), {
7
+ headers: { 'Content-Type': 'application/json' },
8
+ });
9
+
10
+ await expect(readJsonResponse<{ ok: boolean }>(res)).resolves.toEqual({ ok: true });
11
+ });
12
+
13
+ it('returns null for empty bodies', async () => {
14
+ const res = new Response('', { status: 502 });
15
+
16
+ await expect(readJsonResponse(res)).resolves.toBeNull();
17
+ });
18
+
19
+ it('returns null for invalid JSON', async () => {
20
+ const res = new Response('not-json');
21
+
22
+ await expect(readJsonResponse(res)).resolves.toBeNull();
23
+ });
24
+ });
25
+
26
+ describe('getApiErrorMessage', () => {
27
+ it('prefers an api error payload', () => {
28
+ const res = new Response('', { status: 400 });
29
+ expect(getApiErrorMessage(res, { error: 'Bad request' }, 'Fallback')).toBe('Bad request');
30
+ });
31
+
32
+ it('maps gateway failures to a backend-unavailable message', () => {
33
+ const res = new Response('', { status: 502 });
34
+ expect(getApiErrorMessage(res, null, 'Fallback')).toBe(
35
+ 'Backend unavailable. Start the md-redline server.',
36
+ );
37
+ });
38
+
39
+ it('falls back when no api error payload exists', () => {
40
+ const res = new Response('', { status: 500 });
41
+ expect(getApiErrorMessage(res, null, 'Fallback')).toBe('Fallback');
42
+ });
43
+ });
@@ -0,0 +1,29 @@
1
+ export interface ApiErrorPayload {
2
+ error?: string;
3
+ }
4
+
5
+ export async function readJsonResponse<T>(res: Response): Promise<T | null> {
6
+ const text = await res.text();
7
+ if (!text) return null;
8
+
9
+ try {
10
+ return JSON.parse(text) as T;
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ export function getApiErrorMessage(res: Response, data: unknown, fallback: string): string {
17
+ if (data && typeof data === 'object' && 'error' in data) {
18
+ const error = (data as ApiErrorPayload).error;
19
+ if (typeof error === 'string' && error.trim()) {
20
+ return error;
21
+ }
22
+ }
23
+
24
+ if (res.status === 502 || res.status === 503 || res.status === 504) {
25
+ return 'Backend unavailable. Start the md-redline server.';
26
+ }
27
+
28
+ return fallback;
29
+ }