@reddoorla/maintenance 0.61.0 → 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/dist/airtable-HHAWHBNM.js +9 -0
  2. package/dist/{announce-Q2DXP5OO.js → announce-54EBITHX.js} +16 -6
  3. package/dist/announce-54EBITHX.js.map +1 -0
  4. package/dist/{bump-deps-RYHQIKW4.js → bump-deps-2U6GIHM5.js} +2 -2
  5. package/dist/{chunk-BWLUNY7L.js → chunk-2T3HZ3DB.js} +24 -14
  6. package/dist/chunk-2T3HZ3DB.js.map +1 -0
  7. package/dist/{chunk-D3LFQNJK.js → chunk-3HM4S422.js} +2 -2
  8. package/dist/{chunk-LMXIZITU.js → chunk-4REUHZAW.js} +40 -4
  9. package/dist/chunk-4REUHZAW.js.map +1 -0
  10. package/dist/chunk-5L3FPQIJ.js +92 -0
  11. package/dist/chunk-5L3FPQIJ.js.map +1 -0
  12. package/dist/{client-JA6QLEDW.js → chunk-6ZKM7OIJ.js} +5 -4
  13. package/dist/{chunk-KC4HU7BC.js → chunk-76VIUWR3.js} +31 -6
  14. package/dist/chunk-76VIUWR3.js.map +1 -0
  15. package/dist/{chunk-L4ULXHIN.js → chunk-BLD6AYJO.js} +15 -1
  16. package/dist/chunk-BLD6AYJO.js.map +1 -0
  17. package/dist/{chunk-VDBENQUA.js → chunk-BMDBOPN3.js} +2 -2
  18. package/dist/{chunk-HB7QPECR.js → chunk-KTPPKO2C.js} +6 -6
  19. package/dist/chunk-L5F664ZA.js +45 -0
  20. package/dist/chunk-L5F664ZA.js.map +1 -0
  21. package/dist/{chunk-XB6T4ETK.js → chunk-OSRFDGQM.js} +21 -1
  22. package/dist/chunk-OSRFDGQM.js.map +1 -0
  23. package/dist/{chunk-6CSCLBAA.js → chunk-SAOCVNX7.js} +2 -2
  24. package/dist/{chunk-5PHCZ6L5.js → chunk-SS7BKOIX.js} +89 -2
  25. package/dist/chunk-SS7BKOIX.js.map +1 -0
  26. package/dist/{chunk-TZN2G6FD.js → chunk-TYRCYHRA.js} +3 -3
  27. package/dist/{chunk-HG5DTFNF.js → chunk-XKKTK6WA.js} +17 -1
  28. package/dist/chunk-XKKTK6WA.js.map +1 -0
  29. package/dist/{chunk-JNXCDUYB.js → chunk-XVR7UM6Z.js} +4 -4
  30. package/dist/{chunk-HPSGCDWY.js → chunk-YYUY2OVP.js} +2 -2
  31. package/dist/{chunk-TLZ4V2BE.js → chunk-ZEX2T6JH.js} +3 -2
  32. package/dist/{chunk-TLZ4V2BE.js.map → chunk-ZEX2T6JH.js.map} +1 -1
  33. package/dist/cli/bin.js +16 -15
  34. package/dist/cli/bin.js.map +1 -1
  35. package/dist/cli/commands/audit.d.ts +3 -2
  36. package/dist/cli/commands/audit.js +29 -8
  37. package/dist/cli/commands/audit.js.map +1 -1
  38. package/dist/client-GTMJTXBK.js +11 -0
  39. package/dist/{convert-to-pnpm-KNCXU6FM.js → convert-to-pnpm-JG3767ZQ.js} +2 -2
  40. package/dist/{db-AXKMRTVA.js → db-Y6BVTNH7.js} +3 -3
  41. package/dist/{digest-WAS3EJNC.js → digest-ABJROO4H.js} +3 -3
  42. package/dist/{github-signals-UL2BBS3Q.js → github-signals-XTJLKOXJ.js} +26 -5
  43. package/dist/github-signals-XTJLKOXJ.js.map +1 -0
  44. package/dist/index.d.ts +60 -3
  45. package/dist/index.js +86 -21
  46. package/dist/index.js.map +1 -1
  47. package/dist/{init-KD56UENX.js → init-DMAIKUQE.js} +8 -8
  48. package/dist/{launch-UWCI3JRM.js → launch-UZPPB4IE.js} +10 -9
  49. package/dist/{launch-UWCI3JRM.js.map → launch-UZPPB4IE.js.map} +1 -1
  50. package/dist/{lighthouse-airtable-N3KCBPVA.js → lighthouse-airtable-HTEW6BBT.js} +3 -3
  51. package/dist/migrate-BFOG2OCP.js +7 -0
  52. package/dist/{onboard-SCZ3UKZ2.js → onboard-TTIC3YZI.js} +2 -2
  53. package/dist/{orchestrate-D72PB6NG.js → orchestrate-5GGOL5EX.js} +7 -4
  54. package/dist/recipes/sync-configs.d.ts +1 -1
  55. package/dist/recipes/sync-configs.js +2 -2
  56. package/dist/{renovate-dispatch-NG5KUQKY.js → renovate-dispatch-QDDMTATD.js} +3 -3
  57. package/dist/{report-EPHXV2T2.js → report-SZGZIDDG.js} +6 -6
  58. package/dist/{self-updating-CFHXJSAX.js → self-updating-2RLUP4AA.js} +4 -4
  59. package/dist/{svelte-codemods-HNITLWQM.js → svelte-codemods-BPLKSVIA.js} +2 -2
  60. package/dist/{sync-configs-BZXBYKTI.js → sync-configs-VFRV3TJ4.js} +4 -4
  61. package/dist/{types-QG-QhCYh.d.ts → types-Dg4mHvyf.d.ts} +6 -1
  62. package/dist/{upgrade-6YDUELFA.js → upgrade-WCFD2TRN.js} +2 -2
  63. package/dist/{websites-IYP7IGLU.js → websites-HM2NMZMR.js} +2 -2
  64. package/dist/{write-audits-to-airtable-VDGHCZGQ.js → write-audits-to-airtable-QF26PCAR.js} +5 -4
  65. package/dist/write-audits-to-airtable-QF26PCAR.js.map +1 -0
  66. package/package.json +1 -1
  67. package/dist/airtable-YZ6VMSQZ.js +0 -9
  68. package/dist/announce-Q2DXP5OO.js.map +0 -1
  69. package/dist/chunk-5PHCZ6L5.js.map +0 -1
  70. package/dist/chunk-BWLUNY7L.js.map +0 -1
  71. package/dist/chunk-HG5DTFNF.js.map +0 -1
  72. package/dist/chunk-KC4HU7BC.js.map +0 -1
  73. package/dist/chunk-L4ULXHIN.js.map +0 -1
  74. package/dist/chunk-LMXIZITU.js.map +0 -1
  75. package/dist/chunk-XB6T4ETK.js.map +0 -1
  76. package/dist/github-signals-UL2BBS3Q.js.map +0 -1
  77. package/dist/migrate-P2LS4WXI.js +0 -7
  78. /package/dist/{airtable-YZ6VMSQZ.js.map → airtable-HHAWHBNM.js.map} +0 -0
  79. /package/dist/{bump-deps-RYHQIKW4.js.map → bump-deps-2U6GIHM5.js.map} +0 -0
  80. /package/dist/{chunk-D3LFQNJK.js.map → chunk-3HM4S422.js.map} +0 -0
  81. /package/dist/{client-JA6QLEDW.js.map → chunk-6ZKM7OIJ.js.map} +0 -0
  82. /package/dist/{chunk-VDBENQUA.js.map → chunk-BMDBOPN3.js.map} +0 -0
  83. /package/dist/{chunk-HB7QPECR.js.map → chunk-KTPPKO2C.js.map} +0 -0
  84. /package/dist/{chunk-6CSCLBAA.js.map → chunk-SAOCVNX7.js.map} +0 -0
  85. /package/dist/{chunk-TZN2G6FD.js.map → chunk-TYRCYHRA.js.map} +0 -0
  86. /package/dist/{chunk-JNXCDUYB.js.map → chunk-XVR7UM6Z.js.map} +0 -0
  87. /package/dist/{chunk-HPSGCDWY.js.map → chunk-YYUY2OVP.js.map} +0 -0
  88. /package/dist/{lighthouse-airtable-N3KCBPVA.js.map → client-GTMJTXBK.js.map} +0 -0
  89. /package/dist/{convert-to-pnpm-KNCXU6FM.js.map → convert-to-pnpm-JG3767ZQ.js.map} +0 -0
  90. /package/dist/{db-AXKMRTVA.js.map → db-Y6BVTNH7.js.map} +0 -0
  91. /package/dist/{digest-WAS3EJNC.js.map → digest-ABJROO4H.js.map} +0 -0
  92. /package/dist/{init-KD56UENX.js.map → init-DMAIKUQE.js.map} +0 -0
  93. /package/dist/{migrate-P2LS4WXI.js.map → lighthouse-airtable-HTEW6BBT.js.map} +0 -0
  94. /package/dist/{orchestrate-D72PB6NG.js.map → migrate-BFOG2OCP.js.map} +0 -0
  95. /package/dist/{onboard-SCZ3UKZ2.js.map → onboard-TTIC3YZI.js.map} +0 -0
  96. /package/dist/{websites-IYP7IGLU.js.map → orchestrate-5GGOL5EX.js.map} +0 -0
  97. /package/dist/{renovate-dispatch-NG5KUQKY.js.map → renovate-dispatch-QDDMTATD.js.map} +0 -0
  98. /package/dist/{report-EPHXV2T2.js.map → report-SZGZIDDG.js.map} +0 -0
  99. /package/dist/{self-updating-CFHXJSAX.js.map → self-updating-2RLUP4AA.js.map} +0 -0
  100. /package/dist/{svelte-codemods-HNITLWQM.js.map → svelte-codemods-BPLKSVIA.js.map} +0 -0
  101. /package/dist/{sync-configs-BZXBYKTI.js.map → sync-configs-VFRV3TJ4.js.map} +0 -0
  102. /package/dist/{upgrade-6YDUELFA.js.map → upgrade-WCFD2TRN.js.map} +0 -0
  103. /package/dist/{write-audits-to-airtable-VDGHCZGQ.js.map → websites-HM2NMZMR.js.map} +0 -0
@@ -0,0 +1,9 @@
1
+ import {
2
+ fromAirtableBase
3
+ } from "./chunk-ZEX2T6JH.js";
4
+ import "./chunk-U5SZ4FOP.js";
5
+ import "./chunk-BLD6AYJO.js";
6
+ export {
7
+ fromAirtableBase
8
+ };
9
+ //# sourceMappingURL=airtable-HHAWHBNM.js.map
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  fetchGaUsers,
3
3
  fetchSearch
4
- } from "./chunk-TZN2G6FD.js";
4
+ } from "./chunk-TYRCYHRA.js";
5
5
  import {
6
6
  queueDraft
7
7
  } from "./chunk-6LIWDIXU.js";
@@ -10,7 +10,7 @@ import {
10
10
  renderReportHtml,
11
11
  resolveCopy,
12
12
  uploadAttachment
13
- } from "./chunk-BWLUNY7L.js";
13
+ } from "./chunk-2T3HZ3DB.js";
14
14
  import {
15
15
  createDraft,
16
16
  findReportByPeriod,
@@ -20,7 +20,7 @@ import "./chunk-U5SZ4FOP.js";
20
20
  import {
21
21
  listWebsites,
22
22
  siteSlug
23
- } from "./chunk-L4ULXHIN.js";
23
+ } from "./chunk-BLD6AYJO.js";
24
24
  import {
25
25
  openBase,
26
26
  readAirtableConfig
@@ -28,6 +28,7 @@ import {
28
28
  import "./chunk-6VAL7XJT.js";
29
29
 
30
30
  // src/recipes/announce.ts
31
+ var GA_WINDOW_DAYS = 30;
31
32
  async function announce(deps) {
32
33
  const base = deps?.base ?? openBase(readAirtableConfig());
33
34
  const now = deps?.now ?? /* @__PURE__ */ new Date();
@@ -47,7 +48,7 @@ async function announce(deps) {
47
48
  continue;
48
49
  }
49
50
  const periodEnd = now;
50
- const periodStart = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1e3);
51
+ const periodStart = new Date(now.getTime() - GA_WINDOW_DAYS * 24 * 60 * 60 * 1e3);
51
52
  const gaUsers = (await fetchGaUsers(w, periodStart, periodEnd)).value;
52
53
  const search = (await fetchSearch(w, periodStart, periodEnd)).value;
53
54
  const enrichment = {
@@ -75,6 +76,7 @@ async function announce(deps) {
75
76
  lighthouse: scores,
76
77
  gaUsersCurrent: gaUsers?.current,
77
78
  gaUsersPrevious: gaUsers?.previous,
79
+ gaPeriodDays: GA_WINDOW_DAYS,
78
80
  searchPosition: search?.foundOnPage1 ? search.position ?? void 0 : void 0,
79
81
  lastTestedDate: null,
80
82
  commentary: null,
@@ -121,6 +123,14 @@ async function announce(deps) {
121
123
  }
122
124
  return { results };
123
125
  }
126
+ function siteLabel(w) {
127
+ try {
128
+ const host = new URL(w.url).hostname.replace(/^www\./, "");
129
+ return `${w.name} (${host})`;
130
+ } catch {
131
+ return w.name;
132
+ }
133
+ }
124
134
  function scoresFromRow(w) {
125
135
  if (w.pScore === null || w.rScore === null || w.bpScore === null || w.seoScore === null) {
126
136
  return null;
@@ -143,7 +153,7 @@ function draftInputFor(w, scores, now, period, enrichment) {
143
153
  completedOn: now,
144
154
  lighthouse: scores,
145
155
  lastTestedDate: null,
146
- subjectOverride: `Your testing & maintenance schedule for ${w.name}`,
156
+ subjectOverride: `Your testing & maintenance report for ${siteLabel(w)}`,
147
157
  ...enrichment
148
158
  };
149
159
  }
@@ -168,4 +178,4 @@ export {
168
178
  formatAnnounceResult,
169
179
  runAnnounceCommand
170
180
  };
171
- //# sourceMappingURL=announce-Q2DXP5OO.js.map
181
+ //# sourceMappingURL=announce-54EBITHX.js.map
@@ -0,0 +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 } from \"../reports/airtable/websites.js\";\nimport type { WebsiteRow } from \"../reports/airtable/websites.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\";\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 gaUsers = (await fetchGaUsers(w, periodStart, periodEnd)).value;\n const search = (await fetchSearch(w, periodStart, periodEnd)).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 // 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/** The subject's site label: the site's display name plus its bare host, e.g.\n * \"Acme Co (acme.com)\". The `www.` prefix is stripped; an unparseable URL falls back\n * to the name alone so a bad row never breaks the subject. */\nfunction siteLabel(w: WebsiteRow): string {\n try {\n const host = new URL(w.url).hostname.replace(/^www\\./, \"\");\n return `${w.name} (${host})`;\n } catch {\n return w.name;\n }\n}\n\n/** The four stored Lighthouse scores off a Websites row, or null if ANY is missing. */\nfunction scoresFromRow(w: WebsiteRow): LighthouseScores | null {\n if (w.pScore === null || w.rScore === null || w.bpScore === null || w.seoScore === null) {\n return null;\n }\n return {\n performance: w.pScore,\n accessibility: w.rScore,\n bestPractices: w.bpScore,\n seo: w.seoScore,\n };\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: `Your testing & maintenance report for ${siteLabel(w)}`,\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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCA,IAAM,iBAAiB;AAoBvB,eAAsB,SAAS,MAA8C;AAC3E,QAAM,OAAO,MAAM,QAAQ,SAAS,mBAAmB,CAAC;AACxD,QAAM,MAAM,MAAM,OAAO,oBAAI,KAAK;AAElC,QAAM,WAAW,MAAM,aAAa,IAAI;AACxC,MAAI,UAAU,SAAS,OAAO,CAAC,MAAM,EAAE,WAAW,aAAa;AAC/D,MAAI,MAAM,MAAM;AACd,UAAM,SAAS,SAAS,KAAK,IAAI;AACjC,cAAU,QAAQ,OAAO,CAAC,MAAM,SAAS,EAAE,IAAI,MAAM,MAAM;AAAA,EAC7D;AAEA,QAAM,SAAS,IAAI,YAAY,EAAE,MAAM,GAAG,CAAC;AAC3C,QAAM,UAAgC,CAAC;AAEvC,aAAW,KAAK,SAAS;AACvB,QAAI;AACF,YAAM,SAAS,cAAc,CAAC;AAC9B,UAAI,WAAW,MAAM;AACnB,gBAAQ,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,oBAAoB,CAAC;AAC1D;AAAA,MACF;AAMA,YAAM,YAAY;AAClB,YAAM,cAAc,IAAI,KAAK,IAAI,QAAQ,IAAI,iBAAiB,KAAK,KAAK,KAAK,GAAI;AACjF,YAAM,WAAW,MAAM,aAAa,GAAG,aAAa,SAAS,GAAG;AAChE,YAAM,UAAU,MAAM,YAAY,GAAG,aAAa,SAAS,GAAG;AAC9D,YAAM,aAA+B;AAAA,QACnC,GAAI,UAAU,EAAE,gBAAgB,QAAQ,SAAS,iBAAiB,QAAQ,SAAS,IAAI,CAAC;AAAA,QACxF,GAAI,SAAS,EAAE,kBAAkB,OAAO,aAAa,IAAI,CAAC;AAAA,QAC1D,GAAI,QAAQ,gBAAgB,OAAO,aAAa,OAC5C,EAAE,gBAAgB,OAAO,SAAS,IAClC,CAAC;AAAA,MACP;AAMA,UAAI;AACJ,UAAI;AACJ,YAAM,WAAW,MAAM,mBAAmB,MAAM,EAAE,IAAI,gBAAgB,MAAM;AAC5E,UAAI,UAAU;AACZ,cAAM,mBAAmB,MAAM,SAAS,IAAI,QAAQ,KAAK,UAAU;AACnE,iBAAS;AACT,qBAAa;AAAA,MACf,OAAO;AACL,iBAAS,MAAM,YAAY,MAAM,cAAc,GAAG,QAAQ,KAAK,QAAQ,UAAU,CAAC;AAClF,qBAAa;AAAA,MACf;AAEA,YAAM,OAAO,SAAS,EAAE,IAAI;AAC5B,YAAM,EAAE,KAAK,IAAI,MAAM,iBAAiB;AAAA,QACtC,UAAU,EAAE;AAAA,QACZ,SAAS,EAAE;AAAA,QACX,YAAY;AAAA,QACZ,aAAa;AAAA,QACb,YAAY;AAAA,QACZ,gBAAgB,SAAS;AAAA,QACzB,iBAAiB,SAAS;AAAA,QAC1B,cAAc;AAAA,QACd,gBAAgB,QAAQ,eAAgB,OAAO,YAAY,SAAa;AAAA,QACxE,gBAAgB;AAAA,QAChB,YAAY;AAAA,QACZ,MAAM,YAAY,CAAC;AAAA,QACnB,gBAAgB,GAAG,IAAI;AAAA;AAAA;AAAA;AAAA,QAIvB,GAAG,uBAAuB,CAAC;AAAA,MAC7B,CAAC;AAGD,UAAI;AACF,cAAM;AAAA,UACJ,OAAO;AAAA,UACP;AAAA,UACA;AAAA,UACA,GAAG,IAAI,IAAI,IAAI,YAAY,EAAE,MAAM,GAAG,EAAE,CAAC;AAAA,UACzC;AAAA,QACF;AAAA,MACF,SAAS,WAAW;AAClB,gBAAQ;AAAA,UACN,kDAA6C,EAAE,IAAI,KACjD,qBAAqB,QAAQ,UAAU,UAAU,OAAO,SAAS,CACnE;AAAA,QACF;AAAA,MACF;AAMA,YAAM,QAAQ,MAAM,WAAW,MAAM;AAAA,QACnC,IAAI,OAAO;AAAA,QACX,QAAQ,EAAE;AAAA,QACV,YAAY;AAAA,MACd,CAAC;AAED,YAAM,mBAAmB,EAAE,EAAE,sBAAsB,EAAE,mBAAmB,KAAK;AAC7E,cAAQ,KAAK;AAAA,QACX,MAAM,EAAE;AAAA,QACR,QAAQ;AAAA,QACR,UAAU,OAAO;AAAA,QACjB;AAAA,QACA,QAAQ,MAAM;AAAA,MAChB,CAAC;AAAA,IACH,SAAS,KAAK;AACZ,cAAQ,KAAK;AAAA,QACX,MAAM,EAAE;AAAA,QACR,QAAQ;AAAA,QACR,SAAS,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAAA,MAC1D,CAAC;AAAA,IACH;AAAA,EACF;AAEA,SAAO,EAAE,QAAQ;AACnB;AAKA,SAAS,UAAU,GAAuB;AACxC,MAAI;AACF,UAAM,OAAO,IAAI,IAAI,EAAE,GAAG,EAAE,SAAS,QAAQ,UAAU,EAAE;AACzD,WAAO,GAAG,EAAE,IAAI,KAAK,IAAI;AAAA,EAC3B,QAAQ;AACN,WAAO,EAAE;AAAA,EACX;AACF;AAGA,SAAS,cAAc,GAAwC;AAC7D,MAAI,EAAE,WAAW,QAAQ,EAAE,WAAW,QAAQ,EAAE,YAAY,QAAQ,EAAE,aAAa,MAAM;AACvF,WAAO;AAAA,EACT;AACA,SAAO;AAAA,IACL,aAAa,EAAE;AAAA,IACf,eAAe,EAAE;AAAA,IACjB,eAAe,EAAE;AAAA,IACjB,KAAK,EAAE;AAAA,EACT;AACF;AAKA,SAAS,cACP,GACA,QACA,KACA,QACA,YACmC;AACnC,SAAO;AAAA,IACL,UAAU,GAAG,EAAE,IAAI,+BAAqB,IAAI,YAAY,EAAE,MAAM,GAAG,EAAE,CAAC;AAAA,IACtE,QAAQ,EAAE;AAAA,IACV,YAAY;AAAA,IACZ;AAAA,IACA,aAAa;AAAA,IACb,WAAW;AAAA,IACX,aAAa;AAAA,IACb,YAAY;AAAA,IACZ,gBAAgB;AAAA,IAChB,iBAAiB,yCAAyC,UAAU,CAAC,CAAC;AAAA,IACtE,GAAG;AAAA,EACL;AACF;;;AC1NA,SAAS,iBAAiB,GAA+B;AACvD,MAAI,EAAE,WAAW,oBAAqB,QAAO,IAAI,EAAE,IAAI;AACvD,MAAI,EAAE,WAAW,QAAS,QAAO,IAAI,EAAE,IAAI,YAAY,EAAE,OAAO;AAChE,QAAM,OAAO,EAAE,mBAAmB,8BAAyB;AAC3D,SAAO,IAAI,EAAE,IAAI,KAAK,EAAE,MAAM,GAAG,IAAI;AACvC;AAEO,SAAS,qBAAqB,QAAgC;AACnE,MAAI,OAAO,QAAQ,WAAW,EAAG,QAAO;AACxC,SAAO,OAAO,QAAQ,IAAI,gBAAgB,EAAE,KAAK,IAAI;AACvD;AASA,eAAsB,mBACpB,MACA,OAC2C;AAC3C,QAAM,SAAS,MAAM,SAAS,OAAO,EAAE,KAAK,IAAI,CAAC,CAAC;AAClD,QAAM,WAAW,OAAO,QAAQ,KAAK,CAAC,MAAM,EAAE,WAAW,OAAO;AAChE,SAAO,EAAE,QAAQ,qBAAqB,MAAM,GAAG,MAAM,WAAW,IAAI,EAAE;AACxE;","names":[]}
@@ -5,7 +5,7 @@ import {
5
5
  } from "./chunk-4NAOR44V.js";
6
6
  import {
7
7
  resolveSites
8
- } from "./chunk-6CSCLBAA.js";
8
+ } from "./chunk-SAOCVNX7.js";
9
9
  import {
10
10
  bumpDeps
11
11
  } from "./chunk-D3DQFC32.js";
@@ -54,4 +54,4 @@ async function runBumpDepsCommand(site, opts) {
54
54
  export {
55
55
  runBumpDepsCommand
56
56
  };
57
- //# sourceMappingURL=bump-deps-RYHQIKW4.js.map
57
+ //# sourceMappingURL=bump-deps-2U6GIHM5.js.map
@@ -42,7 +42,8 @@ var DEFAULT_COPY = {
42
42
  announceBody: "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:",
43
43
  announceImprovementResend: "Your contact forms now deliver straight to your inbox through reliable infrastructure, so no inquiry slips through the cracks.",
44
44
  announceImprovementSvelte5: "We've modernized your site to the latest framework \u2014 it's faster, more secure, and built to last.",
45
- announceCadence: "After each one we'll send you a short report like this \u2014 there's nothing you need to do.",
45
+ announceCadence: "After each one we'll send you a short report like this \u2014",
46
+ announceCadenceNote: "there's nothing you need to do.",
46
47
  announceOpenDoor: "And if you'd ever like to expand the scope, add features, or freshen anything up, just let us know."
47
48
  };
48
49
  function override(v) {
@@ -171,11 +172,12 @@ function lighthouseScoresSection(lighthouse, opts = {}) {
171
172
  <mj-section background-color="${background}"${sectionPad}>
172
173
  <mj-column>
173
174
  <mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="${labelTop}">LIGHTHOUSE SCORES*</mj-text>${rows}
174
- <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 Google's Lighthouse tool, which evaluates various aspects of a web page's quality.</mj-text>
175
+ <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>
175
176
  </mj-column>
176
177
  </mj-section>`;
177
178
  }
178
- function analyticsTrendLine(cur, prev) {
179
+ function analyticsTrendLine(cur, prev, periodDays) {
180
+ const priorLabel = periodDays && periodDays > 0 ? `the previous ${periodDays} days` : "last period";
179
181
  if (cur === void 0 || prev === void 0) {
180
182
  return trendLine(TREND_NEUTRAL, `Last Period: ${prev !== void 0 ? fmtUsers(prev) : "\u2014"}`);
181
183
  }
@@ -184,9 +186,9 @@ function analyticsTrendLine(cur, prev) {
184
186
  }
185
187
  const pct = Math.round((cur - prev) / prev * 100);
186
188
  const range = `(${fmtUsers(prev)} \u2192 ${fmtUsers(cur)})`;
187
- if (pct > 0) return trendLine(TREND_UP, `\u25B2 ${pct}% vs last period ${range}`);
188
- if (pct < 0) return trendLine(TREND_NEUTRAL, `\u25BC ${Math.abs(pct)}% vs last period ${range}`);
189
- return trendLine(TREND_NEUTRAL, `No change vs last period (${fmtUsers(prev)})`);
189
+ if (pct > 0) return trendLine(TREND_UP, `\u25B2 ${pct}% vs ${priorLabel} ${range}`);
190
+ if (pct < 0) return trendLine(TREND_NEUTRAL, `\u25BC ${Math.abs(pct)}% vs ${priorLabel} ${range}`);
191
+ return trendLine(TREND_NEUTRAL, `No change vs ${priorLabel} (${fmtUsers(prev)})`);
190
192
  }
191
193
  function trendLine(color, text) {
192
194
  return `<mj-text color="${color}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${text}</mj-text>`;
@@ -205,7 +207,7 @@ function analyticsSection(opts) {
205
207
  <mj-column>
206
208
  <mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="${labelTop}">ANALYTICS</mj-text>
207
209
  <mj-text color="${RED}" font-size="44px" font-weight="400">${users} Users</mj-text>
208
- ${analyticsTrendLine(opts.current, opts.previous)}
210
+ ${analyticsTrendLine(opts.current, opts.previous, opts.periodDays)}
209
211
  ${body}
210
212
  ${footnotes}
211
213
  </mj-column>
@@ -313,6 +315,7 @@ function buildMjml(data) {
313
315
  ${analyticsSection({
314
316
  current: data.gaUsersCurrent,
315
317
  previous: data.gaUsersPrevious,
318
+ periodDays: data.gaPeriodDays,
316
319
  background: "white",
317
320
  footnoteLines: [escapeXml2(copy.seoCta)]
318
321
  })}
@@ -348,8 +351,14 @@ var SECTION_PAD = "40px";
348
351
  function sectionLabel(text) {
349
352
  return `<mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="0px">${escapeXml2(text)}</mj-text>`;
350
353
  }
354
+ function bodyLineHtml(html, paddingTop = "8px") {
355
+ return `<mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="${paddingTop}">${html}</mj-text>`;
356
+ }
351
357
  function bodyLine(text, paddingTop = "8px") {
352
- return `<mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="${paddingTop}">${escapeXml2(text)}</mj-text>`;
358
+ return bodyLineHtml(escapeXml2(text), paddingTop);
359
+ }
360
+ function cadenceHtml(copy) {
361
+ return `${escapeXml2(copy.announceCadence)} <em>${escapeXml2(copy.announceCadenceNote)}</em>`;
353
362
  }
354
363
  function announcementSiteExtras(site) {
355
364
  return {
@@ -381,7 +390,7 @@ function buildAnnouncementMjml(data) {
381
390
  <mj-section background-color="${maintBg}" padding-top="${SECTION_PAD}" padding-bottom="0px">
382
391
  <mj-column>
383
392
  ${sectionLabel("MAINTENANCE CHECKS")}
384
- ${bodyLine(`${copy.maintenanceIntro} We do this ${FREQ_PHRASE[cad.maintenance]}.${cad.testing === "None" ? ` ${copy.announceCadence}` : ""}`)}
393
+ ${bodyLineHtml(`${escapeXml2(`${copy.maintenanceIntro} We do this ${FREQ_PHRASE[cad.maintenance]}.`)}${cad.testing === "None" ? ` ${cadenceHtml(copy)}` : ""}`)}
385
394
  </mj-column>
386
395
  </mj-section>${checklistRowsSection(copy.maintenanceChecks, {
387
396
  background: maintBg,
@@ -391,7 +400,7 @@ function buildAnnouncementMjml(data) {
391
400
  <mj-section background-color="${testBg}" padding-top="${SECTION_PAD}" padding-bottom="0px">
392
401
  <mj-column>
393
402
  ${sectionLabel("TESTING")}
394
- ${bodyLine(`${copy.testingIntro} We run a full test ${FREQ_PHRASE[cad.testing]}. ${copy.announceCadence}`)}
403
+ ${bodyLineHtml(`${escapeXml2(`${copy.testingIntro} We run a full test ${FREQ_PHRASE[cad.testing]}.`)} ${cadenceHtml(copy)}`)}
395
404
  </mj-column>
396
405
  </mj-section>${checklistRowsSection(copy.testingChecklist, {
397
406
  background: testBg,
@@ -400,6 +409,7 @@ function buildAnnouncementMjml(data) {
400
409
  const analytics = analyticsSection({
401
410
  current: data.gaUsersCurrent,
402
411
  previous: data.gaUsersPrevious,
412
+ periodDays: data.gaPeriodDays,
403
413
  background: analyticsBg,
404
414
  pad: SECTION_PAD,
405
415
  bodyLines: data.searchPosition !== void 0 ? [`Page 1 Google result (#${data.searchPosition}) for your brand search`] : []
@@ -413,8 +423,8 @@ function buildAnnouncementMjml(data) {
413
423
  </mj-column>
414
424
  </mj-section>` : "";
415
425
  const contactRows = copy.contact.map(
416
- (line) => `
417
- <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml2(line)}</mj-text>`
426
+ (line, i) => `
427
+ <mj-text ${i === 0 ? "" : `color="${GREY2}" `}font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml2(line)}</mj-text>`
418
428
  ).join("");
419
429
  const footerAddressRows = copy.footerAddress.map(
420
430
  (line) => `
@@ -448,7 +458,7 @@ function buildAnnouncementMjml(data) {
448
458
  ${improvementsSection}
449
459
  <mj-section background-color="${contactBg}" padding-top="${SECTION_PAD}" padding-bottom="${SECTION_PAD}">
450
460
  <mj-column>
451
- <mj-text color="${RED2}" font-family="helvetica, sans-serif" font-size="24px" font-weight="700" padding-top="0px" line-height="36px">Any questions, concerns or requests?</mj-text>
461
+ <mj-text color="${RED2}" font-family="helvetica, sans-serif" font-size="24px" font-weight="700" padding-top="0px" line-height="36px">Questions, concerns or requests?</mj-text>
452
462
  ${contactRows}
453
463
  <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
454
464
  <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" line-height="20px" font-style="italic">Copyright ${(/* @__PURE__ */ new Date()).getUTCFullYear()} ${escapeXml2(copy.footerOrg)}. All rights reserved.</mj-text>
@@ -582,4 +592,4 @@ export {
582
592
  fetchAttachmentBytes,
583
593
  uploadAttachment
584
594
  };
585
- //# sourceMappingURL=chunk-BWLUNY7L.js.map
595
+ //# sourceMappingURL=chunk-2T3HZ3DB.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/airtable/attachments.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\nconst RED = \"#C00\";\nconst 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/**\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 */\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 const users = opts.current !== undefined ? fmtUsers(opts.current) : \"—\";\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 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>\n <mj-text color=\"${RED}\" font-size=\"44px\" font-weight=\"400\">${users} Users</mj-text>\n ${analyticsTrendLine(opts.current, opts.previous, opts.periodDays)}\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} 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(lastTested: Date | null): 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 <mj-section background-color=\"#F4F4F4\" padding-top=\"0px\">\n <mj-column>\n <mj-text color=\"#757575\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\">Last Tested: ${fmtDate(lastTested)}</mj-text>\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=\"#C00\" font-size=\"20px\" font-weight=\"700\" padding-top=\"75px\">TESTING</mj-text>\n <mj-text color=\"#757575\" 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=\"#C00\" font-size=\"20px\" font-weight=\"700\" padding-top=\"55px\">${escapeXml(copy.notesHeader)}</mj-text>\n <mj-text color=\"#757575\" 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=\"#C00\" font-size=\"20px\" font-weight=\"700\" padding-top=\"75px\">COMPLETED ON</mj-text>\n <mj-text color=\"#C00\" font-size=\"44px\" font-weight=\"400\">${fmtDate(data.completedOn)}</mj-text>\n <mj-text color=\"#C00\" font-size=\"20px\" font-weight=\"700\" padding-top=\"75px\">MAINTENANCE CHECKS</mj-text>\n <mj-text color=\"#757575\" 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(data.lastTestedDate)}\n ${data.commentary ? commentarySection(data.commentary, copy) : \"\"}\n <mj-section background-color=\"white\">\n <mj-column padding-top=\"36px\">\n <mj-text color=\"#C00\" 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 i === copy.contact.length - 1\n ? `<mj-text font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"300\" padding-top=\"0px\" line-height=\"30px\" padding-bottom=\"36px\">${escapeXml(line)}</mj-text>`\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 \")}\n <mj-divider border-width=\"1px\" border-style=\"solid\" border-color=\"#CCCCCC\" padding=\"0\" />\n <mj-text color=\"#757575\" 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=\"#757575\" 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=\"#757575\" 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} 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 // 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 = 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.\n const analytics = analyticsSection({\n current: data.gaUsersCurrent,\n previous: data.gaUsersPrevious,\n periodDays: data.gaPeriodDays,\n background: analyticsBg,\n pad: SECTION_PAD,\n bodyLines:\n data.searchPosition !== undefined\n ? [`Page 1 Google result (#${data.searchPosition}) for your brand search`]\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.\") stays black for emphasis; any following lines\n // (e.g. \"We're here to help in any way we can.\") render in muted grey.\n const contactRows = copy.contact\n .map(\n (line, i) => `\n <mj-text ${i === 0 ? \"\" : `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 const contactRows = copy.contact\n .map(\n (line) => `\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","/** Cheap HTML sniff: an Airtable signed-URL \"200\" that is really a login/error page\n * starts with `<!doctype html`, `<html`, or `<head` after an optional UTF-8 BOM /\n * leading whitespace. We only need to catch the common error-page case, not parse\n * HTML. */\nfunction looksLikeHtml(bytes: Uint8Array): boolean {\n // Inspect the first ~64 bytes as ASCII (1 byte → 1 char; enough for a doctype /\n // opening tag). Skip a leading UTF-8 BOM (bytes EF BB BF) by index, then strip any\n // leading ASCII whitespace, and match the common HTML openers case-insensitively.\n const start = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf ? 3 : 0;\n const head = Buffer.from(bytes.slice(start, start + 64))\n .toString(\"ascii\")\n .replace(/^[\\s]+/, \"\")\n .toLowerCase();\n return head.startsWith(\"<!doctype html\") || head.startsWith(\"<html\") || head.startsWith(\"<head\");\n}\n\nexport async function fetchAttachmentBytes(\n url: string,\n): Promise<{ bytes: Uint8Array; contentType: string }> {\n const res = await fetch(url);\n if (!res.ok) {\n throw new Error(\n `Failed to fetch Airtable attachment ${res.status} ${res.statusText} (url=${url})`,\n );\n }\n const contentType = res.headers.get(\"content-type\") ?? \"application/octet-stream\";\n const ab = await res.arrayBuffer();\n const bytes = new Uint8Array(ab);\n // Sanity-gate the body: a 200 that is actually an HTML error/login page (expired\n // signed URL, auth wall) would otherwise be attached as the \"image\" and ship a\n // broken header. Accept an explicit image/* content-type; otherwise reject anything\n // that sniffs as HTML — so the send fails loudly rather than emailing a broken image.\n const isImageType = contentType.toLowerCase().startsWith(\"image/\");\n if (!isImageType && looksLikeHtml(bytes)) {\n throw new Error(\n `Airtable attachment did not return image data (content-type=\"${contentType}\", ` +\n `body looks like an HTML page — the signed URL may have expired) (url=${url})`,\n );\n }\n return { bytes, contentType };\n}\n\n/**\n * Upload bytes (or a string) as an attachment to a specific record + field.\n * Uses Airtable's content.airtable.com upload endpoint (base64 body) because\n * the standard SDK only accepts public URLs for attachments, and we don't\n * host the generated content anywhere public.\n *\n * Docs: https://airtable.com/developers/web/api/upload-attachment\n *\n * Requires AIRTABLE_PAT + AIRTABLE_BASE_ID in env (same as the rest of the\n * reports module). The fieldName is URL-encoded for the request path.\n */\nexport async function uploadAttachment(\n recordId: string,\n fieldName: string,\n body: Uint8Array | string,\n filename: string,\n contentType: string,\n): Promise<void> {\n const apiKey = process.env.AIRTABLE_PAT;\n const baseId = process.env.AIRTABLE_BASE_ID;\n if (!apiKey || !baseId) {\n throw new Error(\"AIRTABLE_PAT and AIRTABLE_BASE_ID must be set\");\n }\n const base64 =\n typeof body === \"string\"\n ? Buffer.from(body, \"utf-8\").toString(\"base64\")\n : Buffer.from(body).toString(\"base64\");\n const payload = { contentType, file: base64, filename };\n const url = `https://content.airtable.com/v0/${baseId}/${recordId}/${encodeURIComponent(fieldName)}/uploadAttachment`;\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${apiKey}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok) {\n throw new Error(`Airtable upload failed: ${res.status} ${res.statusText} ${await res.text()}`);\n }\n}\n"],"mappings":";;;;;;;;AA2BO,IAAM,eAA6B;AAAA,EACxC,kBACE;AAAA,EACF,mBAAmB;AAAA,IACjB;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EACA,cACE;AAAA,EACF,kBAAkB;AAAA,IAChB;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EACA,aAAa;AAAA,EACb,QAAQ;AAAA,EACR,SAAS,CAAC,mBAAmB,uCAAuC;AAAA,EACpE,WAAW;AAAA,EACX,eAAe,CAAC,oBAAoB,2BAA2B;AAAA,EAC/D,eAAe;AAAA,EACf,YACE;AAAA,EACF,kBAAkB;AAAA,IAChB;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EACA,iBAAiB;AAAA,EACjB,cACE;AAAA,EACF,2BACE;AAAA,EACF,4BACE;AAAA,EACF,iBAAiB;AAAA,EACjB,qBAAqB;AAAA,EACrB,kBACE;AACJ;AAGA,SAAS,SAAS,GAAiC;AACjD,MAAI,OAAO,MAAM,SAAU,QAAO;AAClC,QAAM,IAAI,EAAE,KAAK;AACjB,SAAO,EAAE,SAAS,IAAI,IAAI;AAC5B;AASA,SAAS,WAAW,GAAqB;AACvC,SAAO,EAAE,MAAM,OAAO,EAAE,OAAO,CAAC,MAAM,EAAE,KAAK,EAAE,SAAS,CAAC;AAC3D;AAEO,SAAS,YAAY,MAAgC;AAC1D,QAAM,QAAQ,SAAS,KAAK,SAAS;AACrC,QAAM,UAAU,SAAS,KAAK,WAAW;AACzC,QAAM,SAAS,SAAS,KAAK,UAAU;AACvC,QAAM,cAAc,SAAS,WAAW,MAAM,IAAI;AAClD,SAAO;AAAA,IACL,GAAG;AAAA,IACH,kBAAkB,SAAS,aAAa;AAAA,IACxC,SAAS,UAAU,WAAW,OAAO,IAAI,aAAa;AAAA,IACtD,WAAW,cAAc,CAAC,KAAK,aAAa;AAAA,IAC5C,eAAe,cAAc,YAAY,MAAM,CAAC,IAAI,aAAa;AAAA,EACnE;AACF;;;ACzGA,SAAS,gBAAgB;AACzB,SAAS,kBAAkB;AAC3B,SAAS,SAAS,YAAY;AAC9B,SAAS,qBAAqB;AAEvB,IAAM,YAAY;AAClB,IAAM,cAAc;AAgB3B,IAAI,kBAAiC;AACrC,SAAS,mBAA2B;AAClC,MAAI,gBAAiB,QAAO;AAC5B,MAAI,MAAM,QAAQ,cAAc,YAAY,GAAG,CAAC;AAChD,SAAO,MAAM;AAGX,UAAM,eAAe,KAAK,KAAK,OAAO,WAAW,qBAAqB,UAAU,WAAW;AAC3F,QAAI,WAAW,YAAY,GAAG;AAC5B,wBAAkB,QAAQ,YAAY;AACtC,aAAO;AAAA,IACT;AAGA,UAAM,gBAAgB,KAAK,KAAK,QAAQ,WAAW,qBAAqB,UAAU,WAAW;AAC7F,QAAI,WAAW,aAAa,GAAG;AAC7B,wBAAkB,QAAQ,aAAa;AACvC,aAAO;AAAA,IACT;AACA,UAAM,SAAS,QAAQ,GAAG;AAC1B,QAAI,WAAW,KAAK;AAClB,YAAM,IAAI;AAAA,QACR,uFAAuF,cAAc,YAAY,GAAG,CAAC;AAAA,MACvH;AAAA,IACF;AACA,UAAM;AAAA,EACR;AACF;AAOA,eAAsB,oBAGnB;AACD,QAAM,YAAY,iBAAiB;AACnC,QAAM,CAAC,OAAO,OAAO,IAAI,MAAM,QAAQ,IAAI;AAAA,IACzC,SAAS,KAAK,WAAW,WAAW,CAAC;AAAA,IACrC,SAAS,KAAK,WAAW,kBAAkB,CAAC;AAAA,EAC9C,CAAC;AACD,SAAO;AAAA,IACL,OAAO;AAAA,MACL,OAAO,IAAI,WAAW,KAAK;AAAA,MAC3B,aAAa;AAAA,MACb,KAAK;AAAA,MACL,UAAU;AAAA,IACZ;AAAA,IACA,SAAS;AAAA,MACP,OAAO,IAAI,WAAW,OAAO;AAAA,MAC7B,aAAa;AAAA,MACb,KAAK;AAAA,MACL,UAAU;AAAA,IACZ;AAAA,EACF;AACF;;;AClEO,IAAM,YAAY;AAEzB,IAAM,MAAM;AACZ,IAAM,OAAO;AACb,IAAM,SAAS;AACf,IAAM,WAAW;AACjB,IAAM,gBAAgB;AAItB,IAAM,YAAY,OAAO,SAAS;AAG3B,SAAS,SAAS,GAAmB;AAC1C,SAAO,EAAE,eAAe,OAAO;AACjC;AAQO,SAAS,qBACd,MACA,MACQ;AACR,SAAO,KACJ,IAAI,CAAC,OAAO,MAAM;AACjB,UAAM,SAAS,MAAM,KAAK,SAAS;AACnC,UAAM,SAAS,SAAS,KAAK,yBAAyB,MAAM;AAC5D,UAAM,UAAU,SAAS,oBAAoB,KAAK,iBAAiB,MAAM;AACzE,WAAO;AAAA,oCACuB,KAAK,UAAU,kBAAkB,OAAO;AAAA;AAAA,mDAEzB,MAAM;AAAA,6DACI,IAAI,gEAAgE,UAAU,KAAK,CAAC;AAAA;AAAA,gCAEjH,MAAM;AAAA,kIAC4F,SAAS;AAAA;AAAA;AAAA;AAAA,EAIvI,CAAC,EACA,KAAK,EAAE;AACZ;AAIA,IAAM,kBAID;AAAA,EACH,EAAE,OAAO,eAAe,KAAK,eAAe,OAAO,6CAAmC;AAAA,EACtF,EAAE,OAAO,sBAAsB,KAAK,iBAAiB,OAAO,qCAAgC;AAAA,EAC5F,EAAE,OAAO,kBAAkB,KAAK,iBAAiB,OAAO,6CAAmC;AAAA,EAC3F,EAAE,OAAO,kBAAkB,KAAK,OAAO,OAAO,6CAAmC;AACnF;AASO,SAAS,wBACd,YACA,OAA8C,CAAC,GACvC;AACR,QAAM,aAAa,KAAK,cAAc;AACtC,QAAM,aAAa,KAAK,MAAM,iBAAiB,KAAK,GAAG,qBAAqB,KAAK,GAAG,MAAM;AAC1F,QAAM,WAAW,KAAK,OAAO;AAC7B,QAAM,iBAAiB,KAAK,MAAM,QAAQ;AAC1C,QAAM,OAAO,gBAAgB;AAAA,IAC3B,CAAC,EAAE,OAAO,KAAK,MAAM,GAAG,MAAM;AAAA,0BACR,GAAG,2DAA2D,KAAK;AAAA,0BACnE,GAAG,0DAA0D,WAAW,GAAG,CAAC;AAAA,0BAC5E,IAAI,oHAAoH,KAAK,aAC7I,IAAI,gBAAgB,SAAS,IACzB;AAAA,4EAC8D,MAAM,qBACpE,EACN;AAAA,EACN,EAAE,KAAK,EAAE;AACT,SAAO;AAAA,oCAC2B,UAAU,IAAI,UAAU;AAAA;AAAA,0BAElC,GAAG,qDAAqD,QAAQ,iCAAiC,IAAI;AAAA,0BACrG,IAAI,+GAA+G,cAAc,4JAA4J,GAAG;AAAA;AAAA;AAG1T;AAKO,SAAS,mBACd,KACA,MACA,YACQ;AAIR,QAAM,aACJ,cAAc,aAAa,IAAI,gBAAgB,UAAU,UAAU;AACrE,MAAI,QAAQ,UAAa,SAAS,QAAW;AAC3C,WAAO,UAAU,eAAe,gBAAgB,SAAS,SAAY,SAAS,IAAI,IAAI,QAAG,EAAE;AAAA,EAC7F;AACA,MAAI,SAAS,GAAG;AACd,WAAO,MAAM,IACT,UAAU,UAAU,wCAAmC,IACvD,UAAU,eAAe,gBAAgB;AAAA,EAC/C;AACA,QAAM,MAAM,KAAK,OAAQ,MAAM,QAAQ,OAAQ,GAAG;AAClD,QAAM,QAAQ,IAAI,SAAS,IAAI,CAAC,WAAM,SAAS,GAAG,CAAC;AACnD,MAAI,MAAM,EAAG,QAAO,UAAU,UAAU,UAAK,GAAG,QAAQ,UAAU,IAAI,KAAK,EAAE;AAC7E,MAAI,MAAM,EAAG,QAAO,UAAU,eAAe,UAAK,KAAK,IAAI,GAAG,CAAC,QAAQ,UAAU,IAAI,KAAK,EAAE;AAC5F,SAAO,UAAU,eAAe,gBAAgB,UAAU,KAAK,SAAS,IAAI,CAAC,GAAG;AAClF;AAGA,SAAS,UAAU,OAAe,MAAsB;AACtD,SAAO,mBAAmB,KAAK,+FAA+F,IAAI;AACpI;AAGA,SAAS,aAAa,MAAsB;AAC1C,SAAO,mBAAmB,IAAI,wIAAwI,IAAI;AAC5K;AAOO,SAAS,iBAAiB,MAStB;AACT,QAAM,QAAQ,KAAK,YAAY,SAAY,SAAS,KAAK,OAAO,IAAI;AACpE,QAAM,QAAQ,KAAK,aAAa,CAAC,GAAG,IAAI,CAAC,MAAM,UAAU,eAAe,CAAC,CAAC,EAAE,KAAK,YAAY;AAC7F,QAAM,aAAa,KAAK,iBAAiB,CAAC,GAAG,IAAI,YAAY,EAAE,KAAK,YAAY;AAChF,QAAM,aAAa,KAAK,MAAM,iBAAiB,KAAK,GAAG,qBAAqB,KAAK,GAAG,MAAM;AAC1F,QAAM,WAAW,KAAK,OAAO;AAC7B,SAAO;AAAA,oCAC2B,KAAK,UAAU,IAAI,UAAU;AAAA;AAAA,0BAEvC,GAAG,qDAAqD,QAAQ;AAAA,0BAChE,GAAG,wCAAwC,KAAK;AAAA,UAChE,mBAAmB,KAAK,SAAS,KAAK,UAAU,KAAK,UAAU,CAAC;AAAA,UAChE,IAAI;AAAA,UACJ,SAAS;AAAA;AAAA;AAGnB;;;ACtJO,IAAMA,aAAY;AAKzB,IAAM,gBAAgB,OAAO,WAAW;AAEjC,SAAS,QAAQ,GAAwB;AAK9C,MAAI,CAAC,KAAK,OAAO,MAAM,EAAE,QAAQ,CAAC,EAAG,QAAO;AAI5C,QAAM,KAAK,OAAO,EAAE,YAAY,IAAI,CAAC,EAAE,SAAS,GAAG,GAAG;AACtD,QAAM,KAAK,OAAO,EAAE,WAAW,CAAC,EAAE,SAAS,GAAG,GAAG;AACjD,QAAM,OAAO,EAAE,eAAe;AAC9B,SAAO,GAAG,EAAE,IAAI,EAAE,IAAI,IAAI;AAC5B;AAEA,SAAS,yBAAyB,MAAoB,gBAAiC;AAErF,QAAM,cACJ,mBAAmB,SACf,0BAA0B,cAAc,MACvC,KAAK,kBAAkB,CAAC,KAAK;AACpC,QAAM,OAAO,KAAK,kBAAkB,IAAI,CAAC,OAAO,MAAO,MAAM,IAAI,cAAc,KAAM;AACrF,SAAO,qBAAqB,MAAM,EAAE,YAAY,SAAS,mBAAmB,OAAO,CAAC;AACtF;AAEA,SAAS,wBAAwB,MAA4B;AAC3D,SAAO,qBAAqB,KAAK,kBAAkB;AAAA,IACjD,YAAY;AAAA,IACZ,mBAAmB;AAAA,EACrB,CAAC;AACH;AAEA,SAAS,8BAA8B,YAAiC;AACtE,SAAO;AAAA;AAAA;AAAA,0DAGiD,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA,0IAKmE,QAAQ,UAAU,CAAC;AAAA;AAAA;AAG7J;AAEA,SAAS,oBAAoB,MAA4B;AACvD,SAAO;AAAA;AAAA;AAAA;AAAA,6HAIoHA,WAAU,KAAK,YAAY,CAAC;AAAA;AAAA;AAGzJ;AAEA,SAAS,kBAAkB,MAAc,MAA4B;AACnE,SAAO;AAAA;AAAA;AAAA,sFAG6EA,WAAU,KAAK,WAAW,CAAC;AAAA,6HACYA,WAAU,IAAI,EAAE,QAAQ,aAAa,OAAO,CAAC;AAAA;AAAA;AAG1K;AAEA,SAAS,cACP,MAC2F;AAC3F,SAAO,QAAQ,KAAK,eAAe,KAAK,gBAAgB,KAAK,aAAa;AAC5E;AAEO,SAAS,eAAe,MAA0B;AACvD,QAAM,MAAM,OAAO,KAAK,cAAc;AACtC,QAAM,MAAM,GAAGA,WAAU,KAAK,QAAQ,CAAC;AAKvC,QAAM,OAAO,UAAU,KAAK,OAAO,IAAIA,WAAU,KAAK,OAAO,IAAI;AAQjE,MAAI,cAAc,IAAI,GAAG;AACvB,WAAO,mBAAmB,IAAI,UAAU,GAAG,UAAU,GAAG,YAAY,KAAK,WAAW,yDAAyD,KAAK,aAAa;AAAA,EACjK;AACA,SAAO,mBAAmB,IAAI,UAAU,GAAG,UAAU,GAAG;AAC1D;AAEO,SAAS,iBAAiB,MAA0B;AACzD,MAAI,CAAC,cAAc,IAAI,EAAG,QAAO;AAIjC,SAAO,qEAAqE,KAAK,WAAW,MAAM,KAAK,YAAY;AACrH;AAEO,SAAS,UAAU,MAA0B;AAClD,QAAM,OAAO,KAAK,QAAQ;AAC1B,QAAM,YAAY,KAAK,eAAe;AACtC,QAAM,cAAc,iBAAiBA,WAAU,KAAK,QAAQ,CAAC;AAE7D,SAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAOS,WAAW;AAAA,MACvB,iBAAiB,IAAI,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA,UAKlB,eAAe,IAAI,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mEAMqC,QAAQ,KAAK,WAAW,CAAC;AAAA;AAAA,6HAEiCA,WAAU,KAAK,gBAAgB,CAAC;AAAA;AAAA;AAAA,MAGvJ,yBAAyB,MAAM,KAAK,cAAc,CAAC;AAAA,MACnD,wBAAwB,KAAK,UAAU,CAAC;AAAA,MACxC,iBAAiB;AAAA,IACjB,SAAS,KAAK;AAAA,IACd,UAAU,KAAK;AAAA,IACf,YAAY,KAAK;AAAA,IACjB,YAAY;AAAA,IACZ,eAAe,CAACA,WAAU,KAAK,MAAM,CAAC;AAAA,EACxC,CAAC,CAAC;AAAA,MACA,YAAY,oBAAoB,IAAI,IAAI,wBAAwB,IAAI,IAAI,8BAA8B,KAAK,cAAc,CAAC;AAAA,MAC1H,KAAK,aAAa,kBAAkB,KAAK,YAAY,IAAI,IAAI,EAAE;AAAA;AAAA;AAAA;AAAA,UAI3D,KAAK,QACJ;AAAA,IAAI,CAAC,MAAM,MACV,MAAM,KAAK,QAAQ,SAAS,IACxB,8IAA8IA,WAAU,IAAI,CAAC,eAC7J,sGAAsGA,WAAU,IAAI,CAAC;AAAA,EAC3H,EACC,KAAK,YAAY,CAAC;AAAA;AAAA,+KAEiJ,oBAAI,KAAK,GAAE,eAAe,CAAC,IAAIA,WAAU,KAAK,SAAS,CAAC;AAAA;AAAA,UAE5N,CAAC,KAAK,WAAW,GAAG,KAAK,aAAa,EACrC;AAAA,IACC,CAAC,SACC,2JAA2JA,WAAU,IAAI,CAAC;AAAA,EAC9K,EACC,KAAK,YAAY,CAAC;AAAA;AAAA;AAAA;AAAA;AAK7B;;;AC1LA,IAAM,cAAgE;AAAA,EACpE,SAAS;AAAA,EACT,WAAW;AAAA,EACX,QAAQ;AACV;AAEA,IAAMC,OAAM;AACZ,IAAMC,QAAO;AAIb,IAAM,cAAc;AAGpB,SAAS,aAAa,MAAsB;AAC1C,SAAO,mBAAmBD,IAAG,0DAA0DE,WAAU,IAAI,CAAC;AACxG;AAIA,SAAS,aAAa,MAAc,aAAa,OAAe;AAC9D,SAAO,mBAAmBD,KAAI,4GAA4G,UAAU,KAAK,IAAI;AAC/J;AAGA,SAAS,SAAS,MAAc,aAAa,OAAe;AAC1D,SAAO,aAAaC,WAAU,IAAI,GAAG,UAAU;AACjD;AAIA,SAAS,YAAY,MAA4B;AAC/C,SAAO,GAAGA,WAAU,KAAK,eAAe,CAAC,QAAQA,WAAU,KAAK,mBAAmB,CAAC;AACtF;AAQO,SAAS,uBACd,MAC8C;AAC9C,SAAO;AAAA,IACL,SAAS,EAAE,aAAa,KAAK,iBAAiB,SAAS,KAAK,YAAY;AAAA,IACxE,cAAc,EAAE,aAAa,MAAM,SAAS,KAAK;AAAA,EACnD;AACF;AAWO,SAAS,sBAAsB,MAA0B;AAC9D,QAAM,OAAO,KAAK,QAAQ;AAC1B,QAAM,cAAc;AACpB,QAAM,MAAM,KAAK;AAEjB,QAAM,WAAW,QAAQ,OAAO,IAAI,gBAAgB,MAAM;AAC1D,QAAM,aAAa,QAAQ,OAAO,IAAI,YAAY,MAAM;AACxD,QAAM,mBAA6B,CAAC;AACpC,MAAI,KAAK,cAAc,YAAa,kBAAiB,KAAK,KAAK,yBAAyB;AACxF,MAAI,KAAK,cAAc,QAAS,kBAAiB,KAAK,KAAK,0BAA0B;AACrF,QAAM,UAAU,iBAAiB,SAAS;AAK1C,QAAM,QAAQ,CAAC,SAAS,SAAS;AACjC,MAAI,QAAQ;AACZ,QAAM,SAAS,MAAc,MAAM,UAAU,CAAC;AAC9C,QAAM,UAAU,OAAO;AACvB,QAAM,UAAU,WAAW,OAAO,IAAI;AACtC,QAAM,SAAS,aAAa,OAAO,IAAI;AACvC,QAAM,eAAe,OAAO;AAC5B,QAAM,cAAc,OAAO;AAC3B,QAAM,iBAAiB,UAAU,OAAO,IAAI;AAC5C,QAAM,YAAY,OAAO;AAKzB,QAAM,qBACJ,OAAO,IAAI,gBAAgB,SACvB;AAAA,oCAC4B,OAAO,kBAAkB,WAAW;AAAA;AAAA,UAE9D,aAAa,oBAAoB,CAAC;AAAA,UAClC,aAAa,GAAGA,WAAU,GAAG,KAAK,gBAAgB,eAAe,YAAY,IAAI,WAAW,CAAC,GAAG,CAAC,GAAG,IAAI,YAAY,SAAS,IAAI,YAAY,IAAI,CAAC,KAAK,EAAE,EAAE,CAAC;AAAA;AAAA,mBAEnJ,qBAAqB,KAAK,mBAAmB;AAAA,IAC1D,YAAY;AAAA,IACZ,mBAAmB;AAAA,EACrB,CAAC,CAAC,KACE;AAIN,QAAM,iBACJ,OAAO,IAAI,YAAY,SACnB;AAAA,oCAC4B,MAAM,kBAAkB,WAAW;AAAA;AAAA,UAE7D,aAAa,SAAS,CAAC;AAAA,UACvB,aAAa,GAAGA,WAAU,GAAG,KAAK,YAAY,uBAAuB,YAAY,IAAI,OAAO,CAAC,GAAG,CAAC,IAAI,YAAY,IAAI,CAAC,EAAE,CAAC;AAAA;AAAA,mBAEhH,qBAAqB,KAAK,kBAAkB;AAAA,IACzD,YAAY;AAAA,IACZ,mBAAmB;AAAA,EACrB,CAAC,CAAC,KACE;AAGN,QAAM,YAAY,iBAAiB;AAAA,IACjC,SAAS,KAAK;AAAA,IACd,UAAU,KAAK;AAAA,IACf,YAAY,KAAK;AAAA,IACjB,YAAY;AAAA,IACZ,KAAK;AAAA,IACL,WACE,KAAK,mBAAmB,SACpB,CAAC,0BAA0B,KAAK,cAAc,yBAAyB,IACvE,CAAC;AAAA,EACT,CAAC;AAID,QAAM,sBAAsB,UACxB;AAAA,oCAC8B,cAAc,kBAAkB,WAAW,qBAAqB,WAAW;AAAA;AAAA,UAErG,aAAa,qBAAqB,CAAC;AAAA,UACnC,iBAAiB,IAAI,CAAC,SAAS,SAAS,IAAI,CAAC,EAAE,KAAK,YAAY,CAAC;AAAA,UACjE,SAAS,KAAK,kBAAkB,MAAM,CAAC;AAAA;AAAA,qBAG3C;AAIJ,QAAM,cAAc,KAAK,QACtB;AAAA,IACC,CAAC,MAAM,MAAM;AAAA,iBACF,MAAM,IAAI,KAAK,UAAUD,KAAI,IAAI,6FAA6FC,WAAU,IAAI,CAAC;AAAA,EAC1J,EACC,KAAK,EAAE;AACV,QAAM,oBAAoB,KAAK,cAC5B;AAAA,IACC,CAAC,SAAS;AAAA,wBACQD,KAAI,oIAAoIC,WAAU,IAAI,CAAC;AAAA,EAC3K,EACC,KAAK,EAAE;AAEV,SAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAOSA,WAAU,WAAW,CAAC;AAAA,MAClC,iBAAiB,IAAI,CAAC;AAAA;AAAA;AAAA;AAAA,mBAIT,eAAe,IAAI,CAAC;AAAA;AAAA,oCAEH,OAAO,kBAAkB,WAAW,qBAAqB,WAAW;AAAA;AAAA,UAE9F,aAAa,KAAK,eAAe,CAAC;AAAA,0BAClBD,KAAI,+HAA+HC,WAAU,KAAK,QAAQ,CAAC;AAAA,UAC3K,SAAS,KAAK,YAAY,CAAC;AAAA;AAAA;AAAA,MAG/B,kBAAkB;AAAA,MAClB,cAAc;AAAA,MACd,wBAAwB,KAAK,YAAY,EAAE,YAAY,cAAc,KAAK,YAAY,CAAC,CAAC;AAAA,MACxF,SAAS;AAAA,MACT,mBAAmB;AAAA,oCACW,SAAS,kBAAkB,WAAW,qBAAqB,WAAW;AAAA;AAAA,0BAEhFF,IAAG;AAAA,UACnB,WAAW;AAAA;AAAA,0BAEKC,KAAI,iJAAgJ,oBAAI,KAAK,GAAE,eAAe,CAAC,IAAIC,WAAU,KAAK,SAAS,CAAC;AAAA,0BAC5MD,KAAI;AAAA,0BACJA,KAAI,oIAAoIC,WAAU,KAAK,SAAS,CAAC;AAAA,UACjL,iBAAiB;AAAA;AAAA;AAAA;AAAA;AAK3B;;;ACjNA,OAAO,eAAe;;;ACStB,IAAMC,OAAM;AACZ,IAAMC,QAAO;AAKN,SAAS,gBAAgB,MAA0B;AACxD,QAAM,OAAO,KAAK,QAAQ;AAC1B,QAAM,cAAc,GAAGC,WAAU,KAAK,QAAQ,CAAC;AAI/C,QAAM,YAAY,KAAK,iBACpB;AAAA,IACC,CAAC,SAAS;AAAA,wBACQD,KAAI,6IAAwIC,WAAU,IAAI,CAAC;AAAA,EAC/K,EACC,KAAK,EAAE;AACV,QAAM,cAAc,KAAK,QACtB;AAAA,IACC,CAAC,SAAS;AAAA,2GAC2FA,WAAU,IAAI,CAAC;AAAA,EACtH,EACC,KAAK,EAAE;AACV,QAAM,oBAAoB,KAAK,cAC5B;AAAA,IACC,CAAC,SAAS;AAAA,wBACQD,KAAI,oIAAoIC,WAAU,IAAI,CAAC;AAAA,EAC3K,EACC,KAAK,EAAE;AAEV,SAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAOS,WAAW;AAAA,MACvB,iBAAiB,IAAI,CAAC;AAAA;AAAA;AAAA;AAAA,mBAIT,eAAe,IAAI,CAAC;AAAA;AAAA;AAAA;AAAA,0BAIbF,IAAG,2DAA2DE,WAAU,KAAK,aAAa,CAAC;AAAA,0BAC3FF,IAAG,wCAAwC,QAAQ,KAAK,WAAW,CAAC;AAAA,0BACpEC,KAAI,kHAAkHC,WAAU,KAAK,UAAU,CAAC;AAAA,UAChK,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA,0BAKOF,IAAG;AAAA,UACnB,WAAW;AAAA;AAAA,0BAEKC,KAAI,iJAAgJ,oBAAI,KAAK,GAAE,eAAe,CAAC,IAAIC,WAAU,KAAK,SAAS,CAAC;AAAA,0BAC5MD,KAAI;AAAA,0BACJA,KAAI,oIAAoIC,WAAU,KAAK,SAAS,CAAC;AAAA,UACjL,iBAAiB;AAAA;AAAA;AAAA;AAAA;AAK3B;;;ADhEA,eAAsB,iBAAiB,MAAyC;AAC9E,QAAM,OACJ,KAAK,eAAe,WAChB,gBAAgB,IAAI,IACpB,KAAK,eAAe,iBAClB,sBAAsB,IAAI,IAC1B,UAAU,IAAI;AACtB,QAAM,MAAM,MAAM,UAAU,MAAM,EAAE,iBAAiB,SAAS,CAAC;AAC/D,SAAO,EAAE,MAAM,IAAI,MAAM,UAAU,IAAI,UAAU,CAAC,EAAE;AACtD;;;AEhBA,SAAS,cAAc,OAA4B;AAIjD,QAAM,QAAQ,MAAM,CAAC,MAAM,OAAQ,MAAM,CAAC,MAAM,OAAQ,MAAM,CAAC,MAAM,MAAO,IAAI;AAChF,QAAM,OAAO,OAAO,KAAK,MAAM,MAAM,OAAO,QAAQ,EAAE,CAAC,EACpD,SAAS,OAAO,EAChB,QAAQ,UAAU,EAAE,EACpB,YAAY;AACf,SAAO,KAAK,WAAW,gBAAgB,KAAK,KAAK,WAAW,OAAO,KAAK,KAAK,WAAW,OAAO;AACjG;AAEA,eAAsB,qBACpB,KACqD;AACrD,QAAM,MAAM,MAAM,MAAM,GAAG;AAC3B,MAAI,CAAC,IAAI,IAAI;AACX,UAAM,IAAI;AAAA,MACR,uCAAuC,IAAI,MAAM,IAAI,IAAI,UAAU,SAAS,GAAG;AAAA,IACjF;AAAA,EACF;AACA,QAAM,cAAc,IAAI,QAAQ,IAAI,cAAc,KAAK;AACvD,QAAM,KAAK,MAAM,IAAI,YAAY;AACjC,QAAM,QAAQ,IAAI,WAAW,EAAE;AAK/B,QAAM,cAAc,YAAY,YAAY,EAAE,WAAW,QAAQ;AACjE,MAAI,CAAC,eAAe,cAAc,KAAK,GAAG;AACxC,UAAM,IAAI;AAAA,MACR,gEAAgE,WAAW,gFACD,GAAG;AAAA,IAC/E;AAAA,EACF;AACA,SAAO,EAAE,OAAO,YAAY;AAC9B;AAaA,eAAsB,iBACpB,UACA,WACA,MACA,UACA,aACe;AACf,QAAM,SAAS,QAAQ,IAAI;AAC3B,QAAM,SAAS,QAAQ,IAAI;AAC3B,MAAI,CAAC,UAAU,CAAC,QAAQ;AACtB,UAAM,IAAI,MAAM,+CAA+C;AAAA,EACjE;AACA,QAAM,SACJ,OAAO,SAAS,WACZ,OAAO,KAAK,MAAM,OAAO,EAAE,SAAS,QAAQ,IAC5C,OAAO,KAAK,IAAI,EAAE,SAAS,QAAQ;AACzC,QAAM,UAAU,EAAE,aAAa,MAAM,QAAQ,SAAS;AACtD,QAAM,MAAM,mCAAmC,MAAM,IAAI,QAAQ,IAAI,mBAAmB,SAAS,CAAC;AAClG,QAAM,MAAM,MAAM,MAAM,KAAK;AAAA,IAC3B,QAAQ;AAAA,IACR,SAAS;AAAA,MACP,eAAe,UAAU,MAAM;AAAA,MAC/B,gBAAgB;AAAA,IAClB;AAAA,IACA,MAAM,KAAK,UAAU,OAAO;AAAA,EAC9B,CAAC;AACD,MAAI,CAAC,IAAI,IAAI;AACX,UAAM,IAAI,MAAM,2BAA2B,IAAI,MAAM,IAAI,IAAI,UAAU,IAAI,MAAM,IAAI,KAAK,CAAC,EAAE;AAAA,EAC/F;AACF;","names":["escapeXml","RED","GREY","escapeXml","RED","GREY","escapeXml"]}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  makeGitHub
3
- } from "./chunk-XB6T4ETK.js";
3
+ } from "./chunk-OSRFDGQM.js";
4
4
  import {
5
5
  templatesByName
6
6
  } from "./chunk-CASCWQQX.js";
@@ -159,4 +159,4 @@ async function selfUpdating(site, deps = {}) {
159
159
  export {
160
160
  selfUpdating
161
161
  };
162
- //# sourceMappingURL=chunk-D3LFQNJK.js.map
162
+ //# sourceMappingURL=chunk-3HM4S422.js.map
@@ -1,12 +1,15 @@
1
1
  import {
2
2
  hasRealScores,
3
3
  lighthouseScoresFromResult
4
- } from "./chunk-HPSGCDWY.js";
4
+ } from "./chunk-YYUY2OVP.js";
5
+ import {
6
+ detectAuditEvents
7
+ } from "./chunk-5L3FPQIJ.js";
5
8
  import {
6
9
  normalizeSecurityAdvisory,
7
10
  siteSlug,
8
11
  updateAuditFields
9
- } from "./chunk-L4ULXHIN.js";
12
+ } from "./chunk-BLD6AYJO.js";
10
13
 
11
14
  // src/audits/a11y-airtable.ts
12
15
  function hasA11yCounts(result) {
@@ -101,6 +104,25 @@ function browserFieldsFromAudit(result) {
101
104
  };
102
105
  }
103
106
 
107
+ // src/audits/netlify-deploy-airtable.ts
108
+ function hasNetlifyDeployResult(result) {
109
+ if (result.audit !== "netlify-deploy") return false;
110
+ const d = result.details;
111
+ return !!d && typeof d.checkedAt === "string";
112
+ }
113
+ function netlifyDeployResultFromAudit(result) {
114
+ if (result.audit !== "netlify-deploy") {
115
+ throw new Error(`Expected a 'netlify-deploy' AuditResult, got '${result.audit}'`);
116
+ }
117
+ const d = result.details;
118
+ return {
119
+ state: typeof d?.state === "string" ? d.state : null,
120
+ deployedAt: typeof d?.deployedAt === "string" ? d.deployedAt : null,
121
+ logUrl: typeof d?.logUrl === "string" ? d.logUrl : null,
122
+ checkedAt: typeof d?.checkedAt === "string" ? d.checkedAt : (/* @__PURE__ */ new Date()).toISOString()
123
+ };
124
+ }
125
+
104
126
  // src/audits/write-audits-to-airtable.ts
105
127
  async function writeAuditsToAirtable(args) {
106
128
  const { base, websites, slug, results } = args;
@@ -148,9 +170,23 @@ async function writeAuditsToAirtable(args) {
148
170
  audits.browser = fields;
149
171
  writes.push({ audit: "browser", counts: fields });
150
172
  }
173
+ const netlifyDeploy = results.find((r) => r.audit === "netlify-deploy");
174
+ if (netlifyDeploy && hasNetlifyDeployResult(netlifyDeploy)) {
175
+ const result = netlifyDeployResultFromAudit(netlifyDeploy);
176
+ audits.netlifyDeploy = result;
177
+ writes.push({ audit: "netlify-deploy", counts: result });
178
+ }
151
179
  if (Object.keys(audits).length > 0) {
152
180
  await updateAuditFields(base, target.id, audits);
153
181
  }
182
+ const events = detectAuditEvents(
183
+ target,
184
+ {
185
+ ...audits.security !== void 0 ? { security: audits.security } : {},
186
+ ...audits.domain !== void 0 ? { domain: audits.domain } : {}
187
+ },
188
+ (/* @__PURE__ */ new Date()).toISOString()
189
+ );
154
190
  if (lhResult && !lhHasScores) {
155
191
  const persisted = writes.map((w) => w.audit);
156
192
  throw Object.assign(
@@ -160,7 +196,7 @@ async function writeAuditsToAirtable(args) {
160
196
  { exitCode: 1 }
161
197
  );
162
198
  }
163
- return { siteName: target.name, writes };
199
+ return { siteName: target.name, writes, events };
164
200
  }
165
201
  function formatFleetWriteSummary(result) {
166
202
  const wrote = result.written.length;
@@ -200,4 +236,4 @@ export {
200
236
  formatFleetWriteSummary,
201
237
  writeFleetAuditsToAirtable
202
238
  };
203
- //# sourceMappingURL=chunk-LMXIZITU.js.map
239
+ //# sourceMappingURL=chunk-4REUHZAW.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/audits/a11y-airtable.ts","../src/audits/deps-airtable.ts","../src/audits/security-airtable.ts","../src/audits/domain-airtable.ts","../src/audits/browser-airtable.ts","../src/audits/netlify-deploy-airtable.ts","../src/audits/write-audits-to-airtable.ts"],"sourcesContent":["import type { AuditResult } from \"../types.js\";\n\ntype A11yDetails = {\n totalViolations: number;\n byImpact: Partial<Record<\"minor\" | \"moderate\" | \"serious\" | \"critical\", number>>;\n};\n\n/** True when an a11y AuditResult carries real counts worth persisting.\n * Mirrors the `hasRealScores` policy on lighthouse: write whenever real\n * data exists, regardless of status (a \"warn\" or \"fail\" with concrete\n * violation counts is exactly what the dashboard needs to track). */\nexport function hasA11yCounts(result: AuditResult): boolean {\n if (result.audit !== \"a11y\") return false;\n const details = result.details as A11yDetails | undefined;\n return typeof details?.totalViolations === \"number\";\n}\n\nexport function a11yCountsFromResult(result: AuditResult): { violations: number } {\n if (result.audit !== \"a11y\") {\n throw new Error(`Expected an 'a11y' AuditResult, got '${result.audit}'`);\n }\n const details = result.details as A11yDetails | undefined;\n return { violations: details?.totalViolations ?? 0 };\n}\n","import type { AuditResult } from \"../types.js\";\nimport type { DepsDetails, DepsDriftEntry } from \"./deps.js\";\n\n/** True when a deps AuditResult carries the structured details (an entries\n * array, per {@link DepsDetails}). */\nexport function hasDepsCounts(result: AuditResult): boolean {\n if (result.audit !== \"deps\") return false;\n const d = result.details as Partial<DepsDetails> | undefined;\n return Array.isArray(d?.entries);\n}\n\nexport function depsCountsFromResult(result: AuditResult): {\n drifted: number;\n majorBehind: number;\n outdated: number | null;\n} {\n if (result.audit !== \"deps\") {\n throw new Error(`Expected a 'deps' AuditResult, got '${result.audit}'`);\n }\n const details = (result.details ?? {}) as Partial<DepsDetails>;\n const entries = (details.entries ?? []) as DepsDriftEntry[];\n // \"drifted\" = drift !== \"same\", which intentionally includes \"newer\" (ahead of\n // baseline) — parity with the deps audit summary text.\n const drifted = entries.filter((e) => e.drift !== \"same\").length;\n const majorBehind = entries.filter((e) => e.drift === \"major\").length;\n // The real installed-version drift, distinct from declared-range drift. Null\n // when the audit couldn't determine it (no/stale lockfile) — kept null (not 0)\n // so the dashboard shows \"—\" rather than a misleading \"clean\".\n const outdated = details.outdated?.outdated ?? null;\n return { drifted, majorBehind, outdated };\n}\n","import type { AuditResult } from \"../types.js\";\nimport { normalizeSecurityAdvisory, type SecurityAdvisory } from \"../reports/airtable/websites.js\";\n\ntype SecurityDetails = {\n counts: { low: number; moderate: number; high: number; critical: number };\n advisories?: unknown;\n};\n\n/** True when a security AuditResult carries a counts object. Skipped runs\n * (no pnpm + no npm) have no details and return false. */\nexport function hasSecurityCounts(result: AuditResult): boolean {\n if (result.audit !== \"security\") return false;\n const details = result.details as SecurityDetails | undefined;\n return !!details && typeof details.counts === \"object\";\n}\n\nexport function securityCountsFromResult(result: AuditResult): {\n critical: number;\n high: number;\n moderate: number;\n low: number;\n} {\n if (result.audit !== \"security\") {\n throw new Error(`Expected a 'security' AuditResult, got '${result.audit}'`);\n }\n const details = result.details as SecurityDetails | undefined;\n const c = details?.counts ?? { low: 0, moderate: 0, high: 0, critical: 0 };\n return { critical: c.critical, high: c.high, moderate: c.moderate, low: c.low };\n}\n\n/** The known advisories behind the counts, normalized for persistence. Empty when the\n * run was clean or carried no advisory detail (npm/pnpm sometimes omit it). Malformed\n * entries are dropped via the shared `normalizeSecurityAdvisory`. */\nexport function advisoriesFromResult(result: AuditResult): SecurityAdvisory[] {\n if (result.audit !== \"security\") {\n throw new Error(`Expected a 'security' AuditResult, got '${result.audit}'`);\n }\n const details = result.details as SecurityDetails | undefined;\n const raw = details?.advisories;\n if (!Array.isArray(raw)) return [];\n return raw.map(normalizeSecurityAdvisory).filter((a): a is SecurityAdvisory => a !== null);\n}\n","import type { AuditResult } from \"../types.js\";\nimport type { DomainResult } from \"../reports/airtable/websites.js\";\n\ntype DomainDetails = { resolved: boolean; certDaysRemaining: number | null; checkedAt: string };\n\n/** True when an AuditResult is a `domain` audit carrying a usable details payload (i.e. it\n * actually ran — a \"skip\" for a site with no deployed URL has no details). */\nexport function hasDomainResult(result: AuditResult): boolean {\n if (result.audit !== \"domain\") return false;\n const d = result.details as DomainDetails | undefined;\n return !!d && typeof d.checkedAt === \"string\";\n}\n\n/** Extract the Airtable-writable domain result (cert days + checked-at) from a `domain` audit. */\nexport function domainResultFromAudit(result: AuditResult): DomainResult {\n if (result.audit !== \"domain\") {\n throw new Error(`Expected a 'domain' AuditResult, got '${result.audit}'`);\n }\n const d = result.details as DomainDetails | undefined;\n return {\n certDaysRemaining: typeof d?.certDaysRemaining === \"number\" ? d.certDaysRemaining : null,\n checkedAt: typeof d?.checkedAt === \"string\" ? d.checkedAt : new Date().toISOString(),\n };\n}\n","import type { AuditResult } from \"../types.js\";\nimport type { BrowserAuditFields } from \"../reports/airtable/websites.js\";\n\ntype BrowserDetails = {\n desktopOk: boolean;\n mobileOk: boolean;\n linksOk: boolean;\n brokenLinks: number;\n checkedAt: string;\n};\n\n/** True when an AuditResult is a `browser` audit that actually ran (has a details payload — a\n * \"skip\" for a site with no deployed URL has none). */\nexport function hasBrowserResult(result: AuditResult): boolean {\n if (result.audit !== \"browser\") return false;\n const d = result.details as BrowserDetails | undefined;\n return !!d && typeof d.checkedAt === \"string\";\n}\n\n/** Extract the Airtable-writable browser verdicts from a `browser` audit. */\nexport function browserFieldsFromAudit(result: AuditResult): BrowserAuditFields {\n if (result.audit !== \"browser\") {\n throw new Error(`Expected a 'browser' AuditResult, got '${result.audit}'`);\n }\n const d = result.details as BrowserDetails | undefined;\n return {\n desktopOk: d?.desktopOk === true,\n mobileOk: d?.mobileOk === true,\n linksOk: d?.linksOk === true,\n brokenLinks: typeof d?.brokenLinks === \"number\" ? d.brokenLinks : 0,\n checkedAt: typeof d?.checkedAt === \"string\" ? d.checkedAt : new Date().toISOString(),\n };\n}\n","import type { AuditResult } from \"../types.js\";\nimport type { NetlifyDeployResult } from \"../reports/airtable/websites.js\";\n\ntype NetlifyDeployDetails = {\n state: string | null;\n deployedAt: string | null;\n logUrl: string | null;\n checkedAt: string;\n};\n\n/** True when an AuditResult is a `netlify-deploy` audit carrying a usable details\n * payload (i.e. it actually ran — a \"skip\" for a site with no Netlify id / no\n * token has no details). */\nexport function hasNetlifyDeployResult(result: AuditResult): boolean {\n if (result.audit !== \"netlify-deploy\") return false;\n const d = result.details as NetlifyDeployDetails | undefined;\n return !!d && typeof d.checkedAt === \"string\";\n}\n\n/** Extract the Airtable-writable deploy result (state + deployedAt + logUrl +\n * checkedAt) from a `netlify-deploy` audit. */\nexport function netlifyDeployResultFromAudit(result: AuditResult): NetlifyDeployResult {\n if (result.audit !== \"netlify-deploy\") {\n throw new Error(`Expected a 'netlify-deploy' AuditResult, got '${result.audit}'`);\n }\n const d = result.details as NetlifyDeployDetails | undefined;\n return {\n state: typeof d?.state === \"string\" ? d.state : null,\n deployedAt: typeof d?.deployedAt === \"string\" ? d.deployedAt : null,\n logUrl: typeof d?.logUrl === \"string\" ? d.logUrl : null,\n checkedAt: typeof d?.checkedAt === \"string\" ? d.checkedAt : new Date().toISOString(),\n };\n}\n","import type { AuditResult } from \"../types.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport { type WebsiteRow, siteSlug, updateAuditFields } from \"../reports/airtable/websites.js\";\nimport type {\n A11yCounts,\n DepsCounts,\n SecurityCounts,\n SecurityAdvisory,\n DomainResult,\n BrowserAuditFields,\n NetlifyDeployResult,\n} from \"../reports/airtable/websites.js\";\nimport type { LighthouseScoreWriteback } from \"../reports/types.js\";\nimport { hasRealScores, lighthouseScoresFromResult } from \"./lighthouse-airtable.js\";\nimport { hasA11yCounts, a11yCountsFromResult } from \"./a11y-airtable.js\";\nimport { hasDepsCounts, depsCountsFromResult } from \"./deps-airtable.js\";\nimport {\n hasSecurityCounts,\n securityCountsFromResult,\n advisoriesFromResult,\n} from \"./security-airtable.js\";\nimport { hasDomainResult, domainResultFromAudit } from \"./domain-airtable.js\";\nimport { hasBrowserResult, browserFieldsFromAudit } from \"./browser-airtable.js\";\nimport { hasNetlifyDeployResult, netlifyDeployResultFromAudit } from \"./netlify-deploy-airtable.js\";\nimport { detectAuditEvents } from \"./fleet-event-detectors.js\";\nimport type { FleetEvent } from \"../db/fleet-events.js\";\n\ntype WriteSummary = {\n siteName: string;\n writes: Array<{\n audit:\n | \"lighthouse\"\n | \"a11y\"\n | \"deps\"\n | \"security\"\n | \"github-signals\"\n | \"domain\"\n | \"browser\"\n | \"netlify-deploy\";\n counts: object;\n }>;\n /** Fleet-activity events detected from this site's prior row vs the fresh audits.\n * Optional: only the fleet path records them; the single-site path ignores them. */\n events?: FleetEvent[];\n};\n\n/** Orchestrates the per-audit Airtable writes for `audit --write-airtable`.\n * Extracted from the CLI command so it can be unit-tested with a fake base\n * and so adding new audit types is a one-line addition here rather than\n * growing the CLI handler.\n *\n * Throws (with .exitCode set) on the failure modes the CLI surfaces today:\n * - 2: --only ran without lighthouse, or no Websites row matched the slug\n * - 1: lighthouse ran but produced no real scores (infrastructure failure).\n * The a11y/deps/security writes still complete FIRST — a Lighthouse\n * miss flags the site without discarding its other audit data.\n *\n * Precedence note: the Websites-row lookup (exitCode 2) is checked BEFORE the\n * no-scores gate (exitCode 1), so the rare no-row + no-scores combo surfaces\n * as exitCode 2. */\nexport async function writeAuditsToAirtable(args: {\n base: AirtableBase;\n websites: WebsiteRow[];\n slug: string;\n results: AuditResult[];\n}): Promise<WriteSummary> {\n const { base, websites, slug, results } = args;\n\n // Lighthouse is OPTIONAL. The checkout-free `--only lighthouse,domain,browser` nightly includes\n // it, but a standalone `--only security` (checkout-ful) sweep legitimately has none. We write\n // whatever audits produced values; only the lighthouse-MISS flag below is lighthouse-specific\n // (so a real Lighthouse infra failure still reds the run without discarding other audit data).\n const lhResult = results.find((r) => r.audit === \"lighthouse\");\n const target = websites.find((w) => siteSlug(w.name) === slug);\n if (!target) {\n throw Object.assign(new Error(`No Websites row matched slug \"${slug}\"`), { exitCode: 2 });\n }\n\n const writes: WriteSummary[\"writes\"] = [];\n const audits: {\n scores?: LighthouseScoreWriteback;\n a11y?: A11yCounts;\n deps?: DepsCounts;\n security?: SecurityCounts;\n securityAdvisories?: SecurityAdvisory[];\n domain?: DomainResult;\n browser?: BrowserAuditFields;\n netlifyDeploy?: NetlifyDeployResult;\n } = {};\n\n // Collect every audit that produced real values into ONE merged input, then do a\n // SINGLE atomic Airtable update (was: up to four sequential updates on the same\n // row — a mid-sequence failure left it half-written yet reported fully failed, at\n // 4× the request volume). Lighthouse is the most timeout-prone audit: a Lighthouse\n // miss must NOT discard the site's valid a11y/deps/security results (morning-brief\n // 2026-06-10 MEDIUM-E). So include Lighthouse scores only when real, include the\n // other audits whenever present, write all of them in one update, then — if\n // Lighthouse missed — throw AFTER that atomic write so the site is still flagged\n // (exitCode 1 / collected in FleetWriteResult.failed) without losing its other data.\n const lhHasScores = lhResult ? hasRealScores(lhResult) : false;\n if (lhResult && lhHasScores) {\n const scores = lighthouseScoresFromResult(lhResult);\n audits.scores = scores;\n writes.push({ audit: \"lighthouse\", counts: scores });\n }\n\n const a11y = results.find((r) => r.audit === \"a11y\");\n if (a11y && hasA11yCounts(a11y)) {\n const counts = a11yCountsFromResult(a11y);\n audits.a11y = counts;\n writes.push({ audit: \"a11y\", counts });\n }\n\n const deps = results.find((r) => r.audit === \"deps\");\n if (deps && hasDepsCounts(deps)) {\n const counts = depsCountsFromResult(deps);\n audits.deps = counts;\n writes.push({ audit: \"deps\", counts });\n }\n\n const sec = results.find((r) => r.audit === \"security\");\n if (sec && hasSecurityCounts(sec)) {\n const counts = securityCountsFromResult(sec);\n audits.security = counts;\n // Persist the advisory list alongside the counts so the dashboard can show which packages\n // are vulnerable. An empty array (clean run) clears any stale list.\n audits.securityAdvisories = advisoriesFromResult(sec);\n writes.push({ audit: \"security\", counts });\n }\n\n const dom = results.find((r) => r.audit === \"domain\");\n if (dom && hasDomainResult(dom)) {\n const result = domainResultFromAudit(dom);\n audits.domain = result;\n writes.push({ audit: \"domain\", counts: result });\n }\n\n const browser = results.find((r) => r.audit === \"browser\");\n if (browser && hasBrowserResult(browser)) {\n const fields = browserFieldsFromAudit(browser);\n audits.browser = fields;\n writes.push({ audit: \"browser\", counts: fields });\n }\n\n const netlifyDeploy = results.find((r) => r.audit === \"netlify-deploy\");\n if (netlifyDeploy && hasNetlifyDeployResult(netlifyDeploy)) {\n const result = netlifyDeployResultFromAudit(netlifyDeploy);\n audits.netlifyDeploy = result;\n writes.push({ audit: \"netlify-deploy\", counts: result });\n }\n\n // One atomic write of everything that ran. Skip the call only if there is nothing\n // to write at all (no real scores AND no other audit produced values) — an empty\n // update is a wasted request.\n if (Object.keys(audits).length > 0) {\n await updateAuditFields(base, target.id, audits);\n }\n\n // Detect fleet-activity transitions from the prior row (`target`, loaded before this\n // write) vs the fresh audits. Computed here where both are in hand; recorded by the\n // caller (fleet path) — single-site callers simply ignore `events`.\n const events = detectAuditEvents(\n target,\n {\n ...(audits.security !== undefined ? { security: audits.security } : {}),\n ...(audits.domain !== undefined ? { domain: audits.domain } : {}),\n },\n new Date().toISOString(),\n );\n\n // Lighthouse-miss flag: only when lighthouse WAS requested (in results) but produced no scores —\n // an infra failure worth reding the run, AFTER persisting the other audits. A sweep that never\n // ran lighthouse (e.g. `--only security`) skips this entirely.\n if (lhResult && !lhHasScores) {\n // Enumerate what WAS persisted so the failure (surfaced to the single-site\n // CLI operator via console.error) reads as a partial write, not a total one.\n const persisted = writes.map((w) => w.audit);\n throw Object.assign(\n new Error(\n `Lighthouse audit produced no scores; ${\n persisted.length ? `wrote ${persisted.join(\"/\")} but refused Lighthouse` : \"wrote nothing\"\n }. Summary: ${lhResult.summary}`,\n ),\n { exitCode: 1 },\n );\n }\n\n return { siteName: target.name, writes, events };\n}\n\nexport type FleetWriteResult = {\n written: WriteSummary[];\n failed: Array<{ slug: string; error: string }>;\n};\n\n/** Render the fleet write-back outcome for the CLI/CI. Beyond the human-readable\n * lines, it emits a single deterministic, machine-parseable line —\n * `FLEET_WRITE_SUMMARY wrote=N failed=M total=T` — that the nightly workflow\n * greps to decide pass/fail. Keying CI on this line (not the prose, and not a\n * \"wrote ≥ 1\" heuristic) lets the gate tolerate a single known flake while\n * still reding on a total or mass write-back failure. */\nexport function formatFleetWriteSummary(result: FleetWriteResult): string {\n const wrote = result.written.length;\n const failed = result.failed.length;\n const total = wrote + failed;\n let out = `→ wrote ${wrote} site(s) to Airtable`;\n if (failed > 0) {\n out += `\\n⚠ ${failed} site(s) not written: ${result.failed\n .map((f) => `${f.slug} (${f.error})`)\n .join(\"; \")}`;\n }\n out += `\\nFLEET_WRITE_SUMMARY wrote=${wrote} failed=${failed} total=${total}`;\n return out;\n}\n\n/** Write each site's pooled audit results back to its own Websites row,\n * best-effort. Results are grouped by `result.site` (the slug the fleet\n * inventory stamped as Site.name). A per-site failure (no scores, no matching\n * row) is collected — not thrown — so one bad site never aborts the batch. */\nexport async function writeFleetAuditsToAirtable(args: {\n base: AirtableBase;\n websites: WebsiteRow[];\n results: AuditResult[];\n}): Promise<FleetWriteResult> {\n const { base, websites, results } = args;\n\n const bySlug = new Map<string, AuditResult[]>();\n for (const r of results) {\n const arr = bySlug.get(r.site) ?? [];\n arr.push(r);\n bySlug.set(r.site, arr);\n }\n\n const written: WriteSummary[] = [];\n const failed: FleetWriteResult[\"failed\"] = [];\n // Serial on purpose: even at one (now atomic) update call per site, Airtable's\n // ~5 req/sec limit means a Promise.all fan-out across the fleet would burst and\n // trip 429s (silently filed as failures). Below a few dozen sites, serial trades\n // wall-clock for safety. (morning-brief 2026-06-09 MEDIUM-3.) Add a bounded pool\n // when the fleet grows.\n for (const [slug, siteResults] of bySlug) {\n try {\n written.push(await writeAuditsToAirtable({ base, websites, slug, results: siteResults }));\n } catch (e) {\n failed.push({ slug, error: (e as Error).message });\n }\n }\n return { written, failed };\n}\n"],"mappings":"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","names":[]}