@reddoorla/maintenance 0.68.0 → 0.71.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 (136) hide show
  1. package/dist/airtable-2PJ4NSAV.js +9 -0
  2. package/dist/{announce-72R3LRNT.js → announce-3JE5FKW7.js} +6 -6
  3. package/dist/blux-FKZYMKLF.js +2205 -0
  4. package/dist/blux-FKZYMKLF.js.map +1 -0
  5. package/dist/{bump-deps-QNM3H4GI.js → bump-deps-K3JMLMBB.js} +3 -2
  6. package/dist/bump-deps-K3JMLMBB.js.map +1 -0
  7. package/dist/{chunk-RINXPU3H.js → chunk-2PWQ6MPG.js} +2 -2
  8. package/dist/{chunk-H6YZBXBQ.js → chunk-52TDNN7K.js} +37 -15
  9. package/dist/chunk-52TDNN7K.js.map +1 -0
  10. package/dist/{chunk-XWPZ46IN.js → chunk-5QC5KJHR.js} +2 -2
  11. package/dist/{chunk-O6G4HHMF.js → chunk-6J25IIAI.js} +162 -42
  12. package/dist/chunk-6J25IIAI.js.map +1 -0
  13. package/dist/chunk-7MJBTV2R.js +145 -0
  14. package/dist/chunk-7MJBTV2R.js.map +1 -0
  15. package/dist/{chunk-6JFOAPW4.js → chunk-AYM2EFSK.js} +38 -6
  16. package/dist/chunk-AYM2EFSK.js.map +1 -0
  17. package/dist/{chunk-GKSXZIMX.js → chunk-CMQMNFFJ.js} +77 -3
  18. package/dist/chunk-CMQMNFFJ.js.map +1 -0
  19. package/dist/{chunk-6MT5W6UF.js → chunk-F6OE6MGQ.js} +6 -3
  20. package/dist/chunk-F6OE6MGQ.js.map +1 -0
  21. package/dist/{chunk-OTBA7DON.js → chunk-J2SHWT2O.js} +1 -1
  22. package/dist/chunk-J2SHWT2O.js.map +1 -0
  23. package/dist/{chunk-XVR7UM6Z.js → chunk-JNXCDUYB.js} +4 -4
  24. package/dist/{chunk-2E2X5BCG.js → chunk-KOAGMTRI.js} +3 -3
  25. package/dist/chunk-KRDLHAKK.js +17 -0
  26. package/dist/chunk-KRDLHAKK.js.map +1 -0
  27. package/dist/{chunk-LYCKN7ZR.js → chunk-LPMQXS2N.js} +8 -4
  28. package/dist/chunk-LPMQXS2N.js.map +1 -0
  29. package/dist/{chunk-WCN54744.js → chunk-M7HOQ4W7.js} +388 -8
  30. package/dist/chunk-M7HOQ4W7.js.map +1 -0
  31. package/dist/{chunk-LCKKIJ22.js → chunk-MPRLMGJU.js} +11 -3
  32. package/dist/chunk-MPRLMGJU.js.map +1 -0
  33. package/dist/chunk-PKVZMFEB.js +315 -0
  34. package/dist/chunk-PKVZMFEB.js.map +1 -0
  35. package/dist/{chunk-A5KYRGQA.js → chunk-PSJQE6T6.js} +4 -4
  36. package/dist/chunk-SL24DCGC.js +41 -0
  37. package/dist/chunk-SL24DCGC.js.map +1 -0
  38. package/dist/{chunk-MAYHLXCS.js → chunk-VR246V3G.js} +25 -5
  39. package/dist/chunk-VR246V3G.js.map +1 -0
  40. package/dist/{chunk-45AUGDFR.js → chunk-WN4NEHE7.js} +2 -7
  41. package/dist/chunk-WN4NEHE7.js.map +1 -0
  42. package/dist/{chunk-YQMYQAJX.js → chunk-Y7LZHPDF.js} +2 -2
  43. package/dist/{chunk-C3PDS623.js → chunk-YWRQEDB3.js} +53 -3
  44. package/dist/chunk-YWRQEDB3.js.map +1 -0
  45. package/dist/cli/bin.js +63 -19
  46. package/dist/cli/bin.js.map +1 -1
  47. package/dist/cli/commands/audit.d.ts +1 -1
  48. package/dist/cli/commands/audit.js +13 -11
  49. package/dist/cli/commands/audit.js.map +1 -1
  50. package/dist/client/index.d.ts +84 -0
  51. package/dist/client/index.js +47 -0
  52. package/dist/client/index.js.map +1 -0
  53. package/dist/configs/playwright-a11y.js +1 -1
  54. package/dist/{convert-to-pnpm-XH7NYRVK.js → convert-to-pnpm-6F4RAFEJ.js} +3 -2
  55. package/dist/convert-to-pnpm-6F4RAFEJ.js.map +1 -0
  56. package/dist/{digest-J7TDG62Q.js → digest-GZCZYJKC.js} +11 -11
  57. package/dist/{ensure-site-2PF522VL.js → ensure-site-H5VMBPLF.js} +2 -2
  58. package/dist/forms/index.d.ts +2 -0
  59. package/dist/forms/index.js +7 -1
  60. package/dist/forms/index.js.map +1 -1
  61. package/dist/{github-signals-PP7GWYIY.js → github-signals-SQECQNST.js} +8 -8
  62. package/dist/health-endpoint-QXWM6GW2.js +54 -0
  63. package/dist/health-endpoint-QXWM6GW2.js.map +1 -0
  64. package/dist/index.d.ts +132 -17
  65. package/dist/index.js +213 -70
  66. package/dist/index.js.map +1 -1
  67. package/dist/{init-WLR35YGN.js → init-KQOIPLVT.js} +12 -8
  68. package/dist/init-KQOIPLVT.js.map +1 -0
  69. package/dist/{launch-2HPQXWII.js → launch-RX4VI7F2.js} +13 -13
  70. package/dist/{lighthouse-airtable-UBSXKHAQ.js → lighthouse-airtable-TTXULEGU.js} +3 -3
  71. package/dist/{onboard-X6KJ5Z2V.js → onboard-TFBL62J3.js} +2 -2
  72. package/dist/{orchestrate-PVCGBH4K.js → orchestrate-DYA4XAQJ.js} +6 -6
  73. package/dist/{preflight-KWVYJ2DU.js → preflight-S3GYME3Q.js} +8 -8
  74. package/dist/probe-DJAMLVTT.js +46 -0
  75. package/dist/probe-DJAMLVTT.js.map +1 -0
  76. package/dist/recipes/sync-configs.d.ts +1 -1
  77. package/dist/recipes/sync-configs.js +2 -2
  78. package/dist/{renovate-dispatch-LG4RJGDY.js → renovate-dispatch-MZPAWVBL.js} +2 -2
  79. package/dist/{report-ZBFMTT76.js → report-BLZ67QAQ.js} +12 -12
  80. package/dist/run-migration-LJNNZWIV.js +205 -0
  81. package/dist/run-migration-LJNNZWIV.js.map +1 -0
  82. package/dist/{self-updating-JTTFOJ7Q.js → self-updating-GSH427XP.js} +2 -2
  83. package/dist/{selftest-LFZPZRJP.js → selftest-KPTL6KC4.js} +11 -11
  84. package/dist/smoke-suite-GP3W57JP.js +54 -0
  85. package/dist/smoke-suite-GP3W57JP.js.map +1 -0
  86. package/dist/{svelte-codemods-KJLNH6FQ.js → svelte-codemods-GYZZX742.js} +3 -2
  87. package/dist/svelte-codemods-GYZZX742.js.map +1 -0
  88. package/dist/{sync-configs-6GBZLGRQ.js → sync-configs-CQNOTJ2B.js} +5 -4
  89. package/dist/sync-configs-CQNOTJ2B.js.map +1 -0
  90. package/dist/{types-Dg4mHvyf.d.ts → types-0jreMCF5.d.ts} +2 -2
  91. package/dist/{upgrade-TPTE26FG.js → upgrade-RTAJZD56.js} +3 -2
  92. package/dist/upgrade-RTAJZD56.js.map +1 -0
  93. package/dist/{websites-6YTT5ZTO.js → websites-SVI4KYSH.js} +8 -2
  94. package/dist/{write-audits-to-airtable-IWOO4FFH.js → write-audits-to-airtable-3XFNWMUE.js} +4 -4
  95. package/package.json +8 -1
  96. package/dist/airtable-WGQQ3KAD.js +0 -9
  97. package/dist/bump-deps-QNM3H4GI.js.map +0 -1
  98. package/dist/chunk-45AUGDFR.js.map +0 -1
  99. package/dist/chunk-6JFOAPW4.js.map +0 -1
  100. package/dist/chunk-6MT5W6UF.js.map +0 -1
  101. package/dist/chunk-C3PDS623.js.map +0 -1
  102. package/dist/chunk-GKSXZIMX.js.map +0 -1
  103. package/dist/chunk-H6YZBXBQ.js.map +0 -1
  104. package/dist/chunk-LCKKIJ22.js.map +0 -1
  105. package/dist/chunk-LYCKN7ZR.js.map +0 -1
  106. package/dist/chunk-MAYHLXCS.js.map +0 -1
  107. package/dist/chunk-O6G4HHMF.js.map +0 -1
  108. package/dist/chunk-OTBA7DON.js.map +0 -1
  109. package/dist/chunk-WCN54744.js.map +0 -1
  110. package/dist/convert-to-pnpm-XH7NYRVK.js.map +0 -1
  111. package/dist/init-WLR35YGN.js.map +0 -1
  112. package/dist/svelte-codemods-KJLNH6FQ.js.map +0 -1
  113. package/dist/sync-configs-6GBZLGRQ.js.map +0 -1
  114. package/dist/upgrade-TPTE26FG.js.map +0 -1
  115. /package/dist/{airtable-WGQQ3KAD.js.map → airtable-2PJ4NSAV.js.map} +0 -0
  116. /package/dist/{announce-72R3LRNT.js.map → announce-3JE5FKW7.js.map} +0 -0
  117. /package/dist/{chunk-RINXPU3H.js.map → chunk-2PWQ6MPG.js.map} +0 -0
  118. /package/dist/{chunk-XWPZ46IN.js.map → chunk-5QC5KJHR.js.map} +0 -0
  119. /package/dist/{chunk-XVR7UM6Z.js.map → chunk-JNXCDUYB.js.map} +0 -0
  120. /package/dist/{chunk-2E2X5BCG.js.map → chunk-KOAGMTRI.js.map} +0 -0
  121. /package/dist/{chunk-A5KYRGQA.js.map → chunk-PSJQE6T6.js.map} +0 -0
  122. /package/dist/{chunk-YQMYQAJX.js.map → chunk-Y7LZHPDF.js.map} +0 -0
  123. /package/dist/{digest-J7TDG62Q.js.map → digest-GZCZYJKC.js.map} +0 -0
  124. /package/dist/{ensure-site-2PF522VL.js.map → ensure-site-H5VMBPLF.js.map} +0 -0
  125. /package/dist/{github-signals-PP7GWYIY.js.map → github-signals-SQECQNST.js.map} +0 -0
  126. /package/dist/{launch-2HPQXWII.js.map → launch-RX4VI7F2.js.map} +0 -0
  127. /package/dist/{lighthouse-airtable-UBSXKHAQ.js.map → lighthouse-airtable-TTXULEGU.js.map} +0 -0
  128. /package/dist/{onboard-X6KJ5Z2V.js.map → onboard-TFBL62J3.js.map} +0 -0
  129. /package/dist/{orchestrate-PVCGBH4K.js.map → orchestrate-DYA4XAQJ.js.map} +0 -0
  130. /package/dist/{preflight-KWVYJ2DU.js.map → preflight-S3GYME3Q.js.map} +0 -0
  131. /package/dist/{renovate-dispatch-LG4RJGDY.js.map → renovate-dispatch-MZPAWVBL.js.map} +0 -0
  132. /package/dist/{report-ZBFMTT76.js.map → report-BLZ67QAQ.js.map} +0 -0
  133. /package/dist/{self-updating-JTTFOJ7Q.js.map → self-updating-GSH427XP.js.map} +0 -0
  134. /package/dist/{selftest-LFZPZRJP.js.map → selftest-KPTL6KC4.js.map} +0 -0
  135. /package/dist/{websites-6YTT5ZTO.js.map → websites-SVI4KYSH.js.map} +0 -0
  136. /package/dist/{write-audits-to-airtable-IWOO4FFH.js.map → write-audits-to-airtable-3XFNWMUE.js.map} +0 -0
@@ -0,0 +1,315 @@
1
+ import {
2
+ PRETTIER_FLAG_NOTE,
3
+ formatWithPrettier
4
+ } from "./chunk-KRDLHAKK.js";
5
+ import {
6
+ withRecipe
7
+ } from "./chunk-XKOZUVK2.js";
8
+ import {
9
+ defaultSpawn
10
+ } from "./chunk-HVOOCK6L.js";
11
+
12
+ // src/recipes/smoke-suite/index.ts
13
+ import { access, mkdir, readdir, readFile, writeFile } from "fs/promises";
14
+ import { dirname, join } from "path";
15
+
16
+ // src/recipes/smoke-suite/template.ts
17
+ var SMOKE_ROUTES_RELATIVE = "tests/smoke/routes.ts";
18
+ var SMOKE_ROUTES_TEMPLATE = `// Committed per-site smoke manifest. \`tests/smoke/pages.spec.ts\` iterates this
19
+ // list, asserting each route returns its expected status and paints a hydration
20
+ // marker with no console errors. This ships the SAFE DEFAULT every reddoor-starter
21
+ // clone inherits; each site's figma-slices build grows the list as real routes
22
+ // land (add \`{ path, name, hydrationMarker }\` entries).
23
+ //
24
+ // NOTE on the default \`/\` entry: it expects 200, which holds once the clone is
25
+ // wired to a real Prismic repo (getByUID("page","home") resolves). On the bare
26
+ // placeholder starter, \`/\` returns 404 (the Prismic lookup throws \u2192 error(404)),
27
+ // so the \`/\` case only goes green after Prismic is wired \u2014 by design, since the
28
+ // gate is about real site health. The hydration marker \`footer\` is the shared
29
+ // layout footer, present on every page including the error page.
30
+
31
+ export type SmokeRoute = {
32
+ /** Route path to visit, e.g. "/" or "/about". */
33
+ path: string;
34
+ /** Human-readable label used in the test title. */
35
+ name: string;
36
+ /** CSS selector asserted visible after load (hydration proof). Default: skip. */
37
+ hydrationMarker?: string;
38
+ /** Expected HTTP status. Default: 200. */
39
+ expectStatus?: number;
40
+ };
41
+
42
+ export const smokeRoutes: SmokeRoute[] = [
43
+ { path: "/", name: "home", hydrationMarker: "footer" },
44
+ ];
45
+ `;
46
+ var SMOKE_SPEC_RELATIVE = "tests/smoke/pages.spec.ts";
47
+ var SMOKE_SPEC_TEMPLATE = `import { test, expect, type Page, type ConsoleMessage } from "@playwright/test";
48
+ import { smokeRoutes } from "./routes";
49
+
50
+ // Console messages we don't care about. Add patterns here only after seeing them
51
+ // in CI and confirming they aren't actionable. Patterns are matched against both
52
+ // the message text and the offending resource URL \u2014 Chromium's "Failed to load
53
+ // resource" text omits the URL, so URL matching catches third-party network noise.
54
+ const ALLOWED_CONSOLE_PATTERNS: RegExp[] = [
55
+ // Vimeo iframe embeds + their CDN telemetry endpoints occasionally 403 from
56
+ // cloud IPs due to bot detection.
57
+ /vimeo/i,
58
+ // Turnstile (Cloudflare) telemetry occasionally surfaces in console.
59
+ /turnstile|challenges\\.cloudflare/i,
60
+ ];
61
+
62
+ function attachConsoleWatcher(page: Page, extraAllowed: RegExp[] = []) {
63
+ const errors: string[] = [];
64
+ const allowed = [...ALLOWED_CONSOLE_PATTERNS, ...extraAllowed];
65
+ const isAllowed = (s: string) => !!s && allowed.some((re) => re.test(s));
66
+
67
+ page.on("console", (msg: ConsoleMessage) => {
68
+ if (msg.type() !== "error") return;
69
+ const text = msg.text();
70
+ const url = msg.location()?.url ?? "";
71
+ if (isAllowed(text) || isAllowed(url)) return;
72
+ errors.push(\`[console.error] \${text}\${url ? \` (\${url})\` : ""}\`);
73
+ });
74
+
75
+ page.on("pageerror", (err) => {
76
+ if (isAllowed(err.message)) return;
77
+ errors.push(\`[pageerror] \${err.message}\`);
78
+ });
79
+
80
+ return errors;
81
+ }
82
+
83
+ for (const route of smokeRoutes) {
84
+ test(\`\${route.path} (\${route.name}) loads with no console errors\`, async ({
85
+ page,
86
+ }) => {
87
+ const errors = attachConsoleWatcher(page);
88
+ const response = await page.goto(route.path, {
89
+ waitUntil: "domcontentloaded",
90
+ });
91
+ expect(response?.status(), \`HTTP status for \${route.path}\`).toBe(
92
+ route.expectStatus ?? 200,
93
+ );
94
+ if (route.hydrationMarker) {
95
+ await expect(
96
+ page.locator(route.hydrationMarker),
97
+ \`hydration marker "\${route.hydrationMarker}" on \${route.path}\`,
98
+ ).toBeVisible();
99
+ }
100
+ expect(errors, \`console errors on \${route.path}\`).toEqual([]);
101
+ });
102
+ }
103
+
104
+ test("404 page renders the custom error component", async ({ page }) => {
105
+ // The browser logs a top-level "Failed to load resource: 404" for the page
106
+ // itself \u2014 expected on a 404 route, not a bug. Allow it here.
107
+ const errors = attachConsoleWatcher(page, [/Failed to load resource.*404/i]);
108
+ const response = await page.goto("/this-uid-does-not-exist", {
109
+ waitUntil: "domcontentloaded",
110
+ });
111
+ expect(response?.status()).toBe(404);
112
+ // src/routes/+error.svelte renders \`<h1>{page.status}</h1>\` \u2192 "404".
113
+ await expect(page.getByText("404", { exact: false }).first()).toBeVisible();
114
+ expect(errors).toEqual([]);
115
+ });
116
+ `;
117
+ var PLAYWRIGHT_CONFIG_RELATIVE = "playwright.config.ts";
118
+ var PLAYWRIGHT_CONFIG_TEMPLATE = `import { defineConfig } from "@playwright/test";
119
+ import base from "@reddoorla/maintenance/configs/playwright-a11y";
120
+
121
+ // Emulate reduced motion in tests: instant scrollIntoView (no long animated
122
+ // smooth-scroll that flakes Playwright's actionability checks under parallel
123
+ // load) and view transitions fall back to instant. Pairs with the
124
+ // prefers-reduced-motion gate on scroll-behavior in src/app.css.
125
+ //
126
+ // R1.1 (health-gate): the central \`smoke\` audit (reddoor-maintenance
127
+ // src/audits/smoke.ts) allocates a free port and passes it as
128
+ // REDDOOR_SMOKE_PORT so a zombie vite already squatting the default 5173 can't
129
+ // silently hijack the run and green a stale build. When it's set, bind vite to
130
+ // exactly that port with --strictPort (forwarded through \`npm run vite:dev\` so
131
+ // it stays portable across pnpm/npm) and aim Playwright's baseURL + readiness
132
+ // probe at it. Unset (local \`pnpm test:smoke\`) \u2192 the shared base's fixed 5173.
133
+ const smokePort = process.env.REDDOOR_SMOKE_PORT;
134
+
135
+ export default defineConfig({
136
+ ...base,
137
+ use: {
138
+ ...base.use,
139
+ reducedMotion: "reduce",
140
+ ...(smokePort ? { baseURL: \`http://localhost:\${smokePort}\` } : {}),
141
+ },
142
+ ...(smokePort
143
+ ? {
144
+ webServer: {
145
+ command: \`npm run vite:dev -- --port \${smokePort} --strictPort\`,
146
+ url: \`http://localhost:\${smokePort}/dev/a11y-fixtures\`,
147
+ reuseExistingServer: !process.env.CI,
148
+ timeout: 120_000,
149
+ },
150
+ }
151
+ : {}),
152
+ });
153
+ `;
154
+ var PLAYWRIGHT_CONFIG_PRE_R11 = `import { defineConfig } from "@playwright/test";
155
+ import base from "@reddoorla/maintenance/configs/playwright-a11y";
156
+
157
+ // Emulate reduced motion in tests: instant scrollIntoView (no long animated
158
+ // smooth-scroll that flakes Playwright's actionability checks under parallel
159
+ // load) and view transitions fall back to instant. Pairs with the
160
+ // prefers-reduced-motion gate on scroll-behavior in src/app.css.
161
+ export default defineConfig({
162
+ ...base,
163
+ use: { ...base.use, reducedMotion: "reduce" },
164
+ });
165
+ `;
166
+
167
+ // src/recipes/smoke-suite/index.ts
168
+ async function fileExists(path) {
169
+ try {
170
+ await access(path);
171
+ return true;
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
176
+ async function readIfExists(path) {
177
+ try {
178
+ return await readFile(path, "utf-8");
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+ var FOOTER_MARKER_SENTENCE = "The hydration marker `footer` is the shared\n// layout footer, present on every page including the error page.";
184
+ function fallbackMarkerSentence(marker) {
185
+ return `The hydration marker \`${marker}\` is a
186
+ // fallback: no <footer> element exists in this site's Svelte source. Add a
187
+ // semantic <footer> landmark and point the marker back at it when possible.`;
188
+ }
189
+ async function detectHydrationMarker(cwd) {
190
+ let entries;
191
+ try {
192
+ entries = await readdir(join(cwd, "src"), { recursive: true });
193
+ } catch {
194
+ return "footer";
195
+ }
196
+ const svelteFiles = entries.filter((p) => p.endsWith(".svelte"));
197
+ if (svelteFiles.length === 0) return "footer";
198
+ let sawMain = false;
199
+ for (const rel of svelteFiles) {
200
+ const text = await readIfExists(join(cwd, "src", rel)) ?? "";
201
+ if (/<footer[\s>/]/.test(text)) return "footer";
202
+ if (/<main[\s>/]/.test(text)) sawMain = true;
203
+ }
204
+ return sawMain ? "main" : "body";
205
+ }
206
+ async function smokeSuite(site, deps = { spawn: defaultSpawn }) {
207
+ const pkgPath = join(site.path, "package.json");
208
+ return withRecipe({
209
+ name: "smoke-suite",
210
+ site,
211
+ plan: async () => {
212
+ const raw = await readIfExists(pkgPath);
213
+ if (raw === null) {
214
+ return { kind: "noop", notes: "no package.json (not a node project)" };
215
+ }
216
+ let pkg;
217
+ try {
218
+ pkg = JSON.parse(raw);
219
+ } catch {
220
+ return { kind: "noop", notes: "unparseable package.json \u2014 skipped" };
221
+ }
222
+ return { kind: "apply", plan: { pkgPath, pkg } };
223
+ },
224
+ apply: async (planned, { commit, cwd }) => {
225
+ const notes = [];
226
+ const written = [];
227
+ let routesTemplate = SMOKE_ROUTES_TEMPLATE;
228
+ if (!await fileExists(join(cwd, SMOKE_ROUTES_RELATIVE))) {
229
+ const marker = await detectHydrationMarker(cwd);
230
+ if (marker !== "footer") {
231
+ routesTemplate = SMOKE_ROUTES_TEMPLATE.replace(
232
+ 'hydrationMarker: "footer"',
233
+ `hydrationMarker: "${marker}"`
234
+ ).replace(FOOTER_MARKER_SENTENCE, fallbackMarkerSentence(marker));
235
+ notes.push(
236
+ `no <footer> element in src/**/*.svelte \u2014 hydration marker set to "${marker}" (add a semantic <footer> landmark to restore the default)`
237
+ );
238
+ }
239
+ }
240
+ const specFiles = [
241
+ [SMOKE_ROUTES_RELATIVE, routesTemplate],
242
+ [SMOKE_SPEC_RELATIVE, SMOKE_SPEC_TEMPLATE]
243
+ ];
244
+ for (const [rel, tmpl] of specFiles) {
245
+ const target = join(cwd, rel);
246
+ if (!await fileExists(target)) {
247
+ await mkdir(dirname(target), { recursive: true });
248
+ await writeFile(target, tmpl, "utf-8");
249
+ written.push(rel);
250
+ }
251
+ }
252
+ const cfgPath = join(cwd, PLAYWRIGHT_CONFIG_RELATIVE);
253
+ const existingCfg = await readIfExists(cfgPath);
254
+ if (existingCfg === null) {
255
+ await writeFile(cfgPath, PLAYWRIGHT_CONFIG_TEMPLATE, "utf-8");
256
+ written.push(PLAYWRIGHT_CONFIG_RELATIVE);
257
+ } else if (existingCfg.includes("REDDOOR_SMOKE_PORT")) {
258
+ } else if (existingCfg.trim() === PLAYWRIGHT_CONFIG_PRE_R11.trim()) {
259
+ await writeFile(cfgPath, PLAYWRIGHT_CONFIG_TEMPLATE, "utf-8");
260
+ written.push(PLAYWRIGHT_CONFIG_RELATIVE);
261
+ } else {
262
+ notes.push(
263
+ "playwright.config.ts exists without REDDOOR_SMOKE_PORT \u2014 add the R1.1 port block manually"
264
+ );
265
+ }
266
+ const pkg = planned.pkg;
267
+ let pkgChanged = false;
268
+ pkg.scripts ??= {};
269
+ if (!pkg.scripts["test:smoke"]) {
270
+ pkg.scripts["test:smoke"] = "playwright install chromium && playwright test";
271
+ pkgChanged = true;
272
+ }
273
+ const hasVitest = !!(pkg.devDependencies?.vitest || pkg.dependencies?.vitest);
274
+ const existingTest = pkg.scripts["test"];
275
+ if (hasVitest || existingTest !== void 0) {
276
+ if (!pkg.scripts["test:unit"]) {
277
+ pkg.scripts["test:unit"] = existingTest ?? "vitest run";
278
+ pkgChanged = true;
279
+ }
280
+ if (pkg.scripts["test"] === void 0) {
281
+ pkg.scripts["test"] = "vitest run";
282
+ pkgChanged = true;
283
+ }
284
+ }
285
+ let depsChanged = false;
286
+ const hasPlaywright = !!pkg.devDependencies?.["@playwright/test"] || !!pkg.dependencies?.["@playwright/test"];
287
+ if (!hasPlaywright) {
288
+ pkg.devDependencies ??= {};
289
+ pkg.devDependencies["@playwright/test"] = "^1.60.0";
290
+ depsChanged = true;
291
+ pkgChanged = true;
292
+ }
293
+ if (pkgChanged) {
294
+ await writeFile(planned.pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8");
295
+ written.push("package.json");
296
+ }
297
+ if (depsChanged) {
298
+ const res = await deps.spawn("pnpm", ["install"], { cwd, streaming: true });
299
+ if (res.code !== 0) {
300
+ return { kind: "failed", notes: `pnpm install failed (exit ${res.code})` };
301
+ }
302
+ }
303
+ if (!await formatWithPrettier(deps.spawn, cwd, written)) {
304
+ notes.push(PRETTIER_FLAG_NOTE);
305
+ }
306
+ await commit("feat: add smoke suite (test:smoke + playwright config + /health smoke routes)");
307
+ return notes.length > 0 ? { kind: "ok", notes: notes.join("; ") } : { kind: "ok" };
308
+ }
309
+ });
310
+ }
311
+
312
+ export {
313
+ smokeSuite
314
+ };
315
+ //# sourceMappingURL=chunk-PKVZMFEB.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/recipes/smoke-suite/index.ts","../src/recipes/smoke-suite/template.ts"],"sourcesContent":["import { access, mkdir, readdir, readFile, writeFile } from \"node:fs/promises\";\nimport { dirname, join } from \"node:path\";\nimport type { RecipeResult, Site } from \"../../types.js\";\nimport { withRecipe } from \"../_with-recipe.js\";\nimport { defaultSpawn, type SpawnFn } from \"../../audits/util/spawn.js\";\nimport { formatWithPrettier, PRETTIER_FLAG_NOTE } from \"../_prettier.js\";\nimport {\n SMOKE_ROUTES_RELATIVE,\n SMOKE_ROUTES_TEMPLATE,\n SMOKE_SPEC_RELATIVE,\n SMOKE_SPEC_TEMPLATE,\n PLAYWRIGHT_CONFIG_RELATIVE,\n PLAYWRIGHT_CONFIG_TEMPLATE,\n PLAYWRIGHT_CONFIG_PRE_R11,\n} from \"./template.js\";\n\nexport type SmokeSuiteDeps = { spawn: SpawnFn };\n\ntype PackageJson = {\n scripts?: Record<string, string>;\n devDependencies?: Record<string, string>;\n dependencies?: Record<string, string>;\n};\n\nasync function fileExists(path: string): Promise<boolean> {\n try {\n await access(path);\n return true;\n } catch {\n return false;\n }\n}\n\nasync function readIfExists(path: string): Promise<string | null> {\n try {\n return await readFile(path, \"utf-8\");\n } catch {\n return null;\n }\n}\n\n/** The template sentence explaining the default `footer` marker — swapped for a\n * fallback explanation when the marker deviates. Must match template.ts. */\nconst FOOTER_MARKER_SENTENCE =\n \"The hydration marker `footer` is the shared\\n// layout footer, present on every page including the error page.\";\n\nfunction fallbackMarkerSentence(marker: string): string {\n return (\n `The hydration marker \\`${marker}\\` is a\\n` +\n \"// fallback: no <footer> element exists in this site's Svelte source. Add a\\n\" +\n \"// semantic <footer> landmark and point the marker back at it when possible.\"\n );\n}\n\n/** The starter's default `footer` hydration marker needs the element to exist,\n * or EVERY route check false-fails (la-homelessness-initiative red'd the first\n * fleet-smoke run exactly this way). Deviate only on positive evidence of a\n * bespoke build: svelte files exist under src/ and none renders a literal\n * lowercase `<footer` element (a capital-F `<Footer` component tag proves\n * nothing — the element the browser paints lives inside that component). No\n * svelte files at all → no signal → keep the starter default. */\nasync function detectHydrationMarker(cwd: string): Promise<\"footer\" | \"main\" | \"body\"> {\n let entries: string[];\n try {\n entries = (await readdir(join(cwd, \"src\"), { recursive: true })) as string[];\n } catch {\n return \"footer\";\n }\n const svelteFiles = entries.filter((p) => p.endsWith(\".svelte\"));\n if (svelteFiles.length === 0) return \"footer\";\n let sawMain = false;\n for (const rel of svelteFiles) {\n const text = (await readIfExists(join(cwd, \"src\", rel))) ?? \"\";\n if (/<footer[\\s>/]/.test(text)) return \"footer\";\n if (/<main[\\s>/]/.test(text)) sawMain = true;\n }\n return sawMain ? \"main\" : \"body\";\n}\n\n/**\n * Adds the smoke suite to a site: the `tests/smoke/*` specs, a `test:smoke` /\n * `test:unit` script split, `@playwright/test`, and a `playwright.config.ts`\n * that honors `REDDOOR_SMOKE_PORT` (R1.1). Conservative + partial-apply — every\n * file is noop-if-exists, every `package.json` script/dep is add-if-absent, and\n * an existing config that isn't a recognizable shared-base shape is left\n * untouched and flagged for a manual R1.1 patch (never a destructive write).\n * A site with no `package.json` noops (not a node project). Branch+commit per site.\n */\nexport async function smokeSuite(\n site: Site,\n deps: SmokeSuiteDeps = { spawn: defaultSpawn },\n): Promise<RecipeResult> {\n const pkgPath = join(site.path, \"package.json\");\n return withRecipe<{ pkgPath: string; pkg: PackageJson }>({\n name: \"smoke-suite\",\n site,\n plan: async () => {\n // Parse in the read-only plan phase so a missing OR unparseable\n // package.json noops cleanly (spec: structurally unrecognizable → noop),\n // BEFORE apply writes any file. A bad JSON that only threw in apply would\n // leave the (safely-restored) branch as a `failed`, not the graceful noop.\n const raw = await readIfExists(pkgPath);\n if (raw === null) {\n return { kind: \"noop\", notes: \"no package.json (not a node project)\" };\n }\n let pkg: PackageJson;\n try {\n pkg = JSON.parse(raw) as PackageJson;\n } catch {\n return { kind: \"noop\", notes: \"unparseable package.json — skipped\" };\n }\n return { kind: \"apply\", plan: { pkgPath, pkg } };\n },\n apply: async (planned, { commit, cwd }) => {\n const notes: string[] = [];\n // Relative paths this run actually wrote/changed — prettier-formatted to the\n // site's own config before committing (never operator files we left alone).\n const written: string[] = [];\n\n // 1. Spec files — write if absent (never clobber operator edits). The\n // routes manifest ships the starter-verbatim `footer` marker only when\n // the site actually renders one; bespoke builds fall back to `main`,\n // then `body`, so the suite proves paint instead of false-failing.\n let routesTemplate = SMOKE_ROUTES_TEMPLATE;\n if (!(await fileExists(join(cwd, SMOKE_ROUTES_RELATIVE)))) {\n const marker = await detectHydrationMarker(cwd);\n if (marker !== \"footer\") {\n routesTemplate = SMOKE_ROUTES_TEMPLATE.replace(\n 'hydrationMarker: \"footer\"',\n `hydrationMarker: \"${marker}\"`,\n ).replace(FOOTER_MARKER_SENTENCE, fallbackMarkerSentence(marker));\n notes.push(\n `no <footer> element in src/**/*.svelte — hydration marker set to \"${marker}\" ` +\n \"(add a semantic <footer> landmark to restore the default)\",\n );\n }\n }\n const specFiles: Array<[string, string]> = [\n [SMOKE_ROUTES_RELATIVE, routesTemplate],\n [SMOKE_SPEC_RELATIVE, SMOKE_SPEC_TEMPLATE],\n ];\n for (const [rel, tmpl] of specFiles) {\n const target = join(cwd, rel);\n if (!(await fileExists(target))) {\n await mkdir(dirname(target), { recursive: true });\n await writeFile(target, tmpl, \"utf-8\");\n written.push(rel);\n }\n }\n\n // 2. playwright.config.ts — four cases, never an in-place edit: absent →\n // write R1.1; already has REDDOOR_SMOKE_PORT → leave; exact pre-R1.1\n // shared-base → safe-replace wholesale; anything else → flag for manual.\n const cfgPath = join(cwd, PLAYWRIGHT_CONFIG_RELATIVE);\n const existingCfg = await readIfExists(cfgPath);\n if (existingCfg === null) {\n await writeFile(cfgPath, PLAYWRIGHT_CONFIG_TEMPLATE, \"utf-8\");\n written.push(PLAYWRIGHT_CONFIG_RELATIVE);\n } else if (existingCfg.includes(\"REDDOOR_SMOKE_PORT\")) {\n // Already R1.1-aware; leave it.\n } else if (existingCfg.trim() === PLAYWRIGHT_CONFIG_PRE_R11.trim()) {\n await writeFile(cfgPath, PLAYWRIGHT_CONFIG_TEMPLATE, \"utf-8\");\n written.push(PLAYWRIGHT_CONFIG_RELATIVE);\n } else {\n notes.push(\n \"playwright.config.ts exists without REDDOOR_SMOKE_PORT — add the R1.1 port block manually\",\n );\n }\n\n // 3. package.json — add-if-absent scripts + @playwright/test. Only rewrite\n // when something changed, so a re-run of a fully-adopted site noops\n // instead of churning the file. (Parsed in plan; see above.)\n const pkg = planned.pkg;\n let pkgChanged = false;\n pkg.scripts ??= {};\n if (!pkg.scripts[\"test:smoke\"]) {\n pkg.scripts[\"test:smoke\"] = \"playwright install chromium && playwright test\";\n pkgChanged = true;\n }\n // Unit-test scripts (`test:unit` / `test` → \"vitest run\") only when the site\n // actually runs vitest — either it depends on vitest, or it already defines a\n // `test` script. Adding `test: \"vitest run\"` to a site WITHOUT vitest makes\n // the shared CI's \"run the test script if present\" step fail with\n // `vitest: not found` (the whole fleet-smoke batch red'd on exactly this).\n const hasVitest = !!(pkg.devDependencies?.vitest || pkg.dependencies?.vitest);\n const existingTest = pkg.scripts[\"test\"];\n if (hasVitest || existingTest !== undefined) {\n if (!pkg.scripts[\"test:unit\"]) {\n pkg.scripts[\"test:unit\"] = existingTest ?? \"vitest run\";\n pkgChanged = true;\n }\n if (pkg.scripts[\"test\"] === undefined) {\n pkg.scripts[\"test\"] = \"vitest run\";\n pkgChanged = true;\n }\n }\n let depsChanged = false;\n const hasPlaywright =\n !!pkg.devDependencies?.[\"@playwright/test\"] || !!pkg.dependencies?.[\"@playwright/test\"];\n if (!hasPlaywright) {\n pkg.devDependencies ??= {};\n pkg.devDependencies[\"@playwright/test\"] = \"^1.60.0\";\n depsChanged = true;\n pkgChanged = true;\n }\n if (pkgChanged) {\n await writeFile(planned.pkgPath, JSON.stringify(pkg, null, 2) + \"\\n\", \"utf-8\");\n written.push(\"package.json\");\n }\n\n // 4. Install only when a dep was added (streaming, so the operator sees\n // progress; matches bump-deps). A failed install aborts the recipe.\n if (depsChanged) {\n const res = await deps.spawn(\"pnpm\", [\"install\"], { cwd, streaming: true });\n if (res.code !== 0) {\n return { kind: \"failed\", notes: `pnpm install failed (exit ${res.code})` };\n }\n }\n\n // 5. Format everything this run wrote to the site's own prettier config, so\n // fleet CI's format check stays green across heterogeneous configs\n // (quotes/tabs/printWidth vary). Best-effort — a site without prettier\n // just commits unformatted with a flag note.\n if (!(await formatWithPrettier(deps.spawn, cwd, written))) {\n notes.push(PRETTIER_FLAG_NOTE);\n }\n\n // 6. Commit. If nothing was written/changed the commit stages nothing and\n // withRecipe reports noop (the flag note, if any, is still surfaced).\n await commit(\"feat: add smoke suite (test:smoke + playwright config + /health smoke routes)\");\n return notes.length > 0 ? { kind: \"ok\", notes: notes.join(\"; \") } : { kind: \"ok\" };\n },\n });\n}\n","// GENERATED verbatim from the reddoor-starter working tree (tests/smoke/*,\n// playwright.config.ts). Byte-fidelity matters — the smoke spec + config are\n// written into each site unchanged. Regenerate with scratchpad/gen-smoke-template.mjs\n// if the starter's smoke suite changes. Do NOT hand-edit the string bodies.\n\nexport const SMOKE_ROUTES_RELATIVE = \"tests/smoke/routes.ts\";\nexport const SMOKE_ROUTES_TEMPLATE = `// Committed per-site smoke manifest. \\`tests/smoke/pages.spec.ts\\` iterates this\n// list, asserting each route returns its expected status and paints a hydration\n// marker with no console errors. This ships the SAFE DEFAULT every reddoor-starter\n// clone inherits; each site's figma-slices build grows the list as real routes\n// land (add \\`{ path, name, hydrationMarker }\\` entries).\n//\n// NOTE on the default \\`/\\` entry: it expects 200, which holds once the clone is\n// wired to a real Prismic repo (getByUID(\"page\",\"home\") resolves). On the bare\n// placeholder starter, \\`/\\` returns 404 (the Prismic lookup throws → error(404)),\n// so the \\`/\\` case only goes green after Prismic is wired — by design, since the\n// gate is about real site health. The hydration marker \\`footer\\` is the shared\n// layout footer, present on every page including the error page.\n\nexport type SmokeRoute = {\n /** Route path to visit, e.g. \"/\" or \"/about\". */\n path: string;\n /** Human-readable label used in the test title. */\n name: string;\n /** CSS selector asserted visible after load (hydration proof). Default: skip. */\n hydrationMarker?: string;\n /** Expected HTTP status. Default: 200. */\n expectStatus?: number;\n};\n\nexport const smokeRoutes: SmokeRoute[] = [\n { path: \"/\", name: \"home\", hydrationMarker: \"footer\" },\n];\n`;\n\nexport const SMOKE_SPEC_RELATIVE = \"tests/smoke/pages.spec.ts\";\nexport const SMOKE_SPEC_TEMPLATE = `import { test, expect, type Page, type ConsoleMessage } from \"@playwright/test\";\nimport { smokeRoutes } from \"./routes\";\n\n// Console messages we don't care about. Add patterns here only after seeing them\n// in CI and confirming they aren't actionable. Patterns are matched against both\n// the message text and the offending resource URL — Chromium's \"Failed to load\n// resource\" text omits the URL, so URL matching catches third-party network noise.\nconst ALLOWED_CONSOLE_PATTERNS: RegExp[] = [\n // Vimeo iframe embeds + their CDN telemetry endpoints occasionally 403 from\n // cloud IPs due to bot detection.\n /vimeo/i,\n // Turnstile (Cloudflare) telemetry occasionally surfaces in console.\n /turnstile|challenges\\\\.cloudflare/i,\n];\n\nfunction attachConsoleWatcher(page: Page, extraAllowed: RegExp[] = []) {\n const errors: string[] = [];\n const allowed = [...ALLOWED_CONSOLE_PATTERNS, ...extraAllowed];\n const isAllowed = (s: string) => !!s && allowed.some((re) => re.test(s));\n\n page.on(\"console\", (msg: ConsoleMessage) => {\n if (msg.type() !== \"error\") return;\n const text = msg.text();\n const url = msg.location()?.url ?? \"\";\n if (isAllowed(text) || isAllowed(url)) return;\n errors.push(\\`[console.error] \\${text}\\${url ? \\` (\\${url})\\` : \"\"}\\`);\n });\n\n page.on(\"pageerror\", (err) => {\n if (isAllowed(err.message)) return;\n errors.push(\\`[pageerror] \\${err.message}\\`);\n });\n\n return errors;\n}\n\nfor (const route of smokeRoutes) {\n test(\\`\\${route.path} (\\${route.name}) loads with no console errors\\`, async ({\n page,\n }) => {\n const errors = attachConsoleWatcher(page);\n const response = await page.goto(route.path, {\n waitUntil: \"domcontentloaded\",\n });\n expect(response?.status(), \\`HTTP status for \\${route.path}\\`).toBe(\n route.expectStatus ?? 200,\n );\n if (route.hydrationMarker) {\n await expect(\n page.locator(route.hydrationMarker),\n \\`hydration marker \"\\${route.hydrationMarker}\" on \\${route.path}\\`,\n ).toBeVisible();\n }\n expect(errors, \\`console errors on \\${route.path}\\`).toEqual([]);\n });\n}\n\ntest(\"404 page renders the custom error component\", async ({ page }) => {\n // The browser logs a top-level \"Failed to load resource: 404\" for the page\n // itself — expected on a 404 route, not a bug. Allow it here.\n const errors = attachConsoleWatcher(page, [/Failed to load resource.*404/i]);\n const response = await page.goto(\"/this-uid-does-not-exist\", {\n waitUntil: \"domcontentloaded\",\n });\n expect(response?.status()).toBe(404);\n // src/routes/+error.svelte renders \\`<h1>{page.status}</h1>\\` → \"404\".\n await expect(page.getByText(\"404\", { exact: false }).first()).toBeVisible();\n expect(errors).toEqual([]);\n});\n`;\n\nexport const PLAYWRIGHT_CONFIG_RELATIVE = \"playwright.config.ts\";\n\n/** The R1.1 config: reads REDDOOR_SMOKE_PORT and binds --strictPort. Written\n * when a site has no playwright.config.ts. */\nexport const PLAYWRIGHT_CONFIG_TEMPLATE = `import { defineConfig } from \"@playwright/test\";\nimport base from \"@reddoorla/maintenance/configs/playwright-a11y\";\n\n// Emulate reduced motion in tests: instant scrollIntoView (no long animated\n// smooth-scroll that flakes Playwright's actionability checks under parallel\n// load) and view transitions fall back to instant. Pairs with the\n// prefers-reduced-motion gate on scroll-behavior in src/app.css.\n//\n// R1.1 (health-gate): the central \\`smoke\\` audit (reddoor-maintenance\n// src/audits/smoke.ts) allocates a free port and passes it as\n// REDDOOR_SMOKE_PORT so a zombie vite already squatting the default 5173 can't\n// silently hijack the run and green a stale build. When it's set, bind vite to\n// exactly that port with --strictPort (forwarded through \\`npm run vite:dev\\` so\n// it stays portable across pnpm/npm) and aim Playwright's baseURL + readiness\n// probe at it. Unset (local \\`pnpm test:smoke\\`) → the shared base's fixed 5173.\nconst smokePort = process.env.REDDOOR_SMOKE_PORT;\n\nexport default defineConfig({\n ...base,\n use: {\n ...base.use,\n reducedMotion: \"reduce\",\n ...(smokePort ? { baseURL: \\`http://localhost:\\${smokePort}\\` } : {}),\n },\n ...(smokePort\n ? {\n webServer: {\n command: \\`npm run vite:dev -- --port \\${smokePort} --strictPort\\`,\n url: \\`http://localhost:\\${smokePort}/dev/a11y-fixtures\\`,\n reuseExistingServer: !process.env.CI,\n timeout: 120_000,\n },\n }\n : {}),\n});\n`;\n\n/** The pre-R1.1 shared-base config (no port block). Used ONLY to recognize a\n * site that adopted the shared base before R1.1, so it can be safely replaced\n * wholesale with PLAYWRIGHT_CONFIG_TEMPLATE. Any other existing config is left\n * untouched and flagged for manual patch. */\nexport const PLAYWRIGHT_CONFIG_PRE_R11 = `import { defineConfig } from \"@playwright/test\";\nimport base from \"@reddoorla/maintenance/configs/playwright-a11y\";\n\n// Emulate reduced motion in tests: instant scrollIntoView (no long animated\n// smooth-scroll that flakes Playwright's actionability checks under parallel\n// load) and view transitions fall back to instant. Pairs with the\n// prefers-reduced-motion gate on scroll-behavior in src/app.css.\nexport default defineConfig({\n ...base,\n use: { ...base.use, reducedMotion: \"reduce\" },\n});\n`;\n"],"mappings":"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","names":[]}
@@ -1,14 +1,14 @@
1
1
  import {
2
2
  fetchGaUsers,
3
3
  fetchSearch
4
- } from "./chunk-O6G4HHMF.js";
4
+ } from "./chunk-6J25IIAI.js";
5
5
  import {
6
6
  announcementSiteExtras,
7
7
  resolveCopy
8
- } from "./chunk-6JFOAPW4.js";
8
+ } from "./chunk-AYM2EFSK.js";
9
9
  import {
10
10
  siteSlug
11
- } from "./chunk-C3PDS623.js";
11
+ } from "./chunk-YWRQEDB3.js";
12
12
 
13
13
  // src/reports/report-data.ts
14
14
  var PREVIEW_WINDOW_DAYS = 30;
@@ -60,4 +60,4 @@ export {
60
60
  scoresFromRow,
61
61
  buildReportDataForSite
62
62
  };
63
- //# sourceMappingURL=chunk-A5KYRGQA.js.map
63
+ //# sourceMappingURL=chunk-PSJQE6T6.js.map
@@ -0,0 +1,41 @@
1
+ // src/blux/assets.ts
2
+ var CDN_HOSTS = ["d3syaxnfm3oj0e.cloudfront.net", "dv4tl7yyk1zlp.cloudfront.net"];
3
+ var HOSTS = CDN_HOSTS;
4
+ function normalizeCdnUrl(url) {
5
+ let u;
6
+ try {
7
+ u = new URL(url);
8
+ } catch {
9
+ return null;
10
+ }
11
+ if (!HOSTS.includes(u.hostname)) return null;
12
+ const segs = u.pathname.split("/").filter(Boolean);
13
+ if (segs.length < 2) return null;
14
+ const siteId = segs[0];
15
+ const file = segs[segs.length - 1];
16
+ if (!/\.[a-z0-9]+$/i.test(file)) return null;
17
+ return `https://${u.hostname}/${siteId}/${file}`;
18
+ }
19
+ var URL_RE = new RegExp(
20
+ `https?://(?:${HOSTS.join("|").replace(/\./g, "\\.")})/[^"'\\\\ )>]+`,
21
+ "g"
22
+ );
23
+ function collectAssetUrls(htmls) {
24
+ const map = /* @__PURE__ */ new Map();
25
+ for (const html of htmls) {
26
+ for (const m of html.matchAll(URL_RE)) {
27
+ const canon = normalizeCdnUrl(m[0]);
28
+ if (!canon) continue;
29
+ const file = canon.split("/").pop();
30
+ const uuid = file.replace(/\.[a-z0-9]+$/i, "");
31
+ if (!map.has(uuid)) map.set(uuid, canon);
32
+ }
33
+ }
34
+ return map;
35
+ }
36
+
37
+ export {
38
+ CDN_HOSTS,
39
+ collectAssetUrls
40
+ };
41
+ //# sourceMappingURL=chunk-SL24DCGC.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/blux/assets.ts"],"sourcesContent":["/** The two Blux CDN hosts (images + files). Shared with the probe stage so the\n * scrape and the reconstruct-probe can never disagree about what a CDN URL is. */\nexport const CDN_HOSTS = [\"d3syaxnfm3oj0e.cloudfront.net\", \"dv4tl7yyk1zlp.cloudfront.net\"];\nconst HOSTS = CDN_HOSTS;\n\n/** Strip Blux transform segments (any path part containing ':') to the proven\n * original `<host>/<siteId>/<uuid>.<ext>`. Returns null for non-CDN urls. */\nexport function normalizeCdnUrl(url: string): string | null {\n let u: URL;\n try {\n u = new URL(url);\n } catch {\n return null;\n }\n if (!HOSTS.includes(u.hostname)) return null;\n const segs = u.pathname.split(\"/\").filter(Boolean);\n if (segs.length < 2) return null;\n const siteId = segs[0]!;\n const file = segs[segs.length - 1]!;\n if (!/\\.[a-z0-9]+$/i.test(file)) return null;\n return `https://${u.hostname}/${siteId}/${file}`;\n}\n\nconst URL_RE = new RegExp(\n `https?://(?:${HOSTS.join(\"|\").replace(/\\./g, \"\\\\.\")})/[^\"'\\\\\\\\ )>]+`,\n \"g\",\n);\n\n/** uuid (last path segment sans extension) → canonical CDN url, scraped from HTML. */\nexport function collectAssetUrls(htmls: string[]): Map<string, string> {\n const map = new Map<string, string>();\n for (const html of htmls) {\n for (const m of html.matchAll(URL_RE)) {\n const canon = normalizeCdnUrl(m[0]);\n if (!canon) continue;\n const file = canon.split(\"/\").pop()!;\n const uuid = file.replace(/\\.[a-z0-9]+$/i, \"\");\n if (!map.has(uuid)) map.set(uuid, canon);\n }\n }\n return map;\n}\n"],"mappings":";AAEO,IAAM,YAAY,CAAC,iCAAiC,8BAA8B;AACzF,IAAM,QAAQ;AAIP,SAAS,gBAAgB,KAA4B;AAC1D,MAAI;AACJ,MAAI;AACF,QAAI,IAAI,IAAI,GAAG;AAAA,EACjB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI,CAAC,MAAM,SAAS,EAAE,QAAQ,EAAG,QAAO;AACxC,QAAM,OAAO,EAAE,SAAS,MAAM,GAAG,EAAE,OAAO,OAAO;AACjD,MAAI,KAAK,SAAS,EAAG,QAAO;AAC5B,QAAM,SAAS,KAAK,CAAC;AACrB,QAAM,OAAO,KAAK,KAAK,SAAS,CAAC;AACjC,MAAI,CAAC,gBAAgB,KAAK,IAAI,EAAG,QAAO;AACxC,SAAO,WAAW,EAAE,QAAQ,IAAI,MAAM,IAAI,IAAI;AAChD;AAEA,IAAM,SAAS,IAAI;AAAA,EACjB,eAAe,MAAM,KAAK,GAAG,EAAE,QAAQ,OAAO,KAAK,CAAC;AAAA,EACpD;AACF;AAGO,SAAS,iBAAiB,OAAsC;AACrE,QAAM,MAAM,oBAAI,IAAoB;AACpC,aAAW,QAAQ,OAAO;AACxB,eAAW,KAAK,KAAK,SAAS,MAAM,GAAG;AACrC,YAAM,QAAQ,gBAAgB,EAAE,CAAC,CAAC;AAClC,UAAI,CAAC,MAAO;AACZ,YAAM,OAAO,MAAM,MAAM,GAAG,EAAE,IAAI;AAClC,YAAM,OAAO,KAAK,QAAQ,iBAAiB,EAAE;AAC7C,UAAI,CAAC,IAAI,IAAI,IAAI,EAAG,KAAI,IAAI,MAAM,KAAK;AAAA,IACzC;AAAA,EACF;AACA,SAAO;AACT;","names":[]}
@@ -1,18 +1,20 @@
1
1
  import {
2
2
  ELIGIBLE_STATUSES,
3
3
  reportPeriodKey
4
- } from "./chunk-45AUGDFR.js";
4
+ } from "./chunk-WN4NEHE7.js";
5
5
  import {
6
6
  isProbablyEmail,
7
7
  parseAddresses
8
- } from "./chunk-H6YZBXBQ.js";
8
+ } from "./chunk-52TDNN7K.js";
9
9
  import {
10
+ gatingHealth,
11
+ isSendOverridden,
10
12
  listAllReports
11
- } from "./chunk-6JFOAPW4.js";
13
+ } from "./chunk-AYM2EFSK.js";
12
14
  import {
13
15
  listWebsites,
14
16
  siteSlug
15
- } from "./chunk-C3PDS623.js";
17
+ } from "./chunk-YWRQEDB3.js";
16
18
  import {
17
19
  openBase,
18
20
  readAirtableConfig
@@ -252,6 +254,23 @@ async function preflight(deps) {
252
254
  );
253
255
  return { results, fleet: deps?.site ? [] : preflightFleet(selected) };
254
256
  }
257
+ function healthBlockers(report) {
258
+ if (isSendOverridden(report)) return [];
259
+ const findings = [];
260
+ for (const { field, status } of gatingHealth({
261
+ reportType: report.reportType,
262
+ autoEvidence: report.autoEvidence ?? {}
263
+ })) {
264
+ if (status === "pass" || status === "n/a") continue;
265
+ const note = report.autoEvidence?.[field]?.note || "no signal yet";
266
+ findings.push({
267
+ level: "fail",
268
+ check: "health-gate",
269
+ message: status === "fail" ? `${field}: failing \u2014 ${note}` : `${field}: not yet green (${status}) \u2014 ${note}`
270
+ });
271
+ }
272
+ return findings;
273
+ }
255
274
  function approveBlockers(site, report) {
256
275
  const findings = [];
257
276
  const explicitTo = parseAddresses(site.reportRecipientsTo);
@@ -312,6 +331,7 @@ function approveBlockers(site, report) {
312
331
  });
313
332
  }
314
333
  }
334
+ findings.push(...healthBlockers(report));
315
335
  return findings;
316
336
  }
317
337
 
@@ -319,4 +339,4 @@ export {
319
339
  preflight,
320
340
  approveBlockers
321
341
  };
322
- //# sourceMappingURL=chunk-MAYHLXCS.js.map
342
+ //# sourceMappingURL=chunk-VR246V3G.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/preflight.ts"],"sourcesContent":["import { openBase, readAirtableConfig } from \"./airtable/client.js\";\nimport type { AirtableBase } from \"./airtable/client.js\";\nimport { listWebsites, siteSlug } from \"./airtable/websites.js\";\nimport type { WebsiteRow } from \"./airtable/websites.js\";\nimport { listAllReports } from \"./airtable/reports.js\";\nimport type { ReportRow } from \"./airtable/reports.js\";\nimport { parseAddresses, isProbablyEmail } from \"./send/orchestrate.js\";\nimport { ELIGIBLE_STATUSES, reportPeriodKey } from \"./due.js\";\nimport type { ReportType } from \"./types.js\";\nimport { gatingHealth, isSendOverridden } from \"./checklist.js\";\n\nexport type PreflightLevel = \"fail\" | \"warn\" | \"info\";\n\nexport type PreflightFinding = {\n level: PreflightLevel;\n /** Stable machine-readable check id, e.g. \"recipients-missing\". */\n check: string;\n message: string;\n};\n\nexport type PreflightSiteResult = {\n site: string;\n findings: PreflightFinding[];\n};\n\n/** Domains whose addresses are the OPERATOR's, not a client's. A client site whose\n * resolved To contains one of these is almost always a test-send leftover — the\n * exact misconfiguration that would silently divert a client announcement. */\nconst OPERATOR_DOMAINS = [\"reddoorla.com\", \"tuckerlemos.com\"];\n\n/** Anchors older than this, WHEN the anchor is actually the schedule's base date\n * (no newer Sent-at — see nextDueDate's `lastSent ?? fallback`), mean the next\n * `report --due` run drafts a surprise back-dated report. */\nconst STALE_ANCHOR_DAYS = 396; // ~13 months\n\nfunction domainOf(addr: string): string {\n const at = addr.lastIndexOf(\"@\");\n return at === -1 ? \"\" : addr.slice(at + 1).toLowerCase();\n}\n\nfunction isOperatorSite(site: WebsiteRow): boolean {\n if (!site.url) return false;\n try {\n // Airtable `url` cells aren't guaranteed a scheme; retry with one before giving up.\n const host = new URL(\n /^[a-z][a-z0-9+.-]*:\\/\\//i.test(site.url) ? site.url : `https://${site.url}`,\n ).hostname.toLowerCase();\n return OPERATOR_DOMAINS.some((d) => host === d || host.endsWith(`.${d}`));\n } catch {\n return false;\n }\n}\n\nfunction lastSentForType(reports: ReportRow[], type: ReportType): string | null {\n const sent = reports\n .filter((r) => r.reportType === type && r.sentAt !== null)\n .map((r) => r.sentAt!)\n .sort();\n return sent[sent.length - 1] ?? null;\n}\n\nfunction checkFrequency(\n site: WebsiteRow,\n which: \"maintenance\" | \"testing\",\n reports: ReportRow[],\n findings: PreflightFinding[],\n now: Date,\n): void {\n // Validate the RAW Airtable cell, not the coerced Frequency: toFrequency maps any\n // unrecognized value (typo, trailing space, renamed option) to \"None\", so by the\n // time WebsiteRow is built the damage is invisible — the site just silently drops\n // off the schedule. This is the loud version.\n const raw = which === \"maintenance\" ? site.maintenanceFreqRaw : site.testingFreqRaw;\n const coerced = which === \"maintenance\" ? site.maintenanceFreq : site.testingFreq;\n if (raw !== null && raw !== \"\" && coerced === \"None\" && raw.trim() !== \"None\") {\n findings.push({\n level: \"fail\",\n check: \"frequency-unrecognized\",\n message: `${which} frequency cell is '${raw}' — not an exact Monthly/Quarterly/Yearly/None, so the scheduler silently treats it as None and the site drops off the calendar; fix the Airtable value`,\n });\n return;\n }\n if (coerced === \"None\") return;\n\n const anchor = which === \"maintenance\" ? site.maintenanceDay : site.testingDay;\n if (!anchor) return;\n // nextDueDate uses `lastSent ?? anchor`: once anything has been sent, the anchor\n // is inert and an old one is normal on every healthy mature site — only warn when\n // the anchor is genuinely the base date the scheduler would use.\n const type: ReportType = which === \"maintenance\" ? \"Maintenance\" : \"Testing\";\n const lastSent = lastSentForType(reports, type);\n if (lastSent && new Date(lastSent).getTime() >= new Date(anchor).getTime()) return;\n const ageDays = (now.getTime() - new Date(anchor).getTime()) / 86_400_000;\n if (ageDays > STALE_ANCHOR_DAYS) {\n findings.push({\n level: \"warn\",\n check: \"anchor-stale\",\n message: `${which} day anchor is ${anchor} (>13 months old) and nothing newer has been sent — \\`report --due\\` will draft a back-dated overdue report; reset the anchor or clear the schedule`,\n });\n }\n}\n\n/**\n * Pure per-site preflight: every check that, left unfixed, makes a report send fail,\n * go to the wrong inbox, or surprise the operator. Read-only over data already\n * fetched; no Airtable handle, no network — trivially testable.\n *\n * Mirrors the send/draft-time validation (recipients + header image in\n * send/orchestrate.ts, Websites-row scores in draft.ts) so problems surface BEFORE\n * draft/approve instead of exploding at `report --send-ready`, and adds the checks\n * send time cannot do: operator-address leftovers, To-override shadowing,\n * pending-draft races, schedule hygiene against the RAW Airtable values.\n */\nexport function preflightSite(\n site: WebsiteRow,\n reports: ReportRow[],\n type: ReportType,\n now: Date,\n): PreflightSiteResult {\n const findings: PreflightFinding[] = [];\n\n // A site that isn't on the checked calendar never drafts, so its send\n // requirements (recipients / header image / scores) can't hurt anyone —\n // checking them anyway would drown the fleet run in fails for legacy hosting\n // rows. Frequency hygiene and pending-draft checks still run: a typo'd cell\n // is exactly HOW a site falls off the calendar, and an already-drafted report\n // can still be approved + sent regardless of schedule.\n const scheduledForType =\n type === \"Announcement\"\n ? true\n : type === \"Maintenance\"\n ? site.maintenanceFreq !== \"None\"\n : site.testingFreq !== \"None\";\n if (!scheduledForType) {\n findings.push({\n level: \"info\",\n check: \"not-scheduled\",\n message: `no ${type} schedule (frequency is None/blank) — send-requirement checks skipped; schedule + pending-draft checks still apply`,\n });\n }\n\n // --- Recipients: same resolution order as the send path (To override, else contact).\n const explicitTo = parseAddresses(site.reportRecipientsTo);\n const fallbackTo = parseAddresses(site.pointOfContact);\n const to = scheduledForType ? (explicitTo ?? fallbackTo ?? []) : [];\n if (scheduledForType && to.length === 0) {\n findings.push({\n level: \"fail\",\n check: \"recipients-missing\",\n message:\n \"no recipients — Report recipients (To) and point of contact are both empty; the send will throw\",\n });\n }\n for (const addr of to) {\n if (!isProbablyEmail(addr)) {\n findings.push({\n level: \"fail\",\n check: \"recipients-malformed\",\n message: `recipient '${addr}' is malformed — use bare addresses only in Report recipients (To) / point of contact`,\n });\n }\n }\n const cc = scheduledForType ? (parseAddresses(site.reportRecipientsCc) ?? []) : [];\n for (const addr of cc) {\n if (!isProbablyEmail(addr)) {\n findings.push({\n level: \"fail\",\n check: \"recipients-malformed\",\n message: `CC '${addr}' is malformed — fix Report recipients (CC)`,\n });\n }\n }\n if (!isOperatorSite(site)) {\n const operatorAddrs = to.filter((a) => OPERATOR_DOMAINS.includes(domainOf(a)));\n if (operatorAddrs.length > 0) {\n const allOperator = operatorAddrs.length === to.length;\n findings.push({\n level: \"warn\",\n check: \"recipient-operator-address\",\n message: allOperator\n ? `resolved To is ONLY operator address(es) ${operatorAddrs.join(\", \")} — probably a test-send leftover; the client will NOT receive this report`\n : `resolved To includes operator address(es) ${operatorAddrs.join(\", \")} alongside the client's — confirm that's intentional (the ops inbox is already CC'd on every send)`,\n });\n }\n }\n if (scheduledForType && explicitTo && fallbackTo) {\n const a = [...explicitTo]\n .map((x) => x.toLowerCase())\n .sort()\n .join(\",\");\n const b = [...fallbackTo]\n .map((x) => x.toLowerCase())\n .sort()\n .join(\",\");\n if (a !== b) {\n findings.push({\n level: \"info\",\n check: \"to-override-shadows-contact\",\n message: `Report recipients (To) [${explicitTo.join(\", \")}] overrides point of contact [${fallbackTo.join(\", \")}] — the contact won't receive this unless also listed`,\n });\n }\n }\n\n // --- Hard requirements of the draft/send paths, surfaced early.\n if (scheduledForType && !site.headerImage) {\n findings.push({\n level: \"fail\",\n check: \"header-image-missing\",\n message: \"no Header image on the Websites row — the send will throw\",\n });\n } else if (scheduledForType && site.headerImage && !site.headerImage.type.startsWith(\"image/\")) {\n findings.push({\n level: \"fail\",\n check: \"header-image-not-image\",\n message: `Header image attachment is '${site.headerImage.type}' (${site.headerImage.filename}) — not a decodable image; the send throws in prepareHeaderImage`,\n });\n }\n const scores = [site.pScore, site.rScore, site.bpScore, site.seoScore];\n if (scheduledForType && scores.some((s) => s === null)) {\n if (type === \"Announcement\") {\n // announce() skips score-less sites (skipped-no-scores) rather than throwing.\n findings.push({\n level: \"warn\",\n check: \"scores-missing\",\n message:\n \"Websites row is missing Lighthouse scores — `announce` will skip this site; run `audit lighthouse --write-airtable` first\",\n });\n } else {\n // report <site> / report --due HARD-THROW here (scoresFromWebsite in draft.ts).\n findings.push({\n level: \"fail\",\n check: \"scores-missing\",\n message:\n \"Websites row is missing Lighthouse scores — drafting will throw (scoresFromWebsite); run `audit lighthouse --write-airtable` first\",\n });\n }\n }\n if (type === \"Announcement\" && site.status !== \"maintenance\") {\n findings.push({\n level: \"info\",\n check: \"status-not-maintenance\",\n message: `status is '${site.status ?? \"(blank)\"}' — \\`announce --all\\` only drafts for maintenance sites`,\n });\n }\n\n // --- Pending drafts. For an Announcement these always race (the queue tier rule\n // supersedes or blocks). For Maintenance/Testing, the CURRENT cycle's own draft\n // sitting ready on send day IS the payload — that's the steady state, not a\n // problem — so only genuinely stale/foreign drafts get the warn.\n const pending = reports.filter((r) => r.draftReady && r.sentAt === null);\n if (pending.length > 0) {\n const currentPeriod = reportPeriodKey(now);\n const isExpectedPayload = (r: ReportRow): boolean =>\n type !== \"Announcement\" && r.reportType === type && r.period === currentPeriod;\n const expected = pending.filter(isExpectedPayload);\n const stale = pending.filter((r) => !isExpectedPayload(r));\n if (expected.length > 0) {\n findings.push({\n level: \"info\",\n check: \"pending-drafts\",\n message: `current-cycle ${type} draft is queued${expected[0]!.approvedToSend ? \" and approved\" : \" (awaiting approval)\"} — this is the report \\`--send-ready\\` will deliver`,\n });\n }\n if (stale.length > 0) {\n const summary = stale\n .map(\n (r) =>\n `${r.reportType} ${r.period ?? \"(no period)\"}${r.approvedToSend ? \" [APPROVED]\" : \"\"}`,\n )\n .join(\", \");\n findings.push({\n level: \"warn\",\n check: \"pending-drafts\",\n message: `${stale.length} unsent draft(s) queued: ${summary} — resolve before sending so the client doesn't get the wrong report first`,\n });\n }\n }\n\n // --- Schedule hygiene (validates the RAW Airtable frequency cells).\n checkFrequency(site, \"maintenance\", reports, findings, now);\n checkFrequency(site, \"testing\", reports, findings, now);\n if (type === \"Announcement\") {\n const anySent = lastSentForType(reports, \"Maintenance\") ?? lastSentForType(reports, \"Testing\");\n if (!site.maintenanceDay && !site.testingDay && !anySent) {\n findings.push({\n level: \"info\",\n check: \"anchor-missing\",\n message:\n \"no maintenance/testing day anchors and nothing ever sent — set both anchors to the announcement send date (the Sonder playbook) so recurrence starts from the announcement\",\n });\n }\n }\n\n return { site: site.name, findings };\n}\n\n/**\n * Fleet-level heuristics that need the whole selection. Airtable column renames\n * don't error — the mapper reads null forever (its own header comment admits this).\n * A load-bearing column empty on EVERY selected site is far more likely a rename\n * than N coincidences; say so. Likewise two different sites resolving to one\n * recipient is worth one eyeball (it can be legitimate — same owner, two sites).\n */\nexport function preflightFleet(sites: WebsiteRow[]): PreflightFinding[] {\n if (sites.length < 3) return []; // too few rows to distinguish rename from data\n const findings: PreflightFinding[] = [];\n const allEmpty = (get: (s: WebsiteRow) => unknown, column: string): void => {\n if (sites.every((s) => get(s) === null || get(s) === \"\")) {\n findings.push({\n level: \"warn\",\n check: \"column-possibly-renamed\",\n message: `'${column}' is empty on all ${sites.length} selected sites — if that column was renamed in Airtable the code reads null silently; verify the column name`,\n });\n }\n };\n allEmpty((s) => s.pointOfContact, \"point of contact\");\n allEmpty((s) => s.maintenanceFreqRaw, \"maintenence freq\");\n allEmpty((s) => s.headerImage, \"Header image\");\n\n const byContact = new Map<string, string[]>();\n for (const s of sites) {\n const to = parseAddresses(s.reportRecipientsTo) ?? parseAddresses(s.pointOfContact) ?? [];\n for (const addr of to) {\n const key = addr.toLowerCase();\n byContact.set(key, [...(byContact.get(key) ?? []), s.name]);\n }\n }\n for (const [addr, names] of byContact) {\n if (names.length > 1 && !OPERATOR_DOMAINS.includes(domainOf(addr))) {\n findings.push({\n level: \"info\",\n check: \"duplicate-contact\",\n message: `${names.join(\" and \")} resolve to the same recipient (${addr}) — fine if one person owns both; otherwise a copy-paste error on one row`,\n });\n }\n }\n return findings;\n}\n\nexport type PreflightDeps = {\n /** Airtable handle. Defaults to opening the live base from credentials. */\n base?: AirtableBase;\n /** Slug of a single site (matched via siteSlug). Mutually exclusive with `all`. */\n site?: string;\n /** Fleet mode: for Announcement, maintenance-status sites (announce's own filter);\n * for Maintenance/Testing, everything `report --due` schedules — ELIGIBLE_STATUSES\n * plus null-status rows (due.ts treats null as active for legacy rows). */\n all?: boolean;\n type?: ReportType;\n now?: Date;\n};\n\nexport type PreflightResult = {\n results: PreflightSiteResult[];\n fleet: PreflightFinding[];\n};\n\n/**\n * Read-only rollout preflight over the live Airtable base. One Websites fetch, one\n * Reports fetch (the --due path does the same for the same rate-limit reason), then\n * {@link preflightSite} per selected site plus {@link preflightFleet} across the\n * selection. NEVER writes and NEVER sends.\n */\nexport async function preflight(deps?: PreflightDeps): Promise<PreflightResult> {\n const base = deps?.base ?? openBase(readAirtableConfig());\n const type: ReportType = deps?.type ?? \"Announcement\";\n const now = deps?.now ?? new Date();\n\n const websites = await listWebsites(base);\n const selected = deps?.site\n ? websites.filter((w) => siteSlug(w.name) === siteSlug(deps.site!))\n : type === \"Announcement\"\n ? websites.filter((w) => w.status === \"maintenance\")\n : websites.filter((w) => w.status === null || ELIGIBLE_STATUSES.has(w.status));\n\n const allReports = selected.length > 0 ? await listAllReports(base) : [];\n const results = selected.map((site) =>\n preflightSite(\n site,\n allReports.filter((r) => r.siteId === site.id),\n type,\n now,\n ),\n );\n return { results, fleet: deps?.site ? [] : preflightFleet(selected) };\n}\n\n/**\n * Health-gate blockers for one report: every GATING field whose evidence is not `pass`/`n/a`\n * (i.e. `fail`, `unknown`, or absent) becomes a fail-level finding, keyed `health-gate`. PURE.\n * Folded into {@link approveBlockers} so health failures ride the existing send-blocked reason +\n * 409 + dashboard chip (the second gate — no third gate is added).\n *\n * A logged send-anyway override ({@link isSendOverridden}) suppresses these findings — but never\n * the REAL send blockers (recipients/header/scores), which are computed outside this function and\n * still gate an overridden send.\n */\nexport function healthBlockers(report: ReportRow): PreflightFinding[] {\n if (isSendOverridden(report)) return [];\n const findings: PreflightFinding[] = [];\n for (const { field, status } of gatingHealth({\n reportType: report.reportType,\n autoEvidence: report.autoEvidence ?? {},\n })) {\n if (status === \"pass\" || status === \"n/a\") continue;\n // `||`, not `??`: an explicit empty-string note (no message recorded) must\n // also fall back to the placeholder, not render as a trailing blank.\n const note = report.autoEvidence?.[field]?.note || \"no signal yet\";\n findings.push({\n level: \"fail\",\n check: \"health-gate\",\n message:\n status === \"fail\"\n ? `${field}: failing — ${note}`\n : `${field}: not yet green (${status}) — ${note}`,\n });\n }\n return findings;\n}\n\n/**\n * The send-blocking subset for ONE report, at approve time: exactly the\n * conditions that make `sendOne` throw (no recipients, malformed To/CC, no\n * header image, no report-level Lighthouse scores) plus the wrong-inbox warn\n * (operator address resolved as a client's To) plus every {@link healthBlockers}\n * finding (a gating checklist item whose auto-evidence isn't `pass`/`n/a` fails\n * the health gate). PURE — the approve gate, the dashboard's pending-row chip,\n * and the digest collector all call this one function so \"approvable\" can't\n * drift from \"sendable\".\n *\n * Deliberately narrower than {@link preflightSite}: schedule hygiene and\n * pending-draft races are fleet/site concerns — blocking THIS report's approval\n * on them would be over-gating.\n */\nexport function approveBlockers(site: WebsiteRow, report: ReportRow): PreflightFinding[] {\n const findings: PreflightFinding[] = [];\n\n const explicitTo = parseAddresses(site.reportRecipientsTo);\n const fallbackTo = parseAddresses(site.pointOfContact);\n const to = explicitTo ?? fallbackTo ?? [];\n if (to.length === 0) {\n findings.push({\n level: \"fail\",\n check: \"recipients-missing\",\n message:\n \"no recipients — Report recipients (To) and point of contact are both empty; the send will throw\",\n });\n }\n for (const addr of to) {\n if (!isProbablyEmail(addr)) {\n findings.push({\n level: \"fail\",\n check: \"recipients-malformed\",\n message: `recipient '${addr}' is malformed — fix Report recipients (To) / point of contact in Airtable`,\n });\n }\n }\n for (const addr of parseAddresses(site.reportRecipientsCc) ?? []) {\n if (!isProbablyEmail(addr)) {\n findings.push({\n level: \"fail\",\n check: \"recipients-malformed\",\n message: `CC '${addr}' is malformed — fix Report recipients (CC) in Airtable`,\n });\n }\n }\n if (!site.headerImage) {\n findings.push({\n level: \"fail\",\n check: \"header-image-missing\",\n message: \"no Header image on the Websites row — the send will throw\",\n });\n } else if (!site.headerImage.type.startsWith(\"image/\")) {\n findings.push({\n level: \"fail\",\n check: \"header-image-not-image\",\n message: `Header image attachment is '${site.headerImage.type}' (${site.headerImage.filename}) — not a decodable image; the send throws in prepareHeaderImage`,\n });\n }\n // sendOne throws on a null report.lighthouse (one blank/non-numeric cell nulls\n // all four) — the REPORT's snapshot, not the Websites-row scores.\n if (report.lighthouse === null) {\n findings.push({\n level: \"fail\",\n check: \"report-scores-missing\",\n message:\n \"the Reports row has no Lighthouse scores (one blank/non-numeric cell nulls all four) — the send will throw\",\n });\n }\n if (!isOperatorSite(site)) {\n const operatorAddrs = to.filter((a) => OPERATOR_DOMAINS.includes(domainOf(a)));\n if (operatorAddrs.length > 0 && operatorAddrs.length === to.length) {\n findings.push({\n level: \"warn\",\n check: \"recipient-operator-address\",\n message: `resolved To is ONLY operator address(es) ${operatorAddrs.join(\", \")} — the client will NOT receive this report`,\n });\n }\n }\n findings.push(...healthBlockers(report));\n return findings;\n}\n\n/** Convenience: just the fail-level blockers, formatted for gate messages. */\nexport function formatBlockers(findings: PreflightFinding[]): string[] {\n return findings.filter((f) => f.level === \"fail\").map((f) => `${f.check}: ${f.message}`);\n}\n"],"mappings":"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","names":[]}
@@ -1,10 +1,5 @@
1
1
  // src/reports/due.ts
2
- var ELIGIBLE_STATUSES = /* @__PURE__ */ new Set([
3
- "in development",
4
- "launch period",
5
- "maintenance",
6
- "hosting"
7
- ]);
2
+ var ELIGIBLE_STATUSES = /* @__PURE__ */ new Set(["maintenance", "hosting"]);
8
3
  var MONTHS = {
9
4
  Monthly: 1,
10
5
  Quarterly: 3,
@@ -79,4 +74,4 @@ export {
79
74
  findDueReports,
80
75
  reportPeriodKey
81
76
  };
82
- //# sourceMappingURL=chunk-45AUGDFR.js.map
77
+ //# sourceMappingURL=chunk-WN4NEHE7.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/due.ts"],"sourcesContent":["import type { WebsiteRow, Frequency, Status } from \"./airtable/websites.js\";\nimport type { ReportRow } from \"./airtable/reports.js\";\nimport type { ReportType } from \"./types.js\";\n\n/** Statuses where recurring Maintenance/Testing reports are appropriate. Only\n * LIVE sites: \"maintenance\" (actively maintained) and \"hosting\". Pre-launch\n * stages (\"in development\" / \"launch period\") are excluded — a not-yet-live site\n * must not be drafted a recurring Maintenance/Testing report (that's what was\n * emailing/alarming pre-launch sites); it starts its report cadence when a Launch\n * report flips its Status to \"maintenance\". Launch reports themselves are a\n * separate manual flow (recipes/launch.ts), never scheduled here. \"deprecated\" /\n * \"probably not our problem\" are dropped too. Sites with status=null pass through\n * (partial data; better to surface than silently skip). */\nexport const ELIGIBLE_STATUSES: ReadonlySet<Status> = new Set<Status>([\"maintenance\", \"hosting\"]);\n\nexport type DueItem = {\n site: WebsiteRow;\n reportType: ReportType;\n /** Inclusive: the day the next report became due. */\n dueDate: Date;\n /** ISO date of the last `Sent at` for this (site, type), or null if there's never been one. */\n lastSent: string | null;\n};\n\nconst MONTHS: Record<Exclude<Frequency, \"None\">, number> = {\n Monthly: 1,\n Quarterly: 3,\n Yearly: 12,\n};\n\n/**\n * Add `n` calendar months in UTC, clamped to the last day of the target month.\n * Jan 31 + 1 month = Feb 28 (not Mar 3, which is what naive setMonth produces).\n * All-UTC accessors mean the result is timezone-independent.\n */\nfunction addMonths(d: Date, n: number): Date {\n const out = new Date(d);\n const day = out.getUTCDate();\n out.setUTCDate(1);\n out.setUTCMonth(out.getUTCMonth() + n);\n const lastDayOfTargetMonth = new Date(\n Date.UTC(out.getUTCFullYear(), out.getUTCMonth() + 1, 0),\n ).getUTCDate();\n out.setUTCDate(Math.min(day, lastDayOfTargetMonth));\n return out;\n}\n\n/** Truncate to UTC midnight. Avoids local-TZ skew when comparing Airtable date-only fields. */\nfunction startOfDay(d: Date): Date {\n const out = new Date(d);\n out.setUTCHours(0, 0, 0, 0);\n return out;\n}\n\nfunction lastSentForType(reports: ReportRow[], siteId: string, type: ReportType): string | null {\n const candidates = reports\n .filter((r) => r.siteId === siteId && r.reportType === type && r.sentAt !== null)\n .map((r) => r.sentAt!)\n .sort();\n return candidates[candidates.length - 1] ?? null;\n}\n\n/**\n * The next-due date for one (site, type): the date the next report of that type is\n * scheduled to draft, whether or not it's due yet. `null` when there's no schedule —\n * an ineligible status, a \"None\"/blank frequency, or an unrecognized frequency value.\n *\n * baseDate = the last `Sent at` for this (site, type), else the site's\n * `maintenance day`/`testing day` anchor. With no baseDate at all the next report is\n * due now (returns `today` at UTC midnight). Otherwise baseDate + frequency.\n *\n * Shared with {@link findDueReports} so the scheduler and any schedule display can't\n * drift on what \"next\" means. NOTE: callers wanting the LOUD unrecognized-frequency\n * warning use findDueReports; this returns null silently for that case.\n */\nexport function nextDueDate(\n site: WebsiteRow,\n reports: ReportRow[],\n type: ReportType,\n today: Date,\n): Date | null {\n if (site.status !== null && !ELIGIBLE_STATUSES.has(site.status)) return null;\n const rawFreq = type === \"Maintenance\" ? site.maintenanceFreq : site.testingFreq;\n const freq = (typeof rawFreq === \"string\" ? rawFreq.trim() : rawFreq) as Frequency;\n if (freq === \"None\" || freq === (\"\" as Frequency)) return null;\n if (!(freq in MONTHS)) return null;\n const lastSent = lastSentForType(reports, site.id, type);\n const fallback = type === \"Maintenance\" ? site.maintenanceDay : site.testingDay;\n const baseIso = lastSent ?? fallback;\n if (!baseIso) return startOfDay(today);\n return addMonths(new Date(baseIso), MONTHS[freq]);\n}\n\n/**\n * Computes which (site, type) pairs are due as of `today`.\n *\n * Algorithm per (site, type):\n * 1. If freq === \"None\", skip.\n * 2. baseDate = max(last Sent at for this type, site's `maintenance/testing day` fallback).\n * 3. If no baseDate exists at all, the site is due now.\n * 4. dueDate = baseDate + frequency months.\n * 5. Due iff startOfDay(today) >= startOfDay(dueDate).\n */\nexport function findDueReports(\n websites: WebsiteRow[],\n reports: ReportRow[],\n today: Date,\n): DueItem[] {\n const out: DueItem[] = [];\n const todayStart = startOfDay(today);\n\n for (const site of websites) {\n // Skip explicitly-non-active statuses (deprecated, \"probably not our problem\").\n // Null status is treated as active for backwards compat with rows that pre-date\n // the Status convention.\n if (site.status !== null && !ELIGIBLE_STATUSES.has(site.status)) continue;\n\n for (const type of [\"Maintenance\", \"Testing\"] as const) {\n const rawFreq = type === \"Maintenance\" ? site.maintenanceFreq : site.testingFreq;\n // Normalize obvious whitespace so a trailing-space typo (\"Quarterly \") still\n // schedules. The LOUD warning below is the real safety net for genuine\n // casing/spelling mistakes (\"monthly\", \"Quaterly\").\n const freq = (typeof rawFreq === \"string\" ? rawFreq.trim() : rawFreq) as Frequency;\n // Intentional silent skip — \"None\" (and the empty/blank default) means \"no\n // schedule\", not a mistake.\n if (freq === \"None\" || freq === (\"\" as Frequency)) continue;\n // A non-empty, non-None value that doesn't match a known schedule used to\n // silently produce no due date — the site just vanished from the loop. Warn\n // LOUDLY so a casing/typo Airtable value is fixable instead of invisible.\n if (!(freq in MONTHS)) {\n console.warn(\n `⚠ ${site.name}: unrecognized ${type === \"Maintenance\" ? \"maintenance\" : \"testing\"} frequency '${rawFreq}' — not scheduling; fix the Airtable value`,\n );\n continue;\n }\n\n const lastSent = lastSentForType(reports, site.id, type);\n // Same computation as the schedule write-back uses. The guards above already\n // proved a schedule exists, so dueDate is non-null; a no-anchor site returns\n // `today`, so the comparison below pushes it as due-now (the prior behavior).\n const dueDate = nextDueDate(site, reports, type, today);\n if (dueDate !== null && todayStart.getTime() >= startOfDay(dueDate).getTime()) {\n out.push({ site, reportType: type, dueDate, lastSent });\n }\n }\n }\n\n return out;\n}\n\n/**\n * The UTC `YYYY-MM` of a `dueDate` from {@link findDueReports} — the per-recurrence\n * idempotency key for drafting. Monthly recurrences land in distinct months; quarterly\n * and yearly land in distinct due-months too, so this uniquely names one draft per cycle.\n * UTC accessors keep it timezone-independent, consistent with the rest of this module.\n */\nexport function reportPeriodKey(dueDate: Date): string {\n if (Number.isNaN(dueDate.getTime())) throw new TypeError(\"reportPeriodKey: invalid Date\");\n const year = dueDate.getUTCFullYear();\n const month = String(dueDate.getUTCMonth() + 1).padStart(2, \"0\");\n return `${year}-${month}`;\n}\n"],"mappings":"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","names":[]}
@@ -30,7 +30,7 @@ async function resolveSites(input) {
30
30
  }
31
31
  if (input.fleet === "airtable") {
32
32
  const { openBase, readAirtableConfig } = await import("./client-EPVO3FEH.js");
33
- const { fromAirtableBase } = await import("./airtable-WGQQ3KAD.js");
33
+ const { fromAirtableBase } = await import("./airtable-2PJ4NSAV.js");
34
34
  const base = openBase(readAirtableConfig());
35
35
  const provider = fromAirtableBase(base, input.workdir ? { workdir: input.workdir } : {});
36
36
  return provider();
@@ -61,4 +61,4 @@ async function resolveSites(input) {
61
61
  export {
62
62
  resolveSites
63
63
  };
64
- //# sourceMappingURL=chunk-YQMYQAJX.js.map
64
+ //# sourceMappingURL=chunk-Y7LZHPDF.js.map