mdv-live 0.5.4 → 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,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
@@ -88,10 +88,7 @@ md.enable('table');
88
88
  md.enable('strikethrough');
89
89
 
90
90
  // Enable task lists (checkboxes)
91
- md.use(taskLists, { enabled: true, label: true, labelAfter: true });
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---/;
91
+ md.use(taskLists);
95
92
 
96
93
  // Pattern to detect YAML frontmatter at start of file
97
94
  const FRONTMATTER_PATTERN = /^---\s*\n([\s\S]*?)\n---\s*(\n|$)/;
@@ -99,14 +96,10 @@ const FRONTMATTER_PATTERN = /^---\s*\n([\s\S]*?)\n---\s*(\n|$)/;
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
@@ -118,6 +111,10 @@ function convertFrontmatter(content) {
118
111
  const match = content.match(FRONTMATTER_PATTERN);
119
112
  if (match) {
120
113
  const frontmatter = match[1];
114
+ // Skip empty frontmatter (treat as horizontal rules instead)
115
+ if (!frontmatter.trim()) {
116
+ return content;
117
+ }
121
118
  const rest = content.slice(match[0].length);
122
119
  return `\`\`\`yaml\n${frontmatter}\n\`\`\`\n${rest}`;
123
120
  }
@@ -125,18 +122,22 @@ function convertFrontmatter(content) {
125
122
  return content;
126
123
  }
127
124
 
125
+ // Generate a per-render nonce to prevent placeholder collision with user content
126
+ const MERMAID_NONCE = Math.random().toString(36).slice(2, 10);
127
+
128
128
  /**
129
129
  * Protect Mermaid blocks from markdown processing
130
130
  * @param {string} content - Markdown content
131
- * @returns {{ content: string, blocks: string[] }}
131
+ * @returns {{ content: string, blocks: string[], nonce: string }}
132
132
  */
133
133
  function protectMermaidBlocks(content) {
134
134
  const blocks = [];
135
+ const nonce = MERMAID_NONCE + '_' + Date.now().toString(36);
135
136
  const protectedContent = content.replace(MERMAID_PATTERN, (match, code) => {
136
137
  blocks.push(code);
137
- return `<!--MERMAID_PLACEHOLDER_${blocks.length - 1}-->`;
138
+ return `<!--MDV_MERMAID_${nonce}_${blocks.length - 1}-->`;
138
139
  });
139
- return { content: protectedContent, blocks };
140
+ return { content: protectedContent, blocks, nonce };
140
141
  }
141
142
 
142
143
  /**
@@ -155,17 +156,19 @@ function escapeHtmlEntities(text) {
155
156
  * Restore Mermaid blocks after markdown processing
156
157
  * @param {string} html - Rendered HTML
157
158
  * @param {string[]} blocks - Mermaid code blocks
159
+ * @param {string} nonce - Nonce used during protection
158
160
  * @returns {string}
159
161
  */
160
- function restoreMermaidBlocks(html, blocks) {
162
+ function restoreMermaidBlocks(html, blocks, nonce) {
161
163
  let result = html;
162
164
  for (let i = 0; i < blocks.length; i++) {
163
165
  const escaped = escapeHtmlEntities(blocks[i]);
164
166
  const mermaidHtml = `<pre><code class="language-mermaid">${escaped}</code></pre>`;
165
- // Replace both paragraph-wrapped and bare placeholders
167
+ const placeholder = `<!--MDV_MERMAID_${nonce}_${i}-->`;
168
+ // Replace both paragraph-wrapped and bare placeholders (use split+join for global replace)
166
169
  result = result
167
- .replace(`<p><!--MERMAID_PLACEHOLDER_${i}--></p>`, mermaidHtml)
168
- .replace(`<!--MERMAID_PLACEHOLDER_${i}-->`, mermaidHtml);
170
+ .split(`<p>${placeholder}</p>`).join(mermaidHtml)
171
+ .split(placeholder).join(mermaidHtml);
169
172
  }
170
173
  return result;
171
174
  }
@@ -177,9 +180,9 @@ function restoreMermaidBlocks(html, blocks) {
177
180
  */
178
181
  export function renderMarkdown(content) {
179
182
  const withFrontmatter = convertFrontmatter(content);
180
- const { content: protectedContent, blocks } = protectMermaidBlocks(withFrontmatter);
183
+ const { content: protectedContent, blocks, nonce } = protectMermaidBlocks(withFrontmatter);
181
184
  const html = md.render(protectedContent);
182
- return restoreMermaidBlocks(html, blocks);
185
+ return restoreMermaidBlocks(html, blocks, nonce);
183
186
  }
184
187
 
185
188
  export default { renderMarkdown, isMarp };
@@ -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'];
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Speaker-note rewriter — pure function operating on a parsed deck.
3
+ *
4
+ * Strategy:
5
+ * - At most ONE speaker note per slide is supported by auto-save (see
6
+ * Multi-note Guard below). The Plan caps editing to single-note slides
7
+ * so that the join-of-comments string round-trips losslessly.
8
+ * - Rewriting replaces or removes the existing single note token's line
9
+ * range, or appends a new comment if the slide has no note.
10
+ * - Marp directives are left untouched (they are not in `classifiedNotes`,
11
+ * so `pickNoteComments` filters them out).
12
+ */
13
+
14
+ import { pickNoteComments } from './marpitAdapter.js';
15
+ import { lineRangeToOffsets } from '../utils/lineMath.js';
16
+ import { mkError } from '../utils/errors.js';
17
+
18
+ /** Speaker notes are stored as HTML comments. Reject text that would close
19
+ * or invalidate the comment on the markdown side. */
20
+ export function validateNoteText(text) {
21
+ if (typeof text !== 'string') return 'note must be a string';
22
+ if (text.length > 64 * 1024) return 'note exceeds 64 KiB';
23
+ if (/\u0000/.test(text)) return 'note contains NUL';
24
+ if (text.includes('-->')) return 'note cannot contain "-->"';
25
+ if (text.includes('--!>')) return 'note cannot contain "--!>"';
26
+ if (/--\s*$/.test(text)) return 'note cannot end with "--"';
27
+ return null;
28
+ }
29
+
30
+ export function formatNoteComment(text, lineEnding) {
31
+ const trimmed = text.trim();
32
+ if (trimmed.includes('\n') || trimmed.includes('\r')) {
33
+ // Normalise embedded newlines to the file's line ending.
34
+ const normalised = trimmed.replace(/\r\n|\r|\n/g, lineEnding);
35
+ return '<!--' + lineEnding + normalised + lineEnding + '-->';
36
+ }
37
+ return '<!-- ' + trimmed + ' -->';
38
+ }
39
+
40
+ /**
41
+ * Find the line index where a new note comment should be inserted into a
42
+ * slide that has no existing note. Walks backwards from `endLine - 1` and
43
+ * returns the line *after* the last non-blank line.
44
+ */
45
+ function findInsertionLine(rawSource, lineStarts, totalLines, range) {
46
+ let line = Math.min(range.endLine, totalLines) - 1;
47
+ while (line > range.startLine) {
48
+ const start = lineStarts[line];
49
+ const end = line + 1 < lineStarts.length ? lineStarts[line + 1] : rawSource.length;
50
+ const text = rawSource.slice(start, end);
51
+ if (text.replace(/\r?\n$/, '').trim().length > 0) {
52
+ return line + 1; // insert immediately after this content line
53
+ }
54
+ line--;
55
+ }
56
+ return range.startLine + 1; // default: just after the slide's first line
57
+ }
58
+
59
+ /**
60
+ * @param {string} rawSource
61
+ * @param {number} slideIndex
62
+ * @param {string} newNote empty string == remove
63
+ * @param {ReturnType<import('./marpitAdapter.js').parseDeck>} parsed
64
+ * @param {ReturnType<import('../utils/lineMath.js').analyseSource>} lineInfo
65
+ * @returns {{ source: string, changed: boolean }}
66
+ */
67
+ export function rewriteSlideNote(rawSource, slideIndex, newNote, parsed, lineInfo) {
68
+ if (slideIndex < 0 || slideIndex >= parsed.slideCount) {
69
+ throw mkError('OUT_OF_RANGE', `slideIndex ${slideIndex} out of range`);
70
+ }
71
+ const reason = validateNoteText(newNote);
72
+ if (reason) throw mkError('INVALID_NOTE', reason);
73
+
74
+ const noteStrings = parsed.classifiedNotes[slideIndex] || [];
75
+ const candidates = parsed.commentsBySlide[slideIndex] || [];
76
+ const noteComments = pickNoteComments(candidates, noteStrings);
77
+
78
+ // Multi-note Guard (defense-in-depth): even if the client misbehaves and
79
+ // POSTs against a slide that has multiple speaker notes, refuse — joining
80
+ // them into a single string is lossy round-trip.
81
+ if (noteComments.length > 1) {
82
+ throw mkError(
83
+ 'MULTI_NOTE_READONLY',
84
+ 'slide has multiple speaker notes; auto-save disabled'
85
+ );
86
+ }
87
+
88
+ const totalLines = parsed.slideRanges[parsed.slideCount - 1].endLine;
89
+ const { lineStarts } = lineInfo;
90
+ const lineEnding = lineInfo.lineEnding;
91
+ const trimmedNew = newNote.trim();
92
+
93
+ if (trimmedNew === '' && noteComments.length === 0) {
94
+ return { source: rawSource, changed: false };
95
+ }
96
+
97
+ if (trimmedNew === '') {
98
+ // Remove the single existing note token, including the line break that
99
+ // delimits it. Does NOT touch surrounding code blocks.
100
+ const c = noteComments[0];
101
+ const { startOffset, endOffset } = lineRangeToOffsets(
102
+ lineStarts, totalLines, rawSource.length, c.startLine, c.endLine
103
+ );
104
+ return {
105
+ source: rawSource.slice(0, startOffset) + rawSource.slice(endOffset),
106
+ changed: true
107
+ };
108
+ }
109
+
110
+ const formatted = formatNoteComment(trimmedNew, lineEnding);
111
+
112
+ if (noteComments.length === 1) {
113
+ // Replace the single existing comment's line range.
114
+ const c = noteComments[0];
115
+ const { startOffset, endOffset } = lineRangeToOffsets(
116
+ lineStarts, totalLines, rawSource.length, c.startLine, c.endLine
117
+ );
118
+ // Preserve the trailing newline of the line we replace, if any.
119
+ const preserveTrailingNewline = endOffset < rawSource.length
120
+ || /(?:\r\n|\r|\n)$/.test(rawSource.slice(startOffset, endOffset));
121
+ return {
122
+ source:
123
+ rawSource.slice(0, startOffset) +
124
+ formatted +
125
+ (preserveTrailingNewline ? lineEnding : '') +
126
+ rawSource.slice(endOffset),
127
+ changed: true
128
+ };
129
+ }
130
+
131
+ // Insert a new comment at the slide's last non-blank line + 1.
132
+ const range = parsed.slideRanges[slideIndex];
133
+ const insertLine = findInsertionLine(rawSource, lineStarts, totalLines, range);
134
+ const insertOffset = insertLine < lineStarts.length
135
+ ? lineStarts[insertLine]
136
+ : rawSource.length;
137
+
138
+ const inserted = formatted + lineEnding + lineEnding;
139
+ // If the slide had no trailing blank line, we need to introduce a blank
140
+ // line between content and our comment.
141
+ const needsLeadingBlank = (() => {
142
+ if (insertOffset === 0) return false;
143
+ const before = rawSource.slice(0, insertOffset);
144
+ return !/(?:\r\n\r\n|\n\n|\r\r)$/.test(before);
145
+ })();
146
+ const prefix = needsLeadingBlank ? lineEnding : '';
147
+
148
+ return {
149
+ source:
150
+ rawSource.slice(0, insertOffset) +
151
+ prefix +
152
+ inserted +
153
+ rawSource.slice(insertOffset),
154
+ changed: true
155
+ };
156
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * MarpitTokenAdapter — Marp/Marpit のパーサ出力を正規化する 1 箇所のラッパ。
3
+ *
4
+ * Slide 範囲・speaker note 位置の特定はすべてこのモジュール経由で行う。
5
+ * 直接 marp.markdown.parse() / marp.render() を別の場所から呼ばない。
6
+ *
7
+ * 契約 (tests/test-marpit-adapter.js で snapshot 凍結):
8
+ * - marpit_slide_open.map === [startLine, endLine]
9
+ * - marpit_comment.content は両側 trim 済み
10
+ * - marpit_comment.map === [startLine, endLineExclusive]
11
+ * - marpit_slide_close.map は null になり得る
12
+ * - BOM 付き入力で slide_open.map[0] === 0
13
+ * - render(rawSource).comments は 2D 配列で directive を除外済み
14
+ */
15
+
16
+ import { Marp } from '@marp-team/marp-core';
17
+ import { mkError } from '../utils/errors.js';
18
+
19
+ const marp = new Marp({
20
+ html: true,
21
+ math: true,
22
+ markdown: { html: true, breaks: false, linkify: true }
23
+ });
24
+ marp.markdown.disable('code');
25
+
26
+ /**
27
+ * Marp deck を解析し、slide 範囲・classified notes・comment 位置を返す。
28
+ *
29
+ * @param {string} rawSource
30
+ * @returns {{
31
+ * slideCount: number,
32
+ * slideRanges: Array<{startLine: number, endLine: number}>,
33
+ * classifiedNotes: string[][],
34
+ * commentsBySlide: Array<Array<{content: string, startLine: number, endLine: number}>>,
35
+ * notesMultiplicity: number[]
36
+ * }}
37
+ * @throws {Error} code='NOT_PARSEABLE' if Marpit returns malformed tokens.
38
+ */
39
+ export function parseDeck(rawSource) {
40
+ const env = {};
41
+ const tokens = marp.markdown.parse(rawSource, env);
42
+ const { comments: classifiedNotes } = marp.render(rawSource);
43
+
44
+ const slideOpens = tokens.filter((t) => t.type === 'marpit_slide_open');
45
+ for (const t of slideOpens) {
46
+ if (!t.map) throw mkError('NOT_PARSEABLE', 'slide_open without source map');
47
+ }
48
+ const slideStartLines = slideOpens.map((t) => t.map[0]);
49
+ const totalLines = countLines(rawSource);
50
+
51
+ const slideRanges = slideStartLines.map((start, i) => ({
52
+ startLine: start,
53
+ endLine: i + 1 < slideStartLines.length ? slideStartLines[i + 1] : totalLines
54
+ }));
55
+
56
+ const commentsBySlide = slideRanges.map(() => []);
57
+ let cursor = -1;
58
+ for (const t of tokens) {
59
+ if (t.type === 'marpit_slide_open') {
60
+ cursor = slideStartLines.indexOf(t.map[0]);
61
+ continue;
62
+ }
63
+ if (t.type === 'marpit_comment' && cursor >= 0) {
64
+ if (!t.map) throw mkError('NOT_PARSEABLE', 'marpit_comment without source map');
65
+ commentsBySlide[cursor].push({
66
+ content: t.content,
67
+ startLine: t.map[0],
68
+ endLine: t.map[1]
69
+ });
70
+ }
71
+ }
72
+
73
+ const notesMultiplicity = classifiedNotes.map((arr) => (arr || []).length);
74
+
75
+ return {
76
+ slideCount: slideRanges.length,
77
+ slideRanges,
78
+ classifiedNotes,
79
+ commentsBySlide,
80
+ notesMultiplicity
81
+ };
82
+ }
83
+
84
+ /**
85
+ * commentsBySlide[i] のうち、classifiedNotes[i] と順序 zip でマッチしたもの
86
+ * だけを speaker note として返す。directive コメントは classifiedNotes には
87
+ * 出ないので、自然に除外される。
88
+ *
89
+ * @param {Array<{content: string, startLine: number, endLine: number}>} commentsInSlide
90
+ * @param {string[]} noteStrings
91
+ * @returns {Array<{content: string, startLine: number, endLine: number}>}
92
+ * @throws {Error} code='NOT_PARSEABLE' if zip ends with mismatched count.
93
+ */
94
+ export function pickNoteComments(commentsInSlide, noteStrings) {
95
+ const notes = [];
96
+ let cursor = 0;
97
+ for (const c of commentsInSlide) {
98
+ if (cursor < noteStrings.length && c.content === noteStrings[cursor]) {
99
+ notes.push(c);
100
+ cursor++;
101
+ }
102
+ }
103
+ if (cursor !== noteStrings.length) {
104
+ throw mkError('NOT_PARSEABLE', 'comment tokens do not match classifiedNotes');
105
+ }
106
+ return notes;
107
+ }
108
+
109
+ /**
110
+ * markdown-it / marpit の行カウント慣例に整合する line 数を返す。
111
+ * `t.map` の `endLine` と整合させるため、末尾改行ありなら改行数、なしなら +1。
112
+ */
113
+ export function countLines(source) {
114
+ if (source.length === 0) return 0;
115
+ const newlines = (source.match(/\r\n|\r|\n/g) || []).length;
116
+ const endsWithNewline = /(?:\r\n|\r|\n)$/.test(source);
117
+ return endsWithNewline ? newlines : newlines + 1;
118
+ }
119
+
120
+ /**
121
+ * Marp 互換 marker (`marp: true` を frontmatter に含むか)。
122
+ */
123
+ export function isMarp(content) {
124
+ return /^---\s*\n[\s\S]*?marp:\s*true[\s\S]*?\n---/.test(content);
125
+ }
126
+
127
+ /**
128
+ * Render の薄いラッパ(既存呼び出し元との互換用)。
129
+ */
130
+ export function renderDeck(rawSource) {
131
+ const { html, css, comments } = marp.render(rawSource);
132
+ const slideCount = (html.match(/<section[^>]*>/g) || []).length;
133
+ const notes = (comments || []).map((arr) =>
134
+ Array.isArray(arr) ? arr.join('\n\n').trim() : ''
135
+ );
136
+ while (notes.length < slideCount) notes.push('');
137
+ const notesMultiplicity = (comments || []).map((arr) => (arr || []).length);
138
+ return { html, css, slideCount, notes, notesMultiplicity };
139
+ }