@henryavila/mdprobe 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +404 -0
- package/bin/cli.js +335 -0
- package/dist/assets/index-DPysqH1p.js +2 -0
- package/dist/assets/index-nl9v2RuJ.css +1 -0
- package/dist/index.html +19 -0
- package/package.json +75 -0
- package/schema.json +104 -0
- package/skills/mdprobe/SKILL.md +358 -0
- package/src/anchoring.js +262 -0
- package/src/annotations.js +504 -0
- package/src/cli-utils.js +58 -0
- package/src/config.js +76 -0
- package/src/export.js +211 -0
- package/src/handler.js +229 -0
- package/src/hash.js +51 -0
- package/src/renderer.js +247 -0
- package/src/server.js +849 -0
- package/src/ui/app.jsx +152 -0
- package/src/ui/components/AnnotationForm.jsx +72 -0
- package/src/ui/components/Content.jsx +334 -0
- package/src/ui/components/ExportMenu.jsx +62 -0
- package/src/ui/components/LeftPanel.jsx +99 -0
- package/src/ui/components/Popover.jsx +94 -0
- package/src/ui/components/ReplyThread.jsx +28 -0
- package/src/ui/components/RightPanel.jsx +171 -0
- package/src/ui/components/SectionApproval.jsx +31 -0
- package/src/ui/components/ThemePicker.jsx +18 -0
- package/src/ui/hooks/useAnnotations.js +160 -0
- package/src/ui/hooks/useClientLibs.js +97 -0
- package/src/ui/hooks/useKeyboard.js +128 -0
- package/src/ui/hooks/useTheme.js +57 -0
- package/src/ui/hooks/useWebSocket.js +126 -0
- package/src/ui/index.html +19 -0
- package/src/ui/state/store.js +76 -0
- package/src/ui/styles/themes.css +1243 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
import node_http from 'node:http'
|
|
2
|
+
import node_fs from 'node:fs/promises'
|
|
3
|
+
import node_path from 'node:path'
|
|
4
|
+
import node_net from 'node:net'
|
|
5
|
+
import { URL } from 'node:url'
|
|
6
|
+
import { WebSocketServer } from 'ws'
|
|
7
|
+
import { watch } from 'chokidar'
|
|
8
|
+
import { render } from './renderer.js'
|
|
9
|
+
import { AnnotationFile, computeSectionStatus } from './annotations.js'
|
|
10
|
+
import { detectDrift } from './hash.js'
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// File resolution
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the `files` option into a list of absolute markdown file paths.
|
|
18
|
+
*
|
|
19
|
+
* - If a single directory is given, recursively discover all `.md` files.
|
|
20
|
+
* - If individual files are given, verify they exist on disk.
|
|
21
|
+
*
|
|
22
|
+
* @param {string[]} files - Array of file paths or a single-element array with a directory
|
|
23
|
+
* @returns {Promise<string[]>} Resolved absolute paths
|
|
24
|
+
*/
|
|
25
|
+
async function resolveFiles(files) {
|
|
26
|
+
if (!files || files.length === 0) {
|
|
27
|
+
throw new Error('No files specified')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const resolved = []
|
|
31
|
+
|
|
32
|
+
for (const entry of files) {
|
|
33
|
+
const abs = node_path.resolve(entry)
|
|
34
|
+
let stat
|
|
35
|
+
try {
|
|
36
|
+
stat = await node_fs.stat(abs)
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error(`File or directory not found: ${entry}`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (stat.isDirectory()) {
|
|
42
|
+
const discovered = await discoverMarkdownFiles(abs)
|
|
43
|
+
if (discovered.length === 0) {
|
|
44
|
+
throw new Error(`No markdown files found in ${entry}`)
|
|
45
|
+
}
|
|
46
|
+
resolved.push(...discovered)
|
|
47
|
+
} else if (stat.isFile()) {
|
|
48
|
+
resolved.push(abs)
|
|
49
|
+
} else {
|
|
50
|
+
throw new Error(`Not a file or directory: ${entry}`)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (resolved.length === 0) {
|
|
55
|
+
throw new Error('No markdown files found')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return resolved
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Recursively discover all `.md` files under a directory.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} dir - Absolute directory path
|
|
65
|
+
* @returns {Promise<string[]>} Absolute paths of discovered markdown files
|
|
66
|
+
*/
|
|
67
|
+
async function discoverMarkdownFiles(dir) {
|
|
68
|
+
const entries = await node_fs.readdir(dir, { withFileTypes: true, recursive: true })
|
|
69
|
+
const mdFiles = []
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
72
|
+
// Node 20+ provides parentPath; older versions use path
|
|
73
|
+
const parent = entry.parentPath || entry.path
|
|
74
|
+
mdFiles.push(node_path.join(parent, entry.name))
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return mdFiles.sort()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Port management
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check whether a port on 127.0.0.1 is available.
|
|
86
|
+
*
|
|
87
|
+
* @param {number} port
|
|
88
|
+
* @returns {Promise<boolean>}
|
|
89
|
+
*/
|
|
90
|
+
function isPortAvailable(port) {
|
|
91
|
+
return new Promise((resolve) => {
|
|
92
|
+
const server = node_net.createServer()
|
|
93
|
+
server.once('error', () => resolve(false))
|
|
94
|
+
server.once('listening', () => {
|
|
95
|
+
server.close(() => resolve(true))
|
|
96
|
+
})
|
|
97
|
+
server.listen(port, '127.0.0.1')
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Find an available port starting from `startPort`, trying up to `maxAttempts`
|
|
103
|
+
* consecutive ports.
|
|
104
|
+
*
|
|
105
|
+
* @param {number} startPort
|
|
106
|
+
* @param {number} [maxAttempts=10]
|
|
107
|
+
* @returns {Promise<number>}
|
|
108
|
+
*/
|
|
109
|
+
async function findAvailablePort(startPort, maxAttempts = 10) {
|
|
110
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
111
|
+
const port = startPort + i
|
|
112
|
+
if (await isPortAvailable(port)) {
|
|
113
|
+
return port
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
throw new Error(
|
|
117
|
+
`No available port found (tried ${startPort}–${startPort + maxAttempts - 1})`,
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// MIME type helpers
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
const MIME_TYPES = {
|
|
126
|
+
'.html': 'text/html',
|
|
127
|
+
'.css': 'text/css',
|
|
128
|
+
'.js': 'application/javascript',
|
|
129
|
+
'.json': 'application/json',
|
|
130
|
+
'.png': 'image/png',
|
|
131
|
+
'.jpg': 'image/jpeg',
|
|
132
|
+
'.jpeg': 'image/jpeg',
|
|
133
|
+
'.gif': 'image/gif',
|
|
134
|
+
'.svg': 'image/svg+xml',
|
|
135
|
+
'.ico': 'image/x-icon',
|
|
136
|
+
'.webp': 'image/webp',
|
|
137
|
+
'.pdf': 'application/pdf',
|
|
138
|
+
'.woff': 'font/woff',
|
|
139
|
+
'.woff2': 'font/woff2',
|
|
140
|
+
'.ttf': 'font/ttf',
|
|
141
|
+
'.txt': 'text/plain',
|
|
142
|
+
'.md': 'text/markdown',
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get the MIME type for a file extension.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} ext - File extension including the leading dot
|
|
149
|
+
* @returns {string}
|
|
150
|
+
*/
|
|
151
|
+
function getMimeType(ext) {
|
|
152
|
+
return MIME_TYPES[ext.toLowerCase()] || 'application/octet-stream'
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// JSON and HTML response helpers
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Send a JSON response.
|
|
161
|
+
*
|
|
162
|
+
* @param {node_http.ServerResponse} res
|
|
163
|
+
* @param {number} statusCode
|
|
164
|
+
* @param {*} data
|
|
165
|
+
*/
|
|
166
|
+
function sendJSON(res, statusCode, data) {
|
|
167
|
+
const body = JSON.stringify(data)
|
|
168
|
+
res.writeHead(statusCode, {
|
|
169
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
170
|
+
'Content-Length': Buffer.byteLength(body),
|
|
171
|
+
})
|
|
172
|
+
res.end(body)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Send an HTML response.
|
|
177
|
+
*
|
|
178
|
+
* @param {node_http.ServerResponse} res
|
|
179
|
+
* @param {number} statusCode
|
|
180
|
+
* @param {string} html
|
|
181
|
+
*/
|
|
182
|
+
function sendHTML(res, statusCode, html) {
|
|
183
|
+
res.writeHead(statusCode, {
|
|
184
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
185
|
+
'Content-Length': Buffer.byteLength(html),
|
|
186
|
+
})
|
|
187
|
+
res.end(html)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Send a 404 Not Found response.
|
|
192
|
+
*
|
|
193
|
+
* @param {node_http.ServerResponse} res
|
|
194
|
+
*/
|
|
195
|
+
function send404(res) {
|
|
196
|
+
sendJSON(res, 404, { error: 'Not Found' })
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Read and parse JSON request body.
|
|
201
|
+
*/
|
|
202
|
+
function readBody(req) {
|
|
203
|
+
return new Promise((resolve, reject) => {
|
|
204
|
+
let data = ''
|
|
205
|
+
req.on('data', (chunk) => { data += chunk })
|
|
206
|
+
req.on('end', () => {
|
|
207
|
+
try {
|
|
208
|
+
resolve(JSON.parse(data))
|
|
209
|
+
} catch {
|
|
210
|
+
reject(new Error('Invalid JSON body'))
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
req.on('error', reject)
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// UI HTML — serve built Preact app if available, fallback to minimal shell
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
222
|
+
|
|
223
|
+
const DIST_DIR = new URL('../dist', import.meta.url).pathname
|
|
224
|
+
const DIST_INDEX = node_path.join(DIST_DIR, 'index.html')
|
|
225
|
+
|
|
226
|
+
let SHELL_HTML
|
|
227
|
+
try {
|
|
228
|
+
if (existsSync(DIST_INDEX)) {
|
|
229
|
+
SHELL_HTML = readFileSync(DIST_INDEX, 'utf-8')
|
|
230
|
+
}
|
|
231
|
+
} catch { /* fallback below */ }
|
|
232
|
+
|
|
233
|
+
if (!SHELL_HTML) {
|
|
234
|
+
SHELL_HTML = `<!DOCTYPE html>
|
|
235
|
+
<html lang="en">
|
|
236
|
+
<head>
|
|
237
|
+
<meta charset="utf-8">
|
|
238
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
239
|
+
<title>mdprobe</title>
|
|
240
|
+
</head>
|
|
241
|
+
<body>
|
|
242
|
+
<div id="app"><p style="padding:40px;font-family:sans-serif">mdprobe — run <code>npm run build:ui</code> to build the UI</p></div>
|
|
243
|
+
</body>
|
|
244
|
+
</html>`
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// createServer
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Create and start the mdprobe development server.
|
|
253
|
+
*
|
|
254
|
+
* @param {object} options
|
|
255
|
+
* @param {string[]} options.files - File paths or a directory path
|
|
256
|
+
* @param {number} [options.port=3000] - Preferred port
|
|
257
|
+
* @param {boolean} [options.open=true] - Open browser on start
|
|
258
|
+
* @param {boolean} [options.once=false] - One-shot review mode
|
|
259
|
+
* @param {string} [options.author] - Reviewer author name
|
|
260
|
+
* @param {Function} [options.onDisconnect] - Called when a WebSocket client disconnects
|
|
261
|
+
* @returns {Promise<{url: string, port: number, close: () => Promise<void>, onFinish?: Function}>}
|
|
262
|
+
*/
|
|
263
|
+
export async function createServer(options) {
|
|
264
|
+
const {
|
|
265
|
+
files,
|
|
266
|
+
port: preferredPort = 3000,
|
|
267
|
+
open = true,
|
|
268
|
+
once = false,
|
|
269
|
+
author,
|
|
270
|
+
onDisconnect,
|
|
271
|
+
} = options
|
|
272
|
+
|
|
273
|
+
// 1. Resolve files
|
|
274
|
+
const resolvedFiles = await resolveFiles(files)
|
|
275
|
+
|
|
276
|
+
// Base directory for static asset serving — use the directory of the first file
|
|
277
|
+
const assetBaseDir = node_path.dirname(resolvedFiles[0])
|
|
278
|
+
|
|
279
|
+
// 2. Find an available port
|
|
280
|
+
const actualPort = await findAvailablePort(preferredPort)
|
|
281
|
+
|
|
282
|
+
// 3. Build route handler (onFinish is set below for --once mode)
|
|
283
|
+
let onFinishCallback = null
|
|
284
|
+
// broadcastToAll is defined below after wss; forward-ref via closure
|
|
285
|
+
let broadcastFn = () => {}
|
|
286
|
+
const handleRequest = createRequestHandler({
|
|
287
|
+
resolvedFiles,
|
|
288
|
+
assetBaseDir,
|
|
289
|
+
once,
|
|
290
|
+
author,
|
|
291
|
+
getOnFinish: () => onFinishCallback,
|
|
292
|
+
broadcast: (msg) => broadcastFn(msg),
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
// 4. Create HTTP server
|
|
296
|
+
const httpServer = node_http.createServer(handleRequest)
|
|
297
|
+
|
|
298
|
+
// 5. Attach WebSocket server
|
|
299
|
+
const wss = new WebSocketServer({ server: httpServer, path: '/ws' })
|
|
300
|
+
|
|
301
|
+
wss.on('connection', (ws) => {
|
|
302
|
+
ws.on('message', (data) => {
|
|
303
|
+
try {
|
|
304
|
+
const msg = JSON.parse(data.toString())
|
|
305
|
+
if (msg.type === 'ping') {
|
|
306
|
+
ws.send(JSON.stringify({ type: 'pong' }))
|
|
307
|
+
}
|
|
308
|
+
} catch {
|
|
309
|
+
// Ignore malformed messages
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
ws.on('close', () => {
|
|
314
|
+
if (typeof onDisconnect === 'function') {
|
|
315
|
+
onDisconnect()
|
|
316
|
+
}
|
|
317
|
+
})
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
// 6. Start listening
|
|
321
|
+
await new Promise((resolve, reject) => {
|
|
322
|
+
httpServer.once('error', reject)
|
|
323
|
+
httpServer.listen(actualPort, '127.0.0.1', () => {
|
|
324
|
+
httpServer.removeListener('error', reject)
|
|
325
|
+
resolve()
|
|
326
|
+
})
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
// 7. Set up file watcher for live reload
|
|
330
|
+
const watchDirs = new Set(resolvedFiles.map((f) => node_path.dirname(f)))
|
|
331
|
+
const watchPaths = [...watchDirs]
|
|
332
|
+
|
|
333
|
+
const watcher = watch(watchPaths, {
|
|
334
|
+
ignored: (path, stats) => {
|
|
335
|
+
// Allow directories through (so we can watch recursively)
|
|
336
|
+
if (!stats || stats.isDirectory()) return false
|
|
337
|
+
// Only watch .md files
|
|
338
|
+
return !path.endsWith('.md')
|
|
339
|
+
},
|
|
340
|
+
ignoreInitial: true,
|
|
341
|
+
persistent: true,
|
|
342
|
+
depth: 10,
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
// Debounce: collect changes and broadcast after 100ms quiet period
|
|
346
|
+
const debounceTimers = new Map()
|
|
347
|
+
|
|
348
|
+
function broadcastToAll(message) {
|
|
349
|
+
const data = JSON.stringify(message)
|
|
350
|
+
for (const client of wss.clients) {
|
|
351
|
+
if (client.readyState === 1) {
|
|
352
|
+
client.send(data)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
broadcastFn = broadcastToAll
|
|
357
|
+
|
|
358
|
+
watcher.on('change', (filePath) => {
|
|
359
|
+
if (!filePath.endsWith('.md')) return
|
|
360
|
+
const fileName = node_path.basename(filePath)
|
|
361
|
+
|
|
362
|
+
// Clear existing timer for this file
|
|
363
|
+
if (debounceTimers.has(filePath)) {
|
|
364
|
+
clearTimeout(debounceTimers.get(filePath))
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
debounceTimers.set(filePath, setTimeout(async () => {
|
|
368
|
+
debounceTimers.delete(filePath)
|
|
369
|
+
try {
|
|
370
|
+
const content = await node_fs.readFile(filePath, 'utf-8')
|
|
371
|
+
const rendered = render(content)
|
|
372
|
+
broadcastToAll({
|
|
373
|
+
type: 'update',
|
|
374
|
+
file: fileName,
|
|
375
|
+
html: rendered.html,
|
|
376
|
+
toc: rendered.toc,
|
|
377
|
+
})
|
|
378
|
+
} catch (err) {
|
|
379
|
+
broadcastToAll({
|
|
380
|
+
type: 'error',
|
|
381
|
+
file: fileName,
|
|
382
|
+
message: err.message,
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
}, 100))
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
watcher.on('add', (filePath) => {
|
|
389
|
+
if (!filePath.endsWith('.md')) return
|
|
390
|
+
const fileName = node_path.basename(filePath)
|
|
391
|
+
// Don't fire for initial files
|
|
392
|
+
broadcastToAll({ type: 'file-added', file: fileName })
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
watcher.on('unlink', (filePath) => {
|
|
396
|
+
if (!filePath.endsWith('.md')) return
|
|
397
|
+
const fileName = node_path.basename(filePath)
|
|
398
|
+
broadcastToAll({ type: 'file-removed', file: fileName })
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
// 8. Build return object
|
|
402
|
+
// Expose address() for compatibility with tests that call server.address()
|
|
403
|
+
const serverObj = {
|
|
404
|
+
url: `http://127.0.0.1:${actualPort}`,
|
|
405
|
+
port: actualPort,
|
|
406
|
+
address: () => httpServer.address(),
|
|
407
|
+
close: (cb) => {
|
|
408
|
+
// Clear all debounce timers
|
|
409
|
+
for (const timer of debounceTimers.values()) clearTimeout(timer)
|
|
410
|
+
debounceTimers.clear()
|
|
411
|
+
|
|
412
|
+
// Stop file watcher
|
|
413
|
+
watcher.close()
|
|
414
|
+
|
|
415
|
+
// Close all WebSocket connections
|
|
416
|
+
for (const client of wss.clients) {
|
|
417
|
+
client.terminate()
|
|
418
|
+
}
|
|
419
|
+
wss.close()
|
|
420
|
+
|
|
421
|
+
if (cb) {
|
|
422
|
+
httpServer.close(cb)
|
|
423
|
+
} else {
|
|
424
|
+
return new Promise((resolve) => httpServer.close(resolve))
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (once) {
|
|
430
|
+
// Create a promise that resolves when the user clicks "Finish Review"
|
|
431
|
+
let finishResolve
|
|
432
|
+
serverObj.finishPromise = new Promise(resolve => { finishResolve = resolve })
|
|
433
|
+
onFinishCallback = (result) => { finishResolve(result) }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return serverObj
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
// Request handler factory
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Create the HTTP request handler with access to resolved state.
|
|
445
|
+
*
|
|
446
|
+
* @param {object} ctx
|
|
447
|
+
* @param {string[]} ctx.resolvedFiles
|
|
448
|
+
* @param {string} ctx.assetBaseDir
|
|
449
|
+
* @param {boolean} ctx.once
|
|
450
|
+
* @param {string} [ctx.author]
|
|
451
|
+
* @returns {(req: node_http.IncomingMessage, res: node_http.ServerResponse) => void}
|
|
452
|
+
*/
|
|
453
|
+
function createRequestHandler({ resolvedFiles, assetBaseDir, once, author, getOnFinish, broadcast }) {
|
|
454
|
+
return async (req, res) => {
|
|
455
|
+
try {
|
|
456
|
+
const parsedUrl = new URL(req.url, `http://${req.headers.host}`)
|
|
457
|
+
const pathname = parsedUrl.pathname
|
|
458
|
+
|
|
459
|
+
// GET /
|
|
460
|
+
if (req.method === 'GET' && pathname === '/') {
|
|
461
|
+
return sendHTML(res, 200, SHELL_HTML)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// GET /api/files
|
|
465
|
+
if (req.method === 'GET' && pathname === '/api/files') {
|
|
466
|
+
const fileList = resolvedFiles.map((absPath) => ({
|
|
467
|
+
path: node_path.basename(absPath),
|
|
468
|
+
absPath,
|
|
469
|
+
label: node_path.basename(absPath, '.md'),
|
|
470
|
+
}))
|
|
471
|
+
return sendJSON(res, 200, fileList)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// GET /api/file?path=<path>
|
|
475
|
+
if (req.method === 'GET' && pathname === '/api/file') {
|
|
476
|
+
const queryPath = parsedUrl.searchParams.get('path')
|
|
477
|
+
if (!queryPath) {
|
|
478
|
+
return sendJSON(res, 400, { error: 'Missing ?path= parameter' })
|
|
479
|
+
}
|
|
480
|
+
const match = findFile(resolvedFiles, queryPath)
|
|
481
|
+
if (!match) {
|
|
482
|
+
return sendJSON(res, 404, { error: `File not found: ${queryPath}` })
|
|
483
|
+
}
|
|
484
|
+
const content = await node_fs.readFile(match, 'utf-8')
|
|
485
|
+
const rendered = render(content)
|
|
486
|
+
return sendJSON(res, 200, {
|
|
487
|
+
html: rendered.html,
|
|
488
|
+
toc: rendered.toc,
|
|
489
|
+
frontmatter: rendered.frontmatter,
|
|
490
|
+
})
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// GET /api/annotations?path=<path>
|
|
494
|
+
if (req.method === 'GET' && pathname === '/api/annotations') {
|
|
495
|
+
const queryPath = parsedUrl.searchParams.get('path')
|
|
496
|
+
if (!queryPath) {
|
|
497
|
+
return sendJSON(res, 400, { error: 'Missing ?path= parameter' })
|
|
498
|
+
}
|
|
499
|
+
const match = findFile(resolvedFiles, queryPath)
|
|
500
|
+
if (!match) {
|
|
501
|
+
return sendJSON(res, 404, { error: `File not found: ${queryPath}` })
|
|
502
|
+
}
|
|
503
|
+
// Attempt to load the sidecar annotation file
|
|
504
|
+
const sidecarPath = match.replace(/\.md$/, '.annotations.yaml')
|
|
505
|
+
let savedSections = []
|
|
506
|
+
let json
|
|
507
|
+
try {
|
|
508
|
+
const af = await AnnotationFile.load(sidecarPath)
|
|
509
|
+
json = af.toJSON()
|
|
510
|
+
savedSections = json.sections || []
|
|
511
|
+
// Check for drift
|
|
512
|
+
try {
|
|
513
|
+
const drift = await detectDrift(sidecarPath, match)
|
|
514
|
+
if (drift.drifted) json.drift = true
|
|
515
|
+
} catch { /* no drift info available */ }
|
|
516
|
+
} catch {
|
|
517
|
+
// No sidecar or unreadable
|
|
518
|
+
json = {
|
|
519
|
+
version: 1,
|
|
520
|
+
source: node_path.basename(match),
|
|
521
|
+
source_hash: null,
|
|
522
|
+
sections: [],
|
|
523
|
+
annotations: [],
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
// Always derive sections from current document headings
|
|
527
|
+
const merged = await mergeSections(match, savedSections)
|
|
528
|
+
json.sections = computeSectionStatus(merged.sections)
|
|
529
|
+
json.sectionLevel = merged.sectionLevel
|
|
530
|
+
return sendJSON(res, 200, json)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// POST /api/annotations — CRUD operations
|
|
534
|
+
if (req.method === 'POST' && pathname === '/api/annotations') {
|
|
535
|
+
const body = await readBody(req)
|
|
536
|
+
const { file: fileName, action, data } = body
|
|
537
|
+
const match = findFile(resolvedFiles, fileName)
|
|
538
|
+
if (!match) return sendJSON(res, 404, { error: `File not found: ${fileName}` })
|
|
539
|
+
|
|
540
|
+
const sidecarPath = match.replace(/\.md$/, '.annotations.yaml')
|
|
541
|
+
let af
|
|
542
|
+
try {
|
|
543
|
+
af = await AnnotationFile.load(sidecarPath)
|
|
544
|
+
} catch {
|
|
545
|
+
const { hashContent } = await import('./hash.js')
|
|
546
|
+
const content = await node_fs.readFile(match, 'utf-8')
|
|
547
|
+
af = AnnotationFile.create(node_path.basename(match), `sha256:${hashContent(content)}`)
|
|
548
|
+
}
|
|
549
|
+
// Sync sections with current document headings
|
|
550
|
+
const mergedAnn = await mergeSections(match, af.sections || [])
|
|
551
|
+
af.sections = mergedAnn.sections
|
|
552
|
+
|
|
553
|
+
switch (action) {
|
|
554
|
+
case 'add':
|
|
555
|
+
af.add(data)
|
|
556
|
+
break
|
|
557
|
+
case 'resolve':
|
|
558
|
+
af.resolve(data.id)
|
|
559
|
+
break
|
|
560
|
+
case 'reopen':
|
|
561
|
+
af.reopen(data.id)
|
|
562
|
+
break
|
|
563
|
+
case 'update':
|
|
564
|
+
if (data.comment) af.updateComment(data.id, data.comment)
|
|
565
|
+
if (data.tag) af.updateTag(data.id, data.tag)
|
|
566
|
+
break
|
|
567
|
+
case 'delete':
|
|
568
|
+
af.delete(data.id)
|
|
569
|
+
break
|
|
570
|
+
case 'reply':
|
|
571
|
+
af.addReply(data.id, { author: data.author, comment: data.comment })
|
|
572
|
+
break
|
|
573
|
+
default:
|
|
574
|
+
return sendJSON(res, 400, { error: `Unknown action: ${action}` })
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
await af.save(sidecarPath)
|
|
578
|
+
const annJson = af.toJSON()
|
|
579
|
+
annJson.sections = computeSectionStatus(annJson.sections || [])
|
|
580
|
+
annJson.sectionLevel = mergedAnn.sectionLevel
|
|
581
|
+
if (broadcast) {
|
|
582
|
+
broadcast({
|
|
583
|
+
type: 'annotations',
|
|
584
|
+
file: node_path.basename(match),
|
|
585
|
+
annotations: annJson.annotations,
|
|
586
|
+
sections: annJson.sections,
|
|
587
|
+
})
|
|
588
|
+
}
|
|
589
|
+
return sendJSON(res, 200, annJson)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// POST /api/sections — section approval
|
|
593
|
+
if (req.method === 'POST' && pathname === '/api/sections') {
|
|
594
|
+
const body = await readBody(req)
|
|
595
|
+
const { file: fileName, action, heading } = body
|
|
596
|
+
const match = findFile(resolvedFiles, fileName)
|
|
597
|
+
if (!match) return sendJSON(res, 404, { error: `File not found: ${fileName}` })
|
|
598
|
+
|
|
599
|
+
const sidecarPath = match.replace(/\.md$/, '.annotations.yaml')
|
|
600
|
+
let af
|
|
601
|
+
try {
|
|
602
|
+
af = await AnnotationFile.load(sidecarPath)
|
|
603
|
+
} catch {
|
|
604
|
+
const { hashContent } = await import('./hash.js')
|
|
605
|
+
const content = await node_fs.readFile(match, 'utf-8')
|
|
606
|
+
af = AnnotationFile.create(node_path.basename(match), `sha256:${hashContent(content)}`)
|
|
607
|
+
}
|
|
608
|
+
// Sync sections with current document headings
|
|
609
|
+
const mergedSec = await mergeSections(match, af.sections || [])
|
|
610
|
+
af.sections = mergedSec.sections
|
|
611
|
+
|
|
612
|
+
switch (action) {
|
|
613
|
+
case 'approve':
|
|
614
|
+
af.approveSection(heading)
|
|
615
|
+
break
|
|
616
|
+
case 'reject':
|
|
617
|
+
af.rejectSection(heading)
|
|
618
|
+
break
|
|
619
|
+
case 'reset':
|
|
620
|
+
af.resetSection(heading)
|
|
621
|
+
break
|
|
622
|
+
case 'approveAll':
|
|
623
|
+
af.approveAll()
|
|
624
|
+
break
|
|
625
|
+
case 'clearAll':
|
|
626
|
+
af.clearAll()
|
|
627
|
+
break
|
|
628
|
+
default:
|
|
629
|
+
return sendJSON(res, 400, { error: `Unknown section action: ${action}` })
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
await af.save(sidecarPath)
|
|
633
|
+
const sectionsResult = computeSectionStatus(af.sections)
|
|
634
|
+
if (broadcast) {
|
|
635
|
+
broadcast({
|
|
636
|
+
type: 'annotations',
|
|
637
|
+
file: node_path.basename(match),
|
|
638
|
+
annotations: af.toJSON().annotations,
|
|
639
|
+
sections: sectionsResult,
|
|
640
|
+
})
|
|
641
|
+
}
|
|
642
|
+
return sendJSON(res, 200, { sections: sectionsResult, sectionLevel: mergedSec.sectionLevel })
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// GET /api/export — export annotations
|
|
646
|
+
if (req.method === 'GET' && pathname === '/api/export') {
|
|
647
|
+
const queryPath = parsedUrl.searchParams.get('path')
|
|
648
|
+
const format = parsedUrl.searchParams.get('format')
|
|
649
|
+
if (!queryPath) return sendJSON(res, 400, { error: 'Missing ?path= parameter' })
|
|
650
|
+
const match = findFile(resolvedFiles, queryPath)
|
|
651
|
+
if (!match) return sendJSON(res, 404, { error: `File not found: ${queryPath}` })
|
|
652
|
+
|
|
653
|
+
const { exportReport, exportInline, exportJSON, exportSARIF } = await import('./export.js')
|
|
654
|
+
const sourceContent = await node_fs.readFile(match, 'utf-8')
|
|
655
|
+
|
|
656
|
+
const sidecarPath = match.replace(/\.md$/, '.annotations.yaml')
|
|
657
|
+
let af
|
|
658
|
+
try {
|
|
659
|
+
af = await AnnotationFile.load(sidecarPath)
|
|
660
|
+
} catch {
|
|
661
|
+
af = AnnotationFile.create(node_path.basename(match), '')
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
switch (format) {
|
|
665
|
+
case 'report': {
|
|
666
|
+
const report = exportReport(af, sourceContent)
|
|
667
|
+
res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' })
|
|
668
|
+
return res.end(report)
|
|
669
|
+
}
|
|
670
|
+
case 'inline': {
|
|
671
|
+
const inline = exportInline(af, sourceContent)
|
|
672
|
+
res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' })
|
|
673
|
+
return res.end(inline)
|
|
674
|
+
}
|
|
675
|
+
case 'json': {
|
|
676
|
+
const json = exportJSON(af)
|
|
677
|
+
return sendJSON(res, 200, json)
|
|
678
|
+
}
|
|
679
|
+
case 'sarif': {
|
|
680
|
+
const sarif = exportSARIF(af, queryPath)
|
|
681
|
+
return sendJSON(res, 200, sarif)
|
|
682
|
+
}
|
|
683
|
+
default:
|
|
684
|
+
return sendJSON(res, 400, { error: `Unknown export format: ${format}` })
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// GET /api/config
|
|
689
|
+
if (req.method === 'GET' && pathname === '/api/config') {
|
|
690
|
+
return sendJSON(res, 200, { author: author || 'anonymous' })
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// GET /api/review/status (only in once mode)
|
|
694
|
+
if (req.method === 'GET' && pathname === '/api/review/status') {
|
|
695
|
+
if (once) {
|
|
696
|
+
return sendJSON(res, 200, { mode: 'once' })
|
|
697
|
+
}
|
|
698
|
+
return send404(res)
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// POST /api/review/finish — signal that review is complete (--once mode)
|
|
702
|
+
if (req.method === 'POST' && pathname === '/api/review/finish') {
|
|
703
|
+
if (!once) return sendJSON(res, 400, { error: 'Not in review mode' })
|
|
704
|
+
// Collect annotation file paths
|
|
705
|
+
const yamlPaths = []
|
|
706
|
+
for (const f of resolvedFiles) {
|
|
707
|
+
const sidecar = f.replace(/\.md$/, '.annotations.yaml')
|
|
708
|
+
try {
|
|
709
|
+
await node_fs.stat(sidecar)
|
|
710
|
+
yamlPaths.push(sidecar)
|
|
711
|
+
} catch { /* no sidecar */ }
|
|
712
|
+
}
|
|
713
|
+
const onFinish = getOnFinish()
|
|
714
|
+
if (typeof onFinish === 'function') {
|
|
715
|
+
onFinish({ files: resolvedFiles, yamlPaths })
|
|
716
|
+
}
|
|
717
|
+
return sendJSON(res, 200, { status: 'finished', yamlPaths })
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// GET /assets/* — serve built UI assets first, then markdown file assets
|
|
721
|
+
if (req.method === 'GET' && pathname.startsWith('/assets/')) {
|
|
722
|
+
const relativePath = pathname.slice('/assets/'.length)
|
|
723
|
+
// Prevent directory traversal
|
|
724
|
+
const normalized = node_path.normalize(relativePath)
|
|
725
|
+
if (normalized.startsWith('..') || node_path.isAbsolute(normalized)) {
|
|
726
|
+
return send404(res)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Try built UI assets (dist/assets/) first
|
|
730
|
+
const distPath = node_path.join(DIST_DIR, 'assets', normalized)
|
|
731
|
+
try {
|
|
732
|
+
const data = await node_fs.readFile(distPath)
|
|
733
|
+
const ext = node_path.extname(distPath)
|
|
734
|
+
res.writeHead(200, {
|
|
735
|
+
'Content-Type': getMimeType(ext),
|
|
736
|
+
'Content-Length': data.length,
|
|
737
|
+
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
738
|
+
})
|
|
739
|
+
return res.end(data)
|
|
740
|
+
} catch {
|
|
741
|
+
// Fall through to markdown assets
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Try markdown directory assets (images, etc.)
|
|
745
|
+
const filePath = node_path.join(assetBaseDir, normalized)
|
|
746
|
+
try {
|
|
747
|
+
const data = await node_fs.readFile(filePath)
|
|
748
|
+
const ext = node_path.extname(filePath)
|
|
749
|
+
res.writeHead(200, {
|
|
750
|
+
'Content-Type': getMimeType(ext),
|
|
751
|
+
'Content-Length': data.length,
|
|
752
|
+
})
|
|
753
|
+
return res.end(data)
|
|
754
|
+
} catch {
|
|
755
|
+
return send404(res)
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Fallback — 404
|
|
760
|
+
send404(res)
|
|
761
|
+
} catch (err) {
|
|
762
|
+
// Unexpected error
|
|
763
|
+
sendJSON(res, 500, { error: err.message })
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// ---------------------------------------------------------------------------
|
|
769
|
+
// File matching helper
|
|
770
|
+
// ---------------------------------------------------------------------------
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Derive current sections from the markdown TOC headings and merge with saved
|
|
774
|
+
* approval statuses from the sidecar. This ensures sections always reflect
|
|
775
|
+
* the current document, even when headings have been added or removed.
|
|
776
|
+
*
|
|
777
|
+
* @param {string} mdPath - Absolute path to the markdown file
|
|
778
|
+
* @param {Array<{heading: string, level: number, status: string}>} [savedSections]
|
|
779
|
+
* @returns {Promise<{sections: Array, sectionLevel: number}>}
|
|
780
|
+
*/
|
|
781
|
+
async function mergeSections(mdPath, savedSections = []) {
|
|
782
|
+
const content = await node_fs.readFile(mdPath, 'utf-8')
|
|
783
|
+
const { toc } = render(content)
|
|
784
|
+
|
|
785
|
+
// Match saved sections to current TOC by position order.
|
|
786
|
+
// For each TOC entry, consume the first matching saved section (same heading+level).
|
|
787
|
+
// This correctly handles duplicate headings by preserving their order.
|
|
788
|
+
const savedPool = savedSections.map(s => ({ ...s })) // shallow copy to consume
|
|
789
|
+
|
|
790
|
+
const sections = toc.map(h => {
|
|
791
|
+
const idx = savedPool.findIndex(s => s.heading === h.heading && (s.level == null || s.level === h.level))
|
|
792
|
+
let status = 'pending'
|
|
793
|
+
if (idx !== -1) {
|
|
794
|
+
status = savedPool[idx].status || 'pending'
|
|
795
|
+
savedPool.splice(idx, 1) // consume this match
|
|
796
|
+
}
|
|
797
|
+
return { heading: h.heading, level: h.level, status }
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
// Adaptive section level: shallowest level appearing 2+ times
|
|
801
|
+
const levelCounts = new Map()
|
|
802
|
+
for (const h of toc) {
|
|
803
|
+
levelCounts.set(h.level, (levelCounts.get(h.level) || 0) + 1)
|
|
804
|
+
}
|
|
805
|
+
let sectionLevel = 2 // default fallback
|
|
806
|
+
for (let lvl = 1; lvl <= 6; lvl++) {
|
|
807
|
+
if ((levelCounts.get(lvl) || 0) >= 2) {
|
|
808
|
+
sectionLevel = lvl
|
|
809
|
+
break
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
return { sections, sectionLevel }
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Find a resolved file matching a query path.
|
|
818
|
+
*
|
|
819
|
+
* The query may be:
|
|
820
|
+
* - a bare filename: `spec.md`
|
|
821
|
+
* - a relative path: `docs/spec.md`
|
|
822
|
+
* - an absolute path: `/tmp/.../spec.md`
|
|
823
|
+
*
|
|
824
|
+
* @param {string[]} resolvedFiles - Absolute file paths
|
|
825
|
+
* @param {string} queryPath - Path from the query string
|
|
826
|
+
* @returns {string|null} Matched absolute path, or null
|
|
827
|
+
*/
|
|
828
|
+
function findFile(resolvedFiles, queryPath) {
|
|
829
|
+
// Try exact absolute match first
|
|
830
|
+
const absQuery = node_path.resolve(queryPath)
|
|
831
|
+
const exactMatch = resolvedFiles.find((f) => f === absQuery)
|
|
832
|
+
if (exactMatch) return exactMatch
|
|
833
|
+
|
|
834
|
+
// Try matching by basename
|
|
835
|
+
const baseMatch = resolvedFiles.find(
|
|
836
|
+
(f) => node_path.basename(f) === queryPath,
|
|
837
|
+
)
|
|
838
|
+
if (baseMatch) return baseMatch
|
|
839
|
+
|
|
840
|
+
// Try matching by path suffix (relative path match)
|
|
841
|
+
// Require path separator boundary to prevent partial matches (e.g., "spec.md" matching "myspec.md")
|
|
842
|
+
const normalizedQuery = queryPath.replace(/^\/+/, '')
|
|
843
|
+
const suffixMatch = resolvedFiles.find(
|
|
844
|
+
(f) => f.endsWith('/' + normalizedQuery) || f === normalizedQuery,
|
|
845
|
+
)
|
|
846
|
+
if (suffixMatch) return suffixMatch
|
|
847
|
+
|
|
848
|
+
return null
|
|
849
|
+
}
|