@coralreefventures/site-tools 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Coral Reef Ventures, LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # @coralreefventures/site-tools
2
+
3
+ Two command-line tools for a Coral Reef public site, which is a Next.js static export served by Amplify Hosting.
4
+ Both sites carried them as `scripts/serve.ts` and `scripts/lighthouse.ts`; they are bins now, and the products' scripts
5
+ go.
6
+
7
+ ```sh
8
+ reef-serve [--root out] [--port 3140]
9
+ reef-lighthouse [--root out] [--reports lighthouse] [--port 3160] [--threshold 90] [--chrome <path>] [path ...]
10
+ ```
11
+
12
+ `reef-serve` serves the export the way Amplify does, for Playwright and Lighthouse: a directory answers with its
13
+ `index.html`, a directory path without its slash redirects to it, anything else is the 404 page with status 404,
14
+ and text is compressed as the CDN compresses it. `PORT` is read when `--port` is not given, so a Playwright
15
+ `webServer` command can pass either:
16
+
17
+ ```ts
18
+ webServer: { command: `pnpm exec reef-serve --port ${port}`, port },
19
+ ```
20
+
21
+ `reef-lighthouse` is the merge gate: Lighthouse on every page in the export's sitemap (or the paths given), with its
22
+ default mobile emulation, each of performance, accessibility, best practices and SEO at the threshold or above. A
23
+ page under it fails the run and leaves its report in `--reports`. Lighthouse itself is not a dependency, because it
24
+ bundles axe-core (MPL-2.0), which is outside the family's licence allowlist; it runs through `pnpm dlx`, pinned
25
+ (`--lighthouse-version`). Chrome is Playwright's Chromium when `@playwright/test` is installed beside this package,
26
+ or `--chrome`.
27
+
28
+ The library entry exports `serve`, `createStaticServer`, `runLighthouse` and `pagesFromSitemap` for a script that
29
+ wants them in-process.
30
+
31
+ This is the one package in the repository with a build: a bin has to be JavaScript, because Node refuses to strip
32
+ types from a file under `node_modules`. `tsc` writes `dist/` on `prepack`.
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=reef-lighthouse.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reef-lighthouse.d.ts","sourceRoot":"","sources":["../../src/bin/reef-lighthouse.ts"],"names":[],"mappings":""}
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { runLighthouse } from "../lighthouse.js";
4
+ /**
5
+ * reef-lighthouse [--root out] [--reports lighthouse] [--port 3160] [--threshold 90] [--chrome <path>] [path ...]
6
+ *
7
+ * Lighthouse on every page in the export's sitemap, or the paths given, each category at the threshold or above.
8
+ * Exits 1 with the failing pages named when one is under, and leaves their reports in --reports.
9
+ */
10
+ const { values, positionals } = parseArgs({
11
+ allowPositionals: true,
12
+ options: {
13
+ root: { type: "string", default: "out" },
14
+ reports: { type: "string", default: "lighthouse" },
15
+ port: { type: "string", default: "3160" },
16
+ threshold: { type: "string", default: "90" },
17
+ chrome: { type: "string" },
18
+ "lighthouse-version": { type: "string", default: "13.5.0" },
19
+ help: { type: "boolean", short: "h" },
20
+ },
21
+ });
22
+ if (values.help) {
23
+ console.log("reef-lighthouse [--root out] [--reports lighthouse] [--port 3160] [--threshold 90] [--chrome <path>] [path ...]");
24
+ process.exit(0);
25
+ }
26
+ const threshold = Number(values.threshold) / 100;
27
+ const results = await runLighthouse({
28
+ root: values.root,
29
+ reportDir: values.reports,
30
+ port: Number(values.port),
31
+ threshold,
32
+ paths: positionals,
33
+ lighthouseVersion: values["lighthouse-version"],
34
+ ...(values.chrome !== undefined && { chrome: values.chrome }),
35
+ });
36
+ const failures = results.filter((result) => result.low.length > 0);
37
+ if (failures.length) {
38
+ console.error(`Under ${threshold * 100}:\n${failures.map((result) => `${result.path}: ${result.low.join(", ")}`).join("\n")}\nReports in ${values.reports}/`);
39
+ process.exit(1);
40
+ }
41
+ //# sourceMappingURL=reef-lighthouse.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reef-lighthouse.js","sourceRoot":"","sources":["../../src/bin/reef-lighthouse.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAEtC,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEjD;;;;;GAKG;AACH,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,GAAG,SAAS,CAAC;IACxC,gBAAgB,EAAE,IAAI;IACtB,OAAO,EAAE;QACP,IAAI,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE;QACxC,OAAO,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,YAAY,EAAE;QAClD,IAAI,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE;QACzC,SAAS,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE;QAC5C,MAAM,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;QAC1B,oBAAoB,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,EAAE;QAC3D,IAAI,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,GAAG,EAAE;KACtC;CACF,CAAC,CAAC;AAEH,IAAI,MAAM,CAAC,IAAI,EAAE,CAAC;IAChB,OAAO,CAAC,GAAG,CACT,iHAAiH,CAClH,CAAC;IACF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AAED,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,GAAG,GAAG,CAAC;AACjD,MAAM,OAAO,GAAG,MAAM,aAAa,CAAC;IAClC,IAAI,EAAE,MAAM,CAAC,IAAI;IACjB,SAAS,EAAE,MAAM,CAAC,OAAO;IACzB,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC;IACzB,SAAS;IACT,KAAK,EAAE,WAAW;IAClB,iBAAiB,EAAE,MAAM,CAAC,oBAAoB,CAAC;IAC/C,GAAG,CAAC,MAAM,CAAC,MAAM,KAAK,SAAS,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC;CAC9D,CAAC,CAAC;AAEH,MAAM,QAAQ,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;AACnE,IAAI,QAAQ,CAAC,MAAM,EAAE,CAAC;IACpB,OAAO,CAAC,KAAK,CACX,SAAS,SAAS,GAAG,GAAG,MAAM,QAAQ,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,GAAG,MAAM,CAAC,IAAI,KAAK,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,gBAAgB,MAAM,CAAC,OAAO,GAAG,CAC/I,CAAC;IACF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC"}
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=reef-serve.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reef-serve.d.ts","sourceRoot":"","sources":["../../src/bin/reef-serve.ts"],"names":[],"mappings":""}
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { serve } from "../serve.js";
4
+ /**
5
+ * reef-serve [--root out] [--port 3140]
6
+ *
7
+ * Serves a static export the way Amplify Hosting does, for screen tests and Lighthouse runs. PORT is read when
8
+ * --port is not given, so a Playwright webServer command can pass either.
9
+ */
10
+ const { values } = parseArgs({
11
+ options: {
12
+ root: { type: "string", default: "out" },
13
+ port: { type: "string", default: process.env.PORT ?? "3140" },
14
+ help: { type: "boolean", short: "h" },
15
+ },
16
+ });
17
+ if (values.help) {
18
+ console.log("reef-serve [--root out] [--port 3140]");
19
+ process.exit(0);
20
+ }
21
+ const { url } = await serve({ root: values.root, port: Number(values.port) });
22
+ console.log(`Serving ${values.root} at ${url}`);
23
+ //# sourceMappingURL=reef-serve.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reef-serve.js","sourceRoot":"","sources":["../../src/bin/reef-serve.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAEtC,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAEpC;;;;;GAKG;AACH,MAAM,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;IAC3B,OAAO,EAAE;QACP,IAAI,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE;QACxC,IAAI,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,MAAM,EAAE;QAC7D,IAAI,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,GAAG,EAAE;KACtC;CACF,CAAC,CAAC;AAEH,IAAI,MAAM,CAAC,IAAI,EAAE,CAAC;IAChB,OAAO,CAAC,GAAG,CAAC,uCAAuC,CAAC,CAAC;IACrD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AAED,MAAM,EAAE,GAAG,EAAE,GAAG,MAAM,KAAK,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;AAC9E,OAAO,CAAC,GAAG,CAAC,WAAW,MAAM,CAAC,IAAI,OAAO,GAAG,EAAE,CAAC,CAAC"}
@@ -0,0 +1,3 @@
1
+ export { type Category, categories, type LighthouseOptions, type PageResult, pagesFromSitemap, reportBase, runLighthouse, } from "./lighthouse.ts";
2
+ export { createStaticServer, encodingFor, type ServeOptions, serve } from "./serve.ts";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,QAAQ,EACb,UAAU,EACV,KAAK,iBAAiB,EACtB,KAAK,UAAU,EACf,gBAAgB,EAChB,UAAU,EACV,aAAa,GACd,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,kBAAkB,EAAE,WAAW,EAAE,KAAK,YAAY,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { categories, pagesFromSitemap, reportBase, runLighthouse, } from "./lighthouse.js";
2
+ export { createStaticServer, encodingFor, serve } from "./serve.js";
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,UAAU,EAGV,gBAAgB,EAChB,UAAU,EACV,aAAa,GACd,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,kBAAkB,EAAE,WAAW,EAAqB,KAAK,EAAE,MAAM,YAAY,CAAC"}
@@ -0,0 +1,38 @@
1
+ export declare const categories: readonly ["performance", "accessibility", "best-practices", "seo"];
2
+ export type Category = (typeof categories)[number];
3
+ export type LighthouseOptions = {
4
+ /** The static export; `out` by default. */
5
+ root?: string;
6
+ /** Where a failing page's report is left; `lighthouse` by default. */
7
+ reportDir?: string;
8
+ /** The port the export is served on for the run. */
9
+ port?: number;
10
+ /** The least score, 0 to 1, every category must reach on every page; 0.9 by default. */
11
+ threshold?: number;
12
+ /** The pages to audit; every page in the export's sitemap when none are given. */
13
+ paths?: readonly string[];
14
+ /** The Lighthouse release `pnpm dlx` runs. */
15
+ lighthouseVersion?: string;
16
+ /** The Chrome binary; Playwright's Chromium when it is installed and nothing is given. */
17
+ chrome?: string;
18
+ /** Where progress is written. */
19
+ log?: (line: string) => void;
20
+ };
21
+ export type PageResult = {
22
+ path: string;
23
+ /** Each category's score, 0 to 1. */
24
+ scores: Record<Category, number>;
25
+ /** The categories under the threshold. */
26
+ low: Category[];
27
+ };
28
+ /** Every page's path from the export's sitemap, whatever origin the sitemap names. */
29
+ export declare const pagesFromSitemap: (root: string) => Promise<string[]>;
30
+ /** The report file a page's audit is written to: `/` is `home`, `/docs/security/` is `docs-security`. */
31
+ export declare const reportBase: (reportDir: string, pagePath: string) => string;
32
+ /**
33
+ * The merge gate: Lighthouse on every page, each category at the threshold or above, with Lighthouse's default
34
+ * mobile emulation and throttling, against the export served as the host serves it. A page under the threshold
35
+ * keeps its report; a passing page's is removed.
36
+ */
37
+ export declare const runLighthouse: (options?: LighthouseOptions) => Promise<PageResult[]>;
38
+ //# sourceMappingURL=lighthouse.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lighthouse.d.ts","sourceRoot":"","sources":["../src/lighthouse.ts"],"names":[],"mappings":"AAMA,eAAO,MAAM,UAAU,oEAAqE,CAAC;AAC7F,MAAM,MAAM,QAAQ,GAAG,CAAC,OAAO,UAAU,CAAC,CAAC,MAAM,CAAC,CAAC;AAEnD,MAAM,MAAM,iBAAiB,GAAG;IAC9B,2CAA2C;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oDAAoD;IACpD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wFAAwF;IACxF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kFAAkF;IAClF,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,8CAA8C;IAC9C,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,0FAA0F;IAC1F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iCAAiC;IACjC,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAC9B,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,qCAAqC;IACrC,MAAM,EAAE,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IACjC,0CAA0C;IAC1C,GAAG,EAAE,QAAQ,EAAE,CAAC;CACjB,CAAC;AAIF,sFAAsF;AACtF,eAAO,MAAM,gBAAgB,GAAU,MAAM,MAAM,KAAG,OAAO,CAAC,MAAM,EAAE,CAGrE,CAAC;AAEF,yGAAyG;AACzG,eAAO,MAAM,UAAU,GAAI,WAAW,MAAM,EAAE,UAAU,MAAM,WACyB,CAAC;AA0CxF;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAU,UAAS,iBAAsB,KAAG,OAAO,CAAC,UAAU,EAAE,CAiCzF,CAAC"}
@@ -0,0 +1,93 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { spawn } from "node:child_process";
10
+ import { mkdir, readFile, rm } from "node:fs/promises";
11
+ import path from "node:path";
12
+ import { serve } from "./serve.js";
13
+ export const categories = ["performance", "accessibility", "best-practices", "seo"];
14
+ /** Every page's path from the export's sitemap, whatever origin the sitemap names. */
15
+ export const pagesFromSitemap = async (root) => {
16
+ const sitemap = await readFile(path.join(root, "sitemap.xml"), "utf8");
17
+ return [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => new URL(match[1] ?? "").pathname);
18
+ };
19
+ /** The report file a page's audit is written to: `/` is `home`, `/docs/security/` is `docs-security`. */
20
+ export const reportBase = (reportDir, pagePath) => path.join(reportDir, pagePath.replace(/^\/|\/$/g, "").replaceAll("/", "-") || "home");
21
+ /**
22
+ * Playwright's Chromium, when the site has Playwright for its screen tests. Resolved at run time rather than
23
+ * imported, so the tool has no dependency on it.
24
+ */
25
+ const playwrightChromium = async () => {
26
+ const specifier = "@playwright/test";
27
+ try {
28
+ const playwright = (await import(__rewriteRelativeImportExtension(specifier)));
29
+ return playwright.chromium.executablePath();
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
34
+ };
35
+ /**
36
+ * One Lighthouse run; writes <base>.report.json and <base>.report.html. Lighthouse is not a dependency: it bundles
37
+ * axe-core (MPL-2.0), which is outside the family's licence allowlist, so its command line runs through `pnpm dlx`,
38
+ * pinned.
39
+ */
40
+ const audit = (url, base, version, chrome) => new Promise((resolve, reject) => {
41
+ const run = spawn("pnpm", [
42
+ "dlx",
43
+ `lighthouse@${version}`,
44
+ url,
45
+ `--only-categories=${categories.join(",")}`,
46
+ "--output=json",
47
+ "--output=html",
48
+ `--output-path=${base}`,
49
+ "--chrome-flags=--headless=new --no-sandbox",
50
+ "--quiet",
51
+ ], { stdio: ["ignore", "ignore", "inherit"], env: { ...process.env, ...(chrome && { CHROME_PATH: chrome }) } });
52
+ run.on("error", reject);
53
+ run.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`Lighthouse exited with ${code} on ${url}`))));
54
+ });
55
+ /**
56
+ * The merge gate: Lighthouse on every page, each category at the threshold or above, with Lighthouse's default
57
+ * mobile emulation and throttling, against the export served as the host serves it. A page under the threshold
58
+ * keeps its report; a passing page's is removed.
59
+ */
60
+ export const runLighthouse = async (options = {}) => {
61
+ const root = options.root ?? "out";
62
+ const reportDir = options.reportDir ?? "lighthouse";
63
+ const threshold = options.threshold ?? 0.9;
64
+ const version = options.lighthouseVersion ?? "13.5.0";
65
+ const log = options.log ?? console.log;
66
+ const paths = options.paths?.length ? options.paths : await pagesFromSitemap(root);
67
+ const chrome = options.chrome ?? (await playwrightChromium());
68
+ const { server, url: origin } = await serve({ root, port: options.port ?? 3160 });
69
+ const results = [];
70
+ try {
71
+ await mkdir(reportDir, { recursive: true });
72
+ for (const pagePath of paths) {
73
+ const base = reportBase(reportDir, pagePath);
74
+ await audit(`${origin}${pagePath}`, base, version, chrome);
75
+ const report = JSON.parse(await readFile(`${base}.report.json`, "utf8"));
76
+ const scores = Object.fromEntries(categories.map((category) => [category, report.categories[category]?.score ?? 0]));
77
+ const low = categories.filter((category) => scores[category] < threshold);
78
+ results.push({ path: pagePath, scores, low });
79
+ const shown = categories.map((category) => Math.round(scores[category] * 100)).join(" ");
80
+ log(`${low.length ? "FAIL" : "ok "} ${pagePath.padEnd(28)} ${shown}`);
81
+ if (low.length === 0) {
82
+ await rm(`${base}.report.json`);
83
+ await rm(`${base}.report.html`);
84
+ }
85
+ }
86
+ }
87
+ finally {
88
+ server.close();
89
+ }
90
+ log(`\n${categories.join(", ")}; ${paths.length} pages, threshold ${threshold * 100}`);
91
+ return results;
92
+ };
93
+ //# sourceMappingURL=lighthouse.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lighthouse.js","sourceRoot":"","sources":["../src/lighthouse.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,22 @@
1
+ import { type Server } from "node:http";
2
+ /** Brotli or gzip for text, as the browser accepts; images and fonts are already compressed. */
3
+ export declare const encodingFor: (type: string, accepts: string) => "br" | "gzip" | undefined;
4
+ /**
5
+ * A server for a static export the way Amplify Hosting serves one, for screen tests and Lighthouse runs: a directory
6
+ * answers with its index.html, a directory path without its slash redirects to it, anything else is the 404 page
7
+ * with status 404, and text is compressed (Brotli or gzip) as the CDN compresses it. Not started: `listen` it, or
8
+ * call `serve`.
9
+ */
10
+ export declare const createStaticServer: (root: string) => Server;
11
+ export type ServeOptions = {
12
+ /** The static export; `out` by default, Next's. */
13
+ root?: string;
14
+ /** 0 takes a free port, which `url` then names. */
15
+ port?: number;
16
+ };
17
+ /** The server, listening. */
18
+ export declare const serve: ({ root, port, }?: ServeOptions) => Promise<{
19
+ server: Server;
20
+ url: string;
21
+ }>;
22
+ //# sourceMappingURL=serve.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../src/serve.ts"],"names":[],"mappings":"AAEA,OAAO,EAAgB,KAAK,MAAM,EAAE,MAAM,WAAW,CAAC;AAoBtD,gGAAgG;AAChG,eAAO,MAAM,WAAW,GAAI,MAAM,MAAM,EAAE,SAAS,MAAM,KAAG,IAAI,GAAG,MAAM,GAAG,SAK3E,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,KAAG,MAiCjD,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IACzB,mDAAmD;IACnD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,mDAAmD;IACnD,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,6BAA6B;AAC7B,eAAO,MAAM,KAAK,GAAU,kBAGzB,YAAiB,KAAG,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAS7D,CAAC"}
package/dist/serve.js ADDED
@@ -0,0 +1,81 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { stat } from "node:fs/promises";
3
+ import { createServer } from "node:http";
4
+ import path from "node:path";
5
+ import { createBrotliCompress, createGzip } from "node:zlib";
6
+ const types = {
7
+ ".html": "text/html; charset=utf-8",
8
+ ".js": "text/javascript; charset=utf-8",
9
+ ".css": "text/css; charset=utf-8",
10
+ ".json": "application/json",
11
+ ".txt": "text/plain; charset=utf-8",
12
+ ".xml": "application/xml",
13
+ ".png": "image/png",
14
+ ".svg": "image/svg+xml",
15
+ ".woff2": "font/woff2",
16
+ ".ico": "image/x-icon",
17
+ };
18
+ const isFile = async (file) => (await stat(file).catch(() => null))?.isFile() ?? false;
19
+ const isDirectory = async (file) => (await stat(file).catch(() => null))?.isDirectory() ?? false;
20
+ /** Brotli or gzip for text, as the browser accepts; images and fonts are already compressed. */
21
+ export const encodingFor = (type, accepts) => {
22
+ if (!/^(text\/|application\/(json|xml)|image\/svg)/.test(type)) {
23
+ return undefined;
24
+ }
25
+ return accepts.includes("br") ? "br" : accepts.includes("gzip") ? "gzip" : undefined;
26
+ };
27
+ /**
28
+ * A server for a static export the way Amplify Hosting serves one, for screen tests and Lighthouse runs: a directory
29
+ * answers with its index.html, a directory path without its slash redirects to it, anything else is the 404 page
30
+ * with status 404, and text is compressed (Brotli or gzip) as the CDN compresses it. Not started: `listen` it, or
31
+ * call `serve`.
32
+ */
33
+ export const createStaticServer = (root) => {
34
+ const base = path.resolve(root);
35
+ return createServer(async (request, response) => {
36
+ const url = new URL(request.url ?? "/", "http://localhost");
37
+ const file = path.join(base, decodeURIComponent(url.pathname));
38
+ if (file !== base && !file.startsWith(`${base}${path.sep}`)) {
39
+ response.writeHead(400).end();
40
+ return;
41
+ }
42
+ const send = (target, status = 200) => {
43
+ const type = types[path.extname(target)] ?? "application/octet-stream";
44
+ const encoding = encodingFor(type, String(request.headers["accept-encoding"] ?? ""));
45
+ response.writeHead(status, {
46
+ "Content-Type": type,
47
+ Vary: "Accept-Encoding",
48
+ ...(encoding && { "Content-Encoding": encoding }),
49
+ });
50
+ const body = createReadStream(target);
51
+ const compressor = encoding === "br" ? createBrotliCompress() : encoding === "gzip" ? createGzip() : undefined;
52
+ (compressor ? body.pipe(compressor) : body).pipe(response);
53
+ };
54
+ if (await isFile(file)) {
55
+ send(file);
56
+ }
57
+ else if (await isDirectory(file)) {
58
+ if (url.pathname.endsWith("/")) {
59
+ send(path.join(file, "index.html"));
60
+ }
61
+ else {
62
+ response.writeHead(301, { Location: `${url.pathname}/${url.search}` }).end();
63
+ }
64
+ }
65
+ else {
66
+ send(path.join(base, "404.html"), 404);
67
+ }
68
+ });
69
+ };
70
+ /** The server, listening. */
71
+ export const serve = async ({ root = "out", port = 3140, } = {}) => {
72
+ const server = createStaticServer(root);
73
+ await new Promise((resolve, reject) => {
74
+ server.once("error", reject);
75
+ server.listen(port, () => resolve());
76
+ });
77
+ const address = server.address();
78
+ const bound = typeof address === "object" && address ? address.port : port;
79
+ return { server, url: `http://localhost:${bound}` };
80
+ };
81
+ //# sourceMappingURL=serve.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve.js","sourceRoot":"","sources":["../src/serve.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@coralreefventures/site-tools",
3
+ "version": "0.1.0",
4
+ "description": "Command-line tools for a Coral Reef public site: serve the static export as the host does, and gate it with Lighthouse.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=24"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/Coral-Reef-Ventures/reef.git",
13
+ "directory": "packages/site-tools"
14
+ },
15
+ "homepage": "https://coralreefventures.com/",
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "keywords": [
20
+ "static-site",
21
+ "lighthouse",
22
+ "coral-reef"
23
+ ],
24
+ "files": [
25
+ "dist",
26
+ "src",
27
+ "README.md"
28
+ ],
29
+ "bin": {
30
+ "reef-serve": "./dist/bin/reef-serve.js",
31
+ "reef-lighthouse": "./dist/bin/reef-lighthouse.js"
32
+ },
33
+ "exports": {
34
+ ".": {
35
+ "types": "./dist/index.d.ts",
36
+ "default": "./dist/index.js"
37
+ }
38
+ },
39
+ "peerDependencies": {
40
+ "@playwright/test": "1.63.0"
41
+ },
42
+ "peerDependenciesMeta": {
43
+ "@playwright/test": {
44
+ "optional": true
45
+ }
46
+ },
47
+ "scripts": {
48
+ "build": "rm -rf dist && tsc -p tsconfig.build.json"
49
+ }
50
+ }
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+
4
+ import { runLighthouse } from "../lighthouse.ts";
5
+
6
+ /**
7
+ * reef-lighthouse [--root out] [--reports lighthouse] [--port 3160] [--threshold 90] [--chrome <path>] [path ...]
8
+ *
9
+ * Lighthouse on every page in the export's sitemap, or the paths given, each category at the threshold or above.
10
+ * Exits 1 with the failing pages named when one is under, and leaves their reports in --reports.
11
+ */
12
+ const { values, positionals } = parseArgs({
13
+ allowPositionals: true,
14
+ options: {
15
+ root: { type: "string", default: "out" },
16
+ reports: { type: "string", default: "lighthouse" },
17
+ port: { type: "string", default: "3160" },
18
+ threshold: { type: "string", default: "90" },
19
+ chrome: { type: "string" },
20
+ "lighthouse-version": { type: "string", default: "13.5.0" },
21
+ help: { type: "boolean", short: "h" },
22
+ },
23
+ });
24
+
25
+ if (values.help) {
26
+ console.log(
27
+ "reef-lighthouse [--root out] [--reports lighthouse] [--port 3160] [--threshold 90] [--chrome <path>] [path ...]",
28
+ );
29
+ process.exit(0);
30
+ }
31
+
32
+ const threshold = Number(values.threshold) / 100;
33
+ const results = await runLighthouse({
34
+ root: values.root,
35
+ reportDir: values.reports,
36
+ port: Number(values.port),
37
+ threshold,
38
+ paths: positionals,
39
+ lighthouseVersion: values["lighthouse-version"],
40
+ ...(values.chrome !== undefined && { chrome: values.chrome }),
41
+ });
42
+
43
+ const failures = results.filter((result) => result.low.length > 0);
44
+ if (failures.length) {
45
+ console.error(
46
+ `Under ${threshold * 100}:\n${failures.map((result) => `${result.path}: ${result.low.join(", ")}`).join("\n")}\nReports in ${values.reports}/`,
47
+ );
48
+ process.exit(1);
49
+ }
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+
4
+ import { serve } from "../serve.ts";
5
+
6
+ /**
7
+ * reef-serve [--root out] [--port 3140]
8
+ *
9
+ * Serves a static export the way Amplify Hosting does, for screen tests and Lighthouse runs. PORT is read when
10
+ * --port is not given, so a Playwright webServer command can pass either.
11
+ */
12
+ const { values } = parseArgs({
13
+ options: {
14
+ root: { type: "string", default: "out" },
15
+ port: { type: "string", default: process.env.PORT ?? "3140" },
16
+ help: { type: "boolean", short: "h" },
17
+ },
18
+ });
19
+
20
+ if (values.help) {
21
+ console.log("reef-serve [--root out] [--port 3140]");
22
+ process.exit(0);
23
+ }
24
+
25
+ const { url } = await serve({ root: values.root, port: Number(values.port) });
26
+ console.log(`Serving ${values.root} at ${url}`);
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export {
2
+ type Category,
3
+ categories,
4
+ type LighthouseOptions,
5
+ type PageResult,
6
+ pagesFromSitemap,
7
+ reportBase,
8
+ runLighthouse,
9
+ } from "./lighthouse.ts";
10
+ export { createStaticServer, encodingFor, type ServeOptions, serve } from "./serve.ts";
@@ -0,0 +1,127 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdir, readFile, rm } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { serve } from "./serve.ts";
6
+
7
+ export const categories = ["performance", "accessibility", "best-practices", "seo"] as const;
8
+ export type Category = (typeof categories)[number];
9
+
10
+ export type LighthouseOptions = {
11
+ /** The static export; `out` by default. */
12
+ root?: string;
13
+ /** Where a failing page's report is left; `lighthouse` by default. */
14
+ reportDir?: string;
15
+ /** The port the export is served on for the run. */
16
+ port?: number;
17
+ /** The least score, 0 to 1, every category must reach on every page; 0.9 by default. */
18
+ threshold?: number;
19
+ /** The pages to audit; every page in the export's sitemap when none are given. */
20
+ paths?: readonly string[];
21
+ /** The Lighthouse release `pnpm dlx` runs. */
22
+ lighthouseVersion?: string;
23
+ /** The Chrome binary; Playwright's Chromium when it is installed and nothing is given. */
24
+ chrome?: string;
25
+ /** Where progress is written. */
26
+ log?: (line: string) => void;
27
+ };
28
+
29
+ export type PageResult = {
30
+ path: string;
31
+ /** Each category's score, 0 to 1. */
32
+ scores: Record<Category, number>;
33
+ /** The categories under the threshold. */
34
+ low: Category[];
35
+ };
36
+
37
+ type Report = { categories: Record<string, { score: number | null } | undefined> };
38
+
39
+ /** Every page's path from the export's sitemap, whatever origin the sitemap names. */
40
+ export const pagesFromSitemap = async (root: string): Promise<string[]> => {
41
+ const sitemap = await readFile(path.join(root, "sitemap.xml"), "utf8");
42
+ return [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => new URL(match[1] ?? "").pathname);
43
+ };
44
+
45
+ /** The report file a page's audit is written to: `/` is `home`, `/docs/security/` is `docs-security`. */
46
+ export const reportBase = (reportDir: string, pagePath: string) =>
47
+ path.join(reportDir, pagePath.replace(/^\/|\/$/g, "").replaceAll("/", "-") || "home");
48
+
49
+ /**
50
+ * Playwright's Chromium, when the site has Playwright for its screen tests. Resolved at run time rather than
51
+ * imported, so the tool has no dependency on it.
52
+ */
53
+ const playwrightChromium = async (): Promise<string | undefined> => {
54
+ const specifier = "@playwright/test";
55
+ try {
56
+ const playwright = (await import(specifier)) as { chromium: { executablePath: () => string } };
57
+ return playwright.chromium.executablePath();
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ };
62
+
63
+ /**
64
+ * One Lighthouse run; writes <base>.report.json and <base>.report.html. Lighthouse is not a dependency: it bundles
65
+ * axe-core (MPL-2.0), which is outside the family's licence allowlist, so its command line runs through `pnpm dlx`,
66
+ * pinned.
67
+ */
68
+ const audit = (url: string, base: string, version: string, chrome: string | undefined) =>
69
+ new Promise<void>((resolve, reject) => {
70
+ const run = spawn(
71
+ "pnpm",
72
+ [
73
+ "dlx",
74
+ `lighthouse@${version}`,
75
+ url,
76
+ `--only-categories=${categories.join(",")}`,
77
+ "--output=json",
78
+ "--output=html",
79
+ `--output-path=${base}`,
80
+ "--chrome-flags=--headless=new --no-sandbox",
81
+ "--quiet",
82
+ ],
83
+ { stdio: ["ignore", "ignore", "inherit"], env: { ...process.env, ...(chrome && { CHROME_PATH: chrome }) } },
84
+ );
85
+ run.on("error", reject);
86
+ run.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`Lighthouse exited with ${code} on ${url}`))));
87
+ });
88
+
89
+ /**
90
+ * The merge gate: Lighthouse on every page, each category at the threshold or above, with Lighthouse's default
91
+ * mobile emulation and throttling, against the export served as the host serves it. A page under the threshold
92
+ * keeps its report; a passing page's is removed.
93
+ */
94
+ export const runLighthouse = async (options: LighthouseOptions = {}): Promise<PageResult[]> => {
95
+ const root = options.root ?? "out";
96
+ const reportDir = options.reportDir ?? "lighthouse";
97
+ const threshold = options.threshold ?? 0.9;
98
+ const version = options.lighthouseVersion ?? "13.5.0";
99
+ const log = options.log ?? console.log;
100
+ const paths = options.paths?.length ? options.paths : await pagesFromSitemap(root);
101
+ const chrome = options.chrome ?? (await playwrightChromium());
102
+ const { server, url: origin } = await serve({ root, port: options.port ?? 3160 });
103
+ const results: PageResult[] = [];
104
+ try {
105
+ await mkdir(reportDir, { recursive: true });
106
+ for (const pagePath of paths) {
107
+ const base = reportBase(reportDir, pagePath);
108
+ await audit(`${origin}${pagePath}`, base, version, chrome);
109
+ const report = JSON.parse(await readFile(`${base}.report.json`, "utf8")) as Report;
110
+ const scores = Object.fromEntries(
111
+ categories.map((category) => [category, report.categories[category]?.score ?? 0]),
112
+ ) as Record<Category, number>;
113
+ const low = categories.filter((category) => scores[category] < threshold);
114
+ results.push({ path: pagePath, scores, low });
115
+ const shown = categories.map((category) => Math.round(scores[category] * 100)).join(" ");
116
+ log(`${low.length ? "FAIL" : "ok "} ${pagePath.padEnd(28)} ${shown}`);
117
+ if (low.length === 0) {
118
+ await rm(`${base}.report.json`);
119
+ await rm(`${base}.report.html`);
120
+ }
121
+ }
122
+ } finally {
123
+ server.close();
124
+ }
125
+ log(`\n${categories.join(", ")}; ${paths.length} pages, threshold ${threshold * 100}`);
126
+ return results;
127
+ };
package/src/serve.ts ADDED
@@ -0,0 +1,92 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { stat } from "node:fs/promises";
3
+ import { createServer, type Server } from "node:http";
4
+ import path from "node:path";
5
+ import { createBrotliCompress, createGzip } from "node:zlib";
6
+
7
+ const types: Record<string, string> = {
8
+ ".html": "text/html; charset=utf-8",
9
+ ".js": "text/javascript; charset=utf-8",
10
+ ".css": "text/css; charset=utf-8",
11
+ ".json": "application/json",
12
+ ".txt": "text/plain; charset=utf-8",
13
+ ".xml": "application/xml",
14
+ ".png": "image/png",
15
+ ".svg": "image/svg+xml",
16
+ ".woff2": "font/woff2",
17
+ ".ico": "image/x-icon",
18
+ };
19
+
20
+ const isFile = async (file: string) => (await stat(file).catch(() => null))?.isFile() ?? false;
21
+ const isDirectory = async (file: string) => (await stat(file).catch(() => null))?.isDirectory() ?? false;
22
+
23
+ /** Brotli or gzip for text, as the browser accepts; images and fonts are already compressed. */
24
+ export const encodingFor = (type: string, accepts: string): "br" | "gzip" | undefined => {
25
+ if (!/^(text\/|application\/(json|xml)|image\/svg)/.test(type)) {
26
+ return undefined;
27
+ }
28
+ return accepts.includes("br") ? "br" : accepts.includes("gzip") ? "gzip" : undefined;
29
+ };
30
+
31
+ /**
32
+ * A server for a static export the way Amplify Hosting serves one, for screen tests and Lighthouse runs: a directory
33
+ * answers with its index.html, a directory path without its slash redirects to it, anything else is the 404 page
34
+ * with status 404, and text is compressed (Brotli or gzip) as the CDN compresses it. Not started: `listen` it, or
35
+ * call `serve`.
36
+ */
37
+ export const createStaticServer = (root: string): Server => {
38
+ const base = path.resolve(root);
39
+ return createServer(async (request, response) => {
40
+ const url = new URL(request.url ?? "/", "http://localhost");
41
+ const file = path.join(base, decodeURIComponent(url.pathname));
42
+ if (file !== base && !file.startsWith(`${base}${path.sep}`)) {
43
+ response.writeHead(400).end();
44
+ return;
45
+ }
46
+ const send = (target: string, status = 200) => {
47
+ const type = types[path.extname(target)] ?? "application/octet-stream";
48
+ const encoding = encodingFor(type, String(request.headers["accept-encoding"] ?? ""));
49
+ response.writeHead(status, {
50
+ "Content-Type": type,
51
+ Vary: "Accept-Encoding",
52
+ ...(encoding && { "Content-Encoding": encoding }),
53
+ });
54
+ const body = createReadStream(target);
55
+ const compressor = encoding === "br" ? createBrotliCompress() : encoding === "gzip" ? createGzip() : undefined;
56
+ (compressor ? body.pipe(compressor) : body).pipe(response);
57
+ };
58
+ if (await isFile(file)) {
59
+ send(file);
60
+ } else if (await isDirectory(file)) {
61
+ if (url.pathname.endsWith("/")) {
62
+ send(path.join(file, "index.html"));
63
+ } else {
64
+ response.writeHead(301, { Location: `${url.pathname}/${url.search}` }).end();
65
+ }
66
+ } else {
67
+ send(path.join(base, "404.html"), 404);
68
+ }
69
+ });
70
+ };
71
+
72
+ export type ServeOptions = {
73
+ /** The static export; `out` by default, Next's. */
74
+ root?: string;
75
+ /** 0 takes a free port, which `url` then names. */
76
+ port?: number;
77
+ };
78
+
79
+ /** The server, listening. */
80
+ export const serve = async ({
81
+ root = "out",
82
+ port = 3140,
83
+ }: ServeOptions = {}): Promise<{ server: Server; url: string }> => {
84
+ const server = createStaticServer(root);
85
+ await new Promise<void>((resolve, reject) => {
86
+ server.once("error", reject);
87
+ server.listen(port, () => resolve());
88
+ });
89
+ const address = server.address();
90
+ const bound = typeof address === "object" && address ? address.port : port;
91
+ return { server, url: `http://localhost:${bound}` };
92
+ };