@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/index.js ADDED
@@ -0,0 +1,1020 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, writeFileSync, watch } from "fs";
3
+ import { readFile } from "fs/promises";
4
+ import { createServer } from "http";
5
+ import { createRequire } from "module";
6
+ import { randomBytes, timingSafeEqual } from "crypto";
7
+ import { resolve, dirname, basename, extname, join, isAbsolute, relative } from "path";
8
+ import { Command } from "commander";
9
+ import open from "open";
10
+ import { parseSlides } from "./parser.js";
11
+ import { deckTitle, docTitle } from "./titles.js";
12
+ import { generateHtml, generateDocHtml, renderSlide } from "./generate.js";
13
+ import { DEFAULT_THEME, findFont, findTheme, fontListing, resolveThemeName, themeListing, } from "./themes.js";
14
+ import { generateEditorHtml } from "./editor.js";
15
+ import { generatePreviewHtml } from "./preview.js";
16
+ import { findBrowser, renderPdfSerial, PdfError } from "./pdf.js";
17
+ import { DEFAULT_TEMPLATE, DEFAULT_TRANSITION, findTemplate, findTransition, resolveTemplateName, resolveTransitionName, templateListing, transitionListing, } from "./presentation-options.js";
18
+ import { lintMarkdown, sanitizeForTerminal } from "./lint.js";
19
+ import { safeFetch, BlockedAddressError } from "./safe-fetch.js";
20
+ const moduleRequire = createRequire(import.meta.url);
21
+ const c = {
22
+ reset: "\x1b[0m",
23
+ bold: "\x1b[1m",
24
+ dim: "\x1b[2m",
25
+ cyan: "\x1b[36m",
26
+ green: "\x1b[32m",
27
+ yellow: "\x1b[33m",
28
+ magenta: "\x1b[35m",
29
+ };
30
+ function packageVersion() {
31
+ try {
32
+ return moduleRequire("../package.json").version;
33
+ }
34
+ catch {
35
+ return "0.0.0";
36
+ }
37
+ }
38
+ const MIME = {
39
+ ".html": "text/html; charset=utf-8",
40
+ ".htm": "text/html; charset=utf-8",
41
+ ".css": "text/css",
42
+ ".js": "application/javascript",
43
+ ".mjs": "application/javascript",
44
+ ".json": "application/json",
45
+ ".md": "text/markdown; charset=utf-8",
46
+ ".txt": "text/plain; charset=utf-8",
47
+ ".png": "image/png",
48
+ ".jpg": "image/jpeg",
49
+ ".jpeg": "image/jpeg",
50
+ ".gif": "image/gif",
51
+ ".svg": "image/svg+xml",
52
+ ".webp": "image/webp",
53
+ ".ico": "image/x-icon",
54
+ ".avif": "image/avif",
55
+ ".mp4": "video/mp4",
56
+ ".webm": "video/webm",
57
+ ".woff": "font/woff",
58
+ ".woff2": "font/woff2",
59
+ ".ttf": "font/ttf",
60
+ };
61
+ function getMime(filepath) {
62
+ return MIME[extname(filepath).toLowerCase()] ?? "application/octet-stream";
63
+ }
64
+ /**
65
+ * Whether a path under the launch directory may be served.
66
+ *
67
+ * The static route exists so that `![](diagram.png)` in a deck resolves, and
68
+ * a deck references assets. It used to serve anything at or below baseDir —
69
+ * traversal *above* it was blocked, but everything inside was not restricted
70
+ * at all, and `deckrun` with no file argument uses the current directory. A
71
+ * user opening a blank editor from their home directory published
72
+ * `.aws/credentials`, `.ssh/id_rsa`, `.env` and `.npmrc` on a known port to
73
+ * every other process, sandboxed app and browser extension on the machine.
74
+ *
75
+ * Only the asset types already enumerated in the MIME table are servable, and
76
+ * no path segment may be a dotfile.
77
+ */
78
+ function servableAsset(relativePath) {
79
+ const segments = relativePath.split(/[\\/]/).filter(Boolean);
80
+ if (segments.length === 0)
81
+ return false;
82
+ if (segments.some((segment) => segment.startsWith(".")))
83
+ return false;
84
+ return Object.hasOwn(MIME, extname(segments[segments.length - 1]).toLowerCase());
85
+ }
86
+ async function findFreePort(preferred) {
87
+ return new Promise((resolvePort) => {
88
+ const server = createServer();
89
+ server.listen(preferred, () => {
90
+ const addr = server.address();
91
+ server.close(() => resolvePort(addr.port));
92
+ });
93
+ server.on("error", () => {
94
+ // Preferred port is taken, take whatever the OS offers.
95
+ const fallback = createServer();
96
+ fallback.listen(0, () => {
97
+ const addr = fallback.address();
98
+ fallback.close(() => resolvePort(addr.port));
99
+ });
100
+ });
101
+ });
102
+ }
103
+ /** A deck name reduced to something safe for a Content-Disposition header. */
104
+ function safeFilename(name) {
105
+ const slug = name
106
+ .trim()
107
+ .toLowerCase()
108
+ .replace(/\.(md|markdown)$/, "")
109
+ .replace(/[^a-z0-9._-]+/g, "-")
110
+ .replace(/^-|-$/g, "");
111
+ return slug || "deck";
112
+ }
113
+ /**
114
+ * A secret minted per server run and handed only to the editor page.
115
+ *
116
+ * Every `/__` route used to answer any caller. A POST with
117
+ * `Content-Type: text/plain` is a CORS *simple* request, so the browser sends
118
+ * it with no preflight and the response being opaque does not matter — the
119
+ * write, the stash, or the fetch has already happened. Any site the user
120
+ * visits while deckrun is running could overwrite the open file, or stash
121
+ * attacker HTML and have it served back from deckrun's own origin.
122
+ */
123
+ const SESSION_TOKEN = randomBytes(24).toString("base64url");
124
+ /** Constant-time comparison, so a wrong token leaks nothing by timing. */
125
+ function tokenMatches(given) {
126
+ if (!given)
127
+ return false;
128
+ const a = Buffer.from(given);
129
+ const b = Buffer.from(SESSION_TOKEN);
130
+ return a.length === b.length && timingSafeEqual(a, b);
131
+ }
132
+ /**
133
+ * Whether a request carries the session token, in the header the editor's
134
+ * fetch() calls set or — for an EventSource and the preview frame, neither of
135
+ * which can set headers — in the query string.
136
+ */
137
+ function authorized(req, query) {
138
+ const header = req.headers["x-deckrun-token"];
139
+ const given = Array.isArray(header) ? header[0] : header;
140
+ return tokenMatches(given) || tokenMatches(query.get("token") ?? undefined);
141
+ }
142
+ /**
143
+ * Rejects a state-changing request whose Origin is another site.
144
+ *
145
+ * The token is what actually closes CSRF; this is the second layer, and it
146
+ * also refuses the `text/plain` content type that made these requests
147
+ * preflight-free in the first place.
148
+ */
149
+ function sameOriginPost(req, port) {
150
+ const origin = req.headers.origin;
151
+ if (typeof origin === "string" && origin !== "null") {
152
+ if (origin !== `http://127.0.0.1:${port}` &&
153
+ origin !== `http://localhost:${port}` &&
154
+ origin !== `http://[::1]:${port}`) {
155
+ return false;
156
+ }
157
+ }
158
+ const site = req.headers["sec-fetch-site"];
159
+ if (typeof site === "string" && site !== "same-origin" && site !== "none")
160
+ return false;
161
+ return true;
162
+ }
163
+ const MAX_BODY = 32 * 1024 * 1024;
164
+ /** Ceiling on a file `deckrun lint` will read, so CI cannot be parked on one. */
165
+ const MAX_LINT_INPUT = 8 * 1024 * 1024;
166
+ function readBody(req) {
167
+ return new Promise((resolveBody, rejectBody) => {
168
+ const chunks = [];
169
+ let size = 0;
170
+ let overflowed = false;
171
+ req.on("data", (chunk) => {
172
+ if (overflowed)
173
+ return;
174
+ size += chunk.length;
175
+ if (size > MAX_BODY) {
176
+ // Reject now but keep the socket open long enough to answer with 413.
177
+ overflowed = true;
178
+ chunks.length = 0;
179
+ rejectBody(new Error("request body too large"));
180
+ return;
181
+ }
182
+ chunks.push(chunk);
183
+ });
184
+ req.on("end", () => {
185
+ if (!overflowed)
186
+ resolveBody(Buffer.concat(chunks).toString("utf-8"));
187
+ });
188
+ req.on("error", rejectBody);
189
+ });
190
+ }
191
+ function sendHtml(res, html) {
192
+ res.writeHead(200, {
193
+ "Content-Type": "text/html; charset=utf-8",
194
+ "Cache-Control": "no-store",
195
+ // Nothing prevented a cross-origin page from framing the editor or the
196
+ // preview and posting into it. It cannot read the frame, but it can post
197
+ // to it, and the handlers wrote what arrived to innerHTML. Both headers
198
+ // are sent because the older one still governs some contexts.
199
+ "X-Frame-Options": "SAMEORIGIN",
200
+ "Content-Security-Policy": "frame-ancestors 'self'",
201
+ });
202
+ res.end(html);
203
+ }
204
+ function sendJson(res, payload) {
205
+ res.writeHead(200, {
206
+ "Content-Type": "application/json; charset=utf-8",
207
+ "Cache-Control": "no-store",
208
+ });
209
+ res.end(JSON.stringify(payload));
210
+ }
211
+ /** Resolve bundled math/diagram assets installed with the npm package. */
212
+ function vendorAsset(pathname) {
213
+ const name = pathname.replace(/^\/__vendor\/?/, "");
214
+ if (name === "katex.min.css" || name === "katex.min.js" || name.startsWith("fonts/")) {
215
+ const katexDist = dirname(moduleRequire.resolve("katex"));
216
+ const target = resolve(katexDist, name);
217
+ const fromRoot = relative(katexDist, target);
218
+ return !isAbsolute(fromRoot) && !fromRoot.startsWith("..") ? target : null;
219
+ }
220
+ if (name === "mermaid.min.js") {
221
+ const mermaidDist = dirname(moduleRequire.resolve("mermaid"));
222
+ return join(mermaidDist, "mermaid.min.js");
223
+ }
224
+ return null;
225
+ }
226
+ /** Editor tabs holding an open /__events stream, waiting on a reload ping. */
227
+ const liveClients = new Set();
228
+ function notifyLiveClients() {
229
+ for (const client of liveClients) {
230
+ try {
231
+ client.write("data: reload\n\n");
232
+ }
233
+ catch {
234
+ liveClients.delete(client);
235
+ }
236
+ }
237
+ }
238
+ /**
239
+ * The content most recently written to the file by the editor itself, so
240
+ * the watcher can tell the editor's own save landing on disk apart from an
241
+ * external edit and not bounce it back as a reload.
242
+ */
243
+ let lastEditorWrite = null;
244
+ /**
245
+ * Watches the opened file and pings the editor when it changes on disk.
246
+ *
247
+ * The parent directory is watched rather than the file itself so editors
248
+ * that save by atomic rename (vim, VS Code, …) do not silently detach the
249
+ * watcher. Events are debounced because a single save fires several.
250
+ */
251
+ function watchSourceFile(absPath) {
252
+ const name = basename(absPath);
253
+ let timer;
254
+ try {
255
+ watch(dirname(absPath), (_event, filename) => {
256
+ if (filename && filename !== name)
257
+ return;
258
+ clearTimeout(timer);
259
+ timer = setTimeout(() => {
260
+ let content;
261
+ try {
262
+ content = readFileSync(absPath, "utf-8");
263
+ }
264
+ catch {
265
+ return; // mid-save window of an atomic rename; the next event retries
266
+ }
267
+ if (content === lastEditorWrite)
268
+ return; // our own save landing
269
+ notifyLiveClients();
270
+ }, 150);
271
+ });
272
+ }
273
+ catch {
274
+ console.error(`${c.dim}deckrun: cannot watch '${name}' for changes; live reload is off.${c.reset}`);
275
+ }
276
+ }
277
+ /** Decks built from editor content, addressable so a new tab can load them. */
278
+ const decks = new Map();
279
+ /**
280
+ * Stores a built deck and returns the path that serves it.
281
+ *
282
+ * The path stays at the root on purpose: a deck served from a subpath would
283
+ * resolve `![](diagram.png)` against that subpath instead of the directory
284
+ * being served, and every local image would 404.
285
+ */
286
+ function stashDeck(html) {
287
+ // Unguessable, because the stash is served back as text/html from this
288
+ // origin. A monotonic counter meant an attacker who had stashed a payload
289
+ // could simply frame `?deck=1` through `?deck=8` to run it.
290
+ const id = randomBytes(16).toString("hex");
291
+ decks.set(id, html);
292
+ // Keep only the handful of most recent builds.
293
+ for (const key of decks.keys()) {
294
+ if (decks.size <= 8)
295
+ break;
296
+ decks.delete(key);
297
+ }
298
+ return `/?deck=${id}`;
299
+ }
300
+ /** A built deck, addressed by `?deck=<id>` so its base URL stays the root. */
301
+ function serveStashedDeck(id, res) {
302
+ const html = decks.get(id);
303
+ if (!html) {
304
+ res.writeHead(410, { "Content-Type": "text/plain; charset=utf-8" });
305
+ res.end("This build has expired. Press present again in the editor.");
306
+ return;
307
+ }
308
+ sendHtml(res, html);
309
+ }
310
+ async function handleEditorRoute(mode, pathname, req, res, query, port) {
311
+ // Every /__ route is part of the editor session, so all of them are gated
312
+ // on the token rather than only the ones that write.
313
+ if (!authorized(req, query)) {
314
+ res.writeHead(403, { "Content-Type": "application/json" });
315
+ res.end(JSON.stringify({ error: "forbidden", detail: "missing or invalid session token" }));
316
+ return true;
317
+ }
318
+ if (req.method === "POST" && !sameOriginPost(req, port)) {
319
+ res.writeHead(403, { "Content-Type": "application/json" });
320
+ res.end(JSON.stringify({ error: "forbidden", detail: "cross-origin request" }));
321
+ return true;
322
+ }
323
+ if (pathname === "/__preview" && req.method === "GET") {
324
+ sendHtml(res, generatePreviewHtml(mode.theme, mode.fonts, mode.template, mode.transition));
325
+ return true;
326
+ }
327
+ if (pathname === "/__file" && req.method === "GET" && mode.file) {
328
+ let content;
329
+ if (mode.file.path) {
330
+ try {
331
+ content = readFileSync(mode.file.path, "utf-8");
332
+ }
333
+ catch {
334
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
335
+ res.end("Cannot read the file.");
336
+ return true;
337
+ }
338
+ }
339
+ else {
340
+ content = mode.file.content ?? "";
341
+ }
342
+ res.writeHead(200, {
343
+ "Content-Type": "text/plain; charset=utf-8",
344
+ "Cache-Control": "no-store",
345
+ });
346
+ res.end(content);
347
+ return true;
348
+ }
349
+ if (pathname === "/__file" && req.method === "POST" && mode.file) {
350
+ if (!mode.file.path) {
351
+ res.writeHead(405, { "Content-Type": "application/json" });
352
+ res.end(JSON.stringify({ error: "read-only", detail: "This document came from a URL; download it to keep changes." }));
353
+ return true;
354
+ }
355
+ const content = await readBody(req);
356
+ // Remember the write before it lands so the watcher can ignore its echo.
357
+ lastEditorWrite = content;
358
+ try {
359
+ writeFileSync(mode.file.path, content, "utf-8");
360
+ }
361
+ catch {
362
+ res.writeHead(500, { "Content-Type": "application/json" });
363
+ res.end(JSON.stringify({ error: "write failed" }));
364
+ return true;
365
+ }
366
+ sendJson(res, { ok: true });
367
+ return true;
368
+ }
369
+ if (pathname === "/__events" && req.method === "GET" && mode.file?.watched) {
370
+ res.writeHead(200, {
371
+ "Content-Type": "text/event-stream",
372
+ "Cache-Control": "no-store",
373
+ Connection: "keep-alive",
374
+ });
375
+ res.write(": connected\n\n");
376
+ liveClients.add(res);
377
+ req.on("close", () => liveClients.delete(res));
378
+ return true;
379
+ }
380
+ if (pathname === "/__parse" && req.method === "POST") {
381
+ const markdown = await readBody(req);
382
+ const slides = parseSlides(markdown);
383
+ sendJson(res, {
384
+ slides: slides.map((slide, i) => renderSlide(slide, i)),
385
+ notes: slides.map((slide) => slide.notes ?? ""),
386
+ title: deckTitle(slides, ""),
387
+ });
388
+ return true;
389
+ }
390
+ if (pathname === "/__present" && req.method === "POST") {
391
+ const body = JSON.parse(await readBody(req));
392
+ const slides = parseSlides(body.markdown ?? "");
393
+ if (slides.length === 0) {
394
+ res.writeHead(422, { "Content-Type": "application/json" });
395
+ res.end(JSON.stringify({ error: "no slides" }));
396
+ return true;
397
+ }
398
+ const theme = resolveThemeName(body.theme);
399
+ const template = resolveTemplateName(body.template);
400
+ const transition = resolveTransitionName(body.transition);
401
+ const title = deckTitle(slides, body.title?.trim() || "deckrun");
402
+ // A deck built for printing must not open behind a fullscreen prompt.
403
+ const forPrint = body.print === true;
404
+ const path = stashDeck(generateHtml(slides, title, forPrint ? false : mode.fullscreen, theme, {
405
+ head: body.head,
406
+ body: body.body,
407
+ }, { template, transition, standalone: body.standalone === true }));
408
+ sendJson(res, { path: forPrint ? `${path}&print=1` : path });
409
+ return true;
410
+ }
411
+ if (pathname === "/__pdf" && req.method === "POST") {
412
+ const body = JSON.parse(await readBody(req));
413
+ const slides = parseSlides(body.markdown ?? "");
414
+ if (slides.length === 0) {
415
+ res.writeHead(422, { "Content-Type": "application/json" });
416
+ res.end(JSON.stringify({ error: "no slides" }));
417
+ return true;
418
+ }
419
+ const browser = await findBrowser();
420
+ if (!browser) {
421
+ // The caller falls back to the print dialog, which prints correctly too.
422
+ res.writeHead(501, { "Content-Type": "application/json" });
423
+ res.end(JSON.stringify({
424
+ error: "no browser",
425
+ detail: "No Chrome, Chromium, Edge, or Brave found. Set DECKRUN_BROWSER to one to export PDFs directly.",
426
+ }));
427
+ return true;
428
+ }
429
+ const theme = resolveThemeName(body.theme);
430
+ const template = resolveTemplateName(body.template);
431
+ const transition = resolveTransitionName(body.transition);
432
+ const title = deckTitle(slides, body.title?.trim() || "deckrun");
433
+ const path = stashDeck(generateHtml(slides, title, false, theme, { head: body.head, body: body.body },
434
+ // A PDF is handed to the audience: no speaker notes travel with it.
435
+ { template, transition, notes: false }));
436
+ try {
437
+ const pdf = await renderPdfSerial(`${mode.origin}${path}`, browser);
438
+ const filename = safeFilename(body.title?.trim() || title) + ".pdf";
439
+ res.writeHead(200, {
440
+ "Content-Type": "application/pdf",
441
+ "Content-Length": pdf.length,
442
+ "Content-Disposition": `attachment; filename="${filename}"`,
443
+ "Cache-Control": "no-store",
444
+ });
445
+ res.end(pdf);
446
+ }
447
+ catch (err) {
448
+ const detail = err instanceof PdfError ? err.message : "rendering failed";
449
+ res.writeHead(500, { "Content-Type": "application/json" });
450
+ res.end(JSON.stringify({ error: "render failed", detail }));
451
+ }
452
+ return true;
453
+ }
454
+ if (pathname === "/__fetch-doc" && req.method === "POST") {
455
+ const body = JSON.parse(await readBody(req));
456
+ const raw = (body.url ?? "").trim();
457
+ let target;
458
+ try {
459
+ target = new URL(raw);
460
+ }
461
+ catch {
462
+ res.writeHead(422, { "Content-Type": "application/json" });
463
+ res.end(JSON.stringify({ error: "invalid url" }));
464
+ return true;
465
+ }
466
+ if (target.protocol !== "http:" && target.protocol !== "https:") {
467
+ res.writeHead(422, { "Content-Type": "application/json" });
468
+ res.end(JSON.stringify({ error: "url must be http or https" }));
469
+ return true;
470
+ }
471
+ let upstream;
472
+ try {
473
+ // Fetched server-side, not from the browser, so a page with no
474
+ // Access-Control-Allow-Origin still loads fine. safeFetch refuses to
475
+ // connect to a private address and re-checks every redirect hop, so
476
+ // this route cannot be used to reach loopback services, the instance
477
+ // metadata endpoint, or anything else on the local network.
478
+ upstream = await safeFetch(target.href, {
479
+ maxBytes: MAX_BODY,
480
+ timeoutMs: 15_000,
481
+ });
482
+ }
483
+ catch (err) {
484
+ const blocked = err instanceof BlockedAddressError;
485
+ res.writeHead(blocked ? 403 : 502, { "Content-Type": "application/json" });
486
+ res.end(JSON.stringify({
487
+ error: blocked ? "address not allowed" : "fetch failed",
488
+ detail: err instanceof Error ? err.message : "network error",
489
+ }));
490
+ return true;
491
+ }
492
+ if (upstream.status < 200 || upstream.status >= 300) {
493
+ res.writeHead(502, { "Content-Type": "application/json" });
494
+ res.end(JSON.stringify({ error: "fetch failed", detail: `upstream responded ${upstream.status}` }));
495
+ return true;
496
+ }
497
+ if (upstream.truncated) {
498
+ res.writeHead(413, { "Content-Type": "application/json" });
499
+ res.end(JSON.stringify({
500
+ error: "too large",
501
+ detail: `page is larger than ${Math.round(MAX_BODY / 1024 / 1024)} MB`,
502
+ }));
503
+ return true;
504
+ }
505
+ const rawContent = upstream.body;
506
+ if (!rawContent.trim()) {
507
+ res.writeHead(422, { "Content-Type": "application/json" });
508
+ res.end(JSON.stringify({ error: "empty document" }));
509
+ return true;
510
+ }
511
+ const contentType = String(upstream.headers["content-type"] ?? "").toLowerCase();
512
+ // The redirect chain may have moved us; name the document by where it
513
+ // actually came from.
514
+ const finalUrl = upstream.url;
515
+ const pathname = finalUrl.pathname.toLowerCase();
516
+ let isHtml = false;
517
+ if (pathname.endsWith(".html") || pathname.endsWith(".htm")) {
518
+ isHtml = true;
519
+ }
520
+ else if (pathname.endsWith(".md") || pathname.endsWith(".markdown")) {
521
+ isHtml = false;
522
+ }
523
+ else if (contentType.includes("text/html") ||
524
+ contentType.includes("application/xhtml+xml")) {
525
+ isHtml = true;
526
+ }
527
+ else if (contentType.includes("text/markdown") ||
528
+ contentType.includes("text/x-markdown") ||
529
+ contentType.includes("text/plain")) {
530
+ isHtml = false;
531
+ }
532
+ else if (/<!doctype\s+html/i.test(rawContent) || /<html[\s>]/i.test(rawContent)) {
533
+ isHtml = true;
534
+ }
535
+ const defaultName = finalUrl.pathname.split("/").filter(Boolean).pop() || finalUrl.hostname;
536
+ let title;
537
+ if (isHtml) {
538
+ title = docTitle(rawContent, defaultName);
539
+ }
540
+ else {
541
+ const slides = parseSlides(rawContent);
542
+ title = deckTitle(slides, defaultName);
543
+ }
544
+ sendJson(res, {
545
+ kind: isHtml ? "html" : "markdown",
546
+ content: rawContent,
547
+ html: isHtml ? rawContent : undefined,
548
+ markdown: !isHtml ? rawContent : undefined,
549
+ title,
550
+ });
551
+ return true;
552
+ }
553
+ if (pathname === "/__present-doc" && req.method === "POST") {
554
+ const body = JSON.parse(await readBody(req));
555
+ const raw = body.html ?? "";
556
+ if (!raw.trim()) {
557
+ res.writeHead(422, { "Content-Type": "application/json" });
558
+ res.end(JSON.stringify({ error: "empty document" }));
559
+ return true;
560
+ }
561
+ const theme = resolveThemeName(body.theme);
562
+ const title = docTitle(raw, body.title?.trim() || "deckrun");
563
+ const forPrint = body.print === true;
564
+ const docPath = stashDeck(raw);
565
+ const wrapperPath = stashDeck(generateDocHtml(docPath, title, forPrint ? false : mode.fullscreen, theme));
566
+ sendJson(res, { path: forPrint ? `${wrapperPath}&print=1` : wrapperPath, docPath });
567
+ return true;
568
+ }
569
+ if (pathname === "/__pdf-doc" && req.method === "POST") {
570
+ const body = JSON.parse(await readBody(req));
571
+ const raw = body.html ?? "";
572
+ if (!raw.trim()) {
573
+ res.writeHead(422, { "Content-Type": "application/json" });
574
+ res.end(JSON.stringify({ error: "empty document" }));
575
+ return true;
576
+ }
577
+ const browser = await findBrowser();
578
+ if (!browser) {
579
+ res.writeHead(501, { "Content-Type": "application/json" });
580
+ res.end(JSON.stringify({
581
+ error: "no browser",
582
+ detail: "No Chrome, Chromium, Edge, or Brave found. Set DECKRUN_BROWSER to one to export PDFs directly.",
583
+ }));
584
+ return true;
585
+ }
586
+ const title = docTitle(raw, body.title?.trim() || "deckrun");
587
+ // Print the raw doc directly, with no chrome wrapper: its own @page /
588
+ // print CSS (or Chrome's defaults) governs pagination, and there is no
589
+ // presenter chrome to strip since there is none in the printed page.
590
+ const docPath = stashDeck(raw);
591
+ try {
592
+ const pdf = await renderPdfSerial(`${mode.origin}${docPath}`, browser);
593
+ const filename = safeFilename(body.title?.trim() || title) + ".pdf";
594
+ res.writeHead(200, {
595
+ "Content-Type": "application/pdf",
596
+ "Content-Length": pdf.length,
597
+ "Content-Disposition": `attachment; filename="${filename}"`,
598
+ "Cache-Control": "no-store",
599
+ });
600
+ res.end(pdf);
601
+ }
602
+ catch (err) {
603
+ const detail = err instanceof PdfError ? err.message : "rendering failed";
604
+ res.writeHead(500, { "Content-Type": "application/json" });
605
+ res.end(JSON.stringify({ error: "render failed", detail }));
606
+ }
607
+ return true;
608
+ }
609
+ return false;
610
+ }
611
+ /**
612
+ * Whether a request's Host header names this server.
613
+ *
614
+ * Binding to 127.0.0.1 keeps other machines from *routing* to the server; it
615
+ * does not keep a remote page from *reaching* it. An attacker domain whose A
616
+ * record flips to 127.0.0.1 after the page loads becomes same-origin with
617
+ * deckrun, so every response the same-origin policy would otherwise hide
618
+ * becomes readable — and a same-origin GET carries no Origin header, so
619
+ * nothing else here would fire. Pinning Host to the names the server is
620
+ * actually reachable under is what closes DNS rebinding.
621
+ */
622
+ function hostIsLocal(host, port) {
623
+ if (!host)
624
+ return false;
625
+ // A bracketed IPv6 literal, or host:port; the port is optional for :80.
626
+ const match = /^(\[[0-9a-f:.]+\]|[^:]+)(?::(\d+))?$/i.exec(host.trim());
627
+ if (!match)
628
+ return false;
629
+ const name = match[1].toLowerCase();
630
+ const given = match[2] ? parseInt(match[2], 10) : 80;
631
+ if (given !== port)
632
+ return false;
633
+ return name === "127.0.0.1" || name === "localhost" || name === "[::1]";
634
+ }
635
+ async function serve(mode, baseDir, port) {
636
+ const server = createServer(async (req, res) => {
637
+ try {
638
+ if (!hostIsLocal(req.headers.host, port)) {
639
+ res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
640
+ res.end("Forbidden: deckrun only answers requests addressed to localhost.");
641
+ return;
642
+ }
643
+ const rawUrl = req.url ?? "/";
644
+ const [rawPath, rawQuery = ""] = rawUrl.split("?");
645
+ const pathname = decodeURIComponent(rawPath);
646
+ const query = new URLSearchParams(rawQuery);
647
+ if (pathname === "/" || pathname === "/index.html") {
648
+ const wantsDeck = query.get("deck");
649
+ if (wantsDeck)
650
+ serveStashedDeck(wantsDeck, res);
651
+ else
652
+ sendHtml(res, generateEditorHtml(mode.theme, mode.fonts, mode.template, mode.transition, mode.file
653
+ ? {
654
+ name: mode.file.name,
655
+ kind: mode.file.kind,
656
+ writable: !!mode.file.path,
657
+ watched: mode.file.watched,
658
+ }
659
+ : null, SESSION_TOKEN));
660
+ return;
661
+ }
662
+ if (pathname.startsWith("/__vendor/")) {
663
+ const asset = vendorAsset(pathname);
664
+ if (!asset) {
665
+ res.writeHead(404, { "Content-Type": "text/plain" });
666
+ res.end("Not found");
667
+ return;
668
+ }
669
+ const data = await readFile(asset);
670
+ res.writeHead(200, {
671
+ "Content-Type": getMime(asset),
672
+ "Cache-Control": "public, max-age=31536000, immutable",
673
+ });
674
+ res.end(data);
675
+ return;
676
+ }
677
+ if (pathname.startsWith("/__") && await handleEditorRoute(mode, pathname, req, res, query, port)) {
678
+ return;
679
+ }
680
+ // Everything else comes off disk, relative to the working directory.
681
+ const filePath = resolve(baseDir, pathname.replace(/^\/+/, ""));
682
+ const fromBase = relative(baseDir, filePath);
683
+ if (isAbsolute(fromBase) || fromBase.startsWith("..") || !servableAsset(fromBase)) {
684
+ res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
685
+ res.end("Forbidden");
686
+ return;
687
+ }
688
+ const data = await readFile(filePath);
689
+ res.writeHead(200, { "Content-Type": getMime(filePath) });
690
+ res.end(data);
691
+ }
692
+ catch (err) {
693
+ const message = err instanceof Error ? err.message : "error";
694
+ if (message === "request body too large") {
695
+ res.writeHead(413, { "Content-Type": "text/plain", Connection: "close" });
696
+ res.end(`Deck is larger than ${Math.round(MAX_BODY / 1024 / 1024)} MB.`);
697
+ req.destroy();
698
+ return;
699
+ }
700
+ if (!res.headersSent)
701
+ res.writeHead(404, { "Content-Type": "text/plain" });
702
+ res.end("Not found");
703
+ }
704
+ });
705
+ await new Promise((ready) => server.listen(port, "127.0.0.1", ready));
706
+ return `http://127.0.0.1:${port}`;
707
+ }
708
+ // ── CLI ───────────────────────────────────────────────────────────────────
709
+ const program = new Command();
710
+ program
711
+ .name("deckrun")
712
+ .description("Open a Markdown file, HTML file, or public URL in the built-in editor, and present from there. Run without an argument for a blank editor.")
713
+ .version(packageVersion(), "-v, --version", "Print the version number")
714
+ .argument("[file]", "Markdown file, HTML file, or public URL to open in the editor. Omit it for a blank editor.")
715
+ .option("-p, --port <number>", "Port to serve on", "7890")
716
+ .option("--no-open", "Do not automatically open the browser")
717
+ .option("--no-watch", "Do not watch the opened file for changes on disk")
718
+ .option("--fullscreen", "Auto-enter fullscreen on first interaction")
719
+ .option("--theme <name>", "Color theme, by id (see --list-themes)", DEFAULT_THEME)
720
+ .option("--head-font <name>", "Override the theme's heading face (see --list-fonts)")
721
+ .option("--body-font <name>", "Override the theme's body face (see --list-fonts)")
722
+ .option("--template <name>", "Composition template (see --list-templates)", DEFAULT_TEMPLATE)
723
+ .option("--transition <name>", "Slide transition (see --list-transitions)", DEFAULT_TRANSITION)
724
+ .option("--list-themes", "Print every theme and exit")
725
+ .option("--list-fonts", "Print every font face and exit")
726
+ .option("--list-templates", "Print every composition template and exit")
727
+ .option("--list-transitions", "Print every slide transition and exit")
728
+ .action(async (file, opts) => {
729
+ if (opts.listThemes) {
730
+ for (const line of themeListing())
731
+ console.log(line);
732
+ process.exit(0);
733
+ }
734
+ if (opts.listFonts) {
735
+ for (const line of fontListing())
736
+ console.log(line);
737
+ process.exit(0);
738
+ }
739
+ if (opts.listTemplates) {
740
+ for (const line of templateListing())
741
+ console.log(line);
742
+ process.exit(0);
743
+ }
744
+ if (opts.listTransitions) {
745
+ for (const line of transitionListing())
746
+ console.log(line);
747
+ process.exit(0);
748
+ }
749
+ const named = findTheme(opts.theme);
750
+ if (!named) {
751
+ console.error(`deckrun: unknown theme '${opts.theme}'. Run --list-themes to see them all.`);
752
+ process.exit(1);
753
+ }
754
+ const templated = findTemplate(opts.template);
755
+ if (!templated) {
756
+ console.error(`deckrun: unknown template '${opts.template}'. Run --list-templates to see them all.`);
757
+ process.exit(1);
758
+ }
759
+ const transitioned = findTransition(opts.transition);
760
+ if (!transitioned) {
761
+ console.error(`deckrun: unknown transition '${opts.transition}'. Run --list-transitions to see them all.`);
762
+ process.exit(1);
763
+ }
764
+ // Both face flags are optional; unset means the theme keeps its own.
765
+ const fonts = { head: null, body: null };
766
+ for (const [flag, slot] of [
767
+ ["--head-font", "head"],
768
+ ["--body-font", "body"],
769
+ ]) {
770
+ const raw = slot === "head" ? opts.headFont : opts.bodyFont;
771
+ if (raw === undefined)
772
+ continue;
773
+ const face = findFont(raw);
774
+ if (!face) {
775
+ console.error(`deckrun: unknown font '${raw}' for ${flag}. Run --list-fonts to see them all.`);
776
+ process.exit(1);
777
+ }
778
+ fonts[slot] = face;
779
+ }
780
+ const theme = named;
781
+ const template = templated;
782
+ const transition = transitioned;
783
+ const fullscreen = !!opts.fullscreen;
784
+ let mode;
785
+ let baseDir;
786
+ if (file) {
787
+ if (/^https?:\/\//i.test(file)) {
788
+ let target;
789
+ try {
790
+ target = new URL(file);
791
+ }
792
+ catch {
793
+ console.error(`deckrun: invalid URL '${file}'`);
794
+ process.exit(1);
795
+ }
796
+ let upstream;
797
+ try {
798
+ upstream = await fetch(target, {
799
+ redirect: "follow",
800
+ signal: AbortSignal.timeout(15_000),
801
+ headers: { "User-Agent": "deckrun" },
802
+ });
803
+ }
804
+ catch (err) {
805
+ console.error(`deckrun: cannot fetch '${file}': ${err instanceof Error ? err.message : "network error"}`);
806
+ process.exit(1);
807
+ }
808
+ if (!upstream.ok) {
809
+ console.error(`deckrun: fetch failed for '${file}' (HTTP ${upstream.status})`);
810
+ process.exit(1);
811
+ }
812
+ const rawContent = await upstream.text();
813
+ if (!rawContent.trim()) {
814
+ console.error(`deckrun: empty document fetched from '${file}'`);
815
+ process.exit(1);
816
+ }
817
+ const contentType = (upstream.headers.get("content-type") ?? "").toLowerCase();
818
+ const pathname = target.pathname.toLowerCase();
819
+ let isHtml = false;
820
+ if (pathname.endsWith(".html") || pathname.endsWith(".htm")) {
821
+ isHtml = true;
822
+ }
823
+ else if (pathname.endsWith(".md") || pathname.endsWith(".markdown")) {
824
+ isHtml = false;
825
+ }
826
+ else if (contentType.includes("text/html") ||
827
+ contentType.includes("application/xhtml+xml")) {
828
+ isHtml = true;
829
+ }
830
+ else if (contentType.includes("text/markdown") ||
831
+ contentType.includes("text/x-markdown") ||
832
+ contentType.includes("text/plain")) {
833
+ isHtml = false;
834
+ }
835
+ else if (/<!doctype\s+html/i.test(rawContent) || /<html[\s>]/i.test(rawContent)) {
836
+ isHtml = true;
837
+ }
838
+ baseDir = process.cwd();
839
+ const defaultName = target.pathname.split("/").filter(Boolean).pop() || target.hostname;
840
+ // A fetched page is a one-shot import: it opens in the editor,
841
+ // held in memory, read-only toward its origin.
842
+ let editorFile;
843
+ if (isHtml) {
844
+ let docHtml = rawContent;
845
+ if (!/<base\s/i.test(docHtml)) {
846
+ if (/<head[^>]*>/i.test(docHtml)) {
847
+ docHtml = docHtml.replace(/<head[^>]*>/i, (m) => `${m}\n <base href="${target.href}">`);
848
+ }
849
+ else {
850
+ docHtml = `<base href="${target.href}">\n` + docHtml;
851
+ }
852
+ }
853
+ editorFile = {
854
+ name: docTitle(rawContent, defaultName),
855
+ kind: "html",
856
+ content: docHtml,
857
+ watched: false,
858
+ };
859
+ console.log(`${c.dim}opening ${file} in the editor as an HTML doc${c.reset}`);
860
+ }
861
+ else {
862
+ const slides = parseSlides(rawContent);
863
+ editorFile = {
864
+ name: defaultName,
865
+ kind: "markdown",
866
+ content: rawContent,
867
+ watched: false,
868
+ };
869
+ console.log(`${c.dim}${slides.length} slide${slides.length !== 1 ? "s" : ""} from ${file} · opening in the editor${c.reset}`);
870
+ }
871
+ mode = { kind: "editor", theme, fonts, template, transition, fullscreen, file: editorFile };
872
+ }
873
+ else {
874
+ const absPath = resolve(process.cwd(), file);
875
+ baseDir = dirname(absPath);
876
+ const ext = extname(absPath).toLowerCase();
877
+ const kind = ext === ".html" || ext === ".htm" ? "html" : "markdown";
878
+ let raw;
879
+ try {
880
+ raw = readFileSync(absPath, "utf-8");
881
+ }
882
+ catch {
883
+ console.error(`deckrun: cannot read file '${file}'`);
884
+ process.exit(1);
885
+ }
886
+ mode = {
887
+ kind: "editor",
888
+ theme,
889
+ fonts,
890
+ template,
891
+ transition,
892
+ fullscreen,
893
+ file: { name: basename(absPath), kind, path: absPath, watched: opts.watch },
894
+ };
895
+ if (opts.watch)
896
+ watchSourceFile(absPath);
897
+ if (kind === "markdown") {
898
+ const slides = parseSlides(raw);
899
+ console.log(`${c.dim}${slides.length} slide${slides.length !== 1 ? "s" : ""} from ${basename(absPath)} · opening in the editor${c.reset}`);
900
+ }
901
+ else {
902
+ console.log(`${c.dim}opening ${basename(absPath)} in the editor as an HTML doc${c.reset}`);
903
+ }
904
+ }
905
+ }
906
+ else {
907
+ baseDir = process.cwd();
908
+ mode = { kind: "editor", theme, fonts, template, transition, fullscreen };
909
+ }
910
+ const port = await findFreePort(parseInt(opts.port, 10));
911
+ mode.origin = `http://127.0.0.1:${port}`;
912
+ const url = await serve(mode, baseDir, port);
913
+ console.log(`${c.bold}${c.magenta}editor${c.reset} ${c.dim}→${c.reset} ${c.cyan}${c.bold}${url}${c.reset} ${c.dim}(Ctrl+C to stop)${c.reset}`);
914
+ if (mode.file?.path) {
915
+ console.log(`${c.dim}write on the left, live deck on the right. saves back to ${mode.file.name}.${c.reset}`);
916
+ if (mode.file.watched) {
917
+ console.log(`${c.dim}edits to ${mode.file.name} on disk reload the editor as well.${c.reset}`);
918
+ }
919
+ }
920
+ else {
921
+ console.log(`${c.dim}write on the left, live deck on the right. autosaves to your browser.${c.reset}`);
922
+ }
923
+ console.log(`${c.dim}Cmd/Ctrl+K inserts anything · template/theme controls recompose live · Cmd/Ctrl+Enter presents${c.reset}`);
924
+ if (opts.open !== false)
925
+ await open(url);
926
+ // Keep the process alive until interrupted.
927
+ await new Promise(() => { });
928
+ });
929
+ program
930
+ .command("lint")
931
+ .description("Check Markdown decks for common authoring and rendering problems")
932
+ .argument("<files...>", "Markdown files to check; use - to read standard input")
933
+ .option("--format <format>", "Output format: stylish or json", "stylish")
934
+ .option("--max-warnings <number>", "Warnings allowed before the command fails", "0")
935
+ .action((files, opts) => {
936
+ if (opts.format !== "stylish" && opts.format !== "json") {
937
+ console.error("deckrun lint: --format must be 'stylish' or 'json'.");
938
+ process.exitCode = 2;
939
+ return;
940
+ }
941
+ const maxWarnings = Number.parseInt(opts.maxWarnings, 10);
942
+ if (!Number.isInteger(maxWarnings) || maxWarnings < -1) {
943
+ console.error("deckrun lint: --max-warnings must be -1 or a non-negative integer.");
944
+ process.exitCode = 2;
945
+ return;
946
+ }
947
+ const reports = [];
948
+ for (const file of files) {
949
+ let markdown;
950
+ try {
951
+ markdown = file === "-" ? readFileSync(0, "utf-8") : readFileSync(resolve(process.cwd(), file), "utf-8");
952
+ if (markdown.length > MAX_LINT_INPUT) {
953
+ reports.push({
954
+ file,
955
+ slides: 0,
956
+ errors: 1,
957
+ warnings: 0,
958
+ issues: [{
959
+ rule: "file-too-large",
960
+ severity: "error",
961
+ message: `The file is larger than ${Math.round(MAX_LINT_INPUT / 1024 / 1024)} MB and was not checked.`,
962
+ line: 1,
963
+ column: 1,
964
+ }],
965
+ });
966
+ continue;
967
+ }
968
+ }
969
+ catch {
970
+ reports.push({
971
+ file,
972
+ slides: 0,
973
+ errors: 1,
974
+ warnings: 0,
975
+ issues: [{
976
+ rule: "file-read",
977
+ severity: "error",
978
+ message: "The file could not be read.",
979
+ line: 1,
980
+ column: 1,
981
+ }],
982
+ });
983
+ continue;
984
+ }
985
+ const result = lintMarkdown(markdown);
986
+ reports.push({ file, ...result });
987
+ }
988
+ const errors = reports.reduce((sum, report) => sum + report.errors, 0);
989
+ const warnings = reports.reduce((sum, report) => sum + report.warnings, 0);
990
+ const issueCount = errors + warnings;
991
+ if (opts.format === "json") {
992
+ console.log(JSON.stringify({ files: reports, errors, warnings }, null, 2));
993
+ }
994
+ else {
995
+ for (const report of reports) {
996
+ if (!report.issues.length)
997
+ continue;
998
+ // Both the path and the message can carry text lifted out of a deck
999
+ // that someone else wrote, and this output is read in a CI job log.
1000
+ console.log(`\n${sanitizeForTerminal(report.file, 500)}`);
1001
+ for (const item of report.issues) {
1002
+ const position = `${item.line}:${item.column}`.padEnd(9);
1003
+ const severity = item.severity.padEnd(7);
1004
+ const slide = item.slide ? `slide ${item.slide} · ` : "";
1005
+ console.log(` ${position} ${severity} ${slide}${sanitizeForTerminal(item.message, 300)} ${item.rule}`);
1006
+ }
1007
+ }
1008
+ if (issueCount === 0) {
1009
+ const slides = reports.reduce((sum, report) => sum + report.slides, 0);
1010
+ console.log(`✓ ${files.length} file${files.length === 1 ? "" : "s"}, ${slides} slide${slides === 1 ? "" : "s"}, no problems`);
1011
+ }
1012
+ else {
1013
+ console.log(`\n✖ ${issueCount} problem${issueCount === 1 ? "" : "s"} (${errors} error${errors === 1 ? "" : "s"}, ${warnings} warning${warnings === 1 ? "" : "s"})`);
1014
+ }
1015
+ }
1016
+ if (errors > 0 || (maxWarnings !== -1 && warnings > maxWarnings)) {
1017
+ process.exitCode = 1;
1018
+ }
1019
+ });
1020
+ program.parse(process.argv);