@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/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
+ * `![](` repeated with no closing paren the run swallows the rest of the line
21
+ * and then backtracks over all of it — from every `![` on the line. One
22
+ * megabyte of that is roughly L^2/8 steps, which parks a CI runner until the
23
+ * job times out on a diff that looks unremarkable.
24
+ *
25
+ * The scanner below walks the line once with a cursor instead. Runs are
26
+ * bounded, and the whole file gets a fixed character budget so no crafted
27
+ * line can turn linting into a denial of service.
28
+ */
29
+ const MAX_ALT = 500;
30
+ const MAX_URL = 2048;
31
+ const MAX_TITLE = 500;
32
+ const SCAN_BUDGET = 4_000_000;
33
+ /**
34
+ * Finds `![alt](url "title")` 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, "&amp;")
6
+ .replace(/</g, "&lt;")
7
+ .replace(/>/g, "&gt;")
8
+ .replace(/"/g, "&quot;");
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: ![alt](src "right opacity:0.7")
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
+ }