mdv-live 0.5.5 → 0.5.8
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 +102 -0
- package/README.md +154 -23
- package/bin/mdv.js +141 -81
- package/package.json +1 -1
- 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 +65 -8
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve and read a deck file safely (path-traversal + symlink-following
|
|
3
|
+
* defenses). Returns { rawSource, stat, realPath }. Throws coded errors:
|
|
4
|
+
* PATH_INVALID / NOT_FOUND.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from 'node:fs/promises';
|
|
8
|
+
import { constants as fsConstants } from 'node:fs';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import { validatePath, validatePathReal } from '../../utils/path.js';
|
|
11
|
+
import { mkError } from '../../utils/errors.js';
|
|
12
|
+
|
|
13
|
+
export async function readDeckSafely(rootDir, relativePath) {
|
|
14
|
+
if (!validatePath(relativePath, rootDir)) throw mkError('PATH_INVALID');
|
|
15
|
+
const ok = await validatePathReal(relativePath, rootDir);
|
|
16
|
+
if (!ok) throw mkError('PATH_INVALID');
|
|
17
|
+
|
|
18
|
+
const fullPath = path.resolve(rootDir, relativePath);
|
|
19
|
+
let realPath;
|
|
20
|
+
try {
|
|
21
|
+
realPath = await fs.realpath(fullPath);
|
|
22
|
+
} catch (err) {
|
|
23
|
+
if (err.code === 'ENOENT') throw mkError('NOT_FOUND');
|
|
24
|
+
throw err;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let fd;
|
|
28
|
+
try {
|
|
29
|
+
fd = await fs.open(realPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
if (err.code === 'ELOOP') throw mkError('PATH_INVALID', 'symlink at terminal');
|
|
32
|
+
if (err.code === 'ENOENT') throw mkError('NOT_FOUND');
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const stat = await fd.stat();
|
|
37
|
+
const rawSource = await fd.readFile('utf-8');
|
|
38
|
+
return { rawSource, stat, realPath };
|
|
39
|
+
} finally {
|
|
40
|
+
await fd.close();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /api/marp/decks/:encodedPath endpoint family — orchestration only.
|
|
3
|
+
*
|
|
4
|
+
* Routing:
|
|
5
|
+
* GET /api/marp/decks/:encodedPath → handleGet
|
|
6
|
+
* PUT /api/marp/decks/:encodedPath/slides/:N/note → handlePut
|
|
7
|
+
* OPTIONS ... → CORS preflight (same-origin only)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { sendError } from '../utils/errors.js';
|
|
11
|
+
import { buildAllowedHosts, checkHost, checkOrigin } from './marpNote/guards.js';
|
|
12
|
+
import { makeGetHandler } from './marpNote/handleGet.js';
|
|
13
|
+
import { makePutHandler } from './marpNote/handlePut.js';
|
|
14
|
+
|
|
15
|
+
function makeOptionsHandler(allowedHosts) {
|
|
16
|
+
return function handleOptions(req, res) {
|
|
17
|
+
const hostErr = checkHost(req, allowedHosts);
|
|
18
|
+
if (hostErr) return sendError(res, hostErr);
|
|
19
|
+
const originErr = checkOrigin(req, allowedHosts);
|
|
20
|
+
if (originErr) return sendError(res, originErr);
|
|
21
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, OPTIONS');
|
|
22
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, If-Match');
|
|
23
|
+
// Note: deliberately NOT setting Allow-Private-Network; PNA is rejected.
|
|
24
|
+
return res.status(204).end();
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function setupMarpNoteRoutes(app, options = {}) {
|
|
29
|
+
const port = options.port || 8080;
|
|
30
|
+
const allowedHosts = buildAllowedHosts(port);
|
|
31
|
+
const rootDir = () => app.locals.rootDir;
|
|
32
|
+
|
|
33
|
+
const handleOptions = makeOptionsHandler(allowedHosts);
|
|
34
|
+
app.options('/api/marp/decks/:encodedPath/slides/:slideIndex/note', handleOptions);
|
|
35
|
+
app.options('/api/marp/decks/:encodedPath', handleOptions);
|
|
36
|
+
|
|
37
|
+
app.get('/api/marp/decks/:encodedPath', makeGetHandler({ rootDir, allowedHosts }));
|
|
38
|
+
app.put('/api/marp/decks/:encodedPath/slides/:slideIndex/note',
|
|
39
|
+
makePutHandler({ rootDir, allowedHosts }));
|
|
40
|
+
}
|
package/src/api/pdf.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PDF Export API
|
|
3
|
-
* Uses marp-cli for Marp presentations
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
1
|
import { execFile } from 'child_process';
|
|
7
2
|
import { promisify } from 'util';
|
|
8
3
|
import fs from 'fs/promises';
|
|
9
4
|
import path from 'path';
|
|
10
5
|
import { fileURLToPath } from 'url';
|
|
11
6
|
import os from 'os';
|
|
12
|
-
import {
|
|
7
|
+
import { createRequire } from 'module';
|
|
8
|
+
import { isMarp } from '../rendering/markdown.js';
|
|
9
|
+
import { validatePath, validatePathReal } from '../utils/path.js';
|
|
10
|
+
import { resolvePdfOptions } from '../styles/index.js';
|
|
13
11
|
|
|
14
12
|
const execFileAsync = promisify(execFile);
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const highlightStylesheet = path.resolve(path.dirname(require.resolve('highlight.js')), '..', 'styles', 'atom-one-dark.css');
|
|
15
15
|
const marpBin = path.join(
|
|
16
16
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
17
17
|
'..',
|
|
@@ -20,6 +20,53 @@ const marpBin = path.join(
|
|
|
20
20
|
'.bin',
|
|
21
21
|
'marp'
|
|
22
22
|
);
|
|
23
|
+
const PDF_EXPORT_TIMEOUT_MS = 180000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve an optional user-selected file path under the server root.
|
|
27
|
+
* @param {string | undefined} relativePath - Path supplied by the web UI.
|
|
28
|
+
* @param {string} rootDir - Server root directory.
|
|
29
|
+
* @returns {Promise<string | null>} Absolute path or null.
|
|
30
|
+
*/
|
|
31
|
+
async function resolveOptionalUserFile(relativePath, rootDir) {
|
|
32
|
+
if (!relativePath) return null;
|
|
33
|
+
if (!await validatePathReal(relativePath, rootDir)) {
|
|
34
|
+
throw new Error(`Access denied: ${relativePath}`);
|
|
35
|
+
}
|
|
36
|
+
const fullPath = path.join(rootDir, relativePath);
|
|
37
|
+
const stat = await fs.stat(fullPath);
|
|
38
|
+
if (!stat.isFile()) {
|
|
39
|
+
throw new Error(`Not a file: ${relativePath}`);
|
|
40
|
+
}
|
|
41
|
+
return fullPath;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Export a regular markdown document with md-to-pdf.
|
|
46
|
+
* @param {string} inputPath - Source markdown file.
|
|
47
|
+
* @param {string} outputPath - Temporary output path.
|
|
48
|
+
* @param {string | null} stylesheetPath - Optional custom CSS file.
|
|
49
|
+
* @param {string | null} pdfOptionsPath - Optional PDF options JSON file.
|
|
50
|
+
* @returns {Promise<void>}
|
|
51
|
+
*/
|
|
52
|
+
async function exportMarkdownPdf(inputPath, outputPath, stylesheetPath, pdfOptionsPath) {
|
|
53
|
+
const pdfOptions = await resolvePdfOptions(pdfOptionsPath || undefined);
|
|
54
|
+
const args = ['md-to-pdf', inputPath, '--pdf-options', JSON.stringify(pdfOptions)];
|
|
55
|
+
|
|
56
|
+
if (stylesheetPath) {
|
|
57
|
+
args.push('--stylesheet', highlightStylesheet);
|
|
58
|
+
args.push('--stylesheet', stylesheetPath);
|
|
59
|
+
args.push('--highlight-style', 'atom-one-dark');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await execFileAsync('npx', args, {
|
|
63
|
+
cwd: path.dirname(inputPath),
|
|
64
|
+
timeout: PDF_EXPORT_TIMEOUT_MS,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const generatedPdf = inputPath.replace(/\.(md|markdown)$/i, '.pdf');
|
|
68
|
+
await fs.rename(generatedPdf, outputPath);
|
|
69
|
+
}
|
|
23
70
|
|
|
24
71
|
/**
|
|
25
72
|
* Setup PDF export routes
|
|
@@ -30,7 +77,7 @@ export function setupPdfRoutes(app) {
|
|
|
30
77
|
const { rootDir } = app.locals;
|
|
31
78
|
|
|
32
79
|
app.post('/api/pdf/export', async (req, res) => {
|
|
33
|
-
const { filePath } = req.body;
|
|
80
|
+
const { filePath, stylePath, pdfOptionsPath } = req.body;
|
|
34
81
|
|
|
35
82
|
if (!filePath) {
|
|
36
83
|
return res.status(400).json({ error: 'filePath is required' });
|
|
@@ -53,7 +100,17 @@ export function setupPdfRoutes(app) {
|
|
|
53
100
|
const outputFileName = `${baseName}.pdf`;
|
|
54
101
|
|
|
55
102
|
try {
|
|
56
|
-
await
|
|
103
|
+
const content = await fs.readFile(fullPath, 'utf-8');
|
|
104
|
+
if (isMarp(content)) {
|
|
105
|
+
await execFileAsync(marpBin, [fullPath, '-o', outputPath, '--html', '--allow-local-files', '--no-stdin'], { timeout: PDF_EXPORT_TIMEOUT_MS });
|
|
106
|
+
} else {
|
|
107
|
+
const [stylesheetPath, resolvedPdfOptionsPath] = await Promise.all([
|
|
108
|
+
resolveOptionalUserFile(stylePath, rootDir),
|
|
109
|
+
resolveOptionalUserFile(pdfOptionsPath, rootDir),
|
|
110
|
+
]);
|
|
111
|
+
await exportMarkdownPdf(fullPath, outputPath, stylesheetPath, resolvedPdfOptionsPath);
|
|
112
|
+
}
|
|
113
|
+
|
|
57
114
|
res.download(outputPath, outputFileName, async (err) => {
|
|
58
115
|
if (err) {
|
|
59
116
|
console.error('Download error:', err);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promise-chain mutex keyed by an arbitrary string.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the naive Map-based implementation in api/marpNote.js, which the
|
|
5
|
+
* concurrency audit identified as susceptible to a thundering-herd race:
|
|
6
|
+
* with two or more waiters, all of them could observe an empty map after
|
|
7
|
+
* the previous holder cleared it and then proceed in parallel.
|
|
8
|
+
*
|
|
9
|
+
* This implementation chains every new request onto the tail of the
|
|
10
|
+
* existing promise chain for the key, guaranteeing strict FIFO order.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const tails = new Map(); // key → Promise (tail of the chain)
|
|
14
|
+
|
|
15
|
+
export async function withLock(key, fn) {
|
|
16
|
+
const previous = tails.get(key) || Promise.resolve();
|
|
17
|
+
|
|
18
|
+
// Create the next tail BEFORE starting fn so subsequent callers chain after us.
|
|
19
|
+
let release;
|
|
20
|
+
const next = new Promise((resolve) => { release = resolve; });
|
|
21
|
+
tails.set(key, next);
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
// Wait for the previous chain to settle (success OR failure).
|
|
25
|
+
await previous.catch(() => {});
|
|
26
|
+
return await fn();
|
|
27
|
+
} finally {
|
|
28
|
+
release();
|
|
29
|
+
// If we are still the tail, drop the entry so the map doesn't grow
|
|
30
|
+
// unboundedly. If a subsequent caller has already replaced us as the
|
|
31
|
+
// tail, leave their entry intact.
|
|
32
|
+
if (tails.get(key) === next) tails.delete(key);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Test helper. */
|
|
37
|
+
export function _activeKeyCount() {
|
|
38
|
+
return tails.size;
|
|
39
|
+
}
|
package/src/rendering/index.js
CHANGED
|
@@ -7,6 +7,8 @@ import path from 'path';
|
|
|
7
7
|
import { getFileType } from '../utils/fileTypes.js';
|
|
8
8
|
import { renderMarkdown, isMarp } from './markdown.js';
|
|
9
9
|
import { renderMarp } from './marp.js';
|
|
10
|
+
import { analyseSource } from '../utils/lineMath.js';
|
|
11
|
+
import { makeEtag } from '../utils/etag.js';
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Escape HTML entities
|
|
@@ -99,10 +101,16 @@ export async function renderFile(filePath, relativeDir) {
|
|
|
99
101
|
*/
|
|
100
102
|
function renderMarkdownFile(content, relativeDir) {
|
|
101
103
|
if (isMarp(content)) {
|
|
102
|
-
const { html, css } = renderMarp(content);
|
|
104
|
+
const { html, css, notes, notesMultiplicity } = renderMarp(content);
|
|
105
|
+
const lineInfo = analyseSource(content);
|
|
103
106
|
return {
|
|
104
107
|
content: rewriteMediaPaths(html, relativeDir),
|
|
105
108
|
css,
|
|
109
|
+
notes,
|
|
110
|
+
notesMultiplicity,
|
|
111
|
+
etag: makeEtag(content),
|
|
112
|
+
lineEnding: lineInfo.lineEnding,
|
|
113
|
+
hasBom: lineInfo.hasBom,
|
|
106
114
|
raw: content,
|
|
107
115
|
fileType: 'markdown',
|
|
108
116
|
isMarp: true
|
|
@@ -90,23 +90,16 @@ md.enable('strikethrough');
|
|
|
90
90
|
// Enable task lists (checkboxes)
|
|
91
91
|
md.use(taskLists);
|
|
92
92
|
|
|
93
|
-
// Pattern to detect Marp frontmatter (must be at very start of file, not using 'm' flag)
|
|
94
|
-
const MARP_PATTERN = /^---\s*\n[\s\S]*?marp:\s*true[\s\S]*?\n---/;
|
|
95
|
-
|
|
96
93
|
// Pattern to detect YAML frontmatter at start of file
|
|
97
94
|
const FRONTMATTER_PATTERN = /^---\s*\n([\s\S]*?)\n---\s*(\n|$)/;
|
|
98
95
|
|
|
99
96
|
// Pattern for Mermaid code blocks
|
|
100
97
|
const MERMAID_PATTERN = /```mermaid\s*\n([\s\S]*?)\n```/g;
|
|
101
98
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
*/
|
|
107
|
-
export function isMarp(content) {
|
|
108
|
-
return MARP_PATTERN.test(content);
|
|
109
|
-
}
|
|
99
|
+
// Re-export the SSOT version of isMarp so callers don't import a separate
|
|
100
|
+
// (and previously slightly different) regex from this module.
|
|
101
|
+
import { isMarp } from './marpitAdapter.js';
|
|
102
|
+
export { isMarp };
|
|
110
103
|
|
|
111
104
|
/**
|
|
112
105
|
* Convert YAML frontmatter to code block for display
|
package/src/rendering/marp.js
CHANGED
|
@@ -1,49 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Marp rendering
|
|
2
|
+
* Marp rendering — thin compat layer over `marpitAdapter.renderDeck`.
|
|
3
|
+
*
|
|
4
|
+
* Historically this file owned its own Marp instance. The audit flagged
|
|
5
|
+
* that as a SOLID/DRY violation: the adapter already owns the canonical
|
|
6
|
+
* instance, and having two of them risked subtle directive-state drift
|
|
7
|
+
* between code paths. We now delegate to the adapter.
|
|
3
8
|
*
|
|
4
9
|
* IMPORTANT: Do not modify Marp's HTML output structure.
|
|
5
10
|
* The CSS depends on the exact structure: div.marpit > svg > foreignObject > section
|
|
6
11
|
*/
|
|
7
12
|
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
// Initialize Marp with full HTML support
|
|
11
|
-
const marp = new Marp({
|
|
12
|
-
html: true,
|
|
13
|
-
math: true,
|
|
14
|
-
markdown: {
|
|
15
|
-
html: true,
|
|
16
|
-
breaks: false,
|
|
17
|
-
linkify: true,
|
|
18
|
-
}
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
// Disable indented code blocks (4-space indent → code)
|
|
22
|
-
// This allows HTML with indentation to render properly
|
|
23
|
-
marp.markdown.disable('code');
|
|
13
|
+
import { renderDeck } from './marpitAdapter.js';
|
|
24
14
|
|
|
25
15
|
/**
|
|
26
|
-
* Render Marp presentation to HTML
|
|
16
|
+
* Render Marp presentation to HTML.
|
|
27
17
|
* @param {string} content - Markdown content with Marp frontmatter
|
|
28
|
-
* @returns {{ html: string, css: string, slideCount: number }}
|
|
18
|
+
* @returns {{ html: string, css: string, slideCount: number, notes: string[], notesMultiplicity: number[] }}
|
|
29
19
|
*/
|
|
30
20
|
export function renderMarp(content) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
// Count slides by counting <section> tags
|
|
34
|
-
const slideCount = (html.match(/<section[^>]*>/g) || []).length;
|
|
35
|
-
|
|
36
|
-
// Return Marp's HTML output AS-IS to preserve CSS selector compatibility
|
|
37
|
-
return {
|
|
38
|
-
html,
|
|
39
|
-
css,
|
|
40
|
-
slideCount
|
|
41
|
-
};
|
|
21
|
+
return renderDeck(content);
|
|
42
22
|
}
|
|
43
23
|
|
|
44
24
|
/**
|
|
45
|
-
* Get available Marp themes
|
|
46
|
-
* @returns {string[]} Theme names
|
|
25
|
+
* Get available Marp themes.
|
|
47
26
|
*/
|
|
48
27
|
export function getThemes() {
|
|
49
28
|
return ['default', 'gaia', 'uncover'];
|