@dustfeather/deckrun 2.0.0
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/LICENSE +21 -0
- package/README.md +1073 -0
- package/THIRD-PARTY-NOTICES.md +38 -0
- package/dist/editor-content.js +485 -0
- package/dist/editor.js +3916 -0
- package/dist/fragments.js +71 -0
- package/dist/generate.js +3488 -0
- package/dist/highlights.js +833 -0
- package/dist/index.js +1020 -0
- package/dist/lint.js +330 -0
- package/dist/parser.js +221 -0
- package/dist/pdf.js +200 -0
- package/dist/presentation-options.js +289 -0
- package/dist/preview.js +400 -0
- package/dist/rich-content.js +195 -0
- package/dist/safe-fetch.js +173 -0
- package/dist/sanitize.js +102 -0
- package/dist/themes.js +1041 -0
- package/dist/titles.js +30 -0
- package/package.json +64 -0
package/dist/lint.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strips C0/C1 control characters from text that came out of a document and
|
|
3
|
+
* bounds its length.
|
|
4
|
+
*
|
|
5
|
+
* `deckrun lint` is meant to run in CI over decks other people wrote, and its
|
|
6
|
+
* findings are printed straight to a terminal. An ESC, BEL or CSI byte lifted
|
|
7
|
+
* out of a deck and echoed into a job log lets the deck blank the screen,
|
|
8
|
+
* conceal the rest of the output, or redraw it — a reviewer then reads a clean
|
|
9
|
+
* log for a deck that did not lint clean.
|
|
10
|
+
*/
|
|
11
|
+
export function sanitizeForTerminal(value, max = 120) {
|
|
12
|
+
const stripped = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "\uFFFD");
|
|
13
|
+
return stripped.length > max ? stripped.slice(0, max) + "…" : stripped;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Caps on the image scanner.
|
|
17
|
+
*
|
|
18
|
+
* The old pattern was `/!\[([^\]]*)\]\(([^\s)]+)(?:\s+"([^"]*)")?\)/g`. The
|
|
19
|
+
* greedy URL run has no upper bound and cannot contain `)`, so on a line of
|
|
20
|
+
* `` occurrences on one line.
|
|
35
|
+
*
|
|
36
|
+
* `budget` is decremented by the number of characters examined and shared
|
|
37
|
+
* across the file; when it runs out the scan stops. A URL run longer than
|
|
38
|
+
* MAX_URL is not a URL, and the cursor jumps past it rather than retrying
|
|
39
|
+
* from every `![` inside it — that is what keeps the pass linear.
|
|
40
|
+
*/
|
|
41
|
+
function scanImages(line, budget) {
|
|
42
|
+
const found = [];
|
|
43
|
+
const isBreak = (ch) => ch === ")" || /\s/.test(ch);
|
|
44
|
+
let i = 0;
|
|
45
|
+
while (budget.left > 0) {
|
|
46
|
+
const start = line.indexOf("![", i);
|
|
47
|
+
if (start < 0)
|
|
48
|
+
break;
|
|
49
|
+
const alt = line.indexOf("]", start + 2);
|
|
50
|
+
if (alt < 0)
|
|
51
|
+
break;
|
|
52
|
+
budget.left -= alt - start;
|
|
53
|
+
if (alt - (start + 2) > MAX_ALT || line[alt + 1] !== "(") {
|
|
54
|
+
i = alt + 1;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
// URL run: anything up to whitespace or the closing paren.
|
|
58
|
+
const urlStart = alt + 2;
|
|
59
|
+
let cursor = urlStart;
|
|
60
|
+
while (cursor < line.length && cursor - urlStart <= MAX_URL && !isBreak(line[cursor])) {
|
|
61
|
+
cursor++;
|
|
62
|
+
}
|
|
63
|
+
budget.left -= cursor - urlStart;
|
|
64
|
+
if (cursor - urlStart > MAX_URL) {
|
|
65
|
+
// Not a URL. Nothing inside a run this long can start a real image
|
|
66
|
+
// either, so resume past it instead of retrying from every `![` in it.
|
|
67
|
+
i = cursor;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (cursor === urlStart) {
|
|
71
|
+
i = alt + 1;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (line[cursor] === ")") {
|
|
75
|
+
found.push({ alt: line.slice(start + 2, alt), title: "", index: start });
|
|
76
|
+
i = cursor + 1;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
// Optional ` "title"` before the closing paren.
|
|
80
|
+
let quote = cursor;
|
|
81
|
+
while (quote < line.length && /[ \t]/.test(line[quote]))
|
|
82
|
+
quote++;
|
|
83
|
+
if (line[quote] !== '"') {
|
|
84
|
+
i = alt + 1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const window = line.slice(quote + 1, quote + 2 + MAX_TITLE);
|
|
88
|
+
budget.left -= window.length;
|
|
89
|
+
const close = window.indexOf('"');
|
|
90
|
+
if (close < 0 || line[quote + 2 + close] !== ")") {
|
|
91
|
+
i = alt + 1;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
found.push({
|
|
95
|
+
alt: line.slice(start + 2, alt),
|
|
96
|
+
title: window.slice(0, close),
|
|
97
|
+
index: start,
|
|
98
|
+
});
|
|
99
|
+
i = quote + 3 + close;
|
|
100
|
+
}
|
|
101
|
+
return found;
|
|
102
|
+
}
|
|
103
|
+
export function lintMarkdown(markdown) {
|
|
104
|
+
const issues = [];
|
|
105
|
+
const trimmed = markdown.trim();
|
|
106
|
+
if (!trimmed) {
|
|
107
|
+
issues.push({
|
|
108
|
+
rule: "empty-deck",
|
|
109
|
+
severity: "error",
|
|
110
|
+
message: "The deck is empty.",
|
|
111
|
+
line: 1,
|
|
112
|
+
column: 1,
|
|
113
|
+
});
|
|
114
|
+
return {
|
|
115
|
+
slides: 0,
|
|
116
|
+
errors: 1,
|
|
117
|
+
warnings: 0,
|
|
118
|
+
issues,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const lines = markdown.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
122
|
+
const slides = [];
|
|
123
|
+
let curSlideLines = [];
|
|
124
|
+
let curStartLine = 1;
|
|
125
|
+
let slideIndex = 1;
|
|
126
|
+
for (let i = 0; i < lines.length; i++) {
|
|
127
|
+
const line = lines[i];
|
|
128
|
+
if (/^[ \t]*---[ \t]*$/.test(line)) {
|
|
129
|
+
slides.push({
|
|
130
|
+
slideIndex,
|
|
131
|
+
startLine: curStartLine,
|
|
132
|
+
endLine: i,
|
|
133
|
+
lines: curSlideLines,
|
|
134
|
+
});
|
|
135
|
+
slideIndex++;
|
|
136
|
+
curStartLine = i + 2;
|
|
137
|
+
curSlideLines = [];
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
curSlideLines.push(line);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
slides.push({
|
|
144
|
+
slideIndex,
|
|
145
|
+
startLine: curStartLine,
|
|
146
|
+
endLine: lines.length,
|
|
147
|
+
lines: curSlideLines,
|
|
148
|
+
});
|
|
149
|
+
// Global & Slide checks
|
|
150
|
+
let inCodeFence = false;
|
|
151
|
+
let fenceStartLine = 1;
|
|
152
|
+
let fenceStartCol = 1;
|
|
153
|
+
let inDisplayMath = false;
|
|
154
|
+
let mathStartLine = 1;
|
|
155
|
+
let mathStartCol = 1;
|
|
156
|
+
// The slide a line belongs to is tracked with a cursor that only moves
|
|
157
|
+
// forward. `slides.find(...)` re-scanned the whole array for every line, and
|
|
158
|
+
// a deck that is nothing but `---` separators produces slides whose ranges
|
|
159
|
+
// are all empty, so nothing ever short-circuits: O(lines x slides), with
|
|
160
|
+
// both factors controlled by whoever wrote the deck.
|
|
161
|
+
let slideCursor = 0;
|
|
162
|
+
const scanBudget = { left: SCAN_BUDGET };
|
|
163
|
+
for (let i = 0; i < lines.length; i++) {
|
|
164
|
+
const lineNum = i + 1;
|
|
165
|
+
const line = lines[i];
|
|
166
|
+
// Determine current slide number
|
|
167
|
+
while (slideCursor < slides.length - 1 &&
|
|
168
|
+
lineNum > slides[slideCursor].endLine) {
|
|
169
|
+
slideCursor++;
|
|
170
|
+
}
|
|
171
|
+
const inRange = lineNum >= slides[slideCursor].startLine &&
|
|
172
|
+
lineNum <= slides[slideCursor].endLine;
|
|
173
|
+
const currentSlide = inRange ? slides[slideCursor].slideIndex : 1;
|
|
174
|
+
// Check code fences
|
|
175
|
+
const fenceMatch = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/);
|
|
176
|
+
if (fenceMatch) {
|
|
177
|
+
if (!inCodeFence) {
|
|
178
|
+
inCodeFence = true;
|
|
179
|
+
fenceStartLine = lineNum;
|
|
180
|
+
fenceStartCol = fenceMatch[1].length + 1;
|
|
181
|
+
const tag = fenceMatch[3].trim();
|
|
182
|
+
if (!tag) {
|
|
183
|
+
issues.push({
|
|
184
|
+
rule: "untagged-code-fence",
|
|
185
|
+
severity: "warning",
|
|
186
|
+
message: "Code fence has no language tag for syntax highlighting.",
|
|
187
|
+
line: lineNum,
|
|
188
|
+
column: fenceStartCol,
|
|
189
|
+
slide: currentSlide,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
inCodeFence = false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Check display math
|
|
198
|
+
if (!inCodeFence) {
|
|
199
|
+
if (/^\s*\$\$\s*$/.test(line) || /^\s*\\\[\s*$/.test(line)) {
|
|
200
|
+
if (!inDisplayMath) {
|
|
201
|
+
inDisplayMath = true;
|
|
202
|
+
mathStartLine = lineNum;
|
|
203
|
+
mathStartCol = 1;
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
inDisplayMath = false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
else if (line.includes("$$")) {
|
|
210
|
+
const occurrences = (line.match(/\$\$/g) || []).length;
|
|
211
|
+
if (occurrences % 2 !== 0) {
|
|
212
|
+
inDisplayMath = !inDisplayMath;
|
|
213
|
+
if (inDisplayMath) {
|
|
214
|
+
mathStartLine = lineNum;
|
|
215
|
+
mathStartCol = line.indexOf("$$") + 1;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// Check headings
|
|
220
|
+
const headingMatch = line.match(/^(\s*#{1,6}\s+)(.*)$/);
|
|
221
|
+
if (headingMatch && headingMatch[2].length > 80) {
|
|
222
|
+
issues.push({
|
|
223
|
+
rule: "long-heading",
|
|
224
|
+
severity: "warning",
|
|
225
|
+
message: `Heading is ${headingMatch[2].length} characters long; consider shortening for presentation readability.`,
|
|
226
|
+
line: lineNum,
|
|
227
|
+
column: headingMatch[1].length + 1,
|
|
228
|
+
slide: currentSlide,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
// Check image directives
|
|
232
|
+
for (const image of scanImages(line, scanBudget)) {
|
|
233
|
+
const alt = image.alt.trim();
|
|
234
|
+
const title = image.title;
|
|
235
|
+
const col = image.index + 1;
|
|
236
|
+
if (!alt) {
|
|
237
|
+
issues.push({
|
|
238
|
+
rule: "missing-image-alt",
|
|
239
|
+
severity: "warning",
|
|
240
|
+
message: "Image is missing alt text.",
|
|
241
|
+
line: lineNum,
|
|
242
|
+
column: col,
|
|
243
|
+
slide: currentSlide,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
if (title) {
|
|
247
|
+
const opMatch = title.toLowerCase().match(/opacity[=:]?\s*([^\s"]+)/);
|
|
248
|
+
if (opMatch) {
|
|
249
|
+
const val = parseFloat(opMatch[1]);
|
|
250
|
+
if (isNaN(val) || val < 0 || val > 1) {
|
|
251
|
+
issues.push({
|
|
252
|
+
rule: "invalid-image-opacity",
|
|
253
|
+
severity: "warning",
|
|
254
|
+
message: `Invalid image opacity '${sanitizeForTerminal(opMatch[1], 40)}'; expected a number between 0 and 1.`,
|
|
255
|
+
line: lineNum,
|
|
256
|
+
column: col,
|
|
257
|
+
slide: currentSlide,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (inCodeFence) {
|
|
266
|
+
issues.push({
|
|
267
|
+
rule: "unclosed-code-fence",
|
|
268
|
+
severity: "error",
|
|
269
|
+
message: "Code fence was opened but never closed.",
|
|
270
|
+
line: fenceStartLine,
|
|
271
|
+
column: fenceStartCol,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
if (inDisplayMath) {
|
|
275
|
+
issues.push({
|
|
276
|
+
rule: "unclosed-math",
|
|
277
|
+
severity: "error",
|
|
278
|
+
message: "Display math block was opened but never closed.",
|
|
279
|
+
line: mathStartLine,
|
|
280
|
+
column: mathStartCol,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
// Per-slide checks
|
|
284
|
+
for (const s of slides) {
|
|
285
|
+
const slideContent = s.lines.join("\n").trim();
|
|
286
|
+
if (!slideContent) {
|
|
287
|
+
issues.push({
|
|
288
|
+
rule: "empty-slide",
|
|
289
|
+
severity: "warning",
|
|
290
|
+
message: `Slide ${s.slideIndex} is empty.`,
|
|
291
|
+
line: s.startLine,
|
|
292
|
+
column: 1,
|
|
293
|
+
slide: s.slideIndex,
|
|
294
|
+
});
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
// Check bullet density
|
|
298
|
+
const bullets = s.lines.filter((l) => /^\s*([-*+]|\d+[.)])\s+/.test(l));
|
|
299
|
+
if (bullets.length > 8) {
|
|
300
|
+
issues.push({
|
|
301
|
+
rule: "dense-slide",
|
|
302
|
+
severity: "warning",
|
|
303
|
+
message: `Slide ${s.slideIndex} has ${bullets.length} bullets (recommended maximum is 8).`,
|
|
304
|
+
line: s.startLine,
|
|
305
|
+
column: 1,
|
|
306
|
+
slide: s.slideIndex,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
// Check reveal markers
|
|
310
|
+
const revealCount = (slideContent.match(/\{reveal\}/g) || []).length;
|
|
311
|
+
if (revealCount > 10) {
|
|
312
|
+
issues.push({
|
|
313
|
+
rule: "reveal-excessive",
|
|
314
|
+
severity: "warning",
|
|
315
|
+
message: `Slide ${s.slideIndex} has ${revealCount} reveal markers (recommended maximum is 10).`,
|
|
316
|
+
line: s.startLine,
|
|
317
|
+
column: 1,
|
|
318
|
+
slide: s.slideIndex,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const errors = issues.filter((i) => i.severity === "error").length;
|
|
323
|
+
const warnings = issues.filter((i) => i.severity === "warning").length;
|
|
324
|
+
return {
|
|
325
|
+
slides: slides.length,
|
|
326
|
+
errors,
|
|
327
|
+
warnings,
|
|
328
|
+
issues,
|
|
329
|
+
};
|
|
330
|
+
}
|
package/dist/parser.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { marked } from "marked";
|
|
2
|
+
import { sanitizeSlideHtml } from "./sanitize.js";
|
|
3
|
+
function escapeHtml(value) {
|
|
4
|
+
return value
|
|
5
|
+
.replace(/&/g, "&")
|
|
6
|
+
.replace(/</g, "<")
|
|
7
|
+
.replace(/>/g, ">")
|
|
8
|
+
.replace(/"/g, """);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Locates a math block without letting the regex engine walk the document.
|
|
12
|
+
*
|
|
13
|
+
* The obvious pattern for this is `^\$\$[ \t]*\n?([\s\S]+?)\n?[ \t]*\$\$`,
|
|
14
|
+
* but marked hands block tokenizers the *entire* remaining source, so the
|
|
15
|
+
* lazy group is expanded one character at a time and the greedy `[ \t]*`
|
|
16
|
+
* in front of the closing fence re-consumes and gives back the whitespace
|
|
17
|
+
* run on every one of those expansions. An unclosed `$$` followed by a few
|
|
18
|
+
* megabytes of spaces is then quadratic, and one `POST /__parse` pins the
|
|
19
|
+
* server's only thread. Scanning for the fence with `indexOf` is linear.
|
|
20
|
+
*/
|
|
21
|
+
function matchMathBlock(src, open, close) {
|
|
22
|
+
if (!src.startsWith(open))
|
|
23
|
+
return;
|
|
24
|
+
// The opening fence may be followed by blanks and an optional newline.
|
|
25
|
+
const head = /^[ \t]*\n?/.exec(src.slice(open.length))[0];
|
|
26
|
+
const bodyStart = open.length + head.length;
|
|
27
|
+
for (let cursor = bodyStart;;) {
|
|
28
|
+
const at = src.indexOf(close, cursor);
|
|
29
|
+
if (at < 0)
|
|
30
|
+
return;
|
|
31
|
+
// A fence only closes the block when the rest of its line is blank.
|
|
32
|
+
const tail = /^[ \t]*(?:\n|$)/.exec(src.slice(at + close.length));
|
|
33
|
+
if (!tail) {
|
|
34
|
+
cursor = at + close.length;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const body = src.slice(bodyStart, at).replace(/\n?[ \t]*$/, "");
|
|
38
|
+
if (!body)
|
|
39
|
+
return;
|
|
40
|
+
return {
|
|
41
|
+
raw: src.slice(0, at + close.length + tail[0].length),
|
|
42
|
+
text: body.trim(),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Capture TeX before the regular Markdown tokenizer sees it. This keeps
|
|
48
|
+
* operators such as `*` and `_` inside a formula instead of turning them into
|
|
49
|
+
* emphasis. The browser can then render these deliberately marked nodes with
|
|
50
|
+
* KaTeX after fonts and layout styles are available.
|
|
51
|
+
*/
|
|
52
|
+
marked.use({
|
|
53
|
+
extensions: [
|
|
54
|
+
{
|
|
55
|
+
name: "deckrunBlockMath",
|
|
56
|
+
level: "block",
|
|
57
|
+
tokenizer(src) {
|
|
58
|
+
const match = matchMathBlock(src, "$$", "$$") ?? matchMathBlock(src, "\\[", "\\]");
|
|
59
|
+
if (!match)
|
|
60
|
+
return;
|
|
61
|
+
return {
|
|
62
|
+
type: "deckrunBlockMath",
|
|
63
|
+
raw: match.raw,
|
|
64
|
+
text: match.text,
|
|
65
|
+
display: true,
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
renderer(token) {
|
|
69
|
+
return `<div class="math-source" data-display="true">${escapeHtml(String(token.text))}</div>\n`;
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "deckrunInlineMath",
|
|
74
|
+
level: "inline",
|
|
75
|
+
start(src) {
|
|
76
|
+
const dollar = src.indexOf("$");
|
|
77
|
+
const paren = src.indexOf("\\(");
|
|
78
|
+
if (dollar < 0)
|
|
79
|
+
return paren < 0 ? undefined : paren;
|
|
80
|
+
if (paren < 0)
|
|
81
|
+
return dollar;
|
|
82
|
+
return Math.min(dollar, paren);
|
|
83
|
+
},
|
|
84
|
+
tokenizer(src) {
|
|
85
|
+
// A closing dollar followed by a digit is treated as currency rather
|
|
86
|
+
// than math, so ordinary prose like "$5 and $10" stays untouched.
|
|
87
|
+
const dollars = /^\$(?!\s|\$)((?:\\.|[^\\$\n])*?[^\\$\s])\$(?!\$|\d)/.exec(src);
|
|
88
|
+
const parens = /^\\\(((?:\\.|[^\\\n])*?)\\\)/.exec(src);
|
|
89
|
+
const match = dollars ?? parens;
|
|
90
|
+
if (!match)
|
|
91
|
+
return;
|
|
92
|
+
return {
|
|
93
|
+
type: "deckrunInlineMath",
|
|
94
|
+
raw: match[0],
|
|
95
|
+
text: match[1],
|
|
96
|
+
display: false,
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
renderer(token) {
|
|
100
|
+
return `<span class="math-source" data-display="false">${escapeHtml(String(token.text))}</span>`;
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "deckrunRevealMarker",
|
|
105
|
+
level: "inline",
|
|
106
|
+
start(src) {
|
|
107
|
+
const at = src.indexOf("{reveal}");
|
|
108
|
+
return at < 0 ? undefined : at;
|
|
109
|
+
},
|
|
110
|
+
tokenizer(src) {
|
|
111
|
+
const match = /^\{reveal\}/.exec(src);
|
|
112
|
+
if (!match)
|
|
113
|
+
return;
|
|
114
|
+
return { type: "deckrunRevealMarker", raw: match[0] };
|
|
115
|
+
},
|
|
116
|
+
renderer() {
|
|
117
|
+
return '<span class="deckrun-fragment-marker" aria-hidden="true"></span>';
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
});
|
|
122
|
+
const NOTES_OPEN = /^<!--[ \t\r\n]{0,64}notes?:/i;
|
|
123
|
+
/**
|
|
124
|
+
* Lifts `<!-- notes: … -->` comments out of a slide and returns what is left.
|
|
125
|
+
*
|
|
126
|
+
* The comments are located with `indexOf` rather than with a global
|
|
127
|
+
* `/<!--\s*notes?:\s*[\s\S]*?-->/g`. That pattern restarts its lazy scan at
|
|
128
|
+
* every `<!--` in the slide, so a deck built from repeated unclosed
|
|
129
|
+
* `<!--notes:` openers costs O(n²) — reachable unauthenticated through
|
|
130
|
+
* `POST /__parse` with a 32 MB body.
|
|
131
|
+
*/
|
|
132
|
+
function extractNotes(raw) {
|
|
133
|
+
let notes;
|
|
134
|
+
let body = "";
|
|
135
|
+
let from = 0;
|
|
136
|
+
for (;;) {
|
|
137
|
+
const open = raw.indexOf("<!--", from);
|
|
138
|
+
if (open < 0)
|
|
139
|
+
break;
|
|
140
|
+
if (!NOTES_OPEN.test(raw.slice(open, open + 80))) {
|
|
141
|
+
// Some other comment: keep it, and resume past its opener.
|
|
142
|
+
body += raw.slice(from, open + 4);
|
|
143
|
+
from = open + 4;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const close = raw.indexOf("-->", open);
|
|
147
|
+
if (close < 0)
|
|
148
|
+
break; // unclosed notes comment: leave the tail untouched
|
|
149
|
+
if (notes === undefined) {
|
|
150
|
+
notes = raw.slice(open, close).replace(NOTES_OPEN, "").trim();
|
|
151
|
+
}
|
|
152
|
+
body += raw.slice(from, open);
|
|
153
|
+
from = close + 3;
|
|
154
|
+
}
|
|
155
|
+
return { notes, body: body + raw.slice(from) };
|
|
156
|
+
}
|
|
157
|
+
function parseImageDirective(title) {
|
|
158
|
+
if (!title)
|
|
159
|
+
return { position: "inline", opacity: 1 };
|
|
160
|
+
const t = title.trim().toLowerCase();
|
|
161
|
+
let position = "inline";
|
|
162
|
+
if (t.includes("right"))
|
|
163
|
+
position = "right";
|
|
164
|
+
else if (t.includes("left"))
|
|
165
|
+
position = "left";
|
|
166
|
+
else if (t.includes("bg"))
|
|
167
|
+
position = "bg";
|
|
168
|
+
const opacityMatch = t.match(/opacity[=:]?\s*([0-9]*\.?[0-9]+)/);
|
|
169
|
+
const opacity = opacityMatch
|
|
170
|
+
? Math.min(1, Math.max(0, parseFloat(opacityMatch[1])))
|
|
171
|
+
: 1;
|
|
172
|
+
return { position, opacity };
|
|
173
|
+
}
|
|
174
|
+
export function parseSlides(markdown) {
|
|
175
|
+
// Normalize line endings
|
|
176
|
+
const normalized = markdown.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
177
|
+
// Split on slide separator: "---" on its own line (with optional surrounding blank lines)
|
|
178
|
+
const rawSlides = normalized.split(/\n[ \t]*---[ \t]*\n/);
|
|
179
|
+
return rawSlides
|
|
180
|
+
.map((raw) => raw.trim())
|
|
181
|
+
.filter((raw) => raw.length > 0)
|
|
182
|
+
.map((raw) => {
|
|
183
|
+
const slide = { html: "" };
|
|
184
|
+
// Extract speaker notes (<!-- notes: ... --> at end)
|
|
185
|
+
const { notes, body } = extractNotes(raw);
|
|
186
|
+
if (notes !== undefined)
|
|
187
|
+
slide.notes = notes;
|
|
188
|
+
let processedMd = body;
|
|
189
|
+
// Find positioned images via title attribute: 
|
|
190
|
+
const imgRegex = /!\[([^\]]*)\]\(([^\s)]+)(?:\s+"([^"]*)")?\)/g;
|
|
191
|
+
const toRemove = [];
|
|
192
|
+
let match;
|
|
193
|
+
// Reset lastIndex before use since we're reusing the regex
|
|
194
|
+
imgRegex.lastIndex = 0;
|
|
195
|
+
while ((match = imgRegex.exec(raw)) !== null) {
|
|
196
|
+
const [full, alt, src, titleAttr] = match;
|
|
197
|
+
const { position, opacity } = parseImageDirective(titleAttr);
|
|
198
|
+
if (position === "bg") {
|
|
199
|
+
slide.bgImage = { src, alt, opacity };
|
|
200
|
+
toRemove.push(full);
|
|
201
|
+
}
|
|
202
|
+
else if (position === "right") {
|
|
203
|
+
slide.rightImage = { src, alt, opacity };
|
|
204
|
+
toRemove.push(full);
|
|
205
|
+
}
|
|
206
|
+
else if (position === "left") {
|
|
207
|
+
slide.leftImage = { src, alt, opacity };
|
|
208
|
+
toRemove.push(full);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
for (const item of toRemove) {
|
|
212
|
+
// Replace only first occurrence (the matched image)
|
|
213
|
+
processedMd = processedMd.replace(item, "");
|
|
214
|
+
}
|
|
215
|
+
// Render remaining markdown to HTML, then strip anything executable.
|
|
216
|
+
// marked has had no sanitize option since v8, so without this a deck
|
|
217
|
+
// someone else wrote runs its own JavaScript on open.
|
|
218
|
+
slide.html = sanitizeSlideHtml(marked.parse(processedMd.trim()));
|
|
219
|
+
return slide;
|
|
220
|
+
});
|
|
221
|
+
}
|