@markdstage/markdstage 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -0
- package/bin/markdstage.mjs +12 -0
- package/package.json +45 -0
- package/shared/README.md +1014 -0
- package/shared/THIRD-PARTY-NOTICES.md +19 -0
- package/shared/deck-state.mjs +105 -0
- package/shared/docs/custom-theme-authoring.md +208 -0
- package/shared/markdown-deck.mjs +220 -0
- package/shared/markdstage-guide.mjs +276 -0
- package/shared/presenter-window.mjs +17 -0
- package/shared/renderer/architecture-document.mjs +596 -0
- package/shared/renderer/architecture-edit.mjs +298 -0
- package/shared/renderer/architecture-editor.mjs +449 -0
- package/shared/renderer/architecture.mjs +4033 -0
- package/shared/renderer/import-path.mjs +11 -0
- package/shared/renderer/index.html +106 -0
- package/shared/renderer/renderer.js +2082 -0
- package/shared/renderer/slides.css +614 -0
- package/shared/renderer/speaker-notes.mjs +106 -0
- package/shared/renderer/theme.mjs +205 -0
- package/shared/runtime/browser.mjs +539 -0
- package/shared/runtime/custom-theme.mjs +135 -0
- package/shared/runtime/deck-session.mjs +188 -0
- package/shared/runtime/errors.mjs +17 -0
- package/shared/runtime/output-paths.mjs +159 -0
- package/shared/runtime/output.mjs +385 -0
- package/shared/runtime/presentation-server.mjs +505 -0
- package/shared/runtime/static-files.mjs +70 -0
- package/shared/schema/README.md +228 -0
- package/shared/schema/architecture-v1.schema.json +664 -0
- package/shared/schema/examples/web-app.architecture.json +119 -0
- package/shared/schema/theme-metadata-v1.schema.json +75 -0
- package/shared/schema/theme-v1.json +84 -0
- package/shared/scripts/architecture-assets.mjs +226 -0
- package/shared/scripts/asset-paths.mjs +92 -0
- package/shared/scripts/atomic-markdown-replace.mjs +46 -0
- package/shared/scripts/markdown-blocks.mjs +182 -0
- package/shared/scripts/markdown-files.mjs +63 -0
- package/shared/scripts/markdown-save-coordinator.mjs +18 -0
- package/shared/scripts/markdown-watcher.mjs +80 -0
- package/shared/scripts/theme-paths.mjs +108 -0
- package/shared/scripts/vendor-assets.mjs +132 -0
- package/shared/scripts/workspace-root.mjs +32 -0
- package/shared/vendor/highlight.LICENSE +29 -0
- package/shared/vendor/highlight.min.js +1244 -0
- package/shared/vendor/marked.min.js +6 -0
- package/shared/vendor/mermaid.min.js.part-0001 +268 -0
- package/shared/vendor/mermaid.min.js.part-0002 +304 -0
- package/shared/vendor/mermaid.min.js.part-0003 +324 -0
- package/shared/vendor/mermaid.min.js.part-0004 +374 -0
- package/shared/vendor/mermaid.min.js.part-0005 +564 -0
- package/shared/vendor/mermaid.min.js.part-0006 +1308 -0
- package/shared/vendor/mermaid.min.js.part-0007 +269 -0
- package/shared/vendor/purify.min.js +3 -0
- package/shared/vendor/vendor-assets.lock.json +60 -0
- package/src/cli.mjs +347 -0
- package/src/commands/capture.mjs +23 -0
- package/src/commands/export.mjs +18 -0
- package/src/commands/guide.mjs +23 -0
- package/src/commands/inspect.mjs +35 -0
- package/src/commands/present.mjs +91 -0
- package/src/commands/skill.mjs +114 -0
- package/src/commands/validate.mjs +79 -0
- package/src/deck.mjs +63 -0
- package/src/exit.mjs +58 -0
- package/src/runtime.mjs +77 -0
- package/src/skills.mjs +155 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
// Chromium discovery, process lifetime, and CDP helpers shared by the Canvas
|
|
2
|
+
// Extension and the MarkdStage CLI.
|
|
3
|
+
//
|
|
4
|
+
// MarkdStage never downloads a browser: it drives an installed Microsoft Edge,
|
|
5
|
+
// Google Chrome, or Chromium. Keep this module free of runtime npm dependencies
|
|
6
|
+
// because the Extension is distributed as a folder ZIP.
|
|
7
|
+
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { readFile, open, rm, stat } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
12
|
+
|
|
13
|
+
export const PDF_RENDER_TIMEOUT_MS = 60_000;
|
|
14
|
+
|
|
15
|
+
export function findExecutableOnPath(names) {
|
|
16
|
+
const locator = process.platform === "win32" ? "where.exe" : "which";
|
|
17
|
+
for (const name of names) {
|
|
18
|
+
try {
|
|
19
|
+
const output = execFileSync(locator, [name], {
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
22
|
+
windowsHide: true,
|
|
23
|
+
});
|
|
24
|
+
const candidate = output
|
|
25
|
+
.split(/\r?\n/)
|
|
26
|
+
.map((line) => line.trim())
|
|
27
|
+
.find((line) => line && existsSync(line));
|
|
28
|
+
if (candidate) return candidate;
|
|
29
|
+
} catch (_) {
|
|
30
|
+
// Try the next browser name.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function findChromiumBrowser() {
|
|
37
|
+
const candidates = [];
|
|
38
|
+
if (process.platform === "win32") {
|
|
39
|
+
for (const base of [
|
|
40
|
+
process.env.ProgramFiles,
|
|
41
|
+
process.env["ProgramFiles(x86)"],
|
|
42
|
+
process.env.LOCALAPPDATA,
|
|
43
|
+
]) {
|
|
44
|
+
if (!base) continue;
|
|
45
|
+
candidates.push(
|
|
46
|
+
join(base, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
47
|
+
join(base, "Google", "Chrome", "Application", "chrome.exe"),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
} else if (process.platform === "darwin") {
|
|
51
|
+
candidates.push(
|
|
52
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
53
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
54
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
55
|
+
);
|
|
56
|
+
} else {
|
|
57
|
+
candidates.push(
|
|
58
|
+
"/usr/bin/microsoft-edge",
|
|
59
|
+
"/usr/bin/microsoft-edge-stable",
|
|
60
|
+
"/usr/bin/google-chrome",
|
|
61
|
+
"/usr/bin/google-chrome-stable",
|
|
62
|
+
"/usr/bin/chromium",
|
|
63
|
+
"/usr/bin/chromium-browser",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const direct = candidates.find((candidate) => existsSync(candidate));
|
|
68
|
+
if (direct) return direct;
|
|
69
|
+
return findExecutableOnPath([
|
|
70
|
+
"msedge",
|
|
71
|
+
"microsoft-edge",
|
|
72
|
+
"microsoft-edge-stable",
|
|
73
|
+
"google-chrome",
|
|
74
|
+
"google-chrome-stable",
|
|
75
|
+
"chrome",
|
|
76
|
+
"chromium",
|
|
77
|
+
"chromium-browser",
|
|
78
|
+
]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function waitForChildExit(child, timeoutMs) {
|
|
82
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
83
|
+
return Promise.resolve(true);
|
|
84
|
+
}
|
|
85
|
+
return new Promise((resolvePromise) => {
|
|
86
|
+
let settled = false;
|
|
87
|
+
const finish = (exited) => {
|
|
88
|
+
if (settled) return;
|
|
89
|
+
settled = true;
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
child.off("exit", onExit);
|
|
92
|
+
resolvePromise(exited);
|
|
93
|
+
};
|
|
94
|
+
const onExit = () => finish(true);
|
|
95
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
96
|
+
child.once("exit", onExit);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function runTerminationCommand(executable, args, timeoutMs) {
|
|
101
|
+
return new Promise((resolvePromise) => {
|
|
102
|
+
const killer = spawn(executable, args, {
|
|
103
|
+
windowsHide: true,
|
|
104
|
+
stdio: "ignore",
|
|
105
|
+
});
|
|
106
|
+
let settled = false;
|
|
107
|
+
const finish = () => {
|
|
108
|
+
if (settled) return;
|
|
109
|
+
settled = true;
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
resolvePromise();
|
|
112
|
+
};
|
|
113
|
+
const timer = setTimeout(() => {
|
|
114
|
+
try {
|
|
115
|
+
killer.kill();
|
|
116
|
+
} catch (_) {
|
|
117
|
+
// The termination helper may already have exited.
|
|
118
|
+
}
|
|
119
|
+
finish();
|
|
120
|
+
}, timeoutMs);
|
|
121
|
+
killer.once("error", finish);
|
|
122
|
+
killer.once("exit", finish);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function terminateProcessTree(child) {
|
|
127
|
+
if (!child.pid || child.exitCode !== null || child.signalCode !== null) return;
|
|
128
|
+
|
|
129
|
+
if (process.platform === "win32") {
|
|
130
|
+
const systemRoot = process.env.SystemRoot || process.env.WINDIR || "C:\\Windows";
|
|
131
|
+
const taskkill = join(systemRoot, "System32", "taskkill.exe");
|
|
132
|
+
if (existsSync(taskkill)) {
|
|
133
|
+
await runTerminationCommand(taskkill, ["/PID", String(child.pid), "/T", "/F"], 5_000);
|
|
134
|
+
} else {
|
|
135
|
+
try {
|
|
136
|
+
child.kill();
|
|
137
|
+
} catch (_) {
|
|
138
|
+
// Fall through to the bounded exit wait.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
await waitForChildExit(child, 5_000);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
process.kill(-child.pid, "SIGTERM");
|
|
147
|
+
} catch (_) {
|
|
148
|
+
try {
|
|
149
|
+
child.kill("SIGTERM");
|
|
150
|
+
} catch (_) {
|
|
151
|
+
// Fall through to the bounded exit wait.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (await waitForChildExit(child, 3_000)) return;
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
process.kill(-child.pid, "SIGKILL");
|
|
158
|
+
} catch (_) {
|
|
159
|
+
try {
|
|
160
|
+
child.kill("SIGKILL");
|
|
161
|
+
} catch (_) {
|
|
162
|
+
// The process may already have exited.
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
await waitForChildExit(child, 2_000);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function isProcessRunning(child) {
|
|
169
|
+
return !!child && child.exitCode === null && child.signalCode === null && !child.killed;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function delay(milliseconds) {
|
|
173
|
+
return new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Run a headless browser once with `--print-to-pdf`.
|
|
178
|
+
*
|
|
179
|
+
* ⚠️ **`pageUrl` must include `?print=1&token=...` (#12).**
|
|
180
|
+
*
|
|
181
|
+
* `--print-to-pdf` completes only when the page becomes idle. In renderer `init()`,
|
|
182
|
+
* only print mode returns early. Normal and presenter views keep an unclosed SSE
|
|
183
|
+
* (`new EventSource("./events")`) and a two-second `setInterval` running.
|
|
184
|
+
* Passing a URL without `?print=1` therefore means **the browser never exits**.
|
|
185
|
+
*
|
|
186
|
+
* Observed results, using Chrome arguments byte-for-byte identical to this function:
|
|
187
|
+
*
|
|
188
|
+
* | URL | Result |
|
|
189
|
+
* | -------------------------- | --------------------------------------- |
|
|
190
|
+
* | `/?print=1&token=<valid>` | exit 0 @ 2.4s (valid PDF) |
|
|
191
|
+
* | `/?print=1&token=` (empty) | exit 0 @ 1.9s (blank; renderer reports failure) |
|
|
192
|
+
* | `/` (normal view) | **HANG** (still running after 120 seconds) |
|
|
193
|
+
* | `/?present=1` | **HANG** |
|
|
194
|
+
* | `/nope-404` (no renderer) | exit 0 @ 3.0s |
|
|
195
|
+
*
|
|
196
|
+
* ⚠️ **`--virtual-time-budget` is effectively ignored by `--headless=new`.**
|
|
197
|
+
* The `--virtual-time-budget=12000` argument below does not stop this hang.
|
|
198
|
+
* Adding `--timeout=8000` is also ineffective, as verified empirically.
|
|
199
|
+
* The argument is harmless and remains in place, but **do not treat it as a
|
|
200
|
+
* wall-clock timeout**. Only Node's `PDF_RENDER_TIMEOUT_MS` and
|
|
201
|
+
* `terminateProcessTree` enforce a limit, and failure may take up to 60 seconds.
|
|
202
|
+
*/
|
|
203
|
+
export async function runHeadlessBrowser(browser, args, failureLabel) {
|
|
204
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
205
|
+
const child = spawn(browser, args, {
|
|
206
|
+
detached: process.platform !== "win32",
|
|
207
|
+
windowsHide: true,
|
|
208
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
209
|
+
});
|
|
210
|
+
let diagnostics = "";
|
|
211
|
+
let settled = false;
|
|
212
|
+
let timedOut = false;
|
|
213
|
+
const appendDiagnostics = (chunk) => {
|
|
214
|
+
diagnostics = `${diagnostics}${chunk.toString()}`.slice(-12_000);
|
|
215
|
+
};
|
|
216
|
+
child.stdout.on("data", appendDiagnostics);
|
|
217
|
+
child.stderr.on("data", appendDiagnostics);
|
|
218
|
+
|
|
219
|
+
const settle = (error) => {
|
|
220
|
+
if (settled) return;
|
|
221
|
+
settled = true;
|
|
222
|
+
clearTimeout(timer);
|
|
223
|
+
if (error) rejectPromise(error);
|
|
224
|
+
else resolvePromise();
|
|
225
|
+
};
|
|
226
|
+
const timer = setTimeout(async () => {
|
|
227
|
+
if (settled) return;
|
|
228
|
+
timedOut = true;
|
|
229
|
+
await terminateProcessTree(child);
|
|
230
|
+
settle(new Error(`${failureLabel} timed out after ${PDF_RENDER_TIMEOUT_MS / 1000}s.`));
|
|
231
|
+
}, PDF_RENDER_TIMEOUT_MS);
|
|
232
|
+
|
|
233
|
+
child.once("error", (error) => {
|
|
234
|
+
if (!timedOut) settle(error);
|
|
235
|
+
});
|
|
236
|
+
child.once("exit", (code, signal) => {
|
|
237
|
+
if (timedOut) return;
|
|
238
|
+
if (code === 0) {
|
|
239
|
+
settle();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const detail = diagnostics.trim();
|
|
243
|
+
settle(
|
|
244
|
+
new Error(
|
|
245
|
+
`${failureLabel} failed (${signal ? `signal ${signal}` : `exit ${code}`})${
|
|
246
|
+
detail ? `: ${detail}` : "."
|
|
247
|
+
}`,
|
|
248
|
+
),
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function withSandboxFallback(args) {
|
|
255
|
+
if (
|
|
256
|
+
process.platform !== "win32" &&
|
|
257
|
+
typeof process.getuid === "function" &&
|
|
258
|
+
process.getuid() === 0
|
|
259
|
+
) {
|
|
260
|
+
return ["--no-sandbox", ...args];
|
|
261
|
+
}
|
|
262
|
+
return args;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export async function runPdfBrowser(browser, pageUrl, outputPath, profileDir) {
|
|
266
|
+
// Enforce the contract at runtime. Otherwise it silently waits 60 seconds
|
|
267
|
+
// before timing out, obscuring the cause; fail immediately with an explanation.
|
|
268
|
+
if (new URL(pageUrl).searchParams.get("print") !== "1") {
|
|
269
|
+
throw new Error(
|
|
270
|
+
`Refusing to run --print-to-pdf against a non-print URL (${pageUrl}): only ?print=1 stops the renderer's SSE and polling loops, so any other page hangs the browser forever.`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
const args = withSandboxFallback([
|
|
274
|
+
"--headless=new",
|
|
275
|
+
"--disable-gpu",
|
|
276
|
+
"--disable-background-networking",
|
|
277
|
+
"--disable-component-update",
|
|
278
|
+
"--disable-default-apps",
|
|
279
|
+
"--disable-extensions",
|
|
280
|
+
"--force-color-profile=srgb",
|
|
281
|
+
"--hide-scrollbars",
|
|
282
|
+
"--no-first-run",
|
|
283
|
+
"--no-pdf-header-footer",
|
|
284
|
+
"--print-to-pdf-no-header",
|
|
285
|
+
"--run-all-compositor-stages-before-draw",
|
|
286
|
+
// Ineffective (#12): --headless=new ignores it. See the JSDoc above.
|
|
287
|
+
// The actual safeguards are PDF_RENDER_TIMEOUT_MS + terminateProcessTree.
|
|
288
|
+
"--virtual-time-budget=12000",
|
|
289
|
+
`--user-data-dir=${profileDir}`,
|
|
290
|
+
`--print-to-pdf=${outputPath}`,
|
|
291
|
+
pageUrl,
|
|
292
|
+
]);
|
|
293
|
+
await runHeadlessBrowser(browser, args, "Browser PDF rendering");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function waitForDevToolsPort(profileDir, child, diagnostics) {
|
|
297
|
+
const portFile = join(profileDir, "DevToolsActivePort");
|
|
298
|
+
const deadline = Date.now() + 10_000;
|
|
299
|
+
while (Date.now() < deadline) {
|
|
300
|
+
try {
|
|
301
|
+
const [portLine] = (await readFile(portFile, "utf8")).split(/\r?\n/);
|
|
302
|
+
const port = Number.parseInt(portLine, 10);
|
|
303
|
+
if (Number.isInteger(port) && port > 0) return port;
|
|
304
|
+
} catch (_) {
|
|
305
|
+
// Chromium creates the file after its remote debugging endpoint is ready.
|
|
306
|
+
}
|
|
307
|
+
if (!isProcessRunning(child)) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
`Headless browser exited before DevTools became ready${
|
|
310
|
+
diagnostics.value.trim() ? `: ${diagnostics.value.trim()}` : "."
|
|
311
|
+
}`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
await delay(25);
|
|
315
|
+
}
|
|
316
|
+
throw new Error("Headless browser DevTools endpoint did not become ready within 10 seconds.");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function findPageTarget(port) {
|
|
320
|
+
const deadline = Date.now() + 10_000;
|
|
321
|
+
while (Date.now() < deadline) {
|
|
322
|
+
try {
|
|
323
|
+
const response = await fetch(`http://127.0.0.1:${port}/json/list`, {
|
|
324
|
+
cache: "no-store",
|
|
325
|
+
});
|
|
326
|
+
if (response.ok) {
|
|
327
|
+
const targets = await response.json();
|
|
328
|
+
const page = Array.isArray(targets)
|
|
329
|
+
? targets.find(
|
|
330
|
+
(target) =>
|
|
331
|
+
target?.type === "page" && typeof target.webSocketDebuggerUrl === "string",
|
|
332
|
+
)
|
|
333
|
+
: null;
|
|
334
|
+
if (page) return page;
|
|
335
|
+
}
|
|
336
|
+
} catch (_) {
|
|
337
|
+
// Retry while Chromium publishes its first page target.
|
|
338
|
+
}
|
|
339
|
+
await delay(25);
|
|
340
|
+
}
|
|
341
|
+
throw new Error("Headless browser did not expose a page target within 10 seconds.");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export async function connectCdp(webSocketUrl) {
|
|
345
|
+
if (typeof WebSocket !== "function") {
|
|
346
|
+
throw new Error("This runtime does not provide WebSocket support required for PNG capture.");
|
|
347
|
+
}
|
|
348
|
+
const socket = new WebSocket(webSocketUrl);
|
|
349
|
+
const pending = new Map();
|
|
350
|
+
let nextId = 1;
|
|
351
|
+
|
|
352
|
+
const opened = new Promise((resolvePromise, rejectPromise) => {
|
|
353
|
+
socket.addEventListener("open", resolvePromise, { once: true });
|
|
354
|
+
socket.addEventListener(
|
|
355
|
+
"error",
|
|
356
|
+
() => rejectPromise(new Error("Could not connect to the Chromium DevTools endpoint.")),
|
|
357
|
+
{ once: true },
|
|
358
|
+
);
|
|
359
|
+
});
|
|
360
|
+
socket.addEventListener("message", (event) => {
|
|
361
|
+
let text;
|
|
362
|
+
if (typeof event.data === "string") text = event.data;
|
|
363
|
+
else if (event.data instanceof ArrayBuffer) text = Buffer.from(event.data).toString("utf8");
|
|
364
|
+
else text = String(event.data);
|
|
365
|
+
let message;
|
|
366
|
+
try {
|
|
367
|
+
message = JSON.parse(text);
|
|
368
|
+
} catch (_) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (!message.id || !pending.has(message.id)) return;
|
|
372
|
+
const request = pending.get(message.id);
|
|
373
|
+
pending.delete(message.id);
|
|
374
|
+
if (message.error) {
|
|
375
|
+
request.reject(new Error(message.error.message || "Chromium DevTools command failed."));
|
|
376
|
+
} else {
|
|
377
|
+
request.resolve(message.result || {});
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
socket.addEventListener("close", () => {
|
|
381
|
+
for (const request of pending.values()) {
|
|
382
|
+
request.reject(new Error("Chromium DevTools connection closed unexpectedly."));
|
|
383
|
+
}
|
|
384
|
+
pending.clear();
|
|
385
|
+
});
|
|
386
|
+
await opened;
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
send(method, params = {}) {
|
|
390
|
+
const id = nextId++;
|
|
391
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
392
|
+
pending.set(id, { resolve: resolvePromise, reject: rejectPromise });
|
|
393
|
+
socket.send(JSON.stringify({ id, method, params }));
|
|
394
|
+
});
|
|
395
|
+
},
|
|
396
|
+
close() {
|
|
397
|
+
try {
|
|
398
|
+
socket.close();
|
|
399
|
+
} catch (_) {
|
|
400
|
+
// Process cleanup below is authoritative.
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function waitForOutputJob(job, child, diagnostics) {
|
|
407
|
+
const deadline = Date.now() + PDF_RENDER_TIMEOUT_MS;
|
|
408
|
+
while (job.status === "pending" && Date.now() < deadline) {
|
|
409
|
+
if (!isProcessRunning(child)) {
|
|
410
|
+
throw new Error(
|
|
411
|
+
`Headless browser exited before rendering completed${
|
|
412
|
+
diagnostics.value.trim() ? `: ${diagnostics.value.trim()}` : "."
|
|
413
|
+
}`,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
await delay(25);
|
|
417
|
+
}
|
|
418
|
+
if (job.status === "pending") {
|
|
419
|
+
throw new Error(
|
|
420
|
+
`Browser rendering timed out after ${PDF_RENDER_TIMEOUT_MS / 1000}s${
|
|
421
|
+
diagnostics.value.trim() ? `: ${diagnostics.value.trim()}` : "."
|
|
422
|
+
}`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
if (job.status !== "ready") {
|
|
426
|
+
throw new Error(job.error || "The renderer reported a failure.");
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export async function runCdpOutputBrowser(browser, pageUrl, profileDir, job, capturePng) {
|
|
431
|
+
const diagnostics = { value: "" };
|
|
432
|
+
await rm(join(profileDir, "DevToolsActivePort"), { force: true }).catch(() => {});
|
|
433
|
+
const args = withSandboxFallback([
|
|
434
|
+
"--headless=new",
|
|
435
|
+
"--disable-gpu",
|
|
436
|
+
"--disable-background-networking",
|
|
437
|
+
"--disable-component-update",
|
|
438
|
+
"--disable-default-apps",
|
|
439
|
+
"--disable-extensions",
|
|
440
|
+
"--force-color-profile=srgb",
|
|
441
|
+
"--force-device-scale-factor=1",
|
|
442
|
+
"--hide-scrollbars",
|
|
443
|
+
"--no-first-run",
|
|
444
|
+
"--run-all-compositor-stages-before-draw",
|
|
445
|
+
"--remote-debugging-port=0",
|
|
446
|
+
"--window-size=1280,720",
|
|
447
|
+
`--user-data-dir=${profileDir}`,
|
|
448
|
+
"about:blank",
|
|
449
|
+
]);
|
|
450
|
+
const child = spawn(browser, args, {
|
|
451
|
+
detached: process.platform !== "win32",
|
|
452
|
+
windowsHide: true,
|
|
453
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
454
|
+
});
|
|
455
|
+
const appendDiagnostics = (chunk) => {
|
|
456
|
+
diagnostics.value = `${diagnostics.value}${chunk.toString()}`.slice(-12_000);
|
|
457
|
+
};
|
|
458
|
+
child.stdout.on("data", appendDiagnostics);
|
|
459
|
+
child.stderr.on("data", appendDiagnostics);
|
|
460
|
+
|
|
461
|
+
let cdp = null;
|
|
462
|
+
try {
|
|
463
|
+
const port = await waitForDevToolsPort(profileDir, child, diagnostics);
|
|
464
|
+
const target = await findPageTarget(port);
|
|
465
|
+
cdp = await connectCdp(target.webSocketDebuggerUrl);
|
|
466
|
+
await cdp.send("Page.enable");
|
|
467
|
+
await cdp.send("Emulation.setDeviceMetricsOverride", {
|
|
468
|
+
width: 1280,
|
|
469
|
+
height: 720,
|
|
470
|
+
deviceScaleFactor: 1,
|
|
471
|
+
mobile: false,
|
|
472
|
+
});
|
|
473
|
+
const navigation = await cdp.send("Page.navigate", { url: pageUrl });
|
|
474
|
+
if (navigation.errorText) {
|
|
475
|
+
throw new Error(`Chromium could not open the renderer: ${navigation.errorText}`);
|
|
476
|
+
}
|
|
477
|
+
await waitForOutputJob(job, child, diagnostics);
|
|
478
|
+
if (!capturePng) return null;
|
|
479
|
+
await cdp.send("Runtime.evaluate", {
|
|
480
|
+
expression:
|
|
481
|
+
"new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))",
|
|
482
|
+
awaitPromise: true,
|
|
483
|
+
});
|
|
484
|
+
const screenshot = await cdp.send("Page.captureScreenshot", {
|
|
485
|
+
format: "png",
|
|
486
|
+
fromSurface: true,
|
|
487
|
+
captureBeyondViewport: false,
|
|
488
|
+
});
|
|
489
|
+
if (typeof screenshot.data !== "string" || screenshot.data.length === 0) {
|
|
490
|
+
throw new Error("Chromium DevTools did not return PNG data.");
|
|
491
|
+
}
|
|
492
|
+
return Buffer.from(screenshot.data, "base64");
|
|
493
|
+
} finally {
|
|
494
|
+
cdp?.close();
|
|
495
|
+
if (isProcessRunning(child)) await terminateProcessTree(child);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export async function verifyPdf(outputPath) {
|
|
500
|
+
const info = await stat(outputPath);
|
|
501
|
+
if (!info.isFile() || info.size < 5) {
|
|
502
|
+
throw new Error("The browser did not create a valid PDF file.");
|
|
503
|
+
}
|
|
504
|
+
const handle = await open(outputPath, "r");
|
|
505
|
+
try {
|
|
506
|
+
const header = Buffer.alloc(5);
|
|
507
|
+
const { bytesRead } = await handle.read(header, 0, header.length, 0);
|
|
508
|
+
if (bytesRead !== header.length || header.toString("ascii") !== "%PDF-") {
|
|
509
|
+
throw new Error("The generated file does not have a PDF header.");
|
|
510
|
+
}
|
|
511
|
+
} finally {
|
|
512
|
+
await handle.close();
|
|
513
|
+
}
|
|
514
|
+
return info.size;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export async function verifyPng(outputPath) {
|
|
518
|
+
const info = await stat(outputPath);
|
|
519
|
+
if (!info.isFile() || info.size < 24) {
|
|
520
|
+
throw new Error("The browser did not create a valid PNG file.");
|
|
521
|
+
}
|
|
522
|
+
const handle = await open(outputPath, "r");
|
|
523
|
+
try {
|
|
524
|
+
const header = Buffer.alloc(24);
|
|
525
|
+
const { bytesRead } = await handle.read(header, 0, header.length, 0);
|
|
526
|
+
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
527
|
+
if (bytesRead !== header.length || !header.subarray(0, 8).equals(signature)) {
|
|
528
|
+
throw new Error("The generated file does not have a PNG header.");
|
|
529
|
+
}
|
|
530
|
+
const width = header.readUInt32BE(16);
|
|
531
|
+
const height = header.readUInt32BE(20);
|
|
532
|
+
if (width !== 1280 || height !== 720) {
|
|
533
|
+
throw new Error(`The generated PNG is ${width}x${height}; expected 1280x720.`);
|
|
534
|
+
}
|
|
535
|
+
return { bytes: info.size, width, height };
|
|
536
|
+
} finally {
|
|
537
|
+
await handle.close();
|
|
538
|
+
}
|
|
539
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Custom theme (CSS + optional theme.json metadata) loading shared by the Canvas
|
|
2
|
+
// Extension and the MarkdStage CLI.
|
|
3
|
+
//
|
|
4
|
+
// Every path is confined to the workspace and to the theme folder, and both the
|
|
5
|
+
// CSS and each declared asset are size-checked before they are served.
|
|
6
|
+
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
9
|
+
import { dirname, join, relative } from "node:path";
|
|
10
|
+
import { MarkdStageError } from "./errors.mjs";
|
|
11
|
+
import { isPathInside } from "./output-paths.mjs";
|
|
12
|
+
import { safeJoin } from "./static-files.mjs";
|
|
13
|
+
import { resolveThemeFile } from "../scripts/theme-paths.mjs";
|
|
14
|
+
import {
|
|
15
|
+
mapThemeMetadataAssets,
|
|
16
|
+
parseThemeMetadata,
|
|
17
|
+
parseThemeVariables,
|
|
18
|
+
serializeThemeVariables,
|
|
19
|
+
THEME_ASSET_MAX_BYTES,
|
|
20
|
+
themeMetadataAssetPaths,
|
|
21
|
+
} from "../renderer/theme.mjs";
|
|
22
|
+
|
|
23
|
+
export const THEME_METADATA_NAME = "theme.json";
|
|
24
|
+
export const THEME_METADATA_MAX_BYTES = 64 * 1024;
|
|
25
|
+
export const THEME_CSS_MAX_BYTES = 64 * 1024;
|
|
26
|
+
|
|
27
|
+
export async function loadCustomTheme(
|
|
28
|
+
workspaceRoot,
|
|
29
|
+
sourceName,
|
|
30
|
+
themeFile,
|
|
31
|
+
{ assetUrlPrefix = "/theme-assets/" } = {},
|
|
32
|
+
) {
|
|
33
|
+
if (!themeFile) {
|
|
34
|
+
throw new MarkdStageError(
|
|
35
|
+
"invalid_theme_file",
|
|
36
|
+
"custom theme requires themeFile or front matter theme-file.",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
let path;
|
|
40
|
+
try {
|
|
41
|
+
path = await resolveThemeFile(workspaceRoot, sourceName, themeFile);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw new MarkdStageError("invalid_theme_file", error.message);
|
|
44
|
+
}
|
|
45
|
+
if (!path) {
|
|
46
|
+
throw new MarkdStageError(
|
|
47
|
+
"theme_file_not_found",
|
|
48
|
+
`Could not read custom theme file: ${themeFile}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
let realThemeFile;
|
|
52
|
+
let realWorkspaceRoot;
|
|
53
|
+
try {
|
|
54
|
+
[realThemeFile, realWorkspaceRoot] = await Promise.all([
|
|
55
|
+
realpath(path),
|
|
56
|
+
realpath(workspaceRoot),
|
|
57
|
+
]);
|
|
58
|
+
} catch (_) {
|
|
59
|
+
throw new MarkdStageError(
|
|
60
|
+
"theme_file_not_found",
|
|
61
|
+
`Could not read custom theme file: ${themeFile}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (!isPathInside(realWorkspaceRoot, realThemeFile)) {
|
|
65
|
+
throw new MarkdStageError(
|
|
66
|
+
"invalid_theme_file",
|
|
67
|
+
"Custom theme files must stay inside the workspace.",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
let css;
|
|
71
|
+
try {
|
|
72
|
+
css = await readFile(realThemeFile, "utf8");
|
|
73
|
+
} catch (error) {
|
|
74
|
+
throw new MarkdStageError(
|
|
75
|
+
"theme_file_not_found",
|
|
76
|
+
`Could not read custom theme file: ${themeFile}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (Buffer.byteLength(css, "utf8") > THEME_CSS_MAX_BYTES) {
|
|
80
|
+
throw new MarkdStageError(
|
|
81
|
+
"invalid_theme_file",
|
|
82
|
+
"Custom theme CSS must be 64 KiB or smaller.",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const themeDir = dirname(path);
|
|
87
|
+
const realThemeDir = await realpath(themeDir);
|
|
88
|
+
if (!isPathInside(realWorkspaceRoot, realThemeDir)) {
|
|
89
|
+
throw new Error("Custom theme metadata must stay inside the workspace.");
|
|
90
|
+
}
|
|
91
|
+
const metadataPath = join(themeDir, THEME_METADATA_NAME);
|
|
92
|
+
let metadata = null;
|
|
93
|
+
if (existsSync(metadataPath)) {
|
|
94
|
+
const realMetadataPath = await realpath(metadataPath);
|
|
95
|
+
if (!isPathInside(realThemeDir, realMetadataPath)) {
|
|
96
|
+
throw new Error("Custom theme metadata must stay inside the theme folder.");
|
|
97
|
+
}
|
|
98
|
+
const metadataText = await readFile(realMetadataPath, "utf8");
|
|
99
|
+
if (Buffer.byteLength(metadataText, "utf8") > THEME_METADATA_MAX_BYTES) {
|
|
100
|
+
throw new Error("Custom theme metadata must be 64 KiB or smaller.");
|
|
101
|
+
}
|
|
102
|
+
metadata = parseThemeMetadata(metadataText);
|
|
103
|
+
for (const assetPath of themeMetadataAssetPaths(metadata)) {
|
|
104
|
+
const candidate = safeJoin(themeDir, assetPath);
|
|
105
|
+
if (!candidate) throw new Error(`Invalid custom theme asset path: ${assetPath}`);
|
|
106
|
+
let realAsset;
|
|
107
|
+
try {
|
|
108
|
+
realAsset = await realpath(candidate);
|
|
109
|
+
} catch (_) {
|
|
110
|
+
throw new Error(`Custom theme asset was not found: ${assetPath}`);
|
|
111
|
+
}
|
|
112
|
+
if (!isPathInside(realThemeDir, realAsset)) {
|
|
113
|
+
throw new Error(`Custom theme asset must stay inside the theme folder: ${assetPath}`);
|
|
114
|
+
}
|
|
115
|
+
const info = await stat(realAsset);
|
|
116
|
+
if (!info.isFile()) throw new Error(`Custom theme asset is not a file: ${assetPath}`);
|
|
117
|
+
if (info.size > THEME_ASSET_MAX_BYTES) {
|
|
118
|
+
throw new Error(`Custom theme asset must be 2 MiB or smaller: ${assetPath}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const assets = metadata ? themeMetadataAssetPaths(metadata) : [];
|
|
123
|
+
return {
|
|
124
|
+
file: relative(workspaceRoot, path),
|
|
125
|
+
css: serializeThemeVariables(parseThemeVariables(css)),
|
|
126
|
+
dir: relative(workspaceRoot, themeDir),
|
|
127
|
+
metadata: metadata
|
|
128
|
+
? mapThemeMetadataAssets(metadata, (assetPath) => `${assetUrlPrefix}${assetPath}`)
|
|
129
|
+
: null,
|
|
130
|
+
assets,
|
|
131
|
+
};
|
|
132
|
+
} catch (error) {
|
|
133
|
+
throw new MarkdStageError("invalid_theme_file", error.message);
|
|
134
|
+
}
|
|
135
|
+
}
|