deckrun 1.4.0 → 1.6.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/index.js CHANGED
@@ -3,7 +3,7 @@ import { readFileSync } from "fs";
3
3
  import { readFile } from "fs/promises";
4
4
  import { createServer } from "http";
5
5
  import { createRequire } from "module";
6
- import { resolve, dirname, basename, extname } from "path";
6
+ import { resolve, dirname, basename, extname, join, isAbsolute, relative } from "path";
7
7
  import { Command } from "commander";
8
8
  import open from "open";
9
9
  import { parseSlides } from "./parser.js";
@@ -12,6 +12,9 @@ import { DEFAULT_SIZE, DEFAULT_THEME, findFont, findSize, findTheme, fontListing
12
12
  import { generateEditorHtml } from "./editor.js";
13
13
  import { generatePreviewHtml } from "./preview.js";
14
14
  import { findBrowser, renderPdfSerial, PdfError } from "./pdf.js";
15
+ import { DEFAULT_TEMPLATE, DEFAULT_TRANSITION, findTemplate, findTransition, resolveTemplateName, resolveTransitionName, templateListing, transitionListing, } from "./presentation-options.js";
16
+ import { lintMarkdown } from "./lint.js";
17
+ const moduleRequire = createRequire(import.meta.url);
15
18
  const c = {
16
19
  reset: "\x1b[0m",
17
20
  bold: "\x1b[1m",
@@ -23,8 +26,7 @@ const c = {
23
26
  };
24
27
  function packageVersion() {
25
28
  try {
26
- const require = createRequire(import.meta.url);
27
- return require("../package.json").version;
29
+ return moduleRequire("../package.json").version;
28
30
  }
29
31
  catch {
30
32
  return "0.0.0";
@@ -135,6 +137,21 @@ function sendJson(res, payload) {
135
137
  });
136
138
  res.end(JSON.stringify(payload));
137
139
  }
140
+ /** Resolve bundled math/diagram assets installed with the npm package. */
141
+ function vendorAsset(pathname) {
142
+ const name = pathname.replace(/^\/__vendor\/?/, "");
143
+ if (name === "katex.min.css" || name === "katex.min.js" || name.startsWith("fonts/")) {
144
+ const katexDist = dirname(moduleRequire.resolve("katex"));
145
+ const target = resolve(katexDist, name);
146
+ const fromRoot = relative(katexDist, target);
147
+ return !isAbsolute(fromRoot) && !fromRoot.startsWith("..") ? target : null;
148
+ }
149
+ if (name === "mermaid.min.js") {
150
+ const mermaidDist = dirname(moduleRequire.resolve("mermaid"));
151
+ return join(mermaidDist, "mermaid.min.js");
152
+ }
153
+ return null;
154
+ }
138
155
  /** Decks built from editor content, addressable so a new tab can load them. */
139
156
  const decks = new Map();
140
157
  let deckSeq = 0;
@@ -168,7 +185,7 @@ function serveStashedDeck(id, res) {
168
185
  }
169
186
  async function handleEditorRoute(mode, pathname, req, res) {
170
187
  if (pathname === "/__preview" && req.method === "GET") {
171
- sendHtml(res, generatePreviewHtml(mode.theme, mode.size, mode.fonts));
188
+ sendHtml(res, generatePreviewHtml(mode.theme, mode.size, mode.fonts, mode.template, mode.transition));
172
189
  return true;
173
190
  }
174
191
  if (pathname === "/__parse" && req.method === "POST") {
@@ -191,13 +208,15 @@ async function handleEditorRoute(mode, pathname, req, res) {
191
208
  }
192
209
  const theme = resolveThemeName(body.theme);
193
210
  const size = resolveSizeName(body.size);
211
+ const template = resolveTemplateName(body.template);
212
+ const transition = resolveTransitionName(body.transition);
194
213
  const title = deckTitle(slides, body.title?.trim() || "deckrun");
195
214
  // A deck built for printing must not open behind a fullscreen prompt.
196
215
  const forPrint = body.print === true;
197
216
  const path = stashDeck(generateHtml(slides, title, forPrint ? false : mode.fullscreen, theme, size, {
198
217
  head: body.head,
199
218
  body: body.body,
200
- }));
219
+ }, { template, transition, standalone: body.standalone === true }));
201
220
  sendJson(res, { path: forPrint ? `${path}&print=1` : path });
202
221
  return true;
203
222
  }
@@ -221,8 +240,10 @@ async function handleEditorRoute(mode, pathname, req, res) {
221
240
  }
222
241
  const theme = resolveThemeName(body.theme);
223
242
  const size = resolveSizeName(body.size);
243
+ const template = resolveTemplateName(body.template);
244
+ const transition = resolveTransitionName(body.transition);
224
245
  const title = deckTitle(slides, body.title?.trim() || "deckrun");
225
- const path = stashDeck(generateHtml(slides, title, false, theme, size, { head: body.head, body: body.body }));
246
+ const path = stashDeck(generateHtml(slides, title, false, theme, size, { head: body.head, body: body.body }, { template, transition }));
226
247
  try {
227
248
  const pdf = await renderPdfSerial(`${mode.origin}${path}`, browser);
228
249
  const filename = safeFilename(body.title?.trim() || title) + ".pdf";
@@ -406,19 +427,63 @@ async function serve(mode, baseDir, port) {
406
427
  else
407
428
  sendHtml(res, mode.kind === "deck"
408
429
  ? mode.html
409
- : generateEditorHtml(mode.theme, mode.size, mode.fonts));
430
+ : generateEditorHtml(mode.theme, mode.size, mode.fonts, mode.template, mode.transition));
410
431
  return;
411
432
  }
412
433
  if (mode.kind === "deck" && mode.remoteDoc && pathname === "/__remote-doc") {
413
434
  sendHtml(res, mode.remoteDoc);
414
435
  return;
415
436
  }
437
+ if (pathname.startsWith("/__vendor/")) {
438
+ const asset = vendorAsset(pathname);
439
+ if (!asset) {
440
+ res.writeHead(404, { "Content-Type": "text/plain" });
441
+ res.end("Not found");
442
+ return;
443
+ }
444
+ const data = await readFile(asset);
445
+ res.writeHead(200, {
446
+ "Content-Type": getMime(asset),
447
+ "Cache-Control": "public, max-age=31536000, immutable",
448
+ });
449
+ res.end(data);
450
+ return;
451
+ }
452
+ if (mode.kind === "deck" && pathname === "/__pdf") {
453
+ const browser = await findBrowser();
454
+ if (!browser) {
455
+ res.writeHead(501, { "Content-Type": "application/json" });
456
+ res.end(JSON.stringify({
457
+ error: "no browser",
458
+ detail: "No Chrome, Chromium, Edge, or Brave found. Set DECKRUN_BROWSER to one to export PDFs directly.",
459
+ }));
460
+ return;
461
+ }
462
+ try {
463
+ const pdf = await renderPdfSerial(`${mode.origin}/`, browser);
464
+ const filename = safeFilename(mode.title || "deck") + ".pdf";
465
+ res.writeHead(200, {
466
+ "Content-Type": "application/pdf",
467
+ "Content-Length": pdf.length,
468
+ "Content-Disposition": `attachment; filename="${filename}"`,
469
+ "Cache-Control": "no-store",
470
+ });
471
+ res.end(pdf);
472
+ }
473
+ catch (err) {
474
+ const detail = err instanceof PdfError ? err.message : "rendering failed";
475
+ res.writeHead(500, { "Content-Type": "application/json" });
476
+ res.end(JSON.stringify({ error: "render failed", detail }));
477
+ }
478
+ return;
479
+ }
416
480
  if (mode.kind === "editor" && (await handleEditorRoute(mode, pathname, req, res))) {
417
481
  return;
418
482
  }
419
483
  // Everything else comes off disk, relative to the working directory.
420
484
  const filePath = resolve(baseDir, pathname.replace(/^\/+/, ""));
421
- if (filePath !== baseDir && !filePath.startsWith(baseDir + "/")) {
485
+ const fromBase = relative(baseDir, filePath);
486
+ if (isAbsolute(fromBase) || fromBase.startsWith("..")) {
422
487
  res.writeHead(403);
423
488
  res.end("Forbidden");
424
489
  return;
@@ -457,9 +522,13 @@ program
457
522
  .option("--size <name>", "Type size: s, m, l, or xl", DEFAULT_SIZE)
458
523
  .option("--head-font <name>", "Override the theme's heading face (see --list-fonts)")
459
524
  .option("--body-font <name>", "Override the theme's body face (see --list-fonts)")
525
+ .option("--template <name>", "Composition template (see --list-templates)", DEFAULT_TEMPLATE)
526
+ .option("--transition <name>", "Slide transition (see --list-transitions)", DEFAULT_TRANSITION)
460
527
  .option("--list-themes", "Print every theme and exit")
461
528
  .option("--list-sizes", "Print every type size and exit")
462
529
  .option("--list-fonts", "Print every font face and exit")
530
+ .option("--list-templates", "Print every composition template and exit")
531
+ .option("--list-transitions", "Print every slide transition and exit")
463
532
  .action(async (file, opts) => {
464
533
  if (opts.listThemes) {
465
534
  for (const line of themeListing())
@@ -476,6 +545,16 @@ program
476
545
  console.log(line);
477
546
  process.exit(0);
478
547
  }
548
+ if (opts.listTemplates) {
549
+ for (const line of templateListing())
550
+ console.log(line);
551
+ process.exit(0);
552
+ }
553
+ if (opts.listTransitions) {
554
+ for (const line of transitionListing())
555
+ console.log(line);
556
+ process.exit(0);
557
+ }
479
558
  const named = findTheme(opts.theme);
480
559
  if (!named) {
481
560
  console.error(`deckrun: unknown theme '${opts.theme}'. Run --list-themes to see them all.`);
@@ -486,6 +565,16 @@ program
486
565
  console.error(`deckrun: unknown size '${opts.size}'. Run --list-sizes to see them all.`);
487
566
  process.exit(1);
488
567
  }
568
+ const templated = findTemplate(opts.template);
569
+ if (!templated) {
570
+ console.error(`deckrun: unknown template '${opts.template}'. Run --list-templates to see them all.`);
571
+ process.exit(1);
572
+ }
573
+ const transitioned = findTransition(opts.transition);
574
+ if (!transitioned) {
575
+ console.error(`deckrun: unknown transition '${opts.transition}'. Run --list-transitions to see them all.`);
576
+ process.exit(1);
577
+ }
489
578
  // Both face flags are optional; unset means the theme keeps its own.
490
579
  const fonts = { head: null, body: null };
491
580
  for (const [flag, slot] of [
@@ -504,6 +593,8 @@ program
504
593
  }
505
594
  const theme = named;
506
595
  const size = sized;
596
+ const template = templated;
597
+ const transition = transitioned;
507
598
  const fullscreen = !!opts.fullscreen;
508
599
  let mode;
509
600
  let baseDir;
@@ -562,8 +653,9 @@ program
562
653
  baseDir = process.cwd();
563
654
  const defaultName = target.pathname.split("/").filter(Boolean).pop() || target.hostname;
564
655
  if (isHtml) {
565
- if (opts.size !== DEFAULT_SIZE || opts.headFont || opts.bodyFont) {
566
- console.error(`${c.dim}deckrun: --size, --head-font, and --body-font only apply to Markdown decks; ignored for an HTML doc.${c.reset}`);
656
+ if (opts.size !== DEFAULT_SIZE || opts.headFont || opts.bodyFont ||
657
+ opts.template !== DEFAULT_TEMPLATE || opts.transition !== DEFAULT_TRANSITION) {
658
+ console.error(`${c.dim}deckrun: --size, font, template, and transition options only apply to Markdown decks; ignored for an HTML doc.${c.reset}`);
567
659
  }
568
660
  const title = docTitle(rawContent, defaultName);
569
661
  let docHtml = rawContent;
@@ -591,13 +683,13 @@ program
591
683
  const title = deckTitle(slides, defaultName);
592
684
  mode = {
593
685
  kind: "deck",
594
- html: generateHtml(slides, title, fullscreen, theme, size, fonts),
686
+ html: generateHtml(slides, title, fullscreen, theme, size, fonts, { template, transition }),
595
687
  };
596
688
  const faces = [
597
689
  fonts.head ? `head ${fontName(fonts.head)}` : "",
598
690
  fonts.body ? `body ${fontName(fonts.body)}` : "",
599
691
  ].filter(Boolean).join(" · ");
600
- console.log(`${c.dim}${slides.length} slide${slides.length !== 1 ? "s" : ""} from ${file} · ${THEMES[theme].label} · type ${size}${faces ? " · " + faces : ""}${c.reset}`);
692
+ console.log(`${c.dim}${slides.length} slide${slides.length !== 1 ? "s" : ""} from ${file} · ${THEMES[theme].label} · ${template} · ${transition} · type ${size}${faces ? " · " + faces : ""}${c.reset}`);
601
693
  }
602
694
  }
603
695
  else {
@@ -613,8 +705,9 @@ program
613
705
  console.error(`deckrun: cannot read file '${file}'`);
614
706
  process.exit(1);
615
707
  }
616
- if (opts.size !== DEFAULT_SIZE || opts.headFont || opts.bodyFont) {
617
- console.error(`${c.dim}deckrun: --size, --head-font, and --body-font only apply to Markdown decks; ignored for an HTML doc.${c.reset}`);
708
+ if (opts.size !== DEFAULT_SIZE || opts.headFont || opts.bodyFont ||
709
+ opts.template !== DEFAULT_TEMPLATE || opts.transition !== DEFAULT_TRANSITION) {
710
+ console.error(`${c.dim}deckrun: --size, font, template, and transition options only apply to Markdown decks; ignored for an HTML doc.${c.reset}`);
618
711
  }
619
712
  const title = docTitle(rawHtml, basename(absPath, extname(absPath)));
620
713
  mode = {
@@ -640,33 +733,106 @@ program
640
733
  const title = deckTitle(slides, basename(absPath, extname(absPath)));
641
734
  mode = {
642
735
  kind: "deck",
643
- html: generateHtml(slides, title, fullscreen, theme, size, fonts),
736
+ title,
737
+ html: generateHtml(slides, title, fullscreen, theme, size, fonts, { template, transition }),
644
738
  };
645
739
  const faces = [
646
740
  fonts.head ? `head ${fontName(fonts.head)}` : "",
647
741
  fonts.body ? `body ${fontName(fonts.body)}` : "",
648
742
  ].filter(Boolean).join(" · ");
649
- console.log(`${c.dim}${slides.length} slide${slides.length !== 1 ? "s" : ""} from ${basename(absPath)} · ${THEMES[theme].label} · type ${size}${faces ? " · " + faces : ""}${c.reset}`);
743
+ console.log(`${c.dim}${slides.length} slide${slides.length !== 1 ? "s" : ""} from ${basename(absPath)} · ${THEMES[theme].label} · ${template} · ${transition} · type ${size}${faces ? " · " + faces : ""}${c.reset}`);
650
744
  }
651
745
  }
652
746
  }
653
747
  else {
654
748
  baseDir = process.cwd();
655
- mode = { kind: "editor", theme, size, fonts, fullscreen };
749
+ mode = { kind: "editor", theme, size, fonts, template, transition, fullscreen };
656
750
  }
657
751
  const port = await findFreePort(parseInt(opts.port, 10));
658
- if (mode.kind === "editor")
659
- mode.origin = `http://127.0.0.1:${port}`;
752
+ mode.origin = `http://127.0.0.1:${port}`;
660
753
  const url = await serve(mode, baseDir, port);
661
754
  const label = mode.kind === "editor" ? "editor" : "present";
662
755
  console.log(`${c.bold}${c.magenta}${label}${c.reset} ${c.dim}→${c.reset} ${c.cyan}${c.bold}${url}${c.reset} ${c.dim}(Ctrl+C to stop)${c.reset}`);
663
756
  if (mode.kind === "editor") {
664
757
  console.log(`${c.dim}write on the left, live deck on the right. autosaves to your browser.${c.reset}`);
665
- console.log(`${c.dim}Cmd/Ctrl+K inserts anything · Cmd/Ctrl+Shift+L switches theme · Cmd/Ctrl+Enter presents${c.reset}`);
758
+ console.log(`${c.dim}Cmd/Ctrl+K inserts anything · template/theme controls recompose live · Cmd/Ctrl+Enter presents${c.reset}`);
666
759
  }
667
760
  if (opts.open !== false)
668
761
  await open(url);
669
762
  // Keep the process alive until interrupted.
670
763
  await new Promise(() => { });
671
764
  });
765
+ program
766
+ .command("lint")
767
+ .description("Check Markdown decks for common authoring and rendering problems")
768
+ .argument("<files...>", "Markdown files to check; use - to read standard input")
769
+ .option("--format <format>", "Output format: stylish or json", "stylish")
770
+ .option("--max-warnings <number>", "Warnings allowed before the command fails", "0")
771
+ .action((files, opts) => {
772
+ if (opts.format !== "stylish" && opts.format !== "json") {
773
+ console.error("deckrun lint: --format must be 'stylish' or 'json'.");
774
+ process.exitCode = 2;
775
+ return;
776
+ }
777
+ const maxWarnings = Number.parseInt(opts.maxWarnings, 10);
778
+ if (!Number.isInteger(maxWarnings) || maxWarnings < -1) {
779
+ console.error("deckrun lint: --max-warnings must be -1 or a non-negative integer.");
780
+ process.exitCode = 2;
781
+ return;
782
+ }
783
+ const reports = [];
784
+ for (const file of files) {
785
+ let markdown;
786
+ try {
787
+ markdown = file === "-" ? readFileSync(0, "utf-8") : readFileSync(resolve(process.cwd(), file), "utf-8");
788
+ }
789
+ catch {
790
+ reports.push({
791
+ file,
792
+ slides: 0,
793
+ errors: 1,
794
+ warnings: 0,
795
+ issues: [{
796
+ rule: "file-read",
797
+ severity: "error",
798
+ message: "The file could not be read.",
799
+ line: 1,
800
+ column: 1,
801
+ }],
802
+ });
803
+ continue;
804
+ }
805
+ const result = lintMarkdown(markdown);
806
+ reports.push({ file, ...result });
807
+ }
808
+ const errors = reports.reduce((sum, report) => sum + report.errors, 0);
809
+ const warnings = reports.reduce((sum, report) => sum + report.warnings, 0);
810
+ const issueCount = errors + warnings;
811
+ if (opts.format === "json") {
812
+ console.log(JSON.stringify({ files: reports, errors, warnings }, null, 2));
813
+ }
814
+ else {
815
+ for (const report of reports) {
816
+ if (!report.issues.length)
817
+ continue;
818
+ console.log(`\n${report.file}`);
819
+ for (const item of report.issues) {
820
+ const position = `${item.line}:${item.column}`.padEnd(9);
821
+ const severity = item.severity.padEnd(7);
822
+ const slide = item.slide ? `slide ${item.slide} · ` : "";
823
+ console.log(` ${position} ${severity} ${slide}${item.message} ${item.rule}`);
824
+ }
825
+ }
826
+ if (issueCount === 0) {
827
+ const slides = reports.reduce((sum, report) => sum + report.slides, 0);
828
+ console.log(`✓ ${files.length} file${files.length === 1 ? "" : "s"}, ${slides} slide${slides === 1 ? "" : "s"}, no problems`);
829
+ }
830
+ else {
831
+ console.log(`\n✖ ${issueCount} problem${issueCount === 1 ? "" : "s"} (${errors} error${errors === 1 ? "" : "s"}, ${warnings} warning${warnings === 1 ? "" : "s"})`);
832
+ }
833
+ }
834
+ if (errors > 0 || (maxWarnings !== -1 && warnings > maxWarnings)) {
835
+ process.exitCode = 1;
836
+ }
837
+ });
672
838
  program.parse(process.argv);
package/dist/lint.js ADDED
@@ -0,0 +1,218 @@
1
+ export function lintMarkdown(markdown) {
2
+ const issues = [];
3
+ const trimmed = markdown.trim();
4
+ if (!trimmed) {
5
+ issues.push({
6
+ rule: "empty-deck",
7
+ severity: "error",
8
+ message: "The deck is empty.",
9
+ line: 1,
10
+ column: 1,
11
+ });
12
+ return {
13
+ slides: 0,
14
+ errors: 1,
15
+ warnings: 0,
16
+ issues,
17
+ };
18
+ }
19
+ const lines = markdown.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
20
+ const slides = [];
21
+ let curSlideLines = [];
22
+ let curStartLine = 1;
23
+ let slideIndex = 1;
24
+ for (let i = 0; i < lines.length; i++) {
25
+ const line = lines[i];
26
+ if (/^[ \t]*---[ \t]*$/.test(line)) {
27
+ slides.push({
28
+ slideIndex,
29
+ startLine: curStartLine,
30
+ endLine: i,
31
+ lines: curSlideLines,
32
+ });
33
+ slideIndex++;
34
+ curStartLine = i + 2;
35
+ curSlideLines = [];
36
+ }
37
+ else {
38
+ curSlideLines.push(line);
39
+ }
40
+ }
41
+ slides.push({
42
+ slideIndex,
43
+ startLine: curStartLine,
44
+ endLine: lines.length,
45
+ lines: curSlideLines,
46
+ });
47
+ // Global & Slide checks
48
+ let inCodeFence = false;
49
+ let fenceStartLine = 1;
50
+ let fenceStartCol = 1;
51
+ let inDisplayMath = false;
52
+ let mathStartLine = 1;
53
+ let mathStartCol = 1;
54
+ for (let i = 0; i < lines.length; i++) {
55
+ const lineNum = i + 1;
56
+ const line = lines[i];
57
+ // Determine current slide number
58
+ const currentSlide = slides.find((s) => lineNum >= s.startLine && lineNum <= s.endLine)?.slideIndex ??
59
+ 1;
60
+ // Check code fences
61
+ const fenceMatch = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/);
62
+ if (fenceMatch) {
63
+ if (!inCodeFence) {
64
+ inCodeFence = true;
65
+ fenceStartLine = lineNum;
66
+ fenceStartCol = fenceMatch[1].length + 1;
67
+ const tag = fenceMatch[3].trim();
68
+ if (!tag) {
69
+ issues.push({
70
+ rule: "untagged-code-fence",
71
+ severity: "warning",
72
+ message: "Code fence has no language tag for syntax highlighting.",
73
+ line: lineNum,
74
+ column: fenceStartCol,
75
+ slide: currentSlide,
76
+ });
77
+ }
78
+ }
79
+ else {
80
+ inCodeFence = false;
81
+ }
82
+ }
83
+ // Check display math
84
+ if (!inCodeFence) {
85
+ if (/^\s*\$\$\s*$/.test(line) || /^\s*\\\[\s*$/.test(line)) {
86
+ if (!inDisplayMath) {
87
+ inDisplayMath = true;
88
+ mathStartLine = lineNum;
89
+ mathStartCol = 1;
90
+ }
91
+ else {
92
+ inDisplayMath = false;
93
+ }
94
+ }
95
+ else if (line.includes("$$")) {
96
+ const occurrences = (line.match(/\$\$/g) || []).length;
97
+ if (occurrences % 2 !== 0) {
98
+ inDisplayMath = !inDisplayMath;
99
+ if (inDisplayMath) {
100
+ mathStartLine = lineNum;
101
+ mathStartCol = line.indexOf("$$") + 1;
102
+ }
103
+ }
104
+ }
105
+ // Check headings
106
+ const headingMatch = line.match(/^(\s*#{1,6}\s+)(.*)$/);
107
+ if (headingMatch && headingMatch[2].length > 80) {
108
+ issues.push({
109
+ rule: "long-heading",
110
+ severity: "warning",
111
+ message: `Heading is ${headingMatch[2].length} characters long; consider shortening for presentation readability.`,
112
+ line: lineNum,
113
+ column: headingMatch[1].length + 1,
114
+ slide: currentSlide,
115
+ });
116
+ }
117
+ // Check image directives
118
+ const imgRegex = /!\[([^\]]*)\]\(([^\s)]+)(?:\s+"([^"]*)")?\)/g;
119
+ let imgMatch;
120
+ while ((imgMatch = imgRegex.exec(line)) !== null) {
121
+ const alt = imgMatch[1].trim();
122
+ const title = imgMatch[3] ?? "";
123
+ const col = imgMatch.index + 1;
124
+ if (!alt) {
125
+ issues.push({
126
+ rule: "missing-image-alt",
127
+ severity: "warning",
128
+ message: "Image is missing alt text.",
129
+ line: lineNum,
130
+ column: col,
131
+ slide: currentSlide,
132
+ });
133
+ }
134
+ if (title) {
135
+ const opMatch = title.toLowerCase().match(/opacity[=:]?\s*([^\s"]+)/);
136
+ if (opMatch) {
137
+ const val = parseFloat(opMatch[1]);
138
+ if (isNaN(val) || val < 0 || val > 1) {
139
+ issues.push({
140
+ rule: "invalid-image-opacity",
141
+ severity: "warning",
142
+ message: `Invalid image opacity '${opMatch[1]}'; expected a number between 0 and 1.`,
143
+ line: lineNum,
144
+ column: col,
145
+ slide: currentSlide,
146
+ });
147
+ }
148
+ }
149
+ }
150
+ }
151
+ }
152
+ }
153
+ if (inCodeFence) {
154
+ issues.push({
155
+ rule: "unclosed-code-fence",
156
+ severity: "error",
157
+ message: "Code fence was opened but never closed.",
158
+ line: fenceStartLine,
159
+ column: fenceStartCol,
160
+ });
161
+ }
162
+ if (inDisplayMath) {
163
+ issues.push({
164
+ rule: "unclosed-math",
165
+ severity: "error",
166
+ message: "Display math block was opened but never closed.",
167
+ line: mathStartLine,
168
+ column: mathStartCol,
169
+ });
170
+ }
171
+ // Per-slide checks
172
+ for (const s of slides) {
173
+ const slideContent = s.lines.join("\n").trim();
174
+ if (!slideContent) {
175
+ issues.push({
176
+ rule: "empty-slide",
177
+ severity: "warning",
178
+ message: `Slide ${s.slideIndex} is empty.`,
179
+ line: s.startLine,
180
+ column: 1,
181
+ slide: s.slideIndex,
182
+ });
183
+ continue;
184
+ }
185
+ // Check bullet density
186
+ const bullets = s.lines.filter((l) => /^\s*([-*+]|\d+[.)])\s+/.test(l));
187
+ if (bullets.length > 8) {
188
+ issues.push({
189
+ rule: "dense-slide",
190
+ severity: "warning",
191
+ message: `Slide ${s.slideIndex} has ${bullets.length} bullets (recommended maximum is 8).`,
192
+ line: s.startLine,
193
+ column: 1,
194
+ slide: s.slideIndex,
195
+ });
196
+ }
197
+ // Check reveal markers
198
+ const revealCount = (slideContent.match(/\{reveal\}/g) || []).length;
199
+ if (revealCount > 10) {
200
+ issues.push({
201
+ rule: "reveal-excessive",
202
+ severity: "warning",
203
+ message: `Slide ${s.slideIndex} has ${revealCount} reveal markers (recommended maximum is 10).`,
204
+ line: s.startLine,
205
+ column: 1,
206
+ slide: s.slideIndex,
207
+ });
208
+ }
209
+ }
210
+ const errors = issues.filter((i) => i.severity === "error").length;
211
+ const warnings = issues.filter((i) => i.severity === "warning").length;
212
+ return {
213
+ slides: slides.length,
214
+ errors,
215
+ warnings,
216
+ issues,
217
+ };
218
+ }
package/dist/parser.js CHANGED
@@ -1,4 +1,89 @@
1
1
  import { marked } from "marked";
2
+ function escapeHtml(value) {
3
+ return value
4
+ .replace(/&/g, "&amp;")
5
+ .replace(/</g, "&lt;")
6
+ .replace(/>/g, "&gt;")
7
+ .replace(/"/g, "&quot;");
8
+ }
9
+ /**
10
+ * Capture TeX before the regular Markdown tokenizer sees it. This keeps
11
+ * operators such as `*` and `_` inside a formula instead of turning them into
12
+ * emphasis. The browser can then render these deliberately marked nodes with
13
+ * KaTeX after fonts and layout styles are available.
14
+ */
15
+ marked.use({
16
+ extensions: [
17
+ {
18
+ name: "deckrunBlockMath",
19
+ level: "block",
20
+ tokenizer(src) {
21
+ const dollars = /^\$\$[ \t]*\n?([\s\S]+?)\n?[ \t]*\$\$(?:[ \t]*(?:\n|$))/.exec(src);
22
+ const brackets = /^\\\[[ \t]*\n?([\s\S]+?)\n?[ \t]*\\\](?:[ \t]*(?:\n|$))/.exec(src);
23
+ const match = dollars ?? brackets;
24
+ if (!match)
25
+ return;
26
+ return {
27
+ type: "deckrunBlockMath",
28
+ raw: match[0],
29
+ text: match[1].trim(),
30
+ display: true,
31
+ };
32
+ },
33
+ renderer(token) {
34
+ return `<div class="math-source" data-display="true">${escapeHtml(String(token.text))}</div>\n`;
35
+ },
36
+ },
37
+ {
38
+ name: "deckrunInlineMath",
39
+ level: "inline",
40
+ start(src) {
41
+ const dollar = src.indexOf("$");
42
+ const paren = src.indexOf("\\(");
43
+ if (dollar < 0)
44
+ return paren < 0 ? undefined : paren;
45
+ if (paren < 0)
46
+ return dollar;
47
+ return Math.min(dollar, paren);
48
+ },
49
+ tokenizer(src) {
50
+ // A closing dollar followed by a digit is treated as currency rather
51
+ // than math, so ordinary prose like "$5 and $10" stays untouched.
52
+ const dollars = /^\$(?!\s|\$)((?:\\.|[^\\$\n])*?[^\\$\s])\$(?!\$|\d)/.exec(src);
53
+ const parens = /^\\\(((?:\\.|[^\\\n])*?)\\\)/.exec(src);
54
+ const match = dollars ?? parens;
55
+ if (!match)
56
+ return;
57
+ return {
58
+ type: "deckrunInlineMath",
59
+ raw: match[0],
60
+ text: match[1],
61
+ display: false,
62
+ };
63
+ },
64
+ renderer(token) {
65
+ return `<span class="math-source" data-display="false">${escapeHtml(String(token.text))}</span>`;
66
+ },
67
+ },
68
+ {
69
+ name: "deckrunRevealMarker",
70
+ level: "inline",
71
+ start(src) {
72
+ const at = src.indexOf("{reveal}");
73
+ return at < 0 ? undefined : at;
74
+ },
75
+ tokenizer(src) {
76
+ const match = /^\{reveal\}/.exec(src);
77
+ if (!match)
78
+ return;
79
+ return { type: "deckrunRevealMarker", raw: match[0] };
80
+ },
81
+ renderer() {
82
+ return '<span class="deckrun-fragment-marker" aria-hidden="true"></span>';
83
+ },
84
+ },
85
+ ],
86
+ });
2
87
  function parseImageDirective(title) {
3
88
  if (!title)
4
89
  return { position: "inline", opacity: 1 };
package/dist/pdf.js CHANGED
@@ -161,7 +161,11 @@ export async function renderPdf(url, browser) {
161
161
  "--mute-audio",
162
162
  // Never touch the browser profile the person is actually using.
163
163
  `--user-data-dir=${join(dir, "profile")}`,
164
- "--virtual-time-budget=5000",
164
+ // Mermaid performs an asynchronous layout pass after its local script has
165
+ // loaded. Give that pass room to settle and flush every compositor stage
166
+ // before Chrome snapshots the pages.
167
+ "--virtual-time-budget=10000",
168
+ "--run-all-compositor-stages-before-draw",
165
169
  // Header/footer flag names differ across versions; unknown switches are ignored.
166
170
  "--no-pdf-header-footer",
167
171
  "--print-to-pdf-no-header",