mdv-live 0.5.5 → 0.5.9

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/bin/mdv.js CHANGED
@@ -16,66 +16,39 @@ import { parseArgs } from 'node:util';
16
16
  import open from 'open';
17
17
 
18
18
  import { createMdvServer } from '../src/server.js';
19
+ import { resolvePdfOptions, resolveStyle } from '../src/styles/index.js';
19
20
 
20
21
  const DEFAULT_PORT = 8642;
21
22
  const MARP_FRONTMATTER_PATTERN = /^---\s*\n[\s\S]*?marp:\s*true[\s\S]*?\n---/;
22
23
 
23
- const OPTIONS = {
24
- port: {
25
- type: 'string',
26
- short: 'p',
27
- },
28
- depth: {
29
- type: 'string',
30
- short: 'd',
31
- },
32
- 'no-browser': {
33
- type: 'boolean',
34
- default: false
35
- },
36
- list: {
37
- type: 'boolean',
38
- short: 'l',
39
- default: false
40
- },
41
- kill: {
42
- type: 'boolean',
43
- short: 'k',
44
- default: false
45
- },
46
- all: {
47
- type: 'boolean',
48
- short: 'a',
49
- default: false
50
- },
51
- pdf: {
52
- type: 'boolean',
53
- default: false
54
- },
55
- output: {
56
- type: 'string',
57
- short: 'o',
58
- },
59
- help: {
60
- type: 'boolean',
61
- short: 'h',
62
- default: false
63
- },
64
- version: {
65
- type: 'boolean',
66
- short: 'v',
67
- default: false
68
- }
24
+ const VIEWER_OPTIONS = {
25
+ port: { type: 'string', short: 'p' },
26
+ depth: { type: 'string', short: 'd' },
27
+ 'no-browser': { type: 'boolean', default: false },
28
+ list: { type: 'boolean', short: 'l', default: false },
29
+ kill: { type: 'boolean', short: 'k', default: false },
30
+ all: { type: 'boolean', short: 'a', default: false },
31
+ help: { type: 'boolean', short: 'h', default: false },
32
+ version: { type: 'boolean', short: 'v', default: false },
33
+ };
34
+
35
+ const CONVERT_OPTIONS = {
36
+ input: { type: 'string', short: 'i' },
37
+ output: { type: 'string', short: 'o' },
38
+ style: { type: 'string', short: 's' },
39
+ 'pdf-options': { type: 'string' },
40
+ help: { type: 'boolean', short: 'h', default: false },
69
41
  };
70
42
 
71
43
  /**
72
- * Display help message
44
+ * Display viewer help message
73
45
  */
74
46
  function showHelp() {
75
47
  console.log(`
76
48
  MDV - Markdown Viewer with file tree + live preview + Marp support
77
49
 
78
50
  Usage: mdv [options] [path]
51
+ mdv convert -i <file.md> -o <file.pdf>
79
52
 
80
53
  Arguments:
81
54
  path Directory or file path to view (default: current directory)
@@ -90,22 +63,42 @@ Server Management:
90
63
  -k, --kill [PID] Stop server (-k -a for all, -k <PID> for specific)
91
64
  -a, --all Use with -k to stop all servers
92
65
 
93
- PDF Conversion:
94
- --pdf Convert markdown file to PDF
95
- -o, --output <file> Output PDF file path
96
-
97
66
  Other:
98
67
  -h, --help Show this help message
99
68
  -v, --version Show version number
100
69
 
101
70
  Examples:
102
- mdv Start viewer in current directory
103
- mdv /path/to/dir Start viewer in specified directory
104
- mdv README.md Open specific file
105
- mdv --pdf README.md Convert markdown to PDF
106
- mdv -p 3000 Start on port 3000
107
- mdv -l List running servers
108
- mdv -k -a Stop all servers
71
+ mdv Start viewer in current directory
72
+ mdv /path/to/dir Start viewer in specified directory
73
+ mdv README.md Open specific file
74
+ mdv convert -i s.md -o s.pdf Convert markdown to PDF
75
+ mdv -p 3000 Start on port 3000
76
+ mdv -l List running servers
77
+ mdv -k -a Stop all servers
78
+ `);
79
+ }
80
+
81
+ /**
82
+ * Display convert subcommand help message
83
+ */
84
+ function showConvertHelp() {
85
+ console.log(`
86
+ MDV convert - Convert markdown to PDF
87
+
88
+ Usage: mdv convert -i <input.md> -o <output.pdf> [options]
89
+
90
+ Options:
91
+ -i, --input <file> Input markdown file (.md or .markdown)
92
+ -o, --output <file> Output PDF file (default: same name as input)
93
+ -s, --style <preset> Built-in preset or custom CSS file path
94
+ Built-in presets: default
95
+ --pdf-options <file> JSON file with Puppeteer PDF options
96
+ -h, --help Show this help message
97
+
98
+ Examples:
99
+ mdv convert -i slide.md -o slide.pdf
100
+ mdv convert -i README.md -s ./src/styles/report.example.css --pdf-options ./src/styles/report.pdf-options.example.json
101
+ mdv convert -i doc.md -o out.pdf -s ./my-style.css
109
102
  `);
110
103
  }
111
104
 
@@ -244,13 +237,15 @@ function isMarpFile(content) {
244
237
 
245
238
  /**
246
239
  * Convert markdown to PDF using appropriate tool
247
- * - Marp slides: use marp-cli
248
- * - Regular markdown: use md-to-pdf for A4 document format
240
+ * - Marp slides: use marp-cli (style option ignored)
241
+ * - Regular markdown: use md-to-pdf with optional style preset
249
242
  * @param {string} inputPath - Input markdown file path
250
243
  * @param {string} [outputPath] - Output PDF file path
244
+ * @param {string} [styleArg] - Style preset name or CSS file path
245
+ * @param {string} [pdfOptionsPath] - JSON file with Puppeteer PDF options
251
246
  * @returns {Promise<number>} Exit code (0 = success, 1 = error)
252
247
  */
253
- async function convertToPdf(inputPath, outputPath) {
248
+ async function convertToPdf(inputPath, outputPath, styleArg, pdfOptionsPath) {
254
249
  const resolved = path.resolve(inputPath);
255
250
 
256
251
  const fileExists = await fs.access(resolved).then(() => true).catch(() => false);
@@ -275,7 +270,20 @@ async function convertToPdf(inputPath, outputPath) {
275
270
  if (isMarp) {
276
271
  return convertMarpToPdf(resolved, finalOutput);
277
272
  }
278
- return convertMarkdownToPdf(resolved, finalOutput);
273
+
274
+ let styleConfig;
275
+ try {
276
+ styleConfig = await resolveStyle(styleArg);
277
+ styleConfig = {
278
+ ...styleConfig,
279
+ pdfOptions: await resolvePdfOptions(pdfOptionsPath, styleConfig.pdfOptions),
280
+ };
281
+ } catch {
282
+ console.error(`Error: Style or PDF options not found: ${styleArg || pdfOptionsPath}`);
283
+ return 1;
284
+ }
285
+
286
+ return convertMarkdownToPdf(resolved, finalOutput, styleConfig);
279
287
  }
280
288
 
281
289
  /**
@@ -299,20 +307,35 @@ async function convertMarpToPdf(inputPath, outputPath) {
299
307
  }
300
308
 
301
309
  /**
302
- * Convert regular markdown to PDF using md-to-pdf (A4 format)
310
+ * Convert regular markdown to PDF using md-to-pdf
303
311
  * @param {string} inputPath - Resolved input file path
304
312
  * @param {string} outputPath - Resolved output file path
313
+ * @param {import('../src/styles/index.js').StyleConfig} styleConfig - Style preset
305
314
  * @returns {Promise<number>} Exit code
306
315
  */
307
- async function convertMarkdownToPdf(inputPath, outputPath) {
316
+ async function convertMarkdownToPdf(inputPath, outputPath, styleConfig) {
308
317
  console.log('Converting as document (A4 portrait)...');
309
318
 
310
319
  try {
311
- const pdfOptions = '{"format":"A4","margin":{"top":"20mm","right":"20mm","bottom":"20mm","left":"20mm"}}';
312
- execFileSync('npx', ['md-to-pdf', inputPath, '--pdf-options', pdfOptions], {
320
+ const args = ['md-to-pdf', inputPath, '--pdf-options', JSON.stringify(styleConfig.pdfOptions)];
321
+ const stylesheetPaths = styleConfig.stylesheets ?? (styleConfig.stylesheet ? [styleConfig.stylesheet] : []);
322
+
323
+ for (const stylesheetPath of stylesheetPaths) {
324
+ args.push('--stylesheet', stylesheetPath);
325
+ }
326
+
327
+ if (styleConfig.highlightStyle) {
328
+ args.push('--highlight-style', styleConfig.highlightStyle);
329
+ }
330
+
331
+ if (styleConfig.css) {
332
+ args.push('--css', styleConfig.css);
333
+ }
334
+
335
+ execFileSync('npx', args, {
313
336
  encoding: 'utf-8',
314
337
  stdio: 'inherit',
315
- cwd: path.dirname(inputPath)
338
+ cwd: path.dirname(inputPath),
316
339
  });
317
340
 
318
341
  // md-to-pdf outputs to same directory with .pdf extension
@@ -431,15 +454,34 @@ async function startViewer(targetPath, startPort, openBrowser, depth) {
431
454
  }
432
455
 
433
456
  /**
434
- * Parse command line arguments safely
457
+ * Parse arguments for the convert subcommand
435
458
  * @returns {{values: object, positionals: string[]}}
436
459
  */
437
- function parseCommandLineArgs() {
460
+ function parseConvertArgs() {
438
461
  try {
439
462
  return parseArgs({
440
- options: OPTIONS,
463
+ args: process.argv.slice(3), // skip node, mdv.js, "convert"
464
+ options: CONVERT_OPTIONS,
465
+ allowPositionals: false,
466
+ strict: false,
467
+ });
468
+ } catch (err) {
469
+ console.error('Error parsing arguments:', err.message);
470
+ showConvertHelp();
471
+ process.exit(1);
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Parse viewer command line arguments safely
477
+ * @returns {{values: object, positionals: string[]}}
478
+ */
479
+ function parseViewerArgs() {
480
+ try {
481
+ return parseArgs({
482
+ options: VIEWER_OPTIONS,
441
483
  allowPositionals: true,
442
- strict: false
484
+ strict: false,
443
485
  });
444
486
  } catch (err) {
445
487
  console.error('Error parsing arguments:', err.message);
@@ -448,11 +490,38 @@ function parseCommandLineArgs() {
448
490
  }
449
491
  }
450
492
 
493
+ /**
494
+ * Handle the convert subcommand
495
+ */
496
+ async function runConvert() {
497
+ const { values } = parseConvertArgs();
498
+
499
+ if (values.help) {
500
+ showConvertHelp();
501
+ process.exit(0);
502
+ }
503
+
504
+ if (!values.input) {
505
+ console.error('Error: -i <file.md> is required');
506
+ showConvertHelp();
507
+ process.exit(1);
508
+ }
509
+
510
+ process.exit(await convertToPdf(values.input, values.output, values.style, values['pdf-options']));
511
+ }
512
+
451
513
  /**
452
514
  * Main entry point
453
515
  */
454
516
  async function main() {
455
- const { values, positionals } = parseCommandLineArgs();
517
+ const subcommand = process.argv[2];
518
+
519
+ if (subcommand === 'convert') {
520
+ await runConvert();
521
+ return;
522
+ }
523
+
524
+ const { values, positionals } = parseViewerArgs();
456
525
 
457
526
  if (values.help) {
458
527
  showHelp();
@@ -477,15 +546,6 @@ async function main() {
477
546
  process.exit(killServers(pid, values.all));
478
547
  }
479
548
 
480
- if (values.pdf) {
481
- const inputPath = positionals[0];
482
- if (!inputPath) {
483
- console.error('Error: --pdf requires a markdown file path');
484
- process.exit(1);
485
- }
486
- process.exit(await convertToPdf(inputPath, values.output));
487
- }
488
-
489
549
  // Default: start viewer
490
550
  const targetPath = positionals[0] || '.';
491
551
  const port = parseInt(values.port, 10) || DEFAULT_PORT;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdv-live",
3
- "version": "0.5.5",
3
+ "version": "0.5.9",
4
4
  "description": "Markdown Viewer - File tree + Live preview + Marp support + Hot reload",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -45,16 +45,17 @@
45
45
  "LICENSE"
46
46
  ],
47
47
  "dependencies": {
48
- "express": "^4.21.2",
49
- "ws": "^8.18.0",
48
+ "@marp-team/marp-core": "^4.0.0",
50
49
  "chokidar": "^4.0.3",
50
+ "express": "^4.21.2",
51
+ "highlight.js": "^11.10.0",
51
52
  "markdown-it": "^14.1.0",
52
53
  "markdown-it-task-lists": "^2.1.1",
53
- "@marp-team/marp-core": "^4.0.0",
54
- "highlight.js": "^11.10.0",
55
- "open": "^10.1.0",
54
+ "md-to-pdf": "^5.2.5",
56
55
  "mime-types": "^2.1.35",
57
- "multer": "^1.4.5-lts.1"
56
+ "multer": "^1.4.5-lts.1",
57
+ "open": "^10.1.0",
58
+ "ws": "^8.18.0"
58
59
  },
59
60
  "optionalDependencies": {
60
61
  "@marp-team/marp-cli": "^4.0.3"
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Common guards / preconditions for the marpNote endpoints.
3
+ *
4
+ * Each function returns an Error (with `.code`) on rejection or `null` on
5
+ * success. The caller then uses `sendError(res, err)` from utils/errors.js.
6
+ */
7
+
8
+ import { mkError } from '../../utils/errors.js';
9
+ import { validateNoteText } from '../../rendering/marpNoteWriter.js';
10
+
11
+ const MAX_SLIDE_INDEX = 1000;
12
+
13
+ export function buildAllowedHosts(port) {
14
+ return [`localhost:${port}`, `127.0.0.1:${port}`];
15
+ }
16
+
17
+ /** Origin / Sec-Fetch-Site judgement (CSRF / DNS rebinding defence). */
18
+ export function checkOrigin(req, allowedHosts) {
19
+ const origin = req.get('Origin');
20
+ if (origin) {
21
+ for (const host of allowedHosts) {
22
+ if (origin === `http://${host}`) return null;
23
+ }
24
+ return mkError('ORIGIN_REJECTED', 'origin not allowed');
25
+ }
26
+ if (req.get('Sec-Fetch-Site') === 'same-origin') return null;
27
+ return mkError('ORIGIN_REJECTED', 'origin not allowed');
28
+ }
29
+
30
+ export function checkHost(req, allowedHosts) {
31
+ const host = req.get('Host');
32
+ if (host && allowedHosts.includes(host)) return null;
33
+ return mkError('ORIGIN_REJECTED', 'host header not allowed');
34
+ }
35
+
36
+ export function checkJsonContent(req) {
37
+ const ct = (req.get('Content-Type') || '').split(';')[0].trim().toLowerCase();
38
+ if (ct === 'application/json') return null;
39
+ return mkError('UNSUPPORTED_MEDIA_TYPE', 'Content-Type must be application/json');
40
+ }
41
+
42
+ export function checkIfMatch(req) {
43
+ if (req.get('If-Match')) return null;
44
+ return mkError('IF_MATCH_REQUIRED', 'If-Match header required');
45
+ }
46
+
47
+ export function parseSlideIndex(req) {
48
+ const n = Number(req.params.slideIndex);
49
+ if (!Number.isInteger(n) || n < 0 || n >= MAX_SLIDE_INDEX) {
50
+ return { error: mkError('OUT_OF_RANGE', 'slideIndex out of range') };
51
+ }
52
+ return { value: n };
53
+ }
54
+
55
+ export function sanitiseRelativePath(decoded) {
56
+ // Express already decoded :encodedPath route param; do not decode again.
57
+ if (typeof decoded !== 'string') return null;
58
+ if (decoded.length === 0 || decoded.length > 1024) return null;
59
+ if (decoded.includes('\0')) return null;
60
+ return decoded;
61
+ }
62
+
63
+ /** Pull and validate the `note` field from the request body. */
64
+ export function extractNote(req) {
65
+ const body = req.body;
66
+ if (!body || typeof body !== 'object') {
67
+ return { error: mkError('INVALID_NOTE', 'body must be JSON object') };
68
+ }
69
+ if (!Object.prototype.hasOwnProperty.call(body, 'note')) {
70
+ return { error: mkError('INVALID_NOTE', 'note required') };
71
+ }
72
+ const note = body.note;
73
+ if (typeof note !== 'string') {
74
+ return { error: mkError('INVALID_NOTE', 'note must be string') };
75
+ }
76
+ const reason = validateNoteText(note);
77
+ if (reason) return { error: mkError('INVALID_NOTE', reason) };
78
+ return { value: note };
79
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * GET /api/marp/decks/:encodedPath — read-only deck snapshot.
3
+ */
4
+
5
+ import { parseDeck, isMarp, renderDeck } from '../../rendering/marpitAdapter.js';
6
+ import { analyseSource } from '../../utils/lineMath.js';
7
+ import { mkError, sendError } from '../../utils/errors.js';
8
+ import { makeEtag } from '../../utils/etag.js';
9
+ import { checkHost, sanitiseRelativePath } from './guards.js';
10
+ import { readDeckSafely } from './readDeck.js';
11
+
12
+ export function makeGetHandler({ rootDir, allowedHosts }) {
13
+ return async function handleGet(req, res) {
14
+ const hostErr = checkHost(req, allowedHosts);
15
+ if (hostErr) return sendError(res, hostErr);
16
+ res.setHeader('Cache-Control', 'no-store');
17
+
18
+ const rel = sanitiseRelativePath(req.params.encodedPath);
19
+ if (!rel) return sendError(res, mkError('PATH_INVALID', 'invalid path'));
20
+
21
+ let deck;
22
+ try {
23
+ deck = await readDeckSafely(rootDir(), rel);
24
+ } catch (err) {
25
+ if (err.code === 'PATH_INVALID' || err.code === 'NOT_FOUND') return sendError(res, err);
26
+ console.error('marpNote GET read error:', err);
27
+ return sendError(res, mkError('READ_FAILED', 'read failed', { cause: err }));
28
+ }
29
+
30
+ if (!isMarp(deck.rawSource)) {
31
+ return sendError(res, mkError('NOT_MARP', 'not a Marp file'));
32
+ }
33
+
34
+ const lineInfo = analyseSource(deck.rawSource);
35
+ const etag = makeEtag(deck.rawSource);
36
+
37
+ let parsed;
38
+ try {
39
+ parsed = parseDeck(deck.rawSource);
40
+ } catch (err) {
41
+ // Adapter contract broken — degrade to read-only (etag null).
42
+ return res.json({
43
+ ok: true,
44
+ degraded: true,
45
+ etag: null,
46
+ slideCount: 0,
47
+ notes: [],
48
+ notesMultiplicity: [],
49
+ lineEnding: lineInfo.lineEnding,
50
+ hasBom: lineInfo.hasBom
51
+ });
52
+ }
53
+
54
+ const { notes } = renderDeck(deck.rawSource);
55
+ return res.json({
56
+ ok: true,
57
+ etag,
58
+ slideCount: parsed.slideCount,
59
+ notes,
60
+ notesMultiplicity: parsed.notesMultiplicity,
61
+ lineEnding: lineInfo.lineEnding,
62
+ hasBom: lineInfo.hasBom
63
+ });
64
+ };
65
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * PUT /api/marp/decks/:encodedPath/slides/:slideIndex/note
3
+ *
4
+ * Optimistic locking via If-Match (sha256 etag) + per-path async mutex
5
+ * for read-check-write atomicity within this process.
6
+ */
7
+
8
+ import * as fs from 'node:fs/promises';
9
+ import * as path from 'node:path';
10
+
11
+ import { parseDeck, isMarp, renderDeck } from '../../rendering/marpitAdapter.js';
12
+ import { rewriteSlideNote } from '../../rendering/marpNoteWriter.js';
13
+ import { analyseSource } from '../../utils/lineMath.js';
14
+ import { atomicWrite } from '../../utils/atomicWrite.js';
15
+ import { mkError, sendError } from '../../utils/errors.js';
16
+ import { makeEtag } from '../../utils/etag.js';
17
+ import { withLock } from '../../concurrency/pathLock.js';
18
+ import {
19
+ checkHost, checkOrigin, checkJsonContent, checkIfMatch,
20
+ parseSlideIndex, sanitiseRelativePath, extractNote
21
+ } from './guards.js';
22
+ import { readDeckSafely } from './readDeck.js';
23
+
24
+ export function makePutHandler({ rootDir, allowedHosts }) {
25
+ return async function handlePut(req, res) {
26
+ const guards = [
27
+ checkHost(req, allowedHosts),
28
+ checkOrigin(req, allowedHosts),
29
+ checkJsonContent(req),
30
+ checkIfMatch(req)
31
+ ];
32
+ for (const err of guards) if (err) return sendError(res, err);
33
+
34
+ const idx = parseSlideIndex(req);
35
+ if (idx.error) return sendError(res, idx.error);
36
+ const rel = sanitiseRelativePath(req.params.encodedPath);
37
+ if (!rel) return sendError(res, mkError('PATH_INVALID', 'invalid path'));
38
+ const noteIn = extractNote(req);
39
+ if (noteIn.error) return sendError(res, noteIn.error);
40
+
41
+ const ifMatch = req.get('If-Match');
42
+
43
+ // Resolve realpath BEFORE acquiring the lock so requests against the
44
+ // same file (via different relative paths) share the same lock key.
45
+ let earlyDeck;
46
+ try {
47
+ earlyDeck = await readDeckSafely(rootDir(), rel);
48
+ } catch (err) {
49
+ if (err.code === 'PATH_INVALID' || err.code === 'NOT_FOUND') return sendError(res, err);
50
+ console.error('marpNote PUT read error:', err);
51
+ return sendError(res, mkError('READ_FAILED', 'read failed', { cause: err }));
52
+ }
53
+
54
+ // Trampoline: handle the realpath-retarget retry OUTSIDE of any
55
+ // existing lock. If we re-acquired a new lock while still holding the
56
+ // old one, two opposite retargets could deadlock. Instead we let the
57
+ // first attempt return a sentinel, release the old lock by exiting
58
+ // its withLock callback, then re-acquire on the new realpath.
59
+ let attemptDeck = earlyDeck;
60
+ for (let i = 0; i < 2; i++) {
61
+ const result = await withLock(attemptDeck.realPath, () =>
62
+ performNoteUpdate({
63
+ req, res, rootDir, rel,
64
+ slideIndex: idx.value,
65
+ note: noteIn.value,
66
+ ifMatch,
67
+ earlyDeck: attemptDeck
68
+ })
69
+ );
70
+ if (!result || !result.retargetTo) return result;
71
+ attemptDeck = result.retargetTo;
72
+ }
73
+ return sendError(res, mkError('PATH_INVALID', 'realpath unstable across retries'));
74
+ };
75
+ }
76
+
77
+ async function performNoteUpdate({ req, res, rootDir, rel, slideIndex, note, ifMatch, earlyDeck }) {
78
+ // Re-read inside the lock so the etag check sees writes by predecessors.
79
+ let deck;
80
+ try {
81
+ deck = await readDeckSafely(rootDir(), rel);
82
+ } catch (err) {
83
+ if (err.code === 'PATH_INVALID' || err.code === 'NOT_FOUND') return sendError(res, err);
84
+ console.error('marpNote PUT re-read error:', err);
85
+ return sendError(res, mkError('READ_FAILED', 'read failed', { cause: err }));
86
+ }
87
+
88
+ if (!isMarp(deck.rawSource)) {
89
+ return sendError(res, mkError('NOT_MARP', 'not a Marp file'));
90
+ }
91
+
92
+ // If the symlink target changed between pre-lock and in-lock reads, the
93
+ // mutex we hold doesn't cover the deck we'd write. Return a sentinel so
94
+ // the caller can release THIS lock first and re-acquire on the new
95
+ // realpath (preventing nested-lock deadlocks under opposite retargets).
96
+ if (deck.realPath !== earlyDeck.realPath) {
97
+ return { retargetTo: deck };
98
+ }
99
+
100
+ const currentEtag = makeEtag(deck.rawSource);
101
+ if (ifMatch !== currentEtag) {
102
+ return res.status(412).json({ ok: false, code: 'STALE', currentEtag });
103
+ }
104
+
105
+ let parsed;
106
+ try {
107
+ parsed = parseDeck(deck.rawSource);
108
+ } catch (err) {
109
+ return sendError(res, mkError('NOT_PARSEABLE', 'failed to parse Marp deck', { cause: err }));
110
+ }
111
+
112
+ const lineInfo = analyseSource(deck.rawSource);
113
+ let result;
114
+ try {
115
+ result = rewriteSlideNote(deck.rawSource, slideIndex, note, parsed, lineInfo);
116
+ } catch (err) {
117
+ return sendError(res, err.code ? err : mkError('WRITE_FAILED', err.message, { cause: err }));
118
+ }
119
+
120
+ // Defensive realpath re-resolve (TOCTOU best-effort).
121
+ // Compare against the realpath observed by the IN-LOCK re-read (`deck`),
122
+ // NOT the pre-lock read (`earlyDeck`). Otherwise a swap that happens
123
+ // between the pre-lock and in-lock reads, then reverts before this
124
+ // check, would slip past — and we'd write contents parsed from the
125
+ // wrong file into the original path.
126
+ let realAtWrite;
127
+ try {
128
+ realAtWrite = await fs.realpath(path.resolve(rootDir(), rel));
129
+ } catch (err) {
130
+ console.error('marpNote PUT realpath at write:', err);
131
+ return sendError(res, mkError('WRITE_FAILED', 'realpath failed', { cause: err }));
132
+ }
133
+ if (realAtWrite !== deck.realPath) {
134
+ return sendError(res, mkError('PATH_INVALID', 'path resolution changed during request'));
135
+ }
136
+
137
+ try {
138
+ await atomicWrite(realAtWrite, result.source, deck.stat);
139
+ } catch (err) {
140
+ return sendError(res, err.code ? err : mkError('WRITE_FAILED', err.message, { cause: err }));
141
+ }
142
+
143
+ // Re-parse so the client refreshes notes / notesMultiplicity / etag in
144
+ // one round-trip (no need to wait for the watcher event).
145
+ let newParsed;
146
+ try {
147
+ newParsed = parseDeck(result.source);
148
+ } catch (err) {
149
+ return sendError(res, mkError('NOT_PARSEABLE', 'failed to re-parse after rewrite', { cause: err }));
150
+ }
151
+ const rendered = renderDeck(result.source);
152
+
153
+ return res.json({
154
+ ok: true,
155
+ etag: makeEtag(result.source),
156
+ normalizedNote: note,
157
+ slideCount: newParsed.slideCount,
158
+ notes: rendered.notes,
159
+ notesMultiplicity: newParsed.notesMultiplicity,
160
+ source: result.source
161
+ });
162
+ }