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.
- package/CHANGELOG.md +127 -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 +24 -21
- 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/src/rendering/slides.js +0 -152
|
@@ -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
|
|
@@ -88,10 +88,7 @@ md.enable('table');
|
|
|
88
88
|
md.enable('strikethrough');
|
|
89
89
|
|
|
90
90
|
// Enable task lists (checkboxes)
|
|
91
|
-
md.use(taskLists
|
|
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
|
-
|
|
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
|
|
@@ -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 `<!--
|
|
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
|
-
|
|
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
|
-
.
|
|
168
|
-
.
|
|
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 };
|
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'];
|
|
@@ -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
|
+
}
|