deckrun 1.3.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/LICENSE +21 -0
- package/README.md +892 -0
- package/dist/editor-content.js +400 -0
- package/dist/editor.js +3592 -0
- package/dist/generate.js +2394 -0
- package/dist/index.js +538 -0
- package/dist/parser.js +65 -0
- package/dist/pdf.js +196 -0
- package/dist/preview.js +277 -0
- package/dist/themes.js +971 -0
- package/package.json +26 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
import { readFile } from "fs/promises";
|
|
4
|
+
import { createServer } from "http";
|
|
5
|
+
import { createRequire } from "module";
|
|
6
|
+
import { resolve, dirname, basename, extname } from "path";
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import open from "open";
|
|
9
|
+
import { parseSlides } from "./parser.js";
|
|
10
|
+
import { generateHtml, generateDocHtml, renderSlide } from "./generate.js";
|
|
11
|
+
import { DEFAULT_SIZE, DEFAULT_THEME, findFont, findSize, findTheme, fontListing, fontName, resolveSizeName, resolveThemeName, THEMES, themeListing, sizeListing, } from "./themes.js";
|
|
12
|
+
import { generateEditorHtml } from "./editor.js";
|
|
13
|
+
import { generatePreviewHtml } from "./preview.js";
|
|
14
|
+
import { findBrowser, renderPdfSerial, PdfError } from "./pdf.js";
|
|
15
|
+
const c = {
|
|
16
|
+
reset: "\x1b[0m",
|
|
17
|
+
bold: "\x1b[1m",
|
|
18
|
+
dim: "\x1b[2m",
|
|
19
|
+
cyan: "\x1b[36m",
|
|
20
|
+
green: "\x1b[32m",
|
|
21
|
+
yellow: "\x1b[33m",
|
|
22
|
+
magenta: "\x1b[35m",
|
|
23
|
+
};
|
|
24
|
+
function packageVersion() {
|
|
25
|
+
try {
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
return require("../package.json").version;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return "0.0.0";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const MIME = {
|
|
34
|
+
".html": "text/html; charset=utf-8",
|
|
35
|
+
".htm": "text/html; charset=utf-8",
|
|
36
|
+
".css": "text/css",
|
|
37
|
+
".js": "application/javascript",
|
|
38
|
+
".mjs": "application/javascript",
|
|
39
|
+
".json": "application/json",
|
|
40
|
+
".md": "text/markdown; charset=utf-8",
|
|
41
|
+
".txt": "text/plain; charset=utf-8",
|
|
42
|
+
".png": "image/png",
|
|
43
|
+
".jpg": "image/jpeg",
|
|
44
|
+
".jpeg": "image/jpeg",
|
|
45
|
+
".gif": "image/gif",
|
|
46
|
+
".svg": "image/svg+xml",
|
|
47
|
+
".webp": "image/webp",
|
|
48
|
+
".ico": "image/x-icon",
|
|
49
|
+
".avif": "image/avif",
|
|
50
|
+
".mp4": "video/mp4",
|
|
51
|
+
".webm": "video/webm",
|
|
52
|
+
".woff": "font/woff",
|
|
53
|
+
".woff2": "font/woff2",
|
|
54
|
+
".ttf": "font/ttf",
|
|
55
|
+
};
|
|
56
|
+
function getMime(filepath) {
|
|
57
|
+
return MIME[extname(filepath).toLowerCase()] ?? "application/octet-stream";
|
|
58
|
+
}
|
|
59
|
+
async function findFreePort(preferred) {
|
|
60
|
+
return new Promise((resolvePort) => {
|
|
61
|
+
const server = createServer();
|
|
62
|
+
server.listen(preferred, () => {
|
|
63
|
+
const addr = server.address();
|
|
64
|
+
server.close(() => resolvePort(addr.port));
|
|
65
|
+
});
|
|
66
|
+
server.on("error", () => {
|
|
67
|
+
// Preferred port is taken, take whatever the OS offers.
|
|
68
|
+
const fallback = createServer();
|
|
69
|
+
fallback.listen(0, () => {
|
|
70
|
+
const addr = fallback.address();
|
|
71
|
+
fallback.close(() => resolvePort(addr.port));
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/** First heading of the deck, with inline markup stripped. */
|
|
77
|
+
function deckTitle(slides, fallback) {
|
|
78
|
+
const heading = slides[0]?.html.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i);
|
|
79
|
+
const text = heading ? heading[1].replace(/<[^>]+>/g, "").trim() : "";
|
|
80
|
+
return text || fallback;
|
|
81
|
+
}
|
|
82
|
+
/** An HTML doc's own `<title>`, with inline markup stripped. */
|
|
83
|
+
function docTitle(html, fallback) {
|
|
84
|
+
const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
85
|
+
const text = match ? match[1].replace(/<[^>]+>/g, "").trim() : "";
|
|
86
|
+
return text || fallback;
|
|
87
|
+
}
|
|
88
|
+
/** A deck name reduced to something safe for a Content-Disposition header. */
|
|
89
|
+
function safeFilename(name) {
|
|
90
|
+
const slug = name
|
|
91
|
+
.trim()
|
|
92
|
+
.toLowerCase()
|
|
93
|
+
.replace(/\.(md|markdown)$/, "")
|
|
94
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
95
|
+
.replace(/^-|-$/g, "");
|
|
96
|
+
return slug || "deck";
|
|
97
|
+
}
|
|
98
|
+
const MAX_BODY = 32 * 1024 * 1024;
|
|
99
|
+
function readBody(req) {
|
|
100
|
+
return new Promise((resolveBody, rejectBody) => {
|
|
101
|
+
const chunks = [];
|
|
102
|
+
let size = 0;
|
|
103
|
+
let overflowed = false;
|
|
104
|
+
req.on("data", (chunk) => {
|
|
105
|
+
if (overflowed)
|
|
106
|
+
return;
|
|
107
|
+
size += chunk.length;
|
|
108
|
+
if (size > MAX_BODY) {
|
|
109
|
+
// Reject now but keep the socket open long enough to answer with 413.
|
|
110
|
+
overflowed = true;
|
|
111
|
+
chunks.length = 0;
|
|
112
|
+
rejectBody(new Error("request body too large"));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
chunks.push(chunk);
|
|
116
|
+
});
|
|
117
|
+
req.on("end", () => {
|
|
118
|
+
if (!overflowed)
|
|
119
|
+
resolveBody(Buffer.concat(chunks).toString("utf-8"));
|
|
120
|
+
});
|
|
121
|
+
req.on("error", rejectBody);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
function sendHtml(res, html) {
|
|
125
|
+
res.writeHead(200, {
|
|
126
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
127
|
+
"Cache-Control": "no-store",
|
|
128
|
+
});
|
|
129
|
+
res.end(html);
|
|
130
|
+
}
|
|
131
|
+
function sendJson(res, payload) {
|
|
132
|
+
res.writeHead(200, {
|
|
133
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
134
|
+
"Cache-Control": "no-store",
|
|
135
|
+
});
|
|
136
|
+
res.end(JSON.stringify(payload));
|
|
137
|
+
}
|
|
138
|
+
/** Decks built from editor content, addressable so a new tab can load them. */
|
|
139
|
+
const decks = new Map();
|
|
140
|
+
let deckSeq = 0;
|
|
141
|
+
/**
|
|
142
|
+
* Stores a built deck and returns the path that serves it.
|
|
143
|
+
*
|
|
144
|
+
* The path stays at the root on purpose: a deck served from a subpath would
|
|
145
|
+
* resolve `` against that subpath instead of the directory
|
|
146
|
+
* being served, and every local image would 404.
|
|
147
|
+
*/
|
|
148
|
+
function stashDeck(html) {
|
|
149
|
+
const id = ++deckSeq;
|
|
150
|
+
decks.set(id, html);
|
|
151
|
+
// Keep only the handful of most recent builds.
|
|
152
|
+
for (const key of decks.keys()) {
|
|
153
|
+
if (decks.size <= 8)
|
|
154
|
+
break;
|
|
155
|
+
decks.delete(key);
|
|
156
|
+
}
|
|
157
|
+
return `/?deck=${id}`;
|
|
158
|
+
}
|
|
159
|
+
/** A built deck, addressed by `?deck=<id>` so its base URL stays the root. */
|
|
160
|
+
function serveStashedDeck(id, res) {
|
|
161
|
+
const html = decks.get(parseInt(id, 10));
|
|
162
|
+
if (!html) {
|
|
163
|
+
res.writeHead(410, { "Content-Type": "text/plain; charset=utf-8" });
|
|
164
|
+
res.end("This build has expired. Press present again in the editor.");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
sendHtml(res, html);
|
|
168
|
+
}
|
|
169
|
+
async function handleEditorRoute(mode, pathname, req, res) {
|
|
170
|
+
if (pathname === "/__preview" && req.method === "GET") {
|
|
171
|
+
sendHtml(res, generatePreviewHtml(mode.theme, mode.size, mode.fonts));
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
if (pathname === "/__parse" && req.method === "POST") {
|
|
175
|
+
const markdown = await readBody(req);
|
|
176
|
+
const slides = parseSlides(markdown);
|
|
177
|
+
sendJson(res, {
|
|
178
|
+
slides: slides.map((slide, i) => renderSlide(slide, i)),
|
|
179
|
+
notes: slides.map((slide) => slide.notes ?? ""),
|
|
180
|
+
title: deckTitle(slides, ""),
|
|
181
|
+
});
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
if (pathname === "/__present" && req.method === "POST") {
|
|
185
|
+
const body = JSON.parse(await readBody(req));
|
|
186
|
+
const slides = parseSlides(body.markdown ?? "");
|
|
187
|
+
if (slides.length === 0) {
|
|
188
|
+
res.writeHead(422, { "Content-Type": "application/json" });
|
|
189
|
+
res.end(JSON.stringify({ error: "no slides" }));
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
const theme = resolveThemeName(body.theme);
|
|
193
|
+
const size = resolveSizeName(body.size);
|
|
194
|
+
const title = deckTitle(slides, body.title?.trim() || "deckrun");
|
|
195
|
+
// A deck built for printing must not open behind a fullscreen prompt.
|
|
196
|
+
const forPrint = body.print === true;
|
|
197
|
+
const path = stashDeck(generateHtml(slides, title, forPrint ? false : mode.fullscreen, theme, size, {
|
|
198
|
+
head: body.head,
|
|
199
|
+
body: body.body,
|
|
200
|
+
}));
|
|
201
|
+
sendJson(res, { path: forPrint ? `${path}&print=1` : path });
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
if (pathname === "/__pdf" && req.method === "POST") {
|
|
205
|
+
const body = JSON.parse(await readBody(req));
|
|
206
|
+
const slides = parseSlides(body.markdown ?? "");
|
|
207
|
+
if (slides.length === 0) {
|
|
208
|
+
res.writeHead(422, { "Content-Type": "application/json" });
|
|
209
|
+
res.end(JSON.stringify({ error: "no slides" }));
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
const browser = await findBrowser();
|
|
213
|
+
if (!browser) {
|
|
214
|
+
// The caller falls back to the print dialog, which prints correctly too.
|
|
215
|
+
res.writeHead(501, { "Content-Type": "application/json" });
|
|
216
|
+
res.end(JSON.stringify({
|
|
217
|
+
error: "no browser",
|
|
218
|
+
detail: "No Chrome, Chromium, Edge, or Brave found. Set DECKRUN_BROWSER to one to export PDFs directly.",
|
|
219
|
+
}));
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
const theme = resolveThemeName(body.theme);
|
|
223
|
+
const size = resolveSizeName(body.size);
|
|
224
|
+
const title = deckTitle(slides, body.title?.trim() || "deckrun");
|
|
225
|
+
const path = stashDeck(generateHtml(slides, title, false, theme, size, { head: body.head, body: body.body }));
|
|
226
|
+
try {
|
|
227
|
+
const pdf = await renderPdfSerial(`${mode.origin}${path}`, browser);
|
|
228
|
+
const filename = safeFilename(body.title?.trim() || title) + ".pdf";
|
|
229
|
+
res.writeHead(200, {
|
|
230
|
+
"Content-Type": "application/pdf",
|
|
231
|
+
"Content-Length": pdf.length,
|
|
232
|
+
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
233
|
+
"Cache-Control": "no-store",
|
|
234
|
+
});
|
|
235
|
+
res.end(pdf);
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
const detail = err instanceof PdfError ? err.message : "rendering failed";
|
|
239
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
240
|
+
res.end(JSON.stringify({ error: "render failed", detail }));
|
|
241
|
+
}
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
if (pathname === "/__fetch-doc" && req.method === "POST") {
|
|
245
|
+
const body = JSON.parse(await readBody(req));
|
|
246
|
+
const raw = (body.url ?? "").trim();
|
|
247
|
+
let target;
|
|
248
|
+
try {
|
|
249
|
+
target = new URL(raw);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
res.writeHead(422, { "Content-Type": "application/json" });
|
|
253
|
+
res.end(JSON.stringify({ error: "invalid url" }));
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
257
|
+
res.writeHead(422, { "Content-Type": "application/json" });
|
|
258
|
+
res.end(JSON.stringify({ error: "url must be http or https" }));
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
let upstream;
|
|
262
|
+
try {
|
|
263
|
+
// Fetched server-side, not from the browser, so a page with no
|
|
264
|
+
// Access-Control-Allow-Origin still loads fine.
|
|
265
|
+
upstream = await fetch(target, {
|
|
266
|
+
redirect: "follow",
|
|
267
|
+
signal: AbortSignal.timeout(15_000),
|
|
268
|
+
headers: { "User-Agent": "deckrun" },
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
273
|
+
res.end(JSON.stringify({
|
|
274
|
+
error: "fetch failed",
|
|
275
|
+
detail: err instanceof Error ? err.message : "network error",
|
|
276
|
+
}));
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
if (!upstream.ok) {
|
|
280
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
281
|
+
res.end(JSON.stringify({ error: "fetch failed", detail: `upstream responded ${upstream.status}` }));
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
const html = await upstream.text();
|
|
285
|
+
if (html.length > MAX_BODY) {
|
|
286
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
287
|
+
res.end(JSON.stringify({
|
|
288
|
+
error: "too large",
|
|
289
|
+
detail: `page is larger than ${Math.round(MAX_BODY / 1024 / 1024)} MB`,
|
|
290
|
+
}));
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
if (!html.trim()) {
|
|
294
|
+
res.writeHead(422, { "Content-Type": "application/json" });
|
|
295
|
+
res.end(JSON.stringify({ error: "empty document" }));
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
sendJson(res, { html, title: docTitle(html, target.hostname) });
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
if (pathname === "/__present-doc" && req.method === "POST") {
|
|
302
|
+
const body = JSON.parse(await readBody(req));
|
|
303
|
+
const raw = body.html ?? "";
|
|
304
|
+
if (!raw.trim()) {
|
|
305
|
+
res.writeHead(422, { "Content-Type": "application/json" });
|
|
306
|
+
res.end(JSON.stringify({ error: "empty document" }));
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
const theme = resolveThemeName(body.theme);
|
|
310
|
+
const title = docTitle(raw, body.title?.trim() || "deckrun");
|
|
311
|
+
const forPrint = body.print === true;
|
|
312
|
+
const docPath = stashDeck(raw);
|
|
313
|
+
const wrapperPath = stashDeck(generateDocHtml(docPath, title, forPrint ? false : mode.fullscreen, theme));
|
|
314
|
+
sendJson(res, { path: forPrint ? `${wrapperPath}&print=1` : wrapperPath, docPath });
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
if (pathname === "/__pdf-doc" && req.method === "POST") {
|
|
318
|
+
const body = JSON.parse(await readBody(req));
|
|
319
|
+
const raw = body.html ?? "";
|
|
320
|
+
if (!raw.trim()) {
|
|
321
|
+
res.writeHead(422, { "Content-Type": "application/json" });
|
|
322
|
+
res.end(JSON.stringify({ error: "empty document" }));
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
const browser = await findBrowser();
|
|
326
|
+
if (!browser) {
|
|
327
|
+
res.writeHead(501, { "Content-Type": "application/json" });
|
|
328
|
+
res.end(JSON.stringify({
|
|
329
|
+
error: "no browser",
|
|
330
|
+
detail: "No Chrome, Chromium, Edge, or Brave found. Set DECKRUN_BROWSER to one to export PDFs directly.",
|
|
331
|
+
}));
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
const title = docTitle(raw, body.title?.trim() || "deckrun");
|
|
335
|
+
// Print the raw doc directly, with no chrome wrapper: its own @page /
|
|
336
|
+
// print CSS (or Chrome's defaults) governs pagination, and there is no
|
|
337
|
+
// presenter chrome to strip since there is none in the printed page.
|
|
338
|
+
const docPath = stashDeck(raw);
|
|
339
|
+
try {
|
|
340
|
+
const pdf = await renderPdfSerial(`${mode.origin}${docPath}`, browser);
|
|
341
|
+
const filename = safeFilename(body.title?.trim() || title) + ".pdf";
|
|
342
|
+
res.writeHead(200, {
|
|
343
|
+
"Content-Type": "application/pdf",
|
|
344
|
+
"Content-Length": pdf.length,
|
|
345
|
+
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
346
|
+
"Cache-Control": "no-store",
|
|
347
|
+
});
|
|
348
|
+
res.end(pdf);
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
const detail = err instanceof PdfError ? err.message : "rendering failed";
|
|
352
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
353
|
+
res.end(JSON.stringify({ error: "render failed", detail }));
|
|
354
|
+
}
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
async function serve(mode, baseDir, port) {
|
|
360
|
+
const server = createServer(async (req, res) => {
|
|
361
|
+
try {
|
|
362
|
+
const rawUrl = req.url ?? "/";
|
|
363
|
+
const [rawPath, rawQuery = ""] = rawUrl.split("?");
|
|
364
|
+
const pathname = decodeURIComponent(rawPath);
|
|
365
|
+
const query = new URLSearchParams(rawQuery);
|
|
366
|
+
if (pathname === "/" || pathname === "/index.html") {
|
|
367
|
+
const wantsDeck = mode.kind === "editor" && query.get("deck");
|
|
368
|
+
if (wantsDeck)
|
|
369
|
+
serveStashedDeck(wantsDeck, res);
|
|
370
|
+
else
|
|
371
|
+
sendHtml(res, mode.kind === "deck"
|
|
372
|
+
? mode.html
|
|
373
|
+
: generateEditorHtml(mode.theme, mode.size, mode.fonts));
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (mode.kind === "editor" && (await handleEditorRoute(mode, pathname, req, res))) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
// Everything else comes off disk, relative to the working directory.
|
|
380
|
+
const filePath = resolve(baseDir, pathname.replace(/^\/+/, ""));
|
|
381
|
+
if (filePath !== baseDir && !filePath.startsWith(baseDir + "/")) {
|
|
382
|
+
res.writeHead(403);
|
|
383
|
+
res.end("Forbidden");
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const data = await readFile(filePath);
|
|
387
|
+
res.writeHead(200, { "Content-Type": getMime(filePath) });
|
|
388
|
+
res.end(data);
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
const message = err instanceof Error ? err.message : "error";
|
|
392
|
+
if (message === "request body too large") {
|
|
393
|
+
res.writeHead(413, { "Content-Type": "text/plain", Connection: "close" });
|
|
394
|
+
res.end(`Deck is larger than ${Math.round(MAX_BODY / 1024 / 1024)} MB.`);
|
|
395
|
+
req.destroy();
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (!res.headersSent)
|
|
399
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
400
|
+
res.end("Not found");
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
await new Promise((ready) => server.listen(port, "127.0.0.1", ready));
|
|
404
|
+
return `http://127.0.0.1:${port}`;
|
|
405
|
+
}
|
|
406
|
+
// ── CLI ───────────────────────────────────────────────────────────────────
|
|
407
|
+
const program = new Command();
|
|
408
|
+
program
|
|
409
|
+
.name("deckrun")
|
|
410
|
+
.description("Present a Markdown file in the browser. Run without a file to write one in the built-in editor.")
|
|
411
|
+
.version(packageVersion(), "-v, --version", "Print the version number")
|
|
412
|
+
.argument("[file]", "Markdown file to present. Omit it to open the editor.")
|
|
413
|
+
.option("-p, --port <number>", "Port to serve on", "7890")
|
|
414
|
+
.option("--no-open", "Do not automatically open the browser")
|
|
415
|
+
.option("--fullscreen", "Auto-enter fullscreen on first interaction")
|
|
416
|
+
.option("--theme <name>", "Color theme, by id (see --list-themes)", DEFAULT_THEME)
|
|
417
|
+
.option("--size <name>", "Type size: s, m, l, or xl", DEFAULT_SIZE)
|
|
418
|
+
.option("--head-font <name>", "Override the theme's heading face (see --list-fonts)")
|
|
419
|
+
.option("--body-font <name>", "Override the theme's body face (see --list-fonts)")
|
|
420
|
+
.option("--list-themes", "Print every theme and exit")
|
|
421
|
+
.option("--list-sizes", "Print every type size and exit")
|
|
422
|
+
.option("--list-fonts", "Print every font face and exit")
|
|
423
|
+
.action(async (file, opts) => {
|
|
424
|
+
if (opts.listThemes) {
|
|
425
|
+
for (const line of themeListing())
|
|
426
|
+
console.log(line);
|
|
427
|
+
process.exit(0);
|
|
428
|
+
}
|
|
429
|
+
if (opts.listSizes) {
|
|
430
|
+
for (const line of sizeListing())
|
|
431
|
+
console.log(line);
|
|
432
|
+
process.exit(0);
|
|
433
|
+
}
|
|
434
|
+
if (opts.listFonts) {
|
|
435
|
+
for (const line of fontListing())
|
|
436
|
+
console.log(line);
|
|
437
|
+
process.exit(0);
|
|
438
|
+
}
|
|
439
|
+
const named = findTheme(opts.theme);
|
|
440
|
+
if (!named) {
|
|
441
|
+
console.error(`deckrun: unknown theme '${opts.theme}'. Run --list-themes to see them all.`);
|
|
442
|
+
process.exit(1);
|
|
443
|
+
}
|
|
444
|
+
const sized = findSize(opts.size);
|
|
445
|
+
if (!sized) {
|
|
446
|
+
console.error(`deckrun: unknown size '${opts.size}'. Run --list-sizes to see them all.`);
|
|
447
|
+
process.exit(1);
|
|
448
|
+
}
|
|
449
|
+
// Both face flags are optional; unset means the theme keeps its own.
|
|
450
|
+
const fonts = { head: null, body: null };
|
|
451
|
+
for (const [flag, slot] of [
|
|
452
|
+
["--head-font", "head"],
|
|
453
|
+
["--body-font", "body"],
|
|
454
|
+
]) {
|
|
455
|
+
const raw = slot === "head" ? opts.headFont : opts.bodyFont;
|
|
456
|
+
if (raw === undefined)
|
|
457
|
+
continue;
|
|
458
|
+
const face = findFont(raw);
|
|
459
|
+
if (!face) {
|
|
460
|
+
console.error(`deckrun: unknown font '${raw}' for ${flag}. Run --list-fonts to see them all.`);
|
|
461
|
+
process.exit(1);
|
|
462
|
+
}
|
|
463
|
+
fonts[slot] = face;
|
|
464
|
+
}
|
|
465
|
+
const theme = named;
|
|
466
|
+
const size = sized;
|
|
467
|
+
const fullscreen = !!opts.fullscreen;
|
|
468
|
+
let mode;
|
|
469
|
+
let baseDir;
|
|
470
|
+
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;
|
|
476
|
+
try {
|
|
477
|
+
rawHtml = readFileSync(absPath, "utf-8");
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
console.error(`deckrun: cannot read file '${file}'`);
|
|
481
|
+
process.exit(1);
|
|
482
|
+
}
|
|
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;
|
|
495
|
+
try {
|
|
496
|
+
markdown = readFileSync(absPath, "utf-8");
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
console.error(`deckrun: cannot read file '${file}'`);
|
|
500
|
+
process.exit(1);
|
|
501
|
+
}
|
|
502
|
+
const slides = parseSlides(markdown);
|
|
503
|
+
if (slides.length === 0) {
|
|
504
|
+
console.error("deckrun: no slides found in the file.");
|
|
505
|
+
process.exit(1);
|
|
506
|
+
}
|
|
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}`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
baseDir = process.cwd();
|
|
521
|
+
mode = { kind: "editor", theme, size, fonts, fullscreen };
|
|
522
|
+
}
|
|
523
|
+
const port = await findFreePort(parseInt(opts.port, 10));
|
|
524
|
+
if (mode.kind === "editor")
|
|
525
|
+
mode.origin = `http://127.0.0.1:${port}`;
|
|
526
|
+
const url = await serve(mode, baseDir, port);
|
|
527
|
+
const label = mode.kind === "editor" ? "editor" : "present";
|
|
528
|
+
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
|
+
if (mode.kind === "editor") {
|
|
530
|
+
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}`);
|
|
532
|
+
}
|
|
533
|
+
if (opts.open !== false)
|
|
534
|
+
await open(url);
|
|
535
|
+
// Keep the process alive until interrupted.
|
|
536
|
+
await new Promise(() => { });
|
|
537
|
+
});
|
|
538
|
+
program.parse(process.argv);
|
package/dist/parser.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { marked } from "marked";
|
|
2
|
+
function parseImageDirective(title) {
|
|
3
|
+
if (!title)
|
|
4
|
+
return { position: "inline", opacity: 1 };
|
|
5
|
+
const t = title.trim().toLowerCase();
|
|
6
|
+
let position = "inline";
|
|
7
|
+
if (t.includes("right"))
|
|
8
|
+
position = "right";
|
|
9
|
+
else if (t.includes("left"))
|
|
10
|
+
position = "left";
|
|
11
|
+
else if (t.includes("bg"))
|
|
12
|
+
position = "bg";
|
|
13
|
+
const opacityMatch = t.match(/opacity[=:]?\s*([0-9]*\.?[0-9]+)/);
|
|
14
|
+
const opacity = opacityMatch
|
|
15
|
+
? Math.min(1, Math.max(0, parseFloat(opacityMatch[1])))
|
|
16
|
+
: 1;
|
|
17
|
+
return { position, opacity };
|
|
18
|
+
}
|
|
19
|
+
export function parseSlides(markdown) {
|
|
20
|
+
// Normalize line endings
|
|
21
|
+
const normalized = markdown.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
22
|
+
// Split on slide separator: "---" on its own line (with optional surrounding blank lines)
|
|
23
|
+
const rawSlides = normalized.split(/\n[ \t]*---[ \t]*\n/);
|
|
24
|
+
return rawSlides
|
|
25
|
+
.map((raw) => raw.trim())
|
|
26
|
+
.filter((raw) => raw.length > 0)
|
|
27
|
+
.map((raw) => {
|
|
28
|
+
const slide = { html: "" };
|
|
29
|
+
// Extract speaker notes (<!-- notes: ... --> at end)
|
|
30
|
+
const notesMatch = raw.match(/<!--\s*notes?:\s*([\s\S]*?)\s*-->/i);
|
|
31
|
+
if (notesMatch) {
|
|
32
|
+
slide.notes = notesMatch[1].trim();
|
|
33
|
+
}
|
|
34
|
+
let processedMd = raw.replace(/<!--\s*notes?:\s*[\s\S]*?\s*-->/gi, "");
|
|
35
|
+
// Find positioned images via title attribute: 
|
|
36
|
+
const imgRegex = /!\[([^\]]*)\]\(([^\s)]+)(?:\s+"([^"]*)")?\)/g;
|
|
37
|
+
const toRemove = [];
|
|
38
|
+
let match;
|
|
39
|
+
// Reset lastIndex before use since we're reusing the regex
|
|
40
|
+
imgRegex.lastIndex = 0;
|
|
41
|
+
while ((match = imgRegex.exec(raw)) !== null) {
|
|
42
|
+
const [full, alt, src, titleAttr] = match;
|
|
43
|
+
const { position, opacity } = parseImageDirective(titleAttr);
|
|
44
|
+
if (position === "bg") {
|
|
45
|
+
slide.bgImage = { src, alt, opacity };
|
|
46
|
+
toRemove.push(full);
|
|
47
|
+
}
|
|
48
|
+
else if (position === "right") {
|
|
49
|
+
slide.rightImage = { src, alt, opacity };
|
|
50
|
+
toRemove.push(full);
|
|
51
|
+
}
|
|
52
|
+
else if (position === "left") {
|
|
53
|
+
slide.leftImage = { src, alt, opacity };
|
|
54
|
+
toRemove.push(full);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const item of toRemove) {
|
|
58
|
+
// Replace only first occurrence (the matched image)
|
|
59
|
+
processedMd = processedMd.replace(item, "");
|
|
60
|
+
}
|
|
61
|
+
// Render remaining markdown to HTML
|
|
62
|
+
slide.html = marked.parse(processedMd.trim());
|
|
63
|
+
return slide;
|
|
64
|
+
});
|
|
65
|
+
}
|