mdv-live 0.5.5 → 0.5.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +124 -0
- package/README.md +154 -23
- package/bin/mdv.js +141 -81
- package/package.json +8 -7
- package/src/api/marpNote/guards.js +79 -0
- package/src/api/marpNote/handleGet.js +65 -0
- package/src/api/marpNote/handlePut.js +162 -0
- package/src/api/marpNote/readDeck.js +42 -0
- package/src/api/marpNote.js +40 -0
- package/src/api/pdf.js +109 -20
- package/src/concurrency/pathLock.js +39 -0
- package/src/rendering/index.js +9 -1
- package/src/rendering/markdown.js +4 -11
- package/src/rendering/marp.js +11 -32
- package/src/rendering/marpNoteWriter.js +156 -0
- package/src/rendering/marpitAdapter.js +139 -0
- package/src/server.js +29 -4
- package/src/static/app.js +369 -22
- package/src/static/index.html +24 -0
- package/src/static/lib/apiClient.js +73 -0
- package/src/static/lib/presenterChannel.js +33 -0
- package/src/static/lib/saveQueue.js +71 -0
- package/src/static/lib/tabRegistry.js +32 -0
- package/src/static/presenter.html +687 -0
- package/src/static/styles.css +34 -0
- package/src/styles/index.js +90 -0
- package/src/styles/report.example.css +201 -0
- package/src/styles/report.pdf-options.example.json +10 -0
- package/src/utils/atomicWrite.js +159 -0
- package/src/utils/errors.js +50 -0
- package/src/utils/etag.js +11 -0
- package/src/utils/lineMath.js +86 -0
|
@@ -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,25 +1,104 @@
|
|
|
1
|
-
|
|
2
|
-
* PDF Export API
|
|
3
|
-
* Uses marp-cli for Marp presentations
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { execFile } from 'child_process';
|
|
7
|
-
import { promisify } from 'util';
|
|
1
|
+
import { spawn } from 'child_process';
|
|
8
2
|
import fs from 'fs/promises';
|
|
9
3
|
import path from 'path';
|
|
10
4
|
import { fileURLToPath } from 'url';
|
|
11
5
|
import os from 'os';
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
6
|
+
import { createRequire } from 'module';
|
|
7
|
+
import { isMarp } from '../rendering/markdown.js';
|
|
8
|
+
import { validatePath, validatePathReal } from '../utils/path.js';
|
|
9
|
+
import { resolvePdfOptions } from '../styles/index.js';
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const highlightStylesheet = path.resolve(path.dirname(require.resolve('highlight.js')), '..', 'styles', 'atom-one-dark.css');
|
|
13
|
+
const binDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'node_modules', '.bin');
|
|
14
|
+
const marpBin = path.join(binDir, 'marp');
|
|
15
|
+
const mdToPdfBin = path.join(binDir, 'md-to-pdf');
|
|
16
|
+
const PDF_EXPORT_TIMEOUT_MS = 180000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Spawn a PDF tool with stdin closed.
|
|
20
|
+
*
|
|
21
|
+
* 注意: execFile は stdio オプションを受け付けない (Node 仕様)。md-to-pdf は
|
|
22
|
+
* 内部で get-stdin を呼ぶため、stdin が pipe のままだと EOF を永遠に待ち続けて
|
|
23
|
+
* ハングする。spawn で stdin を 'ignore' (= /dev/null) に明示的に縛る。
|
|
24
|
+
*/
|
|
25
|
+
function runPdfTool(bin, args, { cwd } = {}) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const child = spawn(bin, args, {
|
|
28
|
+
cwd,
|
|
29
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
30
|
+
});
|
|
31
|
+
let stdout = '';
|
|
32
|
+
let stderr = '';
|
|
33
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
34
|
+
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
35
|
+
|
|
36
|
+
const timer = setTimeout(() => {
|
|
37
|
+
child.kill('SIGTERM');
|
|
38
|
+
}, PDF_EXPORT_TIMEOUT_MS);
|
|
39
|
+
|
|
40
|
+
child.on('error', (err) => {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
reject(err);
|
|
43
|
+
});
|
|
44
|
+
child.on('close', (code, signal) => {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
if (code === 0) {
|
|
47
|
+
resolve({ stdout, stderr });
|
|
48
|
+
} else {
|
|
49
|
+
const err = new Error(`${path.basename(bin)} exited with code=${code} signal=${signal}`);
|
|
50
|
+
err.code = code;
|
|
51
|
+
err.signal = signal;
|
|
52
|
+
err.stdout = stdout;
|
|
53
|
+
err.stderr = stderr;
|
|
54
|
+
reject(err);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve an optional user-selected file path under the server root.
|
|
62
|
+
* @param {string | undefined} relativePath - Path supplied by the web UI.
|
|
63
|
+
* @param {string} rootDir - Server root directory.
|
|
64
|
+
* @returns {Promise<string | null>} Absolute path or null.
|
|
65
|
+
*/
|
|
66
|
+
async function resolveOptionalUserFile(relativePath, rootDir) {
|
|
67
|
+
if (!relativePath) return null;
|
|
68
|
+
if (!await validatePathReal(relativePath, rootDir)) {
|
|
69
|
+
throw new Error(`Access denied: ${relativePath}`);
|
|
70
|
+
}
|
|
71
|
+
const fullPath = path.join(rootDir, relativePath);
|
|
72
|
+
const stat = await fs.stat(fullPath);
|
|
73
|
+
if (!stat.isFile()) {
|
|
74
|
+
throw new Error(`Not a file: ${relativePath}`);
|
|
75
|
+
}
|
|
76
|
+
return fullPath;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Export a regular markdown document with md-to-pdf.
|
|
81
|
+
* @param {string} inputPath - Source markdown file.
|
|
82
|
+
* @param {string} outputPath - Temporary output path.
|
|
83
|
+
* @param {string | null} stylesheetPath - Optional custom CSS file.
|
|
84
|
+
* @param {string | null} pdfOptionsPath - Optional PDF options JSON file.
|
|
85
|
+
* @returns {Promise<void>}
|
|
86
|
+
*/
|
|
87
|
+
async function exportMarkdownPdf(inputPath, outputPath, stylesheetPath, pdfOptionsPath) {
|
|
88
|
+
const pdfOptions = await resolvePdfOptions(pdfOptionsPath || undefined);
|
|
89
|
+
const args = [inputPath, '--pdf-options', JSON.stringify(pdfOptions)];
|
|
90
|
+
|
|
91
|
+
if (stylesheetPath) {
|
|
92
|
+
args.push('--stylesheet', highlightStylesheet);
|
|
93
|
+
args.push('--stylesheet', stylesheetPath);
|
|
94
|
+
args.push('--highlight-style', 'atom-one-dark');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await runPdfTool(mdToPdfBin, args, { cwd: path.dirname(inputPath) });
|
|
98
|
+
|
|
99
|
+
const generatedPdf = inputPath.replace(/\.(md|markdown)$/i, '.pdf');
|
|
100
|
+
await fs.rename(generatedPdf, outputPath);
|
|
101
|
+
}
|
|
23
102
|
|
|
24
103
|
/**
|
|
25
104
|
* Setup PDF export routes
|
|
@@ -30,7 +109,7 @@ export function setupPdfRoutes(app) {
|
|
|
30
109
|
const { rootDir } = app.locals;
|
|
31
110
|
|
|
32
111
|
app.post('/api/pdf/export', async (req, res) => {
|
|
33
|
-
const { filePath } = req.body;
|
|
112
|
+
const { filePath, stylePath, pdfOptionsPath } = req.body;
|
|
34
113
|
|
|
35
114
|
if (!filePath) {
|
|
36
115
|
return res.status(400).json({ error: 'filePath is required' });
|
|
@@ -53,7 +132,17 @@ export function setupPdfRoutes(app) {
|
|
|
53
132
|
const outputFileName = `${baseName}.pdf`;
|
|
54
133
|
|
|
55
134
|
try {
|
|
56
|
-
await
|
|
135
|
+
const content = await fs.readFile(fullPath, 'utf-8');
|
|
136
|
+
if (isMarp(content)) {
|
|
137
|
+
await runPdfTool(marpBin, [fullPath, '-o', outputPath, '--html', '--allow-local-files', '--no-stdin']);
|
|
138
|
+
} else {
|
|
139
|
+
const [stylesheetPath, resolvedPdfOptionsPath] = await Promise.all([
|
|
140
|
+
resolveOptionalUserFile(stylePath, rootDir),
|
|
141
|
+
resolveOptionalUserFile(pdfOptionsPath, rootDir),
|
|
142
|
+
]);
|
|
143
|
+
await exportMarkdownPdf(fullPath, outputPath, stylesheetPath, resolvedPdfOptionsPath);
|
|
144
|
+
}
|
|
145
|
+
|
|
57
146
|
res.download(outputPath, outputFileName, async (err) => {
|
|
58
147
|
if (err) {
|
|
59
148
|
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'];
|
|
@@ -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
|
+
}
|