@codepress/design-kit 0.0.0-stage → 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.
@@ -0,0 +1,725 @@
1
+ /**
2
+ * `design-kit capture pages`: real pages of a running app captured into the
3
+ * kit as `route:` frames with `data-kit-slot` regions.
4
+ *
5
+ * For each width and page: a fresh browser context (signed in from the
6
+ * configured storageState), a fixed clock, UTC, reduced motion and no
7
+ * animations; it waits for the shell and the page's ready selector, marks
8
+ * slots, then serializes the body and the page's sheets. A page that fails
9
+ * is a gaps.json row and the run still succeeds; the run fails only when
10
+ * the app or the browser cannot start.
11
+ *
12
+ * DESIGN_KIT_STOP_TIMEOUT_MS bounds how long stopping the started app and
13
+ * closing the browser may take (default 15000).
14
+ */
15
+ import { spawn, spawnSync } from "node:child_process";
16
+ import fs from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import { pathToFileURL } from "node:url";
20
+
21
+ import {
22
+ CssCollector,
23
+ byKey,
24
+ cssUrls,
25
+ findForbidden,
26
+ forbiddenNeedles,
27
+ isFontUrl,
28
+ isRouteGap,
29
+ isRouteHtmlFile,
30
+ kitHtmlFile,
31
+ kitJson,
32
+ kitRouteId,
33
+ mergeGaps,
34
+ mergeManifest,
35
+ normalizeReactIds,
36
+ rewriteCssUrls,
37
+ selectPages,
38
+ } from "./pages.mjs";
39
+ import { ConfigError } from "./config.mjs";
40
+ import { loadPlaywright } from "./playwright.mjs";
41
+
42
+ const STOP_TIMEOUT_MS =
43
+ Number(process.env.DESIGN_KIT_STOP_TIMEOUT_MS) > 0
44
+ ? Number(process.env.DESIGN_KIT_STOP_TIMEOUT_MS)
45
+ : 15_000;
46
+ /** Inline images and CSS assets up to this size; larger ones are dropped. */
47
+ const MAX_INLINE_BYTES = 256 * 1024;
48
+ /** Polling interval while waiting for `waitUrl`. */
49
+ const WAIT_POLL_MS = 250;
50
+
51
+ /** No animation or transition runs, and no caret blinks, in a capture. */
52
+ const STILL_CSS =
53
+ "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}";
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // The app
57
+
58
+ /**
59
+ * Runs `startCommand` (in its own process group) and waits until `waitUrl`
60
+ * answers. `stop` signals the group, then kills it after the stop timeout.
61
+ */
62
+ export async function startApp(
63
+ { startCommand, waitUrl, cwd, startTimeoutMs },
64
+ log,
65
+ ) {
66
+ log(`starting the app: ${startCommand}`);
67
+ const child = spawn(startCommand, {
68
+ cwd,
69
+ shell: true,
70
+ detached: true,
71
+ stdio: ["ignore", "inherit", "inherit"],
72
+ });
73
+ let exited = child.exitCode !== null;
74
+ const exit = new Promise((resolve) =>
75
+ child.once("exit", () => {
76
+ exited = true;
77
+ resolve();
78
+ }),
79
+ );
80
+ const signal = (name) => {
81
+ try {
82
+ process.kill(-child.pid, name);
83
+ } catch {
84
+ // Already gone.
85
+ }
86
+ };
87
+ const stop = async () => {
88
+ if (exited) return;
89
+ signal("SIGTERM");
90
+ const timer = setTimeout(() => signal("SIGKILL"), STOP_TIMEOUT_MS);
91
+ await exit;
92
+ clearTimeout(timer);
93
+ };
94
+ process.on("exit", () => {
95
+ if (!exited) signal("SIGTERM");
96
+ });
97
+ const deadline = Date.now() + startTimeoutMs;
98
+ for (;;) {
99
+ if (exited)
100
+ throw new Error(`the start command exited before ${waitUrl} answered`);
101
+ try {
102
+ const response = await fetch(waitUrl, { redirect: "manual" });
103
+ if (response.status < 500) break;
104
+ } catch {
105
+ // Not serving yet.
106
+ }
107
+ if (Date.now() > deadline) {
108
+ await stop();
109
+ throw new Error(`${waitUrl} did not answer within ${startTimeoutMs} ms`);
110
+ }
111
+ await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_MS));
112
+ }
113
+ log(`the app answers at ${waitUrl}`);
114
+ return { stop };
115
+ }
116
+
117
+ async function importModule(file) {
118
+ return import(pathToFileURL(file).href);
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Capture
123
+
124
+ /** Fetches app-served assets once per run and turns them into data URIs. */
125
+ class AssetInliner {
126
+ cache = new Map();
127
+ constructor(context) {
128
+ this.context = context;
129
+ }
130
+
131
+ async fetch(url) {
132
+ const response = await this.context().request.get(url, { timeout: 10_000 });
133
+ return { response, body: await response.body() };
134
+ }
135
+
136
+ async inline(url) {
137
+ if (this.cache.has(url)) return this.cache.get(url);
138
+ let data = null;
139
+ try {
140
+ const { response, body } = await this.fetch(url);
141
+ if (response.ok() && body.length <= MAX_INLINE_BYTES) {
142
+ const type = (
143
+ response.headers()["content-type"] ?? "application/octet-stream"
144
+ )
145
+ .split(";")[0]
146
+ .trim();
147
+ data = `data:${type};base64,${body.toString("base64")}`;
148
+ }
149
+ } catch {
150
+ data = null;
151
+ }
152
+ this.cache.set(url, data);
153
+ return data;
154
+ }
155
+ }
156
+
157
+ function escapeRegExp(value) {
158
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
159
+ }
160
+
161
+ /** In the page: marks slots and serializes the body, its attributes and sheets. */
162
+ function extractPage({ slots, layoutSlots, layout, appOrigin, themeClasses }) {
163
+ const shell = layout ? document.querySelector(layout.selector) : null;
164
+ const chosen = layout
165
+ ? shell?.getAttribute(layout.attribute) || layout.default
166
+ : "default";
167
+ const visible = (el) => {
168
+ const rect = el.getBoundingClientRect();
169
+ return rect.width > 0 && rect.height > 0;
170
+ };
171
+ const marked = [];
172
+ for (const [slot, selectors] of Object.entries(
173
+ layoutSlots?.[chosen] ?? slots,
174
+ )) {
175
+ for (const selector of selectors) {
176
+ const el = [...document.querySelectorAll(selector)].find(visible);
177
+ if (!el) continue;
178
+ const filled =
179
+ el.children.length > 0 || (el.textContent ?? "").trim() !== "";
180
+ if (!filled) continue;
181
+ el.setAttribute("data-kit-slot", slot);
182
+ marked.push(slot);
183
+ break;
184
+ }
185
+ }
186
+
187
+ const attrs = (el) => {
188
+ const out = {};
189
+ for (const { name, value } of [...el.attributes]) {
190
+ if (name === "class") {
191
+ const classes = value
192
+ .split(/\s+/)
193
+ .filter((c) => c && !themeClasses.includes(c));
194
+ if (classes.length) out.class = classes.join(" ");
195
+ } else if (
196
+ name === "style" ||
197
+ name === "lang" ||
198
+ name === "dir" ||
199
+ name.startsWith("data-")
200
+ ) {
201
+ out[name] = value;
202
+ }
203
+ }
204
+ return out;
205
+ };
206
+
207
+ const sheets = [];
208
+ for (const sheet of [...document.styleSheets]) {
209
+ const owner = sheet.ownerNode;
210
+ if (sheet.disabled || owner?.hasAttribute?.("data-kit-capture")) continue;
211
+ let rules;
212
+ try {
213
+ rules = [...sheet.cssRules].map((rule) => rule.cssText);
214
+ } catch {
215
+ // A cross-origin sheet without CORS: it cannot travel in the kit.
216
+ console.warn(`design-kit: unreadable sheet ${sheet.href}`);
217
+ continue;
218
+ }
219
+ sheets.push({ href: sheet.href, media: sheet.media.mediaText, rules });
220
+ }
221
+
222
+ const clone = document.body.cloneNode(true);
223
+ clone
224
+ .querySelectorAll(
225
+ "script, noscript, style, link, template, [data-kit-capture]",
226
+ )
227
+ .forEach((el) => el.remove());
228
+ const images = [];
229
+ clone.querySelectorAll("img[src], image[href], source[src]").forEach((el) => {
230
+ const attr = el.tagName.toLowerCase() === "image" ? "href" : "src";
231
+ const raw = el.getAttribute(attr);
232
+ if (raw.startsWith("data:")) return;
233
+ const url = new URL(raw, document.baseURI).href;
234
+ el.setAttribute(attr, url);
235
+ images.push(url);
236
+ });
237
+ clone
238
+ .querySelectorAll("[srcset]")
239
+ .forEach((el) => el.removeAttribute("srcset"));
240
+ clone
241
+ .querySelectorAll("iframe[src]")
242
+ .forEach((el) => el.removeAttribute("src"));
243
+ // A sandboxed frame's srcdoc can carry a per-render nonce (as `NONCE="…"`
244
+ // or in JSON); every copy of it is pinned.
245
+ clone.querySelectorAll("iframe[srcdoc]").forEach((el) => {
246
+ let doc = el.getAttribute("srcdoc");
247
+ const nonces = new Set(
248
+ [
249
+ ...doc.matchAll(
250
+ /\bNONCE=["']([0-9a-f]{8,})["']|"nonce":"([0-9a-f]{8,})"/gi,
251
+ ),
252
+ ].map((match) => match[1] ?? match[2]),
253
+ );
254
+ for (const nonce of nonces) doc = doc.split(nonce).join("kit-nonce");
255
+ el.setAttribute("srcdoc", doc);
256
+ });
257
+ // A framework may set an attribute on a later commit, which moves it to the
258
+ // end; sorted attributes make two captures of one state identical.
259
+ for (const el of [clone, ...clone.querySelectorAll("*")]) {
260
+ const sorted = [...el.attributes]
261
+ .map(({ name, value }) => [name, value])
262
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
263
+ for (const [name] of sorted) el.removeAttribute(name);
264
+ for (const [name, value] of sorted) el.setAttribute(name, value);
265
+ }
266
+ // Links keep their path, never the capture's origin and port.
267
+ clone.querySelectorAll("[href]").forEach((el) => {
268
+ const href = el.getAttribute("href");
269
+ if (href.startsWith(appOrigin))
270
+ el.setAttribute("href", href.slice(appOrigin.length) || "/");
271
+ });
272
+ return {
273
+ html: clone.innerHTML,
274
+ layout: chosen,
275
+ slots: marked,
276
+ htmlAttrs: attrs(document.documentElement),
277
+ bodyAttrs: attrs(document.body),
278
+ sheets,
279
+ images,
280
+ finalPath: location.pathname,
281
+ };
282
+ }
283
+
284
+ async function capturePage(context, page, viewport, run) {
285
+ const { settings, baseUrl, shotPath, hooks, themeClasses } = run;
286
+ const tab = await context.newPage();
287
+ try {
288
+ await tab.clock.setFixedTime(Date.parse(settings.fixedTime));
289
+ // Callers compiled with esbuild's keepNames wrap functions passed to
290
+ // evaluate in a `__name` helper the page lacks.
291
+ await tab.addInitScript("globalThis.__name = (fn) => fn;");
292
+ await tab.addInitScript((css) => {
293
+ const add = () => {
294
+ const style = document.createElement("style");
295
+ style.setAttribute("data-kit-capture", "");
296
+ style.textContent = css;
297
+ document.documentElement.appendChild(style);
298
+ };
299
+ if (document.documentElement) add();
300
+ else document.addEventListener("DOMContentLoaded", add, { once: true });
301
+ }, STILL_CSS);
302
+ const pageHooks = (await hooks.page?.(tab, { page, viewport })) ?? {};
303
+ tab.setDefaultTimeout(run.timeoutMs);
304
+ await tab.goto(new URL(page.path, baseUrl).href, {
305
+ waitUntil: "domcontentloaded",
306
+ });
307
+ if (settings.shellReady) {
308
+ await tab
309
+ .locator(settings.shellReady)
310
+ .first()
311
+ .waitFor({ state: "visible" });
312
+ }
313
+ const ready = tab.locator(page.ready).first();
314
+ try {
315
+ await ready.waitFor({ state: "visible" });
316
+ } catch (error) {
317
+ await pageHooks.readyFailed?.();
318
+ throw new Error(
319
+ `ready selector ${JSON.stringify(page.ready)} never appeared within ${run.timeoutMs} ms (${error.message.split("\n")[0]})`,
320
+ );
321
+ }
322
+ // Late data and lazy sections settle; a page that polls never goes idle,
323
+ // which is fine: the ready selector already holds.
324
+ await tab
325
+ .waitForLoadState("networkidle", { timeout: 10_000 })
326
+ .catch(() => {});
327
+ await tab.evaluate(async () => {
328
+ await document.fonts.ready;
329
+ await new Promise((resolve) =>
330
+ requestAnimationFrame(() => requestAnimationFrame(resolve)),
331
+ );
332
+ });
333
+ const finalPath = new URL(tab.url()).pathname;
334
+ if (finalPath !== page.path)
335
+ throw new Error(`redirected from ${page.path} to ${finalPath}`);
336
+
337
+ // The serialized DOM keeps no scroll, so neither does the screenshot.
338
+ await tab.evaluate(() => {
339
+ window.scrollTo(0, 0);
340
+ for (const el of document.querySelectorAll("*"))
341
+ if (el.scrollTop || el.scrollLeft) el.scrollTo(0, 0);
342
+ });
343
+ if (pageHooks.beforeSerialize)
344
+ await pageHooks.beforeSerialize({ shotPath });
345
+ else if (run.screenshots) await tab.screenshot({ path: shotPath });
346
+
347
+ return await tab.evaluate(extractPage, {
348
+ slots: page.slots,
349
+ layoutSlots: page.layoutSlots ?? null,
350
+ layout: settings.layout,
351
+ appOrigin: new URL(baseUrl).origin,
352
+ themeClasses,
353
+ });
354
+ } finally {
355
+ await tab.close();
356
+ }
357
+ }
358
+
359
+ /** The classes the capture strips from <html>/<body>: compose sets them. */
360
+ function themeClassesOf(theme) {
361
+ if (theme && theme.attribute !== "class") return [];
362
+ const dark = theme?.dark ?? "dark";
363
+ return theme?.light ? [dark, theme.light] : [dark, "light"];
364
+ }
365
+
366
+ /**
367
+ * Captures the configured pages into `out`.
368
+ *
369
+ * Options (all optional except what the config leaves out): `out`, `only`
370
+ * (page names), `ready` ({name: selector} overrides), `screenshots` (a
371
+ * folder for each page's PNG), `timeoutMs`, `baseUrl` and `storageState`
372
+ * (a path or a Playwright storage state object; both override the config),
373
+ * `forbidStrings` (added to the config's), `stripOrigins` (more origins to
374
+ * remove from markup), `hooks` ({context, page}, over the config's hooks
375
+ * module), `playwright` ({chromium}), `log`, and `session` ({interrupted,
376
+ * browser}: set `interrupted` to stop between pages; `browser` is set once
377
+ * launched so a signal handler can close it).
378
+ *
379
+ * Returns 0, or 130 when interrupted (nothing is written then).
380
+ */
381
+ export async function capturePages(config, options = {}) {
382
+ const settings = config.pages;
383
+ if (!settings) {
384
+ throw new ConfigError(
385
+ "design-kit.config.json has no pages section; add one to capture pages",
386
+ );
387
+ }
388
+ const log =
389
+ options.log ??
390
+ ((message) => process.stderr.write(`[design-kit] ${message}\n`));
391
+ const session = options.session ?? { interrupted: false, browser: null };
392
+ const startedAt = performance.now();
393
+ const out = path.resolve(
394
+ options.out ??
395
+ config.out ??
396
+ path.join(process.env.TMPDIR || os.tmpdir(), "design-kit"),
397
+ );
398
+ const timeoutMs = options.timeoutMs ?? settings.timeoutMs;
399
+ const pages = selectPages(settings.pages, {
400
+ only: options.only,
401
+ ready: options.ready,
402
+ });
403
+ if (!options.baseUrl && !settings.baseUrl && !settings.startCommand) {
404
+ throw new ConfigError(
405
+ "pages.baseUrl, or pages.startCommand with pages.waitUrl, is required (or pass --base-url)",
406
+ );
407
+ }
408
+ const configHooks = settings.hooks ? await importModule(settings.hooks) : {};
409
+ const hooks = {
410
+ context: options.hooks?.context ?? configHooks.context,
411
+ page: options.hooks?.page ?? configHooks.page,
412
+ };
413
+ const needles = forbiddenNeedles([
414
+ ...settings.forbidStrings,
415
+ ...(options.forbidStrings ?? []),
416
+ ]);
417
+ const themeClasses = themeClassesOf(config.theme);
418
+
419
+ const stops = [];
420
+ const shutdown = async () => {
421
+ const browser = session.browser;
422
+ const closing = browser
423
+ ? Promise.race([
424
+ browser.close().catch(() => {}),
425
+ new Promise((resolve) =>
426
+ setTimeout(resolve, STOP_TIMEOUT_MS).unref(),
427
+ ),
428
+ ])
429
+ : Promise.resolve();
430
+ await Promise.all([
431
+ closing,
432
+ (async () => {
433
+ for (const stop of [...stops].reverse()) await stop();
434
+ })(),
435
+ ]);
436
+ };
437
+
438
+ const shotsDir =
439
+ options.screenshots ??
440
+ fs.mkdtempSync(
441
+ path.join(process.env.TMPDIR || os.tmpdir(), "design-kit-shots-"),
442
+ );
443
+ const timings = {};
444
+ const gaps = [];
445
+ const rows = [];
446
+ const files = new Map();
447
+ const fontUrls = new Map();
448
+ const css = new CssCollector();
449
+ let current = null;
450
+ const inliner = new AssetInliner(() => current);
451
+ const fontFiles = new Map();
452
+
453
+ try {
454
+ let baseUrl = options.baseUrl ?? settings.baseUrl;
455
+ if (!baseUrl) {
456
+ const t = performance.now();
457
+ const app = await startApp(settings, log);
458
+ stops.push(app.stop);
459
+ baseUrl = new URL(settings.waitUrl).origin;
460
+ timings.servers_ready_s = (performance.now() - t) / 1000;
461
+ }
462
+ const appOrigin = new URL(baseUrl).origin;
463
+ const { chromium } = options.playwright ?? loadPlaywright(config.configDir);
464
+ // A caller's signal handler closes the browser; Playwright's own would
465
+ // exit before the app is stopped.
466
+ session.browser = await chromium.launch({
467
+ headless: true,
468
+ handleSIGINT: false,
469
+ handleSIGTERM: false,
470
+ handleSIGHUP: false,
471
+ });
472
+ let storageState = options.storageState ?? settings.auth.storageState;
473
+ if (!options.storageState && settings.auth.setup) {
474
+ const setup = await importModule(settings.auth.setup);
475
+ storageState = await (setup.default ?? setup.setup)({
476
+ browser: session.browser,
477
+ baseUrl,
478
+ config,
479
+ });
480
+ }
481
+ const captureStart = performance.now();
482
+
483
+ for (const viewport of settings.widths) {
484
+ for (const page of pages) {
485
+ if (session.interrupted) return 130;
486
+ const id = kitRouteId(page.name, viewport.width);
487
+ const context = await session.browser.newContext({
488
+ baseURL: baseUrl,
489
+ viewport: { width: viewport.width, height: viewport.height },
490
+ deviceScaleFactor: 1,
491
+ timezoneId: "UTC",
492
+ locale: "en-US",
493
+ colorScheme: "light",
494
+ reducedMotion: "reduce",
495
+ ...(storageState ? { storageState } : {}),
496
+ });
497
+ current = context;
498
+ let cleanup = null;
499
+ const pageStart = performance.now();
500
+ try {
501
+ cleanup =
502
+ (await hooks.context?.(context, { page, viewport })) ?? null;
503
+ const shotPath = path.join(
504
+ shotsDir,
505
+ `${path.basename(kitHtmlFile(id), ".html")}.png`,
506
+ );
507
+ const capture = await capturePage(context, page, viewport, {
508
+ settings,
509
+ baseUrl,
510
+ shotPath,
511
+ hooks,
512
+ themeClasses,
513
+ timeoutMs,
514
+ screenshots: Boolean(options.screenshots),
515
+ });
516
+ if (!capture.slots.includes("main"))
517
+ throw new Error(
518
+ `no non-empty main slot in the ${capture.layout} layout`,
519
+ );
520
+
521
+ let html = capture.html;
522
+ for (const url of new Set(capture.images)) {
523
+ const data = await inliner.inline(url);
524
+ html = html.replace(
525
+ new RegExp(
526
+ `(src|href)="${escapeRegExp(url.replace(/&/g, "&amp;"))}"`,
527
+ "g",
528
+ ),
529
+ data ? `$1="${data}"` : `$1=""`,
530
+ );
531
+ }
532
+ // No capture-time origin or port survives into the kit, and React's
533
+ // timing-dependent ids are renumbered.
534
+ html = normalizeReactIds(html).replaceAll(appOrigin, "");
535
+ for (const origin of options.stripOrigins ?? [])
536
+ html = html.replaceAll(origin, "");
537
+ html = html.replaceAll(
538
+ `http://127.0.0.1:${new URL(appOrigin).port}`,
539
+ "",
540
+ );
541
+
542
+ const found = findForbidden(
543
+ [
544
+ html,
545
+ JSON.stringify(capture.htmlAttrs),
546
+ JSON.stringify(capture.bodyAttrs),
547
+ ].join("\n"),
548
+ needles,
549
+ );
550
+ if (found)
551
+ throw new Error(
552
+ "the page's markup carries a forbidStrings value; not written",
553
+ );
554
+
555
+ // CSS assets on the app origin (other than fonts) are inlined.
556
+ const inlined = new Map();
557
+ for (const sheet of capture.sheets) {
558
+ const base = sheet.href ?? new URL(page.path, appOrigin).href;
559
+ for (const rule of sheet.rules)
560
+ for (const url of cssUrls(rule, base)) {
561
+ const parsed = new URL(url);
562
+ if (
563
+ parsed.origin !== appOrigin ||
564
+ isFontUrl(parsed, settings.fontsPath)
565
+ )
566
+ continue;
567
+ const data = await inliner.inline(url);
568
+ if (data) inlined.set(url, data);
569
+ }
570
+ }
571
+ css.add(capture.sheets, (rule, href) =>
572
+ rewriteCssUrls(
573
+ rule,
574
+ href ?? new URL(page.path, appOrigin).href,
575
+ appOrigin,
576
+ inlined,
577
+ { fontsPath: settings.fontsPath, fonts: fontUrls },
578
+ ),
579
+ );
580
+ if (settings.copyFonts) {
581
+ for (const [url, name] of fontUrls) {
582
+ if (fontFiles.has(name)) continue;
583
+ try {
584
+ const { response, body } = await inliner.fetch(url);
585
+ if (response.ok()) fontFiles.set(name, body);
586
+ } catch {
587
+ // A font that cannot be fetched stays out; compose drops it.
588
+ }
589
+ }
590
+ }
591
+
592
+ const file = kitHtmlFile(id);
593
+ files.set(file, html);
594
+ rows.push({
595
+ id,
596
+ kind: "route",
597
+ title: `${page.title ?? page.name} (${viewport.width})`,
598
+ file,
599
+ width: viewport.width,
600
+ height: viewport.height,
601
+ bytes: Buffer.byteLength(html),
602
+ slots: [...capture.slots].sort(),
603
+ ...(page.sourceFile ? { source_file: page.sourceFile } : {}),
604
+ path: page.path,
605
+ body_class: capture.bodyAttrs.class ?? "",
606
+ html_attrs: capture.htmlAttrs,
607
+ body_attrs: capture.bodyAttrs,
608
+ });
609
+ log(
610
+ `captured ${id} (${capture.layout}, slots ${capture.slots.join(",")}, ${Math.round(Buffer.byteLength(html) / 1024)} KB, ${((performance.now() - pageStart) / 1000).toFixed(1)}s)`,
611
+ );
612
+ } catch (error) {
613
+ const reason = String(error?.message ?? error)
614
+ .split("\n")
615
+ .slice(0, 3)
616
+ .join(" ");
617
+ gaps.push({ id, route: page.name, reason });
618
+ log(`gap ${id}: ${reason}`);
619
+ } finally {
620
+ await cleanup?.().catch?.(() => {});
621
+ await context.close();
622
+ current = null;
623
+ }
624
+ }
625
+ }
626
+ timings.capture_s = (performance.now() - captureStart) / 1000;
627
+ } finally {
628
+ await shutdown();
629
+ if (!options.screenshots)
630
+ fs.rmSync(shotsDir, { recursive: true, force: true });
631
+ }
632
+
633
+ writePagesKit(out, {
634
+ rows,
635
+ files,
636
+ css: css.toString(),
637
+ gaps,
638
+ needles,
639
+ pages,
640
+ widths: settings.widths,
641
+ fonts: fontFiles,
642
+ theme: config.theme,
643
+ configDir: config.configDir,
644
+ });
645
+ timings.total_s = (performance.now() - startedAt) / 1000;
646
+ const cpu = process.cpuUsage();
647
+ log(
648
+ `done: ${rows.length} captured, ${gaps.length} gaps, into ${out}; ` +
649
+ Object.entries(timings)
650
+ .map(([k, v]) => `${k}=${v.toFixed(1)}`)
651
+ .join(" ") +
652
+ ` this_process_cpu_s=${((cpu.user + cpu.system) / 1e6).toFixed(1)}`,
653
+ );
654
+ return 0;
655
+ }
656
+
657
+ function writePagesKit(
658
+ out,
659
+ { rows, files, css, gaps, needles, pages, widths, fonts, theme, configDir },
660
+ ) {
661
+ fs.mkdirSync(path.join(out, "html"), { recursive: true });
662
+ fs.mkdirSync(path.join(out, "css"), { recursive: true });
663
+ // This half's files only: earlier page captures go, story files stay.
664
+ for (const name of fs.readdirSync(path.join(out, "html")))
665
+ if (isRouteHtmlFile(name)) fs.rmSync(path.join(out, "html", name));
666
+
667
+ const sortedRows = [...rows].sort(byKey((row) => row.id));
668
+ const written = [
669
+ ...[...files.entries()].sort(byKey(([file]) => file)),
670
+ ["routes.json", kitJson(sortedRows)],
671
+ ["css/routes.css", css],
672
+ ];
673
+ for (const [file, text] of written) {
674
+ if (findForbidden(text, needles))
675
+ throw new Error(
676
+ `${file} carries a forbidStrings value; refusing to write the kit`,
677
+ );
678
+ fs.writeFileSync(path.join(out, file), text);
679
+ }
680
+ if (fonts.size) {
681
+ fs.mkdirSync(path.join(out, "fonts"), { recursive: true });
682
+ for (const [name, body] of fonts) {
683
+ const target = path.join(out, "fonts", path.basename(name));
684
+ if (!fs.existsSync(target)) fs.writeFileSync(target, body);
685
+ }
686
+ }
687
+ // kit.mjs travels with every kit, so a pages-only kit composes too.
688
+ const tool = path.join(out, "kit.mjs");
689
+ if (!fs.existsSync(tool))
690
+ fs.copyFileSync(new URL("./kit.mjs", import.meta.url), tool);
691
+
692
+ const gapsPath = path.join(out, "gaps.json");
693
+ fs.writeFileSync(
694
+ gapsPath,
695
+ kitJson(mergeGaps(readJson(gapsPath), gaps.filter(isRouteGap))),
696
+ );
697
+
698
+ const manifestPath = path.join(out, "manifest.json");
699
+ const manifest = mergeManifest(readJson(manifestPath), {
700
+ sha: gitSha(configDir),
701
+ builtAt: new Date().toISOString(),
702
+ captured: sortedRows.length,
703
+ failed: gaps.length,
704
+ pages: pages.map((page) => page.name),
705
+ widths: widths.map((viewport) => viewport.width),
706
+ theme,
707
+ });
708
+ fs.writeFileSync(manifestPath, kitJson(manifest));
709
+ }
710
+
711
+ function readJson(file) {
712
+ try {
713
+ return JSON.parse(fs.readFileSync(file, "utf8"));
714
+ } catch {
715
+ return undefined;
716
+ }
717
+ }
718
+
719
+ function gitSha(cwd) {
720
+ const result = spawnSync("git", ["rev-parse", "HEAD"], {
721
+ cwd,
722
+ encoding: "utf8",
723
+ });
724
+ return result.status === 0 ? result.stdout.trim() : "unknown";
725
+ }