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/README.md +205 -32
- package/dist/editor-content.js +85 -0
- package/dist/editor.js +401 -23
- package/dist/fragments.js +71 -0
- package/dist/generate.js +1032 -37
- package/dist/index.js +186 -20
- package/dist/lint.js +218 -0
- package/dist/parser.js +85 -0
- package/dist/pdf.js +5 -1
- package/dist/presentation-options.js +287 -0
- package/dist/preview.js +45 -7
- package/dist/rich-content.js +170 -0
- package/dist/themes.js +2 -0
- package/package.json +5 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ·
|
|
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, "&")
|
|
5
|
+
.replace(/</g, "<")
|
|
6
|
+
.replace(/>/g, ">")
|
|
7
|
+
.replace(/"/g, """);
|
|
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
|
-
|
|
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",
|