@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/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
+ }