@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/LICENSE +21 -0
- package/README.md +1073 -0
- package/THIRD-PARTY-NOTICES.md +38 -0
- package/dist/editor-content.js +485 -0
- package/dist/editor.js +3916 -0
- package/dist/fragments.js +71 -0
- package/dist/generate.js +3488 -0
- package/dist/highlights.js +833 -0
- package/dist/index.js +1020 -0
- package/dist/lint.js +330 -0
- package/dist/parser.js +221 -0
- package/dist/pdf.js +200 -0
- package/dist/presentation-options.js +289 -0
- package/dist/preview.js +400 -0
- package/dist/rich-content.js +195 -0
- package/dist/safe-fetch.js +173 -0
- package/dist/sanitize.js +102 -0
- package/dist/themes.js +1041 -0
- package/dist/titles.js +30 -0
- package/package.json +64 -0
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 `` 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 `` 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);
|