@reddoorla/maintenance 0.75.0 → 0.76.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/dist/{announce-V6EYPPUR.js → announce-RUT247HS.js} +9 -6
- package/dist/{announce-V6EYPPUR.js.map → announce-RUT247HS.js.map} +1 -1
- package/dist/{blux-C24OVDDW.js → blux-RIJBRVCF.js} +1867 -277
- package/dist/blux-RIJBRVCF.js.map +1 -0
- package/dist/chunk-37S6L6G4.js +170 -0
- package/dist/chunk-37S6L6G4.js.map +1 -0
- package/dist/{chunk-PERP7LSS.js → chunk-3U3IGVAQ.js} +89 -13
- package/dist/chunk-3U3IGVAQ.js.map +1 -0
- package/dist/{chunk-IDBMYDVH.js → chunk-4VAUSRSS.js} +5 -3
- package/dist/{chunk-IDBMYDVH.js.map → chunk-4VAUSRSS.js.map} +1 -1
- package/dist/{run-migration-LJNNZWIV.js → chunk-6DOFNDXU.js} +5 -3
- package/dist/chunk-6DOFNDXU.js.map +1 -0
- package/dist/{chunk-NUIBQPB3.js → chunk-6SZBNALD.js} +62 -9
- package/dist/chunk-6SZBNALD.js.map +1 -0
- package/dist/{chunk-6OYICQSJ.js → chunk-CZAMYVOP.js} +9 -4
- package/dist/chunk-CZAMYVOP.js.map +1 -0
- package/dist/chunk-DRDAEELJ.js +197 -0
- package/dist/chunk-DRDAEELJ.js.map +1 -0
- package/dist/{chunk-KKL3RYFQ.js → chunk-DYKG53IR.js} +2 -2
- package/dist/{chunk-HBFMTM2E.js → chunk-JTRTG5CU.js} +3 -3
- package/dist/{chunk-JCITWZQR.js → chunk-MNE374CZ.js} +2 -50
- package/dist/chunk-MNE374CZ.js.map +1 -0
- package/dist/chunk-MPBXOJ3N.js +51 -0
- package/dist/chunk-MPBXOJ3N.js.map +1 -0
- package/dist/{chunk-I55YWV6T.js → chunk-QWFHC3VH.js} +3 -3
- package/dist/{chunk-KLVMJYZZ.js → chunk-SEQA7MC3.js} +2 -2
- package/dist/chunk-UWJZXL2D.js +9 -0
- package/dist/chunk-UWJZXL2D.js.map +1 -0
- package/dist/cli/bin.js +36 -9
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.js +1 -1
- package/dist/{digest-LEMZS5ZK.js → digest-75FWSC74.js} +9 -6
- package/dist/{digest-LEMZS5ZK.js.map → digest-75FWSC74.js.map} +1 -1
- package/dist/favicon-SRHSJZ62.js +42 -0
- package/dist/favicon-SRHSJZ62.js.map +1 -0
- package/dist/freeze-Y5OJFOST.js +442 -0
- package/dist/freeze-Y5OJFOST.js.map +1 -0
- package/dist/header-image-H4N54DHN.js +95 -0
- package/dist/header-image-H4N54DHN.js.map +1 -0
- package/dist/index.d.ts +63 -1
- package/dist/index.js +64 -16
- package/dist/index.js.map +1 -1
- package/dist/{init-7PTP4O2K.js → init-2KOISBCZ.js} +3 -3
- package/dist/{launch-W33FWWOZ.js → launch-K4EHKTXC.js} +7 -5
- package/dist/{launch-W33FWWOZ.js.map → launch-K4EHKTXC.js.map} +1 -1
- package/dist/{orchestrate-NYRYEKQM.js → orchestrate-CPR6KVS2.js} +4 -3
- package/dist/{preflight-JCQFKB6M.js → preflight-QRRMPI7K.js} +5 -4
- package/dist/{preflight-JCQFKB6M.js.map → preflight-QRRMPI7K.js.map} +1 -1
- package/dist/{report-POHQEV6F.js → report-SLGBDJ6O.js} +8 -6
- package/dist/{report-POHQEV6F.js.map → report-SLGBDJ6O.js.map} +1 -1
- package/dist/reports/header-image/assets/plate.png +0 -0
- package/dist/run-migration-WP57U5XO.js +9 -0
- package/dist/run-migration-WP57U5XO.js.map +1 -0
- package/dist/{selftest-OO2WDSDM.js → selftest-RVASRO3Z.js} +8 -6
- package/dist/{selftest-OO2WDSDM.js.map → selftest-RVASRO3Z.js.map} +1 -1
- package/dist/webflow-624FEBFD.js +548 -0
- package/dist/webflow-624FEBFD.js.map +1 -0
- package/package.json +3 -3
- package/dist/blux-C24OVDDW.js.map +0 -1
- package/dist/chunk-6OYICQSJ.js.map +0 -1
- package/dist/chunk-JCITWZQR.js.map +0 -1
- package/dist/chunk-NUIBQPB3.js.map +0 -1
- package/dist/chunk-PERP7LSS.js.map +0 -1
- package/dist/run-migration-LJNNZWIV.js.map +0 -1
- /package/dist/{chunk-KKL3RYFQ.js.map → chunk-DYKG53IR.js.map} +0 -0
- /package/dist/{chunk-HBFMTM2E.js.map → chunk-JTRTG5CU.js.map} +0 -0
- /package/dist/{chunk-I55YWV6T.js.map → chunk-QWFHC3VH.js.map} +0 -0
- /package/dist/{chunk-KLVMJYZZ.js.map → chunk-SEQA7MC3.js.map} +0 -0
- /package/dist/{init-7PTP4O2K.js.map → init-2KOISBCZ.js.map} +0 -0
- /package/dist/{orchestrate-NYRYEKQM.js.map → orchestrate-CPR6KVS2.js.map} +0 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// src/reports/header-image/assets/index.ts
|
|
2
|
+
import { readFile } from "fs/promises";
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
import { dirname, join } from "path";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
var PLATE = "plate.png";
|
|
7
|
+
var cachedAssetsDir = null;
|
|
8
|
+
function resolveAssetsDir() {
|
|
9
|
+
if (cachedAssetsDir) return cachedAssetsDir;
|
|
10
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
for (; ; ) {
|
|
12
|
+
for (const layout of ["src", "dist"]) {
|
|
13
|
+
const candidate = join(dir, layout, "reports", "header-image", "assets", PLATE);
|
|
14
|
+
if (existsSync(candidate)) {
|
|
15
|
+
cachedAssetsDir = dirname(candidate);
|
|
16
|
+
return cachedAssetsDir;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const parent = dirname(dir);
|
|
20
|
+
if (parent === dir) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`loadPlate: could not locate the header-image assets dir by walking up from ${fileURLToPath(import.meta.url)}. Checked both src/ and dist/ layouts.`
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
dir = parent;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function loadPlate() {
|
|
29
|
+
return new Uint8Array(await readFile(join(resolveAssetsDir(), PLATE)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// src/reports/header-image/index.ts
|
|
33
|
+
import sharp2 from "sharp";
|
|
34
|
+
|
|
35
|
+
// src/reports/header-image/capture.ts
|
|
36
|
+
var VIEWPORT = { width: 1600, height: 1e3 };
|
|
37
|
+
var DEVICE_SCALE_FACTOR = 2;
|
|
38
|
+
var DEFAULT_SETTLE_MS = 2500;
|
|
39
|
+
var NAV_TIMEOUT_MS = 6e4;
|
|
40
|
+
var IDLE_BUDGET_MS = 3e3;
|
|
41
|
+
async function captureHomepage(url, options = {}) {
|
|
42
|
+
const shooter = options.shooter ?? await defaultShooter();
|
|
43
|
+
return shooter.shoot({
|
|
44
|
+
url,
|
|
45
|
+
width: VIEWPORT.width,
|
|
46
|
+
height: VIEWPORT.height,
|
|
47
|
+
deviceScaleFactor: DEVICE_SCALE_FACTOR,
|
|
48
|
+
settleMs: options.settleMs ?? DEFAULT_SETTLE_MS
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
async function defaultShooter() {
|
|
52
|
+
const { chromium } = await import("@playwright/test");
|
|
53
|
+
return {
|
|
54
|
+
async shoot(opts) {
|
|
55
|
+
const browser = await chromium.launch();
|
|
56
|
+
try {
|
|
57
|
+
const page = await browser.newPage({
|
|
58
|
+
viewport: { width: opts.width, height: opts.height },
|
|
59
|
+
deviceScaleFactor: opts.deviceScaleFactor
|
|
60
|
+
});
|
|
61
|
+
await page.goto(opts.url, { waitUntil: "load", timeout: NAV_TIMEOUT_MS });
|
|
62
|
+
await page.waitForLoadState("networkidle", { timeout: IDLE_BUDGET_MS }).catch(() => {
|
|
63
|
+
});
|
|
64
|
+
await page.evaluate("document.fonts && document.fonts.ready");
|
|
65
|
+
await page.waitForTimeout(opts.settleMs);
|
|
66
|
+
const buf = await page.screenshot({ type: "png" });
|
|
67
|
+
return new Uint8Array(buf);
|
|
68
|
+
} finally {
|
|
69
|
+
await browser.close();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src/reports/header-image/compose.ts
|
|
76
|
+
import sharp from "sharp";
|
|
77
|
+
|
|
78
|
+
// src/reports/header-image/geometry.ts
|
|
79
|
+
var CANVAS = { width: 2400, height: 3200 };
|
|
80
|
+
var SCREEN = { x: 302, y: 1913, w: 1349, h: 844 };
|
|
81
|
+
var DOMAIN = {
|
|
82
|
+
x: 280,
|
|
83
|
+
baseline: 3020,
|
|
84
|
+
size: 80,
|
|
85
|
+
weight: 400,
|
|
86
|
+
color: "#747474"
|
|
87
|
+
};
|
|
88
|
+
var PAPER = "#fcfcfc";
|
|
89
|
+
|
|
90
|
+
// src/reports/header-image/compose.ts
|
|
91
|
+
var JPEG_QUALITY = 88;
|
|
92
|
+
function escapeXml(s) {
|
|
93
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
94
|
+
}
|
|
95
|
+
function domainSvg(domain) {
|
|
96
|
+
return Buffer.from(
|
|
97
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${CANVAS.width}" height="${CANVAS.height}">
|
|
98
|
+
<text x="${DOMAIN.x}" y="${DOMAIN.baseline}"
|
|
99
|
+
font-family="Helvetica Neue, Helvetica, Nimbus Sans, Arial, sans-serif"
|
|
100
|
+
font-size="${DOMAIN.size}" font-weight="${DOMAIN.weight}"
|
|
101
|
+
fill="${DOMAIN.color}">${escapeXml(domain)}</text>
|
|
102
|
+
</svg>`,
|
|
103
|
+
"utf-8"
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
async function composeHeaderImage(input) {
|
|
107
|
+
const screen = await sharp(Buffer.from(input.screenshot)).resize(SCREEN.w, SCREEN.h, { fit: "cover", position: "top" }).flatten({ background: PAPER }).toBuffer();
|
|
108
|
+
const layers = [{ input: screen, left: SCREEN.x, top: SCREEN.y }];
|
|
109
|
+
if (input.domain.trim().length > 0) {
|
|
110
|
+
layers.push({ input: domainSvg(input.domain), left: 0, top: 0 });
|
|
111
|
+
}
|
|
112
|
+
const out = await sharp(Buffer.from(input.plate)).composite(layers).jpeg({ quality: JPEG_QUALITY }).toBuffer();
|
|
113
|
+
return new Uint8Array(out);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// src/reports/header-image/index.ts
|
|
117
|
+
function domainFromUrl(url) {
|
|
118
|
+
return new URL(url).hostname.replace(/^www\./, "");
|
|
119
|
+
}
|
|
120
|
+
async function assertNotBlank(shot) {
|
|
121
|
+
const { data, info } = await sharp2(Buffer.from(shot)).resize(64, 40, { fit: "fill" }).removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
122
|
+
const total = info.width * info.height;
|
|
123
|
+
const at = (i) => data[i] ?? 0;
|
|
124
|
+
let sumR = 0;
|
|
125
|
+
let sumG = 0;
|
|
126
|
+
let sumB = 0;
|
|
127
|
+
for (let i = 0; i < data.length; i += info.channels) {
|
|
128
|
+
sumR += at(i);
|
|
129
|
+
sumG += at(i + 1);
|
|
130
|
+
sumB += at(i + 2);
|
|
131
|
+
}
|
|
132
|
+
const meanR = sumR / total;
|
|
133
|
+
const meanG = sumG / total;
|
|
134
|
+
const meanB = sumB / total;
|
|
135
|
+
let near = 0;
|
|
136
|
+
for (let i = 0; i < data.length; i += info.channels) {
|
|
137
|
+
if (Math.abs(at(i) - meanR) < 8 && Math.abs(at(i + 1) - meanG) < 8 && Math.abs(at(i + 2) - meanB) < 8) {
|
|
138
|
+
near++;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const uniform = near / total > 0.95;
|
|
142
|
+
const nearWhite = meanR > 240 && meanG > 240 && meanB > 240;
|
|
143
|
+
if (uniform && nearWhite) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
"header-image: capture looks blank (near-white, >95% one colour) \u2014 keeping the existing header"
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async function generateHeaderImage(input) {
|
|
150
|
+
const screenshot = await captureHomepage(input.url, {
|
|
151
|
+
...input.shooter !== void 0 ? { shooter: input.shooter } : {},
|
|
152
|
+
...input.settleMs !== void 0 ? { settleMs: input.settleMs } : {}
|
|
153
|
+
});
|
|
154
|
+
await assertNotBlank(screenshot);
|
|
155
|
+
const domain = domainFromUrl(input.url);
|
|
156
|
+
const bytes = await composeHeaderImage({
|
|
157
|
+
plate: await loadPlate(),
|
|
158
|
+
screenshot,
|
|
159
|
+
domain
|
|
160
|
+
});
|
|
161
|
+
const base = input.slug ?? domain.split(".")[0];
|
|
162
|
+
return { bytes, domain, filename: `${base}Header.jpg`, contentType: "image/jpeg" };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export {
|
|
166
|
+
loadPlate,
|
|
167
|
+
domainFromUrl,
|
|
168
|
+
generateHeaderImage
|
|
169
|
+
};
|
|
170
|
+
//# sourceMappingURL=chunk-37S6L6G4.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/reports/header-image/assets/index.ts","../src/reports/header-image/index.ts","../src/reports/header-image/capture.ts","../src/reports/header-image/compose.ts","../src/reports/header-image/geometry.ts"],"sourcesContent":["import { readFile } from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst PLATE = \"plate.png\";\n\n// Walk up from this module's URL looking for the assets dir in either the dev\n// layout (src/reports/header-image/assets/) or the published layout\n// (dist/reports/header-image/assets/). REQUIRED because tsup inlines this module\n// into dist/cli/bin.js — so import.meta.url-based sibling resolution looks in\n// dist/cli/ and fails with ENOENT. This exact bug shipped in 0.10.0-0.10.1 for\n// the maintenance-email assets; tests passed in dev because vitest evaluates the\n// source file, where import.meta.url is already correct. Only `pnpm test:dist`\n// catches a regression here.\nlet cachedAssetsDir: string | null = null;\nfunction resolveAssetsDir(): string {\n if (cachedAssetsDir) return cachedAssetsDir;\n let dir = dirname(fileURLToPath(import.meta.url));\n for (;;) {\n for (const layout of [\"src\", \"dist\"]) {\n const candidate = join(dir, layout, \"reports\", \"header-image\", \"assets\", PLATE);\n if (existsSync(candidate)) {\n cachedAssetsDir = dirname(candidate);\n return cachedAssetsDir;\n }\n }\n const parent = dirname(dir);\n if (parent === dir) {\n throw new Error(\n `loadPlate: could not locate the header-image assets dir by walking up from ${fileURLToPath(import.meta.url)}. Checked both src/ and dist/ layouts.`,\n );\n }\n dir = parent;\n }\n}\n\n/** Read the bundled 2400x3200 plate. */\nexport async function loadPlate(): Promise<Uint8Array> {\n return new Uint8Array(await readFile(join(resolveAssetsDir(), PLATE)));\n}\n","import sharp from \"sharp\";\nimport { captureHomepage, type Shooter } from \"./capture.js\";\nimport { composeHeaderImage } from \"./compose.js\";\nimport { loadPlate } from \"./assets/index.js\";\n\nexport type GenerateInput = {\n /** The site's production URL. */\n url: string;\n /** Airtable site slug; drives the attachment filename. */\n slug?: string;\n /** Injected browser IO (tests). */\n shooter?: Shooter;\n /** Per-site settle override for animation-heavy or consent-gated homepages. */\n settleMs?: number;\n};\n\nexport type GeneratedHeaderImage = {\n bytes: Uint8Array;\n /** The domain as printed on the plate. */\n domain: string;\n filename: string;\n contentType: \"image/jpeg\";\n};\n\n/** Strip scheme, leading www and any path — the plate prints a bare domain. */\nexport function domainFromUrl(url: string): string {\n return new URL(url).hostname.replace(/^www\\./, \"\");\n}\n\n/**\n * A capture is \"blank\" when it is almost entirely near-white — a consent gate,\n * a failed render (blank canvas), or a shot fired before first paint. The\n * operator reviews the rendered email before sending, so this is a backstop\n * rather than the only gate; its job is to stop an obviously broken shot from\n * replacing a good stored header.\n *\n * Uniformity alone is NOT the signal: a homepage with a large solid-colour\n * hero (any brand colour) is legitimately uniform but not blank. Only\n * near-white uniformity is flagged, since every real failure mode here\n * (consent overlay, unrendered canvas, pre-paint flash) lands on a white\n * background.\n */\nasync function assertNotBlank(shot: Uint8Array): Promise<void> {\n const { data, info } = await sharp(Buffer.from(shot))\n .resize(64, 40, { fit: \"fill\" })\n .removeAlpha()\n .raw()\n .toBuffer({ resolveWithObject: true });\n const total = info.width * info.height;\n const at = (i: number): number => data[i] ?? 0;\n\n let sumR = 0;\n let sumG = 0;\n let sumB = 0;\n for (let i = 0; i < data.length; i += info.channels) {\n sumR += at(i);\n sumG += at(i + 1);\n sumB += at(i + 2);\n }\n const meanR = sumR / total;\n const meanG = sumG / total;\n const meanB = sumB / total;\n\n let near = 0;\n for (let i = 0; i < data.length; i += info.channels) {\n if (\n Math.abs(at(i) - meanR) < 8 &&\n Math.abs(at(i + 1) - meanG) < 8 &&\n Math.abs(at(i + 2) - meanB) < 8\n ) {\n near++;\n }\n }\n\n const uniform = near / total > 0.95;\n const nearWhite = meanR > 240 && meanG > 240 && meanB > 240;\n if (uniform && nearWhite) {\n throw new Error(\n \"header-image: capture looks blank (near-white, >95% one colour) — keeping the existing header\",\n );\n }\n}\n\n/** Capture a site's homepage and composite its header image. */\nexport async function generateHeaderImage(input: GenerateInput): Promise<GeneratedHeaderImage> {\n const screenshot = await captureHomepage(input.url, {\n ...(input.shooter !== undefined ? { shooter: input.shooter } : {}),\n ...(input.settleMs !== undefined ? { settleMs: input.settleMs } : {}),\n });\n await assertNotBlank(screenshot);\n\n const domain = domainFromUrl(input.url);\n const bytes = await composeHeaderImage({\n plate: await loadPlate(),\n screenshot,\n domain,\n });\n const base = input.slug ?? domain.split(\".\")[0];\n return { bytes, domain, filename: `${base}Header.jpg`, contentType: \"image/jpeg\" };\n}\n","/** Viewport matching the plate's MacBook screen aspect (16:10), so the crop in\n * compose.ts is a no-op for a well-behaved homepage. */\nconst VIEWPORT = { width: 1600, height: 1000 } as const;\n/** 2x so the 1349px-wide screen rect is fed real pixels, not upscaled ones. */\nconst DEVICE_SCALE_FACTOR = 2;\n/** Entrance animations and webfonts settle before the shutter. Measured against\n * live fleet sites; overridable per site because consent gates and long\n * animations vary. */\nconst DEFAULT_SETTLE_MS = 2500;\n/** Budget for the `load` milestone — the navigation we actually gate on.\n *\n * Do NOT put `waitUntil: \"networkidle\"` back on the `goto`. Measured across all\n * 14 live fleet sites (30s timeout): `load` succeeded on 14/14 in 346–1205ms,\n * while `networkidle` NEVER fired on 4 of them (ERP Industrials, Vineyard,\n * Revogen, 1836dig) — chat widgets, analytics polling and websockets hold the\n * connection open forever, so `goto` threw and those sites could never get a\n * header image at all. */\nconst NAV_TIMEOUT_MS = 60_000;\n/** Best-effort extra wait for network idle, applied *after* `load` and with its\n * rejection swallowed. The 10 sites that do settle still get the benefit; the 4\n * that never will just proceed. 3s is sized from the measurement above: the\n * slowest site that does reach idle took 4078ms from `goto` start, and by the\n * time this runs the `load` milestone (~0.3–1.2s) has already passed, so 3s\n * covers the real remaining settle time without stalling the never-idle sites. */\nconst IDLE_BUDGET_MS = 3_000;\n\nexport type ShootOptions = {\n url: string;\n width: number;\n height: number;\n deviceScaleFactor: number;\n settleMs: number;\n};\n\n/** Injected browser IO. The real impl drives Playwright; tests pass a fake. */\nexport type Shooter = {\n shoot: (opts: ShootOptions) => Promise<Uint8Array>;\n};\n\nexport type CaptureOptions = {\n shooter?: Shooter;\n settleMs?: number;\n};\n\n/**\n * Screenshot a site's homepage for the header image.\n *\n * Viewport-only, never `fullPage`: the plate shows a laptop screen, not a\n * scroll. Failures propagate — a caller must be able to keep the site's existing\n * header rather than overwrite it with a broken shot.\n */\nexport async function captureHomepage(\n url: string,\n options: CaptureOptions = {},\n): Promise<Uint8Array> {\n const shooter = options.shooter ?? (await defaultShooter());\n return shooter.shoot({\n url,\n width: VIEWPORT.width,\n height: VIEWPORT.height,\n deviceScaleFactor: DEVICE_SCALE_FACTOR,\n settleMs: options.settleMs ?? DEFAULT_SETTLE_MS,\n });\n}\n\n/** Real Playwright shooter. Lazily imported so unit tests never load it and the\n * static import graph stays central-dep-free for `test:dist`. */\nexport async function defaultShooter(): Promise<Shooter> {\n const { chromium } = await import(\"@playwright/test\");\n return {\n async shoot(opts) {\n const browser = await chromium.launch();\n try {\n const page = await browser.newPage({\n viewport: { width: opts.width, height: opts.height },\n deviceScaleFactor: opts.deviceScaleFactor,\n });\n await page.goto(opts.url, { waitUntil: \"load\", timeout: NAV_TIMEOUT_MS });\n // Best-effort only — a site that never idles must still be captured.\n await page.waitForLoadState(\"networkidle\", { timeout: IDLE_BUDGET_MS }).catch(() => {});\n await page.evaluate(\"document.fonts && document.fonts.ready\");\n await page.waitForTimeout(opts.settleMs);\n const buf = await page.screenshot({ type: \"png\" });\n return new Uint8Array(buf);\n } finally {\n await browser.close();\n }\n },\n };\n}\n","import sharp from \"sharp\";\nimport type { OverlayOptions } from \"sharp\";\nimport { CANVAS, SCREEN, DOMAIN, PAPER } from \"./geometry.js\";\n\nexport type ComposeInput = {\n /** The bundled 2400x3200 plate. */\n plate: Uint8Array;\n /** A homepage screenshot, any size. Cropped to fill the screen rect. */\n screenshot: Uint8Array;\n /** The site's public domain, drawn bottom-left (e.g. \"gallerysonder.com\"). */\n domain: string;\n};\n\n/** JPEG quality. The hand-made headers are ~2.4MB; 88 lands near 0.7MB with no\n * visible difference at the 600px the email actually renders. */\nconst JPEG_QUALITY = 88;\n\n/** Escape the five XML metacharacters. The domain is interpolated into an SVG,\n * and a bare `&` yields invalid XML that sharp rejects outright. */\nfunction escapeXml(s: string): string {\n return s\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\n}\n\n/**\n * The domain line as a transparent SVG overlay the size of the full canvas, so\n * it can be composited at 0,0 and positioned by its own coordinates.\n *\n * ⚠️ FONT AVAILABILITY IS ENVIRONMENT-DEPENDENT. sharp renders SVG through\n * librsvg + fontconfig, so the family below resolves against INSTALLED fonts.\n * On macOS it resolves to real Helvetica Neue, which reproduces the hand-made\n * reference exactly (verified: ink box dx=0 dy=0 dw=0 dh=0). A stock\n * ubuntu-latest runner has none of Helvetica Neue / Helvetica / Arial, so it\n * falls through to `sans-serif` — typically DejaVu Sans, which is wider and\n * would render a visibly different domain line.\n *\n * This matters because report drafting (which regenerates the header) runs on\n * ubuntu-latest in `.github/workflows/daily-reports.yml`. That workflow now\n * installs `fonts-urw-base35`, which provides Nimbus Sans — metrically\n * equivalent to Helvetica and third in the stack below — so a CI-generated\n * header's domain line matches a locally-generated one. Any OTHER environment\n * that runs this code still needs a Helvetica-metric font installed.\n */\nfunction domainSvg(domain: string): Buffer {\n return Buffer.from(\n `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"${CANVAS.width}\" height=\"${CANVAS.height}\">\n <text x=\"${DOMAIN.x}\" y=\"${DOMAIN.baseline}\"\n font-family=\"Helvetica Neue, Helvetica, Nimbus Sans, Arial, sans-serif\"\n font-size=\"${DOMAIN.size}\" font-weight=\"${DOMAIN.weight}\"\n fill=\"${DOMAIN.color}\">${escapeXml(domain)}</text>\n </svg>`,\n \"utf-8\",\n );\n}\n\n/**\n * Composite one site's header image: paste its homepage screenshot into the\n * plate's laptop screen and draw its domain underneath.\n *\n * PURE — no browser, no network, no filesystem. Fidelity lives here, so it must\n * be verifiable in a unit test.\n *\n * The screenshot is cropped to FILL the 16:10 screen from the top: a taller\n * homepage loses its fold rather than being squashed, which would be obvious\n * and wrong. `flatten` guards against a transparent source punching a hole in\n * the plate.\n */\nexport async function composeHeaderImage(input: ComposeInput): Promise<Uint8Array> {\n const screen = await sharp(Buffer.from(input.screenshot))\n .resize(SCREEN.w, SCREEN.h, { fit: \"cover\", position: \"top\" })\n .flatten({ background: PAPER })\n .toBuffer();\n\n const layers: OverlayOptions[] = [{ input: screen, left: SCREEN.x, top: SCREEN.y }];\n if (input.domain.trim().length > 0) {\n layers.push({ input: domainSvg(input.domain), left: 0, top: 0 });\n }\n\n const out = await sharp(Buffer.from(input.plate))\n .composite(layers)\n .jpeg({ quality: JPEG_QUALITY })\n .toBuffer();\n return new Uint8Array(out);\n}\n","// Measured off the real plate, not guessed.\n//\n// SCREEN is the hole INSIDE the laptop bezel. It is derived from the bezel —\n// a perfectly flat black frame (luminance 0.0) — because that is\n// content-independent. Deriving it from content instead (diffing two headers for\n// pixels that vary) yields x=282 y=1856 w=1394 h=871, which is WRONG: that rect\n// spans the laptop's outer panel, covering the bezel on three sides while\n// falling ~29px short at the bottom, so the plate's baked-in screenshot shows\n// as a strip under every site's content.\n//\n// Cross-checked: the photo-to-flat-black transition at the bottom of the plate\n// lands at y=2756, exactly SCREEN.y + SCREEN.h - 1.\n//\n// Re-measure only if the Figma template changes — and if you do, find the bezel.\n\n/** The plate's pixel dimensions — Figma frame 600x800 exported at 4x. */\nexport const CANVAS = { width: 2400, height: 3200 } as const;\n\n/** The MacBook Pro screen the site screenshot is pasted into — the area inside\n * the bezel, so the bezel stays visible around the content exactly as it does\n * in the hand-made headers. Aspect 1.5983 (16:10 to within 0.1%). */\nexport const SCREEN = { x: 302, y: 1913, w: 1349, h: 844 } as const;\n\n/** The per-site domain line, bottom left.\n *\n * Solved against the hand-made reference rather than guessed: rendering\n * \"gallerysonder.com\" with these values reproduces Sonder's original ink box\n * (x=283 y=2963 w=645 h=74) EXACTLY — dx=0 dy=0 dw=0 dh=0. A sweep over sizes\n * 62-88 at weights 300/400 has a single exact solution at size 80 / weight 400.\n *\n * `baseline` is the text baseline in canvas pixels; the ink box sits above it\n * by the cap height and below by the descender. */\nexport const DOMAIN = {\n x: 280,\n baseline: 3020,\n size: 80,\n weight: 400,\n color: \"#747474\",\n} as const;\n\n/** Flat paper tone, used as the compositing background so a transparent or\n * short source can never punch a hole in the plate. */\nexport const PAPER = \"#fcfcfc\" as const;\n"],"mappings":"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","names":["sharp","sharp"]}
|
|
@@ -1243,6 +1243,34 @@ async function discoverRoutes(deployedUrl, deps, cap = DEFAULT_CAP) {
|
|
|
1243
1243
|
}
|
|
1244
1244
|
|
|
1245
1245
|
// src/audits/browser.ts
|
|
1246
|
+
function sameSiteHost(a, b) {
|
|
1247
|
+
const ha = a.toLowerCase();
|
|
1248
|
+
const hb = b.toLowerCase();
|
|
1249
|
+
return ha === hb || ha.endsWith(`.${hb}`) || hb.endsWith(`.${ha}`);
|
|
1250
|
+
}
|
|
1251
|
+
async function canonicalizeBaseUrl(baseUrl, verify) {
|
|
1252
|
+
if (!verify.fetchRedirect) return { url: baseUrl };
|
|
1253
|
+
let origin;
|
|
1254
|
+
try {
|
|
1255
|
+
origin = new URL(baseUrl);
|
|
1256
|
+
} catch {
|
|
1257
|
+
return { url: baseUrl };
|
|
1258
|
+
}
|
|
1259
|
+
const seen = await verify.fetchRedirect(baseUrl);
|
|
1260
|
+
const isRedirect = seen.status !== null && [301, 302, 307, 308].includes(seen.status) && (seen.location ?? "") !== "";
|
|
1261
|
+
if (!isRedirect) return { url: baseUrl };
|
|
1262
|
+
let target;
|
|
1263
|
+
try {
|
|
1264
|
+
target = new URL(seen.location, baseUrl);
|
|
1265
|
+
} catch {
|
|
1266
|
+
return { url: baseUrl };
|
|
1267
|
+
}
|
|
1268
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") return { url: baseUrl };
|
|
1269
|
+
if (!sameSiteHost(origin.hostname, target.hostname)) {
|
|
1270
|
+
return { url: baseUrl, offsiteLocation: target.href };
|
|
1271
|
+
}
|
|
1272
|
+
return { url: target.href, redirectedFrom: baseUrl };
|
|
1273
|
+
}
|
|
1246
1274
|
function isBroken(status) {
|
|
1247
1275
|
return status === null || status >= 400;
|
|
1248
1276
|
}
|
|
@@ -1398,19 +1426,25 @@ function summarizeBrowser(routes, links, familyCounts) {
|
|
|
1398
1426
|
if (urls.length > 1) titleMetaProblems.push(`duplicate title "${t}": ${urls.join(" + ")}`);
|
|
1399
1427
|
}
|
|
1400
1428
|
const titleMetaOk = routes.length > 0 && titleMetaProblems.length === 0;
|
|
1429
|
+
const templateProblems = routes.flatMap(
|
|
1430
|
+
(r) => (r.leakedTemplateTokens ?? []).map((t) => `${r.url}: unsubstituted ${t}`)
|
|
1431
|
+
);
|
|
1432
|
+
const templateOk = templateProblems.length === 0;
|
|
1401
1433
|
const engines = [...new Set(desktopChecks.map((d) => d.engine))];
|
|
1402
1434
|
const devices = [...new Set(mobileChecks.map((m) => m.device))];
|
|
1403
1435
|
const families = Object.entries(familyCounts).map(([f, n]) => f === "/" ? "/" : `${f} \xD7${n}`).join(", ");
|
|
1404
1436
|
const firstOf = (xs, n) => xs.slice(0, n).join("; ") + (xs.length > n ? ` (+${xs.length - n} more)` : "");
|
|
1405
|
-
const note = `${routes.length} routes (${families}); desktop ${engines.join("/") || "\u2014"}; mobile ${devices.join("/") || "\u2014"}; ${links.length} links, ${brokenLinks} broken` + (unreachableUrls.length > 0 ? `; unreachable: ${firstOf(unreachableUrls, 3)}` : "") + (titleMetaProblems.length > 0 ? `; title/meta: ${firstOf(titleMetaProblems, 3)}` : "") + (desktopFailures.length > 0 ? `; desktop failing: ${firstOf(desktopFailures, 3)}` : "") + (mobileFailures.length > 0 ? `; mobile failing: ${firstOf(mobileFailures, 3)}` : "") + (brokenLinkUrls.length > 0 ? `; broken: ${firstOf(brokenLinkUrls, 3)}` : "");
|
|
1437
|
+
const note = `${routes.length} routes (${families}); desktop ${engines.join("/") || "\u2014"}; mobile ${devices.join("/") || "\u2014"}; ${links.length} links, ${brokenLinks} broken` + (unreachableUrls.length > 0 ? `; unreachable: ${firstOf(unreachableUrls, 3)}` : "") + (titleMetaProblems.length > 0 ? `; title/meta: ${firstOf(titleMetaProblems, 3)}` : "") + (templateProblems.length > 0 ? `; template: ${firstOf(templateProblems, 3)}` : "") + (desktopFailures.length > 0 ? `; desktop failing: ${firstOf(desktopFailures, 3)}` : "") + (mobileFailures.length > 0 ? `; mobile failing: ${firstOf(mobileFailures, 3)}` : "") + (brokenLinkUrls.length > 0 ? `; broken: ${firstOf(brokenLinkUrls, 3)}` : "");
|
|
1406
1438
|
return {
|
|
1407
1439
|
desktopOk,
|
|
1408
1440
|
mobileOk,
|
|
1409
1441
|
linksOk,
|
|
1410
1442
|
reachableOk,
|
|
1411
1443
|
titleMetaOk,
|
|
1444
|
+
templateOk,
|
|
1412
1445
|
unreachableUrls,
|
|
1413
1446
|
titleMetaProblems,
|
|
1447
|
+
templateProblems,
|
|
1414
1448
|
desktopFailures,
|
|
1415
1449
|
mobileFailures,
|
|
1416
1450
|
brokenLinkUrls,
|
|
@@ -1430,7 +1464,8 @@ async function browserAudit(ctx) {
|
|
|
1430
1464
|
const verifyDeps = ctx.verifyDeps ?? defaultVerifyDeps();
|
|
1431
1465
|
const runner = ctx.browserRunner ?? await defaultBrowserRunner();
|
|
1432
1466
|
try {
|
|
1433
|
-
const
|
|
1467
|
+
const canonical = await canonicalizeBaseUrl(site.deployedUrl, verifyDeps);
|
|
1468
|
+
const discovered = await discoverRoutes(canonical.url, discoverDeps);
|
|
1434
1469
|
const probed = await runner.probe(discovered.routes);
|
|
1435
1470
|
const { routes: reverifiedRoutes, reverified } = await reverifyRoutes(probed, verifyDeps);
|
|
1436
1471
|
const { routes: routeResults, excused } = excuseChallengedEngineChecks(reverifiedRoutes);
|
|
@@ -1453,8 +1488,8 @@ async function browserAudit(ctx) {
|
|
|
1453
1488
|
const linkReverifiedUrls = clearedLinks.map(
|
|
1454
1489
|
(v) => `${v.url}: first check saw ${v.firstStatus ?? "no response"}, plain re-fetch ${v.fetchedStatus}`
|
|
1455
1490
|
);
|
|
1456
|
-
const note = summary.note + (cleared.length > 0 ? `; ${cleared.length} route(s) re-verified reachable by plain fetch after a browser-side challenge/flake` : "") + (excused.length > 0 ? `; ${excused.length} engine/device check(s) excused (challenge on a re-verified-reachable route)` : "") + (clearedLinks.length > 0 ? `; ${clearedLinks.length} link(s) re-verified ok by plain fetch after cooldown` : "");
|
|
1457
|
-
const status = summary.desktopOk && summary.mobileOk && summary.linksOk ? "pass" : "warn";
|
|
1491
|
+
const note = summary.note + (cleared.length > 0 ? `; ${cleared.length} route(s) re-verified reachable by plain fetch after a browser-side challenge/flake` : "") + (excused.length > 0 ? `; ${excused.length} engine/device check(s) excused (challenge on a re-verified-reachable route)` : "") + (clearedLinks.length > 0 ? `; ${clearedLinks.length} link(s) re-verified ok by plain fetch after cooldown` : "") + (canonical.redirectedFrom ? `; base url redirects to its canonical host \u2014 probed ${canonical.url}` : "") + (canonical.offsiteLocation ? `; base url redirects OFF-SITE to ${canonical.offsiteLocation} \u2014 not followed; update the site's url` : "");
|
|
1492
|
+
const status = summary.desktopOk && summary.mobileOk && summary.linksOk && summary.templateOk ? "pass" : "warn";
|
|
1458
1493
|
return {
|
|
1459
1494
|
audit: "browser",
|
|
1460
1495
|
site: label,
|
|
@@ -1466,6 +1501,10 @@ async function browserAudit(ctx) {
|
|
|
1466
1501
|
reverifiedUrls,
|
|
1467
1502
|
excusedEngineChecks: excused,
|
|
1468
1503
|
linkReverifiedUrls,
|
|
1504
|
+
// Canonicalization evidence (spread-conditional — exactOptionalPropertyTypes idiom):
|
|
1505
|
+
// which base was actually probed, and any off-site redirect the operator must act on.
|
|
1506
|
+
...canonical.redirectedFrom ? { canonicalBaseUrl: canonical.url, canonicalizedFrom: canonical.redirectedFrom } : {},
|
|
1507
|
+
...canonical.offsiteLocation ? { offsiteRedirect: canonical.offsiteLocation } : {},
|
|
1469
1508
|
checkedAt: now.toISOString()
|
|
1470
1509
|
}
|
|
1471
1510
|
};
|
|
@@ -1506,6 +1545,19 @@ function defaultVerifyDeps() {
|
|
|
1506
1545
|
} catch {
|
|
1507
1546
|
return { status: null, title: null, metaDescription: null };
|
|
1508
1547
|
}
|
|
1548
|
+
},
|
|
1549
|
+
// Base-URL preflight for canonicalizeBaseUrl: one no-follow GET so the redirect target is
|
|
1550
|
+
// observable (redirect:"follow" would hide it). Errors degrade to null = "no redirect seen".
|
|
1551
|
+
fetchRedirect: async (url) => {
|
|
1552
|
+
try {
|
|
1553
|
+
const res = await fetch(url, {
|
|
1554
|
+
redirect: "manual",
|
|
1555
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
1556
|
+
});
|
|
1557
|
+
return { status: res.status, location: res.headers.get("location") };
|
|
1558
|
+
} catch {
|
|
1559
|
+
return { status: null, location: null };
|
|
1560
|
+
}
|
|
1509
1561
|
}
|
|
1510
1562
|
};
|
|
1511
1563
|
}
|
|
@@ -1534,6 +1586,7 @@ async function defaultBrowserRunner() {
|
|
|
1534
1586
|
let status = null;
|
|
1535
1587
|
let title = null;
|
|
1536
1588
|
let metaDescription = null;
|
|
1589
|
+
let leakedTemplateTokens = [];
|
|
1537
1590
|
for (let i = 0; i < desktopEngines.length; i++) {
|
|
1538
1591
|
const engine = desktopEngines[i].engine;
|
|
1539
1592
|
const browser = browsers[i];
|
|
@@ -1565,6 +1618,10 @@ async function defaultBrowserRunner() {
|
|
|
1565
1618
|
} catch {
|
|
1566
1619
|
}
|
|
1567
1620
|
}
|
|
1621
|
+
const html = resp ? await resp.text().catch(() => "") : "";
|
|
1622
|
+
leakedTemplateTokens = [
|
|
1623
|
+
...new Set(html.match(/%sveltekit\.[a-zA-Z0-9_.]+%/g) ?? [])
|
|
1624
|
+
];
|
|
1568
1625
|
}
|
|
1569
1626
|
} catch {
|
|
1570
1627
|
ok = false;
|
|
@@ -1590,8 +1647,9 @@ async function defaultBrowserRunner() {
|
|
|
1590
1647
|
timeout: PAGE_TIMEOUT_MS
|
|
1591
1648
|
});
|
|
1592
1649
|
navStatus = resp ? resp.status() : null;
|
|
1650
|
+
const hasMain = await page.locator("main, [role=main]").first().isVisible().catch(() => false);
|
|
1593
1651
|
const overflow = await page.evaluate("document.documentElement.scrollWidth > window.innerWidth + 2").catch(() => true);
|
|
1594
|
-
ok = !!resp && resp.ok() && errors.length === 0 && !overflow;
|
|
1652
|
+
ok = !!resp && resp.ok() && errors.length === 0 && !overflow && hasMain;
|
|
1595
1653
|
} catch {
|
|
1596
1654
|
ok = false;
|
|
1597
1655
|
} finally {
|
|
@@ -1607,7 +1665,8 @@ async function defaultBrowserRunner() {
|
|
|
1607
1665
|
links: [...linkSet],
|
|
1608
1666
|
status,
|
|
1609
1667
|
title,
|
|
1610
|
-
metaDescription
|
|
1668
|
+
metaDescription,
|
|
1669
|
+
leakedTemplateTokens
|
|
1611
1670
|
});
|
|
1612
1671
|
}
|
|
1613
1672
|
} finally {
|
|
@@ -1910,6 +1969,13 @@ async function smokeAudit(ctx) {
|
|
|
1910
1969
|
}
|
|
1911
1970
|
}
|
|
1912
1971
|
}
|
|
1972
|
+
try {
|
|
1973
|
+
await spawn("pnpm", ["exec", "svelte-kit", "sync"], {
|
|
1974
|
+
cwd: site.path,
|
|
1975
|
+
timeoutMs: 6e4
|
|
1976
|
+
});
|
|
1977
|
+
} catch {
|
|
1978
|
+
}
|
|
1913
1979
|
const port = await findFreePort();
|
|
1914
1980
|
let raw;
|
|
1915
1981
|
try {
|
|
@@ -1947,7 +2013,7 @@ async function smokeAudit(ctx) {
|
|
|
1947
2013
|
|
|
1948
2014
|
// src/audits/form-e2e.ts
|
|
1949
2015
|
var CF_TEST_SITEKEY = "1x00000000000000000000AA";
|
|
1950
|
-
var
|
|
2016
|
+
var CONTACT_PATHS = ["/contact", "/"];
|
|
1951
2017
|
var LIVE_ENV_VAR = "REDDOOR_FORM_E2E_LIVE";
|
|
1952
2018
|
function liveRunnerEnabled(env = process.env) {
|
|
1953
2019
|
return env[LIVE_ENV_VAR] === "1" || env[LIVE_ENV_VAR] === "true";
|
|
@@ -2007,6 +2073,19 @@ async function formE2eAudit(ctx) {
|
|
|
2007
2073
|
var FILL_SETTLE_MS = 1200;
|
|
2008
2074
|
var PAGE_TIMEOUT_MS2 = 3e4;
|
|
2009
2075
|
var HEALTH_TIMEOUT_MS2 = 1e4;
|
|
2076
|
+
async function findFormPath(page, baseUrl, paths = CONTACT_PATHS) {
|
|
2077
|
+
for (const path of paths) {
|
|
2078
|
+
const url = new URL(path, baseUrl).toString();
|
|
2079
|
+
const resp = await page.goto(url, { waitUntil: "domcontentloaded", timeout: PAGE_TIMEOUT_MS2 }).catch(() => null);
|
|
2080
|
+
if (!resp || !resp.ok()) continue;
|
|
2081
|
+
const forms = await page.locator("form").count().catch(() => 0);
|
|
2082
|
+
if (forms === 0) continue;
|
|
2083
|
+
const emails = await page.locator('input[name="email"], input[type="email"]').count().catch(() => 0);
|
|
2084
|
+
if (emails === 0) continue;
|
|
2085
|
+
return url;
|
|
2086
|
+
}
|
|
2087
|
+
return null;
|
|
2088
|
+
}
|
|
2010
2089
|
async function declaresTestModeForwarding(baseUrl) {
|
|
2011
2090
|
try {
|
|
2012
2091
|
const res = await fetch(new URL("/health", baseUrl), {
|
|
@@ -2033,11 +2112,8 @@ async function defaultFormRunner() {
|
|
|
2033
2112
|
try {
|
|
2034
2113
|
const ctx = await browser.newContext();
|
|
2035
2114
|
const page = await ctx.newPage();
|
|
2036
|
-
const
|
|
2037
|
-
|
|
2038
|
-
const form = page.locator("form").first();
|
|
2039
|
-
const hasForm = !!resp && resp.ok() && await form.count().catch(() => 0) > 0 && await page.locator('input[name="email"], input[type="email"]').count().catch(() => 0) > 0;
|
|
2040
|
-
if (!hasForm) return { formPresent: false };
|
|
2115
|
+
const found = await findFormPath(page, baseUrl);
|
|
2116
|
+
if (!found) return { formPresent: false };
|
|
2041
2117
|
await page.fill('[name="name"]', "Reddoor Monitor").catch(() => {
|
|
2042
2118
|
});
|
|
2043
2119
|
await page.fill('[name="email"]', "monitor+e2e@reddoorla.com").catch(() => {
|
|
@@ -2146,4 +2222,4 @@ export {
|
|
|
2146
2222
|
runAudits,
|
|
2147
2223
|
runAuditsAcross
|
|
2148
2224
|
};
|
|
2149
|
-
//# sourceMappingURL=chunk-
|
|
2225
|
+
//# sourceMappingURL=chunk-3U3IGVAQ.js.map
|