@mark1russell7/site-pages 0.1.6 → 0.1.8

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/build/cli.js CHANGED
@@ -1,12 +1,682 @@
1
1
  #!/usr/bin/env node
2
- import { a as runCli } from "./build-Dr35nErd.js";
2
+ import { a as runCli } from "./build-CD2wc7Yl.js";
3
+ import { spawn } from "node:child_process";
4
+ import { appendFileSync, createReadStream, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
5
+ import { delimiter, extname, isAbsolute, join, normalize, resolve, sep } from "node:path";
6
+ import { parseArgs } from "node:util";
7
+ import { homedir, tmpdir } from "node:os";
8
+ import { createServer } from "node:http";
9
+ // src/check/browser.ts
10
+ /**
11
+ * A small client of the Chrome DevTools protocol for the `check` command. It starts a Chromium
12
+ * browser (Chrome, Edge or the Chromium of Playwright) in headless mode and drives one page. It
13
+ * uses only Node.js, so the check needs no package install.
14
+ */
15
+ /** The paths that `findBrowser` examines after the option and the Playwright folder. */
16
+ function systemBrowsers(platform, env) {
17
+ if (platform === "win32") return [
18
+ env["PROGRAMFILES"],
19
+ env["PROGRAMFILES(X86)"],
20
+ env["LOCALAPPDATA"]
21
+ ].filter((root) => !!root).flatMap((root) => [join(root, "Google/Chrome/Application/chrome.exe"), join(root, "Microsoft/Edge/Application/msedge.exe")]);
22
+ if (platform === "darwin") return [
23
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
24
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
25
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
26
+ ];
27
+ const names = [
28
+ "google-chrome",
29
+ "google-chrome-stable",
30
+ "chromium",
31
+ "chromium-browser",
32
+ "microsoft-edge"
33
+ ];
34
+ const dirs = (env["PATH"] ?? "").split(delimiter).filter(Boolean);
35
+ return names.flatMap((name) => dirs.map((dir) => join(dir, name)));
36
+ }
37
+ /** The Chromium of the newest `chromium-<n>` folder of Playwright, or undefined. */
38
+ function playwrightChromium(platform, env) {
39
+ const root = env["PLAYWRIGHT_BROWSERS_PATH"] || (platform === "win32" ? join(env["LOCALAPPDATA"] ?? homedir(), "ms-playwright") : platform === "darwin" ? join(homedir(), "Library/Caches/ms-playwright") : join(homedir(), ".cache/ms-playwright"));
40
+ if (!existsSync(root)) return void 0;
41
+ const builds = readdirSync(root).map((name) => /^chromium-(\d+)$/.exec(name)).filter((match) => match !== null).sort((a, b) => Number(b[1]) - Number(a[1]));
42
+ for (const [folder] of builds) for (const sub of readdirSync(join(root, folder))) {
43
+ const found = (platform === "win32" ? [join(root, folder, sub, "chrome.exe")] : platform === "darwin" ? [join(root, folder, sub, "Chromium.app/Contents/MacOS/Chromium"), join(root, folder, sub, "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing")] : [join(root, folder, sub, "chrome")]).find((file) => existsSync(file));
44
+ if (found) return found;
45
+ }
46
+ }
47
+ /**
48
+ * This function finds a Chromium browser. It examines the given path, then `$SITE_CHECK_BROWSER`,
49
+ * then the newest Chromium of Playwright, then a Chrome or an Edge of the system.
50
+ *
51
+ * @returns the path of the executable, or undefined when there is none
52
+ */
53
+ function findBrowser(given, env = process.env, platform = process.platform) {
54
+ const named = given || env["SITE_CHECK_BROWSER"];
55
+ if (named) return named;
56
+ return playwrightChromium(platform, env) ?? systemBrowsers(platform, env).find((file) => existsSync(file));
57
+ }
58
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
59
+ /**
60
+ * This function starts the browser in headless mode with a new profile, and connects to its page.
61
+ * The browser picks a free port, so two checks can run at the same time.
62
+ *
63
+ * @throws Error when the browser does not start in `timeoutMs`
64
+ */
65
+ async function launch(executable, timeoutMs = 3e4) {
66
+ const profile = mkdtempSync(join(tmpdir(), "site-check-"));
67
+ const args = [
68
+ "--headless=new",
69
+ "--remote-debugging-port=0",
70
+ `--user-data-dir=${profile}`,
71
+ "--no-first-run",
72
+ "--no-default-browser-check",
73
+ "--disable-gpu",
74
+ "--disable-extensions",
75
+ "--disable-background-networking",
76
+ "--disable-sync",
77
+ "--hide-scrollbars",
78
+ "--mute-audio",
79
+ ...process.platform === "linux" ? ["--no-sandbox"] : [],
80
+ "about:blank"
81
+ ];
82
+ const child = spawn(executable, args, { stdio: "ignore" });
83
+ let exited;
84
+ child.once("exit", (code, signal) => exited = `the browser stopped (code ${code}, signal ${signal})`);
85
+ child.once("error", (error) => exited = `the browser did not start: ${error.message}`);
86
+ const stop = async () => {
87
+ if (child.exitCode === null && child.signalCode === null) {
88
+ child.kill();
89
+ await Promise.race([new Promise((resolve) => child.once("exit", resolve)), sleep(5e3)]);
90
+ }
91
+ rmSync(profile, {
92
+ recursive: true,
93
+ force: true,
94
+ maxRetries: 5,
95
+ retryDelay: 200
96
+ });
97
+ };
98
+ try {
99
+ const portFile = join(profile, "DevToolsActivePort");
100
+ const end = Date.now() + timeoutMs;
101
+ let port = "";
102
+ while (port === "") {
103
+ if (exited !== void 0) throw new Error(`${executable}: ${exited}`);
104
+ if (Date.now() > end) throw new Error(`${executable}: no DevTools port after ${timeoutMs} ms`);
105
+ if (existsSync(portFile)) port = readFileSync(portFile, "utf8").split("\n")[0]?.trim() ?? "";
106
+ if (port === "") await sleep(100);
107
+ }
108
+ let target;
109
+ while (target === void 0) {
110
+ target = (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()).find((entry) => entry.type === "page");
111
+ if (target === void 0) {
112
+ if (Date.now() > end) throw new Error(`${executable}: no page target after ${timeoutMs} ms`);
113
+ await sleep(100);
114
+ }
115
+ }
116
+ const page = await connect(target.webSocketDebuggerUrl);
117
+ return {
118
+ page: page.page,
119
+ close: async () => {
120
+ page.close();
121
+ await stop();
122
+ }
123
+ };
124
+ } catch (error) {
125
+ await stop();
126
+ throw error;
127
+ }
128
+ }
129
+ async function connect(url) {
130
+ const socket = new WebSocket(url);
131
+ await new Promise((resolve, reject) => {
132
+ socket.addEventListener("open", () => resolve(), { once: true });
133
+ socket.addEventListener("error", () => reject(/* @__PURE__ */ new Error(`no connection to ${url}`)), { once: true });
134
+ });
135
+ let sequence = 0;
136
+ const pending = /* @__PURE__ */ new Map();
137
+ const listeners = /* @__PURE__ */ new Map();
138
+ socket.addEventListener("message", (event) => {
139
+ const message = JSON.parse(String(event.data));
140
+ if (message.id !== void 0) {
141
+ const call = pending.get(message.id);
142
+ pending.delete(message.id);
143
+ if (message.error) call?.reject(new Error(message.error.message));
144
+ else call?.resolve(message.result ?? {});
145
+ } else if (message.method) for (const listener of listeners.get(message.method) ?? []) listener(message.params ?? {});
146
+ });
147
+ socket.addEventListener("close", () => {
148
+ for (const call of pending.values()) call.reject(/* @__PURE__ */ new Error("the browser closed the connection"));
149
+ pending.clear();
150
+ });
151
+ const page = {
152
+ send: (method, params = {}) => new Promise((resolve, reject) => {
153
+ const id = ++sequence;
154
+ pending.set(id, {
155
+ resolve,
156
+ reject
157
+ });
158
+ socket.send(JSON.stringify({
159
+ id,
160
+ method,
161
+ params
162
+ }));
163
+ }),
164
+ on: (method, listener) => listeners.set(method, [...listeners.get(method) ?? [], listener]),
165
+ once: (method, ms) => new Promise((resolve, reject) => {
166
+ const timer = setTimeout(() => {
167
+ remove();
168
+ reject(/* @__PURE__ */ new Error(`no ${method} after ${ms} ms`));
169
+ }, ms);
170
+ const listener = (params) => {
171
+ clearTimeout(timer);
172
+ remove();
173
+ resolve(params);
174
+ };
175
+ const remove = () => listeners.set(method, (listeners.get(method) ?? []).filter((entry) => entry !== listener));
176
+ page.on(method, listener);
177
+ }),
178
+ evaluate: async (expression) => {
179
+ const reply = await page.send("Runtime.evaluate", {
180
+ expression,
181
+ awaitPromise: true,
182
+ returnByValue: true
183
+ });
184
+ if (reply.exceptionDetails) throw new Error(reply.exceptionDetails.exception?.description ?? reply.exceptionDetails.text ?? "the expression failed");
185
+ return reply.result?.value;
186
+ }
187
+ };
188
+ return {
189
+ page,
190
+ close: () => socket.close()
191
+ };
192
+ }
193
+ // src/check/measure.ts
194
+ /**
195
+ * This expression measures the page. An element counts as past the edge when its right side is
196
+ * more than half a pixel past the width of the document. These elements do not count:
197
+ * - an element in a container that scrolls or clips (`overflow-x` other than `visible`, below `body`),
198
+ * - an element of zero size, a hidden element, or a fixed element that is fully past the edge (a closed drawer),
199
+ * - an element in `.sr`, `.sr-only`, `.visually-hidden` or `.skip` (text for screen readers and skip links),
200
+ * - an element in a `pre`, other than the `pre` itself.
201
+ * Of a chain of elements past the edge, only the outermost one counts.
202
+ */
203
+ const MEASURE = `(() => {
204
+ const root = document.documentElement;
205
+ const w = root.clientWidth;
206
+ const name = (e) => e.tagName.toLowerCase() + (e.id ? "#" + e.id : "") + (typeof e.className === "string" && e.className.trim() ? "." + e.className.trim().split(/\\s+/).slice(0, 3).join(".") : "");
207
+ const inScroller = (e) => {
208
+ for (let p = e.parentElement; p && p !== document.body && p !== root; p = p.parentElement) {
209
+ if (getComputedStyle(p).overflowX !== "visible") return true;
210
+ }
211
+ return false;
212
+ };
213
+ const counts = (e) => {
214
+ const r = e.getBoundingClientRect();
215
+ if (r.width === 0 || r.height === 0 || r.right <= w + 0.5) return false;
216
+ if (e.closest(".sr,.sr-only,.visually-hidden,.skip")) return false;
217
+ if (e.closest("pre") && e.tagName !== "PRE") return false;
218
+ const s = getComputedStyle(e);
219
+ if (s.visibility === "hidden" || s.display === "none") return false;
220
+ if (s.position === "fixed" && r.left >= w) return false;
221
+ return !inScroller(e);
222
+ };
223
+ const over = new Set([...document.querySelectorAll("body *")].filter(counts));
224
+ const past = [...over]
225
+ .filter((e) => { for (let p = e.parentElement; p; p = p.parentElement) if (over.has(p)) return false; return true; })
226
+ .map((e) => name(e) + " right=" + Math.round(e.getBoundingClientRect().right));
227
+ const clipped = [...document.querySelectorAll("textarea")]
228
+ .filter((e) => { const s = getComputedStyle(e); return (s.overflowY === "hidden" || s.overflowY === "clip") && e.getBoundingClientRect().height > 0 && e.scrollHeight > e.clientHeight + 1; })
229
+ .map((e) => name(e) + " " + e.scrollHeight + ">" + e.clientHeight);
230
+ return {
231
+ innerWidth,
232
+ scrollWidth: root.scrollWidth,
233
+ height: Math.max(root.scrollHeight, document.body ? document.body.scrollHeight : 0),
234
+ text: (document.body ? document.body.innerText : "").trim().length,
235
+ past: past.slice(0, 8),
236
+ clipped: clipped.slice(0, 8),
237
+ };
238
+ })()`;
239
+ /**
240
+ * This function wraps the code of a probe file into an expression. The code is the body of an async
241
+ * function with these names:
242
+ * - `check(name, ok, detail)` records a check,
243
+ * - `wait(ms)` waits,
244
+ * - `until(fn, ms)` waits until `fn()` gives a true value, at most `ms` (default 5000), and returns the value,
245
+ * - `$(selector)` and `$$(selector)` find elements,
246
+ * - `type(element, text)` writes the text into an input or a text area, with an `input` event,
247
+ * - `click(element)` clicks an element, or the first element of a selector.
248
+ * An exception stops the probe, and it counts as a failed check.
249
+ */
250
+ function probeExpression(code) {
251
+ return `(async () => {
252
+ const __checks = [];
253
+ const __text = (v) => { if (v === undefined) return undefined; if (typeof v === "string") return v; try { return JSON.stringify(v); } catch { return String(v); } };
254
+ const check = (name, ok, detail) => { __checks.push({ name: String(name), ok: !!ok, ...(ok ? {} : { detail: __text(detail) }) }); return !!ok; };
255
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
256
+ const until = async (fn, ms = 5000) => { const end = Date.now() + ms; for (;;) { const v = await fn(); if (v) return v; if (Date.now() > end) return v; await wait(50); } };
257
+ const $ = (s) => document.querySelector(s);
258
+ const $$ = (s) => [...document.querySelectorAll(s)];
259
+ const __el = (e) => { const el = typeof e === "string" ? document.querySelector(e) : e; if (!el) throw new Error("no element: " + e); return el; };
260
+ const type = (e, text) => { const el = __el(e); el.focus(); el.value = text; el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); };
261
+ const click = (e) => __el(e).click();
262
+ try {
263
+ await (async () => {
264
+ ${code}
265
+ })();
266
+ } catch (e) {
267
+ __checks.push({ name: "the probe runs to its end", ok: false, detail: String((e && e.stack) || e) });
268
+ }
269
+ return __checks;
270
+ })()`;
271
+ }
272
+ // src/check/serve.ts
273
+ /**
274
+ * A static file server for the `check` command. It serves a built site folder at the path of the
275
+ * site home, as GitHub Pages does. Thus the links of the site work as on the live site.
276
+ */
277
+ const TYPES = {
278
+ ".html": "text/html; charset=utf-8",
279
+ ".js": "text/javascript; charset=utf-8",
280
+ ".mjs": "text/javascript; charset=utf-8",
281
+ ".css": "text/css; charset=utf-8",
282
+ ".json": "application/json; charset=utf-8",
283
+ ".svg": "image/svg+xml",
284
+ ".png": "image/png",
285
+ ".jpg": "image/jpeg",
286
+ ".jpeg": "image/jpeg",
287
+ ".gif": "image/gif",
288
+ ".webp": "image/webp",
289
+ ".avif": "image/avif",
290
+ ".ico": "image/x-icon",
291
+ ".woff": "font/woff",
292
+ ".woff2": "font/woff2",
293
+ ".ttf": "font/ttf",
294
+ ".otf": "font/otf",
295
+ ".wasm": "application/wasm",
296
+ ".txt": "text/plain; charset=utf-8",
297
+ ".md": "text/markdown; charset=utf-8",
298
+ ".xml": "application/xml",
299
+ ".webmanifest": "application/manifest+json",
300
+ ".map": "application/json; charset=utf-8"
301
+ };
302
+ /**
303
+ * This function maps the path of a request to a file of the folder, as GitHub Pages does. A folder
304
+ * gives its `index.html`. For a path with no extension, the function also tries `<path>.html`.
305
+ *
306
+ * @returns the absolute file, or undefined when the path is outside the base or the file is missing
307
+ */
308
+ function resolveFile(root, base, pathname) {
309
+ let decoded;
310
+ try {
311
+ decoded = decodeURIComponent(pathname);
312
+ } catch {
313
+ return;
314
+ }
315
+ const home = base.endsWith("/") ? base : `${base}/`;
316
+ if (decoded === home.slice(0, -1)) decoded = home;
317
+ if (!decoded.startsWith(home)) return void 0;
318
+ const top = resolve(root);
319
+ const file = normalize(join(top, decoded.slice(home.length)));
320
+ if (file !== top && !file.startsWith(top + sep)) return void 0;
321
+ return [
322
+ file,
323
+ join(file, "index.html"),
324
+ `${file}.html`
325
+ ].find((candidate) => existsSync(candidate) && statSync(candidate).isFile());
326
+ }
327
+ /**
328
+ * This function serves the folder at `base` on a free port of 127.0.0.1. A missing file gives the
329
+ * status 404 with the `404.html` of the folder, when it has one.
330
+ */
331
+ async function serve(root, base) {
332
+ const server = createServer((request, response) => {
333
+ const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
334
+ const home = base.endsWith("/") ? base : `${base}/`;
335
+ if (pathname === home.slice(0, -1) && home !== "/") {
336
+ response.writeHead(301, { location: home }).end();
337
+ return;
338
+ }
339
+ const file = resolveFile(root, base, pathname);
340
+ const missing = join(resolve(root), "404.html");
341
+ const send = file ?? (existsSync(missing) ? missing : void 0);
342
+ if (send === void 0) {
343
+ response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end(`404: ${pathname}\n`);
344
+ return;
345
+ }
346
+ response.writeHead(file ? 200 : 404, {
347
+ "content-type": TYPES[extname(send).toLowerCase()] ?? "application/octet-stream",
348
+ "cache-control": "no-store"
349
+ });
350
+ createReadStream(send).pipe(response);
351
+ });
352
+ await new Promise((done, fail) => {
353
+ server.once("error", fail);
354
+ server.listen(0, "127.0.0.1", () => done());
355
+ });
356
+ const address = server.address();
357
+ if (address === null || typeof address === "string") throw new Error("the server has no port");
358
+ return {
359
+ url: `http://127.0.0.1:${address.port}${base.endsWith("/") ? base : `${base}/`}`,
360
+ close: () => new Promise((done) => server.close(() => done()))
361
+ };
362
+ }
363
+ // src/check/check.ts
364
+ /**
365
+ * The `site-pages check` command. It opens the pages of a built site (or of a live URL) in a
366
+ * headless Chromium at phone and desktop widths, in the light and the dark scheme.
367
+ *
368
+ * A view fails when the page is wider than the screen or an element goes past the right edge. It
369
+ * also fails when a text area hides its text or the page shows no text. A console error, an
370
+ * exception, a failed request of the site and a failed check of the probe also fail it. The command
371
+ * writes a screenshot of each view.
372
+ */
373
+ /** This function gives the file name part of a page: `/` is `home`, `/releases/` is `releases`. */
374
+ function pageSlug(page) {
375
+ const slug = page.replace(/^\/+|\/+$/g, "").replace(/[^A-Za-z0-9._-]+/g, "_");
376
+ return slug === "" ? "home" : slug;
377
+ }
378
+ /**
379
+ * This function checks the site. It starts one browser and, for `out`, one server, and stops both at
380
+ * the end.
381
+ *
382
+ * @throws Error when there is no browser, or the browser does not start
383
+ */
384
+ async function checkSite(options) {
385
+ const log = options.log ?? (() => {});
386
+ const executable = findBrowser(options.browser);
387
+ if (executable === void 0) throw new Error("no Chromium browser was found: give --browser, set SITE_CHECK_BROWSER, or install the Chromium of Playwright");
388
+ if (options.pages.length === 0) throw new Error("no page to check");
389
+ const server = options.out !== void 0 ? await serve(options.out, options.base ?? "/") : void 0;
390
+ const home = server?.url ?? options.url;
391
+ if (home === void 0) throw new Error("give out or url");
392
+ const homeUrl = new URL(home.endsWith("/") ? home : `${home}/`);
393
+ const browser = await launch(executable, options.timeoutMs);
394
+ const views = [];
395
+ try {
396
+ const { page } = browser;
397
+ const events = watch(page, homeUrl.origin);
398
+ await Promise.all([
399
+ page.send("Page.enable"),
400
+ page.send("Runtime.enable"),
401
+ page.send("Network.enable")
402
+ ]);
403
+ if (options.shots) mkdirSync(options.shots, { recursive: true });
404
+ for (const [index, path] of options.pages.entries()) {
405
+ const target = new URL(path.replace(/^\/+/, ""), homeUrl).href;
406
+ for (const width of options.widths) for (const scheme of options.schemes) {
407
+ const view = await checkView(page, events, {
408
+ target,
409
+ path,
410
+ width,
411
+ scheme,
412
+ withProbe: options.probe !== void 0 && index === 0 && scheme === options.schemes[0],
413
+ options
414
+ });
415
+ views.push(view);
416
+ log(`${view.ok ? "PASS" : "FAIL"} ${path} ${width} ${scheme}${view.measure ? ` scrollWidth=${view.measure.scrollWidth} innerWidth=${view.measure.innerWidth}` : ""}${view.probe ? ` probe ${view.probe.filter((c) => c.ok).length}/${view.probe.length}` : ""}${view.problems.length > 0 ? `\n - ${view.problems.join("\n - ")}` : ""}`);
417
+ }
418
+ }
419
+ } finally {
420
+ await browser.close();
421
+ await server?.close();
422
+ }
423
+ const result = {
424
+ ok: views.length > 0 && views.every((view) => view.ok),
425
+ url: homeUrl.href,
426
+ browser: executable,
427
+ views
428
+ };
429
+ if (options.shots) {
430
+ writeFileSync(join(options.shots, "report.json"), `${JSON.stringify(result, null, 2)}\n`);
431
+ writeFileSync(join(options.shots, "summary.md"), summary(result));
432
+ }
433
+ return result;
434
+ }
435
+ function watch(page, origin) {
436
+ let problems = [];
437
+ const urls = /* @__PURE__ */ new Map();
438
+ const own = (url) => {
439
+ try {
440
+ const parsed = new URL(url);
441
+ return parsed.origin === origin && parsed.pathname !== "/favicon.ico";
442
+ } catch {
443
+ return false;
444
+ }
445
+ };
446
+ page.on("Runtime.exceptionThrown", (params) => {
447
+ const details = params["exceptionDetails"];
448
+ problems.push(`exception: ${(details.exception?.description ?? details.text ?? "").split("\n")[0]}`);
449
+ });
450
+ page.on("Runtime.consoleAPICalled", (params) => {
451
+ if (params["type"] !== "error" && params["type"] !== "assert") return;
452
+ const args = params["args"] ?? [];
453
+ problems.push(`console.error: ${args.map((arg) => String(arg.value ?? arg.description ?? "")).join(" ").slice(0, 300)}`);
454
+ });
455
+ page.on("Network.requestWillBeSent", (params) => {
456
+ urls.set(String(params["requestId"]), String(params["request"].url));
457
+ });
458
+ page.on("Network.responseReceived", (params) => {
459
+ const response = params["response"];
460
+ if (response.status >= 400 && own(response.url)) problems.push(`HTTP ${response.status}: ${response.url}`);
461
+ });
462
+ page.on("Network.loadingFailed", (params) => {
463
+ const url = urls.get(String(params["requestId"])) ?? "";
464
+ if (own(url) && params["canceled"] !== true) problems.push(`request failed (${String(params["errorText"])}): ${url}`);
465
+ });
466
+ return {
467
+ reset: () => {
468
+ problems = [];
469
+ },
470
+ problems: () => [...new Set(problems)]
471
+ };
472
+ }
473
+ async function checkView(page, events, input) {
474
+ const { target, path, width, scheme, withProbe, options } = input;
475
+ const view = {
476
+ page: path,
477
+ width,
478
+ scheme,
479
+ ok: false,
480
+ problems: []
481
+ };
482
+ try {
483
+ await page.send("Emulation.setDeviceMetricsOverride", {
484
+ width,
485
+ height: 900,
486
+ deviceScaleFactor: 1,
487
+ mobile: width < 768
488
+ });
489
+ await page.send("Emulation.setEmulatedMedia", { features: [{
490
+ name: "prefers-color-scheme",
491
+ value: scheme
492
+ }] });
493
+ events.reset();
494
+ const loaded = page.once("Page.loadEventFired", options.timeoutMs);
495
+ const navigation = await page.send("Page.navigate", { url: target });
496
+ if (navigation.errorText) {
497
+ loaded.catch(() => {});
498
+ throw new Error(`the page did not open: ${navigation.errorText}`);
499
+ }
500
+ await loaded;
501
+ await page.evaluate("document.fonts ? document.fonts.ready.then(() => true) : true");
502
+ await new Promise((done) => setTimeout(done, options.settleMs));
503
+ const measure = await page.evaluate(MEASURE);
504
+ view.measure = measure;
505
+ if (measure.scrollWidth > width) view.problems.push(`the page is wider than the screen: ${measure.scrollWidth} > ${width}`);
506
+ for (const past of measure.past) view.problems.push(`past the right edge: ${past}`);
507
+ for (const clipped of measure.clipped) view.problems.push(`a text area hides its text: ${clipped}`);
508
+ if (measure.text === 0) view.problems.push("the page shows no text");
509
+ if (options.shots) {
510
+ const file = join(options.shots, `${pageSlug(path)}-${width}-${scheme}.png`);
511
+ const height = Math.min(Math.max(measure.height, 1), 8e3);
512
+ const shot = await page.send("Page.captureScreenshot", {
513
+ format: "png",
514
+ captureBeyondViewport: true,
515
+ clip: {
516
+ x: 0,
517
+ y: 0,
518
+ width,
519
+ height,
520
+ scale: 1
521
+ }
522
+ });
523
+ writeFileSync(file, Buffer.from(shot.data, "base64"));
524
+ view.shot = file;
525
+ }
526
+ if (withProbe && options.probe !== void 0) {
527
+ const checks = await page.evaluate(probeExpression(options.probe)) ?? [];
528
+ view.probe = checks;
529
+ if (checks.length === 0) view.problems.push("the probe made no check");
530
+ for (const check of checks.filter((entry) => !entry.ok)) view.problems.push(`probe: ${check.name}${check.detail ? ` (${check.detail})` : ""}`);
531
+ }
532
+ } catch (error) {
533
+ view.problems.push(error instanceof Error ? error.message : String(error));
534
+ }
535
+ view.problems.push(...events.problems());
536
+ view.ok = view.problems.length === 0;
537
+ return view;
538
+ }
539
+ /** This function makes a text safe for a cell of a Markdown table. It escapes `\`, `|`, `<` and `>`. It changes each new line to a space. */
540
+ function cell(text) {
541
+ return text.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\r?\n/g, " ");
542
+ }
543
+ /** This function gives the Markdown summary of a result, for the job summary of a workflow. */
544
+ function summary(result) {
545
+ const failed = result.views.filter((view) => !view.ok);
546
+ return [
547
+ `### Site check: ${result.ok ? "passed" : "failed"}`,
548
+ "",
549
+ `${result.views.length - failed.length} of ${result.views.length} views passed, at ${result.url}.`,
550
+ "",
551
+ "| Page | Width | Scheme | Result | Problems |",
552
+ "| --- | ---: | --- | --- | --- |",
553
+ ...result.views.map((view) => `| \`${view.page}\` | ${view.width} | ${view.scheme} | ${view.ok ? "pass" : "**fail**"}${view.probe ? ` (probe ${view.probe.filter((c) => c.ok).length}/${view.probe.length})` : ""} | ${view.problems.map(cell).join("<br>")} |`),
554
+ ""
555
+ ].join("\n");
556
+ }
557
+ const CHECK_HELP = `Usage: site-pages check (--out <dir> | --url <url>) [options]
558
+
559
+ Opens the pages of the site in a headless Chromium at phone and desktop widths, in the light and
560
+ the dark scheme, and fails when a page is wider than the screen, an element goes past the right
561
+ edge, a text area hides its text, a page shows no text, or on a console error, an exception, a
562
+ failed request of the site or a failed check of the probe.
563
+
564
+ Options:
565
+ --out <dir> The built site folder. The check serves it at --base.
566
+ --url <url> The URL of a live site home, in place of --out
567
+ --repo <owner/name> The GitHub repository, for the default base (default: $GITHUB_REPOSITORY)
568
+ --base </name/> The path of the site home (default: "/<name>/", or "/" for <owner>.github.io)
569
+ --pages <paths> The pages, from the site home, separated by commas (default: /)
570
+ --widths <px> The widths, separated by commas (default: 360,400,1280)
571
+ --schemes <list> light, dark or both (default: light,dark)
572
+ --probe <file> A JavaScript file: the body of an async function with check(name, ok, detail),
573
+ wait, until, $, $$, type and click. It runs on the first page, in the first
574
+ scheme, at each width.
575
+ --shots <dir> The folder for the screenshots, report.json and summary.md
576
+ --browser <file> The Chromium executable (default: $SITE_CHECK_BROWSER, the Chromium of
577
+ Playwright, else Chrome or Edge)
578
+ --settle <ms> The wait after the load event (default: 1500)
579
+ --timeout <ms> The time limit of a page load (default: 30000)
580
+ --help Shows this text
581
+
582
+ With $GITHUB_STEP_SUMMARY, the check adds its table to the job summary.
583
+ `;
584
+ /** This function runs the `check` command with its arguments, without the word `check`. */
585
+ async function runCheckCli(argv, io) {
586
+ let values;
587
+ try {
588
+ ({values} = parseArgs({
589
+ args: [...argv],
590
+ allowPositionals: false,
591
+ options: {
592
+ out: { type: "string" },
593
+ url: { type: "string" },
594
+ repo: { type: "string" },
595
+ base: { type: "string" },
596
+ pages: { type: "string" },
597
+ widths: { type: "string" },
598
+ schemes: { type: "string" },
599
+ probe: { type: "string" },
600
+ shots: { type: "string" },
601
+ browser: { type: "string" },
602
+ settle: { type: "string" },
603
+ timeout: { type: "string" },
604
+ help: { type: "boolean" }
605
+ }
606
+ }));
607
+ } catch (error) {
608
+ io.stderr(`site-pages check: ${error instanceof Error ? error.message : String(error)}\n\n${CHECK_HELP}`);
609
+ return 2;
610
+ }
611
+ if (values["help"]) {
612
+ io.stdout(CHECK_HELP);
613
+ return 0;
614
+ }
615
+ const usage = (message) => {
616
+ io.stderr(`site-pages check: ${message}\n\n${CHECK_HELP}`);
617
+ return 2;
618
+ };
619
+ const text = (key) => typeof values[key] === "string" ? values[key] : void 0;
620
+ const list = (key, fallback) => (text(key) ?? fallback).split(",").map((entry) => entry.trim()).filter(Boolean);
621
+ const out = text("out");
622
+ const url = text("url");
623
+ if (out === void 0 === (url === void 0)) return usage("give --out or --url, not both");
624
+ const widths = list("widths", "360,400,1280").map(Number);
625
+ if (widths.length === 0 || widths.some((width) => !Number.isInteger(width) || width < 200 || width > 4e3)) return usage("--widths must be whole numbers from 200 to 4000");
626
+ const schemes = list("schemes", "light,dark");
627
+ if (schemes.length === 0 || schemes.some((scheme) => scheme !== "light" && scheme !== "dark")) return usage("--schemes must be light, dark or both");
628
+ const pages = list("pages", "/");
629
+ if (pages.length === 0) return usage("--pages has no page");
630
+ const notPath = pages.find((entry) => entry.includes(":") || entry.includes("\\"));
631
+ if (notPath !== void 0) return usage(`a page is a path from the site home, not "${notPath}" (in Git Bash, set MSYS_NO_PATHCONV=1)`);
632
+ const settleMs = Number(text("settle") ?? 1500);
633
+ const timeoutMs = Number(text("timeout") ?? 3e4);
634
+ if (!(settleMs >= 0) || !(timeoutMs > 0)) return usage("--settle and --timeout must be numbers of milliseconds");
635
+ let base = text("base");
636
+ if (out !== void 0 && base === void 0) {
637
+ const name = (text("repo") ?? io.env["GITHUB_REPOSITORY"] ?? "").split("/")[1];
638
+ if (!name) return usage("give --base, --repo or set GITHUB_REPOSITORY");
639
+ base = /\.github\.io$/i.test(name) ? "/" : `/${name}/`;
640
+ }
641
+ if (base !== void 0) base = `/${base.replace(/^\/+|\/+$/g, "")}/`.replace(/^\/\/$/, "/");
642
+ const at = (file) => isAbsolute(file) ? file : resolve(io.cwd, file);
643
+ const probeFile = text("probe");
644
+ let probe;
645
+ if (probeFile !== void 0) try {
646
+ probe = readFileSync(at(probeFile), "utf8");
647
+ } catch (error) {
648
+ return usage(`the probe file ${probeFile} cannot be read: ${error instanceof Error ? error.message : String(error)}`);
649
+ }
650
+ const shots = text("shots");
651
+ const result = await checkSite({
652
+ out: out === void 0 ? void 0 : at(out),
653
+ base,
654
+ url,
655
+ pages,
656
+ widths,
657
+ schemes,
658
+ probe,
659
+ shots: shots === void 0 ? void 0 : at(shots),
660
+ browser: text("browser"),
661
+ settleMs,
662
+ timeoutMs,
663
+ log: (line) => io.stdout(`${line}\n`)
664
+ });
665
+ const failed = result.views.filter((view) => !view.ok).length;
666
+ io.stdout(`site-pages check: ${result.views.length - failed} of ${result.views.length} views passed (${result.browser}).\n`);
667
+ const step = io.env["GITHUB_STEP_SUMMARY"];
668
+ if (step) appendFileSync(step, summary(result));
669
+ return result.ok ? 0 : 1;
670
+ }
3
671
  // src/cli.ts
4
- runCli(process.argv.slice(2), {
672
+ const argv = process.argv.slice(2);
673
+ const io = {
5
674
  cwd: process.cwd(),
6
675
  env: process.env,
7
676
  stdout: (text) => process.stdout.write(text),
8
677
  stderr: (text) => process.stderr.write(text)
9
- }).then((code) => {
678
+ };
679
+ (argv[0] === "check" ? runCheckCli(argv.slice(1), io) : runCli(argv, io)).then((code) => {
10
680
  process.exitCode = code;
11
681
  }, (error) => {
12
682
  process.stderr.write(`site-pages: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);