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,736 @@
1
+ import { Hono } from 'hono';
2
+ import { cors } from 'hono/cors';
3
+ import { bodyLimit } from 'hono/body-limit';
4
+ import { serve } from '@hono/node-server';
5
+ import { readFile, readdir, stat, realpath, rename, open } from 'fs/promises';
6
+ import { watch, statSync, realpathSync, unlinkSync, type FSWatcher } from 'fs';
7
+ import { join, extname, resolve, dirname } from 'path';
8
+ import { homedir, platform, tmpdir } from 'os';
9
+ import { execFile } from 'child_process';
10
+ import { pathToFileURL } from 'url';
11
+ import { readPreferences, writePreferences } from './preferences';
12
+
13
+ export interface CreateAppOptions {
14
+ cwd?: string;
15
+ execFileImpl?: typeof execFile;
16
+ homeDir?: string;
17
+ initialArg?: string;
18
+ platformName?: NodeJS.Platform;
19
+ }
20
+
21
+ function canonicalize(p: string): string {
22
+ try {
23
+ return realpathSync(p);
24
+ } catch {
25
+ return resolve(p);
26
+ }
27
+ }
28
+
29
+ function normalizePathForComparison(path: string, caseInsensitive: boolean): string {
30
+ const normalized = path.replace(/[\\/]+/g, '/').replace(/\/+$/, '');
31
+ const comparable = normalized || '/';
32
+ return caseInsensitive ? comparable.toLowerCase() : comparable;
33
+ }
34
+
35
+ export function isPathInsideRoot(path: string, root: string, caseInsensitive = false): boolean {
36
+ const normalizedPath = normalizePathForComparison(path, caseInsensitive);
37
+ const normalizedRoot = normalizePathForComparison(root, caseInsensitive);
38
+ if (normalizedRoot === '/') return true;
39
+ return normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`);
40
+ }
41
+
42
+ function isMdFile(resolved: string): boolean {
43
+ return extname(resolved).toLowerCase() === '.md';
44
+ }
45
+
46
+ function expandHomePath(inputPath: string, homeDir: string): string {
47
+ if (inputPath === '~') return homeDir;
48
+ if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
49
+ return join(homeDir, inputPath.slice(2));
50
+ }
51
+ return inputPath;
52
+ }
53
+
54
+ function sseFrame(event: string, data: string): Uint8Array {
55
+ const lines = data.split('\n');
56
+ const frame = `event: ${event}\n${lines.map((l) => `data: ${l}`).join('\n')}\n\n`;
57
+ return sseEncoder.encode(frame);
58
+ }
59
+
60
+ const sseEncoder = new TextEncoder();
61
+
62
+ export function createApp(options: CreateAppOptions = {}) {
63
+ const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
64
+ const homeDir = options.homeDir ?? process.env.MD_REDLINE_HOME ?? homedir();
65
+ const initialArgRaw = options.initialArg ?? process.argv[2] ?? '';
66
+ const initialArg = initialArgRaw ? resolve(cwd, initialArgRaw) : '';
67
+ const platformName = options.platformName ?? platform();
68
+ const execFileImpl = options.execFileImpl ?? execFile;
69
+ const caseInsensitivePaths = platformName === 'win32';
70
+
71
+ const app = new Hono();
72
+ // Allow CORS only from Vite dev server ports (default 5188-5197, or custom via env)
73
+ const viteBasePort = Number.parseInt(process.env.MD_REDLINE_VITE_PORT ?? '5188', 10);
74
+ const allowedPorts = new Set<number>();
75
+ for (let p = viteBasePort; p < viteBasePort + 10; p++) allowedPorts.add(p);
76
+ // Also allow the default range if a custom port is configured
77
+ if (viteBasePort !== 5188) {
78
+ for (let p = 5188; p < 5198; p++) allowedPorts.add(p);
79
+ }
80
+ app.use(
81
+ '*',
82
+ cors({
83
+ origin: (origin) => {
84
+ if (!origin) return `http://localhost:${viteBasePort}`;
85
+ try {
86
+ const url = new URL(origin);
87
+ if (
88
+ (url.hostname === 'localhost' || url.hostname === '127.0.0.1') &&
89
+ url.port !== '' &&
90
+ allowedPorts.has(Number(url.port))
91
+ ) {
92
+ return origin;
93
+ }
94
+ } catch {
95
+ /* invalid origin */
96
+ }
97
+ return null;
98
+ },
99
+ }),
100
+ );
101
+ app.use('*', bodyLimit({ maxSize: 10 * 1024 * 1024 }));
102
+ // Enforce application/json Content-Type on POST/PUT to block CSRF via text/plain forms
103
+ app.use('*', async (c, next) => {
104
+ if (c.req.method === 'POST' || c.req.method === 'PUT') {
105
+ const ct = c.req.header('content-type') ?? '';
106
+ if (!ct.includes('application/json')) {
107
+ return c.json({ error: 'Content-Type must be application/json' }, 415);
108
+ }
109
+ }
110
+ await next();
111
+ });
112
+
113
+ let initialFile = '';
114
+ let initialDir = '';
115
+ try {
116
+ const argStat = initialArg ? statSync(initialArg) : null;
117
+ if (argStat?.isDirectory()) {
118
+ initialDir = initialArg;
119
+ } else if (initialArg) {
120
+ initialFile = initialArg;
121
+ }
122
+ } catch {
123
+ if (initialArg) initialFile = initialArg;
124
+ }
125
+
126
+ const allowedRoots = [canonicalize(cwd)];
127
+ // When opening a single file, grant access to its parent directory so
128
+ // the user can navigate to sibling files via the explorer.
129
+ if (initialFile) {
130
+ const fileDir = canonicalize(dirname(initialFile));
131
+ if (!allowedRoots.some((root) => isPathInsideRoot(fileDir, root, caseInsensitivePaths))) {
132
+ allowedRoots.push(fileDir);
133
+ }
134
+ }
135
+ if (initialDir) {
136
+ const dir = canonicalize(initialDir);
137
+ if (!allowedRoots.some((root) => isPathInsideRoot(dir, root, caseInsensitivePaths))) {
138
+ allowedRoots.push(dir);
139
+ }
140
+ }
141
+
142
+ async function resolveAndValidate(inputPath: string): Promise<string> {
143
+ const expanded = expandHomePath(inputPath, homeDir);
144
+ const resolved = resolve(cwd, expanded);
145
+ let real: string;
146
+ try {
147
+ real = await realpath(resolved);
148
+ } catch {
149
+ // File may not exist yet — resolve via its parent
150
+ const parent = dirname(resolved);
151
+ try {
152
+ const realParent = await realpath(parent);
153
+ real = join(realParent, resolved.slice(parent.length + 1));
154
+ } catch {
155
+ // Parent doesn't exist either — cannot safely validate the path
156
+ throw new Error('Access denied: cannot resolve path');
157
+ }
158
+ }
159
+ const allowed = allowedRoots.some((root) => isPathInsideRoot(real, root, caseInsensitivePaths));
160
+ if (!allowed) {
161
+ throw new Error('Access denied: path outside allowed directories');
162
+ }
163
+ return real;
164
+ }
165
+
166
+ const lastWrittenContent = new Map<string, string>();
167
+ const writeLocks = new Map<string, Promise<void>>();
168
+ const fileWatchers = new Map<
169
+ string,
170
+ {
171
+ watcher: FSWatcher;
172
+ clients: Set<WritableStreamDefaultWriter>;
173
+ cleanup: () => void;
174
+ }
175
+ >();
176
+
177
+ app.get('/api/config', (c) => {
178
+ return c.json({ initialFile, initialDir });
179
+ });
180
+
181
+ app.get('/api/preferences', async (c) => {
182
+ return c.json(await readPreferences(homeDir));
183
+ });
184
+
185
+ app.put('/api/preferences', async (c) => {
186
+ let body: Record<string, unknown>;
187
+ try {
188
+ body = await c.req.json();
189
+ } catch {
190
+ return c.json({ error: 'Invalid JSON body' }, 400);
191
+ }
192
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) {
193
+ return c.json({ error: 'Body must be a JSON object' }, 400);
194
+ }
195
+ try {
196
+ const merged = await writePreferences(homeDir, body);
197
+ return c.json(merged);
198
+ } catch (err) {
199
+ console.error('PUT /api/preferences failed:', err);
200
+ return c.json({ error: 'Failed to save preferences' }, 500);
201
+ }
202
+ });
203
+
204
+ app.get('/api/file', async (c) => {
205
+ const path = c.req.query('path');
206
+ if (!path) return c.json({ error: 'path query parameter is required' }, 400);
207
+
208
+ try {
209
+ const resolved = await resolveAndValidate(path);
210
+ if (!isMdFile(resolved)) {
211
+ return c.json({ error: 'Only .md files are supported' }, 400);
212
+ }
213
+ const [content, fileStat] = await Promise.all([
214
+ readFile(resolved, 'utf-8'),
215
+ stat(resolved),
216
+ ]);
217
+ return c.json({ content, path: resolved, mtime: fileStat.mtimeMs });
218
+ } catch (err) {
219
+ if (err instanceof Error && err.message.startsWith('Access denied')) {
220
+ return c.json({ error: err.message }, 403);
221
+ }
222
+ console.error('GET /api/file failed:', err);
223
+ return c.json({ error: 'File not found or not readable' }, 404);
224
+ }
225
+ });
226
+
227
+ app.put('/api/file', async (c) => {
228
+ let body: { path: string; content: string; expectedMtime?: number };
229
+ try {
230
+ body = await c.req.json<{ path: string; content: string; expectedMtime?: number }>();
231
+ } catch {
232
+ return c.json({ error: 'Invalid JSON body' }, 400);
233
+ }
234
+ if (!body.path || body.content === undefined) {
235
+ return c.json({ error: 'path and content are required' }, 400);
236
+ }
237
+
238
+ try {
239
+ const resolved = await resolveAndValidate(body.path);
240
+ if (!isMdFile(resolved)) {
241
+ return c.json({ error: 'Only .md files are supported' }, 400);
242
+ }
243
+ // Serialize writes to the same path to prevent concurrent write races
244
+ const commentCount = (body.content.match(/@comment\{/g) ?? []).length;
245
+ const prevLock = writeLocks.get(resolved) ?? Promise.resolve();
246
+ let conflictResponse: Response | null = null;
247
+ const currentWrite = prevLock
248
+ .then(async () => {
249
+ // Conflict detection: if the client sent an expectedMtime, verify the
250
+ // file hasn't been modified externally since the client last loaded it.
251
+ if (body.expectedMtime != null) {
252
+ try {
253
+ const currentStat = await stat(resolved);
254
+ if (Math.abs(currentStat.mtimeMs - body.expectedMtime) > 1) {
255
+ const currentContent = await readFile(resolved, 'utf-8');
256
+ conflictResponse = c.json(
257
+ {
258
+ error: 'File was modified externally. Reload to see the latest version.',
259
+ code: 'CONFLICT',
260
+ currentContent,
261
+ mtime: currentStat.mtimeMs,
262
+ },
263
+ 409,
264
+ );
265
+ return;
266
+ }
267
+ } catch {
268
+ // File may have been deleted — proceed with write to recreate it
269
+ }
270
+ }
271
+
272
+ // Atomic write: write to a temp file then rename, so a crash
273
+ // mid-write can't leave a half-written file on disk.
274
+ // Use O_EXCL to prevent symlink clobber attacks on the temp file.
275
+ const tmpPath = `${resolved}.tmp`;
276
+ try {
277
+ unlinkSync(tmpPath);
278
+ } catch {
279
+ // No existing file — fine
280
+ }
281
+ const fd = await open(tmpPath, 'wx');
282
+ try {
283
+ await fd.writeFile(body.content, 'utf-8');
284
+ } finally {
285
+ await fd.close();
286
+ }
287
+ await rename(tmpPath, resolved);
288
+ lastWrittenContent.set(resolved, body.content);
289
+ console.log(
290
+ `[SAVE OK] ${resolved} — ${commentCount} comment(s), ${body.content.length} bytes`,
291
+ );
292
+ })
293
+ .finally(() => {
294
+ if (writeLocks.get(resolved) === currentWrite) {
295
+ writeLocks.delete(resolved);
296
+ }
297
+ });
298
+ writeLocks.set(resolved, currentWrite);
299
+ await currentWrite;
300
+ if (conflictResponse) return conflictResponse;
301
+ const newStat = await stat(resolved);
302
+ return c.json({ success: true, path: resolved, mtime: newStat.mtimeMs });
303
+ } catch (err) {
304
+ if (err instanceof Error && err.message.startsWith('Access denied')) {
305
+ return c.json({ error: err.message }, 403);
306
+ }
307
+ console.error('PUT /api/file failed:', err);
308
+ return c.json({ error: 'Failed to write file' }, 500);
309
+ }
310
+ });
311
+
312
+ app.get('/api/files', async (c) => {
313
+ const dir = c.req.query('dir') || cwd;
314
+
315
+ try {
316
+ const resolved = await resolveAndValidate(dir);
317
+ const entries = await readdir(resolved, { withFileTypes: true });
318
+ const files = entries
319
+ .filter((entry) => entry.isFile() && extname(entry.name).toLowerCase() === '.md')
320
+ .map((entry) => join(resolved, entry.name));
321
+ files.sort((a, b) => a.localeCompare(b));
322
+ return c.json({ files, dir: resolved });
323
+ } catch (err) {
324
+ if (err instanceof Error && err.message.startsWith('Access denied')) {
325
+ return c.json({ error: err.message }, 403);
326
+ }
327
+ console.error('GET /api/files failed:', err);
328
+ return c.json({ error: 'Directory not found' }, 404);
329
+ }
330
+ });
331
+
332
+ app.get('/api/browse', async (c) => {
333
+ const dir = c.req.query('dir') || cwd;
334
+
335
+ try {
336
+ const resolved = await resolveAndValidate(dir);
337
+ const stats = await stat(resolved);
338
+ if (!stats.isDirectory()) {
339
+ return c.json({ error: 'Not a directory' }, 400);
340
+ }
341
+
342
+ const entries = await readdir(resolved, { withFileTypes: true });
343
+
344
+ const directories = entries
345
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
346
+ .map((entry) => ({ name: entry.name, path: join(resolved, entry.name) }))
347
+ .sort((a, b) => a.name.localeCompare(b.name));
348
+
349
+ const files = entries
350
+ .filter((entry) => entry.isFile() && extname(entry.name).toLowerCase() === '.md')
351
+ .map((entry) => ({ name: entry.name, path: join(resolved, entry.name) }))
352
+ .sort((a, b) => a.name.localeCompare(b.name));
353
+
354
+ const parent = dirname(resolved);
355
+ let parentAllowed = false;
356
+ try {
357
+ await resolveAndValidate(parent);
358
+ parentAllowed = true;
359
+ } catch {
360
+ /* parent outside allowed roots */
361
+ }
362
+
363
+ return c.json({
364
+ dir: resolved,
365
+ parent: parentAllowed && parent !== resolved ? parent : null,
366
+ directories,
367
+ files,
368
+ });
369
+ } catch (err) {
370
+ if (err instanceof Error && err.message.startsWith('Access denied')) {
371
+ return c.json({ error: err.message }, 403);
372
+ }
373
+ console.error('GET /api/browse failed:', err);
374
+ return c.json({ error: 'Directory not found or not accessible' }, 404);
375
+ }
376
+ });
377
+
378
+ app.get('/api/pick-file', async (c) => {
379
+ try {
380
+ const path = await new Promise<string>((promiseResolve, reject) => {
381
+ if (platformName === 'darwin') {
382
+ execFileImpl(
383
+ 'osascript',
384
+ [
385
+ '-e',
386
+ 'set f to POSIX path of (choose file of type {"md", "markdown", "public.plain-text"} with prompt "Choose a markdown file")',
387
+ '-e',
388
+ 'return f',
389
+ ],
390
+ (err, stdout) => {
391
+ if (err) return reject(err);
392
+ promiseResolve(stdout.trim());
393
+ },
394
+ );
395
+ } else if (platformName === 'linux') {
396
+ execFileImpl(
397
+ 'zenity',
398
+ [
399
+ '--file-selection',
400
+ '--title=Choose a markdown file',
401
+ '--file-filter=Markdown files | *.md *.markdown',
402
+ ],
403
+ (err, stdout) => {
404
+ if (err) return reject(err);
405
+ promiseResolve(stdout.trim());
406
+ },
407
+ );
408
+ } else if (platformName === 'win32') {
409
+ execFileImpl(
410
+ 'powershell',
411
+ [
412
+ '-NoProfile',
413
+ '-STA',
414
+ '-Command',
415
+ 'Add-Type -AssemblyName System.Windows.Forms; $dialog = New-Object System.Windows.Forms.OpenFileDialog; $dialog.Filter = "Markdown files (*.md;*.markdown)|*.md;*.markdown|All files (*.*)|*.*"; if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $dialog.FileName }',
416
+ ],
417
+ (err, stdout) => {
418
+ if (err) return reject(err);
419
+ promiseResolve(stdout.trim());
420
+ },
421
+ );
422
+ } else {
423
+ reject(new Error('Unsupported platform'));
424
+ }
425
+ });
426
+ if (!path) return c.json({ error: 'No file selected' }, 400);
427
+ // Validate the picked path (OS picker returns an absolute path)
428
+ if (!isMdFile(path)) {
429
+ return c.json({ error: 'Only .md files are supported' }, 400);
430
+ }
431
+ // Grant access to the file's directory so subsequent API calls work
432
+ const pickedDir = dirname(resolve(path));
433
+ try {
434
+ const realDir = canonicalize(pickedDir);
435
+ if (!allowedRoots.some((root) => isPathInsideRoot(realDir, root, caseInsensitivePaths))) {
436
+ allowedRoots.push(realDir);
437
+ }
438
+ } catch {
439
+ // Can't canonicalize — add the raw resolved dir
440
+ if (!allowedRoots.some((root) => isPathInsideRoot(pickedDir, root, caseInsensitivePaths))) {
441
+ allowedRoots.push(pickedDir);
442
+ }
443
+ }
444
+ return c.json({ path });
445
+ } catch {
446
+ return c.json({ cancelled: true });
447
+ }
448
+ });
449
+
450
+ app.get('/api/watch', async (c) => {
451
+ // Accept one or many paths: ?path=a&path=b (single SSE for all)
452
+ const paths = c.req.queries('path') ?? [];
453
+ if (paths.length === 0) return c.json({ error: 'path query parameter is required' }, 400);
454
+
455
+ const resolvedPaths: string[] = [];
456
+ for (const p of paths) {
457
+ try {
458
+ const resolved = await resolveAndValidate(p);
459
+ if (!isMdFile(resolved)) {
460
+ if (paths.length === 1) return c.json({ error: 'Only .md files are supported' }, 400);
461
+ continue;
462
+ }
463
+ resolvedPaths.push(resolved);
464
+ } catch (err) {
465
+ if (paths.length === 1) {
466
+ if (err instanceof Error && err.message.startsWith('Access denied')) {
467
+ return c.json({ error: err.message }, 403);
468
+ }
469
+ return c.json({ error: 'File not found' }, 404);
470
+ }
471
+ }
472
+ }
473
+
474
+ if (resolvedPaths.length === 0) {
475
+ return c.json({ error: 'No valid .md files to watch' }, 400);
476
+ }
477
+
478
+ const stream = new TransformStream();
479
+ const writer = stream.writable.getWriter();
480
+
481
+ for (const resolved of resolvedPaths) {
482
+ if (!fileWatchers.has(resolved)) {
483
+ const clients = new Set<WritableStreamDefaultWriter>();
484
+ let debounce: ReturnType<typeof setTimeout> | null = null;
485
+ let lastBroadcast: string | null = null;
486
+
487
+ const broadcastChange = async () => {
488
+ try {
489
+ const content = await readFile(resolved, 'utf-8');
490
+ if (lastWrittenContent.get(resolved) === content) return;
491
+ if (content === lastBroadcast) return;
492
+ const extComments = (content.match(/@comment\{/g) ?? []).length;
493
+ const prevComments = (lastBroadcast?.match(/@comment\{/g) ?? []).length;
494
+ console.warn(
495
+ `[EXTERNAL CHANGE] ${resolved} — ${extComments} comment(s) (was ${prevComments})`,
496
+ );
497
+ lastWrittenContent.delete(resolved);
498
+ lastBroadcast = content;
499
+ const fileStat = await stat(resolved);
500
+ const frame = sseFrame(
501
+ 'change',
502
+ JSON.stringify({ content, path: resolved, mtime: fileStat.mtimeMs }),
503
+ );
504
+ for (const client of clients) {
505
+ client.write(frame).catch(() => {
506
+ clients.delete(client);
507
+ });
508
+ }
509
+ } catch {
510
+ const frame = sseFrame(
511
+ 'error',
512
+ JSON.stringify({ path: resolved, reason: 'file_gone' }),
513
+ );
514
+ for (const client of clients) {
515
+ client.write(frame).catch(() => {
516
+ clients.delete(client);
517
+ });
518
+ }
519
+ cleanUpWatcher();
520
+ }
521
+ };
522
+
523
+ const onFsEvent = (eventType: string) => {
524
+ if (debounce) clearTimeout(debounce);
525
+ debounce = setTimeout(async () => {
526
+ await broadcastChange();
527
+ // On macOS, fs.watch uses kqueue which tracks by file descriptor.
528
+ // Atomic writes (rename-over) create a new inode, leaving the old
529
+ // watcher stale. Re-attach after every rename so we keep watching
530
+ // the current file.
531
+ if (eventType === 'rename') {
532
+ reattachWatcher();
533
+ }
534
+ }, 150);
535
+ };
536
+
537
+ let activeWatcher = watch(resolved, onFsEvent);
538
+
539
+ const reattachWatcher = () => {
540
+ activeWatcher.close();
541
+ try {
542
+ activeWatcher = watch(resolved, onFsEvent);
543
+ activeWatcher.on('error', cleanUpWatcher);
544
+ const entry = fileWatchers.get(resolved);
545
+ if (entry) entry.watcher = activeWatcher;
546
+ } catch {
547
+ // File deleted — clean up entirely
548
+ cleanUpWatcher();
549
+ }
550
+ };
551
+
552
+ const cleanUpWatcher = () => {
553
+ if (debounce) {
554
+ clearTimeout(debounce);
555
+ debounce = null;
556
+ }
557
+ for (const client of clients) {
558
+ client.close().catch(() => {});
559
+ }
560
+ clients.clear();
561
+ activeWatcher.close();
562
+ fileWatchers.delete(resolved);
563
+ lastWrittenContent.delete(resolved);
564
+ };
565
+
566
+ activeWatcher.on('error', cleanUpWatcher);
567
+ fileWatchers.set(resolved, { watcher: activeWatcher, clients, cleanup: cleanUpWatcher });
568
+ }
569
+
570
+ const entry = fileWatchers.get(resolved)!;
571
+ entry.clients.add(writer);
572
+ }
573
+
574
+ for (const resolved of resolvedPaths) {
575
+ writer.write(sseFrame('connected', JSON.stringify({ path: resolved }))).catch(() => {});
576
+ }
577
+
578
+ c.req.raw.signal.addEventListener('abort', () => {
579
+ for (const resolved of resolvedPaths) {
580
+ const entry = fileWatchers.get(resolved);
581
+ if (!entry) continue;
582
+ entry.clients.delete(writer);
583
+ if (entry.clients.size === 0) {
584
+ entry.cleanup();
585
+ }
586
+ }
587
+ writer.close().catch(() => {});
588
+ });
589
+
590
+ return new Response(stream.readable, {
591
+ headers: {
592
+ 'Content-Type': 'text/event-stream',
593
+ 'Cache-Control': 'no-cache',
594
+ Connection: 'keep-alive',
595
+ },
596
+ });
597
+ });
598
+
599
+ app.get('/api/platform', (c) => {
600
+ return c.json({ platform: platformName });
601
+ });
602
+
603
+ app.post('/api/reveal', async (c) => {
604
+ let body: { path: string };
605
+ try {
606
+ body = await c.req.json<{ path: string }>();
607
+ } catch {
608
+ return c.json({ error: 'Invalid JSON body' }, 400);
609
+ }
610
+ if (!body.path) return c.json({ error: 'path is required' }, 400);
611
+
612
+ try {
613
+ const resolved = await resolveAndValidate(body.path);
614
+ await new Promise<void>((promiseResolve, reject) => {
615
+ if (platformName === 'darwin') {
616
+ const escaped = resolved.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
617
+ execFileImpl(
618
+ 'osascript',
619
+ [
620
+ '-e',
621
+ `tell application "Finder" to reveal POSIX file "${escaped}"`,
622
+ '-e',
623
+ 'tell application "Finder" to activate',
624
+ ],
625
+ (err) => {
626
+ if (err) return reject(err);
627
+ promiseResolve();
628
+ },
629
+ );
630
+ } else if (platformName === 'linux') {
631
+ execFileImpl('xdg-open', [dirname(resolved)], (err) => {
632
+ if (err) return reject(err);
633
+ promiseResolve();
634
+ });
635
+ } else if (platformName === 'win32') {
636
+ execFileImpl('explorer', ['/select,', resolved], (err) => {
637
+ if (err) return reject(err);
638
+ promiseResolve();
639
+ });
640
+ } else {
641
+ reject(new Error('Unsupported platform'));
642
+ }
643
+ });
644
+ return c.json({ success: true });
645
+ } catch (err) {
646
+ if (err instanceof Error && err.message.startsWith('Access denied')) {
647
+ return c.json({ error: err.message }, 403);
648
+ }
649
+ console.error('POST /api/reveal failed:', err);
650
+ return c.json({ error: 'Failed to reveal file' }, 500);
651
+ }
652
+ });
653
+
654
+ return app;
655
+ }
656
+
657
+ export const app = createApp();
658
+
659
+ const DEFAULT_PORT = Number.parseInt(process.env.MD_REDLINE_PORT ?? process.env.PORT ?? '3001', 10);
660
+ const MAX_PORT_ATTEMPTS = 10;
661
+ const PORT_FILE = join(tmpdir(), 'md-redline.port');
662
+
663
+ function tryListen(appFetch: typeof app.fetch, port: number): Promise<number> {
664
+ return new Promise((res, rej) => {
665
+ const server = serve({ fetch: appFetch, port, hostname: '127.0.0.1' }, () => res(port));
666
+ server.on('error', (err: NodeJS.ErrnoException) => {
667
+ rej(err);
668
+ });
669
+ });
670
+ }
671
+
672
+ async function findAvailablePort(appFetch: typeof app.fetch): Promise<number> {
673
+ for (let p = DEFAULT_PORT; p < DEFAULT_PORT + MAX_PORT_ATTEMPTS; p++) {
674
+ try {
675
+ return await tryListen(appFetch, p);
676
+ } catch (err) {
677
+ if ((err as NodeJS.ErrnoException).code !== 'EADDRINUSE') throw err;
678
+ }
679
+ }
680
+ throw new Error(
681
+ `No available port found (tried ${DEFAULT_PORT}-${DEFAULT_PORT + MAX_PORT_ATTEMPTS - 1})`,
682
+ );
683
+ }
684
+
685
+ const isMainModule =
686
+ typeof process.argv[1] === 'string' &&
687
+ import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
688
+
689
+ if (isMainModule) {
690
+ findAvailablePort(app.fetch)
691
+ .then(async (port) => {
692
+ // Write port file safely: use O_EXCL to prevent symlink clobber attacks.
693
+ // If the file already exists (previous unclean exit), unlink it first
694
+ // to avoid following a symlink that may have replaced the stale file.
695
+ try {
696
+ const fd = await open(PORT_FILE, 'wx');
697
+ await fd.writeFile(String(port));
698
+ await fd.close();
699
+ } catch (e) {
700
+ if ((e as NodeJS.ErrnoException).code === 'EEXIST') {
701
+ unlinkSync(PORT_FILE);
702
+ const fd = await open(PORT_FILE, 'wx');
703
+ await fd.writeFile(String(port));
704
+ await fd.close();
705
+ } else {
706
+ throw e;
707
+ }
708
+ }
709
+ console.log(`md-redline server running on http://localhost:${port}`);
710
+ const initialArg = process.argv[2] ? resolve(process.cwd(), process.argv[2]) : '';
711
+ if (initialArg) {
712
+ console.log(`Initial path: ${initialArg}`);
713
+ }
714
+
715
+ const cleanup = () => {
716
+ try {
717
+ unlinkSync(PORT_FILE);
718
+ } catch {
719
+ /* ignore */
720
+ }
721
+ };
722
+ process.on('exit', cleanup);
723
+ process.on('SIGINT', () => {
724
+ cleanup();
725
+ process.exit(0);
726
+ });
727
+ process.on('SIGTERM', () => {
728
+ cleanup();
729
+ process.exit(0);
730
+ });
731
+ })
732
+ .catch((err) => {
733
+ console.error(err.message);
734
+ process.exit(1);
735
+ });
736
+ }