mdv-live 0.5.5 → 0.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +102 -0
- package/README.md +154 -23
- package/bin/mdv.js +141 -81
- package/package.json +1 -1
- package/src/api/marpNote/guards.js +79 -0
- package/src/api/marpNote/handleGet.js +65 -0
- package/src/api/marpNote/handlePut.js +162 -0
- package/src/api/marpNote/readDeck.js +42 -0
- package/src/api/marpNote.js +40 -0
- package/src/api/pdf.js +65 -8
- package/src/concurrency/pathLock.js +39 -0
- package/src/rendering/index.js +9 -1
- package/src/rendering/markdown.js +4 -11
- package/src/rendering/marp.js +11 -32
- package/src/rendering/marpNoteWriter.js +156 -0
- package/src/rendering/marpitAdapter.js +139 -0
- package/src/server.js +29 -4
- package/src/static/app.js +369 -22
- package/src/static/index.html +24 -0
- package/src/static/lib/apiClient.js +73 -0
- package/src/static/lib/presenterChannel.js +33 -0
- package/src/static/lib/saveQueue.js +71 -0
- package/src/static/lib/tabRegistry.js +32 -0
- package/src/static/presenter.html +687 -0
- package/src/static/styles.css +34 -0
- package/src/styles/index.js +90 -0
- package/src/styles/report.example.css +201 -0
- package/src/styles/report.pdf-options.example.json +10 -0
- package/src/utils/atomicWrite.js +159 -0
- package/src/utils/errors.js +50 -0
- package/src/utils/etag.js +11 -0
- package/src/utils/lineMath.js +86 -0
|
@@ -0,0 +1,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
|
+
}
|
package/src/server.js
CHANGED
|
@@ -10,11 +10,13 @@ import path from 'path';
|
|
|
10
10
|
import { fileURLToPath } from 'url';
|
|
11
11
|
|
|
12
12
|
import { setupFileRoutes } from './api/file.js';
|
|
13
|
+
import { setupMarpNoteRoutes } from './api/marpNote.js';
|
|
13
14
|
import { setupPdfRoutes } from './api/pdf.js';
|
|
14
15
|
import { setupTreeRoutes } from './api/tree.js';
|
|
15
16
|
import { setupUploadRoutes } from './api/upload.js';
|
|
16
17
|
import { setupWatcher } from './watcher.js';
|
|
17
18
|
import { setupWebSocket } from './websocket.js';
|
|
19
|
+
import { sweepStaleTemps } from './utils/atomicWrite.js';
|
|
18
20
|
|
|
19
21
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
22
|
const STATIC_DIR = path.join(__dirname, 'static');
|
|
@@ -26,11 +28,12 @@ const { version: VERSION } = JSON.parse(
|
|
|
26
28
|
* Setup API routes for the Express app
|
|
27
29
|
* @param {express.Application} app - Express application instance
|
|
28
30
|
*/
|
|
29
|
-
function setupApiRoutes(app) {
|
|
31
|
+
function setupApiRoutes(app, options) {
|
|
30
32
|
setupTreeRoutes(app);
|
|
31
33
|
setupFileRoutes(app);
|
|
32
34
|
setupUploadRoutes(app);
|
|
33
35
|
setupPdfRoutes(app);
|
|
36
|
+
setupMarpNoteRoutes(app, { port: options.port });
|
|
34
37
|
|
|
35
38
|
app.get('/api/info', (req, res) => {
|
|
36
39
|
res.json({
|
|
@@ -61,11 +64,31 @@ export function createMdvServer(options) {
|
|
|
61
64
|
|
|
62
65
|
app.locals.rootDir = path.resolve(rootDir);
|
|
63
66
|
|
|
64
|
-
app.use(express.json());
|
|
65
|
-
app.use(express.urlencoded({ extended: true }));
|
|
67
|
+
app.use(express.json({ limit: '128kb' }));
|
|
68
|
+
app.use(express.urlencoded({ extended: true, limit: '128kb' }));
|
|
66
69
|
app.use('/static', express.static(STATIC_DIR));
|
|
67
70
|
|
|
68
|
-
setupApiRoutes(app);
|
|
71
|
+
setupApiRoutes(app, { port });
|
|
72
|
+
|
|
73
|
+
// Body-parser error handler (size limit, malformed JSON) must come AFTER
|
|
74
|
+
// the routes so route-level errors fall through to the default handler.
|
|
75
|
+
app.use((err, req, res, next) => {
|
|
76
|
+
if (err && err.type === 'entity.too.large') {
|
|
77
|
+
return res.status(413).json({
|
|
78
|
+
ok: false,
|
|
79
|
+
code: 'PAYLOAD_TOO_LARGE',
|
|
80
|
+
error: 'request body exceeds limit'
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
if (err && err.type === 'entity.parse.failed') {
|
|
84
|
+
return res.status(400).json({
|
|
85
|
+
ok: false,
|
|
86
|
+
code: 'INVALID_NOTE',
|
|
87
|
+
error: 'malformed JSON'
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
next(err);
|
|
91
|
+
});
|
|
69
92
|
|
|
70
93
|
// Catch-all: serve index.html for SPA (path-based routing)
|
|
71
94
|
// Express matches routes in order, so API/static routes above take priority
|
|
@@ -80,6 +103,8 @@ export function createMdvServer(options) {
|
|
|
80
103
|
app.locals.wss = wss;
|
|
81
104
|
|
|
82
105
|
function start() {
|
|
106
|
+
// Best-effort sweep of stale temp files left by a previous crashed write.
|
|
107
|
+
sweepStaleTemps(app.locals.rootDir).catch(() => {});
|
|
83
108
|
return new Promise((resolve) => {
|
|
84
109
|
server.listen(port, () => {
|
|
85
110
|
console.log(`MDV server running at http://localhost:${port}`);
|