@reddoorla/maintenance 0.69.0 → 0.72.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 (133) hide show
  1. package/README.md +15 -14
  2. package/dist/airtable-U2GTDTK5.js +9 -0
  3. package/dist/{announce-72R3LRNT.js → announce-HJCGEVJA.js} +6 -6
  4. package/dist/blux-FKZYMKLF.js +2205 -0
  5. package/dist/blux-FKZYMKLF.js.map +1 -0
  6. package/dist/{bump-deps-QNM3H4GI.js → bump-deps-GZEJAUKI.js} +3 -2
  7. package/dist/bump-deps-GZEJAUKI.js.map +1 -0
  8. package/dist/chunk-7MJBTV2R.js +145 -0
  9. package/dist/chunk-7MJBTV2R.js.map +1 -0
  10. package/dist/{chunk-2DCA7FHZ.js → chunk-7P7YETHG.js} +34 -12
  11. package/dist/chunk-7P7YETHG.js.map +1 -0
  12. package/dist/{chunk-42KKVLYN.js → chunk-APTAITXZ.js} +10 -2
  13. package/dist/chunk-APTAITXZ.js.map +1 -0
  14. package/dist/{chunk-6G7BC5SN.js → chunk-BHE5CUWI.js} +392 -7
  15. package/dist/chunk-BHE5CUWI.js.map +1 -0
  16. package/dist/{chunk-XWPZ46IN.js → chunk-BJCA7O4A.js} +2 -2
  17. package/dist/{chunk-C3PDS623.js → chunk-BWKK2ZXS.js} +67 -8
  18. package/dist/chunk-BWKK2ZXS.js.map +1 -0
  19. package/dist/{chunk-45AUGDFR.js → chunk-D4FVAIEJ.js} +6 -20
  20. package/dist/chunk-D4FVAIEJ.js.map +1 -0
  21. package/dist/{chunk-YQMYQAJX.js → chunk-GI4M2AMO.js} +2 -2
  22. package/dist/{chunk-OTBA7DON.js → chunk-GYNRZCM6.js} +7 -2
  23. package/dist/chunk-GYNRZCM6.js.map +1 -0
  24. package/dist/{chunk-6MT5W6UF.js → chunk-HR5SONJC.js} +6 -3
  25. package/dist/chunk-HR5SONJC.js.map +1 -0
  26. package/dist/{chunk-6JFOAPW4.js → chunk-JCITWZQR.js} +51 -19
  27. package/dist/chunk-JCITWZQR.js.map +1 -0
  28. package/dist/{chunk-GKSXZIMX.js → chunk-JGLAWXUC.js} +77 -3
  29. package/dist/chunk-JGLAWXUC.js.map +1 -0
  30. package/dist/{chunk-E2GKKUEX.js → chunk-KLB2OLPW.js} +26 -6
  31. package/dist/chunk-KLB2OLPW.js.map +1 -0
  32. package/dist/{chunk-RINXPU3H.js → chunk-KLVMJYZZ.js} +2 -2
  33. package/dist/chunk-KRDLHAKK.js +17 -0
  34. package/dist/chunk-KRDLHAKK.js.map +1 -0
  35. package/dist/{chunk-AESJBEM7.js → chunk-KRHPS3R4.js} +3 -3
  36. package/dist/{chunk-LYCKN7ZR.js → chunk-LJPCMXR7.js} +8 -5
  37. package/dist/chunk-LJPCMXR7.js.map +1 -0
  38. package/dist/{chunk-O6G4HHMF.js → chunk-NRISG4XD.js} +308 -104
  39. package/dist/chunk-NRISG4XD.js.map +1 -0
  40. package/dist/chunk-PKVZMFEB.js +315 -0
  41. package/dist/chunk-PKVZMFEB.js.map +1 -0
  42. package/dist/chunk-SL24DCGC.js +41 -0
  43. package/dist/chunk-SL24DCGC.js.map +1 -0
  44. package/dist/{chunk-A5KYRGQA.js → chunk-SQJ53Z7R.js} +4 -4
  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 +11 -9
  49. package/dist/cli/commands/audit.js.map +1 -1
  50. package/dist/configs/playwright-a11y.d.ts +1 -1
  51. package/dist/configs/playwright-a11y.js +1 -3
  52. package/dist/{convert-to-pnpm-XH7NYRVK.js → convert-to-pnpm-SVFDLXZS.js} +3 -2
  53. package/dist/convert-to-pnpm-SVFDLXZS.js.map +1 -0
  54. package/dist/{digest-Q2TAQHXF.js → digest-VX7Q4MNF.js} +8 -8
  55. package/dist/{ensure-site-2PF522VL.js → ensure-site-O7G5ZJS7.js} +2 -2
  56. package/dist/forms/index.d.ts +5 -2
  57. package/dist/forms/index.js +22 -9
  58. package/dist/forms/index.js.map +1 -1
  59. package/dist/{github-signals-CLQC2IPY.js → github-signals-QVW2LB7N.js} +5 -5
  60. package/dist/health-endpoint-IYR5VBUT.js +54 -0
  61. package/dist/health-endpoint-IYR5VBUT.js.map +1 -0
  62. package/dist/index.d.ts +136 -20
  63. package/dist/index.js +207 -64
  64. package/dist/index.js.map +1 -1
  65. package/dist/{init-UA6D7GKW.js → init-CU6PVZRG.js} +9 -5
  66. package/dist/init-CU6PVZRG.js.map +1 -0
  67. package/dist/{launch-AENAS2O2.js → launch-JWXCTJRG.js} +9 -9
  68. package/dist/{lighthouse-airtable-UBSXKHAQ.js → lighthouse-airtable-J5TLASJC.js} +3 -3
  69. package/dist/{onboard-X6KJ5Z2V.js → onboard-6UQE5QIO.js} +2 -2
  70. package/dist/{orchestrate-GADNFGDH.js → orchestrate-ZDH24Z6Y.js} +5 -5
  71. package/dist/{preflight-D5RQGX4V.js → preflight-BNTMWTPG.js} +7 -7
  72. package/dist/probe-DJAMLVTT.js +46 -0
  73. package/dist/probe-DJAMLVTT.js.map +1 -0
  74. package/dist/recipes/sync-configs.d.ts +1 -1
  75. package/dist/{renovate-dispatch-LG4RJGDY.js → renovate-dispatch-C3ALLACX.js} +2 -2
  76. package/dist/{report-3FJ224H5.js → report-YLFSGNWR.js} +12 -12
  77. package/dist/run-migration-LJNNZWIV.js +205 -0
  78. package/dist/run-migration-LJNNZWIV.js.map +1 -0
  79. package/dist/{self-updating-JTTFOJ7Q.js → self-updating-64FNRQB5.js} +2 -2
  80. package/dist/{selftest-55OJIWEB.js → selftest-7DPNGL6R.js} +12 -9
  81. package/dist/selftest-7DPNGL6R.js.map +1 -0
  82. package/dist/smoke-suite-TWILWDSF.js +54 -0
  83. package/dist/smoke-suite-TWILWDSF.js.map +1 -0
  84. package/dist/{svelte-codemods-KJLNH6FQ.js → svelte-codemods-RVDTTXFD.js} +3 -2
  85. package/dist/svelte-codemods-RVDTTXFD.js.map +1 -0
  86. package/dist/{sync-configs-TIBO6DUJ.js → sync-configs-BUUD6L5Q.js} +3 -2
  87. package/dist/sync-configs-BUUD6L5Q.js.map +1 -0
  88. package/dist/{types-Dg4mHvyf.d.ts → types-0jreMCF5.d.ts} +2 -2
  89. package/dist/{upgrade-TPTE26FG.js → upgrade-HHKWYJRN.js} +3 -2
  90. package/dist/upgrade-HHKWYJRN.js.map +1 -0
  91. package/dist/{websites-6YTT5ZTO.js → websites-EH2ULELZ.js} +8 -2
  92. package/dist/{write-audits-to-airtable-IWOO4FFH.js → write-audits-to-airtable-LZDYTNX5.js} +4 -4
  93. package/package.json +4 -1
  94. package/dist/airtable-WGQQ3KAD.js +0 -9
  95. package/dist/bump-deps-QNM3H4GI.js.map +0 -1
  96. package/dist/chunk-2DCA7FHZ.js.map +0 -1
  97. package/dist/chunk-42KKVLYN.js.map +0 -1
  98. package/dist/chunk-45AUGDFR.js.map +0 -1
  99. package/dist/chunk-6G7BC5SN.js.map +0 -1
  100. package/dist/chunk-6JFOAPW4.js.map +0 -1
  101. package/dist/chunk-6MT5W6UF.js.map +0 -1
  102. package/dist/chunk-C3PDS623.js.map +0 -1
  103. package/dist/chunk-E2GKKUEX.js.map +0 -1
  104. package/dist/chunk-GKSXZIMX.js.map +0 -1
  105. package/dist/chunk-LYCKN7ZR.js.map +0 -1
  106. package/dist/chunk-O6G4HHMF.js.map +0 -1
  107. package/dist/chunk-OTBA7DON.js.map +0 -1
  108. package/dist/convert-to-pnpm-XH7NYRVK.js.map +0 -1
  109. package/dist/init-UA6D7GKW.js.map +0 -1
  110. package/dist/selftest-55OJIWEB.js.map +0 -1
  111. package/dist/svelte-codemods-KJLNH6FQ.js.map +0 -1
  112. package/dist/sync-configs-TIBO6DUJ.js.map +0 -1
  113. package/dist/upgrade-TPTE26FG.js.map +0 -1
  114. /package/dist/{airtable-WGQQ3KAD.js.map → airtable-U2GTDTK5.js.map} +0 -0
  115. /package/dist/{announce-72R3LRNT.js.map → announce-HJCGEVJA.js.map} +0 -0
  116. /package/dist/{chunk-XWPZ46IN.js.map → chunk-BJCA7O4A.js.map} +0 -0
  117. /package/dist/{chunk-YQMYQAJX.js.map → chunk-GI4M2AMO.js.map} +0 -0
  118. /package/dist/{chunk-RINXPU3H.js.map → chunk-KLVMJYZZ.js.map} +0 -0
  119. /package/dist/{chunk-AESJBEM7.js.map → chunk-KRHPS3R4.js.map} +0 -0
  120. /package/dist/{chunk-A5KYRGQA.js.map → chunk-SQJ53Z7R.js.map} +0 -0
  121. /package/dist/{digest-Q2TAQHXF.js.map → digest-VX7Q4MNF.js.map} +0 -0
  122. /package/dist/{ensure-site-2PF522VL.js.map → ensure-site-O7G5ZJS7.js.map} +0 -0
  123. /package/dist/{github-signals-CLQC2IPY.js.map → github-signals-QVW2LB7N.js.map} +0 -0
  124. /package/dist/{launch-AENAS2O2.js.map → launch-JWXCTJRG.js.map} +0 -0
  125. /package/dist/{lighthouse-airtable-UBSXKHAQ.js.map → lighthouse-airtable-J5TLASJC.js.map} +0 -0
  126. /package/dist/{onboard-X6KJ5Z2V.js.map → onboard-6UQE5QIO.js.map} +0 -0
  127. /package/dist/{orchestrate-GADNFGDH.js.map → orchestrate-ZDH24Z6Y.js.map} +0 -0
  128. /package/dist/{preflight-D5RQGX4V.js.map → preflight-BNTMWTPG.js.map} +0 -0
  129. /package/dist/{renovate-dispatch-LG4RJGDY.js.map → renovate-dispatch-C3ALLACX.js.map} +0 -0
  130. /package/dist/{report-3FJ224H5.js.map → report-YLFSGNWR.js.map} +0 -0
  131. /package/dist/{self-updating-JTTFOJ7Q.js.map → self-updating-64FNRQB5.js.map} +0 -0
  132. /package/dist/{websites-6YTT5ZTO.js.map → websites-EH2ULELZ.js.map} +0 -0
  133. /package/dist/{write-audits-to-airtable-IWOO4FFH.js.map → write-audits-to-airtable-LZDYTNX5.js.map} +0 -0
@@ -0,0 +1,145 @@
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/health-endpoint/index.ts
13
+ import { access, mkdir, writeFile } from "fs/promises";
14
+ import { dirname, join } from "path";
15
+
16
+ // src/recipes/health-endpoint/template.ts
17
+ var HEALTH_ENDPOINT_RELATIVE = "src/routes/health/+server.ts";
18
+ var HEALTH_ENDPOINT_TEMPLATE = `import { json } from "@sveltejs/kit";
19
+ import { env as privateEnv } from "$env/dynamic/private";
20
+ import { env as publicEnv } from "$env/dynamic/public";
21
+ import * as prismicio from "$lib/prismicio";
22
+ import type { RequestHandler } from "./$types";
23
+
24
+ // A live probe \u2014 must never be prerendered.
25
+ export const prerender = false;
26
+
27
+ type PrismicHealth = "ok" | "error" | "skipped";
28
+
29
+ type PrismicClient = { getRepository: () => Promise<unknown> };
30
+ type PrismicModule = {
31
+ createClient?: (opts: { fetch: typeof globalThis.fetch }) => PrismicClient;
32
+ isPlaceholderRepo?: boolean;
33
+ };
34
+
35
+ // Server-side Prismic reachability probe. Hits the PUBLIC repository-metadata
36
+ // endpoint (getRepository \u2014 no token), time-boxed, returning ONLY a status
37
+ // string; the repository body is never included (/health is public).
38
+ async function probePrismic(fetch: typeof globalThis.fetch): Promise<PrismicHealth> {
39
+ const mod = prismicio as PrismicModule;
40
+ const isPlaceholder = mod.isPlaceholderRepo ?? false;
41
+ if (isPlaceholder || typeof mod.createClient !== "function") return "skipped";
42
+ let timer: ReturnType<typeof setTimeout> | undefined;
43
+ try {
44
+ const client = mod.createClient({ fetch });
45
+ const timeout = new Promise<never>((_, reject) => {
46
+ timer = setTimeout(() => reject(new Error("prismic health probe timed out")), 5000);
47
+ });
48
+ await Promise.race([client.getRepository(), timeout]);
49
+ return "ok";
50
+ } catch {
51
+ return "error";
52
+ } finally {
53
+ if (timer) clearTimeout(timer);
54
+ }
55
+ }
56
+
57
+ export const GET: RequestHandler = async ({ fetch }) => {
58
+ const prismic = await probePrismic(fetch);
59
+ const forms = {
60
+ ingestUrl: !!privateEnv.FORMS_INGEST_URL,
61
+ ingestToken: !!privateEnv.FORMS_INGEST_TOKEN,
62
+ turnstile: !!publicEnv.PUBLIC_TURNSTILE_SITE_KEY?.trim(),
63
+ };
64
+ // We're inside the handler, so the function ran; ok is false only when the
65
+ // Prismic probe actively errored.
66
+ const ok = prismic !== "error";
67
+ return json({ ok, prismic, forms });
68
+ };
69
+ `;
70
+ var HEALTH_ENDPOINT_TEMPLATE_NO_PRISMIC = `import { json } from "@sveltejs/kit";
71
+ import { env as privateEnv } from "$env/dynamic/private";
72
+ import { env as publicEnv } from "$env/dynamic/public";
73
+ import type { RequestHandler } from "./$types";
74
+
75
+ // A live probe \u2014 must never be prerendered.
76
+ export const prerender = false;
77
+
78
+ export const GET: RequestHandler = async () => {
79
+ // This site has no $lib/prismicio module, so there is no CMS to probe: report
80
+ // "skipped" (the gate treats CMS as "never ran", never a false green).
81
+ const prismic = "skipped" as const;
82
+ const forms = {
83
+ ingestUrl: !!privateEnv.FORMS_INGEST_URL,
84
+ ingestToken: !!privateEnv.FORMS_INGEST_TOKEN,
85
+ turnstile: !!publicEnv.PUBLIC_TURNSTILE_SITE_KEY?.trim(),
86
+ };
87
+ // We're inside the handler, so the function ran.
88
+ const ok = true;
89
+ return json({ ok, prismic, forms });
90
+ };
91
+ `;
92
+
93
+ // src/recipes/health-endpoint/index.ts
94
+ var PRISMICIO_CANDIDATES = [
95
+ "src/lib/prismicio.ts",
96
+ "src/lib/prismicio.js",
97
+ "src/lib/prismicio.mjs"
98
+ ];
99
+ async function fileExists(path) {
100
+ try {
101
+ await access(path);
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+ async function siteHasPrismic(sitePath) {
108
+ for (const rel of PRISMICIO_CANDIDATES) {
109
+ if (await fileExists(join(sitePath, rel))) return true;
110
+ }
111
+ return false;
112
+ }
113
+ async function healthEndpoint(site, deps = { spawn: defaultSpawn }) {
114
+ const target = join(site.path, HEALTH_ENDPOINT_RELATIVE);
115
+ return withRecipe({
116
+ name: "health-endpoint",
117
+ site,
118
+ plan: async () => {
119
+ if (await fileExists(target)) {
120
+ return { kind: "noop", notes: `${HEALTH_ENDPOINT_RELATIVE} already exists` };
121
+ }
122
+ const hasPrismic = await siteHasPrismic(site.path);
123
+ const template = hasPrismic ? HEALTH_ENDPOINT_TEMPLATE : HEALTH_ENDPOINT_TEMPLATE_NO_PRISMIC;
124
+ return { kind: "apply", plan: { target, template, hasPrismic } };
125
+ },
126
+ apply: async (planned, { commit, cwd }) => {
127
+ await mkdir(dirname(planned.target), { recursive: true });
128
+ await writeFile(planned.target, planned.template, "utf-8");
129
+ const notes = [];
130
+ if (!planned.hasPrismic) {
131
+ notes.push("no $lib/prismicio \u2014 wrote Prismic-free /health (prismic: skipped)");
132
+ }
133
+ if (!await formatWithPrettier(deps.spawn, cwd, [HEALTH_ENDPOINT_RELATIVE])) {
134
+ notes.push(PRETTIER_FLAG_NOTE);
135
+ }
136
+ await commit("feat: add /health endpoint (function-health probe)");
137
+ return notes.length > 0 ? { kind: "ok", notes: notes.join("; ") } : { kind: "ok" };
138
+ }
139
+ });
140
+ }
141
+
142
+ export {
143
+ healthEndpoint
144
+ };
145
+ //# sourceMappingURL=chunk-7MJBTV2R.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/recipes/health-endpoint/index.ts","../src/recipes/health-endpoint/template.ts"],"sourcesContent":["import { access, mkdir, 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 HEALTH_ENDPOINT_RELATIVE,\n HEALTH_ENDPOINT_TEMPLATE,\n HEALTH_ENDPOINT_TEMPLATE_NO_PRISMIC,\n} from \"./template.js\";\n\nexport type HealthEndpointDeps = { spawn: SpawnFn };\n\n/** Where a Prismic client module lives across the fleet — `.ts` on sources,\n * `.js`/`.mjs` on some compiled clones. Existence of ANY picks the Prismic\n * variant; none => the import-free variant (a static import of a missing module\n * breaks the Vite build, which feature-detection can't rescue). */\nconst PRISMICIO_CANDIDATES = [\n \"src/lib/prismicio.ts\",\n \"src/lib/prismicio.js\",\n \"src/lib/prismicio.mjs\",\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 siteHasPrismic(sitePath: string): Promise<boolean> {\n for (const rel of PRISMICIO_CANDIDATES) {\n if (await fileExists(join(sitePath, rel))) return true;\n }\n return false;\n}\n\n/**\n * Writes a resilient `src/routes/health/+server.ts` if the route doesn't already\n * exist. The function-health audit fetches this endpoint; existing sites need it\n * or the Report Health Gate blocks their Maintenance reports on \"unknown\". Operator\n * edits to an existing endpoint are never clobbered (noop on existing file).\n *\n * Two heterogeneity fixes the fleet rollout exposed: (1) it detects whether the\n * site has a `$lib/prismicio` module and writes the Prismic-free variant when it\n * doesn't (a static import of a missing module fails the build); (2) it runs the\n * SITE's own prettier on the written file before committing, so the file matches\n * that site's formatting config (tabs/spaces, quote style vary fleet-wide) and\n * doesn't red the CI format check. Prettier is best-effort — a site without it\n * installed commits unformatted with a flag note rather than failing the recipe.\n */\nexport async function healthEndpoint(\n site: Site,\n deps: HealthEndpointDeps = { spawn: defaultSpawn },\n): Promise<RecipeResult> {\n const target = join(site.path, HEALTH_ENDPOINT_RELATIVE);\n return withRecipe<{ target: string; template: string; hasPrismic: boolean }>({\n name: \"health-endpoint\",\n site,\n plan: async () => {\n if (await fileExists(target)) {\n return { kind: \"noop\", notes: `${HEALTH_ENDPOINT_RELATIVE} already exists` };\n }\n const hasPrismic = await siteHasPrismic(site.path);\n const template = hasPrismic ? HEALTH_ENDPOINT_TEMPLATE : HEALTH_ENDPOINT_TEMPLATE_NO_PRISMIC;\n return { kind: \"apply\", plan: { target, template, hasPrismic } };\n },\n apply: async (planned, { commit, cwd }) => {\n await mkdir(dirname(planned.target), { recursive: true });\n await writeFile(planned.target, planned.template, \"utf-8\");\n\n const notes: string[] = [];\n if (!planned.hasPrismic) {\n notes.push(\"no $lib/prismicio — wrote Prismic-free /health (prismic: skipped)\");\n }\n\n // Format to the SITE's own prettier config so CI's format check stays green\n // across the heterogeneous fleet. Best-effort: a site without prettier just\n // commits unformatted with a flag note (never fails the /health rollout).\n if (!(await formatWithPrettier(deps.spawn, cwd, [HEALTH_ENDPOINT_RELATIVE]))) {\n notes.push(PRETTIER_FLAG_NOTE);\n }\n\n await commit(\"feat: add /health endpoint (function-health probe)\");\n return notes.length > 0 ? { kind: \"ok\", notes: notes.join(\"; \") } : { kind: \"ok\" };\n },\n });\n}\n","/** Relative path inside a site where the /health endpoint lives. Deploys as a\n * Netlify function (adapter-netlify v6); the function-health audit fetches it. */\nexport const HEALTH_ENDPOINT_RELATIVE = \"src/routes/health/+server.ts\";\n\n/** Resilient /health for existing sites. Unlike the starter's, it does NOT\n * statically import { createClient, isPlaceholderRepo } from \"$lib/prismicio\" —\n * older clones lack `isPlaceholderRepo`, and a missing named import breaks the\n * Vite build. Instead it namespace-imports and feature-detects: `createClient`\n * is universal to Prismic SvelteKit sites; a missing one => prismic:\"skipped\"\n * (the gate treats CMS as \"never ran\" and keeps blocking — never a false green).\n * Any probe error => \"error\" (reds CMS, fail-safe). The gate keys off ok + prismic. */\nexport const HEALTH_ENDPOINT_TEMPLATE = `import { json } from \"@sveltejs/kit\";\nimport { env as privateEnv } from \"$env/dynamic/private\";\nimport { env as publicEnv } from \"$env/dynamic/public\";\nimport * as prismicio from \"$lib/prismicio\";\nimport type { RequestHandler } from \"./$types\";\n\n// A live probe — must never be prerendered.\nexport const prerender = false;\n\ntype PrismicHealth = \"ok\" | \"error\" | \"skipped\";\n\ntype PrismicClient = { getRepository: () => Promise<unknown> };\ntype PrismicModule = {\n createClient?: (opts: { fetch: typeof globalThis.fetch }) => PrismicClient;\n isPlaceholderRepo?: boolean;\n};\n\n// Server-side Prismic reachability probe. Hits the PUBLIC repository-metadata\n// endpoint (getRepository — no token), time-boxed, returning ONLY a status\n// string; the repository body is never included (/health is public).\nasync function probePrismic(fetch: typeof globalThis.fetch): Promise<PrismicHealth> {\n const mod = prismicio as PrismicModule;\n const isPlaceholder = mod.isPlaceholderRepo ?? false;\n if (isPlaceholder || typeof mod.createClient !== \"function\") return \"skipped\";\n let timer: ReturnType<typeof setTimeout> | undefined;\n try {\n const client = mod.createClient({ fetch });\n const timeout = new Promise<never>((_, reject) => {\n timer = setTimeout(() => reject(new Error(\"prismic health probe timed out\")), 5000);\n });\n await Promise.race([client.getRepository(), timeout]);\n return \"ok\";\n } catch {\n return \"error\";\n } finally {\n if (timer) clearTimeout(timer);\n }\n}\n\nexport const GET: RequestHandler = async ({ fetch }) => {\n const prismic = await probePrismic(fetch);\n const forms = {\n ingestUrl: !!privateEnv.FORMS_INGEST_URL,\n ingestToken: !!privateEnv.FORMS_INGEST_TOKEN,\n turnstile: !!publicEnv.PUBLIC_TURNSTILE_SITE_KEY?.trim(),\n };\n // We're inside the handler, so the function ran; ok is false only when the\n // Prismic probe actively errored.\n const ok = prismic !== \"error\";\n return json({ ok, prismic, forms });\n};\n`;\n\n/** Prismic-free /health for sites WITHOUT a `$lib/prismicio` module (non-Prismic\n * sites, e.g. a static/bespoke build). Feature-detection can survive a missing\n * *export*, but a static `import * as prismicio from \"$lib/prismicio\"` fails the\n * Vite build when the *module* doesn't exist — so those sites need a variant with\n * no import at all. CMS is reported as a constant \"skipped\" (the gate treats it as\n * \"never ran\" and never false-greens it). Same JSON shape as the Prismic template\n * (ok/prismic/forms) so the function-health audit parses both identically. */\nexport const HEALTH_ENDPOINT_TEMPLATE_NO_PRISMIC = `import { json } from \"@sveltejs/kit\";\nimport { env as privateEnv } from \"$env/dynamic/private\";\nimport { env as publicEnv } from \"$env/dynamic/public\";\nimport type { RequestHandler } from \"./$types\";\n\n// A live probe — must never be prerendered.\nexport const prerender = false;\n\nexport const GET: RequestHandler = async () => {\n // This site has no $lib/prismicio module, so there is no CMS to probe: report\n // \"skipped\" (the gate treats CMS as \"never ran\", never a false green).\n const prismic = \"skipped\" as const;\n const forms = {\n ingestUrl: !!privateEnv.FORMS_INGEST_URL,\n ingestToken: !!privateEnv.FORMS_INGEST_TOKEN,\n turnstile: !!publicEnv.PUBLIC_TURNSTILE_SITE_KEY?.trim(),\n };\n // We're inside the handler, so the function ran.\n const ok = true;\n return json({ ok, prismic, forms });\n};\n`;\n"],"mappings":";;;;;;;;;;;;AAAA,SAAS,QAAQ,OAAO,iBAAiB;AACzC,SAAS,SAAS,YAAY;;;ACCvB,IAAM,2BAA2B;AASjC,IAAM,2BAA2B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA4DjC,IAAM,sCAAsC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ADrDnD,IAAM,uBAAuB;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AACF;AAEA,eAAe,WAAW,MAAgC;AACxD,MAAI;AACF,UAAM,OAAO,IAAI;AACjB,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,eAAe,eAAe,UAAoC;AAChE,aAAW,OAAO,sBAAsB;AACtC,QAAI,MAAM,WAAW,KAAK,UAAU,GAAG,CAAC,EAAG,QAAO;AAAA,EACpD;AACA,SAAO;AACT;AAgBA,eAAsB,eACpB,MACA,OAA2B,EAAE,OAAO,aAAa,GAC1B;AACvB,QAAM,SAAS,KAAK,KAAK,MAAM,wBAAwB;AACvD,SAAO,WAAsE;AAAA,IAC3E,MAAM;AAAA,IACN;AAAA,IACA,MAAM,YAAY;AAChB,UAAI,MAAM,WAAW,MAAM,GAAG;AAC5B,eAAO,EAAE,MAAM,QAAQ,OAAO,GAAG,wBAAwB,kBAAkB;AAAA,MAC7E;AACA,YAAM,aAAa,MAAM,eAAe,KAAK,IAAI;AACjD,YAAM,WAAW,aAAa,2BAA2B;AACzD,aAAO,EAAE,MAAM,SAAS,MAAM,EAAE,QAAQ,UAAU,WAAW,EAAE;AAAA,IACjE;AAAA,IACA,OAAO,OAAO,SAAS,EAAE,QAAQ,IAAI,MAAM;AACzC,YAAM,MAAM,QAAQ,QAAQ,MAAM,GAAG,EAAE,WAAW,KAAK,CAAC;AACxD,YAAM,UAAU,QAAQ,QAAQ,QAAQ,UAAU,OAAO;AAEzD,YAAM,QAAkB,CAAC;AACzB,UAAI,CAAC,QAAQ,YAAY;AACvB,cAAM,KAAK,wEAAmE;AAAA,MAChF;AAKA,UAAI,CAAE,MAAM,mBAAmB,KAAK,OAAO,KAAK,CAAC,wBAAwB,CAAC,GAAI;AAC5E,cAAM,KAAK,kBAAkB;AAAA,MAC/B;AAEA,YAAM,OAAO,oDAAoD;AACjE,aAAO,MAAM,SAAS,IAAI,EAAE,MAAM,MAAM,OAAO,MAAM,KAAK,IAAI,EAAE,IAAI,EAAE,MAAM,KAAK;AAAA,IACnF;AAAA,EACF,CAAC;AACH;","names":[]}
@@ -3,26 +3,27 @@ import {
3
3
  } from "./chunk-GECESMVS.js";
4
4
  import {
5
5
  announcementSiteExtras,
6
- checklistFor,
7
6
  fetchAttachmentBytes,
8
- isChecklistComplete,
7
+ gatingHealth,
8
+ isHealthGateClear,
9
+ isSendOverridden,
9
10
  listSendableReports,
10
11
  loadBundledImages,
11
12
  renderReportHtml,
12
13
  resolveCopy,
13
14
  stampSent
14
- } from "./chunk-6JFOAPW4.js";
15
+ } from "./chunk-JCITWZQR.js";
15
16
  import {
16
17
  defaultResendClient
17
18
  } from "./chunk-WEKL2HYJ.js";
18
19
  import {
19
20
  recordFleetEventsBestEffort
20
- } from "./chunk-OTBA7DON.js";
21
+ } from "./chunk-GYNRZCM6.js";
21
22
  import {
22
23
  listWebsites,
23
24
  siteSlug,
24
25
  updateLaunched
25
- } from "./chunk-C3PDS623.js";
26
+ } from "./chunk-BWKK2ZXS.js";
26
27
  import {
27
28
  openBase,
28
29
  readAirtableConfig
@@ -146,6 +147,26 @@ async function sendApprovedReports(options = {}) {
146
147
  try {
147
148
  const messageId = await sendOne(client, base, site, report);
148
149
  lines.push(`\u2713 sent: ${report.reportId} (${messageId})`);
150
+ if (report.sendOverride) {
151
+ const failing = gatingHealth({
152
+ reportType: report.reportType,
153
+ autoEvidence: report.autoEvidence ?? {}
154
+ }).filter((h) => h.status !== "pass" && h.status !== "n/a").map((h) => h.field);
155
+ await recordFleetEventsBestEffort(
156
+ [
157
+ {
158
+ id: `report_sent_with_override:${report.id}`,
159
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
160
+ type: "report_sent_with_override",
161
+ siteId: site.id,
162
+ siteName: site.name,
163
+ summary: `sent with override \u2014 ${report.overrideReason ?? ""}`,
164
+ data: { reportId: report.reportId, reason: report.overrideReason, failing }
165
+ }
166
+ ],
167
+ /* @__PURE__ */ new Date()
168
+ );
169
+ }
149
170
  if (report.reportType === "Launch") {
150
171
  try {
151
172
  await updateLaunched(base, site.id, (/* @__PURE__ */ new Date()).toISOString());
@@ -176,12 +197,13 @@ async function sendApprovedReports(options = {}) {
176
197
  return { output: lines.join("\n"), code: anyFailed ? 1 : 0 };
177
198
  }
178
199
  async function sendOne(client, base, site, report) {
179
- if (!isChecklistComplete(report)) {
180
- const items = checklistFor(report.reportType);
181
- const done = items.filter((i) => report.checklist[i.field] === true).length;
182
- throw new Error(
183
- `Report ${report.reportId} checklist incomplete \u2014 ${done}/${items.length} items checked`
184
- );
200
+ const gateReport = { reportType: report.reportType, autoEvidence: report.autoEvidence ?? {} };
201
+ if (!isHealthGateClear(gateReport) && !isSendOverridden(report)) {
202
+ const failing = gatingHealth(gateReport).filter((h) => h.status !== "pass" && h.status !== "n/a").map((h) => {
203
+ const note = report.autoEvidence?.[h.field]?.note;
204
+ return `${h.field} (${h.status}${note ? `: ${note}` : ""})`;
205
+ }).join("; ");
206
+ throw new Error(`Report ${report.reportId} health gate not clear \u2014 ${failing}`);
185
207
  }
186
208
  if (!site.headerImage) {
187
209
  throw new Error(`Site '${site.name}' has no Header image set on the Websites row`);
@@ -309,4 +331,4 @@ export {
309
331
  parseAddresses,
310
332
  isProbablyEmail
311
333
  };
312
- //# sourceMappingURL=chunk-2DCA7FHZ.js.map
334
+ //# sourceMappingURL=chunk-7P7YETHG.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/send/render-email.ts","../src/reports/maintenance-email/header-image.ts","../src/reports/send/idempotency.ts","../src/reports/send/orchestrate.ts"],"sourcesContent":["import { renderReportHtml } from \"../render.js\";\nimport { loadBundledImages } from \"../maintenance-email/assets/index.js\";\nimport { defaultReportSubject } from \"../subject.js\";\nimport type { ReportData } from \"../types.js\";\nimport type { ResendSendInput } from \"./resend.js\";\n\n/** A single Resend inline attachment (CID-referenced). */\nexport type InlineAttachment = NonNullable<ResendSendInput[\"attachments\"]>[number];\n\n/** The downscaled header image + display metadata produced by `prepareHeaderImage`. */\nexport type PreparedHeader = {\n bytes: Uint8Array;\n contentType: string;\n displayWidth: number;\n displayHeight: number;\n placeholderColor: string;\n};\n\nexport type RenderedReportEmail = {\n html: string;\n attachments: InlineAttachment[];\n subject: string;\n};\n\n/** Build a Resend inline (CID-referenced) attachment from raw bytes — the header image and both\n * bundled images share this exact shape. */\nfunction toInlineAttachment(a: {\n bytes: Uint8Array;\n filename: string;\n contentType: string;\n cid: string;\n}): InlineAttachment {\n return {\n filename: a.filename,\n content: Buffer.from(a.bytes).toString(\"base64\"),\n contentType: a.contentType,\n inlineContentId: a.cid,\n };\n}\n\n/**\n * Render a report email from fully-assembled `ReportData`: produce the HTML, the gated inline\n * attachments, and the subject. The per-site header attaches always; the two bundled images\n * (`rd-check-png`, `rd-blurred-tests-jpg`) attach only when their cid actually appears in the\n * rendered HTML (a dangling inline part shows as a stray download in some clients). The subject\n * is `subjectOverride` when given, else `defaultReportSubject`. Shared by the production send path\n * (`sendOne`) and the `selftest` command so the rendered email, attachments, and subject can't\n * drift between them. The only I/O is `loadBundledImages` (a disk read of two bundled images).\n */\nexport async function renderReportEmail(\n reportData: ReportData,\n ctx: { header: PreparedHeader; cidName: string; subjectOverride?: string | undefined },\n): Promise<RenderedReportEmail> {\n const { html } = await renderReportHtml(reportData);\n const bundled = await loadBundledImages();\n const attachments: InlineAttachment[] = [\n toInlineAttachment({\n bytes: ctx.header.bytes,\n filename: `${ctx.cidName}.jpg`,\n contentType: ctx.header.contentType,\n cid: ctx.cidName,\n }),\n ];\n for (const img of [bundled.check, bundled.blurred]) {\n if (html.includes(`cid:${img.cid}`)) {\n attachments.push(\n toInlineAttachment({\n bytes: img.bytes,\n filename: img.filename,\n contentType: img.contentType,\n cid: img.cid,\n }),\n );\n }\n }\n const subject =\n ctx.subjectOverride ??\n defaultReportSubject({\n name: reportData.siteName,\n url: reportData.siteUrl,\n type: reportData.reportType,\n date: reportData.completedOn,\n });\n return { html, attachments, subject };\n}\n","import sharp from \"sharp\";\n\nexport type PreparedHeaderImage = {\n /** Resized JPEG bytes to attach inline (CID) in place of the Airtable original. */\n bytes: Uint8Array;\n /** Always \"image/jpeg\" — we re-encode for predictable size and a flat white background. */\n contentType: string;\n /** CSS display width in px (≤ requested, never wider than the source has pixels for). */\n displayWidth: number;\n /** CSS display height in px, source aspect ratio preserved (no distortion). */\n displayHeight: number;\n /** Dominant-color hex (e.g. \"#cfc3a8\"), used as the loading/blocked placeholder box. */\n placeholderColor: string;\n};\n\nexport type PrepareHeaderImageOptions = {\n /** Intended CSS display width. The email body is 600px, so that's the default. */\n displayWidth?: number;\n};\n\nconst DEFAULT_DISPLAY_WIDTH = 600;\n/** Encode the source at 2× display width so it stays crisp on retina screens. */\nconst RETINA_SCALE = 2;\n/** Quality is for *resized* pixels — at 1200px the texture/text read as sharp; bytes are tiny. */\nconst JPEG_QUALITY = 82;\n\nfunction channelToHex(value: number): string {\n return Math.max(0, Math.min(255, Math.round(value)))\n .toString(16)\n .padStart(2, \"0\");\n}\n\n/**\n * Downscale an oversized header image for email: 2× the display width (retina) at most,\n * never upscaled, re-encoded as JPEG on a flat white background. Also reports the display\n * dimensions (so the template can reserve the box and stop reflow) and a dominant color\n * (so the reserved box shows a matched placeholder while the image loads).\n *\n * Root cause this addresses: Airtable headers can be multi-MB / 2400px+ while the email\n * renders them at ~600px — shipping ~16× more pixels than the display can use.\n */\nexport async function prepareHeaderImage(\n bytes: Uint8Array,\n options: PrepareHeaderImageOptions = {},\n): Promise<PreparedHeaderImage> {\n const requestedDisplayWidth = options.displayWidth ?? DEFAULT_DISPLAY_WIDTH;\n const input = Buffer.from(bytes);\n\n const meta = await sharp(input).metadata();\n const origWidth = meta.width;\n const origHeight = meta.height;\n if (!origWidth || !origHeight) {\n throw new Error(\"prepareHeaderImage: could not read source image dimensions\");\n }\n\n // Never claim a wider display than the source can fill at 1×.\n const displayWidth = Math.min(requestedDisplayWidth, origWidth);\n const displayHeight = Math.round((displayWidth * origHeight) / origWidth);\n\n // Encode at 2× display for retina, but never enlarge a smaller original.\n const targetSourceWidth = Math.min(origWidth, displayWidth * RETINA_SCALE);\n\n const out = await sharp(input)\n .resize({ width: targetSourceWidth, withoutEnlargement: true })\n .flatten({ background: \"#ffffff\" })\n .jpeg({ quality: JPEG_QUALITY })\n .toBuffer();\n\n const { dominant } = await sharp(out).stats();\n const placeholderColor = `#${channelToHex(dominant.r)}${channelToHex(dominant.g)}${channelToHex(dominant.b)}`;\n\n return {\n bytes: new Uint8Array(out),\n contentType: \"image/jpeg\",\n displayWidth,\n displayHeight,\n placeholderColor,\n };\n}\n","/**\n * True when a thrown send error is Resend's same-key + DIFFERENT-body 409\n * (`invalid_idempotent_request`). The ResendClient (send/resend.ts) discards the\n * status/name and only surfaces the message string, so we match defensively on the\n * stable message substring \"idempotency key has been used\" (case-insensitive); a\n * `name`/`statusCode` of 409/`invalid_idempotent_request` is also accepted if a\n * future client happens to preserve it. A same-key + SAME-body re-send is deduped\n * by Resend (returns the original id) and never reaches here.\n *\n * Shared by both send surfaces that key into Resend's idempotency window:\n * `runDigest` (digest.ts, `digest-<date>` key) and `sendOne` (orchestrate.ts,\n * `report:<id>` key). Both treat a 409 as \"the email already went out under this\n * key on a prior run\" — a no-op for the digest, an already-done success for sendOne.\n */\nexport function isIdempotencyConflict(err: unknown): boolean {\n const message = err instanceof Error ? err.message : String(err);\n if (/idempotency key has been used/i.test(message)) return true;\n const e = err as { name?: unknown; statusCode?: unknown };\n if (e.name === \"invalid_idempotent_request\") return true;\n if (e.statusCode === 409) return true;\n return false;\n}\n","import { openBase, readAirtableConfig } from \"../airtable/client.js\";\nimport { listSendableReports, stampSent } from \"../airtable/reports.js\";\nimport { listWebsites, siteSlug, updateLaunched } from \"../airtable/websites.js\";\nimport type { WebsiteRow } from \"../airtable/websites.js\";\nimport type { ReportRow } from \"../airtable/reports.js\";\nimport { fetchAttachmentBytes } from \"../airtable/attachments.js\";\nimport { resolveCopy } from \"../copy.js\";\nimport { announcementSiteExtras } from \"../announcement-email/template.js\";\nimport { renderReportEmail } from \"./render-email.js\";\nimport type { ReportData } from \"../types.js\";\nimport { prepareHeaderImage } from \"../maintenance-email/header-image.js\";\nimport { defaultResendClient, type ResendClient } from \"./resend.js\";\nimport { isIdempotencyConflict } from \"./idempotency.js\";\nimport { gatingHealth, isHealthGateClear, isSendOverridden } from \"../checklist.js\";\nimport { recordFleetEventsBestEffort } from \"../../audits/fleet-events-writer.js\";\n\nconst FROM_ADDRESS = \"Reddoor Reports <reports@reddoorla.com>\";\nconst REPLY_TO = \"info@reddoorla.com\";\n\n/** Operations inbox CC'd on every outgoing report so there's always an internal\n * copy on file alongside the client recipients. */\nexport const GLOBAL_REPORT_CC = \"info@reddoorla.com\";\n\n/**\n * Append {@link GLOBAL_REPORT_CC} to a site's per-site CC list. The per-site CC is\n * passed through unchanged (preserving prior behavior); the global address is added\n * only when it isn't already present in the CC or To lists (case-insensitive), so a\n * report is never double-addressed to the ops inbox. Returns the final CC list (may\n * be empty if the global address is already the sole To recipient — the caller omits\n * an empty CC).\n */\nexport function withGlobalCc(perSiteCc: string[] | null, to: string[]): string[] {\n const cc = [...(perSiteCc ?? [])];\n const present = new Set([...cc, ...to].map((a) => a.toLowerCase()));\n if (!present.has(GLOBAL_REPORT_CC.toLowerCase())) cc.push(GLOBAL_REPORT_CC);\n return cc;\n}\n\n/** Whole days spanned by an ISO period window, or undefined when either bound is missing or the\n * span isn't positive. Drives the analytics trend's \"vs the previous N days\" label. */\nfunction windowDays(start: string | null, end: string | null): number | undefined {\n if (!start || !end) return undefined;\n const ms = new Date(end).getTime() - new Date(start).getTime();\n if (!Number.isFinite(ms) || ms <= 0) return undefined;\n return Math.round(ms / (24 * 60 * 60 * 1000));\n}\n\nexport type OrchestrateOptions = {\n resend?: ResendClient;\n};\n\nexport async function sendApprovedReports(\n options: OrchestrateOptions = {},\n): Promise<{ output: string; code: number }> {\n const base = openBase(readAirtableConfig());\n const client = options.resend ?? defaultResendClient();\n\n const sendable = await listSendableReports(base);\n if (sendable.length === 0) return { output: \"No reports ready to send.\", code: 0 };\n\n const websites = await listWebsites(base);\n const sites = new Map(websites.map((w) => [w.id, w]));\n\n const lines: string[] = [];\n let anyFailed = false;\n for (const report of sendable) {\n const site = sites.get(report.siteId);\n if (!site) {\n lines.push(`✗ ${report.reportId} — Site row not found for id=${report.siteId}`);\n anyFailed = true;\n continue;\n }\n try {\n const messageId = await sendOne(client, base, site, report);\n lines.push(`✓ sent: ${report.reportId} (${messageId})`);\n if (report.sendOverride) {\n const failing = gatingHealth({\n reportType: report.reportType,\n autoEvidence: report.autoEvidence ?? {},\n })\n .filter((h) => h.status !== \"pass\" && h.status !== \"n/a\")\n .map((h) => h.field);\n await recordFleetEventsBestEffort(\n [\n {\n id: `report_sent_with_override:${report.id}`,\n ts: new Date().toISOString(),\n type: \"report_sent_with_override\",\n siteId: site.id,\n siteName: site.name,\n summary: `sent with override — ${report.overrideReason ?? \"\"}`,\n data: { reportId: report.reportId, reason: report.overrideReason, failing },\n },\n ],\n new Date(),\n );\n }\n if (report.reportType === \"Launch\") {\n try {\n await updateLaunched(base, site.id, new Date().toISOString());\n lines.push(` ↳ launched: ${site.name} flipped to maintenance`);\n await recordFleetEventsBestEffort(\n [\n {\n id: `site_launched:${site.id}`,\n ts: new Date().toISOString(),\n type: \"site_launched\",\n siteId: site.id,\n siteName: site.name,\n summary: \"launched — now in maintenance\",\n data: null,\n },\n ],\n new Date(),\n );\n } catch (e) {\n lines.push(` ⚠ launch flip failed for ${site.name}: ${(e as Error).message}`);\n }\n }\n } catch (e) {\n lines.push(`✗ ${report.reportId} — ${(e as Error).message}`);\n anyFailed = true;\n }\n }\n return { output: lines.join(\"\\n\"), code: anyFailed ? 1 : 0 };\n}\n\nasync function sendOne(\n client: ResendClient,\n base: ReturnType<typeof openBase>,\n site: WebsiteRow,\n report: ReportRow,\n): Promise<string> {\n // Hard health gate: a Maintenance/Testing report whose gating evidence isn't all pass/n/a must\n // never go out — even if \"Approved to send\" was set directly in Airtable. Throw so the row is\n // skipped and `Sent at` stays null (at-least-once retry preserved). Launch/Announcement have no\n // gating fields → vacuously clear. A logged send-anyway override (Phase 10) bypasses the gate.\n const gateReport = { reportType: report.reportType, autoEvidence: report.autoEvidence ?? {} };\n if (!isHealthGateClear(gateReport) && !isSendOverridden(report)) {\n const failing = gatingHealth(gateReport)\n .filter((h) => h.status !== \"pass\" && h.status !== \"n/a\")\n .map((h) => {\n const note = report.autoEvidence?.[h.field]?.note;\n return `${h.field} (${h.status}${note ? `: ${note}` : \"\"})`;\n })\n .join(\"; \");\n throw new Error(`Report ${report.reportId} health gate not clear — ${failing}`);\n }\n if (!site.headerImage) {\n throw new Error(`Site '${site.name}' has no Header image set on the Websites row`);\n }\n if (!report.lighthouse) {\n throw new Error(\n `Report ${report.reportId} has no Lighthouse scores — all four cells ` +\n `(Lighthouse — Performance / Accessibility / Best Practices / SEO) must be numeric ` +\n `on the Reports row; one non-numeric or blank cell nulls all four`,\n );\n }\n\n // Resolve + validate recipients BEFORE the expensive work (header fetch + sharp\n // downscale + full MJML render). A misconfigured-recipients site is a guaranteed\n // failure, so fail fast here rather than after burning that work. Same checks +\n // messages as before — only the position moved.\n const explicitTo = parseAddresses(site.reportRecipientsTo);\n // Run pointOfContact through the parser too — operators sometimes paste\n // \"a@x, b@y\" into that single-line field.\n const fallbackTo = parseAddresses(site.pointOfContact);\n const to = explicitTo ?? fallbackTo ?? [];\n if (to.length === 0) {\n throw new Error(\n `Site '${site.name}' has no recipients (Report recipients (To) AND point of contact are both empty)`,\n );\n }\n for (const addr of to) {\n if (!isProbablyEmail(addr)) {\n throw new Error(\n `Site '${site.name}' recipient is malformed: ${addr} — use a bare address only ` +\n `(no \\`Name <addr>\\` display-name syntax); fix Report recipients (To) or point of contact in Airtable`,\n );\n }\n }\n const cc = parseAddresses(site.reportRecipientsCc);\n if (cc) {\n for (const addr of cc) {\n if (!isProbablyEmail(addr)) {\n throw new Error(\n `Site '${site.name}' CC is malformed: ${addr} — fix Report recipients (CC) in Airtable`,\n );\n }\n }\n }\n\n const original = await fetchAttachmentBytes(site.headerImage.url);\n // Downscale the (often multi-MB / 2400px+) Airtable header to email display size, and get\n // back display dims + a placeholder color so the template can reserve the box.\n const header = await prepareHeaderImage(original.bytes);\n\n const slug = siteSlug(site.name);\n const cidName = `${slug}-header`;\n const gaPeriodDays =\n report.reportType === \"Announcement\" ? 30 : windowDays(report.periodStart, report.periodEnd);\n const reportData: ReportData = {\n siteName: site.name,\n siteUrl: site.url,\n reportType: report.reportType,\n completedOn: report.completedOn ? new Date(report.completedOn) : new Date(),\n lighthouse: report.lighthouse,\n gaUsersCurrent: report.gaUsersCurrent ?? undefined,\n gaUsersPrevious: report.gaUsersPrevious ?? undefined,\n gaPeriodDays,\n searchPosition:\n report.searchFoundPage1 && report.searchPosition !== null ? report.searchPosition : undefined,\n lastTestedDate: report.lastTestedDate ? new Date(report.lastTestedDate) : null,\n commentary: report.commentary,\n copy: resolveCopy(site),\n headerImageCid: cidName,\n headerWidth: header.displayWidth,\n headerHeight: header.displayHeight,\n headerBgColor: header.placeholderColor,\n // Announcement-only: re-derive cadence + improvements from the site row so the SENT email\n // keeps its cadence copy + improvement callouts (not stored on the Reports row).\n ...(report.reportType === \"Announcement\" ? announcementSiteExtras(site) : {}),\n };\n const { html, attachments, subject } = await renderReportEmail(reportData, {\n header,\n cidName,\n subjectOverride: report.subjectOverride ?? undefined,\n });\n\n const payload: Parameters<ResendClient[\"send\"]>[0] = {\n from: FROM_ADDRESS,\n to,\n replyTo: REPLY_TO,\n subject,\n html,\n attachments,\n // Stable across retries of the same row — if Airtable stamping fails after a\n // successful Resend, the next --send-ready replays with the same key and\n // Resend returns the original message id rather than sending a duplicate.\n idempotencyKey: `report:${report.id}`,\n };\n // Always CC the ops inbox (info@reddoorla.com), in addition to any per-site CC.\n const finalCc = withGlobalCc(cc, to);\n if (finalCc.length > 0) payload.cc = finalCc;\n\n let result: Awaited<ReturnType<ResendClient[\"send\"]>>;\n try {\n result = await client.send(payload);\n } catch (err) {\n // The send path is at-least-once: client.send succeeds → stampSent writes\n // `Sent at` (the ONLY thing that removes the row from listSendableReports). If\n // stampSent threw on a PRIOR run (an Airtable blip), `Sent at` stayed null and\n // the row replays here. By replay time the rendered body has usually changed\n // (operator Commentary edit, `report --due` rewrote scores, or the header\n // re-encodes non-deterministically), so Resend rejects the same-key\n // (`report:<id>`) / different-body re-send with a 409 (`invalid_idempotent_request`).\n //\n // That 409 means the email ALREADY WENT OUT under this key on the prior run.\n // Do NOT re-throw and do NOT re-send (re-throwing leaves the row unstamped, and\n // after the 24h key TTL a SECOND real email would go out). Instead stamp the row\n // so it stops replaying, then return success so the caller runs the Launch flip —\n // which self-heals a launch that sent-but-never-flipped on the prior run.\n //\n // Any OTHER error (real network/Resend failure) re-throws, exactly as before, so\n // a genuine failure still fails loudly and the row replays next run.\n if (isIdempotencyConflict(err)) {\n // Stamp `Sent at` ONLY — the original send's messageId is unrecoverable on\n // the 409 path, so we leave `Resend message ID` null rather than writing a\n // sentinel that would masquerade as a real id and orphan webhook lookups.\n // Still return the sentinel string so the caller logs the already-sent path\n // and runs the Launch flip.\n await stampSent(base, report.id, new Date(), null);\n console.log(`↻ already sent (idempotency conflict), stamped: ${report.reportId}`);\n return \"idempotent-conflict\";\n }\n throw err;\n }\n await stampSent(base, report.id, new Date(), result.messageId);\n return result.messageId;\n}\n\n/**\n * Split a comma/newline-separated address field into a clean array.\n * Lowercases (case-insensitive dedupe) and removes empty entries. Returns\n * null if nothing survives. Does NOT understand `Display Name <email>` —\n * operators should put a bare address in the Airtable field, or use multiple\n * lines if needing multiple recipients.\n */\nexport function parseAddresses(field: string | null): string[] | null {\n if (!field) return null;\n const seen = new Set<string>();\n const list: string[] = [];\n for (const raw of field.split(/[,\\n]/)) {\n const trimmed = raw.trim().toLowerCase();\n if (!trimmed) continue;\n if (seen.has(trimmed)) continue;\n seen.add(trimmed);\n list.push(trimmed);\n }\n return list.length > 0 ? list : null;\n}\n\n/**\n * Cheap email shape check — must contain exactly one @, with non-empty\n * local and domain parts and at least one dot in the domain. We're not\n * trying to be a full RFC validator; we're trying to catch operator\n * mistakes like \"ops at acme dot com\" or a missing @ before they 422\n * at Resend.\n */\nexport function isProbablyEmail(s: string): boolean {\n const at = s.indexOf(\"@\");\n if (at < 1 || at !== s.lastIndexOf(\"@\")) return false;\n const local = s.slice(0, at);\n const domain = s.slice(at + 1);\n if (!local || !domain) return false;\n if (!domain.includes(\".\")) return false;\n if (/\\s/.test(s)) return false;\n return true;\n}\n"],"mappings":"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","names":[]}
@@ -4,6 +4,12 @@ import {
4
4
  import {
5
5
  svelteCodemods
6
6
  } from "./chunk-BTEBRTW4.js";
7
+ import {
8
+ healthEndpoint
9
+ } from "./chunk-7MJBTV2R.js";
10
+ import {
11
+ smokeSuite
12
+ } from "./chunk-PKVZMFEB.js";
7
13
  import {
8
14
  onboard
9
15
  } from "./chunk-ZYBOT5PM.js";
@@ -15,7 +21,7 @@ import {
15
21
  } from "./chunk-XKOZUVK2.js";
16
22
  import {
17
23
  runAudits
18
- } from "./chunk-6G7BC5SN.js";
24
+ } from "./chunk-BHE5CUWI.js";
19
25
  import {
20
26
  siteLabel
21
27
  } from "./chunk-XXTZBPUY.js";
@@ -105,6 +111,8 @@ var DEFAULT_INIT_STEPS = [
105
111
  recipeStep("sync-configs", syncConfigs),
106
112
  recipeStep("svelte-codemods", svelteCodemods),
107
113
  recipeStep("a11y-fixtures-page", a11yFixturesPage),
114
+ recipeStep("health-endpoint", healthEndpoint),
115
+ recipeStep("smoke-suite", smokeSuite),
108
116
  {
109
117
  name: "audit",
110
118
  run: async (site) => ({ kind: "audit", results: await runAudits(site) })
@@ -135,4 +143,4 @@ export {
135
143
  DEFAULT_INIT_STEPS,
136
144
  init
137
145
  };
138
- //# sourceMappingURL=chunk-42KKVLYN.js.map
146
+ //# sourceMappingURL=chunk-APTAITXZ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/recipes/a11y-fixtures-page/index.ts","../src/recipes/a11y-fixtures-page/template.ts","../src/recipes/init.ts"],"sourcesContent":["import { access, mkdir, 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 { A11Y_FIXTURES_PAGE_RELATIVE, A11Y_FIXTURES_PAGE_TEMPLATE } from \"./template.js\";\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\n/**\n * Writes a starter `src/routes/dev/a11y-fixtures/+page.svelte` if the route\n * doesn't already exist. The hardcoded URL in `src/configs/lighthouse.ts` +\n * `src/configs/playwright-a11y.ts` targets this path — newly-onboarded sites\n * need the route to exist for either audit to pass. Operator edits to an\n * existing page are never clobbered (noop on existing file).\n */\nexport async function a11yFixturesPage(site: Site): Promise<RecipeResult> {\n const target = join(site.path, A11Y_FIXTURES_PAGE_RELATIVE);\n return withRecipe<{ target: string }>({\n name: \"a11y-fixtures-page\",\n site,\n plan: async () => {\n if (await fileExists(target)) {\n return { kind: \"noop\", notes: `${A11Y_FIXTURES_PAGE_RELATIVE} already exists` };\n }\n return { kind: \"apply\", plan: { target } };\n },\n apply: async (planned, { commit }) => {\n await mkdir(dirname(planned.target), { recursive: true });\n await writeFile(planned.target, A11Y_FIXTURES_PAGE_TEMPLATE, \"utf-8\");\n await commit(\"feat: add /dev/a11y-fixtures starter route\");\n return { kind: \"ok\" };\n },\n });\n}\n","/** Relative path inside a site where the a11y fixtures route lives. The\n * hardcoded URL in `src/configs/lighthouse.ts` + `src/configs/playwright-a11y.ts`\n * is `/dev/a11y-fixtures`, so a SvelteKit `+page.svelte` here resolves. */\nexport const A11Y_FIXTURES_PAGE_RELATIVE = \"src/routes/dev/a11y-fixtures/+page.svelte\";\n\n/** Stub `+page.svelte` for newly-onboarded sites. Generic on purpose —\n * landmarks, heading hierarchy, and a relative link cover the axe-core +\n * lhci defaults without committing the operator to any specific fixture\n * shape. Replace with site-specific patterns over time. */\nexport const A11Y_FIXTURES_PAGE_TEMPLATE = `<svelte:head>\n <title>a11y fixtures — Reddoor</title>\n <meta\n name=\"description\"\n content=\"Reddoor accessibility fixtures — semantic landmarks, heading hierarchy, and a stable target for @lhci/cli and Playwright + axe-core coverage. Not linked from the public site.\"\n />\n</svelte:head>\n\n<main>\n <header>\n <h1>Accessibility fixtures</h1>\n <p>\n This page exists so <code>@lhci/cli</code> and Playwright + axe-core have a\n stable target with predictable a11y characteristics. It is not linked from\n the public site.\n </p>\n </header>\n\n <section aria-labelledby=\"landmarks-heading\">\n <h2 id=\"landmarks-heading\">Landmarks</h2>\n <p>\n A single <code>main</code> wraps the page; sections each declare\n <code>aria-labelledby</code> matched to their heading id so screen readers\n and axe both see a clean outline.\n </p>\n </section>\n\n <section aria-labelledby=\"links-heading\">\n <h2 id=\"links-heading\">Links</h2>\n <p>\n <a href=\"/\">Back to home</a> — relative link with descriptive visible text,\n so no <code>aria-label</code> override is needed.\n </p>\n </section>\n</main>\n`;\n","import type { AuditResult, RecipeResult, Site } from \"../types.js\";\nimport { siteLabel } from \"../util/site.js\";\nimport { convertToPnpm } from \"./convert-to-pnpm.js\";\nimport { onboard } from \"./onboard.js\";\nimport { syncConfigs } from \"./sync-configs.js\";\nimport { svelteCodemods } from \"./svelte-codemods.js\";\nimport { a11yFixturesPage } from \"./a11y-fixtures-page/index.js\";\nimport { healthEndpoint } from \"./health-endpoint/index.js\";\nimport { smokeSuite } from \"./smoke-suite/index.js\";\nimport { runAudits } from \"../audits/index.js\";\n\nexport type InitStepResult =\n | { kind: \"recipe\"; result: RecipeResult }\n | { kind: \"audit\"; results: AuditResult[] }\n | { kind: \"error\"; message: string };\n\nexport type InitStep = {\n name: string;\n run: (site: Site) => Promise<InitStepResult>;\n};\n\nexport type InitResult = {\n site: string;\n steps: Array<{ name: string; result: InitStepResult }>;\n /** True if every step ran; false if an `error` or `failed` recipe result\n * short-circuited the chain. `noop` recipes do not break completeness. */\n complete: boolean;\n};\n\nexport type InitOptions = {\n /** Override the default step list. Tests inject mocked steps; production\n * code relies on the default. */\n steps?: InitStep[];\n};\n\nfunction recipeStep(name: string, fn: (site: Site) => Promise<RecipeResult>): InitStep {\n return {\n name,\n run: async (site) => ({ kind: \"recipe\", result: await fn(site) }),\n };\n}\n\n/** convert-to-pnpm → onboard → sync-configs → svelte-codemods →\n * a11y-fixtures-page → health-endpoint → smoke-suite → audit. Order is\n * deliberate — every step depends on the prior one's output (pnpm before\n * onboard's installs, onboard's deps before sync-configs writes lighthouserc,\n * fixtures page before audit actually has a route to hit; smoke-suite's\n * `pnpm install` lands before the closing audit). */\nexport const DEFAULT_INIT_STEPS: InitStep[] = [\n recipeStep(\"convert-to-pnpm\", convertToPnpm),\n recipeStep(\"onboard\", onboard),\n recipeStep(\"sync-configs\", syncConfigs),\n recipeStep(\"svelte-codemods\", svelteCodemods),\n recipeStep(\"a11y-fixtures-page\", a11yFixturesPage),\n recipeStep(\"health-endpoint\", healthEndpoint),\n recipeStep(\"smoke-suite\", smokeSuite),\n {\n name: \"audit\",\n run: async (site) => ({ kind: \"audit\", results: await runAudits(site) }),\n },\n];\n\n/**\n * One-shot guided onboarding. Runs the default step sequence against a\n * site, collecting per-step results into an InitResult. Each underlying\n * recipe still creates its own branch — init is a thin orchestrator, not\n * a branch-collapser; the operator ends up with one stack of branches per\n * mutated step (recipes that noop don't branch).\n *\n * Stops the chain on the first uncaught error or `failed` recipe result.\n * `noop` results are expected (e.g. running init twice) and continue the\n * chain. The final audit pass runs if no prior step errored.\n */\nexport async function init(site: Site, opts: InitOptions = {}): Promise<InitResult> {\n const steps = opts.steps ?? DEFAULT_INIT_STEPS;\n const out: Array<{ name: string; result: InitStepResult }> = [];\n\n for (const step of steps) {\n let result: InitStepResult;\n try {\n result = await step.run(site);\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n out.push({ name: step.name, result: { kind: \"error\", message } });\n return { site: siteLabel(site), steps: out, complete: false };\n }\n out.push({ name: step.name, result });\n if (result.kind === \"recipe\" && result.result.status === \"failed\") {\n return { site: siteLabel(site), steps: out, complete: false };\n }\n }\n\n return { site: siteLabel(site), steps: out, complete: true };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,SAAS,QAAQ,OAAO,iBAAiB;AACzC,SAAS,SAAS,YAAY;;;ACEvB,IAAM,8BAA8B;AAMpC,IAAM,8BAA8B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ADH3C,eAAe,WAAW,MAAgC;AACxD,MAAI;AACF,UAAM,OAAO,IAAI;AACjB,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AASA,eAAsB,iBAAiB,MAAmC;AACxE,QAAM,SAAS,KAAK,KAAK,MAAM,2BAA2B;AAC1D,SAAO,WAA+B;AAAA,IACpC,MAAM;AAAA,IACN;AAAA,IACA,MAAM,YAAY;AAChB,UAAI,MAAM,WAAW,MAAM,GAAG;AAC5B,eAAO,EAAE,MAAM,QAAQ,OAAO,GAAG,2BAA2B,kBAAkB;AAAA,MAChF;AACA,aAAO,EAAE,MAAM,SAAS,MAAM,EAAE,OAAO,EAAE;AAAA,IAC3C;AAAA,IACA,OAAO,OAAO,SAAS,EAAE,OAAO,MAAM;AACpC,YAAM,MAAM,QAAQ,QAAQ,MAAM,GAAG,EAAE,WAAW,KAAK,CAAC;AACxD,YAAM,UAAU,QAAQ,QAAQ,6BAA6B,OAAO;AACpE,YAAM,OAAO,4CAA4C;AACzD,aAAO,EAAE,MAAM,KAAK;AAAA,IACtB;AAAA,EACF,CAAC;AACH;;;AELA,SAAS,WAAW,MAAc,IAAqD;AACrF,SAAO;AAAA,IACL;AAAA,IACA,KAAK,OAAO,UAAU,EAAE,MAAM,UAAU,QAAQ,MAAM,GAAG,IAAI,EAAE;AAAA,EACjE;AACF;AAQO,IAAM,qBAAiC;AAAA,EAC5C,WAAW,mBAAmB,aAAa;AAAA,EAC3C,WAAW,WAAW,OAAO;AAAA,EAC7B,WAAW,gBAAgB,WAAW;AAAA,EACtC,WAAW,mBAAmB,cAAc;AAAA,EAC5C,WAAW,sBAAsB,gBAAgB;AAAA,EACjD,WAAW,mBAAmB,cAAc;AAAA,EAC5C,WAAW,eAAe,UAAU;AAAA,EACpC;AAAA,IACE,MAAM;AAAA,IACN,KAAK,OAAO,UAAU,EAAE,MAAM,SAAS,SAAS,MAAM,UAAU,IAAI,EAAE;AAAA,EACxE;AACF;AAaA,eAAsB,KAAK,MAAY,OAAoB,CAAC,GAAwB;AAClF,QAAM,QAAQ,KAAK,SAAS;AAC5B,QAAM,MAAuD,CAAC;AAE9D,aAAW,QAAQ,OAAO;AACxB,QAAI;AACJ,QAAI;AACF,eAAS,MAAM,KAAK,IAAI,IAAI;AAAA,IAC9B,SAAS,KAAK;AACZ,YAAM,UAAU,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAC/D,UAAI,KAAK,EAAE,MAAM,KAAK,MAAM,QAAQ,EAAE,MAAM,SAAS,QAAQ,EAAE,CAAC;AAChE,aAAO,EAAE,MAAM,UAAU,IAAI,GAAG,OAAO,KAAK,UAAU,MAAM;AAAA,IAC9D;AACA,QAAI,KAAK,EAAE,MAAM,KAAK,MAAM,OAAO,CAAC;AACpC,QAAI,OAAO,SAAS,YAAY,OAAO,OAAO,WAAW,UAAU;AACjE,aAAO,EAAE,MAAM,UAAU,IAAI,GAAG,OAAO,KAAK,UAAU,MAAM;AAAA,IAC9D;AAAA,EACF;AAEA,SAAO,EAAE,MAAM,UAAU,IAAI,GAAG,OAAO,KAAK,UAAU,KAAK;AAC7D;","names":[]}