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/CHANGELOG.md +124 -0
- package/README.md +154 -23
- package/bin/mdv.js +141 -81
- package/package.json +8 -7
- package/src/api/marpNote/guards.js +79 -0
- package/src/api/marpNote/handleGet.js +65 -0
- package/src/api/marpNote/handlePut.js +162 -0
- package/src/api/marpNote/readDeck.js +42 -0
- package/src/api/marpNote.js +40 -0
- package/src/api/pdf.js +109 -20
- package/src/concurrency/pathLock.js +39 -0
- package/src/rendering/index.js +9 -1
- package/src/rendering/markdown.js +4 -11
- package/src/rendering/marp.js +11 -32
- package/src/rendering/marpNoteWriter.js +156 -0
- package/src/rendering/marpitAdapter.js +139 -0
- package/src/server.js +29 -4
- package/src/static/app.js +369 -22
- package/src/static/index.html +24 -0
- package/src/static/lib/apiClient.js +73 -0
- package/src/static/lib/presenterChannel.js +33 -0
- package/src/static/lib/saveQueue.js +71 -0
- package/src/static/lib/tabRegistry.js +32 -0
- package/src/static/presenter.html +687 -0
- package/src/static/styles.css +34 -0
- package/src/styles/index.js +90 -0
- package/src/styles/report.example.css +201 -0
- package/src/styles/report.pdf-options.example.json +10 -0
- package/src/utils/atomicWrite.js +159 -0
- package/src/utils/errors.js +50 -0
- package/src/utils/etag.js +11 -0
- package/src/utils/lineMath.js +86 -0
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
|
|
24
|
-
port: {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
},
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
},
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
},
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
103
|
-
mdv /path/to/dir
|
|
104
|
-
mdv README.md
|
|
105
|
-
mdv
|
|
106
|
-
mdv -p 3000
|
|
107
|
-
mdv -l
|
|
108
|
-
mdv -k -a
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
312
|
-
|
|
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
|
|
457
|
+
* Parse arguments for the convert subcommand
|
|
435
458
|
* @returns {{values: object, positionals: string[]}}
|
|
436
459
|
*/
|
|
437
|
-
function
|
|
460
|
+
function parseConvertArgs() {
|
|
438
461
|
try {
|
|
439
462
|
return parseArgs({
|
|
440
|
-
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
+
}
|