@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 @@
1
+ {"version":3,"sources":["../src/reports/ga/config.ts","../src/reports/draft.ts","../src/reports/auto-tick.ts","../src/reports/ga/client.ts","../src/reports/ga/failover.ts","../src/reports/search/client.ts"],"sourcesContent":["import { dirname, join } from \"node:path\";\nimport { defaultCredentialsPath } from \"../../util/credentials.js\";\n\nexport type GaConfig = {\n /** Workspace users the service account impersonates (domain-wide delegation), in\n * failover order — the first subject that authenticates wins. */\n subjects: string[];\n /** Absolute path to the service-account JSON key file. */\n keyPath: string;\n};\n\n/**\n * Read GA configuration from the environment (credentials.env is already loaded into\n * process.env by the CLI entrypoint). Returns null when `GA_SUBJECT` is unset/blank — the\n * signal that GA enrichment is simply not configured, so drafting skips it silently.\n *\n * `GA_SUBJECT` is a comma-separated list of impersonation subjects tried in order\n * (e.g. `reports@reddoorla.com,person@reddoorla.com`), so losing one Workspace account\n * degrades to a logged failover instead of blanking the whole fleet's analytics; a single\n * address remains the degenerate one-element case. Entries are trimmed, empties dropped.\n *\n * `GA_SA_KEY_PATH` is optional; it defaults to `ga-service-account.json` alongside the\n * credentials file (e.g. ~/.config/reddoor-maint/), keeping the key out of the repo.\n */\nexport function readGaConfig(): GaConfig | null {\n const subjects = (process.env.GA_SUBJECT ?? \"\")\n .split(\",\")\n .map((s) => s.trim())\n .filter(Boolean);\n if (subjects.length === 0) return null;\n const keyPath =\n process.env.GA_SA_KEY_PATH?.trim() ||\n join(dirname(defaultCredentialsPath()), \"ga-service-account.json\");\n return { subjects, keyPath };\n}\n","import { mkdir, writeFile } from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\nimport type { ReportType, LighthouseScores } from \"./types.js\";\nimport { renderReportHtml } from \"./render.js\";\nimport { siteSlug, updateAnalyticsHealth } from \"./airtable/websites.js\";\nimport { resolveCopy } from \"./copy.js\";\nimport type { WebsiteRow } from \"./airtable/websites.js\";\nimport type { ReportRow } from \"./airtable/reports.js\";\nimport { createDraft, listReportsForSite } from \"./airtable/reports.js\";\nimport { queueDraft } from \"./queue.js\";\nimport { autoTickChecklist } from \"./auto-tick.js\";\nimport { uploadAttachment } from \"./airtable/attachments.js\";\nimport type { AirtableBase } from \"./airtable/client.js\";\nimport { readGaConfig } from \"./ga/config.js\";\nimport { fetchPeriodUsers } from \"./ga/client.js\";\nimport { fetchSearchPresence } from \"./search/client.js\";\nimport type { SearchPresence } from \"./search/client.js\";\n\nexport type DraftOptions = {\n /** Where to write the local preview HTML when `previewOnly`. Defaults to `reports/<slug>/draft.html`. */\n previewPath?: string;\n /** If true: render locally only, never touch Airtable. */\n previewOnly?: boolean;\n /** UTC \"YYYY-MM\" recurrence key; falls back to periodEnd's month when omitted. */\n period?: string;\n /** Airtable record id of an EXISTING (not-ready) row to COMPLETE in place rather\n * than creating a new one. When set, we skip createDraft and only re-render →\n * upload the HTML attachment → flip Draft ready on this row. Used by the --due\n * re-draft path to finish a draft whose createDraft succeeded but whose\n * setDraftReady never ran (a crash mid-sequence wedged the period). */\n completeRowId?: string;\n /** The mapped ReportRow being completed, returned as `reportRow` from the\n * complete path so callers keep the same shape they get on the create path. */\n existingRow?: ReportRow;\n};\n\n/** An enrichment fetch that *errored* (not one that was legitimately skipped\n * because it isn't configured / the site lacks the inputs). Surfaced so a\n * fleet-wide GA/Search outage is visible in a `--due` batch summary instead of\n * hiding behind one easily-missed console.warn per site. */\nexport type SoftFailure = \"ga\" | \"search\";\n\nexport type DraftResult = {\n /** null when previewOnly. */\n reportRow: ReportRow | null;\n /** Path to the local preview file (only set when previewOnly). */\n htmlPath: string | null;\n /** Always present — the rendered HTML string. */\n html: string;\n /** Enrichment fetches that errored for this site (empty on success or skip). */\n softFailures: SoftFailure[];\n /** Whether the draft was placed in the approve queue. False when a higher-or-equal-tier\n * report is already queued for the site (single-queue rule); null on the previewOnly path. */\n queued: boolean | null;\n /** Ids of lower-tier queued reports this draft superseded (un-queued). */\n supersededIds: string[];\n};\n\nfunction scoresFromWebsite(siteRow: WebsiteRow): LighthouseScores {\n const { pScore, rScore, bpScore, seoScore } = siteRow;\n if (pScore === null || rScore === null || bpScore === null || seoScore === null) {\n throw new Error(\n `Site '${siteRow.name}' is missing one or more Lighthouse scores on the Websites row (pScore, rScore, bpScore, seoScore). ` +\n `Run 'reddoor-maint audit lighthouse' from the site's checkout and paste the four numbers into Airtable, then retry.`,\n );\n }\n return { performance: pScore, accessibility: rScore, bestPractices: bpScore, seo: seoScore };\n}\n\nfunction daysAgo(today: Date, n: number): Date {\n // UTC accessors to stay TZ-consistent with `due.ts` (and avoid landing\n // Airtable's `Period start` on a different calendar day than the operator\n // expects on late-night runs near a month boundary). See morning brief\n // 2026-05-29 (M1) for context.\n const out = new Date(today);\n out.setUTCDate(out.getUTCDate() - n);\n return out;\n}\n\n/**\n * Render and create an Airtable draft for one site.\n *\n * No idempotency guard here — the recurrence guard lives in draftDueReports\n * (cli/commands/report.ts), keyed on reportPeriodKey(dueDate). The manual\n * single-site path intentionally always drafts (an operator asking for a draft\n * gets one). findReportByPeriod (airtable/reports.ts) is the real-Airtable\n * point lookup available to dashboard/digest callers that need the same\n * idempotency guarantee outside the CLI batch loop.\n */\nexport async function draftReportForSite(\n base: AirtableBase | null,\n siteRow: WebsiteRow,\n reportType: ReportType,\n options: DraftOptions = {},\n): Promise<DraftResult> {\n const scores = scoresFromWebsite(siteRow);\n\n const today = new Date();\n const slug = siteSlug(siteRow.name);\n\n const periodStart =\n base !== null ? await derivePeriodStart(base, siteRow, reportType, today) : daysAgo(today, 30);\n\n const periodEnd = today;\n const completedOn = today;\n // \"Last Tested\" on the Maintenance email is the REAL timestamp of the most recent automated\n // Lighthouse audit — stamped live on the Websites row (`Last lighthouse audit at`) by\n // `audit lighthouse --write-airtable` every time the scores refresh. It is deliberately NOT\n // the `testing day` field: that's the recurrence anchor consumed by due.ts and is hand-set, so\n // it goes stale. Reading the audit timestamp keeps the date current with no manual upkeep.\n const lastTestedDate =\n reportType === \"Maintenance\" && siteRow.lastLighthouseAuditAt\n ? new Date(siteRow.lastLighthouseAuditAt)\n : null;\n\n // GA enrichment (real path only). Soft-fail: any GA problem leaves the numbers null so\n // the draft still proceeds (operator fills them manually) — GA is an enhancement, not a\n // gate. Rendered with the fetched numbers so the review HTML matches the Airtable fields.\n // An *error* (vs a legitimate not-configured skip) is recorded in softFailures so the\n // caller can surface a fleet-wide outage in the batch summary.\n const gaResult =\n base !== null ? await fetchGaUsers(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;\n const searchResult =\n base !== null ? await fetchSearch(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;\n const gaUsers = gaResult.value;\n const search = searchResult.value;\n const softFailures: SoftFailure[] = [\n ...(gaResult.softFailed ? ([\"ga\"] as const) : []),\n ...(searchResult.softFailed ? ([\"search\"] as const) : []),\n ];\n\n const cidName = `${slug}-header`;\n const { html } = await renderReportHtml({\n siteName: siteRow.name,\n siteUrl: siteRow.url,\n reportType,\n completedOn,\n lighthouse: scores,\n gaUsersCurrent: gaUsers?.current,\n gaUsersPrevious: gaUsers?.previous,\n searchPosition: search?.foundOnPage1 ? (search.position ?? undefined) : undefined,\n lastTestedDate,\n commentary: null,\n copy: resolveCopy(siteRow),\n headerImageCid: cidName,\n });\n\n if (options.previewOnly) {\n const path = options.previewPath ?? `reports/${slug}/draft.html`;\n await mkdir(dirname(path), { recursive: true });\n await writeFile(path, html, \"utf-8\");\n return { reportRow: null, htmlPath: path, html, softFailures, queued: null, supersededIds: [] };\n }\n\n if (base === null) throw new Error(\"base required when previewOnly=false\");\n\n // Record this site's GA/Search enrichment health for the per-site analytics-failure\n // signal (cockpit/digest). Only when analytics is configured for THIS site — set the\n // timestamp on a soft-fail, clear it (null) on a clean enrichment so the signal\n // self-heals. Best-effort: the `Analytics soft-fail at` column is operator-added, so\n // until it exists the write throws UNKNOWN_FIELD_NAME — which must NOT break drafting.\n if (readGaConfig() !== null && Boolean(siteRow.ga4PropertyId || siteRow.searchQuery)) {\n try {\n await updateAnalyticsHealth(\n base,\n siteRow.id,\n softFailures.length > 0 ? today.toISOString() : null,\n );\n } catch (e) {\n console.warn(`⚠ analytics-health write skipped for ${siteRow.name}: ${(e as Error).message}`);\n }\n }\n\n // \"Finish an existing row\" path (the --due re-draft wedge fix). When the caller\n // hands us a row that was created but never made Draft-ready — a crash between\n // createDraft and setDraftReady leaves exactly this — we DON'T createDraft again\n // (that would duplicate the period). We re-attach the rendered HTML and queue the\n // EXISTING row, completing the half-made draft in place. The row's other fields\n // (scores, period, dates) were already written at create time; the only pieces a\n // crash drops are the attachment + the ready flag.\n if (options.completeRowId) {\n await uploadDraftHtml(options.completeRowId, slug, periodEnd, html);\n const outcome = await queueDraft(base, {\n id: options.completeRowId,\n siteId: siteRow.id,\n reportType,\n });\n return {\n reportRow: options.existingRow ?? null,\n htmlPath: null,\n html,\n softFailures,\n queued: outcome.queued,\n supersededIds: outcome.supersededIds,\n };\n }\n\n // Auto-tick the checklist boxes the system can prove (Phase 1: Google Indexed via the\n // inline search signal). Fail-safe lives in autoTickChecklist: only `pass` entries are ticked;\n // the evidence snapshot drives the dashboard's green/amber badges. The operator's approve gate\n // and per-box override are unchanged.\n const evidence = autoTickChecklist(siteRow, reportType, completedOn, { search: searchResult });\n const checklistTicks = [...evidence.entries()]\n .filter(([, e]) => e.result === \"pass\")\n .map(([field]) => field);\n const autoEvidence = Object.fromEntries(evidence);\n\n const reportId = `${siteRow.name} — ${reportType} — ${periodEnd.toISOString().slice(0, 10)}`;\n const created = await createDraft(base, {\n reportId,\n siteId: siteRow.id,\n reportType,\n period: options.period ?? periodEnd.toISOString().slice(0, 7),\n periodStart,\n periodEnd,\n completedOn,\n lighthouse: scores,\n lastTestedDate,\n ...(gaUsers ? { gaUsersCurrent: gaUsers.current, gaUsersPrevious: gaUsers.previous } : {}),\n ...(search ? { searchFoundPage1: search.foundOnPage1 } : {}),\n ...(search?.foundOnPage1 && search.position !== null\n ? { searchPosition: search.position }\n : {}),\n checklistTicks,\n autoEvidence,\n });\n\n await uploadDraftHtml(created.id, slug, periodEnd, html);\n const outcome = await queueDraft(base, {\n id: created.id,\n siteId: siteRow.id,\n reportType,\n });\n\n return {\n reportRow: created,\n htmlPath: null,\n html,\n softFailures,\n queued: outcome.queued,\n supersededIds: outcome.supersededIds,\n };\n}\n\n/** Attach the rendered HTML to a Reports row. Queueing (Draft ready + the single-queue\n * reconciliation) is handled separately by queueDraft so both the create path and the\n * \"complete a half-made row\" path share the identical, re-runnable upload step. */\nasync function uploadDraftHtml(\n rowId: string,\n slug: string,\n periodEnd: Date,\n html: string,\n): Promise<void> {\n const htmlFilename = `${slug}-${periodEnd.toISOString().slice(0, 10)}.html`;\n await uploadAttachment(rowId, \"Rendered HTML\", html, htmlFilename, \"text/html\");\n}\n\n/** Result of an enrichment fetch: the value (null if unavailable) plus whether\n * it errored (`softFailed`) as opposed to being legitimately not-configured. */\ntype Enrichment<T> = { value: T | null; softFailed: boolean };\n/** A not-configured / skipped enrichment — null value, not a soft-failure. */\nconst NO_ENRICHMENT: Enrichment<never> = { value: null, softFailed: false };\n\n/**\n * Fetch GA \"Users\" for the period, soft-failing to null. Returns a null value (no enrichment)\n * when GA isn't configured (`GA_SUBJECT` unset) or the site has no GA4 property ID — those are\n * legitimate skips, `softFailed: false`. When the GA API errors it logs a one-line warning and\n * returns `softFailed: true`. Never throws, so a GA problem can never block a draft; the\n * operator can always enter the numbers by hand.\n */\nexport async function fetchGaUsers(\n siteRow: WebsiteRow,\n periodStart: Date,\n periodEnd: Date,\n): Promise<Enrichment<{ current: number; previous: number }>> {\n const cfg = readGaConfig();\n if (!cfg || !siteRow.ga4PropertyId) return NO_ENRICHMENT;\n try {\n const value = await fetchPeriodUsers(\n { propertyId: siteRow.ga4PropertyId, subjects: cfg.subjects, keyPath: cfg.keyPath },\n periodStart,\n periodEnd,\n );\n return { value, softFailed: false };\n } catch (e) {\n console.warn(`⚠ GA skipped for ${siteRow.name}: ${(e as Error).message}`);\n return { value: null, softFailed: true };\n }\n}\n\n/**\n * Fetch the site's Google search presence for the period, soft-failing to null. Returns a null\n * value when GA/SA isn't configured (`readGaConfig()` null — search shares the SA credentials)\n * or the site has no `searchQuery` (legitimate skips, `softFailed: false`). When the Search\n * Console API errors it logs a one-line warning and returns `softFailed: true`. Never throws,\n * so a search problem can never block a draft.\n */\nexport async function fetchSearch(\n siteRow: WebsiteRow,\n periodStart: Date,\n periodEnd: Date,\n): Promise<Enrichment<SearchPresence>> {\n const cfg = readGaConfig();\n if (!cfg || !siteRow.searchQuery) return NO_ENRICHMENT;\n try {\n const value = await fetchSearchPresence(\n {\n keyPath: cfg.keyPath,\n subjects: cfg.subjects,\n property: siteRow.searchConsoleProperty ?? undefined,\n host: siteRow.url,\n query: siteRow.searchQuery,\n },\n periodStart,\n periodEnd,\n );\n return { value, softFailed: false };\n } catch (e) {\n console.warn(`⚠ Search presence skipped for ${siteRow.name}: ${(e as Error).message}`);\n return { value: null, softFailed: true };\n }\n}\n\nasync function derivePeriodStart(\n base: AirtableBase,\n siteRow: WebsiteRow,\n reportType: ReportType,\n today: Date,\n): Promise<Date> {\n const prior = await listReportsForSite(base, siteRow.id);\n const sameType = prior\n .filter((r) => r.reportType === reportType && r.periodEnd)\n .map((r) => r.periodEnd!)\n .sort();\n const latest = sameType[sameType.length - 1];\n if (!latest) return daysAgo(today, 30);\n // Half-open periods. The prior report's GA/Search windows are inclusive of its\n // periodEnd, so starting this report on the *same* day double-counts that\n // boundary day across two consecutive reports (and inflates the headline Users\n // count). Start the next day instead. UTC to stay TZ-consistent with daysAgo.\n const start = new Date(latest);\n start.setUTCDate(start.getUTCDate() + 1);\n return start;\n}\n","import type { ReportType } from \"./types.js\";\nimport type { WebsiteRow } from \"./airtable/websites.js\";\nimport type { SearchPresence } from \"./search/client.js\";\nimport { checklistFor, gatingFields } from \"./checklist.js\";\nimport { isNetlifyAppUrl } from \"../util/url.js\";\n\n/** A persisted audit signal is only trusted within this window — a stale \"pass\" degrades to\n * \"unknown\" (no tick). Matches the github-signals staleness convention (~3 days). */\nconst STALE_DAYS = 3;\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\n\n/** True when `checkedAt` is a valid timestamp within STALE_DAYS of `now`. */\nfunction isFresh(checkedAt: string | null, now: Date): boolean {\n if (!checkedAt) return false;\n const t = new Date(checkedAt).getTime();\n if (Number.isNaN(t)) return false;\n return now.getTime() - t <= STALE_DAYS * MS_PER_DAY;\n}\n\n/** Cert-expiry buffer: a cert must have MORE than this many days left to pass (renew window).\n * Exactly 14 days does NOT pass — matches the spec's strict \">14 days\" and the domain audit's\n * own `> 14` pass status, so the two thresholds can't drift. */\nconst CERT_MIN_DAYS = 14;\n\n/** A single auto-check outcome. `pass` + fresh ⇒ the caller ticks the box. */\nexport type EvidenceResult = \"pass\" | \"fail\" | \"unknown\" | \"n/a\";\nexport type EvidenceRecord = { result: EvidenceResult; checkedAt: string | null; note: string };\n\n/**\n * Inline signals fetched during the draft. Phase 1 carries Search Console only; Phase 2/3 read\n * the remaining signals (security, domain, browser) off the Websites row passed as `site`.\n */\nexport type AutoTickSignals = {\n search: { value: SearchPresence | null; softFailed: boolean };\n};\n\n/**\n * Decide, per checklist item for this report type, an evidence record. PURE. The fail-safe\n * invariant lives here: a box is only tickable when `result === \"pass\"`. Google Indexed is an\n * inline signal (fetched at draft), so its evidence is inherently fresh (`checkedAt = now`).\n *\n * The semantic inversion: every GATING item for this report type gets a status even with no\n * signal — a never-measured gating item resolves to `unknown` (which blocks the send gate), not\n * an omission the caller could read as \"manual/OK\". Only non-gating (advisory) items keep the old\n * omit-when-absent behavior, so an unconfigured advisory check stays silently manual.\n */\nexport function autoTickChecklist(\n site: WebsiteRow,\n reportType: ReportType,\n now: Date,\n signals: AutoTickSignals,\n): Map<string, EvidenceRecord> {\n const out = new Map<string, EvidenceRecord>();\n const gating = new Set(gatingFields(reportType));\n\n for (const item of checklistFor(reportType)) {\n let ev: EvidenceRecord | null;\n switch (item.field) {\n case \"Maint: Google Indexed\":\n ev = googleEvidence(now, signals.search);\n break;\n case \"Maint: Deploy & Function Health\":\n ev = deployEvidence(site, now);\n break;\n case \"Maint: CMS Checked\":\n ev = cmsEvidence(site, now);\n break;\n case \"Maint: Domain, DNS & SSL\":\n ev = domainEvidence(site, now);\n break;\n case \"Maint: Security Updates\":\n ev = securityEvidence(site, now);\n break;\n case \"Maint: Uptime Checked\":\n ev = uptimeEvidence(site, now);\n break;\n case \"Test: Desktop Browsers\":\n ev = browserEvidence(\n site.crossbrowserOk,\n site,\n now,\n \"Desktop renders cleanly\",\n \"render errors\",\n );\n break;\n case \"Test: Mobile Browsers\":\n ev = browserEvidence(site.mobileOk, site, now, \"Mobile renders cleanly\", \"overflow/errors\");\n break;\n case \"Test: Page Titles & Meta\":\n ev = titlesEvidence(site, now);\n break;\n case \"Test: Links & Navigation\": {\n const broken = site.brokenLinks;\n const failNote = broken && broken > 0 ? `${broken} broken link(s)` : \"broken links / nav\";\n ev = browserEvidence(site.linksOk, site, now, \"All internal links resolve\", failNote);\n break;\n }\n case \"Test: Form Functionality\":\n ev = formsEvidence(site, now);\n break;\n case \"Test: Interactions & Animations\":\n ev = interactionsEvidence(site, now);\n break;\n case \"Test: Verified After Updates\":\n ev = updatesEvidence(site, now);\n break;\n default:\n ev = null;\n }\n // The semantic inversion: a GATING item with no fresh signal must still carry a status —\n // `unknown` (blocks) — so an unwired/absent signal can never leave the gate silently\n // passable. Advisory items (e.g. Google Indexed on a Maintenance report) keep the old\n // omit-when-absent behavior.\n if (ev === null && gating.has(item.field)) {\n ev = { result: \"unknown\", checkedAt: null, note: \"Not yet measured\" };\n }\n if (ev !== null) out.set(item.field, ev);\n }\n\n return out;\n}\n\n/** One browser-audit verdict (Desktop/Mobile/Links) → evidence, gated on `Browser checked at`\n * freshness. null verdict (never run) → omit (manual); stale → unknown; true → pass; false →\n * fail (with the failure note). The verdict itself was computed by the audit (summarizeBrowser). */\nfunction browserEvidence(\n ok: boolean | null,\n site: WebsiteRow,\n now: Date,\n passNote: string,\n failNote: string,\n): EvidenceRecord | null {\n if (ok === null || !site.browserCheckedAt) return null;\n const at = site.browserCheckedAt;\n if (!isFresh(at, now)) {\n return { result: \"unknown\", checkedAt: at, note: \"Browser check is stale (>3d)\" };\n }\n return ok\n ? { result: \"pass\", checkedAt: at, note: passNote }\n : { result: \"fail\", checkedAt: at, note: failNote };\n}\n\n/**\n * Security Updates evidence from the persisted `security` audit. Omits (→ manual) when the audit\n * never ran (counts null / no timestamp); stale → unknown; 0 critical AND 0 high → pass; any\n * critical/high → fail (with the count). Honest scope: \"no known critical/high advisories in the\n * declared dependencies as of the last audit\" — moderate/low are advisory, not gating, and this\n * does not prove the fix is deployed.\n */\nfunction securityEvidence(site: WebsiteRow, now: Date): EvidenceRecord | null {\n const crit = site.securityVulnsCritical;\n const high = site.securityVulnsHigh;\n if (crit === null || high === null || !site.lastSecurityAuditAt) return null;\n const at = site.lastSecurityAuditAt;\n if (!isFresh(at, now)) {\n return { result: \"unknown\", checkedAt: at, note: \"Security audit is stale (>3d)\" };\n }\n if (crit === 0 && high === 0) {\n return { result: \"pass\", checkedAt: at, note: \"No known critical/high vulnerabilities\" };\n }\n return { result: \"fail\", checkedAt: at, note: `${crit} critical / ${high} high vuln(s)` };\n}\n\n/** Search Console → Google Indexed evidence. `null` (not configured) emits nothing so the box\n * stays manual; a soft-fail is `unknown`; page-1 is `pass` (with the position in the note). */\nfunction googleEvidence(now: Date, search: AutoTickSignals[\"search\"]): EvidenceRecord | null {\n const at = now.toISOString();\n if (search.softFailed) {\n return { result: \"unknown\", checkedAt: at, note: \"Search Console unavailable this run\" };\n }\n if (search.value === null) return null;\n if (search.value.foundOnPage1) {\n const pos = search.value.position;\n return {\n result: \"pass\",\n checkedAt: at,\n note: `Page 1 on Google${pos !== null ? ` (#${pos})` : \"\"}`,\n };\n }\n const pos = search.value.position;\n return {\n result: \"fail\",\n checkedAt: at,\n note: `Not on page 1${pos !== null ? ` (avg #${pos})` : \"\"}`,\n };\n}\n\n/**\n * Domain/DNS/SSL evidence from the persisted `domain` audit. Omits (→ manual) when there's no\n * custom domain to verify (`*.netlify.app`) or the audit never ran. A `pass` requires a fresh\n * check that resolved with a cert comfortably before expiry; stale → unknown; resolved-but-no-cert\n * or near/past expiry → fail. The honest scope is resolve + valid cert — NOT registrar expiry,\n * www↔apex redirect, or MX.\n */\nfunction domainEvidence(site: WebsiteRow, now: Date): EvidenceRecord | null {\n // No custom domain to verify — the no-domain watch signal covers `*.netlify.app` elsewhere.\n if (!site.url || isNetlifyAppUrl(site.url)) return null;\n if (!site.domainCheckedAt) return null; // never probed → leave manual\n const at = site.domainCheckedAt;\n if (!isFresh(site.domainCheckedAt, now)) {\n return { result: \"unknown\", checkedAt: at, note: \"Domain check is stale (>3d)\" };\n }\n const days = site.certDaysRemaining;\n if (days === null) {\n return { result: \"fail\", checkedAt: at, note: \"Did not resolve, or no valid TLS cert\" };\n }\n if (days <= CERT_MIN_DAYS) {\n return { result: \"fail\", checkedAt: at, note: `TLS cert expires in ${days}d` };\n }\n return { result: \"pass\", checkedAt: at, note: `Custom domain, valid cert (${days}d left)` };\n}\n\n/**\n * Deploy & Function Health: the Netlify build is `ready` AND the deployed function responds\n * healthy. Two freshness stamps must both be fresh (deploy check + function-health check). Never\n * measured → null (omit → the gating dispatch coerces to unknown); either stale → unknown; both\n * fresh + ready + fn pass → pass; otherwise fail.\n */\nfunction deployEvidence(site: WebsiteRow, now: Date): EvidenceRecord | null {\n if (!site.deployCheckedAt && !site.functionHealthCheckedAt) return null;\n if (!isFresh(site.deployCheckedAt, now) || !isFresh(site.functionHealthCheckedAt, now)) {\n return {\n result: \"unknown\",\n checkedAt: site.functionHealthCheckedAt ?? site.deployCheckedAt,\n note: \"Deploy/function-health check is stale (>3d)\",\n };\n }\n const ready = site.deployStatus === \"ready\";\n const fnOk = site.functionHealth === \"pass\";\n if (ready && fnOk) {\n return {\n result: \"pass\",\n checkedAt: site.functionHealthCheckedAt,\n note: \"Netlify build ready + functions respond\",\n };\n }\n const why =\n !ready && !fnOk\n ? \"build not ready + functions unhealthy\"\n : !ready\n ? \"build not ready\"\n : \"functions unhealthy\";\n return {\n result: \"fail\",\n checkedAt: site.functionHealthCheckedAt,\n note: `Deploy/function-health failing — ${why}`,\n };\n}\n\n/**\n * CMS Checked: the server-side `/health` Prismic probe reported reachable. Freshness rides the\n * function-health check stamp (one `/health` fetch feeds both Deploy and CMS). Never measured →\n * null; stale → unknown; pass/fail mirror the stored verdict; a fresh stamp with no verdict →\n * unknown.\n */\nfunction cmsEvidence(site: WebsiteRow, now: Date): EvidenceRecord | null {\n if (!site.functionHealthCheckedAt) return null;\n const at = site.functionHealthCheckedAt;\n if (!isFresh(at, now)) {\n return { result: \"unknown\", checkedAt: at, note: \"CMS check is stale (>3d)\" };\n }\n if (site.cmsReachable === \"pass\") {\n return { result: \"pass\", checkedAt: at, note: \"Prismic reachable (server-side)\" };\n }\n if (site.cmsReachable === \"fail\") {\n return { result: \"fail\", checkedAt: at, note: \"Prismic unreachable (server-side)\" };\n }\n return { result: \"unknown\", checkedAt: at, note: \"CMS reachability not reported\" };\n}\n\n/**\n * Uptime Checked: every sampled route returned 2xx/3xx on the browser audit (point-in-time).\n * Freshness rides the shared `browserCheckedAt`. Never ran → null; stale → unknown.\n */\nfunction uptimeEvidence(site: WebsiteRow, now: Date): EvidenceRecord | null {\n if (site.reachableOk === null || !site.browserCheckedAt) return null;\n const at = site.browserCheckedAt;\n if (!isFresh(at, now)) {\n return { result: \"unknown\", checkedAt: at, note: \"Uptime check is stale (>3d)\" };\n }\n return site.reachableOk === \"pass\"\n ? { result: \"pass\", checkedAt: at, note: \"All sampled routes reachable (point-in-time)\" }\n : { result: \"fail\", checkedAt: at, note: \"One or more sampled routes did not respond 2xx/3xx\" };\n}\n\n/**\n * Page Titles & Meta: every sampled route had a non-empty title + meta description with no\n * duplicate titles (browser audit, chromium). Freshness rides `browserCheckedAt`.\n */\nfunction titlesEvidence(site: WebsiteRow, now: Date): EvidenceRecord | null {\n if (site.titleMetaOk === null || !site.browserCheckedAt) return null;\n const at = site.browserCheckedAt;\n if (!isFresh(at, now)) {\n return { result: \"unknown\", checkedAt: at, note: \"Titles/meta check is stale (>3d)\" };\n }\n return site.titleMetaOk === \"pass\"\n ? { result: \"pass\", checkedAt: at, note: \"Titles + meta present\" }\n : {\n result: \"fail\",\n checkedAt: at,\n note: \"Missing/duplicate title or missing meta description\",\n };\n}\n\n/**\n * Form Functionality: a synthetic prod submission succeeded (form-e2e audit). `n/a` when the audit\n * ran (checked-at stamp set) but the site has no contact form (verdict cleared to null). Never ran\n * (no stamp) → null; stale → unknown.\n */\nfunction formsEvidence(site: WebsiteRow, now: Date): EvidenceRecord | null {\n if (!site.formE2eCheckedAt) return null;\n const at = site.formE2eCheckedAt;\n if (!isFresh(at, now)) {\n return { result: \"unknown\", checkedAt: at, note: \"Form E2E check is stale (>3d)\" };\n }\n if (site.formE2eOk === \"pass\") {\n return { result: \"pass\", checkedAt: at, note: \"Synthetic submission succeeded\" };\n }\n if (site.formE2eOk === \"fail\") {\n return { result: \"fail\", checkedAt: at, note: \"Synthetic submission failed\" };\n }\n return { result: \"n/a\", checkedAt: at, note: \"No contact form on this site\" };\n}\n\n/**\n * Interactions & Animations: the per-site smoke suite is green. Freshness rides `lastSmokeAt`.\n * Never ran → null; stale → unknown.\n */\nfunction interactionsEvidence(site: WebsiteRow, now: Date): EvidenceRecord | null {\n if (site.smokeOk === null || !site.lastSmokeAt) return null;\n const at = site.lastSmokeAt;\n if (!isFresh(at, now)) {\n return { result: \"unknown\", checkedAt: at, note: \"Smoke suite is stale (>3d)\" };\n }\n return site.smokeOk === \"pass\"\n ? { result: \"pass\", checkedAt: at, note: \"Smoke suite green\" }\n : { result: \"fail\", checkedAt: at, note: \"Smoke suite red\" };\n}\n\n/**\n * Tested After Updates: default-branch CI is green on the latest commit (github-signals). A repo\n * with no CI (`defaultBranchCi === \"none\"`) is `n/a`. Never swept (null / no stamp) → null; stale\n * → unknown; passing → pass; failing → fail; pending → unknown.\n */\nfunction updatesEvidence(site: WebsiteRow, now: Date): EvidenceRecord | null {\n if (site.defaultBranchCi === null || !site.githubSignalsAt) return null;\n const at = site.githubSignalsAt;\n if (site.defaultBranchCi === \"none\") {\n return { result: \"n/a\", checkedAt: at, note: \"Repository has no CI\" };\n }\n if (!isFresh(at, now)) {\n return { result: \"unknown\", checkedAt: at, note: \"CI signal is stale (>3d)\" };\n }\n if (site.defaultBranchCi === \"passing\") {\n return { result: \"pass\", checkedAt: at, note: \"Default-branch CI green on latest commit\" };\n }\n if (site.defaultBranchCi === \"failing\") {\n return { result: \"fail\", checkedAt: at, note: \"Default-branch CI is failing\" };\n }\n return { result: \"unknown\", checkedAt: at, note: \"Default-branch CI is pending\" };\n}\n","import { readFileSync } from \"node:fs\";\nimport { JWT } from \"google-auth-library\";\nimport { BetaAnalyticsDataClient } from \"@google-analytics/data\";\nimport { withSubjectFailover } from \"./failover.js\";\n\nconst ANALYTICS_READONLY = \"https://www.googleapis.com/auth/analytics.readonly\";\nconst MS_PER_DAY = 86_400_000;\n\nexport type GaQuery = {\n /** GA4 numeric property ID (e.g. \"471880366\"). */\n propertyId: string;\n /** Workspace users to impersonate via domain-wide delegation, tried in order\n * (auth-failure failover — see `withSubjectFailover`). */\n subjects: string[];\n /** Path to the service-account JSON key. */\n keyPath: string;\n};\n\n/** UTC YYYY-MM-DD — matches the rest of the reports pipeline's date handling. */\nfunction ymd(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * Fetch GA4 `activeUsers` (\"Users\") for a report period and the equal-length window\n * immediately before it, via a domain-wide-delegation service account impersonating each\n * of `subjects` in order until one authenticates. Throws on any non-auth API error, or\n * once every subject has failed auth — the caller (draftReportForSite) soft-fails.\n *\n * Previous window: same length as the current period, ending the day before `periodStart`.\n */\nexport async function fetchPeriodUsers(\n query: GaQuery,\n periodStart: Date,\n periodEnd: Date,\n): Promise<{ current: number; previous: number }> {\n const key = JSON.parse(readFileSync(query.keyPath, \"utf8\")) as {\n client_email: string;\n private_key: string;\n };\n\n const lengthDays = Math.round((periodEnd.getTime() - periodStart.getTime()) / MS_PER_DAY);\n const prevEnd = new Date(periodStart.getTime() - MS_PER_DAY);\n const prevStart = new Date(prevEnd.getTime() - lengthDays * MS_PER_DAY);\n const property = `properties/${query.propertyId}`;\n\n return withSubjectFailover(query.subjects, \"GA\", async (subject) => {\n const authClient = new JWT({\n email: key.client_email,\n key: key.private_key,\n scopes: [ANALYTICS_READONLY],\n subject,\n });\n const client = new BetaAnalyticsDataClient({ authClient });\n\n const run = async (start: Date, end: Date): Promise<number> => {\n const [resp] = await client.runReport({\n property,\n dateRanges: [{ startDate: ymd(start), endDate: ymd(end) }],\n metrics: [{ name: \"activeUsers\" }],\n });\n const raw = resp.rows?.[0]?.metricValues?.[0]?.value ?? \"0\";\n const n = Number.parseInt(raw, 10);\n return Number.isFinite(n) ? n : 0;\n };\n\n const current = await run(periodStart, periodEnd);\n const previous = await run(prevStart, prevEnd);\n return { current, previous };\n });\n}\n","/**\n * Impersonation-subject failover, shared by the GA Data client and the Search Console\n * client (both impersonate the same `GA_SUBJECT` list via domain-wide delegation).\n *\n * The point: one personal Workspace account backing every site's analytics is a fleet-wide\n * SPOF — if it's offboarded/suspended or loses property access, all analytics soft-fail to\n * blank at once. With `GA_SUBJECT` as an ordered list, an auth failure on one subject falls\n * through to the next, and a *partial* outage (primary dying, backup carrying) is surfaced\n * as a greppable `subject failover` warning before it becomes a total one.\n */\n\nconst AUTH_HTTP_STATUS = new Set([401, 403]);\n/** gRPC PERMISSION_DENIED (7) / UNAUTHENTICATED (16) — how the GA Data API surfaces auth. */\nconst AUTH_GRPC_CODES = new Set([7, 16]);\n/** Message shapes across the three auth surfaces: gRPC code names (GA Data), OAuth token\n * exchange (`invalid_grant` = suspended/deleted subject, `unauthorized_client` = DWD scope\n * missing), and plain HTTP 401/403 texts (Search Console via gaxios). */\nconst AUTH_MESSAGE =\n /permission[_ ]denied|unauthenticated|unauthorized|invalid_grant|access[_ ]denied|forbidden|insufficient permission|status code 40[13]/i;\n\ntype ErrorShape = {\n code?: number | string;\n status?: number | string;\n response?: { status?: number; data?: { error?: { errors?: Array<{ reason?: string }> } } };\n /** Explicit opt-in marker for errors that aren't auth-shaped per se but mean \"this\n * subject can't do the job, try the next one\" (see the search client's empty\n * sites.list case — that API hides inaccessible properties instead of 403ing). */\n failoverToNextSubject?: boolean;\n};\n\n/** Google surfaces per-user throughput caps as HTTP 403 with one of these `reason`s — a\n * transient rate-limit, NOT a lost grant. Distinguished so the failover warning doesn't\n * send operators to the offboarding runbook over a quota blip. */\nconst QUOTA_REASONS = new Set([\n \"userratelimitexceeded\",\n \"ratelimitexceeded\",\n \"quotaexceeded\",\n \"dailylimitexceeded\",\n]);\nconst QUOTA_MESSAGE = /quota exceeded|rate limit exceeded|user rate limit/i;\n\n/** True when an error looks like THIS subject's auth/authorization failed — the only\n * condition worth retrying under a different subject. Network/5xx/bad-input errors\n * would fail identically for every subject, so they propagate immediately. */\nexport function isAuthShapedError(e: unknown): boolean {\n const err = e as ErrorShape | null | undefined;\n if (err?.failoverToNextSubject === true) return true;\n if ([err?.status, err?.response?.status].some((s) => AUTH_HTTP_STATUS.has(Number(s))))\n return true;\n if (AUTH_HTTP_STATUS.has(Number(err?.code)) || AUTH_GRPC_CODES.has(Number(err?.code)))\n return true;\n const msg = e instanceof Error ? e.message : String(e);\n return AUTH_MESSAGE.test(msg);\n}\n\n/** A soft \"this subject can't see the resource, try the next one\" marker (the search\n * client's empty sites.list case) — NOT a genuine failure. It must never dominate a real\n * auth error when deciding what to throw after all subjects are exhausted. */\nfunction isFailoverSentinel(e: unknown): boolean {\n return (e as ErrorShape | null | undefined)?.failoverToNextSubject === true;\n}\n\n/** True when a 403 is a transient per-user quota/rate-limit, not an access loss. A\n * structured `reason` is authoritative (so a real `forbidden` 403 whose message happens to\n * mention a limit is NOT quota); otherwise fall back to the message. Still auth-shaped for\n * failover purposes — this only softens the warning wording, never the control flow. */\nexport function isQuotaShapedError(e: unknown): boolean {\n const err = e as ErrorShape | null | undefined;\n if (err?.failoverToNextSubject === true) return false;\n const reasons = err?.response?.data?.error?.errors;\n if (Array.isArray(reasons) && reasons.length > 0) {\n return reasons.some((r) => r?.reason != null && QUOTA_REASONS.has(r.reason.toLowerCase()));\n }\n const msg = e instanceof Error ? e.message : String(e);\n return QUOTA_MESSAGE.test(msg);\n}\n\n/**\n * Run `attempt` with each subject in order until one succeeds. Auth-shaped failures fall\n * through to the next subject; anything else throws immediately. When all subjects fail,\n * the LAST error is thrown — callers keep their existing catch-and-soft-fail behavior.\n * When an earlier subject failed but a later one succeeded, emit ONE console.warn so a\n * dying primary is visible in the cron log before it becomes a total outage.\n */\nexport async function withSubjectFailover<T>(\n subjects: readonly string[],\n label: string,\n attempt: (subject: string) => Promise<T>,\n): Promise<T> {\n if (subjects.length === 0) throw new Error(`${label}: no impersonation subjects configured`);\n const failed: string[] = [];\n // Track a genuine auth failure separately from a soft sentinel. If ANY subject failed for\n // real, that error must be what we throw — never a later subject's \"no resource here\"\n // sentinel. Otherwise [real-auth-failure, empty-result] would surface as a clean sentinel\n // and the caller would record it as verified data instead of soft-failing (the exact\n // masking the analytics-health alarm exists to catch).\n let lastRealError: unknown;\n let lastSentinel: unknown;\n // Stays true only while every failure so far is a transient quota/rate-limit — a lost\n // grant or a sentinel flips it, escalating the warning from \"transient\" to the runbook.\n let allQuota = true;\n for (const subject of subjects) {\n try {\n const result = await attempt(subject);\n if (failed.length > 0) {\n console.warn(\n allQuota\n ? `⚠ ${label} subject failover: ${failed.join(\"; \")} — succeeded via ${subject} ` +\n `(transient quota/rate-limit, not an access loss).`\n : `⚠ ${label} subject failover: ${failed.join(\"; \")} — succeeded via ${subject}. ` +\n `Restore the failing subject's access or drop it from GA_SUBJECT ` +\n `(docs/runbooks/ga-search-role-account-cutover.md).`,\n );\n }\n return result;\n } catch (e) {\n if (!isAuthShapedError(e)) throw e;\n failed.push(`${subject} (${e instanceof Error ? e.message : String(e)})`);\n if (isFailoverSentinel(e)) {\n lastSentinel = e;\n allQuota = false;\n } else {\n lastRealError = e;\n if (!isQuotaShapedError(e)) allQuota = false;\n }\n }\n }\n // A real auth failure dominates a sentinel regardless of subject order; only when EVERY\n // subject merely returned the soft sentinel do we throw it (the search client turns that\n // into a legitimate not-found).\n throw lastRealError ?? lastSentinel;\n}\n","import { readFileSync } from \"node:fs\";\nimport { JWT } from \"google-auth-library\";\nimport { withSubjectFailover } from \"../ga/failover.js\";\n\nconst WEBMASTERS_READONLY = \"https://www.googleapis.com/auth/webmasters.readonly\";\nconst SC_BASE = \"https://searchconsole.googleapis.com/webmasters/v3\";\n/** Average-position threshold for \"on page 1\" (10 organic results per page). */\nconst PAGE_1_MAX_POSITION = 10;\n/** Rows to pull for the brand `contains` match. searchAnalytics has no orderBy and returns\n * rows clicks-desc, so the exact-query row survives into the response only if it isn't paged\n * out beyond this cap. Sized with wide headroom — a brand has a handful of phrasing variants,\n * far below 100 — so the exact query (and every variant) is in practice always present. */\nexport const BRAND_QUERY_ROW_LIMIT = 100;\n\nexport type SearchPresenceQuery = {\n /** Path to the service-account JSON key (same one GA uses). */\n keyPath: string;\n /** Workspace users to impersonate via domain-wide delegation, tried in order\n * (auth-failure failover — see `withSubjectFailover`). */\n subjects: string[];\n /** Explicit Search Console property (`sc-domain:...` or `https://.../`). Overrides auto-resolution. */\n property?: string | undefined;\n /** Site host, used to auto-resolve the property from `sites.list` when `property` is absent. */\n host: string;\n /** Operator-supplied brand hint (e.g. the business name). Matched as a case-insensitive\n * SUBSTRING (`contains`) of the actual user query — NOT a hard exact string — so it tolerates\n * phrasing (\"red door creative\" vs \"…creative la\"). An exact-query match is preferred when\n * present; otherwise the most-searched matching query wins. */\n query: string;\n};\n\nexport type SearchPresence = {\n /** True when the average position for the query is on page 1 (<= 10). */\n foundOnPage1: boolean;\n /** Rounded average position, or null when not found / no data. */\n position: number | null;\n};\n\ntype SiteEntry = { siteUrl: string };\n\n/** Reduce any property string or URL to a bare host: no `sc-domain:`, scheme, `www.`, path, lowercased. */\nexport function bareHost(s: string): string {\n return s\n .trim()\n .replace(/^sc-domain:/i, \"\")\n .replace(/^https?:\\/\\//i, \"\")\n .split(\"/\")[0]!\n .replace(/^www\\./i, \"\")\n .toLowerCase();\n}\n\n/**\n * All Search Console properties matching `host`, ordered for query fallback: Domain\n * (`sc-domain:`) forms first (broadest coverage), then URL-prefix forms. A site can be verified\n * as both; a freshly-created Domain property has no backfilled history, so its data can be empty\n * even while a long-lived URL-prefix property has data — hence we return every match and let the\n * caller try them in order until one returns data. Empty list = nothing matches.\n */\nexport function resolvePropertyCandidates(entries: SiteEntry[], host: string): string[] {\n const target = bareHost(host);\n const matches = entries.filter((e) => bareHost(e.siteUrl) === target).map((e) => e.siteUrl);\n const domains = matches.filter((s) => s.toLowerCase().startsWith(\"sc-domain:\"));\n const prefixes = matches.filter((s) => !s.toLowerCase().startsWith(\"sc-domain:\"));\n return [...domains, ...prefixes];\n}\n\n/** UTC YYYY-MM-DD — matches the rest of the reports pipeline. */\nfunction ymd(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * From the queries matching the brand substring, pick the one the brand is actually FOUND\n * by most — the highest-impression row — and return its average position; tie-break on the\n * better (lower) position. Returns undefined when no row carries a numeric position. This is\n * what makes the reported \"brand search\" position robust to phrasing: with a `contains`\n * filter several variants can match, and we report the position of the variant real users\n * search most, not whichever the operator happened to type. Rows missing `impressions` count\n * as 0 (keeps a single-row, impression-less response — e.g. a test fixture — working). PURE.\n */\nexport function pickBrandQuery(\n rows: Array<{ position?: number; impressions?: number }>,\n): number | undefined {\n let best: { position: number; impressions: number } | undefined;\n for (const r of rows) {\n if (typeof r.position !== \"number\") continue;\n const impressions = r.impressions ?? 0;\n if (\n best === undefined ||\n impressions > best.impressions ||\n (impressions === best.impressions && r.position < best.position)\n ) {\n best = { position: r.position, impressions };\n }\n }\n return best?.position;\n}\n\n/**\n * The brand's reported position from the `contains`-matched rows, preferring an EXACT match.\n * When a returned row's query equals the operator's configured string (case-insensitive), its\n * position is used verbatim — a precisely-configured brand query is honored, with no chance a\n * longer higher-impression variant (\"…reviews\", \"…near me\") hijacks the number. Otherwise we\n * fall back to the most-searched matching query (`pickBrandQuery`). Operates only on the rows\n * actually returned: a string `contains` itself, so the exact query is among the `contains`\n * results whenever it has data AND isn't paged out beyond `BRAND_QUERY_ROW_LIMIT` (sized so a\n * brand's handful of phrasing variants always fit; if it ever were paged out, the most-searched\n * fallback still returns a valid brand position). Returns undefined when no row carries a\n * numeric position. PURE.\n */\nexport function selectBrandPosition(\n rows: Array<{ keys?: string[]; position?: number; impressions?: number }>,\n exactQuery: string,\n): number | undefined {\n const want = exactQuery.toLowerCase();\n const exact = rows.find(\n (r) => typeof r.position === \"number\" && (r.keys?.[0] ?? \"\").toLowerCase() === want,\n );\n if (exact) return exact.position;\n return pickBrandQuery(rows);\n}\n\n/** Per-subject \"this subject resolved NO matching Search Console property\". sites.list only\n * returns properties the impersonated user can access — a subject that lost access sees an\n * empty list with no auth error at all — so this opts into subject failover explicitly.\n * When EVERY subject resolves empty, `fetchSearchPresence` restores the pre-failover\n * behavior: a plain not-found result, not a throw. */\nclass NoPropertyForSubjectError extends Error {\n readonly failoverToNextSubject = true;\n constructor(subject: string, host: string) {\n super(`no Search Console property matching ${host} is visible to ${subject}`);\n }\n}\n\n/**\n * Query Google Search Console for the brand's average position on the site over the report\n * period, via a domain-wide-delegation service account impersonating each of `subjects` in\n * order until one authenticates. `query` is a case-insensitive SUBSTRING (`contains`) hint;\n * among the matching user queries we report the position of the exact-match query when\n * present, else the most-searched one (see `selectBrandPosition`) — so it doesn't depend on\n * the operator typing the exact search string, yet honors one verbatim when they do. Uses\n * `property` verbatim when given (operator's choice is final — no fallback); otherwise\n * auto-discovers all matching properties via `sites.list` and tries them in order (Domain\n * first) until one returns data. Throws on any non-auth API error, or once every subject\n * has failed auth — the caller (draftReportForSite) soft-fails.\n */\nexport async function fetchSearchPresence(\n q: SearchPresenceQuery,\n periodStart: Date,\n periodEnd: Date,\n): Promise<SearchPresence> {\n const key = JSON.parse(readFileSync(q.keyPath, \"utf8\")) as {\n client_email: string;\n private_key: string;\n };\n\n try {\n return await withSubjectFailover(q.subjects, \"Search Console\", async (subject) => {\n const jwt = new JWT({\n email: key.client_email,\n key: key.private_key,\n scopes: [WEBMASTERS_READONLY],\n subject,\n });\n\n const explicit = q.property?.trim();\n let candidates: string[];\n if (explicit) {\n candidates = [explicit];\n } else {\n const list = await jwt.request<{ siteEntry?: SiteEntry[] }>({\n url: `${SC_BASE}/sites`,\n method: \"GET\",\n });\n candidates = resolvePropertyCandidates(list.data.siteEntry ?? [], q.host);\n if (candidates.length === 0) throw new NoPropertyForSubjectError(subject, q.host);\n }\n\n for (const property of candidates) {\n const res = await jwt.request<{\n rows?: Array<{ keys?: string[]; position?: number; impressions?: number }>;\n }>({\n url: `${SC_BASE}/sites/${encodeURIComponent(property)}/searchAnalytics/query`,\n method: \"POST\",\n data: {\n startDate: ymd(periodStart),\n endDate: ymd(periodEnd),\n dimensions: [\"query\"],\n dimensionFilterGroups: [\n {\n filters: [\n { dimension: \"query\", operator: \"contains\", expression: q.query.toLowerCase() },\n ],\n },\n ],\n rowLimit: BRAND_QUERY_ROW_LIMIT,\n },\n });\n const pos = selectBrandPosition(res.data.rows ?? [], q.query.toLowerCase());\n if (typeof pos === \"number\") {\n // Search Console can average below 1; floor to 1 so the template never\n // renders a nonsensical \"#0\" (positions are 1-indexed).\n return {\n foundOnPage1: pos <= PAGE_1_MAX_POSITION,\n position: Math.max(1, Math.round(pos)),\n };\n }\n }\n return { foundOnPage1: false, position: null };\n });\n } catch (e) {\n // Every subject resolved zero matching properties — same legitimate \"no property\"\n // outcome as before failover existed, NOT an analytics soft-failure.\n if (e instanceof NoPropertyForSubjectError) return { foundOnPage1: false, position: null };\n throw e;\n }\n}\n"],"mappings":"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","names":["dirname","pos","MS_PER_DAY","readFileSync","JWT","ymd","readFileSync","JWT","dirname","outcome"]}
@@ -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":";;;;;;;;;;;;AAAA,SAAS,QAAQ,OAAO,SAAS,UAAU,iBAAiB;AAC5D,SAAS,SAAS,YAAY;;;ACIvB,IAAM,wBAAwB;AAC9B,IAAM,wBAAwB;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;AA6B9B,IAAM,sBAAsB;AAC5B,IAAM,sBAAsB;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;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAuE5B,IAAM,6BAA6B;AAInC,IAAM,6BAA6B;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;AAyCnC,IAAM,4BAA4B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ADhIzC,eAAe,WAAW,MAAgC;AACxD,MAAI;AACF,UAAM,OAAO,IAAI;AACjB,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,eAAe,aAAa,MAAsC;AAChE,MAAI;AACF,WAAO,MAAM,SAAS,MAAM,OAAO;AAAA,EACrC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAIA,IAAM,yBACJ;AAEF,SAAS,uBAAuB,QAAwB;AACtD,SACE,0BAA0B,MAAM;AAAA;AAAA;AAIpC;AASA,eAAe,sBAAsB,KAAkD;AACrF,MAAI;AACJ,MAAI;AACF,cAAW,MAAM,QAAQ,KAAK,KAAK,KAAK,GAAG,EAAE,WAAW,KAAK,CAAC;AAAA,EAChE,QAAQ;AACN,WAAO;AAAA,EACT;AACA,QAAM,cAAc,QAAQ,OAAO,CAAC,MAAM,EAAE,SAAS,SAAS,CAAC;AAC/D,MAAI,YAAY,WAAW,EAAG,QAAO;AACrC,MAAI,UAAU;AACd,aAAW,OAAO,aAAa;AAC7B,UAAM,OAAQ,MAAM,aAAa,KAAK,KAAK,OAAO,GAAG,CAAC,KAAM;AAC5D,QAAI,gBAAgB,KAAK,IAAI,EAAG,QAAO;AACvC,QAAI,cAAc,KAAK,IAAI,EAAG,WAAU;AAAA,EAC1C;AACA,SAAO,UAAU,SAAS;AAC5B;AAWA,eAAsB,WACpB,MACA,OAAuB,EAAE,OAAO,aAAa,GACtB;AACvB,QAAM,UAAU,KAAK,KAAK,MAAM,cAAc;AAC9C,SAAO,WAAkD;AAAA,IACvD,MAAM;AAAA,IACN;AAAA,IACA,MAAM,YAAY;AAKhB,YAAM,MAAM,MAAM,aAAa,OAAO;AACtC,UAAI,QAAQ,MAAM;AAChB,eAAO,EAAE,MAAM,QAAQ,OAAO,uCAAuC;AAAA,MACvE;AACA,UAAI;AACJ,UAAI;AACF,cAAM,KAAK,MAAM,GAAG;AAAA,MACtB,QAAQ;AACN,eAAO,EAAE,MAAM,QAAQ,OAAO,0CAAqC;AAAA,MACrE;AACA,aAAO,EAAE,MAAM,SAAS,MAAM,EAAE,SAAS,IAAI,EAAE;AAAA,IACjD;AAAA,IACA,OAAO,OAAO,SAAS,EAAE,QAAQ,IAAI,MAAM;AACzC,YAAM,QAAkB,CAAC;AAGzB,YAAM,UAAoB,CAAC;AAM3B,UAAI,iBAAiB;AACrB,UAAI,CAAE,MAAM,WAAW,KAAK,KAAK,qBAAqB,CAAC,GAAI;AACzD,cAAM,SAAS,MAAM,sBAAsB,GAAG;AAC9C,YAAI,WAAW,UAAU;AACvB,2BAAiB,sBAAsB;AAAA,YACrC;AAAA,YACA,qBAAqB,MAAM;AAAA,UAC7B,EAAE,QAAQ,wBAAwB,uBAAuB,MAAM,CAAC;AAChE,gBAAM;AAAA,YACJ,0EAAqE,MAAM;AAAA,UAE7E;AAAA,QACF;AAAA,MACF;AACA,YAAM,YAAqC;AAAA,QACzC,CAAC,uBAAuB,cAAc;AAAA,QACtC,CAAC,qBAAqB,mBAAmB;AAAA,MAC3C;AACA,iBAAW,CAAC,KAAK,IAAI,KAAK,WAAW;AACnC,cAAM,SAAS,KAAK,KAAK,GAAG;AAC5B,YAAI,CAAE,MAAM,WAAW,MAAM,GAAI;AAC/B,gBAAM,MAAM,QAAQ,MAAM,GAAG,EAAE,WAAW,KAAK,CAAC;AAChD,gBAAM,UAAU,QAAQ,MAAM,OAAO;AACrC,kBAAQ,KAAK,GAAG;AAAA,QAClB;AAAA,MACF;AAKA,YAAM,UAAU,KAAK,KAAK,0BAA0B;AACpD,YAAM,cAAc,MAAM,aAAa,OAAO;AAC9C,UAAI,gBAAgB,MAAM;AACxB,cAAM,UAAU,SAAS,4BAA4B,OAAO;AAC5D,gBAAQ,KAAK,0BAA0B;AAAA,MACzC,WAAW,YAAY,SAAS,oBAAoB,GAAG;AAAA,MAEvD,WAAW,YAAY,KAAK,MAAM,0BAA0B,KAAK,GAAG;AAClE,cAAM,UAAU,SAAS,4BAA4B,OAAO;AAC5D,gBAAQ,KAAK,0BAA0B;AAAA,MACzC,OAAO;AACL,cAAM;AAAA,UACJ;AAAA,QACF;AAAA,MACF;AAKA,YAAM,MAAM,QAAQ;AACpB,UAAI,aAAa;AACjB,UAAI,YAAY,CAAC;AACjB,UAAI,CAAC,IAAI,QAAQ,YAAY,GAAG;AAC9B,YAAI,QAAQ,YAAY,IAAI;AAC5B,qBAAa;AAAA,MACf;AAMA,YAAM,YAAY,CAAC,EAAE,IAAI,iBAAiB,UAAU,IAAI,cAAc;AACtE,YAAM,eAAe,IAAI,QAAQ,MAAM;AACvC,UAAI,aAAa,iBAAiB,QAAW;AAC3C,YAAI,CAAC,IAAI,QAAQ,WAAW,GAAG;AAC7B,cAAI,QAAQ,WAAW,IAAI,gBAAgB;AAC3C,uBAAa;AAAA,QACf;AACA,YAAI,IAAI,QAAQ,MAAM,MAAM,QAAW;AACrC,cAAI,QAAQ,MAAM,IAAI;AACtB,uBAAa;AAAA,QACf;AAAA,MACF;AACA,UAAI,cAAc;AAClB,YAAM,gBACJ,CAAC,CAAC,IAAI,kBAAkB,kBAAkB,KAAK,CAAC,CAAC,IAAI,eAAe,kBAAkB;AACxF,UAAI,CAAC,eAAe;AAClB,YAAI,oBAAoB,CAAC;AACzB,YAAI,gBAAgB,kBAAkB,IAAI;AAC1C,sBAAc;AACd,qBAAa;AAAA,MACf;AACA,UAAI,YAAY;AACd,cAAM,UAAU,QAAQ,SAAS,KAAK,UAAU,KAAK,MAAM,CAAC,IAAI,MAAM,OAAO;AAC7E,gBAAQ,KAAK,cAAc;AAAA,MAC7B;AAIA,UAAI,aAAa;AACf,cAAM,MAAM,MAAM,KAAK,MAAM,QAAQ,CAAC,SAAS,GAAG,EAAE,KAAK,WAAW,KAAK,CAAC;AAC1E,YAAI,IAAI,SAAS,GAAG;AAClB,iBAAO,EAAE,MAAM,UAAU,OAAO,6BAA6B,IAAI,IAAI,IAAI;AAAA,QAC3E;AAAA,MACF;AAMA,UAAI,CAAE,MAAM,mBAAmB,KAAK,OAAO,KAAK,OAAO,GAAI;AACzD,cAAM,KAAK,kBAAkB;AAAA,MAC/B;AAIA,YAAM,OAAO,+EAA+E;AAC5F,aAAO,MAAM,SAAS,IAAI,EAAE,MAAM,MAAM,OAAO,MAAM,KAAK,IAAI,EAAE,IAAI,EAAE,MAAM,KAAK;AAAA,IACnF;AAAA,EACF,CAAC;AACH;","names":[]}
@@ -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,14 +1,14 @@
1
1
  import {
2
2
  fetchGaUsers,
3
3
  fetchSearch
4
- } from "./chunk-O6G4HHMF.js";
4
+ } from "./chunk-NRISG4XD.js";
5
5
  import {
6
6
  announcementSiteExtras,
7
7
  resolveCopy
8
- } from "./chunk-6JFOAPW4.js";
8
+ } from "./chunk-JCITWZQR.js";
9
9
  import {
10
10
  siteSlug
11
- } from "./chunk-C3PDS623.js";
11
+ } from "./chunk-BWKK2ZXS.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-SQJ53Z7R.js.map