mdv-live 0.5.5 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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}`);