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,291 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ buildHighlightedHtml,
4
+ escapeHtml,
5
+ extractRawHeadings,
6
+ splitHighlightedHtml,
7
+ } from './RawView';
8
+
9
+ describe('escapeHtml', () => {
10
+ it('escapes &, <, >, "', () => {
11
+ expect(escapeHtml('a & b < c > d "e"')).toBe('a &amp; b &lt; c &gt; d &quot;e&quot;');
12
+ });
13
+
14
+ it('returns empty string unchanged', () => {
15
+ expect(escapeHtml('')).toBe('');
16
+ });
17
+
18
+ it('does not double-escape', () => {
19
+ expect(escapeHtml('&amp;')).toBe('&amp;amp;');
20
+ });
21
+ });
22
+
23
+ describe('buildHighlightedHtml', () => {
24
+ describe('plain text', () => {
25
+ it('returns escaped plain text when no syntax matches', () => {
26
+ const html = buildHighlightedHtml('Just some plain text.');
27
+ expect(html).toBe('Just some plain text.');
28
+ });
29
+
30
+ it('preserves empty lines', () => {
31
+ const html = buildHighlightedHtml('line1\n\nline3');
32
+ expect(html).toBe('line1\n\nline3');
33
+ });
34
+ });
35
+
36
+ describe('headings', () => {
37
+ it('highlights ATX headings', () => {
38
+ const html = buildHighlightedHtml('# Hello World');
39
+ expect(html).toContain('class="raw-heading"');
40
+ expect(html).toContain('# Hello World');
41
+ });
42
+
43
+ it('highlights h2-h6', () => {
44
+ for (const prefix of ['##', '###', '####', '#####', '######']) {
45
+ const html = buildHighlightedHtml(`${prefix} Heading`);
46
+ expect(html).toContain('class="raw-heading"');
47
+ }
48
+ });
49
+
50
+ it('does not highlight # without space', () => {
51
+ const html = buildHighlightedHtml('#nospace');
52
+ expect(html).not.toContain('raw-heading');
53
+ });
54
+ });
55
+
56
+ describe('bold and italic', () => {
57
+ it('highlights bold text', () => {
58
+ const html = buildHighlightedHtml('**bold text**');
59
+ expect(html).toContain('class="raw-bold"');
60
+ expect(html).toContain('**bold text**');
61
+ });
62
+
63
+ it('highlights italic text', () => {
64
+ const html = buildHighlightedHtml('*italic text*');
65
+ expect(html).toContain('class="raw-italic"');
66
+ });
67
+
68
+ it('does not highlight bold as italic', () => {
69
+ const html = buildHighlightedHtml('**bold** *italic*');
70
+ // Bold should be matched, not italic within bold
71
+ expect(html).toContain('raw-bold');
72
+ expect(html).toContain('raw-italic');
73
+ });
74
+ });
75
+
76
+ describe('inline code', () => {
77
+ it('highlights inline code', () => {
78
+ const html = buildHighlightedHtml('Use `console.log()` here');
79
+ expect(html).toContain('class="raw-inline-code"');
80
+ expect(html).toContain('`console.log()`');
81
+ });
82
+ });
83
+
84
+ describe('links', () => {
85
+ it('highlights markdown links', () => {
86
+ const html = buildHighlightedHtml('[click here](https://example.com)');
87
+ expect(html).toContain('class="raw-link"');
88
+ });
89
+ });
90
+
91
+ describe('blockquotes', () => {
92
+ it('highlights blockquote lines', () => {
93
+ const html = buildHighlightedHtml('> This is a quote');
94
+ expect(html).toContain('class="raw-blockquote"');
95
+ });
96
+ });
97
+
98
+ describe('horizontal rules', () => {
99
+ it('highlights --- as HR', () => {
100
+ const html = buildHighlightedHtml('---');
101
+ expect(html).toContain('class="raw-hr"');
102
+ });
103
+
104
+ it('highlights ---- (4+ dashes) as HR', () => {
105
+ const html = buildHighlightedHtml('----');
106
+ expect(html).toContain('class="raw-hr"');
107
+ });
108
+
109
+ it('highlights *** as HR', () => {
110
+ const html = buildHighlightedHtml('***');
111
+ expect(html).toContain('class="raw-hr"');
112
+ });
113
+ });
114
+
115
+ describe('tables', () => {
116
+ it('highlights table rows', () => {
117
+ const html = buildHighlightedHtml('| A | B |\n|---|---|\n| 1 | 2 |');
118
+ expect(html).toContain('class="raw-table"');
119
+ });
120
+ });
121
+
122
+ describe('frontmatter', () => {
123
+ it('highlights YAML frontmatter at start of file', () => {
124
+ const html = buildHighlightedHtml('---\ntitle: Hello\n---\n# Content');
125
+ expect(html).toContain('class="raw-frontmatter"');
126
+ expect(html).toContain('class="raw-heading"');
127
+ });
128
+
129
+ it('does not highlight --- in middle of file as frontmatter', () => {
130
+ const html = buildHighlightedHtml('# Title\n\n---\n\nMore text');
131
+ // The --- should be an HR, not frontmatter
132
+ expect(html).not.toContain('raw-frontmatter');
133
+ });
134
+ });
135
+
136
+ describe('comment markers', () => {
137
+ const marker =
138
+ '<!-- @comment{"id":"abc","anchor":"hello","text":"fix this","author":"User","timestamp":"2026-01-01T00:00:00Z","replies":[]} -->';
139
+
140
+ it('highlights comment markers', () => {
141
+ const html = buildHighlightedHtml(`Some text ${marker}hello world`);
142
+ expect(html).toContain('class="raw-comment-marker"');
143
+ });
144
+
145
+ it('adds data-comment-id attribute', () => {
146
+ const html = buildHighlightedHtml(`${marker}hello`);
147
+ expect(html).toContain('data-comment-id="abc"');
148
+ });
149
+
150
+ it('handles multiline comment markers', () => {
151
+ // Use \\n in JSON (escaped newline) so JSON.parse succeeds — this is how the app serializes them
152
+ const multilineMarker =
153
+ '<!-- @comment{"id":"m1","anchor":"test","text":"long\\ncomment","author":"User","timestamp":"2026-01-01T00:00:00Z","replies":[]} -->';
154
+ const html = buildHighlightedHtml(`before ${multilineMarker}after`);
155
+ expect(html).toContain('data-comment-id="m1"');
156
+ expect(html).toContain('raw-comment-marker');
157
+ });
158
+
159
+ it('gives comment markers priority over bold', () => {
160
+ // When bold wraps around a comment marker: **<!-- @comment{...} -->text**
161
+ const raw = `**${marker}hello world**`;
162
+ const html = buildHighlightedHtml(raw);
163
+ // Comment marker should be highlighted, not swallowed by bold
164
+ expect(html).toContain('raw-comment-marker');
165
+ expect(html).toContain('data-comment-id="abc"');
166
+ });
167
+
168
+ it('gives comment markers priority over headings', () => {
169
+ const raw = `## ${marker}Section Title`;
170
+ const html = buildHighlightedHtml(raw);
171
+ expect(html).toContain('raw-comment-marker');
172
+ expect(html).toContain('data-comment-id="abc"');
173
+ // Heading should not overlap with the comment marker
174
+ expect(html).not.toContain('raw-heading');
175
+ });
176
+
177
+ it('handles multiple comment markers', () => {
178
+ const m1 =
179
+ '<!-- @comment{"id":"c1","anchor":"a","text":"x","author":"U","timestamp":"2026-01-01T00:00:00Z","replies":[]} -->';
180
+ const m2 =
181
+ '<!-- @comment{"id":"c2","anchor":"b","text":"y","author":"U","timestamp":"2026-01-01T00:00:00Z","replies":[]} -->';
182
+ const html = buildHighlightedHtml(`${m1}alpha ${m2}beta`);
183
+ expect(html).toContain('data-comment-id="c1"');
184
+ expect(html).toContain('data-comment-id="c2"');
185
+ });
186
+ });
187
+
188
+ describe('overlap resolution', () => {
189
+ it('first syntax match wins when two non-comment rules overlap', () => {
190
+ // Inline code appears before bold in rule order
191
+ // But if bold wraps inline code, the one that starts first wins
192
+ const html = buildHighlightedHtml('**bold `code` more**');
193
+ // Bold starts first, should capture everything
194
+ expect(html).toContain('raw-bold');
195
+ });
196
+
197
+ it('does not apply syntax highlighting inside comment markers', () => {
198
+ const marker =
199
+ '<!-- @comment{"id":"x","anchor":"# heading","text":"fix","author":"U","timestamp":"2026-01-01T00:00:00Z","replies":[]} -->';
200
+ const html = buildHighlightedHtml(marker);
201
+ // The "# heading" inside the JSON should not be highlighted as a heading
202
+ expect(html).not.toContain('raw-heading');
203
+ expect(html).toContain('raw-comment-marker');
204
+ });
205
+ });
206
+
207
+ describe('HTML escaping in output', () => {
208
+ it('escapes HTML entities in plain text', () => {
209
+ const html = buildHighlightedHtml('a < b & c > d');
210
+ expect(html).toContain('&lt;');
211
+ expect(html).toContain('&amp;');
212
+ expect(html).toContain('&gt;');
213
+ });
214
+
215
+ it('escapes HTML inside highlighted spans', () => {
216
+ const html = buildHighlightedHtml('## Title <script>');
217
+ expect(html).toContain('&lt;script&gt;');
218
+ });
219
+ });
220
+ });
221
+
222
+ describe('extractRawHeadings', () => {
223
+ it('extracts headings with stable slug ids and line indexes', () => {
224
+ const headings = extractRawHeadings('# Title\n\n## Section One\n\n## Section One\n');
225
+ expect(headings).toEqual([
226
+ { id: 'title', text: 'Title', level: 1, lineIndex: 0 },
227
+ { id: 'section-one', text: 'Section One', level: 2, lineIndex: 2 },
228
+ { id: 'section-one-1', text: 'Section One', level: 2, lineIndex: 4 },
229
+ ]);
230
+ });
231
+
232
+ it('ignores inline comment markers when matching heading lines', () => {
233
+ const headings = extractRawHeadings(
234
+ '# Intro\n\n## <!-- @comment{"id":"c1","anchor":"Heading","text":"Fix","author":"U","timestamp":"2026-01-01T00:00:00Z","replies":[]} -->Heading\n',
235
+ );
236
+ expect(headings).toEqual([
237
+ { id: 'intro', text: 'Intro', level: 1, lineIndex: 0 },
238
+ { id: 'heading', text: 'Heading', level: 2, lineIndex: 2 },
239
+ ]);
240
+ });
241
+ });
242
+
243
+ describe('splitHighlightedHtml', () => {
244
+ it('splits plain text into one segment per source line', () => {
245
+ const raw = 'line1\nline2\nline3';
246
+ const html = buildHighlightedHtml(raw);
247
+ const lines = splitHighlightedHtml(raw, html);
248
+ expect(lines).toHaveLength(3);
249
+ expect(lines[0]).toBe('line1');
250
+ expect(lines[1]).toBe('line2');
251
+ expect(lines[2]).toBe('line3');
252
+ });
253
+
254
+ it('handles a single line with no newlines', () => {
255
+ const raw = 'hello world';
256
+ const html = buildHighlightedHtml(raw);
257
+ const lines = splitHighlightedHtml(raw, html);
258
+ expect(lines).toHaveLength(1);
259
+ expect(lines[0]).toBe('hello world');
260
+ });
261
+
262
+ it('preserves empty lines', () => {
263
+ const raw = 'a\n\nb';
264
+ const html = buildHighlightedHtml(raw);
265
+ const lines = splitHighlightedHtml(raw, html);
266
+ expect(lines).toHaveLength(3);
267
+ expect(lines[0]).toBe('a');
268
+ expect(lines[1]).toBe('');
269
+ expect(lines[2]).toBe('b');
270
+ });
271
+
272
+ it('closes and reopens spans that cross line boundaries', () => {
273
+ const raw = '```mermaid\ngraph TD\n```';
274
+ const html = buildHighlightedHtml(raw);
275
+ const lines = splitHighlightedHtml(raw, html);
276
+ expect(lines).toHaveLength(3);
277
+ // Each line should have balanced tags
278
+ for (const line of lines) {
279
+ const opens = (line.match(/<span/g) ?? []).length;
280
+ const closes = (line.match(/<\/span>/g) ?? []).length;
281
+ expect(opens).toBe(closes);
282
+ }
283
+ });
284
+
285
+ it('matches source line count even with syntax-highlighted content', () => {
286
+ const raw = '# Title\n\n**bold** and *italic*\n\n> quote';
287
+ const html = buildHighlightedHtml(raw);
288
+ const lines = splitHighlightedHtml(raw, html);
289
+ expect(lines).toHaveLength(raw.split('\n').length);
290
+ });
291
+ });
@@ -0,0 +1,120 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useState,
5
+ useCallback,
6
+ useEffect,
7
+ useRef,
8
+ type ReactNode,
9
+ } from 'react';
10
+ import {
11
+ type AppSettings,
12
+ type CommentTemplate,
13
+ DEFAULT_SETTINGS,
14
+ DEFAULT_TEMPLATES,
15
+ loadSettings,
16
+ saveSettings,
17
+ } from '../lib/settings';
18
+ import { fetchPreferences } from '../lib/preferences-client';
19
+
20
+ interface SettingsContextValue {
21
+ settings: AppSettings;
22
+ updateTemplates: (templates: CommentTemplate[]) => void;
23
+ updateCommentMaxLength: (maxLength: number) => void;
24
+ updateShowTemplatesByDefault: (show: boolean) => void;
25
+ updateEnableResolve: (enable: boolean) => void;
26
+ updateQuickComment: (quick: boolean) => void;
27
+ resetTemplates: () => void;
28
+ resetAll: () => void;
29
+ }
30
+
31
+ const SettingsContext = createContext<SettingsContextValue | null>(null);
32
+
33
+ export function SettingsProvider({ children }: { children: ReactNode }) {
34
+ const [settings, setSettings] = useState<AppSettings>(loadSettings);
35
+ const hasLocalMutationRef = useRef(false);
36
+
37
+ // Hydrate from disk on mount — disk overrides localStorage
38
+ useEffect(() => {
39
+ let cancelled = false;
40
+ fetchPreferences().then((prefs) => {
41
+ if (cancelled || hasLocalMutationRef.current) return;
42
+ if (prefs.settings && typeof prefs.settings === 'object') {
43
+ const diskSettings = prefs.settings as Partial<AppSettings>;
44
+ setSettings((prev) => {
45
+ const merged = { ...prev, ...diskSettings };
46
+ saveSettings(merged);
47
+ return merged;
48
+ });
49
+ }
50
+ });
51
+ return () => {
52
+ cancelled = true;
53
+ };
54
+ }, []);
55
+
56
+ const update = useCallback((patch: Partial<AppSettings>) => {
57
+ hasLocalMutationRef.current = true;
58
+ setSettings((prev) => {
59
+ const next = { ...prev, ...patch };
60
+ saveSettings(next);
61
+ return next;
62
+ });
63
+ }, []);
64
+
65
+ const updateTemplates = useCallback(
66
+ (templates: CommentTemplate[]) => update({ templates }),
67
+ [update],
68
+ );
69
+
70
+ const updateCommentMaxLength = useCallback(
71
+ (commentMaxLength: number) => update({ commentMaxLength }),
72
+ [update],
73
+ );
74
+
75
+ const updateShowTemplatesByDefault = useCallback(
76
+ (showTemplatesByDefault: boolean) => update({ showTemplatesByDefault }),
77
+ [update],
78
+ );
79
+
80
+ const updateEnableResolve = useCallback(
81
+ (enableResolve: boolean) => update({ enableResolve }),
82
+ [update],
83
+ );
84
+
85
+ const updateQuickComment = useCallback(
86
+ (quickComment: boolean) => update({ quickComment }),
87
+ [update],
88
+ );
89
+
90
+ const resetTemplates = useCallback(() => update({ templates: DEFAULT_TEMPLATES }), [update]);
91
+
92
+ const resetAll = useCallback(() => {
93
+ hasLocalMutationRef.current = true;
94
+ setSettings(DEFAULT_SETTINGS);
95
+ saveSettings(DEFAULT_SETTINGS);
96
+ }, []);
97
+
98
+ return (
99
+ <SettingsContext.Provider
100
+ value={{
101
+ settings,
102
+ updateTemplates,
103
+ updateCommentMaxLength,
104
+ updateShowTemplatesByDefault,
105
+ updateEnableResolve,
106
+ updateQuickComment,
107
+ resetTemplates,
108
+ resetAll,
109
+ }}
110
+ >
111
+ {children}
112
+ </SettingsContext.Provider>
113
+ );
114
+ }
115
+
116
+ export function useSettings(): SettingsContextValue {
117
+ const ctx = useContext(SettingsContext);
118
+ if (!ctx) throw new Error('useSettings must be used within a SettingsProvider');
119
+ return ctx;
120
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { hashString, getAuthorColor } from './useAuthor';
3
+
4
+ describe('hashString', () => {
5
+ it('returns a non-negative number', () => {
6
+ expect(hashString('test')).toBeGreaterThanOrEqual(0);
7
+ expect(hashString('another string')).toBeGreaterThanOrEqual(0);
8
+ });
9
+
10
+ it('returns the same hash for the same input', () => {
11
+ expect(hashString('hello')).toBe(hashString('hello'));
12
+ });
13
+
14
+ it('returns different hashes for different inputs', () => {
15
+ expect(hashString('alice')).not.toBe(hashString('bob'));
16
+ });
17
+
18
+ it('returns 0 for empty string', () => {
19
+ expect(hashString('')).toBe(0);
20
+ });
21
+
22
+ it('handles single character', () => {
23
+ expect(hashString('a')).toBeGreaterThanOrEqual(0);
24
+ });
25
+ });
26
+
27
+ describe('getAuthorColor', () => {
28
+ it('returns a color object with bg, text, border', () => {
29
+ const color = getAuthorColor('Alice');
30
+ expect(color).toHaveProperty('bg');
31
+ expect(color).toHaveProperty('text');
32
+ expect(color).toHaveProperty('border');
33
+ expect(color.bg).toMatch(/^#/);
34
+ expect(color.text).toMatch(/^#/);
35
+ expect(color.border).toMatch(/^#/);
36
+ });
37
+
38
+ it('returns the same color for the same author', () => {
39
+ expect(getAuthorColor('Alice')).toEqual(getAuthorColor('Alice'));
40
+ });
41
+
42
+ it('returns different colors for different authors', () => {
43
+ // Not guaranteed for all pairs, but very likely for these
44
+ const colors = new Set([
45
+ getAuthorColor('Alice').bg,
46
+ getAuthorColor('Bob').bg,
47
+ getAuthorColor('Charlie').bg,
48
+ ]);
49
+ expect(colors.size).toBeGreaterThan(1);
50
+ });
51
+
52
+ it('returns a valid color for empty string', () => {
53
+ const color = getAuthorColor('');
54
+ expect(color).toHaveProperty('bg');
55
+ expect(color).toHaveProperty('text');
56
+ expect(color).toHaveProperty('border');
57
+ });
58
+ });
@@ -0,0 +1,69 @@
1
+ import { useState, useCallback, useEffect, useRef } from 'react';
2
+ import { fetchPreferences, savePreferencesToDisk } from '../lib/preferences-client';
3
+
4
+ const STORAGE_KEY = 'md-redline-author';
5
+ const DEFAULT_AUTHOR = 'User';
6
+
7
+ // 8 maximally distinct hues for author color coding
8
+ const AUTHOR_COLORS = [
9
+ { bg: '#dbeafe', text: '#2563eb', border: '#93c5fd' }, // blue
10
+ { bg: '#fce7f3', text: '#db2777', border: '#f9a8d4' }, // pink
11
+ { bg: '#d1fae5', text: '#059669', border: '#6ee7b7' }, // green
12
+ { bg: '#fef3c7', text: '#d97706', border: '#fcd34d' }, // amber
13
+ { bg: '#ede9fe', text: '#7c3aed', border: '#c4b5fd' }, // violet
14
+ { bg: '#ffedd5', text: '#ea580c', border: '#fdba74' }, // orange
15
+ { bg: '#cffafe', text: '#0891b2', border: '#67e8f9' }, // cyan
16
+ { bg: '#fce4ec', text: '#e11d48', border: '#f48fb1' }, // rose
17
+ ];
18
+
19
+ export function hashString(str: string): number {
20
+ let hash = 0;
21
+ for (let i = 0; i < str.length; i++) {
22
+ hash = (hash << 5) - hash + str.charCodeAt(i);
23
+ hash |= 0;
24
+ }
25
+ return Math.abs(hash);
26
+ }
27
+
28
+ export function getAuthorColor(author: string) {
29
+ return AUTHOR_COLORS[hashString(author) % AUTHOR_COLORS.length];
30
+ }
31
+
32
+ export function useAuthor() {
33
+ const [author, setAuthorState] = useState(DEFAULT_AUTHOR);
34
+ const hasLocalMutationRef = useRef(false);
35
+
36
+ // Hydrate from disk on mount
37
+ useEffect(() => {
38
+ let cancelled = false;
39
+ fetchPreferences().then((prefs) => {
40
+ if (cancelled || hasLocalMutationRef.current) return;
41
+ if (typeof prefs.author === 'string' && prefs.author.trim() && prefs.author !== author) {
42
+ setAuthorState(prefs.author);
43
+ try {
44
+ localStorage.setItem(STORAGE_KEY, prefs.author);
45
+ } catch {
46
+ /* storage unavailable */
47
+ }
48
+ }
49
+ });
50
+ return () => {
51
+ cancelled = true;
52
+ };
53
+ // eslint-disable-next-line react-hooks/exhaustive-deps
54
+ }, []);
55
+
56
+ const setAuthor = useCallback((name: string) => {
57
+ const trimmed = name.trim() || DEFAULT_AUTHOR;
58
+ hasLocalMutationRef.current = true;
59
+ setAuthorState(trimmed);
60
+ try {
61
+ localStorage.setItem(STORAGE_KEY, trimmed);
62
+ } catch {
63
+ // Storage unavailable
64
+ }
65
+ savePreferencesToDisk({ author: trimmed });
66
+ }, []);
67
+
68
+ return { author, setAuthor };
69
+ }
@@ -0,0 +1,20 @@
1
+ import { useCallback, useEffect, type RefObject } from 'react';
2
+
3
+ /**
4
+ * Auto-resizes a textarea to fit its content.
5
+ * Call `trigger` after programmatic value changes (e.g. template insert).
6
+ */
7
+ export function useAutoResize(ref: RefObject<HTMLTextAreaElement | null>, value: string) {
8
+ const resize = useCallback(() => {
9
+ const el = ref.current;
10
+ if (!el) return;
11
+ el.style.height = 'auto';
12
+ el.style.height = `${el.scrollHeight}px`;
13
+ }, [ref]);
14
+
15
+ useEffect(() => {
16
+ resize();
17
+ }, [value, resize]);
18
+
19
+ return resize;
20
+ }
@@ -0,0 +1,20 @@
1
+ import { useState, useCallback } from 'react';
2
+ import type { SidebarCommentEditorState } from '../lib/comment-editor-state';
3
+
4
+ export function useCommentCardTriggers() {
5
+ const [requestedEditor, setRequestedEditor] = useState<SidebarCommentEditorState>(null);
6
+
7
+ const triggerEdit = useCallback((commentId: string) => {
8
+ setRequestedEditor({ mode: 'comment-edit', commentId, token: Date.now() });
9
+ }, []);
10
+
11
+ const triggerReply = useCallback((commentId: string) => {
12
+ setRequestedEditor({ mode: 'reply-compose', commentId, token: Date.now() });
13
+ }, []);
14
+
15
+ return {
16
+ requestedEditor,
17
+ triggerEdit,
18
+ triggerReply,
19
+ };
20
+ }