deckrun 1.3.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";
@@ -281,8 +302,8 @@ async function handleEditorRoute(mode, pathname, req, res) {
281
302
  res.end(JSON.stringify({ error: "fetch failed", detail: `upstream responded ${upstream.status}` }));
282
303
  return true;
283
304
  }
284
- const html = await upstream.text();
285
- if (html.length > MAX_BODY) {
305
+ const rawContent = await upstream.text();
306
+ if (rawContent.length > MAX_BODY) {
286
307
  res.writeHead(413, { "Content-Type": "application/json" });
287
308
  res.end(JSON.stringify({
288
309
  error: "too large",
@@ -290,12 +311,48 @@ async function handleEditorRoute(mode, pathname, req, res) {
290
311
  }));
291
312
  return true;
292
313
  }
293
- if (!html.trim()) {
314
+ if (!rawContent.trim()) {
294
315
  res.writeHead(422, { "Content-Type": "application/json" });
295
316
  res.end(JSON.stringify({ error: "empty document" }));
296
317
  return true;
297
318
  }
298
- sendJson(res, { html, title: docTitle(html, target.hostname) });
319
+ const contentType = (upstream.headers.get("content-type") ?? "").toLowerCase();
320
+ const pathname = target.pathname.toLowerCase();
321
+ let isHtml = false;
322
+ if (pathname.endsWith(".html") || pathname.endsWith(".htm")) {
323
+ isHtml = true;
324
+ }
325
+ else if (pathname.endsWith(".md") || pathname.endsWith(".markdown")) {
326
+ isHtml = false;
327
+ }
328
+ else if (contentType.includes("text/html") ||
329
+ contentType.includes("application/xhtml+xml")) {
330
+ isHtml = true;
331
+ }
332
+ else if (contentType.includes("text/markdown") ||
333
+ contentType.includes("text/x-markdown") ||
334
+ contentType.includes("text/plain")) {
335
+ isHtml = false;
336
+ }
337
+ else if (/<!doctype\s+html/i.test(rawContent) || /<html[\s>]/i.test(rawContent)) {
338
+ isHtml = true;
339
+ }
340
+ const defaultName = target.pathname.split("/").filter(Boolean).pop() || target.hostname;
341
+ let title;
342
+ if (isHtml) {
343
+ title = docTitle(rawContent, defaultName);
344
+ }
345
+ else {
346
+ const slides = parseSlides(rawContent);
347
+ title = deckTitle(slides, defaultName);
348
+ }
349
+ sendJson(res, {
350
+ kind: isHtml ? "html" : "markdown",
351
+ content: rawContent,
352
+ html: isHtml ? rawContent : undefined,
353
+ markdown: !isHtml ? rawContent : undefined,
354
+ title,
355
+ });
299
356
  return true;
300
357
  }
301
358
  if (pathname === "/__present-doc" && req.method === "POST") {
@@ -370,7 +427,54 @@ async function serve(mode, baseDir, port) {
370
427
  else
371
428
  sendHtml(res, mode.kind === "deck"
372
429
  ? mode.html
373
- : generateEditorHtml(mode.theme, mode.size, mode.fonts));
430
+ : generateEditorHtml(mode.theme, mode.size, mode.fonts, mode.template, mode.transition));
431
+ return;
432
+ }
433
+ if (mode.kind === "deck" && mode.remoteDoc && pathname === "/__remote-doc") {
434
+ sendHtml(res, mode.remoteDoc);
435
+ return;
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
+ }
374
478
  return;
375
479
  }
376
480
  if (mode.kind === "editor" && (await handleEditorRoute(mode, pathname, req, res))) {
@@ -378,7 +482,8 @@ async function serve(mode, baseDir, port) {
378
482
  }
379
483
  // Everything else comes off disk, relative to the working directory.
380
484
  const filePath = resolve(baseDir, pathname.replace(/^\/+/, ""));
381
- if (filePath !== baseDir && !filePath.startsWith(baseDir + "/")) {
485
+ const fromBase = relative(baseDir, filePath);
486
+ if (isAbsolute(fromBase) || fromBase.startsWith("..")) {
382
487
  res.writeHead(403);
383
488
  res.end("Forbidden");
384
489
  return;
@@ -407,9 +512,9 @@ async function serve(mode, baseDir, port) {
407
512
  const program = new Command();
408
513
  program
409
514
  .name("deckrun")
410
- .description("Present a Markdown file in the browser. Run without a file to write one in the built-in editor.")
515
+ .description("Present a Markdown file, HTML file, or public URL in the browser. Run without a file or URL to write in the built-in editor.")
411
516
  .version(packageVersion(), "-v, --version", "Print the version number")
412
- .argument("[file]", "Markdown file to present. Omit it to open the editor.")
517
+ .argument("[file]", "Markdown file, HTML file, or public URL to present. Omit it to open the editor.")
413
518
  .option("-p, --port <number>", "Port to serve on", "7890")
414
519
  .option("--no-open", "Do not automatically open the browser")
415
520
  .option("--fullscreen", "Auto-enter fullscreen on first interaction")
@@ -417,9 +522,13 @@ program
417
522
  .option("--size <name>", "Type size: s, m, l, or xl", DEFAULT_SIZE)
418
523
  .option("--head-font <name>", "Override the theme's heading face (see --list-fonts)")
419
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)
420
527
  .option("--list-themes", "Print every theme and exit")
421
528
  .option("--list-sizes", "Print every type size and exit")
422
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")
423
532
  .action(async (file, opts) => {
424
533
  if (opts.listThemes) {
425
534
  for (const line of themeListing())
@@ -436,6 +545,16 @@ program
436
545
  console.log(line);
437
546
  process.exit(0);
438
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
+ }
439
558
  const named = findTheme(opts.theme);
440
559
  if (!named) {
441
560
  console.error(`deckrun: unknown theme '${opts.theme}'. Run --list-themes to see them all.`);
@@ -446,6 +565,16 @@ program
446
565
  console.error(`deckrun: unknown size '${opts.size}'. Run --list-sizes to see them all.`);
447
566
  process.exit(1);
448
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
+ }
449
578
  // Both face flags are optional; unset means the theme keeps its own.
450
579
  const fonts = { head: null, body: null };
451
580
  for (const [flag, slot] of [
@@ -464,75 +593,246 @@ program
464
593
  }
465
594
  const theme = named;
466
595
  const size = sized;
596
+ const template = templated;
597
+ const transition = transitioned;
467
598
  const fullscreen = !!opts.fullscreen;
468
599
  let mode;
469
600
  let baseDir;
470
601
  if (file) {
471
- const absPath = resolve(process.cwd(), file);
472
- baseDir = dirname(absPath);
473
- const ext = extname(absPath).toLowerCase();
474
- if (ext === ".html" || ext === ".htm") {
475
- let rawHtml;
602
+ if (/^https?:\/\//i.test(file)) {
603
+ let target;
476
604
  try {
477
- rawHtml = readFileSync(absPath, "utf-8");
605
+ target = new URL(file);
478
606
  }
479
607
  catch {
480
- console.error(`deckrun: cannot read file '${file}'`);
608
+ console.error(`deckrun: invalid URL '${file}'`);
481
609
  process.exit(1);
482
610
  }
483
- if (opts.size !== DEFAULT_SIZE || opts.headFont || opts.bodyFont) {
484
- console.error(`${c.dim}deckrun: --size, --head-font, and --body-font only apply to Markdown decks; ignored for an HTML doc.${c.reset}`);
485
- }
486
- const title = docTitle(rawHtml, basename(absPath, extname(absPath)));
487
- mode = {
488
- kind: "deck",
489
- html: generateDocHtml(`/${basename(absPath)}`, title, fullscreen, theme),
490
- };
491
- console.log(`${c.dim}presenting ${basename(absPath)} · ${THEMES[theme].label}${c.reset}`);
492
- }
493
- else {
494
- let markdown;
611
+ let upstream;
495
612
  try {
496
- markdown = readFileSync(absPath, "utf-8");
613
+ upstream = await fetch(target, {
614
+ redirect: "follow",
615
+ signal: AbortSignal.timeout(15_000),
616
+ headers: { "User-Agent": "deckrun" },
617
+ });
497
618
  }
498
- catch {
499
- console.error(`deckrun: cannot read file '${file}'`);
619
+ catch (err) {
620
+ console.error(`deckrun: cannot fetch '${file}': ${err instanceof Error ? err.message : "network error"}`);
621
+ process.exit(1);
622
+ }
623
+ if (!upstream.ok) {
624
+ console.error(`deckrun: fetch failed for '${file}' (HTTP ${upstream.status})`);
500
625
  process.exit(1);
501
626
  }
502
- const slides = parseSlides(markdown);
503
- if (slides.length === 0) {
504
- console.error("deckrun: no slides found in the file.");
627
+ const rawContent = await upstream.text();
628
+ if (!rawContent.trim()) {
629
+ console.error(`deckrun: empty document fetched from '${file}'`);
505
630
  process.exit(1);
506
631
  }
507
- const title = deckTitle(slides, basename(absPath, extname(absPath)));
508
- mode = {
509
- kind: "deck",
510
- html: generateHtml(slides, title, fullscreen, theme, size, fonts),
511
- };
512
- const faces = [
513
- fonts.head ? `head ${fontName(fonts.head)}` : "",
514
- fonts.body ? `body ${fontName(fonts.body)}` : "",
515
- ].filter(Boolean).join(" · ");
516
- console.log(`${c.dim}${slides.length} slide${slides.length !== 1 ? "s" : ""} from ${basename(absPath)} · ${THEMES[theme].label} · type ${size}${faces ? " · " + faces : ""}${c.reset}`);
632
+ const contentType = (upstream.headers.get("content-type") ?? "").toLowerCase();
633
+ const pathname = target.pathname.toLowerCase();
634
+ let isHtml = false;
635
+ if (pathname.endsWith(".html") || pathname.endsWith(".htm")) {
636
+ isHtml = true;
637
+ }
638
+ else if (pathname.endsWith(".md") || pathname.endsWith(".markdown")) {
639
+ isHtml = false;
640
+ }
641
+ else if (contentType.includes("text/html") ||
642
+ contentType.includes("application/xhtml+xml")) {
643
+ isHtml = true;
644
+ }
645
+ else if (contentType.includes("text/markdown") ||
646
+ contentType.includes("text/x-markdown") ||
647
+ contentType.includes("text/plain")) {
648
+ isHtml = false;
649
+ }
650
+ else if (/<!doctype\s+html/i.test(rawContent) || /<html[\s>]/i.test(rawContent)) {
651
+ isHtml = true;
652
+ }
653
+ baseDir = process.cwd();
654
+ const defaultName = target.pathname.split("/").filter(Boolean).pop() || target.hostname;
655
+ if (isHtml) {
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}`);
659
+ }
660
+ const title = docTitle(rawContent, defaultName);
661
+ let docHtml = rawContent;
662
+ if (!/<base\s/i.test(docHtml)) {
663
+ if (/<head[^>]*>/i.test(docHtml)) {
664
+ docHtml = docHtml.replace(/<head[^>]*>/i, (m) => `${m}\n <base href="${target.href}">`);
665
+ }
666
+ else {
667
+ docHtml = `<base href="${target.href}">\n` + docHtml;
668
+ }
669
+ }
670
+ mode = {
671
+ kind: "deck",
672
+ html: generateDocHtml("/__remote-doc", title, fullscreen, theme),
673
+ remoteDoc: docHtml,
674
+ };
675
+ console.log(`${c.dim}presenting ${file} · ${THEMES[theme].label}${c.reset}`);
676
+ }
677
+ else {
678
+ const slides = parseSlides(rawContent);
679
+ if (slides.length === 0) {
680
+ console.error("deckrun: no slides found in the fetched content.");
681
+ process.exit(1);
682
+ }
683
+ const title = deckTitle(slides, defaultName);
684
+ mode = {
685
+ kind: "deck",
686
+ html: generateHtml(slides, title, fullscreen, theme, size, fonts, { template, transition }),
687
+ };
688
+ const faces = [
689
+ fonts.head ? `head ${fontName(fonts.head)}` : "",
690
+ fonts.body ? `body ${fontName(fonts.body)}` : "",
691
+ ].filter(Boolean).join(" · ");
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}`);
693
+ }
694
+ }
695
+ else {
696
+ const absPath = resolve(process.cwd(), file);
697
+ baseDir = dirname(absPath);
698
+ const ext = extname(absPath).toLowerCase();
699
+ if (ext === ".html" || ext === ".htm") {
700
+ let rawHtml;
701
+ try {
702
+ rawHtml = readFileSync(absPath, "utf-8");
703
+ }
704
+ catch {
705
+ console.error(`deckrun: cannot read file '${file}'`);
706
+ process.exit(1);
707
+ }
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}`);
711
+ }
712
+ const title = docTitle(rawHtml, basename(absPath, extname(absPath)));
713
+ mode = {
714
+ kind: "deck",
715
+ html: generateDocHtml(`/${basename(absPath)}`, title, fullscreen, theme),
716
+ };
717
+ console.log(`${c.dim}presenting ${basename(absPath)} · ${THEMES[theme].label}${c.reset}`);
718
+ }
719
+ else {
720
+ let markdown;
721
+ try {
722
+ markdown = readFileSync(absPath, "utf-8");
723
+ }
724
+ catch {
725
+ console.error(`deckrun: cannot read file '${file}'`);
726
+ process.exit(1);
727
+ }
728
+ const slides = parseSlides(markdown);
729
+ if (slides.length === 0) {
730
+ console.error("deckrun: no slides found in the file.");
731
+ process.exit(1);
732
+ }
733
+ const title = deckTitle(slides, basename(absPath, extname(absPath)));
734
+ mode = {
735
+ kind: "deck",
736
+ title,
737
+ html: generateHtml(slides, title, fullscreen, theme, size, fonts, { template, transition }),
738
+ };
739
+ const faces = [
740
+ fonts.head ? `head ${fontName(fonts.head)}` : "",
741
+ fonts.body ? `body ${fontName(fonts.body)}` : "",
742
+ ].filter(Boolean).join(" · ");
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}`);
744
+ }
517
745
  }
518
746
  }
519
747
  else {
520
748
  baseDir = process.cwd();
521
- mode = { kind: "editor", theme, size, fonts, fullscreen };
749
+ mode = { kind: "editor", theme, size, fonts, template, transition, fullscreen };
522
750
  }
523
751
  const port = await findFreePort(parseInt(opts.port, 10));
524
- if (mode.kind === "editor")
525
- mode.origin = `http://127.0.0.1:${port}`;
752
+ mode.origin = `http://127.0.0.1:${port}`;
526
753
  const url = await serve(mode, baseDir, port);
527
754
  const label = mode.kind === "editor" ? "editor" : "present";
528
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}`);
529
756
  if (mode.kind === "editor") {
530
757
  console.log(`${c.dim}write on the left, live deck on the right. autosaves to your browser.${c.reset}`);
531
- 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}`);
532
759
  }
533
760
  if (opts.open !== false)
534
761
  await open(url);
535
762
  // Keep the process alive until interrupted.
536
763
  await new Promise(() => { });
537
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
+ });
538
838
  program.parse(process.argv);