@reddoorla/maintenance 0.79.0 → 0.80.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{announce-RUT247HS.js → announce-KZDU65IF.js} +6 -5
- package/dist/{announce-RUT247HS.js.map → announce-KZDU65IF.js.map} +1 -1
- package/dist/{chunk-FVVGDIGX.js → chunk-42A4H6SF.js} +2 -2
- package/dist/{chunk-SEQA7MC3.js → chunk-62VF32IC.js} +2 -2
- package/dist/{chunk-6SZBNALD.js → chunk-6M2IXJ3E.js} +3 -3
- package/dist/{chunk-MNE374CZ.js → chunk-6QRLRT3P.js} +4 -16
- package/dist/chunk-6QRLRT3P.js.map +1 -0
- package/dist/{chunk-QMXQUL5Z.js → chunk-7ADQL5Y3.js} +2 -2
- package/dist/{chunk-QWFHC3VH.js → chunk-AKRUYPLU.js} +3 -3
- package/dist/{chunk-3LWKIKZ2.js → chunk-JMAFXEQE.js} +4 -4
- package/dist/{chunk-KPQXB5CR.js → chunk-KKIKJWM2.js} +2 -2
- package/dist/chunk-N4MOEF32.js +62 -0
- package/dist/{chunk-LJPCMXR7.js.map → chunk-N4MOEF32.js.map} +1 -1
- package/dist/{chunk-EYODPUHV.js → chunk-NMAWXBJA.js} +13 -2
- package/dist/chunk-NMAWXBJA.js.map +1 -0
- package/dist/{chunk-YIQ7IWE3.js → chunk-R5KAXGSP.js} +3 -3
- package/dist/chunk-SEEA6RXF.js +18 -0
- package/dist/chunk-SEEA6RXF.js.map +1 -0
- package/dist/{chunk-FAQIMC26.js → chunk-TEOENEJH.js} +2 -2
- package/dist/{chunk-VSHC4HOI.js → chunk-YAMRVXOD.js} +93 -1
- package/dist/chunk-YAMRVXOD.js.map +1 -0
- package/dist/cli/bin.js +22 -10
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.js +4 -4
- package/dist/configs/lighthouse.d.ts +1 -1
- package/dist/configs/lighthouse.js +1 -1
- package/dist/configs/playwright-a11y.js +1 -1
- package/dist/{digest-NQAQI34P.js → digest-N232AYVG.js} +8 -6
- package/dist/{digest-NQAQI34P.js.map → digest-N232AYVG.js.map} +1 -1
- package/dist/forms-notify-target-ZDQUEEEY.js +253 -0
- package/dist/forms-notify-target-ZDQUEEEY.js.map +1 -0
- package/dist/{github-signals-4LWQGD5V.js → github-signals-2BFMZLPV.js} +2 -2
- package/dist/index.js +16 -14
- package/dist/index.js.map +1 -1
- package/dist/{init-Y5O33BUL.js → init-RYKQFSZI.js} +6 -6
- package/dist/{launch-3SYLX263.js → launch-EGKYS3BD.js} +9 -8
- package/dist/{launch-3SYLX263.js.map → launch-EGKYS3BD.js.map} +1 -1
- package/dist/{orchestrate-QOQXOJLI.js → orchestrate-NY6L4Q7Q.js} +4 -3
- package/dist/{preflight-2PWVVIRR.js → preflight-SRDQS7Y6.js} +5 -4
- package/dist/{preflight-2PWVVIRR.js.map → preflight-SRDQS7Y6.js.map} +1 -1
- package/dist/{protection-audit-X3BHT2MB.js → protection-audit-2UR45SOC.js} +30 -2
- package/dist/protection-audit-2UR45SOC.js.map +1 -0
- package/dist/{renovate-dispatch-2VYCEH42.js → renovate-dispatch-7D7FMMJP.js} +2 -2
- package/dist/{report-IU4CLJS5.js → report-BGKMN5GU.js} +9 -7
- package/dist/{report-IU4CLJS5.js.map → report-BGKMN5GU.js.map} +1 -1
- package/dist/{self-updating-ALMUWJNX.js → self-updating-WJEHPASK.js} +3 -3
- package/dist/{selftest-PJFABZDM.js → selftest-ZJDB4JQB.js} +7 -6
- package/dist/{selftest-PJFABZDM.js.map → selftest-ZJDB4JQB.js.map} +1 -1
- package/package.json +1 -1
- package/dist/chunk-EYODPUHV.js.map +0 -1
- package/dist/chunk-LJPCMXR7.js +0 -44
- package/dist/chunk-MNE374CZ.js.map +0 -1
- package/dist/chunk-VSHC4HOI.js.map +0 -1
- package/dist/protection-audit-X3BHT2MB.js.map +0 -1
- /package/dist/{chunk-FVVGDIGX.js.map → chunk-42A4H6SF.js.map} +0 -0
- /package/dist/{chunk-SEQA7MC3.js.map → chunk-62VF32IC.js.map} +0 -0
- /package/dist/{chunk-6SZBNALD.js.map → chunk-6M2IXJ3E.js.map} +0 -0
- /package/dist/{chunk-QMXQUL5Z.js.map → chunk-7ADQL5Y3.js.map} +0 -0
- /package/dist/{chunk-QWFHC3VH.js.map → chunk-AKRUYPLU.js.map} +0 -0
- /package/dist/{chunk-3LWKIKZ2.js.map → chunk-JMAFXEQE.js.map} +0 -0
- /package/dist/{chunk-KPQXB5CR.js.map → chunk-KKIKJWM2.js.map} +0 -0
- /package/dist/{chunk-YIQ7IWE3.js.map → chunk-R5KAXGSP.js.map} +0 -0
- /package/dist/{chunk-FAQIMC26.js.map → chunk-TEOENEJH.js.map} +0 -0
- /package/dist/{github-signals-4LWQGD5V.js.map → github-signals-2BFMZLPV.js.map} +0 -0
- /package/dist/{init-Y5O33BUL.js.map → init-RYKQFSZI.js.map} +0 -0
- /package/dist/{orchestrate-QOQXOJLI.js.map → orchestrate-NY6L4Q7Q.js.map} +0 -0
- /package/dist/{renovate-dispatch-2VYCEH42.js.map → renovate-dispatch-7D7FMMJP.js.map} +0 -0
- /package/dist/{self-updating-ALMUWJNX.js.map → self-updating-WJEHPASK.js.map} +0 -0
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
2
|
scoresFromRow
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-AKRUYPLU.js";
|
|
4
4
|
import {
|
|
5
5
|
fetchGaUsers,
|
|
6
6
|
fetchSearch,
|
|
7
7
|
readGaConfig
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-6M2IXJ3E.js";
|
|
9
9
|
import {
|
|
10
10
|
queueDraft
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-62VF32IC.js";
|
|
12
12
|
import "./chunk-37S6L6G4.js";
|
|
13
13
|
import {
|
|
14
14
|
defaultReportSubject
|
|
@@ -20,10 +20,11 @@ import {
|
|
|
20
20
|
renderReportHtml,
|
|
21
21
|
resolveCopy,
|
|
22
22
|
updateReportScores
|
|
23
|
-
} from "./chunk-
|
|
23
|
+
} from "./chunk-6QRLRT3P.js";
|
|
24
24
|
import {
|
|
25
25
|
uploadAttachment
|
|
26
26
|
} from "./chunk-MPBXOJ3N.js";
|
|
27
|
+
import "./chunk-SEEA6RXF.js";
|
|
27
28
|
import "./chunk-U5SZ4FOP.js";
|
|
28
29
|
import {
|
|
29
30
|
listWebsites,
|
|
@@ -186,4 +187,4 @@ export {
|
|
|
186
187
|
formatAnnounceResult,
|
|
187
188
|
runAnnounceCommand
|
|
188
189
|
};
|
|
189
|
-
//# sourceMappingURL=announce-
|
|
190
|
+
//# sourceMappingURL=announce-KZDU65IF.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/recipes/announce.ts","../src/cli/commands/announce.ts"],"sourcesContent":["import { openBase, readAirtableConfig } from \"../reports/airtable/client.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport { listWebsites, siteSlug, updateAnalyticsHealth } from \"../reports/airtable/websites.js\";\nimport type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport { readGaConfig } from \"../reports/ga/config.js\";\nimport {\n createDraft,\n findReportByPeriod,\n updateReportScores,\n type ReportEnrichment,\n} from \"../reports/airtable/reports.js\";\nimport { queueDraft } from \"../reports/queue.js\";\nimport { uploadAttachment } from \"../reports/airtable/attachments.js\";\nimport { renderReportHtml } from \"../reports/render.js\";\nimport { resolveCopy } from \"../reports/copy.js\";\nimport { fetchGaUsers, fetchSearch } from \"../reports/draft.js\";\nimport { announcementSiteExtras } from \"../reports/announcement-email/template.js\";\nimport type { LighthouseScores } from \"../reports/types.js\";\nimport { defaultReportSubject } from \"../reports/subject.js\";\nimport { scoresFromRow } from \"../reports/report-data.js\";\n\nexport type AnnounceSiteResult =\n | {\n site: string;\n status: \"drafted\" | \"reused\";\n reportId: string;\n recipientMissing: boolean;\n /** False when a higher-or-equal-tier report was already queued (single-queue rule). */\n queued: boolean;\n }\n | { site: string; status: \"skipped-no-scores\" }\n | { site: string; status: \"error\"; message: string };\n\nexport type AnnounceResult = { results: AnnounceSiteResult[] };\n\n/** The traffic/search lookback window (days) the announcement reports on. The trend compares it\n * against the equal-length prior window, and the email labels it \"vs the previous N days\". */\nconst GA_WINDOW_DAYS = 30;\n\nexport type AnnounceDeps = {\n /** Airtable handle. Defaults to opening the live base from credentials. */\n base?: AirtableBase;\n /** When set, restrict to the single site whose slug matches. Default: all maintenance sites. */\n site?: string;\n /** Single timestamp driving the period key, render, draft, and preview filename. */\n now?: Date;\n};\n\n/**\n * Draft the monthly-report ANNOUNCEMENT email for every `maintenance` site (or one,\n * via `deps.site`). Airtable-driven and fleet-wide: unlike `launch`, it runs no audits\n * and takes no `Site`/inventory object — it reads the Lighthouse scores already stored\n * on each Websites row. DRAFTS ONLY; the M3 approve loop sends.\n *\n * One bad site must never abort the run: each site is wrapped in its own try/catch that\n * records an `error` result and continues.\n */\nexport async function announce(deps?: AnnounceDeps): Promise<AnnounceResult> {\n const base = deps?.base ?? openBase(readAirtableConfig());\n const now = deps?.now ?? new Date();\n\n const websites = await listWebsites(base);\n let targets = websites.filter((w) => w.status === \"maintenance\");\n if (deps?.site) {\n const wanted = siteSlug(deps.site);\n targets = targets.filter((w) => siteSlug(w.name) === wanted);\n }\n\n const period = now.toISOString().slice(0, 7);\n const results: AnnounceSiteResult[] = [];\n\n for (const w of targets) {\n try {\n const scores = scoresFromRow(w);\n if (scores === null) {\n results.push({ site: w.name, status: \"skipped-no-scores\" });\n continue;\n }\n\n // Traffic + search snapshot over a ~30-day window ending now (fetchPeriodUsers derives\n // the equal-length previous window for the trend). Reuses the report pipeline's\n // soft-failing enrichment: GA/search unconfigured or an API error leaves the numbers\n // null and the email simply omits the traffic section — it never blocks the draft.\n const periodEnd = now;\n const periodStart = new Date(now.getTime() - GA_WINDOW_DAYS * 24 * 60 * 60 * 1000);\n const gaResult = await fetchGaUsers(w, periodStart, periodEnd);\n const searchResult = await fetchSearch(w, periodStart, periodEnd);\n const gaUsers = gaResult.value;\n const search = searchResult.value;\n const enrichment: ReportEnrichment = {\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 };\n\n // Record this site's GA/Search enrichment health for the per-site analytics-failure\n // signal (cockpit/digest) — exactly as the `--due` draft path does. Without this, an\n // announcement-time GA outage hides the traffic block with NO operator signal (it reads\n // identically to \"site has no GA configured\"). Set the timestamp on a soft-fail, clear\n // it on a clean enrichment so the signal self-heals. Best-effort: the column is\n // operator-added, so until it exists the write throws — which must not break the draft.\n if (readGaConfig() !== null && Boolean(w.ga4PropertyId || w.searchQuery)) {\n try {\n await updateAnalyticsHealth(\n base,\n w.id,\n gaResult.softFailed || searchResult.softFailed ? now.toISOString() : null,\n );\n } catch (e) {\n console.warn(`⚠ analytics-health write skipped for ${w.name}: ${(e as Error).message}`);\n }\n }\n\n // Dedupe: reuse an existing Announcement row for this (site, period) rather than\n // stacking a second draft. The reuse path refreshes the stored scores + traffic/search\n // (and Completed on) so the eventually-sent email — which reads the row — isn't stale.\n // The create path writes them via createDraft.\n let report;\n let statusKind: \"drafted\" | \"reused\";\n const existing = await findReportByPeriod(base, w.id, \"Announcement\", period);\n if (existing) {\n await updateReportScores(base, existing.id, scores, now, enrichment);\n report = existing;\n statusKind = \"reused\";\n } else {\n report = await createDraft(base, draftInputFor(w, scores, now, period, enrichment));\n statusKind = \"drafted\";\n }\n\n const slug = siteSlug(w.name);\n const { html } = await renderReportHtml({\n siteName: w.name,\n siteUrl: w.url,\n reportType: \"Announcement\",\n completedOn: now,\n lighthouse: scores,\n gaUsersCurrent: gaUsers?.current,\n gaUsersPrevious: gaUsers?.previous,\n gaPeriodDays: GA_WINDOW_DAYS,\n searchPosition: search?.foundOnPage1 ? (search.position ?? undefined) : undefined,\n lastTestedDate: null,\n commentary: null,\n copy: resolveCopy(w),\n headerImageCid: `${slug}-header`,\n // cadence (the client's go-forward pace, \"None\" omitted) + default-on improvement\n // callouts. Shared with the send re-render via announcementSiteExtras so the sent\n // email matches this reviewed preview.\n ...announcementSiteExtras(w),\n });\n\n // A preview-upload hiccup must NOT fail the site — log and continue.\n try {\n await uploadAttachment(\n report.id,\n \"Rendered HTML\",\n html,\n `${slug}-${now.toISOString().slice(0, 10)}.html`,\n \"text/html\",\n );\n } catch (uploadErr) {\n console.warn(\n `⚠ Announcement preview upload skipped for ${w.name}: ${\n uploadErr instanceof Error ? uploadErr.message : String(uploadErr)\n }`,\n );\n }\n\n // Critical: NOT wrapped — without queueing, the draft never enters the approve queue,\n // so a failure here must surface as an error result for the site. queueDraft also\n // supersedes any lower-tier (Maintenance/Testing) drafts queued for this site, and\n // stands down if an equal-or-higher report is already queued (single-queue rule).\n const queue = await queueDraft(base, {\n id: report.id,\n siteId: w.id,\n reportType: \"Announcement\",\n });\n\n const recipientMissing = !(w.reportRecipientsTo && w.reportRecipientsTo.trim());\n results.push({\n site: w.name,\n status: statusKind,\n reportId: report.id,\n recipientMissing,\n queued: queue.queued,\n });\n } catch (err) {\n results.push({\n site: w.name,\n status: \"error\",\n message: err instanceof Error ? err.message : String(err),\n });\n }\n }\n\n return { results };\n}\n\n/** Build the Announcement `DraftInput`. Announcements have no period window and no prior\n * maintenance test, so periodStart/periodEnd/completedOn all collapse to `now` and\n * `lastTestedDate` is null. The subject override gives the email a purpose-built line. */\nfunction draftInputFor(\n w: WebsiteRow,\n scores: LighthouseScores,\n now: Date,\n period: string,\n enrichment: ReportEnrichment,\n): Parameters<typeof createDraft>[1] {\n return {\n reportId: `${w.name} — Announcement — ${now.toISOString().slice(0, 10)}`,\n siteId: w.id,\n reportType: \"Announcement\",\n period,\n periodStart: now,\n periodEnd: now,\n completedOn: now,\n lighthouse: scores,\n lastTestedDate: null,\n subjectOverride: defaultReportSubject({\n name: w.name,\n url: w.url,\n type: \"Announcement\",\n date: now,\n }),\n ...enrichment,\n };\n}\n","import { announce, type AnnounceResult, type AnnounceSiteResult } from \"../../recipes/announce.js\";\n\nexport type AnnounceCommandOptions = {\n cwd?: string;\n};\n\nfunction formatSiteResult(r: AnnounceSiteResult): string {\n if (r.status === \"skipped-no-scores\") return `[${r.site}] skipped-no-scores`;\n if (r.status === \"error\") return `[${r.site}] error: ${r.message}`;\n const note = r.recipientMissing ? \" ⚠ recipient missing\" : \"\";\n return `[${r.site}] ${r.status}${note}`;\n}\n\nexport function formatAnnounceResult(result: AnnounceResult): string {\n if (result.results.length === 0) return \"No maintenance sites to announce.\";\n return result.results.map(formatSiteResult).join(\"\\n\");\n}\n\n/**\n * `announce [site]` — Airtable-driven and fleet-wide. Draft the monthly-report\n * announcement email for every `maintenance` site (or one, when `site` is given) into\n * the M3 approve queue. Never sends; the operator approves each draft and the next send\n * run delivers it. Reads the Lighthouse scores already stored on each Websites row —\n * no audits are run.\n */\nexport async function runAnnounceCommand(\n site: string | undefined,\n _opts: AnnounceCommandOptions,\n): Promise<{ output: string; code: number }> {\n const result = await announce(site ? { site } : {});\n const hadError = result.results.some((r) => r.status === \"error\");\n return { output: formatAnnounceResult(result), code: hadError ? 1 : 0 };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/recipes/announce.ts","../src/cli/commands/announce.ts"],"sourcesContent":["import { openBase, readAirtableConfig } from \"../reports/airtable/client.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport { listWebsites, siteSlug, updateAnalyticsHealth } from \"../reports/airtable/websites.js\";\nimport type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport { readGaConfig } from \"../reports/ga/config.js\";\nimport {\n createDraft,\n findReportByPeriod,\n updateReportScores,\n type ReportEnrichment,\n} from \"../reports/airtable/reports.js\";\nimport { queueDraft } from \"../reports/queue.js\";\nimport { uploadAttachment } from \"../reports/airtable/attachments.js\";\nimport { renderReportHtml } from \"../reports/render.js\";\nimport { resolveCopy } from \"../reports/copy.js\";\nimport { fetchGaUsers, fetchSearch } from \"../reports/draft.js\";\nimport { announcementSiteExtras } from \"../reports/announcement-email/template.js\";\nimport type { LighthouseScores } from \"../reports/types.js\";\nimport { defaultReportSubject } from \"../reports/subject.js\";\nimport { scoresFromRow } from \"../reports/report-data.js\";\n\nexport type AnnounceSiteResult =\n | {\n site: string;\n status: \"drafted\" | \"reused\";\n reportId: string;\n recipientMissing: boolean;\n /** False when a higher-or-equal-tier report was already queued (single-queue rule). */\n queued: boolean;\n }\n | { site: string; status: \"skipped-no-scores\" }\n | { site: string; status: \"error\"; message: string };\n\nexport type AnnounceResult = { results: AnnounceSiteResult[] };\n\n/** The traffic/search lookback window (days) the announcement reports on. The trend compares it\n * against the equal-length prior window, and the email labels it \"vs the previous N days\". */\nconst GA_WINDOW_DAYS = 30;\n\nexport type AnnounceDeps = {\n /** Airtable handle. Defaults to opening the live base from credentials. */\n base?: AirtableBase;\n /** When set, restrict to the single site whose slug matches. Default: all maintenance sites. */\n site?: string;\n /** Single timestamp driving the period key, render, draft, and preview filename. */\n now?: Date;\n};\n\n/**\n * Draft the monthly-report ANNOUNCEMENT email for every `maintenance` site (or one,\n * via `deps.site`). Airtable-driven and fleet-wide: unlike `launch`, it runs no audits\n * and takes no `Site`/inventory object — it reads the Lighthouse scores already stored\n * on each Websites row. DRAFTS ONLY; the M3 approve loop sends.\n *\n * One bad site must never abort the run: each site is wrapped in its own try/catch that\n * records an `error` result and continues.\n */\nexport async function announce(deps?: AnnounceDeps): Promise<AnnounceResult> {\n const base = deps?.base ?? openBase(readAirtableConfig());\n const now = deps?.now ?? new Date();\n\n const websites = await listWebsites(base);\n let targets = websites.filter((w) => w.status === \"maintenance\");\n if (deps?.site) {\n const wanted = siteSlug(deps.site);\n targets = targets.filter((w) => siteSlug(w.name) === wanted);\n }\n\n const period = now.toISOString().slice(0, 7);\n const results: AnnounceSiteResult[] = [];\n\n for (const w of targets) {\n try {\n const scores = scoresFromRow(w);\n if (scores === null) {\n results.push({ site: w.name, status: \"skipped-no-scores\" });\n continue;\n }\n\n // Traffic + search snapshot over a ~30-day window ending now (fetchPeriodUsers derives\n // the equal-length previous window for the trend). Reuses the report pipeline's\n // soft-failing enrichment: GA/search unconfigured or an API error leaves the numbers\n // null and the email simply omits the traffic section — it never blocks the draft.\n const periodEnd = now;\n const periodStart = new Date(now.getTime() - GA_WINDOW_DAYS * 24 * 60 * 60 * 1000);\n const gaResult = await fetchGaUsers(w, periodStart, periodEnd);\n const searchResult = await fetchSearch(w, periodStart, periodEnd);\n const gaUsers = gaResult.value;\n const search = searchResult.value;\n const enrichment: ReportEnrichment = {\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 };\n\n // Record this site's GA/Search enrichment health for the per-site analytics-failure\n // signal (cockpit/digest) — exactly as the `--due` draft path does. Without this, an\n // announcement-time GA outage hides the traffic block with NO operator signal (it reads\n // identically to \"site has no GA configured\"). Set the timestamp on a soft-fail, clear\n // it on a clean enrichment so the signal self-heals. Best-effort: the column is\n // operator-added, so until it exists the write throws — which must not break the draft.\n if (readGaConfig() !== null && Boolean(w.ga4PropertyId || w.searchQuery)) {\n try {\n await updateAnalyticsHealth(\n base,\n w.id,\n gaResult.softFailed || searchResult.softFailed ? now.toISOString() : null,\n );\n } catch (e) {\n console.warn(`⚠ analytics-health write skipped for ${w.name}: ${(e as Error).message}`);\n }\n }\n\n // Dedupe: reuse an existing Announcement row for this (site, period) rather than\n // stacking a second draft. The reuse path refreshes the stored scores + traffic/search\n // (and Completed on) so the eventually-sent email — which reads the row — isn't stale.\n // The create path writes them via createDraft.\n let report;\n let statusKind: \"drafted\" | \"reused\";\n const existing = await findReportByPeriod(base, w.id, \"Announcement\", period);\n if (existing) {\n await updateReportScores(base, existing.id, scores, now, enrichment);\n report = existing;\n statusKind = \"reused\";\n } else {\n report = await createDraft(base, draftInputFor(w, scores, now, period, enrichment));\n statusKind = \"drafted\";\n }\n\n const slug = siteSlug(w.name);\n const { html } = await renderReportHtml({\n siteName: w.name,\n siteUrl: w.url,\n reportType: \"Announcement\",\n completedOn: now,\n lighthouse: scores,\n gaUsersCurrent: gaUsers?.current,\n gaUsersPrevious: gaUsers?.previous,\n gaPeriodDays: GA_WINDOW_DAYS,\n searchPosition: search?.foundOnPage1 ? (search.position ?? undefined) : undefined,\n lastTestedDate: null,\n commentary: null,\n copy: resolveCopy(w),\n headerImageCid: `${slug}-header`,\n // cadence (the client's go-forward pace, \"None\" omitted) + default-on improvement\n // callouts. Shared with the send re-render via announcementSiteExtras so the sent\n // email matches this reviewed preview.\n ...announcementSiteExtras(w),\n });\n\n // A preview-upload hiccup must NOT fail the site — log and continue.\n try {\n await uploadAttachment(\n report.id,\n \"Rendered HTML\",\n html,\n `${slug}-${now.toISOString().slice(0, 10)}.html`,\n \"text/html\",\n );\n } catch (uploadErr) {\n console.warn(\n `⚠ Announcement preview upload skipped for ${w.name}: ${\n uploadErr instanceof Error ? uploadErr.message : String(uploadErr)\n }`,\n );\n }\n\n // Critical: NOT wrapped — without queueing, the draft never enters the approve queue,\n // so a failure here must surface as an error result for the site. queueDraft also\n // supersedes any lower-tier (Maintenance/Testing) drafts queued for this site, and\n // stands down if an equal-or-higher report is already queued (single-queue rule).\n const queue = await queueDraft(base, {\n id: report.id,\n siteId: w.id,\n reportType: \"Announcement\",\n });\n\n const recipientMissing = !(w.reportRecipientsTo && w.reportRecipientsTo.trim());\n results.push({\n site: w.name,\n status: statusKind,\n reportId: report.id,\n recipientMissing,\n queued: queue.queued,\n });\n } catch (err) {\n results.push({\n site: w.name,\n status: \"error\",\n message: err instanceof Error ? err.message : String(err),\n });\n }\n }\n\n return { results };\n}\n\n/** Build the Announcement `DraftInput`. Announcements have no period window and no prior\n * maintenance test, so periodStart/periodEnd/completedOn all collapse to `now` and\n * `lastTestedDate` is null. The subject override gives the email a purpose-built line. */\nfunction draftInputFor(\n w: WebsiteRow,\n scores: LighthouseScores,\n now: Date,\n period: string,\n enrichment: ReportEnrichment,\n): Parameters<typeof createDraft>[1] {\n return {\n reportId: `${w.name} — Announcement — ${now.toISOString().slice(0, 10)}`,\n siteId: w.id,\n reportType: \"Announcement\",\n period,\n periodStart: now,\n periodEnd: now,\n completedOn: now,\n lighthouse: scores,\n lastTestedDate: null,\n subjectOverride: defaultReportSubject({\n name: w.name,\n url: w.url,\n type: \"Announcement\",\n date: now,\n }),\n ...enrichment,\n };\n}\n","import { announce, type AnnounceResult, type AnnounceSiteResult } from \"../../recipes/announce.js\";\n\nexport type AnnounceCommandOptions = {\n cwd?: string;\n};\n\nfunction formatSiteResult(r: AnnounceSiteResult): string {\n if (r.status === \"skipped-no-scores\") return `[${r.site}] skipped-no-scores`;\n if (r.status === \"error\") return `[${r.site}] error: ${r.message}`;\n const note = r.recipientMissing ? \" ⚠ recipient missing\" : \"\";\n return `[${r.site}] ${r.status}${note}`;\n}\n\nexport function formatAnnounceResult(result: AnnounceResult): string {\n if (result.results.length === 0) return \"No maintenance sites to announce.\";\n return result.results.map(formatSiteResult).join(\"\\n\");\n}\n\n/**\n * `announce [site]` — Airtable-driven and fleet-wide. Draft the monthly-report\n * announcement email for every `maintenance` site (or one, when `site` is given) into\n * the M3 approve queue. Never sends; the operator approves each draft and the next send\n * run delivers it. Reads the Lighthouse scores already stored on each Websites row —\n * no audits are run.\n */\nexport async function runAnnounceCommand(\n site: string | undefined,\n _opts: AnnounceCommandOptions,\n): Promise<{ output: string; code: number }> {\n const result = await announce(site ? { site } : {});\n const hadError = result.results.some((r) => r.status === \"error\");\n return { output: formatAnnounceResult(result), code: hadError ? 1 : 0 };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
renderReportHtml,
|
|
12
12
|
resolveCopy,
|
|
13
13
|
stampSent
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-6QRLRT3P.js";
|
|
15
15
|
import {
|
|
16
16
|
fetchAttachmentBytes
|
|
17
17
|
} from "./chunk-MPBXOJ3N.js";
|
|
@@ -333,4 +333,4 @@ export {
|
|
|
333
333
|
parseAddresses,
|
|
334
334
|
isProbablyEmail
|
|
335
335
|
};
|
|
336
|
-
//# sourceMappingURL=chunk-
|
|
336
|
+
//# sourceMappingURL=chunk-42A4H6SF.js.map
|
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
isPendingApproval,
|
|
3
3
|
listReportsForSite,
|
|
4
4
|
setDraftReady
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-6QRLRT3P.js";
|
|
6
6
|
|
|
7
7
|
// src/reports/queue.ts
|
|
8
8
|
var REPORT_TIER = {
|
|
@@ -35,4 +35,4 @@ export {
|
|
|
35
35
|
reportTier,
|
|
36
36
|
queueDraft
|
|
37
37
|
};
|
|
38
|
-
//# sourceMappingURL=chunk-
|
|
38
|
+
//# sourceMappingURL=chunk-62VF32IC.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
queueDraft
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-62VF32IC.js";
|
|
4
4
|
import {
|
|
5
5
|
generateHeaderImage
|
|
6
6
|
} from "./chunk-37S6L6G4.js";
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
listReportsForSite,
|
|
12
12
|
renderReportHtml,
|
|
13
13
|
resolveCopy
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-6QRLRT3P.js";
|
|
15
15
|
import {
|
|
16
16
|
uploadAttachment
|
|
17
17
|
} from "./chunk-MPBXOJ3N.js";
|
|
@@ -727,4 +727,4 @@ export {
|
|
|
727
727
|
fetchGaUsers,
|
|
728
728
|
fetchSearch
|
|
729
729
|
};
|
|
730
|
-
//# sourceMappingURL=chunk-
|
|
730
|
+
//# sourceMappingURL=chunk-6M2IXJ3E.js.map
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import {
|
|
2
|
+
escapeHtml
|
|
3
|
+
} from "./chunk-SEEA6RXF.js";
|
|
1
4
|
import {
|
|
2
5
|
isHttpUrl
|
|
3
6
|
} from "./chunk-U5SZ4FOP.js";
|
|
@@ -118,19 +121,6 @@ async function loadBundledImages() {
|
|
|
118
121
|
};
|
|
119
122
|
}
|
|
120
123
|
|
|
121
|
-
// src/util/html.ts
|
|
122
|
-
function escapeHtml(s) {
|
|
123
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
124
|
-
}
|
|
125
|
-
function safeUrl(raw) {
|
|
126
|
-
try {
|
|
127
|
-
const u = new URL(raw);
|
|
128
|
-
if (u.protocol === "http:" || u.protocol === "https:") return raw;
|
|
129
|
-
} catch {
|
|
130
|
-
}
|
|
131
|
-
return "#";
|
|
132
|
-
}
|
|
133
|
-
|
|
134
124
|
// src/reports/email-sections.ts
|
|
135
125
|
var escapeXml = escapeHtml;
|
|
136
126
|
var RED = "#C00";
|
|
@@ -802,8 +792,6 @@ export {
|
|
|
802
792
|
CHECK_CID,
|
|
803
793
|
BLURRED_CID,
|
|
804
794
|
loadBundledImages,
|
|
805
|
-
escapeHtml,
|
|
806
|
-
safeUrl,
|
|
807
795
|
announcementSiteExtras,
|
|
808
796
|
renderReportHtml,
|
|
809
797
|
checklistFor,
|
|
@@ -821,4 +809,4 @@ export {
|
|
|
821
809
|
stampSent,
|
|
822
810
|
findReportByPeriod
|
|
823
811
|
};
|
|
824
|
-
//# sourceMappingURL=chunk-
|
|
812
|
+
//# sourceMappingURL=chunk-6QRLRT3P.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/reports/copy.ts","../src/reports/maintenance-email/assets/index.ts","../src/reports/email-sections.ts","../src/reports/maintenance-email/template.ts","../src/reports/announcement-email/template.ts","../src/reports/render.ts","../src/reports/launch-email/template.ts","../src/reports/checklist.ts","../src/reports/airtable/reports.ts"],"sourcesContent":["import type { WebsiteRow } from \"./airtable/websites.js\";\n\nexport type ResolvedCopy = {\n maintenanceIntro: string;\n maintenanceChecks: string[]; // 6; index 3 is the Google row's no-position default\n testingIntro: string;\n testingChecklist: string[]; // 7\n notesHeader: string;\n seoCta: string;\n contact: string[]; // closing invitation lines\n footerOrg: string;\n footerAddress: string[];\n launchHeading: string;\n launchBody: string;\n launchSetupItems: string[];\n announceHeading: string;\n announceBody: string;\n announceImprovementResend: string;\n announceImprovementSvelte5: string;\n /** The cadence lead-in, ending at the em dash. Rendered followed by `announceCadenceNote`\n * in italics. */\n announceCadence: string;\n /** The reassurance tail, rendered italic after `announceCadence`. */\n announceCadenceNote: string;\n announceOpenDoor: string;\n};\n\nexport const DEFAULT_COPY: ResolvedCopy = {\n maintenanceIntro:\n \"Includes checking the hosting, DNS, Content Management System (CMS, if applicable), search indexing and security of the site for major flaws and updating as necessary.\",\n maintenanceChecks: [\n \"Deploy & Function Health\",\n \"CMS Checked\",\n \"Domain, DNS & SSL\",\n \"Google Indexed\",\n \"Security Updates\",\n \"Uptime Checked\",\n ],\n testingIntro:\n \"Testing includes checks similar to those at launch: testing on common browsers and operating systems, at different screen sizes, and checking every function, and updating all packages for performance rather than just those needed for security.\",\n testingChecklist: [\n \"Desktop Browsers\",\n \"Mobile Browsers\",\n \"Page Titles & Meta\",\n \"Links & Navigation\",\n \"Form Functionality\",\n \"Interactions & Animations\",\n \"Tested After Updates\",\n ],\n notesHeader: \"NOTES\",\n seoCta: \"Contact us if you are interested in more in-depth data or have questions about SEO.\",\n contact: [\"Just hit reply.\", \"We're here to help in any way we can.\"],\n footerOrg: \"Reddoor Creative, LLC\",\n footerAddress: [\"29027 Dapper Dan\", \"Fair Oaks Ranch, TX 78015\"],\n launchHeading: \"LAUNCHED\",\n launchBody:\n \"Your site is live. We've set it up on the Reddoor stack with hosting, security, and automatic maintenance so it stays fast and healthy. Here's what's in place:\",\n launchSetupItems: [\n \"Hosting, DNS, and SSL configured\",\n \"Continuous integration + automatic dependency updates\",\n \"Analytics and uptime monitoring\",\n ],\n announceHeading: \"YOUR ONGOING SITE CARE\",\n announceBody:\n \"We've completed a full test of your site and set it up for ongoing care to keep it fast, secure, and healthy. Here's what you can expect from us going forward:\",\n announceImprovementResend:\n \"Your contact forms now deliver straight to your inbox through reliable infrastructure, so no inquiry slips through the cracks.\",\n announceImprovementSvelte5:\n \"We've modernized your site to the latest framework — it's faster, more secure, and built to last.\",\n announceCadence: \"After each one we'll send you a short report like this —\",\n announceCadenceNote: \"there's nothing you need to do.\",\n announceOpenDoor:\n \"And if you'd ever like to expand the scope, add features, or freshen anything up, just let us know.\",\n};\n\n/** Trim an override to null when blank (mirrors the trim-to-null handling). */\nfunction override(v: string | null): string | null {\n if (typeof v !== \"string\") return null;\n const t = v.trim();\n return t.length > 0 ? t : null;\n}\n\n/**\n * Resolve a site's effective copy: DEFAULT_COPY with the three per-site narrative\n * overrides applied. Only maintenanceIntro/contact/footer are per-site (M6a §2);\n * everything else is the shared default. PURE.\n */\n/** Split an operator override into lines: tolerate CRLF, drop blank lines (a stray\n * blank in the Airtable cell shouldn't render an empty address row). */\nfunction splitLines(s: string): string[] {\n return s.split(/\\r?\\n/).filter((l) => l.trim().length > 0);\n}\n\nexport function resolveCopy(site: WebsiteRow): ResolvedCopy {\n const intro = override(site.copyIntro);\n const contact = override(site.copyContact);\n const footer = override(site.copyFooter);\n const footerLines = footer ? splitLines(footer) : null;\n return {\n ...DEFAULT_COPY,\n maintenanceIntro: intro ?? DEFAULT_COPY.maintenanceIntro,\n contact: contact ? splitLines(contact) : DEFAULT_COPY.contact,\n footerOrg: footerLines?.[0] ?? DEFAULT_COPY.footerOrg,\n footerAddress: footerLines ? footerLines.slice(1) : DEFAULT_COPY.footerAddress,\n };\n}\n","import { readFile } from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nexport const CHECK_CID = \"rd-check-png\";\nexport const BLURRED_CID = \"rd-blurred-tests-jpg\";\n\nexport type BundledImage = {\n bytes: Uint8Array;\n contentType: string;\n cid: string;\n filename: string;\n};\n\n// Walk up from the current module's URL looking for the assets dir in either\n// the dev layout (src/reports/maintenance-email/assets/) or the published\n// layout (dist/reports/maintenance-email/assets/). REQUIRED because tsup\n// inlines this module into dist/cli/bin.js — so `import.meta.url`-based\n// sibling resolution looks in dist/cli/ for the PNGs and fails with ENOENT.\n// Regression that shipped in 0.10.0–0.10.1; tests passed in dev because\n// vitest evaluates the source file where import.meta.url is already correct.\nlet cachedAssetsDir: string | null = null;\nfunction resolveAssetsDir(): string {\n if (cachedAssetsDir) return cachedAssetsDir;\n let dir = dirname(fileURLToPath(import.meta.url));\n while (true) {\n // Source layout preferred — single source of truth in the workspace\n // and the only one present in dev/test environments.\n const srcCandidate = join(dir, \"src\", \"reports\", \"maintenance-email\", \"assets\", \"check.png\");\n if (existsSync(srcCandidate)) {\n cachedAssetsDir = dirname(srcCandidate);\n return cachedAssetsDir;\n }\n // Published layout — only `dist/` ships per package.json#files, so\n // consumers fall through to here.\n const distCandidate = join(dir, \"dist\", \"reports\", \"maintenance-email\", \"assets\", \"check.png\");\n if (existsSync(distCandidate)) {\n cachedAssetsDir = dirname(distCandidate);\n return cachedAssetsDir;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n throw new Error(\n `loadBundledImages: could not locate maintenance-email assets dir by walking up from ${fileURLToPath(import.meta.url)}. Checked both src/ and dist/ layouts.`,\n );\n }\n dir = parent;\n }\n}\n\n/**\n * Read the bundled image bytes from disk. Both Maintenance and Testing\n * variants reference `check.png`; only the Maintenance variant references\n * `blurredTests.jpg`.\n */\nexport async function loadBundledImages(): Promise<{\n check: BundledImage;\n blurred: BundledImage;\n}> {\n const assetsDir = resolveAssetsDir();\n const [check, blurred] = await Promise.all([\n readFile(join(assetsDir, \"check.png\")),\n readFile(join(assetsDir, \"blurredTests.jpg\")),\n ]);\n return {\n check: {\n bytes: new Uint8Array(check),\n contentType: \"image/png\",\n cid: CHECK_CID,\n filename: \"check.png\",\n },\n blurred: {\n bytes: new Uint8Array(blurred),\n contentType: \"image/jpeg\",\n cid: BLURRED_CID,\n filename: \"blurredTests.jpg\",\n },\n };\n}\n","import { escapeHtml } from \"../util/html.js\";\nimport { CHECK_CID } from \"./maintenance-email/assets/index.js\";\nimport type { ReportData } from \"./types.js\";\n\n/**\n * Shared MJML section builders for the report family of emails (maintenance/testing report,\n * announcement). Centralizing them here guarantees the announcement renders the SAME polished\n * components as the monthly report — the checklist rows, the full Lighthouse block, and the\n * analytics block — so the two can't drift in design. PURE string builders; no I/O.\n *\n * Escaping: callers pass already-trusted copy for fixed labels, but any site/operator string\n * (check labels, trailing notes) is escaped here via `escapeXml`.\n */\nexport const escapeXml = escapeHtml;\n\n// Brand palette shared across the report-email family (maintenance/testing,\n// launch, announcement) so a template can't drift onto hand-typed literals.\nexport const RED = \"#C00\";\nexport const GREY = \"#757575\";\nconst BORDER = \"#CCCCCC\";\nconst TREND_UP = \"#2E7D32\"; // positive green — growth reads as good\nconst TREND_NEUTRAL = GREY; // muted grey — dips/flat aren't failures (brand red is reserved)\n\n// The report's bundled green check (cid:rd-check-png), attached inline by orchestrate.ts at\n// send time. Standalone previews (no attachments) show the image's alt instead.\nconst CHECK_PNG = `cid:${CHECK_CID}`;\n\n/** Thousands-grouped user/visitor count. */\nexport function fmtUsers(n: number): string {\n return n.toLocaleString(\"en-US\");\n}\n\n/**\n * A checklist \"table\": one ruled row per label, the label on the left and the green check\n * right-aligned, matching the monthly report. `background` tints the band (white for the\n * maintenance list, #F4F4F4 for the testing list); `lastPaddingBottom` is the trailing gap\n * under the final row. Labels are escaped. PURE.\n */\nexport function checklistRowsSection(\n rows: string[],\n opts: { background: string; lastPaddingBottom: string },\n): string {\n return rows\n .map((label, i) => {\n const isLast = i === rows.length - 1;\n const border = isLast ? \"\" : ` border-bottom=\"solid ${BORDER} 1px\"`;\n const lastPad = isLast ? ` padding-bottom=\"${opts.lastPaddingBottom}\"` : \"\";\n return `\n <mj-section background-color=\"${opts.background}\" padding=\"0px\"${lastPad}>\n <mj-group>\n <mj-column padding-left=\"0px\" width=\"90%\"${border}>\n <mj-text height=\"25px\" padding-left=\"0px\" color=\"${GREY}\" padding-top=\"20px\" padding-bottom=\"7.5px\" font-size=\"16px\">${escapeXml(label)}</mj-text>\n </mj-column>\n <mj-column width=\"10%\"${border} padding-top=\"15px\">\n <mj-image align=\"right\" padding-right=\"0px\" width=\"20px\" height=\"20px\" padding-top=\"2.5px\" padding-bottom=\"15px\" src=\"${CHECK_PNG}\" />\n </mj-column>\n </mj-group>\n </mj-section>`;\n })\n .join(\"\");\n}\n\n/** The four Lighthouse scores with their client-facing labels and acceptable/ideal bands.\n * Ideal always tops at 100 (the metric's ceiling). */\nconst LIGHTHOUSE_ROWS: ReadonlyArray<{\n label: string;\n key: keyof ReportData[\"lighthouse\"];\n range: string;\n}> = [\n { label: \"Performance\", key: \"performance\", range: \"Acceptable 50–89 // Ideal 90–100\" },\n { label: \"Readability (A11y)\", key: \"accessibility\", range: \"Acceptable 80–99 // Ideal 100\" },\n { label: \"Best Practices\", key: \"bestPractices\", range: \"Acceptable 60–79 // Ideal 80–100\" },\n { label: \"Site Structure\", key: \"seo\", range: \"Acceptable 50–89 // Ideal 90–100\" },\n];\n\n/**\n * The full \"LIGHTHOUSE SCORES*\" block: each score as a big red number under its label with the\n * acceptable/ideal band beneath, ruled between scores, closed by the explanatory footnote.\n * `background` tints the section (default #F4F4F4, matching the report). When `pad` is given the\n * band's top/bottom padding moves to the section (symmetric bands for the announcement); omitting\n * it keeps the report's original inner paddings. PURE.\n */\nexport function lighthouseScoresSection(\n lighthouse: ReportData[\"lighthouse\"],\n opts: { background?: string; pad?: string } = {},\n): string {\n const background = opts.background ?? \"#F4F4F4\";\n const sectionPad = opts.pad ? ` padding-top=\"${opts.pad}\" padding-bottom=\"${opts.pad}\"` : \"\";\n const labelTop = opts.pad ?? \"55px\";\n const footnoteBottom = opts.pad ? \"0px\" : \"36px\";\n const rows = LIGHTHOUSE_ROWS.map(\n ({ label, key, range }, i) => `\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"300\" padding-top=\"25px\">${label}</mj-text>\n <mj-text color=\"${RED}\" font-size=\"44px\" font-weight=\"400\" padding-top=\"0px\">${lighthouse[key]}</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" padding-top=\"0px\" padding-bottom=\"36px\">${range}</mj-text>${\n i < LIGHTHOUSE_ROWS.length - 1\n ? `\n <mj-divider border-width=\"1px\" border-style=\"solid\" border-color=\"${BORDER}\" padding=\"0\" />`\n : \"\"\n }`,\n ).join(\"\");\n return `\n <mj-section background-color=\"${background}\"${sectionPad}>\n <mj-column>\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"${labelTop}\">LIGHTHOUSE SCORES*</mj-text>${rows}\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" padding-top=\"24px\" padding-bottom=\"${footnoteBottom}\" line-height=\"20px\">*A Lighthouse score is a numerical measure provided by <a href=\"https://developer.chrome.com/docs/lighthouse/overview\" style=\"color:${RED}; text-decoration:underline;\">Google's Lighthouse tool</a>, which evaluates various aspects of a web page's quality.</mj-text>\n </mj-column>\n </mj-section>`;\n}\n\n/** The line under \"{N} Users\": a directional trend vs the previous period when both numbers\n * are real, else a graceful fallback. `undefined` = GA unavailable (distinct from a real 0).\n * Up = green; down/flat = muted grey (a traffic dip isn't a failure). PURE. */\nexport function analyticsTrendLine(\n cur: number | undefined,\n prev: number | undefined,\n periodDays?: number,\n): string {\n // The prior window the trend compares against: a concrete \"the previous N days\" when the\n // caller knows the window length, else the generic \"last period\" (keeps the label honest for\n // callers — and tests — that don't supply it).\n const priorLabel =\n periodDays && periodDays > 0 ? `the previous ${periodDays} days` : \"last period\";\n if (cur === undefined || prev === undefined) {\n return trendLine(TREND_NEUTRAL, `Last Period: ${prev !== undefined ? fmtUsers(prev) : \"—\"}`);\n }\n if (prev === 0) {\n return cur > 0\n ? trendLine(TREND_UP, \"▲ New this period (0 last period)\")\n : trendLine(TREND_NEUTRAL, \"Last Period: 0\");\n }\n const pct = Math.round(((cur - prev) / prev) * 100);\n const range = `(${fmtUsers(prev)} → ${fmtUsers(cur)})`;\n if (pct > 0) return trendLine(TREND_UP, `▲ ${pct}% vs ${priorLabel} ${range}`);\n if (pct < 0) return trendLine(TREND_NEUTRAL, `▼ ${Math.abs(pct)}% vs ${priorLabel} ${range}`);\n return trendLine(TREND_NEUTRAL, `No change vs ${priorLabel} (${fmtUsers(prev)})`);\n}\n\n/** A 16px coloured line (the trend, and the announcement's search line). */\nfunction trendLine(color: string, text: string): string {\n return `<mj-text color=\"${color}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\">${text}</mj-text>`;\n}\n\n/** A 12px muted footnote line (the SEO call-to-action / Lighthouse note style). */\nfunction footnoteLine(text: string): string {\n return `<mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" padding-top=\"24px\" padding-bottom=\"36px\" line-height=\"20px\">${text}</mj-text>`;\n}\n\n/** True when the analytics block has at least one real datum to show — a GA user count or a body\n * line (e.g. the announcement's page-1 search callout). The static SEO call-to-action footnote\n * alone does NOT qualify: a block with no data is hidden rather than rendered empty. PURE. */\nexport function hasAnalyticsData(opts: {\n current?: number | undefined;\n bodyLines?: string[];\n}): boolean {\n return opts.current !== undefined || (opts.bodyLines?.length ?? 0) > 0;\n}\n\n/**\n * The \"ANALYTICS\" block: a big red user count, the trend line, then any 16px `bodyLines`\n * (e.g. the announcement's Google-position line) and 12px muted `footnoteLines` (e.g. the\n * report's SEO call-to-action). Callers pass already-escaped line text. PURE.\n *\n * Returns \"\" when there's no data ({@link hasAnalyticsData} false) — an empty \"— Users\" block\n * reads as broken, so the section (and its data-contextual SEO footnote) is omitted. When a body\n * line exists but no GA count (a GA-less site still ranking on page 1), the user count + trend\n * are suppressed and only the body shows under the label.\n */\nexport function analyticsSection(opts: {\n current?: number | undefined;\n previous?: number | undefined;\n /** Length in days of the current window; drives the trend's \"vs the previous N days\" label. */\n periodDays?: number | undefined;\n background: string;\n bodyLines?: string[];\n footnoteLines?: string[];\n pad?: string;\n}): string {\n if (!hasAnalyticsData(opts)) return \"\";\n const body = (opts.bodyLines ?? []).map((l) => trendLine(TREND_NEUTRAL, l)).join(\"\\n \");\n const footnotes = (opts.footnoteLines ?? []).map(footnoteLine).join(\"\\n \");\n const sectionPad = opts.pad ? ` padding-top=\"${opts.pad}\" padding-bottom=\"${opts.pad}\"` : \"\";\n const labelTop = opts.pad ?? \"75px\";\n const usersBlock =\n opts.current !== undefined\n ? `\n <mj-text color=\"${RED}\" font-size=\"44px\" font-weight=\"400\">${fmtUsers(opts.current)} Users</mj-text>\n ${analyticsTrendLine(opts.current, opts.previous, opts.periodDays)}`\n : \"\";\n return `\n <mj-section background-color=\"${opts.background}\"${sectionPad}>\n <mj-column>\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"${labelTop}\">ANALYTICS</mj-text>${usersBlock}\n ${body}\n ${footnotes}\n </mj-column>\n </mj-section>`;\n}\n","import type { ReportData } from \"../types.js\";\nimport { DEFAULT_COPY, type ResolvedCopy } from \"../copy.js\";\nimport { BLURRED_CID } from \"./assets/index.js\";\nimport {\n checklistRowsSection,\n lighthouseScoresSection,\n analyticsSection,\n RED,\n GREY,\n} from \"../email-sections.js\";\nimport { escapeHtml } from \"../../util/html.js\";\nimport { isHttpUrl } from \"../../util/url.js\";\n\n/**\n * Escape operator/site-controlled strings before interpolating into the MJML markup.\n * MJML parses as XML with `validationLevel: \"strict\"`. Under mjml@4.18 a raw `&`, `<`,\n * or `>` does NOT throw — it passes straight through into the rendered output, so an\n * unescaped value (e.g. a site name \"Brown & Co\", a URL, or commentary) silently\n * injects HTML/markup into the email. A raw `\"` inside an ATTRIBUTE value (e.g. the\n * image `href`/`alt`) is the one that throws — it terminates the attribute and trips a\n * parse error that blocks the send. So we escape for two reasons: prevent\n * HTML/markup injection in text, and prevent the attribute-quote parse error. Apply\n * to every interpolation of siteName / siteUrl / commentary / copy.\n *\n * This IS `src/util/html.ts`'s `escapeHtml` (the strict-XML set is identical),\n * re-exported under the name the email templates import (the launch template imports\n * `escapeXml` from here).\n */\nexport const escapeXml = escapeHtml;\n\n// Bundled images: shipped in dist/ via tsup onSuccess copy, attached inline via\n// CID by orchestrate.ts at send time. No external CDN dependency. (The green check\n// image lives in the shared email-sections checklist component.)\nconst BLURRED_TESTS = `cid:${BLURRED_CID}`;\n\nexport function fmtDate(d: Date | null): string {\n // Guard BOTH null AND an Invalid Date — `new Date(\"not-a-date\")` (a malformed\n // Airtable date string) is a truthy Date whose getUTC* accessors all return\n // NaN, which would render \"NaN.NaN.NaN\" into a real client email. `!d` alone\n // misses it; `Number.isNaN(d.getTime())` catches it.\n if (!d || Number.isNaN(d.getTime())) return \"\";\n // Airtable date fields are wall-clock YYYY-MM-DD strings parsed as UTC midnight.\n // Use UTC accessors so the rendered date matches what the operator entered.\n // US format: MM.DD.YYYY (Reddoor is Texas-based, clients are US).\n const mm = String(d.getUTCMonth() + 1).padStart(2, \"0\");\n const dd = String(d.getUTCDate()).padStart(2, \"0\");\n const yyyy = d.getUTCFullYear();\n return `${mm}.${dd}.${yyyy}`;\n}\n\nfunction maintenanceChecksSection(copy: ResolvedCopy, searchPosition?: number): string {\n // The Google row shows the live search position when available, else the plain label.\n const googleLabel =\n searchPosition !== undefined\n ? `Page 1 Google Result (#${searchPosition})`\n : (copy.maintenanceChecks[3] ?? \"\");\n const rows = copy.maintenanceChecks.map((label, i) => (i === 3 ? googleLabel : label));\n return checklistRowsSection(rows, { background: \"white\", lastPaddingBottom: \"36px\" });\n}\n\nfunction testingChecklistSection(copy: ResolvedCopy): string {\n return checklistRowsSection(copy.testingChecklist, {\n background: \"#F4F4F4\",\n lastPaddingBottom: \"60px\",\n });\n}\n\nfunction maintenanceTestingPlaceholder(): string {\n return `\n <mj-section background-color=\"#F4F4F4\">\n <mj-column>\n <mj-image href=\"mailto:info@reddoorla.com\" src=\"${BLURRED_TESTS}\" />\n </mj-column>\n </mj-section>`;\n}\n\nfunction testingIntroSection(copy: ResolvedCopy): string {\n return `\n <mj-section background-color=\"#F4F4F4\">\n <mj-column>\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"75px\">TESTING</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\">${escapeXml(copy.testingIntro)}</mj-text>\n </mj-column>\n </mj-section>`;\n}\n\nfunction commentarySection(text: string, copy: ResolvedCopy): string {\n return `\n <mj-section background-color=\"white\">\n <mj-column>\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"55px\">${escapeXml(copy.notesHeader)}</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\">${escapeXml(text).replace(/\\r\\n?|\\n/g, \"<br/>\")}</mj-text>\n </mj-column>\n </mj-section>`;\n}\n\nfunction hasHeaderDims(\n data: ReportData,\n): data is ReportData & { headerWidth: number; headerHeight: number; headerBgColor: string } {\n return Boolean(data.headerWidth && data.headerHeight && data.headerBgColor);\n}\n\nexport function headerImageTag(data: ReportData): string {\n const src = `cid:${data.headerImageCid}`;\n const alt = `${escapeXml(data.siteName)} maintenance report`;\n // escapeXml only escapes markup chars — it does NOT neutralize a dangerous URL\n // scheme. A `javascript:`/`data:` siteUrl would survive escaping and become a live\n // header href. Gate on isHttpUrl (the same http(s) allowlist the audit path uses)\n // and DROP a non-http(s) href entirely (fall back to \"#\") rather than linking it.\n const href = isHttpUrl(data.siteUrl) ? escapeXml(data.siteUrl) : \"#\";\n // Reserve the box and show a matched placeholder while the image loads / if blocked.\n // Critically, we do NOT set an mj-image `height` — MJML would emit `height:<px>` while\n // keeping `width:100%`, locking the height while the width scales and distorting the\n // image at any rendered width != the design width (mobile, narrow panes). Instead the\n // image stays `height:auto` (proportional) and the box is reserved via `aspect-ratio`\n // in the head <mj-style> below (see headerStyleBlock). `container-background-color` is\n // the placeholder; the bare fallback (no dims, e.g. local preview) keeps today's behavior.\n if (hasHeaderDims(data)) {\n return `<mj-image href=\"${href}\" src=\"${src}\" alt=\"${alt}\" width=\"${data.headerWidth}px\" css-class=\"rd-header\" container-background-color=\"${data.headerBgColor}\" />`;\n }\n return `<mj-image href=\"${href}\" src=\"${src}\" alt=\"${alt}\" />`;\n}\n\nexport function headerStyleBlock(data: ReportData): string {\n if (!hasHeaderDims(data)) return \"\";\n // Reserve the header's vertical space by aspect ratio so it scales proportionally with\n // its fluid (width:100%) width — no fixed pixel height, so it never squishes.\n // `height:auto !important` defends against any client honoring MJML's inline height.\n return `<mj-style>.rd-header img { height: auto !important; aspect-ratio: ${data.headerWidth} / ${data.headerHeight}; }</mj-style>`;\n}\n\nexport function buildMjml(data: ReportData): string {\n const copy = data.copy ?? DEFAULT_COPY;\n const isTesting = data.reportType === \"Testing\";\n const previewText = `Checked up on ${escapeXml(data.siteName)}`;\n\n return `<mjml>\n <mj-head>\n <mj-attributes>\n <mj-text font-family=\"helvetica, sans-serif\" padding-left=\"5px\" padding-right=\"5px\" />\n <mj-section padding-left=\"11%\" padding-right=\"11%\"/>\n <mj-image padding=\"0px\" />\n </mj-attributes>\n <mj-preview>${previewText}</mj-preview>\n ${headerStyleBlock(data)}\n </mj-head>\n <mj-body background-color=\"white\">\n <mj-section background-color=\"#F4F4F4\" padding-top=\"0px\" padding-bottom=\"0px\" padding-left=\"0px\" padding-right=\"0px\">\n <mj-column>\n ${headerImageTag(data)}\n </mj-column>\n </mj-section>\n <mj-section background-color=\"white\">\n <mj-column>\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"75px\">COMPLETED ON</mj-text>\n <mj-text color=\"${RED}\" font-size=\"44px\" font-weight=\"400\">${fmtDate(data.completedOn)}</mj-text>\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"75px\">MAINTENANCE CHECKS</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\">${escapeXml(copy.maintenanceIntro)}</mj-text>\n </mj-column>\n </mj-section>\n ${maintenanceChecksSection(copy, data.searchPosition)}\n ${lighthouseScoresSection(data.lighthouse)}\n ${analyticsSection({\n current: data.gaUsersCurrent,\n previous: data.gaUsersPrevious,\n periodDays: data.gaPeriodDays,\n background: \"white\",\n footnoteLines: [escapeXml(copy.seoCta)],\n })}\n ${isTesting ? testingIntroSection(copy) + testingChecklistSection(copy) : maintenanceTestingPlaceholder()}\n ${data.commentary ? commentarySection(data.commentary, copy) : \"\"}\n <mj-section background-color=\"white\">\n <mj-column padding-top=\"36px\">\n <mj-text color=\"${RED}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"700\" padding-top=\"36px\" line-height=\"36px\">Any questions, concerns or requests?</mj-text>\n ${copy.contact\n .map((line, i) => {\n // First line (\"Just hit reply.\") renders as a red bold heading, matching the\n // \"Any questions, concerns or requests?\" title above it; later lines are grey\n // (matching the launch/announcement templates); the last line keeps its\n // closing padding.\n const isLast = i === copy.contact.length - 1;\n const emphasis =\n i === 0\n ? `color=\"${RED}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"700\"`\n : `color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"300\"`;\n return isLast\n ? `<mj-text ${emphasis} padding-top=\"0px\" line-height=\"30px\" padding-bottom=\"36px\">${escapeXml(line)}</mj-text>`\n : `<mj-text ${emphasis} line-height=\"30px\">${escapeXml(line)}</mj-text>`;\n })\n .join(\"\\n \")}\n <mj-divider border-width=\"1px\" border-style=\"solid\" border-color=\"#CCCCCC\" padding=\"0\" />\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" padding-top=\"24px\" line-height=\"20px\" font-style=\"italic\">Copyright ${new Date().getUTCFullYear()} ${escapeXml(copy.footerOrg)}. All rights reserved.</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"700\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">Our mailing address is:</mj-text>\n ${[copy.footerOrg, ...copy.footerAddress]\n .map(\n (line) =>\n `<mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">${escapeXml(line)}</mj-text>`,\n )\n .join(\"\\n \")}\n </mj-column>\n </mj-section>\n </mj-body>\n</mjml>`;\n}\n","import type { ReportData, ReportFrequency } from \"../types.js\";\nimport type { WebsiteRow } from \"../airtable/websites.js\";\nimport { DEFAULT_COPY, type ResolvedCopy } from \"../copy.js\";\nimport { escapeXml, headerImageTag, headerStyleBlock } from \"../maintenance-email/template.js\";\nimport {\n checklistRowsSection,\n lighthouseScoresSection,\n analyticsSection,\n hasAnalyticsData,\n} from \"../email-sections.js\";\n\n/** Frequency → client-facing phrase. \"None\" is never rendered (the line is omitted). */\nconst FREQ_PHRASE: Record<Exclude<ReportFrequency, \"None\">, string> = {\n Monthly: \"every month\",\n Quarterly: \"every quarter\",\n Yearly: \"every year\",\n};\n\nconst RED = \"#C00\";\nconst GREY = \"#757575\";\n\n// Equal top/bottom padding for every alternating-background band, applied at the mj-section\n// level so each colored band has symmetric breathing room. (Starting baseline — easy to tune.)\nconst SECTION_PAD = \"40px\";\n\n/** A red all-caps section label. Top spacing comes from the section padding, so this is flush. */\nfunction sectionLabel(text: string): string {\n return `<mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"0px\">${escapeXml(text)}</mj-text>`;\n}\n\n/** A grey 16px body paragraph whose caller passes already-escaped HTML, so partial inline markup\n * (e.g. the cadence line's italic <em>…</em> tail) survives. */\nfunction bodyLineHtml(html: string, paddingTop = \"8px\"): string {\n return `<mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\" padding-top=\"${paddingTop}\">${html}</mj-text>`;\n}\n\n/** A grey 16px body paragraph, matching the report's section intros. Escapes `text`. */\nfunction bodyLine(text: string, paddingTop = \"8px\"): string {\n return bodyLineHtml(escapeXml(text), paddingTop);\n}\n\n/** The go-forward cadence sentence as HTML: the lead-in followed by its reassurance tail in\n * italics (\"…short report like this — <em>there's nothing you need to do.</em>\"). Both escaped. */\nfunction cadenceHtml(copy: ResolvedCopy): string {\n return `${escapeXml(copy.announceCadence)} <em>${escapeXml(copy.announceCadenceNote)}</em>`;\n}\n\n/**\n * The announcement-only ReportData extras derived from the Websites row: the go-forward\n * cadence and the default-on improvement callouts. Used by BOTH the draft (announce recipe)\n * and the send-time re-render (orchestrate) so the sent email matches the reviewed preview —\n * `renderReportHtml` in the send path otherwise omits these, dropping the cadence + improvements.\n */\nexport function announcementSiteExtras(\n site: WebsiteRow,\n): Pick<ReportData, \"cadence\" | \"improvements\"> {\n return {\n cadence: { maintenance: site.maintenanceFreq, testing: site.testingFreq },\n improvements: { resendForms: true, svelte5: true },\n };\n}\n\n/**\n * One-time onboarding announcement, built from the SAME components as the monthly report so it\n * reads as a testing report with extra explanation: header · intro (\"your ongoing care\") ·\n * MAINTENANCE CHECKS · TESTING (each: intro with the cadence baked into the copy + the report's\n * checklist rows) · LIGHTHOUSE SCORES · ANALYTICS (users + trend + the Google-position line) ·\n * RECENT IMPROVEMENTS (conditional; closes with the open-door invitation). Reuses the M6a copy\n * layer (contact/footer honor per-site overrides). No pricing. A pace set to None omits its\n * checklist section. Every band carries equal top/bottom padding (SECTION_PAD).\n */\nexport function buildAnnouncementMjml(data: ReportData): string {\n const copy = data.copy ?? DEFAULT_COPY;\n const previewText = \"Your monthly report from Reddoor\";\n const cad = data.cadence;\n\n const hasMaint = Boolean(cad && cad.maintenance !== \"None\");\n const hasTesting = Boolean(cad && cad.testing !== \"None\");\n const improvementItems: string[] = [];\n if (data.improvements?.resendForms) improvementItems.push(copy.announceImprovementResend);\n if (data.improvements?.svelte5) improvementItems.push(copy.announceImprovementSvelte5);\n const hasImpr = improvementItems.length > 0;\n\n // ANALYTICS renders only with real data — a GA user count or a page-1 search callout (the latter\n // keeps the block alive for a GA-less site that still ranks). Computed up here so the band\n // counter below can SKIP its background slot when analytics is hidden; otherwise a consumed-but-\n // invisible slot would push the next band to the wrong color and two same-color bands would abut.\n const analyticsBodyLines =\n data.searchPosition !== undefined\n ? [`Page 1 Google result (#${data.searchPosition}) for your brand search`]\n : [];\n const hasAnalytics = hasAnalyticsData({\n current: data.gaUsersCurrent,\n bodyLines: analyticsBodyLines,\n });\n\n // Alternating band backgrounds assigned in render order, so the white/#F4F4F4 pattern holds\n // no matter which optional sections (maintenance / testing / improvements) actually render —\n // a counter that only advances for bands that appear avoids two same-colored bands abutting.\n const BANDS = [\"white\", \"#F4F4F4\"] as const;\n let bandN = 0;\n const nextBg = (): string => BANDS[bandN++ % 2]!;\n const introBg = nextBg();\n const maintBg = hasMaint ? nextBg() : \"\";\n const testBg = hasTesting ? nextBg() : \"\";\n const lighthouseBg = nextBg();\n const analyticsBg = hasAnalytics ? nextBg() : \"\";\n const improvementsBg = hasImpr ? nextBg() : \"\";\n const contactBg = nextBg();\n\n // MAINTENANCE CHECKS (first) — intro with the maintenance cadence baked into the copy, then the\n // report's checklist rows. The report-frequency reassurance (announceCadence) trails the LAST\n // check block, so it sits here only when there's no testing block after it. Omitted when None.\n const maintenanceSection =\n cad && cad.maintenance !== \"None\"\n ? `\n <mj-section background-color=\"${maintBg}\" padding-top=\"${SECTION_PAD}\" padding-bottom=\"0px\">\n <mj-column>\n ${sectionLabel(\"MAINTENANCE CHECKS\")}\n ${bodyLineHtml(`${escapeXml(`${copy.maintenanceIntro} We do this ${FREQ_PHRASE[cad.maintenance]}.`)}${cad.testing === \"None\" ? ` ${cadenceHtml(copy)}` : \"\"}`)}\n </mj-column>\n </mj-section>${checklistRowsSection(copy.maintenanceChecks, {\n background: maintBg,\n lastPaddingBottom: SECTION_PAD,\n })}`\n : \"\";\n\n // TESTING (second) — intro with the testing cadence + the report-frequency note baked in, then\n // the report's checklist rows. Omitted when None.\n const testingSection =\n cad && cad.testing !== \"None\"\n ? `\n <mj-section background-color=\"${testBg}\" padding-top=\"${SECTION_PAD}\" padding-bottom=\"0px\">\n <mj-column>\n ${sectionLabel(\"TESTING\")}\n ${bodyLineHtml(`${escapeXml(`${copy.testingIntro} We run a full test ${FREQ_PHRASE[cad.testing]}.`)} ${cadenceHtml(copy)}`)}\n </mj-column>\n </mj-section>${checklistRowsSection(copy.testingChecklist, {\n background: testBg,\n lastPaddingBottom: SECTION_PAD,\n })}`\n : \"\";\n\n // ANALYTICS — the report's big user count + trend, plus the Google search-position line. Hidden\n // entirely when there's no traffic or search data (hasAnalytics false), so it never shows empty.\n const analytics = hasAnalytics\n ? analyticsSection({\n current: data.gaUsersCurrent,\n previous: data.gaUsersPrevious,\n periodDays: data.gaPeriodDays,\n background: analyticsBg,\n pad: SECTION_PAD,\n bodyLines: analyticsBodyLines,\n })\n : \"\";\n\n // RECENT IMPROVEMENTS — the toggled callouts, closed by the open-door invitation. Omitted\n // entirely when there are no improvements (the open-door rides along with this block).\n const improvementsSection = hasImpr\n ? `\n <mj-section background-color=\"${improvementsBg}\" padding-top=\"${SECTION_PAD}\" padding-bottom=\"${SECTION_PAD}\">\n <mj-column>\n ${sectionLabel(\"RECENT IMPROVEMENTS\")}\n ${improvementItems.map((item) => bodyLine(item)).join(\"\\n \")}\n ${bodyLine(copy.announceOpenDoor, \"16px\")}\n </mj-column>\n </mj-section>`\n : \"\";\n\n // First contact line (\"Just hit reply.\") renders as a red bold heading, matching the\n // \"Questions, concerns or requests?\" title above it; any following lines (e.g. \"We're\n // here to help in any way we can.\") render in muted grey.\n const contactRows = copy.contact\n .map((line, i) =>\n i === 0\n ? `\n <mj-text color=\"${RED}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"700\" line-height=\"30px\">${escapeXml(line)}</mj-text>`\n : `\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"300\" line-height=\"30px\">${escapeXml(line)}</mj-text>`,\n )\n .join(\"\");\n const footerAddressRows = copy.footerAddress\n .map(\n (line) => `\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">${escapeXml(line)}</mj-text>`,\n )\n .join(\"\");\n\n return `<mjml>\n <mj-head>\n <mj-attributes>\n <mj-text font-family=\"helvetica, sans-serif\" padding-left=\"5px\" padding-right=\"5px\" />\n <mj-section padding-left=\"11%\" padding-right=\"11%\"/>\n <mj-image padding=\"0px\" />\n </mj-attributes>\n <mj-preview>${escapeXml(previewText)}</mj-preview>\n ${headerStyleBlock(data)}\n </mj-head>\n <mj-body background-color=\"white\">\n <mj-section background-color=\"#F4F4F4\" padding-top=\"0px\" padding-bottom=\"0px\" padding-left=\"0px\" padding-right=\"0px\">\n <mj-column>${headerImageTag(data)}</mj-column>\n </mj-section>\n <mj-section background-color=\"${introBg}\" padding-top=\"${SECTION_PAD}\" padding-bottom=\"${SECTION_PAD}\">\n <mj-column>\n ${sectionLabel(copy.announceHeading)}\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\" padding-top=\"20px\">Prepared for ${escapeXml(data.siteName)}</mj-text>\n ${bodyLine(copy.announceBody)}\n </mj-column>\n </mj-section>\n ${maintenanceSection}\n ${testingSection}\n ${lighthouseScoresSection(data.lighthouse, { background: lighthouseBg, pad: SECTION_PAD })}\n ${analytics}\n ${improvementsSection}\n <mj-section background-color=\"${contactBg}\" padding-top=\"${SECTION_PAD}\" padding-bottom=\"${SECTION_PAD}\">\n <mj-column>\n <mj-text color=\"${RED}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"700\" padding-top=\"0px\" line-height=\"36px\">Questions, concerns or requests?</mj-text>\n ${contactRows}\n <mj-divider border-width=\"1px\" border-style=\"solid\" border-color=\"#CCCCCC\" padding=\"0\" />\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" padding-top=\"24px\" line-height=\"20px\" font-style=\"italic\">Copyright ${new Date().getUTCFullYear()} ${escapeXml(copy.footerOrg)}. All rights reserved.</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"700\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">Our mailing address is:</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">${escapeXml(copy.footerOrg)}</mj-text>\n ${footerAddressRows}\n </mj-column>\n </mj-section>\n </mj-body>\n</mjml>`;\n}\n","import mjml2html from \"mjml\";\nimport type { ReportData } from \"./types.js\";\nimport { buildMjml } from \"./maintenance-email/template.js\";\nimport { buildLaunchMjml } from \"./launch-email/template.js\";\nimport { buildAnnouncementMjml } from \"./announcement-email/template.js\";\n\nexport type RenderResult = {\n html: string;\n warnings: Array<{ line: number; message: string }>;\n};\n\nexport async function renderReportHtml(data: ReportData): Promise<RenderResult> {\n const mjml =\n data.reportType === \"Launch\"\n ? buildLaunchMjml(data)\n : data.reportType === \"Announcement\"\n ? buildAnnouncementMjml(data)\n : buildMjml(data);\n const out = await mjml2html(mjml, { validationLevel: \"strict\" });\n return { html: out.html, warnings: out.errors ?? [] };\n}\n","import type { ReportData } from \"../types.js\";\nimport { DEFAULT_COPY } from \"../copy.js\";\nimport {\n escapeXml,\n fmtDate,\n headerImageTag,\n headerStyleBlock,\n} from \"../maintenance-email/template.js\";\n\nconst RED = \"#C00\";\nconst GREY = \"#757575\";\n\n/** Purpose-built go-live email: header · LAUNCHED + date · message · what-we-set-up\n * · contact · footer. Reuses the M6a copy layer (contact/footer honor per-site\n * overrides). No maintenance checklist / Lighthouse / analytics. */\nexport function buildLaunchMjml(data: ReportData): string {\n const copy = data.copy ?? DEFAULT_COPY;\n const previewText = `${escapeXml(data.siteName)} is live`;\n // All copy — launchHeading/launchBody/launchSetupItems included — is escaped\n // (spec §3.3: all copy escaped). It keeps strict MJML from choking on a stray\n // `&`/`<` if the default copy ever gains one, matching contact/footer below.\n const setupRows = copy.launchSetupItems\n .map(\n (item) => `\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\" padding-top=\"4px\" padding-bottom=\"4px\">• ${escapeXml(item)}</mj-text>`,\n )\n .join(\"\");\n // First contact line (\"Just hit reply.\") renders as a red bold heading, matching the\n // \"Any questions, concerns or requests?\" title above it; following lines stay default.\n const contactRows = copy.contact\n .map((line, i) =>\n i === 0\n ? `\n <mj-text color=\"${RED}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"700\" line-height=\"30px\">${escapeXml(line)}</mj-text>`\n : `\n <mj-text font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"300\" line-height=\"30px\">${escapeXml(line)}</mj-text>`,\n )\n .join(\"\");\n const footerAddressRows = copy.footerAddress\n .map(\n (line) => `\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">${escapeXml(line)}</mj-text>`,\n )\n .join(\"\");\n\n return `<mjml>\n <mj-head>\n <mj-attributes>\n <mj-text font-family=\"helvetica, sans-serif\" padding-left=\"5px\" padding-right=\"5px\" />\n <mj-section padding-left=\"11%\" padding-right=\"11%\"/>\n <mj-image padding=\"0px\" />\n </mj-attributes>\n <mj-preview>${previewText}</mj-preview>\n ${headerStyleBlock(data)}\n </mj-head>\n <mj-body background-color=\"white\">\n <mj-section background-color=\"#F4F4F4\" padding-top=\"0px\" padding-bottom=\"0px\" padding-left=\"0px\" padding-right=\"0px\">\n <mj-column>${headerImageTag(data)}</mj-column>\n </mj-section>\n <mj-section background-color=\"white\">\n <mj-column>\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"75px\">${escapeXml(copy.launchHeading)}</mj-text>\n <mj-text color=\"${RED}\" font-size=\"44px\" font-weight=\"400\">${fmtDate(data.completedOn)}</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\" padding-top=\"20px\">${escapeXml(copy.launchBody)}</mj-text>\n ${setupRows}\n </mj-column>\n </mj-section>\n <mj-section background-color=\"white\">\n <mj-column padding-top=\"36px\">\n <mj-text color=\"${RED}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"700\" padding-top=\"36px\" line-height=\"36px\">Any questions, concerns or requests?</mj-text>\n ${contactRows}\n <mj-divider border-width=\"1px\" border-style=\"solid\" border-color=\"#CCCCCC\" padding=\"0\" />\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" padding-top=\"24px\" line-height=\"20px\" font-style=\"italic\">Copyright ${new Date().getUTCFullYear()} ${escapeXml(copy.footerOrg)}. All rights reserved.</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"700\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">Our mailing address is:</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">${escapeXml(copy.footerOrg)}</mj-text>\n ${footerAddressRows}\n </mj-column>\n </mj-section>\n </mj-body>\n</mjml>`;\n}\n","import type { ReportType } from \"./types.js\";\nimport type { EvidenceResult, EvidenceRecord } from \"./auto-tick.js\";\n\n/**\n * One operator checklist item: a stable `key`, its display `label` (which mirrors\n * the client email's checklist line — kept in sync by a test against DEFAULT_COPY),\n * and the exact Airtable checkbox column `field` it reads/writes.\n */\nexport type ChecklistItem = { key: string; label: string; field: string };\n\n/**\n * The 6 Maintenance-report items. `label`s mirror DEFAULT_COPY.maintenanceChecks\n * (same order — \"Google Indexed\" MUST stay at index 3; the email template special-cases\n * that row to inject the live search position).\n */\nexport const MAINTENANCE_CHECKLIST: ChecklistItem[] = [\n { key: \"deploy\", label: \"Deploy & Function Health\", field: \"Maint: Deploy & Function Health\" },\n { key: \"cms\", label: \"CMS Checked\", field: \"Maint: CMS Checked\" },\n { key: \"domain\", label: \"Domain, DNS & SSL\", field: \"Maint: Domain, DNS & SSL\" },\n { key: \"google\", label: \"Google Indexed\", field: \"Maint: Google Indexed\" },\n { key: \"security\", label: \"Security Updates\", field: \"Maint: Security Updates\" },\n { key: \"uptime\", label: \"Uptime Checked\", field: \"Maint: Uptime Checked\" },\n];\n\n/** The 7 Testing-report items. `label`s mirror DEFAULT_COPY.testingChecklist. */\nexport const TESTING_CHECKLIST: ChecklistItem[] = [\n { key: \"desktop\", label: \"Desktop Browsers\", field: \"Test: Desktop Browsers\" },\n { key: \"mobile\", label: \"Mobile Browsers\", field: \"Test: Mobile Browsers\" },\n { key: \"titles\", label: \"Page Titles & Meta\", field: \"Test: Page Titles & Meta\" },\n { key: \"links\", label: \"Links & Navigation\", field: \"Test: Links & Navigation\" },\n { key: \"forms\", label: \"Form Functionality\", field: \"Test: Form Functionality\" },\n {\n key: \"interactions\",\n label: \"Interactions & Animations\",\n field: \"Test: Interactions & Animations\",\n },\n // `field` keeps its original Airtable column name (\"…Verified After Updates\") even though\n // the client-facing label is now \"Tested After Updates\" — the column holds operator data,\n // so renaming the label is display-only and avoids a live-base column migration.\n { key: \"updates\", label: \"Tested After Updates\", field: \"Test: Verified After Updates\" },\n];\n\n/** All 13 Airtable checkbox column names. mapRow reads exactly these into the row's checklist. */\nexport const ALL_CHECKLIST_FIELDS: string[] = [...MAINTENANCE_CHECKLIST, ...TESTING_CHECKLIST].map(\n (i) => i.field,\n);\n\n/**\n * The checklist that gates a report of the given type, in the order the email renders it.\n * Maintenance gates on its 6 items. A Testing pass also performs the maintenance checks —\n * and the Testing email shows BOTH lists — so a Testing report gates on all 13\n * (maintenance first, then testing). Launch/Announcement have no checklist (the gate is\n * vacuously satisfied). PURE.\n */\nexport function checklistFor(type: ReportType): ChecklistItem[] {\n if (type === \"Maintenance\") return MAINTENANCE_CHECKLIST;\n if (type === \"Testing\") return [...MAINTENANCE_CHECKLIST, ...TESTING_CHECKLIST];\n return [];\n}\n\n/** The maintenance items whose HEALTH gates a Maintenance send. Google Indexed is advisory\n * (reported, never blocks) so it is excluded. */\nconst MAINTENANCE_GATING_FIELDS: string[] = [\n \"Maint: Deploy & Function Health\",\n \"Maint: CMS Checked\",\n \"Maint: Domain, DNS & SSL\",\n \"Maint: Security Updates\",\n \"Maint: Uptime Checked\",\n];\n\n/**\n * The checklist fields whose health gates a send of this report type. Maintenance gates\n * availability/integrity only (Google Indexed is advisory); Testing holds the full bar (all 13,\n * including Google Indexed); Launch/Announcement are ungated. PURE.\n */\nexport function gatingFields(type: ReportType): string[] {\n if (type === \"Maintenance\") return MAINTENANCE_GATING_FIELDS;\n if (type === \"Testing\") return checklistFor(\"Testing\").map((i) => i.field);\n return [];\n}\n\n/**\n * The health gate: clear iff EVERY gating field's evidence result is `pass` or `n/a`. A `fail`,\n * an `unknown`, or an ABSENT record all block — the semantic inversion (no fresh signal → cannot\n * confirm health → don't send). Launch/Announcement have no gating fields → vacuously clear. PURE.\n */\nexport function isHealthGateClear(report: {\n reportType: ReportType;\n autoEvidence: Record<string, EvidenceRecord>;\n}): boolean {\n return gatingFields(report.reportType).every((field) => {\n const r = report.autoEvidence[field]?.result;\n return r === \"pass\" || r === \"n/a\";\n });\n}\n\n/** Per-gating-field status for by-name blocker messaging (send log, dashboard). An absent record\n * surfaces as `unknown`. PURE. */\nexport function gatingHealth(report: {\n reportType: ReportType;\n autoEvidence: Record<string, EvidenceRecord>;\n}): { field: string; status: EvidenceResult }[] {\n return gatingFields(report.reportType).map((field) => ({\n field,\n status: report.autoEvidence[field]?.result ?? \"unknown\",\n }));\n}\n\n/**\n * True when a logged send-anyway override is active AND carries a non-empty reason. The effective\n * send gate is `isHealthGateClear(report) || isSendOverridden(report)`. PURE — takes the minimal\n * structural shape so it can be evaluated over a ReportRow or a synthetic \"about to override\" copy.\n */\nexport function isSendOverridden(report: {\n sendOverride: boolean;\n overrideReason: string | null;\n}): boolean {\n return report.sendOverride && (report.overrideReason ?? \"\").trim() !== \"\";\n}\n","import type { FieldSet, Records } from \"airtable\";\nimport type { AirtableBase } from \"./client.js\";\nimport type { ReportType, LighthouseScores } from \"../types.js\";\nimport { ALL_CHECKLIST_FIELDS } from \"../checklist.js\";\nimport type { EvidenceRecord } from \"../auto-tick.js\";\n\nexport const REPORTS_TABLE = \"Reports\";\n\nconst REPORT_TYPES: readonly ReportType[] = [\"Maintenance\", \"Testing\", \"Launch\", \"Announcement\"];\n\n/** Coerce the Airtable `Report type` (a single-select string) to a known\n * ReportType. A bare `as ReportType` cast is a compile-time lie: if the\n * single-select gains an unexpected option, the bad value flows to render.ts,\n * where `reportType === \"Launch\"` silently falls through to the Maintenance\n * template. Validate at the boundary; warn + default to \"Maintenance\" so an\n * unknown type is VISIBLE in the logs rather than silently mis-templated. */\nexport function toReportType(raw: string | undefined): ReportType {\n if (raw && (REPORT_TYPES as readonly string[]).includes(raw)) return raw as ReportType;\n if (raw)\n console.warn(`[reports] unknown Report type ${JSON.stringify(raw)} — treating as Maintenance`);\n return \"Maintenance\";\n}\n\nexport type DeliveryStatus = \"pending\" | \"delivered\" | \"bounced\" | \"complained\";\n\nexport type ReportRow = {\n id: string;\n reportId: string;\n siteId: string;\n reportType: ReportType;\n /** UTC `YYYY-MM` recurrence key (idempotency for search-before-create). Null on legacy rows. */\n period: string | null;\n periodStart: string | null;\n periodEnd: string | null;\n completedOn: string | null;\n lighthouse: LighthouseScores | null;\n gaUsersCurrent: number | null;\n gaUsersPrevious: number | null;\n searchFoundPage1: boolean | null;\n searchPosition: number | null;\n lastTestedDate: string | null;\n commentary: string | null;\n subjectOverride: string | null;\n draftReady: boolean;\n approvedToSend: boolean;\n sentAt: string | null;\n approvedAt: string | null;\n approvedBy: string | null;\n deliveryStatus: DeliveryStatus;\n renderedHtmlAttachment: { url: string; filename: string } | null;\n /** Read out of the Resend response and stored in a hidden field; needed for webhook reconciliation. */\n resendMessageId: string | null;\n /** The 12 operator-checklist checkboxes, keyed by their Airtable column name (ALL_CHECKLIST_FIELDS);\n * missing/false cells read false. Maintenance/Testing reports gate approve+send on the relevant\n * subset (see src/reports/checklist.ts). */\n checklist: Record<string, boolean>;\n /** Snapshot of the auto-tick evidence at draft time, keyed by checklist field → evidence\n * record. Null when the report predates auto-tick or carried no auto-checked items. This is the\n * health gate's source of truth: `isHealthGateClear`/`gatingHealth` (src/reports/checklist.ts)\n * read these evidence records, NOT the checklist booleans — and also drive the dashboard badges. */\n autoEvidence: Record<string, EvidenceRecord> | null;\n /** Logged send-anyway override (Phase 10). When `sendOverride` is true AND `overrideReason` is\n * non-empty, the health gate is bypassed for THIS report; `overrideBy`/`overrideAt` are the\n * audit trail (parallel to approvedBy/approvedAt). Missing cells read false/null. */\n sendOverride: boolean;\n overrideReason: string | null;\n overrideBy: string | null;\n overrideAt: string | null;\n};\n\n/**\n * The \"Ready for your yes\" gate: Draft ready ∧ ¬Approved to send ∧ Sent at BLANK.\n * The single source of truth for \"pending the operator's approval\" — `listPendingApproval`,\n * `runDigest`'s ready-list, the per-site dashboard, and the fleet cockpit all key off this\n * one predicate so the surfaces can't drift.\n */\nexport function isPendingApproval(r: ReportRow): boolean {\n return r.draftReady && !r.approvedToSend && r.sentAt === null;\n}\n\nfunction mapRow(rec: { id: string; fields: Record<string, unknown> }): ReportRow {\n const f = rec.fields;\n const linkSites = (f[\"Site\"] as string[] | undefined) ?? [];\n const html =\n ((f[\"Rendered HTML\"] as Array<{ url: string; filename: string }> | undefined) ?? [])[0] ?? null;\n return {\n id: rec.id,\n reportId: String(f[\"Report ID\"] ?? \"\"),\n siteId: linkSites[0] ?? \"\",\n reportType: toReportType(f[\"Report type\"] as string | undefined),\n period: (f[\"Period\"] as string | undefined) ?? null,\n periodStart: (f[\"Period start\"] as string | undefined) ?? null,\n periodEnd: (f[\"Period end\"] as string | undefined) ?? null,\n completedOn: (f[\"Completed on\"] as string | undefined) ?? null,\n lighthouse: lighthouseFromFields(f),\n gaUsersCurrent: (f[\"GA users (period)\"] as number | undefined) ?? null,\n gaUsersPrevious: (f[\"GA users (prev period)\"] as number | undefined) ?? null,\n searchFoundPage1:\n typeof f[\"Search found page 1\"] === \"boolean\" ? (f[\"Search found page 1\"] as boolean) : null,\n searchPosition: (f[\"Search position\"] as number | undefined) ?? null,\n lastTestedDate: (f[\"Last tested date\"] as string | undefined) ?? null,\n commentary: (f[\"Commentary\"] as string | undefined) ?? null,\n subjectOverride: (f[\"Subject override\"] as string | undefined) ?? null,\n draftReady: Boolean(f[\"Draft ready\"]),\n approvedToSend: Boolean(f[\"Approved to send\"]),\n sentAt: (f[\"Sent at\"] as string | undefined) ?? null,\n approvedAt: (f[\"Approved At\"] as string | undefined) ?? null,\n approvedBy: (f[\"Approved By\"] as string | undefined) ?? null,\n deliveryStatus: ((f[\"Delivery status\"] as string | undefined) ?? \"pending\") as DeliveryStatus,\n renderedHtmlAttachment: html,\n resendMessageId: (f[\"Resend message ID\"] as string | undefined) ?? null,\n checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])])),\n autoEvidence: parseAutoEvidence(f[\"Checklist auto-evidence\"]),\n sendOverride: Boolean(f[\"Send override\"]),\n overrideReason: (f[\"Override reason\"] as string | undefined) ?? null,\n overrideBy: (f[\"Override by\"] as string | undefined) ?? null,\n overrideAt: (f[\"Override at\"] as string | undefined) ?? null,\n };\n}\n\n/**\n * Parse the `Checklist auto-evidence` JSON field → a field→EvidenceRecord map, or null when\n * absent/malformed. Permissive on inner shape (it's display-only — a bad blob just yields null,\n * never throws), mirroring `parseNotifyRouting`.\n */\nexport function parseAutoEvidence(raw: unknown): Record<string, EvidenceRecord> | null {\n if (typeof raw !== \"string\" || !raw.trim()) return null;\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n return null;\n }\n if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) return null;\n // Validate each entry's inner shape (it drives display) — drop any record whose `result` isn't\n // one of the four literals, and coerce `note`/`checkedAt` to safe types. A garbage blob yields\n // null rather than a record that would render \"auto: undefined\" on the dashboard.\n const out: Record<string, EvidenceRecord> = {};\n for (const [field, v] of Object.entries(parsed as Record<string, unknown>)) {\n if (!v || typeof v !== \"object\") continue;\n const o = v as Record<string, unknown>;\n if (o.result !== \"pass\" && o.result !== \"fail\" && o.result !== \"unknown\" && o.result !== \"n/a\")\n continue;\n out[field] = {\n result: o.result,\n checkedAt: typeof o.checkedAt === \"string\" ? o.checkedAt : null,\n note: typeof o.note === \"string\" ? o.note : \"\",\n };\n }\n return Object.keys(out).length > 0 ? out : null;\n}\n\nfunction lighthouseFromFields(f: Record<string, unknown>): LighthouseScores | null {\n const p = f[\"Lighthouse — Performance\"];\n const a = f[\"Lighthouse — Accessibility\"];\n const b = f[\"Lighthouse — Best Practices\"];\n const s = f[\"Lighthouse — SEO\"];\n if (\n typeof p !== \"number\" ||\n typeof a !== \"number\" ||\n typeof b !== \"number\" ||\n typeof s !== \"number\"\n )\n return null;\n return { performance: p, accessibility: a, bestPractices: b, seo: s };\n}\n\n/** The GA + Search-presence fields a draft can carry, written to the Reports row.\n * `searchFoundPage1` is written whenever the check ran (true or false — false is the\n * operator-only negative signal); `searchPosition` only when found on page 1. Shared by\n * the create path (DraftInput) and the reuse path (updateReportScores). */\nexport type ReportEnrichment = {\n gaUsersCurrent?: number;\n gaUsersPrevious?: number;\n searchFoundPage1?: boolean;\n searchPosition?: number;\n};\n\nexport type DraftInput = {\n reportId: string;\n siteId: string;\n reportType: ReportType;\n /** UTC `YYYY-MM` recurrence key. Omitted on legacy callers; written only when supplied. */\n period?: string;\n periodStart: Date;\n periodEnd: Date;\n completedOn: Date;\n lighthouse: LighthouseScores;\n lastTestedDate: Date | null;\n /** GA \"Users\" for the period / previous period. Omitted when GA is not configured\n * for the site or the fetch failed — the operator fills the fields manually. */\n gaUsersCurrent?: number;\n gaUsersPrevious?: number;\n /** Search-presence result. `searchFoundPage1` is written whenever the check ran (true or\n * false — false is the operator-only negative signal). `searchPosition` only when found. */\n searchFoundPage1?: boolean;\n searchPosition?: number;\n subjectOverride?: string;\n /** Checklist fields to tick at create time (the auto-tick \"pass\" set). */\n checklistTicks?: string[];\n /** Auto-tick evidence snapshot to persist (keyed by checklist field). */\n autoEvidence?: Record<string, EvidenceRecord>;\n};\n\nfunction ymd(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * Escape a string for safe interpolation into an Airtable filterByFormula.\n * Airtable formulas use SQL-like string literals; we escape backslash and\n * double quote. Used wherever an externally-supplied string flows into a\n * formula (e.g. Resend message ids on the webhook path).\n */\nexport function escapeFormulaString(s: string): string {\n return s.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"');\n}\n\nexport async function createDraft(base: AirtableBase, input: DraftInput): Promise<ReportRow> {\n // Set Delivery status to \"pending\" at creation time, NOT at send time. This\n // matters for H4: if stampSent wrote \"pending\" after the webhook had already\n // written \"delivered\" (race), the operator would see a regressed status.\n const fields: FieldSet = {\n \"Report ID\": input.reportId,\n Site: [input.siteId],\n \"Report type\": input.reportType,\n \"Period start\": ymd(input.periodStart),\n \"Period end\": ymd(input.periodEnd),\n \"Completed on\": ymd(input.completedOn),\n \"Lighthouse — Performance\": input.lighthouse.performance,\n \"Lighthouse — Accessibility\": input.lighthouse.accessibility,\n \"Lighthouse — Best Practices\": input.lighthouse.bestPractices,\n \"Lighthouse — SEO\": input.lighthouse.seo,\n \"Delivery status\": \"pending\",\n };\n if (input.lastTestedDate) fields[\"Last tested date\"] = ymd(input.lastTestedDate);\n // GA fields are written only when supplied (GA configured + fetch succeeded). When\n // omitted the row keeps them blank for manual entry — the pre-GA behavior.\n if (input.gaUsersCurrent !== undefined) fields[\"GA users (period)\"] = input.gaUsersCurrent;\n if (input.gaUsersPrevious !== undefined) fields[\"GA users (prev period)\"] = input.gaUsersPrevious;\n if (input.searchFoundPage1 !== undefined) fields[\"Search found page 1\"] = input.searchFoundPage1;\n if (input.searchPosition !== undefined) fields[\"Search position\"] = input.searchPosition;\n if (input.period !== undefined) fields[\"Period\"] = input.period;\n if (input.subjectOverride !== undefined) fields[\"Subject override\"] = input.subjectOverride;\n // Auto-ticked checklist boxes + the evidence snapshot. The booleans are the same columns the\n // operator/dashboard toggle; the JSON is what the health gate reads (isHealthGateClear/gatingHealth)\n // and also drives the dashboard's green/amber badges.\n for (const field of input.checklistTicks ?? []) fields[field] = true;\n if (input.autoEvidence && Object.keys(input.autoEvidence).length > 0) {\n fields[\"Checklist auto-evidence\"] = JSON.stringify(input.autoEvidence);\n }\n const created = (await base(REPORTS_TABLE).create([{ fields }])) as Records<FieldSet>;\n const rec = created[0];\n if (!rec) throw new Error(\"Airtable create returned no records\");\n return mapRow({ id: rec.id, fields: rec.fields });\n}\n\nexport async function setDraftReady(\n base: AirtableBase,\n recordId: string,\n ready: boolean,\n): Promise<void> {\n await base(REPORTS_TABLE).update([{ id: recordId, fields: { \"Draft ready\": ready } }]);\n}\n\n/**\n * Overwrite the four `Lighthouse — *` score cells (and, when supplied, `Completed\n * on`) on an EXISTING Reports row. The launch re-run path uses this: it reuses the\n * already-created Launch row but must refresh its scores to match the freshly-run\n * audit — otherwise the re-rendered preview shows new scores while the row (and the\n * eventually-sent email, which reads the row) keeps the stale ones. The create path\n * already writes fresh scores via `createDraft`; this is its update-side mirror, using\n * the same exact field names so the two stay in lockstep.\n */\nexport async function updateReportScores(\n base: AirtableBase,\n recordId: string,\n scores: LighthouseScores,\n completedOn?: Date,\n enrichment?: ReportEnrichment,\n): Promise<void> {\n const fields: FieldSet = {\n \"Lighthouse — Performance\": scores.performance,\n \"Lighthouse — Accessibility\": scores.accessibility,\n \"Lighthouse — Best Practices\": scores.bestPractices,\n \"Lighthouse — SEO\": scores.seo,\n };\n if (completedOn) fields[\"Completed on\"] = ymd(completedOn);\n if (enrichment?.gaUsersCurrent !== undefined)\n fields[\"GA users (period)\"] = enrichment.gaUsersCurrent;\n if (enrichment?.gaUsersPrevious !== undefined)\n fields[\"GA users (prev period)\"] = enrichment.gaUsersPrevious;\n if (enrichment?.searchFoundPage1 !== undefined)\n fields[\"Search found page 1\"] = enrichment.searchFoundPage1;\n if (enrichment?.searchPosition !== undefined)\n fields[\"Search position\"] = enrichment.searchPosition;\n await base(REPORTS_TABLE).update([{ id: recordId, fields }]);\n}\n\nexport async function listSendableReports(base: AirtableBase): Promise<ReportRow[]> {\n const out: ReportRow[] = [];\n await base(REPORTS_TABLE)\n .select({\n filterByFormula:\n \"AND({Draft ready} = TRUE(), {Approved to send} = TRUE(), {Sent at} = BLANK())\",\n pageSize: 100,\n })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));\n fetchNextPage();\n });\n return out;\n}\n\n/**\n * Fetch every Reports row, unfiltered. Site-scoped callers filter the result in\n * memory: the `Site` linked-record field CANNOT be formula-filtered by record id\n * (see findReportByPeriod's doc for why), and the fleet's Reports table is small\n * enough that one paged fetch-all beats N broken-or-per-site queries.\n */\nexport async function listAllReports(base: AirtableBase): Promise<ReportRow[]> {\n const out: ReportRow[] = [];\n await base(REPORTS_TABLE)\n .select({ pageSize: 100 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));\n fetchNextPage();\n });\n return out;\n}\n\nexport async function listReportsForSite(base: AirtableBase, siteId: string): Promise<ReportRow[]> {\n // Client-side match on the mapped siteId (mapRow reads the record id from the\n // REST response, where it IS present) — record ids can't appear in formulas.\n return (await listAllReports(base)).filter((r) => r.siteId === siteId);\n}\n\n/**\n * Mark a row as sent: write `Sent at` and (when known) `Resend message ID`.\n * Crucially does NOT touch `Delivery status` — that's set to \"pending\" in\n * createDraft and updated by the webhook from there. If we wrote \"pending\" here\n * we could clobber a \"delivered\" that the webhook raced ahead and wrote first (H4).\n *\n * `messageId` may be `null`: the 409 idempotency-conflict path has no recoverable\n * id (the original send's id was lost when the prior run's stamp failed), so it\n * passes null and we write ONLY `Sent at`. Writing a sentinel string like\n * \"idempotent-conflict\" into the id column would masquerade as a real Resend id\n * and silently orphan `findReportByMessageId` lookups; leaving it null is honest —\n * the row still stops replaying (Sent at is set), delivery tracking for that one\n * report is simply degraded (the id is genuinely unknowable on that path).\n */\nexport async function stampSent(\n base: AirtableBase,\n recordId: string,\n sentAt: Date,\n messageId: string | null,\n): Promise<void> {\n const fields: Record<string, string> = { \"Sent at\": sentAt.toISOString() };\n if (messageId !== null) fields[\"Resend message ID\"] = messageId;\n await base(REPORTS_TABLE).update([\n {\n id: recordId,\n fields,\n },\n ]);\n}\n\nexport async function setDeliveryStatus(\n base: AirtableBase,\n recordId: string,\n status: DeliveryStatus,\n): Promise<void> {\n await base(REPORTS_TABLE).update([{ id: recordId, fields: { \"Delivery status\": status } }]);\n}\n\n/**\n * Stamp the approval on a Reports row: flips `Approved to send` TRUE and records\n * who/when for the audit trail. The caller (approveReport handler) is responsible\n * for idempotency — this is the raw write. Never touches `Sent at`.\n */\nexport async function approveReportRow(\n base: AirtableBase,\n recordId: string,\n approvedAt: Date,\n approvedBy: string,\n): Promise<void> {\n await base(REPORTS_TABLE).update([\n {\n id: recordId,\n fields: {\n \"Approved to send\": true,\n \"Approved At\": approvedAt.toISOString(),\n \"Approved By\": approvedBy,\n },\n },\n ]);\n}\n\n/**\n * Stamp a logged send-anyway override on a Reports row: sets `Send override` TRUE, the reason, and\n * who/when — AND flips `Approved to send` TRUE (with the same Approved At/By stamp) so the daily\n * cron delivers the overridden report. Mirrors {@link approveReportRow}; never touches `Sent at`.\n */\nexport async function overrideReportRow(\n base: AirtableBase,\n recordId: string,\n overrideAt: Date,\n overrideBy: string,\n reason: string,\n): Promise<void> {\n const at = overrideAt.toISOString();\n await base(REPORTS_TABLE).update([\n {\n id: recordId,\n fields: {\n \"Send override\": true,\n \"Override reason\": reason,\n \"Override by\": overrideBy,\n \"Override at\": at,\n \"Approved to send\": true,\n \"Approved At\": at,\n \"Approved By\": overrideBy,\n },\n },\n ]);\n}\n\n/**\n * True when an `.find` rejection is a GENUINE not-found, not a transient failure.\n * The Airtable SDK stamps `.statusCode` (404) and/or `.error` (\"NOT_FOUND\") on\n * its errors. Anything else (429 rate-limit, 500 outage, bad-PAT 401, network\n * error) must NOT be masked as a 404 — see getReportById.\n */\nfunction isNotFoundError(err: unknown): boolean {\n if (typeof err !== \"object\" || err === null) return false;\n const e = err as { statusCode?: unknown; error?: unknown; name?: unknown; message?: unknown };\n if (e.statusCode === 404) return true;\n const tag = String(e.error ?? e.name ?? e.message ?? \"\");\n return tag === \"NOT_FOUND\" || /not found/i.test(tag);\n}\n\n/**\n * Fetch one Reports row by its Airtable record id, or null if it doesn't exist.\n * Only a GENUINE not-found (404 / NOT_FOUND) collapses to null; every other\n * failure (outage, 429, bad PAT, network error) is rethrown so the adapter\n * surfaces a 500 instead of a misleading 404. Swallowing all throws previously\n * turned an Airtable outage into a \"no such report\".\n */\nexport async function getReportById(\n base: AirtableBase,\n recordId: string,\n): Promise<ReportRow | null> {\n try {\n const rec = await base(REPORTS_TABLE).find(recordId);\n return mapRow({ id: rec.id, fields: rec.fields as Record<string, unknown> });\n } catch (err) {\n if (isNotFoundError(err)) return null;\n throw err;\n }\n}\n\nexport async function findReportByMessageId(\n base: AirtableBase,\n messageId: string,\n): Promise<ReportRow | null> {\n const rows: ReportRow[] = [];\n await base(REPORTS_TABLE)\n .select({\n filterByFormula: `{Resend message ID} = \"${escapeFormulaString(messageId)}\"`,\n maxRecords: 1,\n })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push(mapRow({ id: rec.id, fields: rec.fields }));\n fetchNextPage();\n });\n return rows[0] ?? null;\n}\n\n/**\n * Find the Reports row for a `(site, reportType, period)` triple, or null. The\n * idempotency lookup behind search-before-create drafting.\n *\n * The site is matched CLIENT-side, never in the formula: Airtable's formula layer\n * renders linked-record fields ({Site}) as the linked rows' PRIMARY-FIELD NAMES,\n * not record ids, so any formula comparing {Site} or ARRAYJOIN({Site}) against a\n * `recXXX` id matches NOTHING (live-proven against the real base — do not\n * reintroduce that idiom). Record ids exist only in the REST response, where\n * mapRow reads them. So the formula filters on the real scalar fields (Report\n * type + Period — escaped, keeping it injection-safe if their source ever\n * changes), and the first mapped row whose siteId matches wins. The candidate\n * set is at most one row per site for the (type, period), so this stays small.\n */\nexport async function findReportByPeriod(\n base: AirtableBase,\n siteId: string,\n reportType: ReportType,\n period: string,\n): Promise<ReportRow | null> {\n const safeType = escapeFormulaString(reportType);\n const safePeriod = escapeFormulaString(period);\n const formula = `AND({Report type} = \"${safeType}\", {Period} = \"${safePeriod}\")`;\n const rows: ReportRow[] = [];\n await base(REPORTS_TABLE)\n .select({ filterByFormula: formula, pageSize: 100 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push(mapRow({ id: rec.id, fields: rec.fields }));\n fetchNextPage();\n });\n return rows.find((r) => r.siteId === siteId) ?? null;\n}\n"],"mappings":"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","names":["escapeXml","RED","GREY","escapeXml","RED","GREY","escapeXml"]}
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from "./chunk-GASBX52O.js";
|
|
13
13
|
import {
|
|
14
14
|
makeGitHub
|
|
15
|
-
} from "./chunk-
|
|
15
|
+
} from "./chunk-YAMRVXOD.js";
|
|
16
16
|
import {
|
|
17
17
|
siteLabel
|
|
18
18
|
} from "./chunk-XXTZBPUY.js";
|
|
@@ -175,4 +175,4 @@ async function selfUpdating(site, deps = {}) {
|
|
|
175
175
|
export {
|
|
176
176
|
selfUpdating
|
|
177
177
|
};
|
|
178
|
-
//# sourceMappingURL=chunk-
|
|
178
|
+
//# sourceMappingURL=chunk-7ADQL5Y3.js.map
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
fetchGaUsers,
|
|
3
3
|
fetchSearch
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-6M2IXJ3E.js";
|
|
5
5
|
import {
|
|
6
6
|
announcementSiteExtras,
|
|
7
7
|
resolveCopy
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-6QRLRT3P.js";
|
|
9
9
|
import {
|
|
10
10
|
siteSlug
|
|
11
11
|
} from "./chunk-GXVX4HUH.js";
|
|
@@ -60,4 +60,4 @@ export {
|
|
|
60
60
|
scoresFromRow,
|
|
61
61
|
buildReportDataForSite
|
|
62
62
|
};
|
|
63
|
-
//# sourceMappingURL=chunk-
|
|
63
|
+
//# sourceMappingURL=chunk-AKRUYPLU.js.map
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
} from "./chunk-GASBX52O.js";
|
|
4
4
|
import {
|
|
5
5
|
assertUrlSegment
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-YAMRVXOD.js";
|
|
7
7
|
import {
|
|
8
8
|
baselineVersions
|
|
9
9
|
} from "./chunk-LKGVSM2O.js";
|
|
@@ -18,11 +18,11 @@ import {
|
|
|
18
18
|
} from "./chunk-XXTZBPUY.js";
|
|
19
19
|
import {
|
|
20
20
|
lighthouseConfig
|
|
21
|
-
} from "./chunk-
|
|
21
|
+
} from "./chunk-NMAWXBJA.js";
|
|
22
22
|
import {
|
|
23
23
|
a11yRoutes,
|
|
24
24
|
smokeRoutes
|
|
25
|
-
} from "./chunk-
|
|
25
|
+
} from "./chunk-N4MOEF32.js";
|
|
26
26
|
|
|
27
27
|
// src/audits/deps.ts
|
|
28
28
|
import { readFile } from "fs/promises";
|
|
@@ -2260,4 +2260,4 @@ export {
|
|
|
2260
2260
|
runAudits,
|
|
2261
2261
|
runAuditsAcross
|
|
2262
2262
|
};
|
|
2263
|
-
//# sourceMappingURL=chunk-
|
|
2263
|
+
//# sourceMappingURL=chunk-JMAFXEQE.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
approveBlockers
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-R5KAXGSP.js";
|
|
4
4
|
import {
|
|
5
5
|
siteSlug
|
|
6
6
|
} from "./chunk-GXVX4HUH.js";
|
|
@@ -297,4 +297,4 @@ export {
|
|
|
297
297
|
readDigestState,
|
|
298
298
|
writeDigestState
|
|
299
299
|
};
|
|
300
|
-
//# sourceMappingURL=chunk-
|
|
300
|
+
//# sourceMappingURL=chunk-KKIKJWM2.js.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// src/configs/playwright-a11y.ts
|
|
2
|
+
import { defineConfig, devices } from "@playwright/test";
|
|
3
|
+
var a11yRoutes = [
|
|
4
|
+
{ path: "/dev/a11y-fixtures", name: "a11y fixtures" },
|
|
5
|
+
{ path: "/dev/animate-in", name: "animate-in demo" }
|
|
6
|
+
];
|
|
7
|
+
var smokeRoutes = [{ path: "/", name: "home" }];
|
|
8
|
+
var smokePort = process.env.REDDOOR_SMOKE_PORT;
|
|
9
|
+
var port = smokePort || "5173";
|
|
10
|
+
var playwrightA11yConfig = defineConfig({
|
|
11
|
+
testDir: "tests",
|
|
12
|
+
testMatch: /.*\.spec\.ts$/,
|
|
13
|
+
fullyParallel: true,
|
|
14
|
+
forbidOnly: !!process.env.CI,
|
|
15
|
+
retries: process.env.CI ? 2 : 0,
|
|
16
|
+
reporter: process.env.CI ? "github" : "list",
|
|
17
|
+
use: {
|
|
18
|
+
baseURL: `http://localhost:${port}`,
|
|
19
|
+
trace: "on-first-retry"
|
|
20
|
+
},
|
|
21
|
+
projects: [
|
|
22
|
+
{
|
|
23
|
+
name: "chromium",
|
|
24
|
+
use: { ...devices["Desktop Chrome"] }
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
webServer: {
|
|
28
|
+
// Portable across pnpm and npm sites — pnpm respects `npm run` too.
|
|
29
|
+
//
|
|
30
|
+
// `--port ... --strictPort` in BOTH cases. It used to be applied only when
|
|
31
|
+
// REDDOOR_SMOKE_PORT allocated one, on the reasoning that we should "fail
|
|
32
|
+
// loudly rather than let vite drift to a free port the baseURL doesn't
|
|
33
|
+
// point at" — but that argument covers the unset case just as well. 5173 is
|
|
34
|
+
// equally a fixed port that `baseURL` and the readiness probe below are
|
|
35
|
+
// pinned to, and vite left to itself drifts off it whenever something else
|
|
36
|
+
// holds it.
|
|
37
|
+
//
|
|
38
|
+
// The symptom that exposed this: a non-vite process on 5173 sends vite to
|
|
39
|
+
// 5174 while the probe keeps polling 5173, so the run dies on
|
|
40
|
+
// "Timed out waiting 120000ms from config.webServer" — 120 seconds of
|
|
41
|
+
// nothing, naming neither the port nor the squatter. With --strictPort it
|
|
42
|
+
// is an immediate "Port 5173 is already in use".
|
|
43
|
+
//
|
|
44
|
+
// This does NOT overlap with `reuseExistingServer`: that check runs first,
|
|
45
|
+
// so a dev server already serving the probe URL is still reused and the
|
|
46
|
+
// command never executes. --strictPort only bites when 5173 is held by
|
|
47
|
+
// something that is not the server under test, which is exactly the case
|
|
48
|
+
// worth failing on.
|
|
49
|
+
command: `npm run vite:dev -- --port ${port} --strictPort`,
|
|
50
|
+
url: `http://localhost:${port}/dev/a11y-fixtures`,
|
|
51
|
+
reuseExistingServer: !process.env.CI,
|
|
52
|
+
timeout: 12e4
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
var playwright_a11y_default = playwrightA11yConfig;
|
|
56
|
+
|
|
57
|
+
export {
|
|
58
|
+
a11yRoutes,
|
|
59
|
+
smokeRoutes,
|
|
60
|
+
playwright_a11y_default
|
|
61
|
+
};
|
|
62
|
+
//# sourceMappingURL=chunk-N4MOEF32.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/configs/playwright-a11y.ts"],"sourcesContent":["import { defineConfig, devices, type PlaywrightTestConfig } from \"@playwright/test\";\n\nexport type A11yRoute = { path: string; name: string };\n\nexport const a11yRoutes: A11yRoute[] = [\n { path: \"/dev/a11y-fixtures\", name: \"a11y fixtures\" },\n { path: \"/dev/animate-in\", name: \"animate-in demo\" },\n];\n\n// Routes smoke-loaded for client-side (hydration) errors only — NOT axe-scanned.\n// Catches the class of bug where build + SSR succeed but client hydration throws\n// and blanks the page (data-dynamiq 2026-06-09: a Svelte 4->5 `run()` referenced\n// a `$state` declared after it → TDZ ReferenceError on hydrate). `/` is the one\n// route every site has; real routes carry a11y debt we don't gate on here, so we\n// assert only that they don't crash on hydrate.\nexport const smokeRoutes: A11yRoute[] = [{ path: \"/\", name: \"home\" }];\n\n// R1.1 (health-gate): the central `smoke` audit (src/audits/smoke.ts) allocates\n// a free port and passes it as REDDOOR_SMOKE_PORT so a zombie vite already\n// squatting the default 5173 can't silently hijack the run and green a stale\n// build. The per-site R1.1 config template honors it, but sites whose\n// playwright.config.ts merely re-exports this shared base (pre-R1.1 adopters\n// the smoke-suite recipe flags-but-never-rewrites) would otherwise ignore it —\n// so honor it here too and every re-exporter inherits the port binding on its\n// next package bump. Unset (local `pnpm test:smoke`) → the fixed 5173.\nconst smokePort = process.env.REDDOOR_SMOKE_PORT;\nconst port = smokePort || \"5173\";\n\n// NOTE: default export only — sites consume this as `import base from\n// \"@reddoorla/maintenance/configs/playwright-a11y\"` (or re-export the default).\n// The old `playwrightA11yConfig` named alias had zero importers and was removed.\nconst playwrightA11yConfig: PlaywrightTestConfig = defineConfig({\n testDir: \"tests\",\n testMatch: /.*\\.spec\\.ts$/,\n fullyParallel: true,\n forbidOnly: !!process.env.CI,\n retries: process.env.CI ? 2 : 0,\n reporter: process.env.CI ? \"github\" : \"list\",\n use: {\n baseURL: `http://localhost:${port}`,\n trace: \"on-first-retry\",\n },\n projects: [\n {\n name: \"chromium\",\n use: { ...devices[\"Desktop Chrome\"] },\n },\n ],\n webServer: {\n // Portable across pnpm and npm sites — pnpm respects `npm run` too.\n // `--strictPort` only when
|
|
1
|
+
{"version":3,"sources":["../src/configs/playwright-a11y.ts"],"sourcesContent":["import { defineConfig, devices, type PlaywrightTestConfig } from \"@playwright/test\";\n\nexport type A11yRoute = { path: string; name: string };\n\nexport const a11yRoutes: A11yRoute[] = [\n { path: \"/dev/a11y-fixtures\", name: \"a11y fixtures\" },\n { path: \"/dev/animate-in\", name: \"animate-in demo\" },\n];\n\n// Routes smoke-loaded for client-side (hydration) errors only — NOT axe-scanned.\n// Catches the class of bug where build + SSR succeed but client hydration throws\n// and blanks the page (data-dynamiq 2026-06-09: a Svelte 4->5 `run()` referenced\n// a `$state` declared after it → TDZ ReferenceError on hydrate). `/` is the one\n// route every site has; real routes carry a11y debt we don't gate on here, so we\n// assert only that they don't crash on hydrate.\nexport const smokeRoutes: A11yRoute[] = [{ path: \"/\", name: \"home\" }];\n\n// R1.1 (health-gate): the central `smoke` audit (src/audits/smoke.ts) allocates\n// a free port and passes it as REDDOOR_SMOKE_PORT so a zombie vite already\n// squatting the default 5173 can't silently hijack the run and green a stale\n// build. The per-site R1.1 config template honors it, but sites whose\n// playwright.config.ts merely re-exports this shared base (pre-R1.1 adopters\n// the smoke-suite recipe flags-but-never-rewrites) would otherwise ignore it —\n// so honor it here too and every re-exporter inherits the port binding on its\n// next package bump. Unset (local `pnpm test:smoke`) → the fixed 5173.\nconst smokePort = process.env.REDDOOR_SMOKE_PORT;\nconst port = smokePort || \"5173\";\n\n// NOTE: default export only — sites consume this as `import base from\n// \"@reddoorla/maintenance/configs/playwright-a11y\"` (or re-export the default).\n// The old `playwrightA11yConfig` named alias had zero importers and was removed.\nconst playwrightA11yConfig: PlaywrightTestConfig = defineConfig({\n testDir: \"tests\",\n testMatch: /.*\\.spec\\.ts$/,\n fullyParallel: true,\n forbidOnly: !!process.env.CI,\n retries: process.env.CI ? 2 : 0,\n reporter: process.env.CI ? \"github\" : \"list\",\n use: {\n baseURL: `http://localhost:${port}`,\n trace: \"on-first-retry\",\n },\n projects: [\n {\n name: \"chromium\",\n use: { ...devices[\"Desktop Chrome\"] },\n },\n ],\n webServer: {\n // Portable across pnpm and npm sites — pnpm respects `npm run` too.\n //\n // `--port ... --strictPort` in BOTH cases. It used to be applied only when\n // REDDOOR_SMOKE_PORT allocated one, on the reasoning that we should \"fail\n // loudly rather than let vite drift to a free port the baseURL doesn't\n // point at\" — but that argument covers the unset case just as well. 5173 is\n // equally a fixed port that `baseURL` and the readiness probe below are\n // pinned to, and vite left to itself drifts off it whenever something else\n // holds it.\n //\n // The symptom that exposed this: a non-vite process on 5173 sends vite to\n // 5174 while the probe keeps polling 5173, so the run dies on\n // \"Timed out waiting 120000ms from config.webServer\" — 120 seconds of\n // nothing, naming neither the port nor the squatter. With --strictPort it\n // is an immediate \"Port 5173 is already in use\".\n //\n // This does NOT overlap with `reuseExistingServer`: that check runs first,\n // so a dev server already serving the probe URL is still reused and the\n // command never executes. --strictPort only bites when 5173 is held by\n // something that is not the server under test, which is exactly the case\n // worth failing on.\n command: `npm run vite:dev -- --port ${port} --strictPort`,\n url: `http://localhost:${port}/dev/a11y-fixtures`,\n reuseExistingServer: !process.env.CI,\n timeout: 120_000,\n },\n});\n\nexport default playwrightA11yConfig;\n"],"mappings":";AAAA,SAAS,cAAc,eAA0C;AAI1D,IAAM,aAA0B;AAAA,EACrC,EAAE,MAAM,sBAAsB,MAAM,gBAAgB;AAAA,EACpD,EAAE,MAAM,mBAAmB,MAAM,kBAAkB;AACrD;AAQO,IAAM,cAA2B,CAAC,EAAE,MAAM,KAAK,MAAM,OAAO,CAAC;AAUpE,IAAM,YAAY,QAAQ,IAAI;AAC9B,IAAM,OAAO,aAAa;AAK1B,IAAM,uBAA6C,aAAa;AAAA,EAC9D,SAAS;AAAA,EACT,WAAW;AAAA,EACX,eAAe;AAAA,EACf,YAAY,CAAC,CAAC,QAAQ,IAAI;AAAA,EAC1B,SAAS,QAAQ,IAAI,KAAK,IAAI;AAAA,EAC9B,UAAU,QAAQ,IAAI,KAAK,WAAW;AAAA,EACtC,KAAK;AAAA,IACH,SAAS,oBAAoB,IAAI;AAAA,IACjC,OAAO;AAAA,EACT;AAAA,EACA,UAAU;AAAA,IACR;AAAA,MACE,MAAM;AAAA,MACN,KAAK,EAAE,GAAG,QAAQ,gBAAgB,EAAE;AAAA,IACtC;AAAA,EACF;AAAA,EACA,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAsBT,SAAS,8BAA8B,IAAI;AAAA,IAC3C,KAAK,oBAAoB,IAAI;AAAA,IAC7B,qBAAqB,CAAC,QAAQ,IAAI;AAAA,IAClC,SAAS;AAAA,EACX;AACF,CAAC;AAED,IAAO,0BAAQ;","names":[]}
|