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.
@@ -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 { validatePath } from '../utils/path.js';
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 execFileAsync(marpBin, [fullPath, '-o', outputPath, '--html', '--allow-local-files', '--no-stdin'], { timeout: 60000 });
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
+ }
@@ -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
- * Check if content is a Marp presentation
104
- * @param {string} content - Markdown content
105
- * @returns {boolean}
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
@@ -1,49 +1,28 @@
1
1
  /**
2
- * Marp rendering using @marp-team/marp-core
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 { Marp } from '@marp-team/marp-core';
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
- const { html, css } = marp.render(content);
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'];