@coralreefventures/site-tools 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -7
- package/dist/bin/reef-door-bundle.js +19 -1
- package/dist/bin/reef-door-bundle.js.map +1 -1
- package/dist/bin/reef-lighthouse.js +8 -4
- package/dist/bin/reef-lighthouse.js.map +1 -1
- package/dist/door/config.d.ts +5 -1
- package/dist/door/config.d.ts.map +1 -1
- package/dist/door/config.js +3 -0
- package/dist/door/config.js.map +1 -1
- package/dist/door/package.d.ts +16 -0
- package/dist/door/package.d.ts.map +1 -1
- package/dist/door/package.js +47 -2
- package/dist/door/package.js.map +1 -1
- package/dist/door/page.d.ts +70 -0
- package/dist/door/page.d.ts.map +1 -0
- package/dist/door/page.js +171 -0
- package/dist/door/page.js.map +1 -0
- package/dist/door/server.d.ts +24 -3
- package/dist/door/server.d.ts.map +1 -1
- package/dist/door/server.js +85 -13
- package/dist/door/server.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/lighthouse.d.ts +16 -1
- package/dist/lighthouse.d.ts.map +1 -1
- package/dist/lighthouse.js +39 -8
- package/dist/lighthouse.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/reef-door-bundle.ts +19 -1
- package/src/bin/reef-lighthouse.ts +8 -4
- package/src/door/config.ts +6 -1
- package/src/door/package.ts +57 -2
- package/src/door/page.ts +218 -0
- package/src/door/server.ts +92 -12
- package/src/index.ts +10 -0
- package/src/lighthouse.ts +69 -11
package/dist/lighthouse.js
CHANGED
|
@@ -11,6 +11,22 @@ import { mkdir, readFile, rm } from "node:fs/promises";
|
|
|
11
11
|
import path from "node:path";
|
|
12
12
|
import { serve } from "./serve.js";
|
|
13
13
|
export const categories = ["performance", "accessibility", "best-practices", "seo"];
|
|
14
|
+
/** The median of the scores, which three runs with one low outlier put at the typical run. */
|
|
15
|
+
export const median = (values) => {
|
|
16
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
17
|
+
const middle = Math.floor(sorted.length / 2);
|
|
18
|
+
if (sorted.length === 0) {
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
return sorted.length % 2 === 1
|
|
22
|
+
? sorted[middle]
|
|
23
|
+
: (sorted[middle - 1] + sorted[middle]) / 2;
|
|
24
|
+
};
|
|
25
|
+
/** The categories under the threshold, by the median of each one's scores over the runs so far. */
|
|
26
|
+
export const judge = (runs, threshold) => {
|
|
27
|
+
const scores = Object.fromEntries(categories.map((category) => [category, median(runs.map((run) => run[category]))]));
|
|
28
|
+
return { scores, low: categories.filter((category) => scores[category] < threshold) };
|
|
29
|
+
};
|
|
14
30
|
/** Every page's path from the export's sitemap, whatever origin the sitemap names. */
|
|
15
31
|
export const pagesFromSitemap = async (root) => {
|
|
16
32
|
const sitemap = await readFile(path.join(root, "sitemap.xml"), "utf8");
|
|
@@ -52,6 +68,25 @@ const audit = (url, base, version, chrome) => new Promise((resolve, reject) => {
|
|
|
52
68
|
run.on("error", reject);
|
|
53
69
|
run.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`Lighthouse exited with ${code} on ${url}`))));
|
|
54
70
|
});
|
|
71
|
+
const shown = (scores) => categories.map((category) => Math.round(scores[category] * 100)).join(" ");
|
|
72
|
+
/** One page's verdict: measured again while under the threshold, up to `retries` more times; the median decides. */
|
|
73
|
+
const measurePage = async (pagePath, base, measure) => {
|
|
74
|
+
const runs = [];
|
|
75
|
+
let verdict = judge(runs, measure.threshold);
|
|
76
|
+
while (runs.length === 0 || (verdict.low.length > 0 && runs.length <= measure.retries)) {
|
|
77
|
+
await audit(`${measure.origin}${pagePath}`, base, measure.version, measure.chrome);
|
|
78
|
+
const report = JSON.parse(await readFile(`${base}.report.json`, "utf8"));
|
|
79
|
+
const scores = Object.fromEntries(categories.map((category) => [category, report.categories[category]?.score ?? 0]));
|
|
80
|
+
runs.push(scores);
|
|
81
|
+
verdict = judge(runs, measure.threshold);
|
|
82
|
+
if (verdict.low.length > 0 && runs.length <= measure.retries) {
|
|
83
|
+
measure.log(`again ${pagePath.padEnd(28)} ${shown(scores)} (run ${runs.length} of ${measure.retries + 1})`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const over = runs.length > 1 ? ` (median of ${runs.length} runs)` : "";
|
|
87
|
+
measure.log(`${verdict.low.length ? "FAIL" : "ok "} ${pagePath.padEnd(28)} ${shown(verdict.scores)}${over}`);
|
|
88
|
+
return { path: pagePath, scores: verdict.scores, low: verdict.low, runs: runs.length };
|
|
89
|
+
};
|
|
55
90
|
/**
|
|
56
91
|
* The merge gate: Lighthouse on every page, each category at the threshold or above, with Lighthouse's default
|
|
57
92
|
* mobile emulation and throttling, against the export served as the host serves it. A page under the threshold
|
|
@@ -61,6 +96,7 @@ export const runLighthouse = async (options = {}) => {
|
|
|
61
96
|
const root = options.root ?? "out";
|
|
62
97
|
const reportDir = options.reportDir ?? "lighthouse";
|
|
63
98
|
const threshold = options.threshold ?? 0.9;
|
|
99
|
+
const retries = Math.max(0, options.retries ?? 2);
|
|
64
100
|
const version = options.lighthouseVersion ?? "13.5.0";
|
|
65
101
|
const log = options.log ?? console.log;
|
|
66
102
|
const paths = options.paths?.length ? options.paths : await pagesFromSitemap(root);
|
|
@@ -71,14 +107,9 @@ export const runLighthouse = async (options = {}) => {
|
|
|
71
107
|
await mkdir(reportDir, { recursive: true });
|
|
72
108
|
for (const pagePath of paths) {
|
|
73
109
|
const base = reportBase(reportDir, pagePath);
|
|
74
|
-
await
|
|
75
|
-
|
|
76
|
-
|
|
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) {
|
|
110
|
+
const result = await measurePage(pagePath, base, { origin, version, chrome, threshold, retries, log });
|
|
111
|
+
results.push(result);
|
|
112
|
+
if (result.low.length === 0) {
|
|
82
113
|
await rm(`${base}.report.json`);
|
|
83
114
|
await rm(`${base}.report.html`);
|
|
84
115
|
}
|
package/dist/lighthouse.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lighthouse.js","sourceRoot":"","sources":["../src/lighthouse.ts"],"names":[],"mappings":";;;;;;;;AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAC3C,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,kBAAkB,CAAC;AACvD,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAEnC,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,aAAa,EAAE,eAAe,EAAE,gBAAgB,EAAE,KAAK,CAAU,CAAC;
|
|
1
|
+
{"version":3,"file":"lighthouse.js","sourceRoot":"","sources":["../src/lighthouse.ts"],"names":[],"mappings":";;;;;;;;AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAC3C,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,kBAAkB,CAAC;AACvD,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAEnC,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,aAAa,EAAE,eAAe,EAAE,gBAAgB,EAAE,KAAK,CAAU,CAAC;AAsC7F,8FAA8F;AAC9F,MAAM,CAAC,MAAM,MAAM,GAAG,CAAC,MAAyB,EAAU,EAAE;IAC1D,MAAM,MAAM,GAAG,CAAC,GAAG,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,IAAI,GAAG,KAAK,CAAC,CAAC;IAC/D,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IAC7C,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACxB,OAAO,CAAC,CAAC;IACX,CAAC;IACD,OAAO,MAAM,CAAC,MAAM,GAAG,CAAC,KAAK,CAAC;QAC5B,CAAC,CAAE,MAAM,CAAC,MAAM,CAAY;QAC5B,CAAC,CAAC,CAAE,MAAM,CAAC,MAAM,GAAG,CAAC,CAAY,GAAI,MAAM,CAAC,MAAM,CAAY,CAAC,GAAG,CAAC,CAAC;AACxE,CAAC,CAAC;AAEF,mGAAmG;AACnG,MAAM,CAAC,MAAM,KAAK,GAAG,CACnB,IAAyC,EACzC,SAAiB,EACsC,EAAE;IACzD,MAAM,MAAM,GAAG,MAAM,CAAC,WAAW,CAC/B,UAAU,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC,QAAQ,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CACvD,CAAC;IAC9B,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,UAAU,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,GAAG,SAAS,CAAC,EAAE,CAAC;AACxF,CAAC,CAAC;AAIF,sFAAsF;AACtF,MAAM,CAAC,MAAM,gBAAgB,GAAG,KAAK,EAAE,IAAY,EAAqB,EAAE;IACxE,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,aAAa,CAAC,EAAE,MAAM,CAAC,CAAC;IACvE,OAAO,CAAC,GAAG,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC;AACxG,CAAC,CAAC;AAEF,yGAAyG;AACzG,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,SAAiB,EAAE,QAAgB,EAAE,EAAE,CAChE,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,QAAQ,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC,UAAU,CAAC,GAAG,EAAE,GAAG,CAAC,IAAI,MAAM,CAAC,CAAC;AAExF;;;GAGG;AACH,MAAM,kBAAkB,GAAG,KAAK,IAAiC,EAAE;IACjE,MAAM,SAAS,GAAG,kBAAkB,CAAC;IACrC,IAAI,CAAC;QACH,MAAM,UAAU,GAAG,CAAC,MAAM,MAAM,kCAAC,SAAS,EAAC,CAAmD,CAAC;QAC/F,OAAO,UAAU,CAAC,QAAQ,CAAC,cAAc,EAAE,CAAC;IAC9C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC,CAAC;AAEF;;;;GAIG;AACH,MAAM,KAAK,GAAG,CAAC,GAAW,EAAE,IAAY,EAAE,OAAe,EAAE,MAA0B,EAAE,EAAE,CACvF,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;IACpC,MAAM,GAAG,GAAG,KAAK,CACf,MAAM,EACN;QACE,KAAK;QACL,cAAc,OAAO,EAAE;QACvB,GAAG;QACH,qBAAqB,UAAU,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE;QAC3C,eAAe;QACf,eAAe;QACf,iBAAiB,IAAI,EAAE;QACvB,4CAA4C;QAC5C,SAAS;KACV,EACD,EAAE,KAAK,EAAE,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,CAAC,EAAE,GAAG,EAAE,EAAE,GAAG,OAAO,CAAC,GAAG,EAAE,GAAG,CAAC,MAAM,IAAI,EAAE,WAAW,EAAE,MAAM,EAAE,CAAC,EAAE,EAAE,CAC5G,CAAC;IACF,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IACxB,GAAG,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,0BAA0B,IAAI,OAAO,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;AACrH,CAAC,CAAC,CAAC;AAWL,MAAM,KAAK,GAAG,CAAC,MAAgC,EAAE,EAAE,CACjD,UAAU,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,QAAQ,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAE9E,oHAAoH;AACpH,MAAM,WAAW,GAAG,KAAK,EAAE,QAAgB,EAAE,IAAY,EAAE,OAAgB,EAAuB,EAAE;IAClG,MAAM,IAAI,GAA+B,EAAE,CAAC;IAC5C,IAAI,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,OAAO,CAAC,SAAS,CAAC,CAAC;IAC7C,OAAO,IAAI,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,IAAI,CAAC,MAAM,IAAI,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QACvF,MAAM,KAAK,CAAC,GAAG,OAAO,CAAC,MAAM,GAAG,QAAQ,EAAE,EAAE,IAAI,EAAE,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC;QACnF,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,QAAQ,CAAC,GAAG,IAAI,cAAc,EAAE,MAAM,CAAC,CAAW,CAAC;QACnF,MAAM,MAAM,GAAG,MAAM,CAAC,WAAW,CAC/B,UAAU,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC,QAAQ,EAAE,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,KAAK,IAAI,CAAC,CAAC,CAAC,CACtD,CAAC;QAC9B,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QAClB,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,OAAO,CAAC,SAAS,CAAC,CAAC;QACzC,IAAI,OAAO,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,IAAI,CAAC,MAAM,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC;YAC7D,OAAO,CAAC,GAAG,CAAC,SAAS,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,WAAW,IAAI,CAAC,MAAM,OAAO,OAAO,CAAC,OAAO,GAAG,CAAC,GAAG,CAAC,CAAC;QAChH,CAAC;IACH,CAAC;IACD,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,iBAAiB,IAAI,CAAC,MAAM,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;IACzE,OAAO,CAAC,GAAG,CAAC,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,IAAI,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,IAAI,EAAE,CAAC,CAAC;IAC9G,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,IAAI,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC;AACzF,CAAC,CAAC;AAEF;;;;GAIG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,KAAK,EAAE,UAA6B,EAAE,EAAyB,EAAE;IAC5F,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,KAAK,CAAC;IACnC,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,YAAY,CAAC;IACpD,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,GAAG,CAAC;IAC3C,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,CAAC,OAAO,IAAI,CAAC,CAAC,CAAC;IAClD,MAAM,OAAO,GAAG,OAAO,CAAC,iBAAiB,IAAI,QAAQ,CAAC;IACtD,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,OAAO,CAAC,GAAG,CAAC;IACvC,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,gBAAgB,CAAC,IAAI,CAAC,CAAC;IACnF,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC,MAAM,kBAAkB,EAAE,CAAC,CAAC;IAC9D,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,KAAK,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,IAAI,CAAC,EAAE,CAAC,CAAC;IAC/E,MAAM,OAAO,GAAiB,EAAE,CAAC;IACjC,IAAI,CAAC;QACH,MAAM,KAAK,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC5C,KAAK,MAAM,QAAQ,IAAI,KAAK,EAAE,CAAC;YAC7B,MAAM,IAAI,GAAG,UAAU,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;YAC7C,MAAM,MAAM,GAAG,MAAM,WAAW,CAAC,QAAQ,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC;YACvG,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YACrB,IAAI,MAAM,CAAC,GAAG,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;gBAC5B,MAAM,EAAE,CAAC,GAAG,IAAI,cAAc,CAAC,CAAC;gBAChC,MAAM,EAAE,CAAC,GAAG,IAAI,cAAc,CAAC,CAAC;YAClC,CAAC;QACH,CAAC;IACH,CAAC;YAAS,CAAC;QACT,MAAM,CAAC,KAAK,EAAE,CAAC;IACjB,CAAC;IACD,GAAG,CAAC,KAAK,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,KAAK,CAAC,MAAM,qBAAqB,SAAS,GAAG,GAAG,EAAE,CAAC,CAAC;IACvF,OAAO,OAAO,CAAC;AACjB,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coralreefventures/site-tools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Command-line tools for a Coral Reef public site: serve the static export as the host does, gate it with Lighthouse, and lock it behind the door.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -6,10 +6,16 @@ import { bundleDoor } from "../door/package.ts";
|
|
|
6
6
|
|
|
7
7
|
const usage = `reef-door-bundle --site <id> [--out out] [--dest .amplify-hosting] --door-url <url> --hosts <h1,h2>
|
|
8
8
|
--jwks-url <url> [--signout-chain id=host,id=host] [--host-header host|x-forwarded-host]
|
|
9
|
+
[--name <display name>] [--mark <file in out>] [--mark-dark <file in out>]
|
|
10
|
+
[--accent #rrggbb] [--accent-dark #rrggbb]
|
|
9
11
|
|
|
10
12
|
Locks a static export behind the door: writes .amplify-hosting with the export inside the gate's compute bundle and
|
|
11
13
|
one route, /* to Compute. --door-url, --hosts, --jwks-url, --signout-chain and --host-header fall back to
|
|
12
|
-
CRV_DOOR_URL, CRV_DOOR_HOSTS, CRV_DOOR_JWKS_URL, CRV_DOOR_SIGNOUT_CHAIN and CRV_DOOR_HOST_HEADER
|
|
14
|
+
CRV_DOOR_URL, CRV_DOOR_HOSTS, CRV_DOOR_JWKS_URL, CRV_DOOR_SIGNOUT_CHAIN and CRV_DOOR_HOST_HEADER.
|
|
15
|
+
|
|
16
|
+
A page load without a session gets a coming-soon page: --name (the site id, capitalized, by default), --mark and
|
|
17
|
+
--mark-dark (an .svg or .png inside the export, inlined; none by default), --accent and --accent-dark (the button's
|
|
18
|
+
color in each scheme, at least 3:1 on the page; neutral by default).`;
|
|
13
19
|
|
|
14
20
|
const { values } = parseArgs({
|
|
15
21
|
options: {
|
|
@@ -21,6 +27,11 @@ const { values } = parseArgs({
|
|
|
21
27
|
"jwks-url": { type: "string" },
|
|
22
28
|
"signout-chain": { type: "string" },
|
|
23
29
|
"host-header": { type: "string" },
|
|
30
|
+
name: { type: "string" },
|
|
31
|
+
mark: { type: "string" },
|
|
32
|
+
"mark-dark": { type: "string" },
|
|
33
|
+
accent: { type: "string" },
|
|
34
|
+
"accent-dark": { type: "string" },
|
|
24
35
|
help: { type: "boolean", short: "h" },
|
|
25
36
|
},
|
|
26
37
|
});
|
|
@@ -52,6 +63,13 @@ try {
|
|
|
52
63
|
jwksUrl,
|
|
53
64
|
signout: parseSignoutChain(values["signout-chain"] ?? env.CRV_DOOR_SIGNOUT_CHAIN),
|
|
54
65
|
hostHeader,
|
|
66
|
+
page: {
|
|
67
|
+
name: values.name,
|
|
68
|
+
mark: values.mark,
|
|
69
|
+
markDark: values["mark-dark"],
|
|
70
|
+
accent: values.accent,
|
|
71
|
+
accentDark: values["accent-dark"],
|
|
72
|
+
},
|
|
55
73
|
});
|
|
56
74
|
console.log(`reef-door-bundle: ${values.dest} written for ${values.site}, gate ${manifest.framework.version}`);
|
|
57
75
|
} catch (error) {
|
|
@@ -4,11 +4,13 @@ import { parseArgs } from "node:util";
|
|
|
4
4
|
import { runLighthouse } from "../lighthouse.ts";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* reef-lighthouse [--root out] [--reports lighthouse] [--port N] [--threshold 90] [--chrome <path>] [path ...]
|
|
7
|
+
* reef-lighthouse [--root out] [--reports lighthouse] [--port N] [--threshold 90] [--retries 2] [--chrome <path>] [path ...]
|
|
8
8
|
*
|
|
9
9
|
* Lighthouse on every page in the export's sitemap, or the paths given, each category at the threshold or above. The
|
|
10
|
-
* export is served on a free port unless --port pins one (Streamlane's run met EADDRINUSE on a fixed 3160).
|
|
11
|
-
*
|
|
10
|
+
* export is served on a free port unless --port pins one (Streamlane's run met EADDRINUSE on a fixed 3160). A page
|
|
11
|
+
* under the threshold is measured again, up to --retries more times, and the median of its runs decides, since a
|
|
12
|
+
* performance score swings a few points between runs on a shared runner. Exits 1 with the failing pages named when
|
|
13
|
+
* one is under, and leaves their reports in --reports.
|
|
12
14
|
*/
|
|
13
15
|
const { values, positionals } = parseArgs({
|
|
14
16
|
allowPositionals: true,
|
|
@@ -17,6 +19,7 @@ const { values, positionals } = parseArgs({
|
|
|
17
19
|
reports: { type: "string", default: "lighthouse" },
|
|
18
20
|
port: { type: "string" },
|
|
19
21
|
threshold: { type: "string", default: "90" },
|
|
22
|
+
retries: { type: "string", default: "2" },
|
|
20
23
|
chrome: { type: "string" },
|
|
21
24
|
"lighthouse-version": { type: "string", default: "13.5.0" },
|
|
22
25
|
help: { type: "boolean", short: "h" },
|
|
@@ -25,7 +28,7 @@ const { values, positionals } = parseArgs({
|
|
|
25
28
|
|
|
26
29
|
if (values.help) {
|
|
27
30
|
console.log(
|
|
28
|
-
"reef-lighthouse [--root out] [--reports lighthouse] [--port N] [--threshold 90] [--chrome <path>] [path ...]",
|
|
31
|
+
"reef-lighthouse [--root out] [--reports lighthouse] [--port N] [--threshold 90] [--retries 2] [--chrome <path>] [path ...]",
|
|
29
32
|
);
|
|
30
33
|
process.exit(0);
|
|
31
34
|
}
|
|
@@ -36,6 +39,7 @@ const results = await runLighthouse({
|
|
|
36
39
|
reportDir: values.reports,
|
|
37
40
|
...(values.port !== undefined && { port: Number(values.port) }),
|
|
38
41
|
threshold,
|
|
42
|
+
retries: Number(values.retries),
|
|
39
43
|
paths: positionals,
|
|
40
44
|
lighthouseVersion: values["lighthouse-version"],
|
|
41
45
|
...(values.chrome !== undefined && { chrome: values.chrome }),
|
package/src/door/config.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
|
|
3
|
+
import { type PageConfig, type PageConfigFile, parsePageConfig } from "./page.ts";
|
|
3
4
|
import { type Jwks, parseJwks } from "./verify.ts";
|
|
4
5
|
|
|
5
6
|
/** One stop on the sign-out walk: a site behind the door, by id, and the host its gate answers on. */
|
|
@@ -20,9 +21,11 @@ export type GateConfigFile = {
|
|
|
20
21
|
signout: SignoutStop[];
|
|
21
22
|
/** The door's public keys. */
|
|
22
23
|
jwks: { keys: unknown[] };
|
|
24
|
+
/** The coming-soon page a visitor without a session sees: the product's name, mark and colors. Optional (0.4.0). */
|
|
25
|
+
page?: PageConfigFile;
|
|
23
26
|
};
|
|
24
27
|
|
|
25
|
-
export type GateConfig = Omit<GateConfigFile, "jwks"> & { keys: Jwks; issuer: string };
|
|
28
|
+
export type GateConfig = Omit<GateConfigFile, "jwks" | "page"> & { keys: Jwks; issuer: string; page: PageConfig };
|
|
26
29
|
|
|
27
30
|
const siteId = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
28
31
|
const hostName = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*(:\d{1,5})?$/;
|
|
@@ -79,6 +82,7 @@ export const parseGateConfig = (value: unknown): GateConfig => {
|
|
|
79
82
|
}
|
|
80
83
|
const signout = checkSignout(file.signout ?? []);
|
|
81
84
|
const keys = parseJwks(file.jwks);
|
|
85
|
+
const page = parsePageConfig(file.page, file.site);
|
|
82
86
|
return {
|
|
83
87
|
version: 1,
|
|
84
88
|
site: file.site,
|
|
@@ -88,6 +92,7 @@ export const parseGateConfig = (value: unknown): GateConfig => {
|
|
|
88
92
|
hostHeader: file.hostHeader,
|
|
89
93
|
signout,
|
|
90
94
|
keys,
|
|
95
|
+
page,
|
|
91
96
|
};
|
|
92
97
|
};
|
|
93
98
|
|
package/src/door/package.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
1
|
+
import { cp, mkdir, readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
|
|
5
5
|
import { type GateConfigFile, parseGateConfig, type SignoutStop } from "./config.ts";
|
|
6
|
+
import { maxMarkBytes, type PageConfigFile, type PageImage } from "./page.ts";
|
|
6
7
|
import { parseJwks } from "./verify.ts";
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -10,7 +11,14 @@ import { parseJwks } from "./verify.ts";
|
|
|
10
11
|
* import Node's modules and each other and nothing else, so the bundle needs no node_modules (a test holds the list
|
|
11
12
|
* against the imports).
|
|
12
13
|
*/
|
|
13
|
-
export const gateFiles = [
|
|
14
|
+
export const gateFiles = [
|
|
15
|
+
"serve.js",
|
|
16
|
+
"door/server.js",
|
|
17
|
+
"door/config.js",
|
|
18
|
+
"door/next.js",
|
|
19
|
+
"door/page.js",
|
|
20
|
+
"door/verify.js",
|
|
21
|
+
];
|
|
14
22
|
|
|
15
23
|
/** Amplify takes a deployment of up to 220 MB; the gate refuses one over 200, which leaves room. */
|
|
16
24
|
export const maxBundleBytes = 200 * 1024 * 1024;
|
|
@@ -38,11 +46,57 @@ export type BundleDoorOptions = {
|
|
|
38
46
|
/** The sign-out walk, in order. */
|
|
39
47
|
signout?: SignoutStop[];
|
|
40
48
|
hostHeader?: GateConfigFile["hostHeader"];
|
|
49
|
+
/** The coming-soon page: the product's display name, its mark and dark mark as files in the export, its colors. */
|
|
50
|
+
page?: BundlePageOptions;
|
|
41
51
|
/** The compiled package root the gate's files are copied from; this package's own `dist` by default. */
|
|
42
52
|
gateRoot?: string;
|
|
43
53
|
maxBytes?: number;
|
|
44
54
|
};
|
|
45
55
|
|
|
56
|
+
/** What the bin's page flags name. The marks are paths inside the static export, which the bundle inlines. */
|
|
57
|
+
export type BundlePageOptions = {
|
|
58
|
+
name?: string | undefined;
|
|
59
|
+
mark?: string | undefined;
|
|
60
|
+
markDark?: string | undefined;
|
|
61
|
+
accent?: string | undefined;
|
|
62
|
+
accentDark?: string | undefined;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Reads a mark from the site's own export, for the coming-soon page to carry inline: an SVG or a PNG, inside the
|
|
69
|
+
* export (a symbolic link out of it is refused), and small. It is shown through `<img>`, where an SVG runs no script.
|
|
70
|
+
*/
|
|
71
|
+
export const readMark = async (out: string, file: string): Promise<PageImage> => {
|
|
72
|
+
const root = await realpath(out);
|
|
73
|
+
const resolved = await realpath(path.resolve(root, file.replace(/^\/+/, ""))).catch(() => {
|
|
74
|
+
throw new Error(`the mark is not in the static export: ${file}`);
|
|
75
|
+
});
|
|
76
|
+
if (!resolved.startsWith(`${root}${path.sep}`)) throw new Error(`the mark is outside the static export: ${file}`);
|
|
77
|
+
const bytes = await readFile(resolved);
|
|
78
|
+
if (bytes.length > maxMarkBytes) throw new Error(`the mark is ${bytes.length} bytes, over ${maxMarkBytes}: ${file}`);
|
|
79
|
+
const extension = path.extname(resolved).toLowerCase();
|
|
80
|
+
if (extension === ".png") {
|
|
81
|
+
if (!bytes.subarray(0, 8).equals(pngSignature)) throw new Error(`the mark is not a PNG: ${file}`);
|
|
82
|
+
return { type: "image/png", data: bytes.toString("base64") };
|
|
83
|
+
}
|
|
84
|
+
if (extension === ".svg") {
|
|
85
|
+
if (!/<svg[\s>]/.test(bytes.toString("utf8"))) throw new Error(`the mark is not an SVG: ${file}`);
|
|
86
|
+
return { type: "image/svg+xml", data: bytes.toString("base64") };
|
|
87
|
+
}
|
|
88
|
+
throw new Error(`the mark must be an .svg or a .png: ${file}`);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** The page's configuration from the bin's flags: the marks read from the export, everything else as given. */
|
|
92
|
+
const pageFile = async (out: string, options: BundlePageOptions = {}): Promise<PageConfigFile> => ({
|
|
93
|
+
...(options.name !== undefined && { name: options.name }),
|
|
94
|
+
...(options.mark !== undefined && { mark: await readMark(out, options.mark) }),
|
|
95
|
+
...(options.markDark !== undefined && { markDark: await readMark(out, options.markDark) }),
|
|
96
|
+
...(options.accent !== undefined && { accent: options.accent }),
|
|
97
|
+
...(options.accentDark !== undefined && { accentDark: options.accentDark }),
|
|
98
|
+
});
|
|
99
|
+
|
|
46
100
|
const packageRoot = fileURLToPath(new URL("../../", import.meta.url));
|
|
47
101
|
const compiledRoot = fileURLToPath(new URL("../", import.meta.url));
|
|
48
102
|
|
|
@@ -155,6 +209,7 @@ export const bundleDoor = async (options: BundleDoorOptions): Promise<DeployMani
|
|
|
155
209
|
hostHeader: options.hostHeader ?? "host",
|
|
156
210
|
signout: options.signout ?? [],
|
|
157
211
|
jwks,
|
|
212
|
+
page: await pageFile(out, options.page),
|
|
158
213
|
};
|
|
159
214
|
const config = parseGateConfig(configFile);
|
|
160
215
|
configFile.door = config.door;
|
package/src/door/page.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The coming-soon page: what a locked site answers a page load with when the visitor holds no session. It is the
|
|
5
|
+
* gate's own HTML and carries nothing of the site: one inline stylesheet, the mark as a data URI, no script and no
|
|
6
|
+
* request to anywhere. It is the same bytes for every path but one, the canonical next its sign-in link carries.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** An image the page shows, carried in gate.config.json as base64 so the page needs no request for it. */
|
|
10
|
+
export type PageImage = { type: "image/svg+xml" | "image/png"; data: string };
|
|
11
|
+
|
|
12
|
+
/** gate.config.json's `page`: everything a site says about its coming-soon page. All optional. */
|
|
13
|
+
export type PageConfigFile = {
|
|
14
|
+
/** The product's display name, as the page writes it. The site id, capitalized, by default. */
|
|
15
|
+
name?: string;
|
|
16
|
+
/** The mark, shown beside the name. None by default, and the name stands alone. */
|
|
17
|
+
mark?: PageImage;
|
|
18
|
+
/** The mark for a dark scheme. Without it the dark scheme shows `mark` on a light tile. */
|
|
19
|
+
markDark?: PageImage;
|
|
20
|
+
/** The button's color in the light scheme, `#rrggbb`. */
|
|
21
|
+
accent?: string;
|
|
22
|
+
/** The button's color in the dark scheme, `#rrggbb`. */
|
|
23
|
+
accentDark?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type PageConfig = Required<Pick<PageConfigFile, "name" | "accent" | "accentDark">> &
|
|
27
|
+
Pick<PageConfigFile, "mark" | "markDark">;
|
|
28
|
+
|
|
29
|
+
/** The page's own surfaces; a site's accent has to stand out from these. */
|
|
30
|
+
export const surfaces = {
|
|
31
|
+
light: { background: "#ffffff", text: "#1a1f24", muted: "#4b5560" },
|
|
32
|
+
dark: { background: "#11161b", text: "#e6e9ec", muted: "#a9b3bc" },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const defaultAccent = "#1f2933";
|
|
36
|
+
export const defaultAccentDark = "#d5dbe1";
|
|
37
|
+
/** A mark larger than this is not a mark; the page is answered to every visitor without a session. */
|
|
38
|
+
export const maxMarkBytes = 64 * 1024;
|
|
39
|
+
|
|
40
|
+
const hexColor = /^#[0-9a-f]{6}$/i;
|
|
41
|
+
const base64 = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
42
|
+
|
|
43
|
+
/** WCAG 2's relative luminance of `#rrggbb`. */
|
|
44
|
+
export const luminance = (hex: string): number => {
|
|
45
|
+
const channel = (offset: number) => {
|
|
46
|
+
const value = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255;
|
|
47
|
+
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
48
|
+
};
|
|
49
|
+
return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** WCAG 2's contrast ratio between two `#rrggbb` colors, 1 to 21. */
|
|
53
|
+
export const contrast = (a: string, b: string): number => {
|
|
54
|
+
const [high, low] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
|
|
55
|
+
return (high + 0.05) / (low + 0.05);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The button's text on an accent: white or black, whichever reads better. One of the two always reaches 4.5:1 (the
|
|
60
|
+
* worst accent, where they tie, gives 4.58), so the label meets AA whatever color a site picks. Not a softer near-black:
|
|
61
|
+
* #111111 ties at 4.35, under the line.
|
|
62
|
+
*/
|
|
63
|
+
export const textOn = (accent: string): string =>
|
|
64
|
+
contrast(accent, "#ffffff") >= contrast(accent, "#000000") ? "#ffffff" : "#000000";
|
|
65
|
+
|
|
66
|
+
/** The site id as a display name: `driftline` becomes `Driftline`. */
|
|
67
|
+
export const defaultName = (site: string): string => site.charAt(0).toUpperCase() + site.slice(1);
|
|
68
|
+
|
|
69
|
+
const checkImage = (value: unknown, label: string): PageImage | undefined => {
|
|
70
|
+
if (value === undefined) return undefined;
|
|
71
|
+
const image = value as Partial<PageImage> | null;
|
|
72
|
+
if (image?.type !== "image/svg+xml" && image?.type !== "image/png") {
|
|
73
|
+
throw new Error(`the page's ${label} must be an SVG or a PNG`);
|
|
74
|
+
}
|
|
75
|
+
if (typeof image.data !== "string" || !base64.test(image.data)) {
|
|
76
|
+
throw new Error(`the page's ${label} is not base64`);
|
|
77
|
+
}
|
|
78
|
+
if (Buffer.byteLength(image.data, "base64") > maxMarkBytes) {
|
|
79
|
+
throw new Error(`the page's ${label} is over ${maxMarkBytes} bytes`);
|
|
80
|
+
}
|
|
81
|
+
return { type: image.type, data: image.data };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const checkColor = (value: unknown, fallback: string, label: string, surface: string): string => {
|
|
85
|
+
if (value === undefined) return fallback;
|
|
86
|
+
if (typeof value !== "string" || !hexColor.test(value))
|
|
87
|
+
throw new Error(`the page's ${label} is not #rrggbb: ${value}`);
|
|
88
|
+
// WCAG 1.4.11: the button's edge and the focus ring are drawn in the accent, so it must reach 3:1 on the page.
|
|
89
|
+
const ratio = contrast(value, surface);
|
|
90
|
+
if (ratio < 3) {
|
|
91
|
+
throw new Error(`the page's ${label} ${value} is ${ratio.toFixed(2)}:1 on ${surface}; it needs 3:1`);
|
|
92
|
+
}
|
|
93
|
+
return value.toLowerCase();
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Checks gate.config.json's `page` and fills in the defaults, or throws naming what is wrong. With no `page` at all
|
|
98
|
+
* the result still renders a correct page: the site id as the name, no mark, and a neutral button.
|
|
99
|
+
*/
|
|
100
|
+
export const parsePageConfig = (value: unknown, site: string): PageConfig => {
|
|
101
|
+
if (value !== undefined && (typeof value !== "object" || value === null)) {
|
|
102
|
+
throw new Error("the page configuration is not an object");
|
|
103
|
+
}
|
|
104
|
+
const file = (value ?? {}) as PageConfigFile;
|
|
105
|
+
const name = file.name ?? defaultName(site);
|
|
106
|
+
if (typeof name !== "string" || name.trim() !== name || name.length === 0 || name.length > 60) {
|
|
107
|
+
throw new Error(`the page's name must be 1 to 60 characters with no space at either end: ${JSON.stringify(name)}`);
|
|
108
|
+
}
|
|
109
|
+
if ([...name].some((c) => c <= "\u001f" || c === "\u007f"))
|
|
110
|
+
throw new Error("the page's name has a control character");
|
|
111
|
+
const mark = checkImage(file.mark, "mark");
|
|
112
|
+
const markDark = checkImage(file.markDark, "dark mark");
|
|
113
|
+
if (markDark && !mark) throw new Error("the page has a dark mark but no mark");
|
|
114
|
+
return {
|
|
115
|
+
name,
|
|
116
|
+
accent: checkColor(file.accent, defaultAccent, "accent", surfaces.light.background),
|
|
117
|
+
accentDark: checkColor(file.accentDark, defaultAccentDark, "dark accent", surfaces.dark.background),
|
|
118
|
+
...(mark && { mark }),
|
|
119
|
+
...(markDark && { markDark }),
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const escapeHtml = (value: string) =>
|
|
124
|
+
value
|
|
125
|
+
.replace(/&/g, "&")
|
|
126
|
+
.replace(/</g, "<")
|
|
127
|
+
.replace(/>/g, ">")
|
|
128
|
+
.replace(/"/g, """)
|
|
129
|
+
.replace(/'/g, "'");
|
|
130
|
+
|
|
131
|
+
const dataUri = (image: PageImage) => `data:${image.type};base64,${image.data}`;
|
|
132
|
+
|
|
133
|
+
/** The stylesheet, which depends only on the configuration, so its hash is fixed for the gate's life. */
|
|
134
|
+
const stylesheet = (page: PageConfig): string => {
|
|
135
|
+
const { light, dark } = surfaces;
|
|
136
|
+
// The dark scheme shows the dark mark if there is one; otherwise the one mark, on a light tile so a dark-ink mark
|
|
137
|
+
// stays visible.
|
|
138
|
+
const darkMark = page.markDark
|
|
139
|
+
? ".mark-light{display:none}.mark-dark{display:block}"
|
|
140
|
+
: `.mark-light{background:${light.background};border-radius:10px;padding:4px}`;
|
|
141
|
+
return [
|
|
142
|
+
":root{color-scheme:light dark;",
|
|
143
|
+
`--bg:${light.background};--text:${light.text};--muted:${light.muted};`,
|
|
144
|
+
`--accent:${page.accent};--on-accent:${textOn(page.accent)}}`,
|
|
145
|
+
"@media (prefers-color-scheme:dark){:root{",
|
|
146
|
+
`--bg:${dark.background};--text:${dark.text};--muted:${dark.muted};`,
|
|
147
|
+
`--accent:${page.accentDark};--on-accent:${textOn(page.accentDark)}}${darkMark}}`,
|
|
148
|
+
"*{box-sizing:border-box}",
|
|
149
|
+
"html{-webkit-text-size-adjust:100%;text-size-adjust:100%}",
|
|
150
|
+
"body{margin:0;min-height:100vh;min-height:100dvh;display:flex;align-items:center;justify-content:center;",
|
|
151
|
+
"padding:48px 24px;background:var(--bg);color:var(--text);",
|
|
152
|
+
'font:400 1.0625rem/1.55 system-ui,-apple-system,"Segoe UI",Roboto,"Noto Sans",sans-serif}',
|
|
153
|
+
"main{width:100%;max-width:34rem}",
|
|
154
|
+
".lockup{display:flex;align-items:center;gap:12px;margin:0 0 40px;font-weight:700;font-size:1.25rem;",
|
|
155
|
+
"letter-spacing:-0.01em}",
|
|
156
|
+
".lockup img{display:block;height:48px;width:auto;max-width:96px}",
|
|
157
|
+
".mark-dark{display:none}",
|
|
158
|
+
"h1{margin:0 0 12px;font-size:clamp(1.75rem,6vw,2.5rem);line-height:1.15;letter-spacing:-0.02em;",
|
|
159
|
+
"font-weight:700;overflow-wrap:anywhere}",
|
|
160
|
+
".lede{margin:0 0 32px;color:var(--muted)}",
|
|
161
|
+
".actions{display:flex;flex-wrap:wrap;align-items:center;gap:16px 24px}",
|
|
162
|
+
".button{display:inline-flex;align-items:center;min-height:48px;padding:12px 20px;border-radius:8px;",
|
|
163
|
+
"background:var(--accent);color:var(--on-accent);font-weight:600;text-decoration:none;",
|
|
164
|
+
"border:2px solid var(--accent)}",
|
|
165
|
+
".button:hover{text-decoration:underline;text-underline-offset:3px}",
|
|
166
|
+
".link{color:var(--text);font-weight:600;text-decoration:underline;text-decoration-color:var(--accent);",
|
|
167
|
+
"text-decoration-thickness:2px;text-underline-offset:4px;padding:12px 0}",
|
|
168
|
+
".link:hover{text-decoration-thickness:3px}",
|
|
169
|
+
"a:focus-visible{outline:3px solid var(--accent);outline-offset:3px}",
|
|
170
|
+
"@media (max-width:480px){body{padding:32px 16px;align-items:flex-start}.lockup{margin-bottom:32px}}",
|
|
171
|
+
].join("");
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export type ComingSoon = {
|
|
175
|
+
/** The page for one canonical next: the only part that differs between paths. */
|
|
176
|
+
html: (next: string) => string;
|
|
177
|
+
/** The Content-Security-Policy that allows exactly what the page uses. */
|
|
178
|
+
csp: string;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Builds the coming-soon page for a site once, as the gate starts. `door` is the door's origin, which the "Get
|
|
183
|
+
* involved" link points into; `site` is the id the door knows the site by.
|
|
184
|
+
*/
|
|
185
|
+
export const comingSoon = (page: PageConfig, door: string, site: string): ComingSoon => {
|
|
186
|
+
const css = stylesheet(page);
|
|
187
|
+
const styleHash = createHash("sha256").update(css).digest("base64");
|
|
188
|
+
const name = escapeHtml(page.name);
|
|
189
|
+
const getInvolved = escapeHtml(`${door}/get-involved/?${new URLSearchParams({ site })}`);
|
|
190
|
+
const marks = page.mark
|
|
191
|
+
? `<img class="mark-light" src="${dataUri(page.mark)}" alt="">` +
|
|
192
|
+
(page.markDark ? `<img class="mark-dark" src="${dataUri(page.markDark)}" alt="">` : "")
|
|
193
|
+
: "";
|
|
194
|
+
const csp = [
|
|
195
|
+
"default-src 'none'",
|
|
196
|
+
`style-src 'sha256-${styleHash}'`,
|
|
197
|
+
...(page.mark ? ["img-src data:"] : []),
|
|
198
|
+
"base-uri 'none'",
|
|
199
|
+
"form-action 'none'",
|
|
200
|
+
"frame-ancestors 'none'",
|
|
201
|
+
].join("; ");
|
|
202
|
+
const head =
|
|
203
|
+
'<!doctype html><html lang="en"><head><meta charset="utf-8">' +
|
|
204
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">' +
|
|
205
|
+
'<meta name="color-scheme" content="light dark">' +
|
|
206
|
+
'<meta name="robots" content="noindex, nofollow">' +
|
|
207
|
+
'<meta name="referrer" content="strict-origin-when-cross-origin">' +
|
|
208
|
+
`<title>${name} is coming soon</title><style>${css}</style></head>`;
|
|
209
|
+
const before =
|
|
210
|
+
`<body><main><p class="lockup">${marks}<span>${name}</span></p>` +
|
|
211
|
+
`<h1>${name} is coming soon.</h1><p class="lede">Open for now to invited guests.</p>` +
|
|
212
|
+
'<p class="actions"><a class="button" href="';
|
|
213
|
+
const after = `">Have an invitation? Sign in</a><a class="link" href="${getInvolved}">Get involved</a></p></main></body></html>`;
|
|
214
|
+
return {
|
|
215
|
+
html: (next) => `${head}${before}${escapeHtml(`/_door/signin?${new URLSearchParams({ next })}`)}${after}`,
|
|
216
|
+
csp,
|
|
217
|
+
};
|
|
218
|
+
};
|