@reddoorla/maintenance 0.75.1 → 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.
Files changed (70) hide show
  1. package/dist/{announce-V6EYPPUR.js → announce-RUT247HS.js} +9 -6
  2. package/dist/{announce-V6EYPPUR.js.map → announce-RUT247HS.js.map} +1 -1
  3. package/dist/{blux-C24OVDDW.js → blux-RIJBRVCF.js} +1867 -277
  4. package/dist/blux-RIJBRVCF.js.map +1 -0
  5. package/dist/chunk-37S6L6G4.js +170 -0
  6. package/dist/chunk-37S6L6G4.js.map +1 -0
  7. package/dist/{chunk-TDG5Z5OT.js → chunk-3U3IGVAQ.js} +72 -9
  8. package/dist/chunk-3U3IGVAQ.js.map +1 -0
  9. package/dist/{chunk-IDBMYDVH.js → chunk-4VAUSRSS.js} +5 -3
  10. package/dist/{chunk-IDBMYDVH.js.map → chunk-4VAUSRSS.js.map} +1 -1
  11. package/dist/{run-migration-LJNNZWIV.js → chunk-6DOFNDXU.js} +5 -3
  12. package/dist/chunk-6DOFNDXU.js.map +1 -0
  13. package/dist/{chunk-NUIBQPB3.js → chunk-6SZBNALD.js} +62 -9
  14. package/dist/chunk-6SZBNALD.js.map +1 -0
  15. package/dist/{chunk-6OYICQSJ.js → chunk-CZAMYVOP.js} +9 -4
  16. package/dist/chunk-CZAMYVOP.js.map +1 -0
  17. package/dist/chunk-DRDAEELJ.js +197 -0
  18. package/dist/chunk-DRDAEELJ.js.map +1 -0
  19. package/dist/{chunk-2AUUAJ5X.js → chunk-DYKG53IR.js} +2 -2
  20. package/dist/{chunk-HBFMTM2E.js → chunk-JTRTG5CU.js} +3 -3
  21. package/dist/{chunk-JCITWZQR.js → chunk-MNE374CZ.js} +2 -50
  22. package/dist/chunk-MNE374CZ.js.map +1 -0
  23. package/dist/chunk-MPBXOJ3N.js +51 -0
  24. package/dist/chunk-MPBXOJ3N.js.map +1 -0
  25. package/dist/{chunk-I55YWV6T.js → chunk-QWFHC3VH.js} +3 -3
  26. package/dist/{chunk-KLVMJYZZ.js → chunk-SEQA7MC3.js} +2 -2
  27. package/dist/chunk-UWJZXL2D.js +9 -0
  28. package/dist/chunk-UWJZXL2D.js.map +1 -0
  29. package/dist/cli/bin.js +36 -9
  30. package/dist/cli/bin.js.map +1 -1
  31. package/dist/cli/commands/audit.js +1 -1
  32. package/dist/{digest-LEMZS5ZK.js → digest-75FWSC74.js} +9 -6
  33. package/dist/{digest-LEMZS5ZK.js.map → digest-75FWSC74.js.map} +1 -1
  34. package/dist/favicon-SRHSJZ62.js +42 -0
  35. package/dist/favicon-SRHSJZ62.js.map +1 -0
  36. package/dist/freeze-Y5OJFOST.js +442 -0
  37. package/dist/freeze-Y5OJFOST.js.map +1 -0
  38. package/dist/header-image-H4N54DHN.js +95 -0
  39. package/dist/header-image-H4N54DHN.js.map +1 -0
  40. package/dist/index.d.ts +58 -1
  41. package/dist/index.js +64 -16
  42. package/dist/index.js.map +1 -1
  43. package/dist/{init-Y2DMKKE7.js → init-2KOISBCZ.js} +3 -3
  44. package/dist/{launch-ZDX7UA3Y.js → launch-K4EHKTXC.js} +7 -5
  45. package/dist/{launch-ZDX7UA3Y.js.map → launch-K4EHKTXC.js.map} +1 -1
  46. package/dist/{orchestrate-NYRYEKQM.js → orchestrate-CPR6KVS2.js} +4 -3
  47. package/dist/{preflight-JCQFKB6M.js → preflight-QRRMPI7K.js} +5 -4
  48. package/dist/{preflight-JCQFKB6M.js.map → preflight-QRRMPI7K.js.map} +1 -1
  49. package/dist/{report-POHQEV6F.js → report-SLGBDJ6O.js} +8 -6
  50. package/dist/{report-POHQEV6F.js.map → report-SLGBDJ6O.js.map} +1 -1
  51. package/dist/reports/header-image/assets/plate.png +0 -0
  52. package/dist/run-migration-WP57U5XO.js +9 -0
  53. package/dist/run-migration-WP57U5XO.js.map +1 -0
  54. package/dist/{selftest-OO2WDSDM.js → selftest-RVASRO3Z.js} +8 -6
  55. package/dist/{selftest-OO2WDSDM.js.map → selftest-RVASRO3Z.js.map} +1 -1
  56. package/dist/webflow-624FEBFD.js +548 -0
  57. package/dist/webflow-624FEBFD.js.map +1 -0
  58. package/package.json +3 -3
  59. package/dist/blux-C24OVDDW.js.map +0 -1
  60. package/dist/chunk-6OYICQSJ.js.map +0 -1
  61. package/dist/chunk-JCITWZQR.js.map +0 -1
  62. package/dist/chunk-NUIBQPB3.js.map +0 -1
  63. package/dist/chunk-TDG5Z5OT.js.map +0 -1
  64. package/dist/run-migration-LJNNZWIV.js.map +0 -1
  65. /package/dist/{chunk-2AUUAJ5X.js.map → chunk-DYKG53IR.js.map} +0 -0
  66. /package/dist/{chunk-HBFMTM2E.js.map → chunk-JTRTG5CU.js.map} +0 -0
  67. /package/dist/{chunk-I55YWV6T.js.map → chunk-QWFHC3VH.js.map} +0 -0
  68. /package/dist/{chunk-KLVMJYZZ.js.map → chunk-SEQA7MC3.js.map} +0 -0
  69. /package/dist/{init-Y2DMKKE7.js.map → init-2KOISBCZ.js.map} +0 -0
  70. /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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
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, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&apos;\");\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
  }
@@ -1436,7 +1464,8 @@ async function browserAudit(ctx) {
1436
1464
  const verifyDeps = ctx.verifyDeps ?? defaultVerifyDeps();
1437
1465
  const runner = ctx.browserRunner ?? await defaultBrowserRunner();
1438
1466
  try {
1439
- const discovered = await discoverRoutes(site.deployedUrl, discoverDeps);
1467
+ const canonical = await canonicalizeBaseUrl(site.deployedUrl, verifyDeps);
1468
+ const discovered = await discoverRoutes(canonical.url, discoverDeps);
1440
1469
  const probed = await runner.probe(discovered.routes);
1441
1470
  const { routes: reverifiedRoutes, reverified } = await reverifyRoutes(probed, verifyDeps);
1442
1471
  const { routes: routeResults, excused } = excuseChallengedEngineChecks(reverifiedRoutes);
@@ -1459,7 +1488,7 @@ async function browserAudit(ctx) {
1459
1488
  const linkReverifiedUrls = clearedLinks.map(
1460
1489
  (v) => `${v.url}: first check saw ${v.firstStatus ?? "no response"}, plain re-fetch ${v.fetchedStatus}`
1461
1490
  );
1462
- 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` : "");
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` : "");
1463
1492
  const status = summary.desktopOk && summary.mobileOk && summary.linksOk && summary.templateOk ? "pass" : "warn";
1464
1493
  return {
1465
1494
  audit: "browser",
@@ -1472,6 +1501,10 @@ async function browserAudit(ctx) {
1472
1501
  reverifiedUrls,
1473
1502
  excusedEngineChecks: excused,
1474
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 } : {},
1475
1508
  checkedAt: now.toISOString()
1476
1509
  }
1477
1510
  };
@@ -1512,6 +1545,19 @@ function defaultVerifyDeps() {
1512
1545
  } catch {
1513
1546
  return { status: null, title: null, metaDescription: null };
1514
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
+ }
1515
1561
  }
1516
1562
  };
1517
1563
  }
@@ -1923,6 +1969,13 @@ async function smokeAudit(ctx) {
1923
1969
  }
1924
1970
  }
1925
1971
  }
1972
+ try {
1973
+ await spawn("pnpm", ["exec", "svelte-kit", "sync"], {
1974
+ cwd: site.path,
1975
+ timeoutMs: 6e4
1976
+ });
1977
+ } catch {
1978
+ }
1926
1979
  const port = await findFreePort();
1927
1980
  let raw;
1928
1981
  try {
@@ -1960,7 +2013,7 @@ async function smokeAudit(ctx) {
1960
2013
 
1961
2014
  // src/audits/form-e2e.ts
1962
2015
  var CF_TEST_SITEKEY = "1x00000000000000000000AA";
1963
- var CONTACT_PATH = "/contact";
2016
+ var CONTACT_PATHS = ["/contact", "/"];
1964
2017
  var LIVE_ENV_VAR = "REDDOOR_FORM_E2E_LIVE";
1965
2018
  function liveRunnerEnabled(env = process.env) {
1966
2019
  return env[LIVE_ENV_VAR] === "1" || env[LIVE_ENV_VAR] === "true";
@@ -2020,6 +2073,19 @@ async function formE2eAudit(ctx) {
2020
2073
  var FILL_SETTLE_MS = 1200;
2021
2074
  var PAGE_TIMEOUT_MS2 = 3e4;
2022
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
+ }
2023
2089
  async function declaresTestModeForwarding(baseUrl) {
2024
2090
  try {
2025
2091
  const res = await fetch(new URL("/health", baseUrl), {
@@ -2046,11 +2112,8 @@ async function defaultFormRunner() {
2046
2112
  try {
2047
2113
  const ctx = await browser.newContext();
2048
2114
  const page = await ctx.newPage();
2049
- const url = new URL(CONTACT_PATH, baseUrl).toString();
2050
- const resp = await page.goto(url, { waitUntil: "domcontentloaded", timeout: PAGE_TIMEOUT_MS2 }).catch(() => null);
2051
- const form = page.locator("form").first();
2052
- const hasForm = !!resp && resp.ok() && await form.count().catch(() => 0) > 0 && await page.locator('input[name="email"], input[type="email"]').count().catch(() => 0) > 0;
2053
- if (!hasForm) return { formPresent: false };
2115
+ const found = await findFormPath(page, baseUrl);
2116
+ if (!found) return { formPresent: false };
2054
2117
  await page.fill('[name="name"]', "Reddoor Monitor").catch(() => {
2055
2118
  });
2056
2119
  await page.fill('[name="email"]', "monitor+e2e@reddoorla.com").catch(() => {
@@ -2159,4 +2222,4 @@ export {
2159
2222
  runAudits,
2160
2223
  runAuditsAcross
2161
2224
  };
2162
- //# sourceMappingURL=chunk-TDG5Z5OT.js.map
2225
+ //# sourceMappingURL=chunk-3U3IGVAQ.js.map