@reddoorla/maintenance 0.86.0 → 0.87.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/airtable-ZBUPQJSC.js +9 -0
- package/dist/{announce-MXTUQ6C7.js → announce-I2Z4JN3I.js} +49 -28
- package/dist/announce-I2Z4JN3I.js.map +1 -0
- package/dist/attachments-NLZIJ5NU.js +9 -0
- package/dist/{bump-deps-EC74CPLS.js → bump-deps-CGVELRLK.js} +2 -2
- package/dist/{chunk-2ZPT7GLV.js → chunk-3JQ2FO3D.js} +3 -3
- package/dist/chunk-3JQ2FO3D.js.map +1 -0
- package/dist/{chunk-KTH5ZODA.js → chunk-7BZUOZPU.js} +2 -2
- package/dist/{chunk-LDWZSLSU.js → chunk-7NYN2P6I.js} +7 -4
- package/dist/{chunk-LDWZSLSU.js.map → chunk-7NYN2P6I.js.map} +1 -1
- package/dist/{chunk-3VQWSE3F.js → chunk-7UAYMAKR.js} +4 -4
- package/dist/{chunk-BYK66EWO.js → chunk-BBUSGEES.js} +52 -46
- package/dist/chunk-BBUSGEES.js.map +1 -0
- package/dist/{chunk-SLUQ7R6I.js → chunk-C6CLRKPV.js} +5 -5
- package/dist/{chunk-P2FE5AI6.js → chunk-CROKUHJB.js} +2 -2
- package/dist/{chunk-W7Z3BGCI.js → chunk-DEWJMMG3.js} +2 -2
- package/dist/{chunk-ACBRLLFT.js → chunk-F3JWOPTG.js} +7 -3
- package/dist/chunk-F3JWOPTG.js.map +1 -0
- package/dist/{chunk-TERE5SDF.js → chunk-HEWSRXYV.js} +4 -4
- package/dist/chunk-HHMMUR4P.js +335 -0
- package/dist/chunk-HHMMUR4P.js.map +1 -0
- package/dist/{chunk-X3B723J4.js → chunk-HSI6Q7MI.js} +38 -9
- package/dist/chunk-HSI6Q7MI.js.map +1 -0
- package/dist/{chunk-Z7AXYKQB.js → chunk-IY6256GK.js} +1 -1
- package/dist/{chunk-CQR4LIM7.js → chunk-MT2JINSS.js} +5 -1
- package/dist/{chunk-CQR4LIM7.js.map → chunk-MT2JINSS.js.map} +1 -1
- package/dist/{chunk-F5KM4LEQ.js → chunk-NCMZMLY7.js} +3 -3
- package/dist/{chunk-2NDRHXMR.js → chunk-NDWYJLRF.js} +17 -140
- package/dist/chunk-NDWYJLRF.js.map +1 -0
- package/dist/{chunk-PPE5KZJK.js → chunk-NXNPDCF3.js} +3 -3
- package/dist/{chunk-ITIOFN7A.js → chunk-P6AORG32.js} +29 -10
- package/dist/chunk-P6AORG32.js.map +1 -0
- package/dist/{chunk-N45M3FDK.js → chunk-SDDPUAWH.js} +4 -4
- package/dist/{chunk-ZKXSX4LO.js → chunk-TD2NNXLZ.js} +9 -9
- package/dist/{chunk-ILUNZHYV.js → chunk-YFAXOD7Z.js} +10 -6
- package/dist/chunk-YFAXOD7Z.js.map +1 -0
- package/dist/{chunk-2EVEFOZY.js → chunk-ZBDZTJUO.js} +2 -2
- package/dist/chunk-ZGBEIWSC.js +146 -0
- package/dist/chunk-ZGBEIWSC.js.map +1 -0
- package/dist/cli/bin.js +32 -27
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.js +15 -11
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/{convert-to-pnpm-6L6P74WR.js → convert-to-pnpm-QFJOEP5V.js} +2 -2
- package/dist/{db-B35OGV3G.js → db-UXNIZ2RG.js} +6 -6
- package/dist/{digest-BL6UJYKH.js → digest-FW6QMZZJ.js} +17 -16
- package/dist/{digest-BL6UJYKH.js.map → digest-FW6QMZZJ.js.map} +1 -1
- package/dist/{email-VTTTKKL5.js → email-KJXASVH5.js} +3 -3
- package/dist/{ensure-site-DFTB7MN3.js → ensure-site-4DCEBAPM.js} +19 -11
- package/dist/ensure-site-4DCEBAPM.js.map +1 -0
- package/dist/fleet-state-7TJ2GYT2.js +39 -0
- package/dist/{forms-notify-target-75U6OU7L.js → forms-notify-target-2EAWCWBW.js} +7 -4
- package/dist/forms-notify-target-2EAWCWBW.js.map +1 -0
- package/dist/{github-signals-IJUPGCUX.js → github-signals-E2WPYTBK.js} +9 -9
- package/dist/{header-image-AWLLQA4L.js → header-image-6IC53W27.js} +7 -7
- package/dist/{header-images-335L2AKB.js → header-images-SQBCE5OD.js} +21 -1
- package/dist/header-images-SQBCE5OD.js.map +1 -0
- package/dist/{health-endpoint-WJC2UMYS.js → health-endpoint-RFXL2F4A.js} +2 -2
- package/dist/health-mirror-L5JBEJVA.js +41 -0
- package/dist/health-mirror-L5JBEJVA.js.map +1 -0
- package/dist/{import-airtable-HE43FB4H.js → import-airtable-OHVY344G.js} +7 -5
- package/dist/index.d.ts +149 -0
- package/dist/index.js +147 -50
- package/dist/index.js.map +1 -1
- package/dist/{init-NJEOEO7I.js → init-XRAQZYID.js} +9 -9
- package/dist/{launch-XNAX427U.js → launch-IX4Y5JJC.js} +55 -26
- package/dist/launch-IX4Y5JJC.js.map +1 -0
- package/dist/{lighthouse-airtable-LBVIYYP5.js → lighthouse-airtable-3GUWHETW.js} +3 -3
- package/dist/{onboard-GK4PHPNI.js → onboard-O4IAHJMO.js} +2 -2
- package/dist/{orchestrate-77UO4BYJ.js → orchestrate-YLWQ6KK2.js} +10 -9
- package/dist/{parity-MWS24A3H.js → parity-56KA2DGR.js} +4 -4
- package/dist/{pipeline-S6CVHDGB.js → pipeline-3AANZFCR.js} +2 -2
- package/dist/{preflight-6WBNU2GZ.js → preflight-TNORKGCX.js} +11 -10
- package/dist/{preflight-6WBNU2GZ.js.map → preflight-TNORKGCX.js.map} +1 -1
- package/dist/{prismic-ci-3RHOZUUT.js → prismic-ci-G2AK53VL.js} +5 -5
- package/dist/{prismic-models-QBGUT2Q3.js → prismic-models-O6Z3OK3A.js} +13 -8
- package/dist/prismic-models-O6Z3OK3A.js.map +1 -0
- package/dist/prospect/types.d.ts +267 -0
- package/dist/prospect/types.js +1 -0
- package/dist/{prospect-audit-IUNCJ2LV.js → prospect-audit-KSXUNQ5J.js} +4 -4
- package/dist/recipes/sync-configs.js +2 -2
- package/dist/{render-AQAVQDPZ.js → render-IFOGFB5T.js} +3 -3
- package/dist/render-IFOGFB5T.js.map +1 -0
- package/dist/render-from-row-BWSOIYDS.js +17 -0
- package/dist/render-from-row-BWSOIYDS.js.map +1 -0
- package/dist/{renovate-dispatch-QJY75ODR.js → renovate-dispatch-6GQWTRKX.js} +10 -10
- package/dist/{renovate-dispatch-QJY75ODR.js.map → renovate-dispatch-6GQWTRKX.js.map} +1 -1
- package/dist/{report-4EVNDHOZ.js → report-R3E3Y7HR.js} +63 -21
- package/dist/report-R3E3Y7HR.js.map +1 -0
- package/dist/report-mirror-4MNORYQ4.js +49 -0
- package/dist/report-mirror-4MNORYQ4.js.map +1 -0
- package/dist/rerender-3FM6DNRH.js +32 -0
- package/dist/rerender-3FM6DNRH.js.map +1 -0
- package/dist/{self-updating-6DWI2Z56.js → self-updating-UCIWESAX.js} +2 -2
- package/dist/{selftest-RC36MTKP.js → selftest-VGHWPN72.js} +22 -20
- package/dist/{selftest-RC36MTKP.js.map → selftest-VGHWPN72.js.map} +1 -1
- package/dist/site-mirror-NIREX5ME.js +52 -0
- package/dist/site-mirror-NIREX5ME.js.map +1 -0
- package/dist/{smoke-suite-VYY2LXM2.js → smoke-suite-U3UKD3NS.js} +2 -2
- package/dist/{submissions-Q7DRFOCD.js → submissions-IQEJX6FK.js} +2 -2
- package/dist/{svelte-codemods-25J2FO22.js → svelte-codemods-GOKXTBHW.js} +2 -2
- package/dist/{sync-34HNZZ56.js → sync-CTUVXMCK.js} +4 -4
- package/dist/{sync-configs-3SE6PSCO.js → sync-configs-SPTLFJCC.js} +4 -4
- package/dist/{upgrade-WBRQ62FX.js → upgrade-W6ZR3FJH.js} +2 -2
- package/dist/{webflow-IRKOIT7S.js → webflow-624FEBFD.js} +4 -4
- package/dist/{websites-SXWTP72S.js → websites-D6WYFAES.js} +2 -2
- package/dist/websites-D6WYFAES.js.map +1 -0
- package/dist/{write-audits-to-airtable-MBEILLPU.js → write-audits-to-airtable-CMKHGJNQ.js} +4 -4
- package/dist/write-audits-to-airtable-CMKHGJNQ.js.map +1 -0
- package/package.json +5 -1
- package/dist/airtable-G7WBQQMI.js +0 -9
- package/dist/announce-MXTUQ6C7.js.map +0 -1
- package/dist/chunk-2NDRHXMR.js.map +0 -1
- package/dist/chunk-2ZPT7GLV.js.map +0 -1
- package/dist/chunk-ACBRLLFT.js.map +0 -1
- package/dist/chunk-BYK66EWO.js.map +0 -1
- package/dist/chunk-ILUNZHYV.js.map +0 -1
- package/dist/chunk-ITIOFN7A.js.map +0 -1
- package/dist/chunk-X3B723J4.js.map +0 -1
- package/dist/ensure-site-DFTB7MN3.js.map +0 -1
- package/dist/forms-notify-target-75U6OU7L.js.map +0 -1
- package/dist/header-images-335L2AKB.js.map +0 -1
- package/dist/health-mirror-4B2L7T2Y.js +0 -75
- package/dist/health-mirror-4B2L7T2Y.js.map +0 -1
- package/dist/launch-XNAX427U.js.map +0 -1
- package/dist/prismic-models-QBGUT2Q3.js.map +0 -1
- package/dist/report-4EVNDHOZ.js.map +0 -1
- /package/dist/{airtable-G7WBQQMI.js.map → airtable-ZBUPQJSC.js.map} +0 -0
- /package/dist/{import-airtable-HE43FB4H.js.map → attachments-NLZIJ5NU.js.map} +0 -0
- /package/dist/{bump-deps-EC74CPLS.js.map → bump-deps-CGVELRLK.js.map} +0 -0
- /package/dist/{chunk-KTH5ZODA.js.map → chunk-7BZUOZPU.js.map} +0 -0
- /package/dist/{chunk-3VQWSE3F.js.map → chunk-7UAYMAKR.js.map} +0 -0
- /package/dist/{chunk-SLUQ7R6I.js.map → chunk-C6CLRKPV.js.map} +0 -0
- /package/dist/{chunk-P2FE5AI6.js.map → chunk-CROKUHJB.js.map} +0 -0
- /package/dist/{chunk-W7Z3BGCI.js.map → chunk-DEWJMMG3.js.map} +0 -0
- /package/dist/{chunk-TERE5SDF.js.map → chunk-HEWSRXYV.js.map} +0 -0
- /package/dist/{chunk-Z7AXYKQB.js.map → chunk-IY6256GK.js.map} +0 -0
- /package/dist/{chunk-F5KM4LEQ.js.map → chunk-NCMZMLY7.js.map} +0 -0
- /package/dist/{chunk-PPE5KZJK.js.map → chunk-NXNPDCF3.js.map} +0 -0
- /package/dist/{chunk-N45M3FDK.js.map → chunk-SDDPUAWH.js.map} +0 -0
- /package/dist/{chunk-ZKXSX4LO.js.map → chunk-TD2NNXLZ.js.map} +0 -0
- /package/dist/{chunk-2EVEFOZY.js.map → chunk-ZBDZTJUO.js.map} +0 -0
- /package/dist/{convert-to-pnpm-6L6P74WR.js.map → convert-to-pnpm-QFJOEP5V.js.map} +0 -0
- /package/dist/{db-B35OGV3G.js.map → db-UXNIZ2RG.js.map} +0 -0
- /package/dist/{email-VTTTKKL5.js.map → email-KJXASVH5.js.map} +0 -0
- /package/dist/{lighthouse-airtable-LBVIYYP5.js.map → fleet-state-7TJ2GYT2.js.map} +0 -0
- /package/dist/{github-signals-IJUPGCUX.js.map → github-signals-E2WPYTBK.js.map} +0 -0
- /package/dist/{header-image-AWLLQA4L.js.map → header-image-6IC53W27.js.map} +0 -0
- /package/dist/{health-endpoint-WJC2UMYS.js.map → health-endpoint-RFXL2F4A.js.map} +0 -0
- /package/dist/{orchestrate-77UO4BYJ.js.map → import-airtable-OHVY344G.js.map} +0 -0
- /package/dist/{init-NJEOEO7I.js.map → init-XRAQZYID.js.map} +0 -0
- /package/dist/{parity-MWS24A3H.js.map → lighthouse-airtable-3GUWHETW.js.map} +0 -0
- /package/dist/{onboard-GK4PHPNI.js.map → onboard-O4IAHJMO.js.map} +0 -0
- /package/dist/{pipeline-S6CVHDGB.js.map → orchestrate-YLWQ6KK2.js.map} +0 -0
- /package/dist/{render-AQAVQDPZ.js.map → parity-56KA2DGR.js.map} +0 -0
- /package/dist/{websites-SXWTP72S.js.map → pipeline-3AANZFCR.js.map} +0 -0
- /package/dist/{prismic-ci-3RHOZUUT.js.map → prismic-ci-G2AK53VL.js.map} +0 -0
- /package/dist/{write-audits-to-airtable-MBEILLPU.js.map → prospect/types.js.map} +0 -0
- /package/dist/{prospect-audit-IUNCJ2LV.js.map → prospect-audit-KSXUNQ5J.js.map} +0 -0
- /package/dist/{self-updating-6DWI2Z56.js.map → self-updating-UCIWESAX.js.map} +0 -0
- /package/dist/{smoke-suite-VYY2LXM2.js.map → smoke-suite-U3UKD3NS.js.map} +0 -0
- /package/dist/{submissions-Q7DRFOCD.js.map → submissions-IQEJX6FK.js.map} +0 -0
- /package/dist/{svelte-codemods-25J2FO22.js.map → svelte-codemods-GOKXTBHW.js.map} +0 -0
- /package/dist/{sync-34HNZZ56.js.map → sync-CTUVXMCK.js.map} +0 -0
- /package/dist/{sync-configs-3SE6PSCO.js.map → sync-configs-SPTLFJCC.js.map} +0 -0
- /package/dist/{upgrade-WBRQ62FX.js.map → upgrade-W6ZR3FJH.js.map} +0 -0
- /package/dist/{webflow-IRKOIT7S.js.map → webflow-624FEBFD.js.map} +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
hasRealScores,
|
|
3
3
|
lighthouseScoresFromResult
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-7BZUOZPU.js";
|
|
5
5
|
import {
|
|
6
6
|
detectAuditEvents
|
|
7
7
|
} from "./chunk-5L3FPQIJ.js";
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
normalizeSecurityAdvisory,
|
|
10
10
|
siteSlug,
|
|
11
11
|
updateAuditFields
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-7NYN2P6I.js";
|
|
13
13
|
|
|
14
14
|
// src/audits/a11y-airtable.ts
|
|
15
15
|
function hasA11yCounts(result) {
|
|
@@ -333,4 +333,4 @@ export {
|
|
|
333
333
|
formatFleetWriteSummary,
|
|
334
334
|
writeFleetAuditsToAirtable
|
|
335
335
|
};
|
|
336
|
-
//# sourceMappingURL=chunk-
|
|
336
|
+
//# sourceMappingURL=chunk-NXNPDCF3.js.map
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
} from "./chunk-VAD43MOR.js";
|
|
5
5
|
import {
|
|
6
6
|
siteSlug
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-7NYN2P6I.js";
|
|
8
8
|
|
|
9
9
|
// src/db/import-airtable.ts
|
|
10
10
|
var EXCLUDED_WEBSITE_FIELDS = /* @__PURE__ */ new Set([
|
|
@@ -61,8 +61,30 @@ var SITE_FIELDS = {
|
|
|
61
61
|
"Mailchimp Audience ID": "mailchimp_audience_id",
|
|
62
62
|
"Notify Routing": "notify_routing",
|
|
63
63
|
"Prismic Ack Until": "prismic_ack_until",
|
|
64
|
-
"Launched at": "launched_at"
|
|
64
|
+
"Launched at": "launched_at",
|
|
65
|
+
// Non-text columns. They live here so `mirrorSiteField` can resolve them like
|
|
66
|
+
// any other editor field; the coercion that makes them non-text is in
|
|
67
|
+
// `siteValueFor`, which BOTH this importer and that mirror go through.
|
|
68
|
+
"Require Turnstile": "require_turnstile",
|
|
69
|
+
"Accepted Watch Conditions": "accepted_watch_conditions"
|
|
65
70
|
};
|
|
71
|
+
function normalizeAwc(raw) {
|
|
72
|
+
if (Array.isArray(raw)) {
|
|
73
|
+
return raw.filter((x) => typeof x === "string").map((x) => x.trim()).filter(Boolean);
|
|
74
|
+
}
|
|
75
|
+
if (typeof raw === "string") {
|
|
76
|
+
return raw.split(/[\n,]/).map((x) => x.trim()).filter(Boolean);
|
|
77
|
+
}
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
function siteValueFor(column, raw) {
|
|
81
|
+
if (column === "require_turnstile") return b01(raw);
|
|
82
|
+
if (column === "accepted_watch_conditions") {
|
|
83
|
+
const awc = normalizeAwc(raw);
|
|
84
|
+
return awc.length > 0 ? JSON.stringify(awc) : null;
|
|
85
|
+
}
|
|
86
|
+
return s(raw);
|
|
87
|
+
}
|
|
66
88
|
var HEALTH_FIELDS = {
|
|
67
89
|
pScore: "p_score",
|
|
68
90
|
rScore: "r_score",
|
|
@@ -153,8 +175,6 @@ function mapWebsiteRecord(rec, computedAt) {
|
|
|
153
175
|
if (!name) throw new Error(`Websites ${rec.id}: blank Name \u2014 cannot derive a slug`);
|
|
154
176
|
const slug = siteSlug(name);
|
|
155
177
|
if (!slug) throw new Error(`Websites ${rec.id}: Name "${name}" yields an empty slug`);
|
|
156
|
-
const awcRaw = f["Accepted Watch Conditions"];
|
|
157
|
-
const awc = Array.isArray(awcRaw) ? awcRaw.filter((x) => typeof x === "string").map((x) => x.trim()).filter(Boolean) : typeof awcRaw === "string" ? awcRaw.split(/[\n,]/).map((x) => x.trim()).filter(Boolean) : [];
|
|
158
178
|
const site = {
|
|
159
179
|
id: rec.id,
|
|
160
180
|
slug,
|
|
@@ -180,15 +200,15 @@ function mapWebsiteRecord(rec, computedAt) {
|
|
|
180
200
|
mailchimp_api_key: null,
|
|
181
201
|
mailchimp_audience_id: null,
|
|
182
202
|
notify_routing: null,
|
|
183
|
-
require_turnstile:
|
|
184
|
-
accepted_watch_conditions:
|
|
203
|
+
require_turnstile: 0,
|
|
204
|
+
accepted_watch_conditions: null,
|
|
185
205
|
prismic_ack_until: null,
|
|
186
206
|
launched_at: null,
|
|
187
207
|
legacy: null
|
|
188
208
|
};
|
|
189
209
|
for (const [field, col] of Object.entries(SITE_FIELDS)) {
|
|
190
210
|
if (col === "name") continue;
|
|
191
|
-
site[col] =
|
|
211
|
+
site[col] = siteValueFor(col, f[field]);
|
|
192
212
|
}
|
|
193
213
|
const health = { site_id: rec.id };
|
|
194
214
|
for (const field of [...Object.keys(HEALTH_FIELDS), ...Object.keys(HEALTH_BOOLEAN)]) {
|
|
@@ -203,8 +223,6 @@ function mapWebsiteRecord(rec, computedAt) {
|
|
|
203
223
|
};
|
|
204
224
|
const claimed = /* @__PURE__ */ new Set([
|
|
205
225
|
"Name",
|
|
206
|
-
"Require Turnstile",
|
|
207
|
-
"Accepted Watch Conditions",
|
|
208
226
|
...Object.keys(SITE_FIELDS),
|
|
209
227
|
...Object.keys(HEALTH_FIELDS),
|
|
210
228
|
...Object.keys(HEALTH_BOOLEAN),
|
|
@@ -380,6 +398,7 @@ async function importFleetState(db, io, opts = {}) {
|
|
|
380
398
|
export {
|
|
381
399
|
EXCLUDED_WEBSITE_FIELDS,
|
|
382
400
|
SITE_FIELDS,
|
|
401
|
+
siteValueFor,
|
|
383
402
|
HEALTH_FIELDS,
|
|
384
403
|
HEALTH_BOOLEAN,
|
|
385
404
|
SCHEDULE_FIELDS,
|
|
@@ -393,4 +412,4 @@ export {
|
|
|
393
412
|
reapRefusal,
|
|
394
413
|
importFleetState
|
|
395
414
|
};
|
|
396
|
-
//# sourceMappingURL=chunk-
|
|
415
|
+
//# sourceMappingURL=chunk-P6AORG32.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/db/import-airtable.ts"],"sourcesContent":["import type { Db } from \"./client.js\";\nimport type { SitesTable, SiteHealthTable, SiteScheduleTable, ReportsTable } from \"./schema.js\";\nimport { siteSlug } from \"../reports/airtable/websites.js\";\nimport { MAINTENANCE_CHECKLIST, TESTING_CHECKLIST } from \"../reports/checklist.js\";\n\n/** One raw Airtable record: id + the fields object exactly as the API returns it\n * (empty cells are ABSENT, not null — the API omits them). */\nexport type RawRecord = { id: string; fields: Record<string, unknown> };\n\n/**\n * Phase 1.3 of #539: map raw Airtable records into the 0007 fleet-state tables,\n * and upsert them idempotently.\n *\n * The mapping here is the SINGLE source of truth for Airtable-column →\n * Turso-column: the parity harness (`db parity`) imports these same functions,\n * so \"what the importer writes\" and \"what parity expects\" cannot drift apart.\n *\n * Values are stored RAW (the cell as Airtable returns it, stringified where the\n * column is TEXT). Coercion — `toFrequency`, `toVerdict`, recipient splitting —\n * stays in the READ layer, exactly as `mapRow` does it today, so the store\n * never bakes in a lossy interpretation. The two exceptions are shapes, not\n * meanings: `Accepted Watch Conditions` normalizes to a JSON array (mapRow\n * accepts array or delimited string; the new store keeps one shape), and the\n * report checklist re-keys from Airtable column names to the stable keys in\n * src/reports/checklist.ts.\n */\n\n/** Websites columns that must NEVER reach Turso, and why. Everything else that\n * is populated but unmapped lands in `sites.legacy` (JSON, keyed by original\n * column name) so no data is silently dropped. */\nexport const EXCLUDED_WEBSITE_FIELDS: ReadonlySet<string> = new Set([\n // Dropped under D4's rule (empty everywhere + unreferenced), verified 2026-08-23.\n \"site host username\",\n \"site host password\",\n \"launch day\",\n \"contract link\",\n // Plaintext credentials — operator ruling 2026-08-23: these live on ONLY in\n // the frozen Airtable base. A new store does not inherit plaintext creds.\n \"DNS username\",\n \"DNS password\",\n \"cms username\",\n \"cms password\",\n // Link columns: Turso joins by rec id (D1); the links carry no extra data.\n \"Reports\",\n \"Submissions\",\n \"Spam Screenouts\",\n // Regenerated into sites.header_image as a BLOB by the header-image CLI (D5);\n // the Airtable attachment (an expiring signed URL) is never migrated.\n \"Header image\",\n]);\n\nconst s = (v: unknown): string | null => {\n if (v === undefined || v === null) return null;\n if (typeof v === \"string\") return v.trim() === \"\" ? null : v;\n return String(v);\n};\nconst n = (v: unknown): number | null => (typeof v === \"number\" ? v : null);\nconst b01 = (v: unknown): number => (v === true ? 1 : 0);\nconst b01n = (v: unknown): number | null => (typeof v === \"boolean\" ? (v ? 1 : 0) : null);\nconst json = (v: unknown): string | null => (v === undefined ? null : JSON.stringify(v));\n\n/** Direct field→column map for `sites` (operator-owned config). Exported for\n * the site-details write-through (fleet-state.mirrorSiteField), so the editor\n * and the importer share ONE Airtable-column → sites-column truth. */\nexport const SITE_FIELDS: Record<string, keyof SitesTable> = {\n Name: \"name\",\n url: \"url\",\n Status: \"status\",\n \"point of contact\": \"point_of_contact\",\n \"maintenence freq\": \"maintenance_freq\", // Airtable's misspelling dies at this boundary\n \"testing freq\": \"testing_freq\",\n \"maintenance day\": \"maintenance_day\",\n \"testing day\": \"testing_day\",\n \"GA4 property ID\": \"ga4_property_id\",\n \"Search query\": \"search_query\",\n \"Search Console property\": \"search_console_property\",\n \"Git repo\": \"git_repo\",\n \"Netlify ID\": \"netlify_id\",\n \"Report recipients (To)\": \"report_recipients_to\",\n \"Report recipients (CC)\": \"report_recipients_cc\",\n \"Copy — Intro\": \"copy_intro\",\n \"Copy — Contact\": \"copy_contact\",\n \"Copy — Footer\": \"copy_footer\",\n \"Newsletter Webhook\": \"newsletter_webhook\",\n \"Mailchimp API Key\": \"mailchimp_api_key\",\n \"Mailchimp Audience ID\": \"mailchimp_audience_id\",\n \"Notify Routing\": \"notify_routing\",\n \"Prismic Ack Until\": \"prismic_ack_until\",\n \"Launched at\": \"launched_at\",\n // Non-text columns. They live here so `mirrorSiteField` can resolve them like\n // any other editor field; the coercion that makes them non-text is in\n // `siteValueFor`, which BOTH this importer and that mirror go through.\n \"Require Turnstile\": \"require_turnstile\",\n \"Accepted Watch Conditions\": \"accepted_watch_conditions\",\n};\n\n/** Normalize an `Accepted Watch Conditions` cell (Airtable array, or a delimited\n * string) to the trimmed list both stores agree on. */\nfunction normalizeAwc(raw: unknown): string[] {\n if (Array.isArray(raw)) {\n return raw\n .filter((x): x is string => typeof x === \"string\")\n .map((x) => x.trim())\n .filter(Boolean);\n }\n if (typeof raw === \"string\") {\n return raw\n .split(/[\\n,]/)\n .map((x) => x.trim())\n .filter(Boolean);\n }\n return [];\n}\n\n/**\n * Resolve one Airtable Websites cell to the value its `sites` column stores.\n *\n * The ONE shared path between `mapWebsiteRecord` (full-row import) and\n * `fleet-state.mirrorSiteField` (the editor's write-through) — same contract and\n * the same reason as `healthColumnFor`: a coercion that exists twice will\n * eventually disagree, and here a disagreement is not subtle. Parity compares\n * raw-to-raw, so a mirror storing `\"true\"` where the importer stores `1` reds\n * every hourly run until the next import quietly papers over it.\n */\nexport function siteValueFor(column: keyof SitesTable, raw: unknown): string | number | null {\n if (column === \"require_turnstile\") return b01(raw);\n if (column === \"accepted_watch_conditions\") {\n const awc = normalizeAwc(raw);\n return awc.length > 0 ? JSON.stringify(awc) : null;\n }\n return s(raw);\n}\n\n/** Direct field→column map for `site_health` (nightly-cron-owned). Exported for\n * the Phase 3 writer mirrors (fleet-state.mirrorHealthFields) and their\n * lockstep tests, so the nightly writers and the importer share ONE\n * Airtable-column → site_health-column truth. */\nexport const HEALTH_FIELDS: Record<string, keyof SiteHealthTable> = {\n pScore: \"p_score\",\n rScore: \"r_score\",\n bpScore: \"bp_score\",\n seoScore: \"seo_score\",\n \"Last lighthouse audit at\": \"lighthouse_at\",\n \"A11y Violations\": \"a11y_violations\",\n \"Deps Drifted\": \"deps_drifted\",\n \"Deps Major Behind\": \"deps_major_behind\",\n \"Deps Outdated\": \"deps_outdated\",\n \"Deps Major Outdated\": \"deps_major_outdated\",\n \"Security Vulns Critical\": \"vulns_critical\",\n \"Security Vulns High\": \"vulns_high\",\n \"Security Vulns Moderate\": \"vulns_moderate\",\n \"Security Vulns Low\": \"vulns_low\",\n \"Last security audit at\": \"security_audit_at\",\n \"Security advisories\": \"security_advisories\",\n \"Security Auto-Fix Attempts\": \"auto_fix_attempts\",\n \"Analytics soft-fail at\": \"analytics_soft_fail_at\",\n \"Cert days remaining\": \"cert_days_remaining\",\n \"Domain checked at\": \"domain_checked_at\",\n \"Deploy status\": \"deploy_status\",\n \"Last deploy at\": \"last_deploy_at\",\n \"Deploy log URL\": \"deploy_log_url\",\n \"Deploy checked at\": \"deploy_checked_at\",\n \"Function health\": \"function_health\",\n \"CMS Reachable\": \"cms_reachable\",\n \"Turnstile widget\": \"turnstile_widget\",\n \"Function health checked at\": \"function_health_checked_at\",\n \"Broken links\": \"broken_links\",\n \"Browser checked at\": \"browser_checked_at\",\n \"Uptime Reachable\": \"uptime_reachable\",\n \"Titles & Meta OK\": \"titles_meta_ok\",\n \"Smoke OK\": \"smoke_ok\",\n \"Last Smoke At\": \"last_smoke_at\",\n \"Form E2E OK\": \"form_e2e_ok\",\n \"Form E2E checked at\": \"form_e2e_checked_at\",\n \"Renovate Failing CIs\": \"renovate_failing_cis\",\n \"Default Branch CI\": \"default_branch_ci\",\n \"Last Commit At\": \"last_commit_at\",\n \"GitHub Signals At\": \"github_signals_at\",\n \"Prismic Models\": \"prismic_models\",\n \"Prismic Models Checked At\": \"prismic_models_checked_at\",\n \"Prismic Models Drift\": \"prismic_models_drift\",\n};\n\n/** Numeric health columns (everything else in HEALTH_FIELDS stores as text). */\nconst HEALTH_NUMERIC: ReadonlySet<keyof SiteHealthTable> = new Set([\n \"p_score\",\n \"r_score\",\n \"bp_score\",\n \"seo_score\",\n \"a11y_violations\",\n \"deps_drifted\",\n \"deps_major_behind\",\n \"deps_outdated\",\n \"deps_major_outdated\",\n \"vulns_critical\",\n \"vulns_high\",\n \"vulns_moderate\",\n \"vulns_low\",\n \"auto_fix_attempts\",\n \"cert_days_remaining\",\n \"broken_links\",\n \"renovate_failing_cis\",\n]);\n\n/** Boolean-checkbox health columns (Airtable true/absent → 1/0/null). */\nexport const HEALTH_BOOLEAN: Record<string, keyof SiteHealthTable> = {\n \"Crossbrowser OK\": \"crossbrowser_ok\",\n \"Mobile OK\": \"mobile_ok\",\n \"Links OK\": \"links_ok\",\n};\n\n/** Fields consumed by the schedule table. */\nexport const SCHEDULE_FIELDS: Record<string, keyof SiteScheduleTable> = {\n \"Next maintenance at\": \"next_maintenance_at\",\n \"Next testing at\": \"next_testing_at\",\n};\n\n/** Resolve one Airtable health field to its site_health column AND the exact\n * coercion the importer applies to it. The ONE shared path between\n * mapWebsiteRecord (full-row import) and fleet-state.mirrorHealthFields\n * (partial write-through): a coercion that exists twice will eventually\n * disagree, and the hourly import silently papers over the loser. Returns\n * null for a field no health column claims. */\nexport function healthColumnFor(\n field: string,\n): { col: keyof SiteHealthTable; coerce: (v: unknown) => string | number | null } | null {\n const direct = HEALTH_FIELDS[field];\n if (direct) return { col: direct, coerce: HEALTH_NUMERIC.has(direct) ? n : s };\n const bool = HEALTH_BOOLEAN[field];\n if (bool) return { col: bool, coerce: b01n };\n return null;\n}\n\n/** The schedule twin of {@link healthColumnFor} (both columns store as text). */\nexport function scheduleColumnFor(\n field: string,\n): { col: keyof SiteScheduleTable; coerce: (v: unknown) => string | null } | null {\n const col = SCHEDULE_FIELDS[field];\n return col ? { col, coerce: s } : null;\n}\n\nexport type MappedWebsite = {\n site: Omit<\n SitesTable,\n \"header_image\" | \"header_image_filename\" | \"header_image_type\" | \"header_image_generated_at\"\n >;\n health: SiteHealthTable;\n schedule: SiteScheduleTable;\n};\n\n/** Map one raw Websites record into its three-table shape. Pure. */\nexport function mapWebsiteRecord(rec: RawRecord, computedAt: string): MappedWebsite {\n const f = rec.fields;\n const name = s(f[\"Name\"]);\n if (!name) throw new Error(`Websites ${rec.id}: blank Name — cannot derive a slug`);\n const slug = siteSlug(name);\n if (!slug) throw new Error(`Websites ${rec.id}: Name \"${name}\" yields an empty slug`);\n\n const site: MappedWebsite[\"site\"] = {\n id: rec.id,\n slug,\n name,\n url: null,\n status: null,\n point_of_contact: null,\n maintenance_freq: null,\n testing_freq: null,\n maintenance_day: null,\n testing_day: null,\n ga4_property_id: null,\n search_query: null,\n search_console_property: null,\n git_repo: null,\n netlify_id: null,\n report_recipients_to: null,\n report_recipients_cc: null,\n copy_intro: null,\n copy_contact: null,\n copy_footer: null,\n newsletter_webhook: null,\n mailchimp_api_key: null,\n mailchimp_audience_id: null,\n notify_routing: null,\n require_turnstile: 0,\n accepted_watch_conditions: null,\n prismic_ack_until: null,\n launched_at: null,\n legacy: null,\n };\n // Every mapped column goes through siteValueFor, so the non-text ones\n // (require_turnstile, accepted_watch_conditions) get the SAME coercion the\n // editor's write-through mirror applies. The literals above are placeholders\n // this loop overwrites; they exist only to satisfy the row type.\n for (const [field, col] of Object.entries(SITE_FIELDS)) {\n if (col === \"name\") continue; // handled above (validated)\n (site as unknown as Record<string, unknown>)[col] = siteValueFor(col, f[field]);\n }\n\n const health = { site_id: rec.id } as SiteHealthTable;\n // Through healthColumnFor — the SAME resolution+coercion the Phase 3 writer\n // mirrors use, so import and write-through cannot drift apart.\n for (const field of [...Object.keys(HEALTH_FIELDS), ...Object.keys(HEALTH_BOOLEAN)]) {\n const m = healthColumnFor(field);\n if (m) (health as unknown as Record<string, unknown>)[m.col] = m.coerce(f[field]);\n }\n\n const schedule: SiteScheduleTable = {\n site_id: rec.id,\n next_maintenance_at: s(f[\"Next maintenance at\"]),\n next_testing_at: s(f[\"Next testing at\"]),\n computed_at: computedAt,\n };\n\n // Everything populated that no table claims and no exclusion bans → legacy.\n const claimed = new Set<string>([\n \"Name\",\n ...Object.keys(SITE_FIELDS),\n ...Object.keys(HEALTH_FIELDS),\n ...Object.keys(HEALTH_BOOLEAN),\n ...Object.keys(SCHEDULE_FIELDS),\n ]);\n const legacy: Record<string, unknown> = {};\n for (const [field, value] of Object.entries(f)) {\n if (claimed.has(field) || EXCLUDED_WEBSITE_FIELDS.has(field)) continue;\n legacy[field] = value;\n }\n site.legacy = Object.keys(legacy).length > 0 ? JSON.stringify(legacy) : null;\n\n return { site, health, schedule };\n}\n\n/** Stable-key checklist mapping (Airtable column name → key). */\nconst CHECKLIST_BY_FIELD: ReadonlyArray<{ field: string; key: string }> = [\n ...MAINTENANCE_CHECKLIST,\n ...TESTING_CHECKLIST,\n].map((i) => ({ field: i.field, key: i.key }));\n\n/** Map one raw Reports record. `renderedHtml` is fetched by the caller (the\n * attachment URL is an expiring signed URL — see the design's Attachments\n * section); pass null when the record has no attachment or the fetch failed. */\nexport function mapReportRecord(rec: RawRecord, renderedHtml: string | null): ReportsTable {\n const f = rec.fields;\n const linkSites = (f[\"Site\"] as string[] | undefined) ?? [];\n const checklist: Record<string, boolean> = {};\n for (const { field, key } of CHECKLIST_BY_FIELD) checklist[key] = Boolean(f[field]);\n\n return {\n id: rec.id,\n site_id: linkSites[0] ?? null,\n report_id: s(f[\"Report ID\"]),\n report_type: s(f[\"Report type\"]),\n period: s(f[\"Period\"]),\n period_start: s(f[\"Period start\"]),\n period_end: s(f[\"Period end\"]),\n completed_on: s(f[\"Completed on\"]),\n lighthouse_performance: n(f[\"Lighthouse — Performance\"]),\n lighthouse_accessibility: n(f[\"Lighthouse — Accessibility\"]),\n lighthouse_best_practices: n(f[\"Lighthouse — Best Practices\"]),\n lighthouse_seo: n(f[\"Lighthouse — SEO\"]),\n ga_users_current: n(f[\"GA users (period)\"]),\n ga_users_previous: n(f[\"GA users (prev period)\"]),\n search_found_page1: b01n(f[\"Search found page 1\"]),\n search_position: n(f[\"Search position\"]),\n last_tested_date: s(f[\"Last tested date\"]),\n commentary: s(f[\"Commentary\"]),\n subject_override: s(f[\"Subject override\"]),\n draft_ready: b01(f[\"Draft ready\"]),\n approved_to_send: b01(f[\"Approved to send\"]),\n approved_at: s(f[\"Approved At\"]),\n approved_by: s(f[\"Approved By\"]),\n send_override: b01(f[\"Send override\"]),\n override_reason: s(f[\"Override reason\"]),\n override_by: s(f[\"Override by\"]),\n override_at: s(f[\"Override at\"]),\n sent_at: s(f[\"Sent at\"]),\n delivery_status: s(f[\"Delivery status\"]) ?? \"pending\",\n resend_message_id: s(f[\"Resend message ID\"]),\n checklist: JSON.stringify(checklist),\n // The Airtable cell is a long-text field, so the API hands us a STRING of\n // JSON — store it verbatim. json() here would double-encode it, and\n // parseAutoEvidence on the read side would then parse to a string and\n // yield null (evidence silently lost). The non-string branch only exists\n // for defensive completeness.\n checklist_auto_evidence:\n typeof f[\"Checklist auto-evidence\"] === \"string\"\n ? s(f[\"Checklist auto-evidence\"])\n : json(f[\"Checklist auto-evidence\"] ?? undefined),\n rendered_html: renderedHtml,\n };\n}\n\n/** First Rendered HTML attachment's signed URL, or null. Exposed so the CLI can\n * fetch it and tests can assert which URL would be fetched. */\nexport function renderedHtmlUrl(rec: RawRecord): string | null {\n const atts = rec.fields[\"Rendered HTML\"] as Array<{ url?: string }> | undefined;\n const url = atts?.[0]?.url;\n return typeof url === \"string\" && url.length > 0 ? url : null;\n}\n\n/** What the import removed because Airtable no longer has the record — and what\n * it declined to remove. `refusals` is non-empty only when the incoming read\n * looked untrustworthy; in that case NOTHING was deleted for that table. */\nexport type ReapSummary = {\n sites: string[];\n reports: string[];\n refusals: string[];\n};\n\n/** Reaping is the only destructive thing the importer does, and it acts on the\n * absence of evidence — so it is only ever as trustworthy as the read that\n * motivated it. A short read (a truncated page, a partial outage) is\n * indistinguishable from mass deletion by looking at the rows alone, so the\n * allowance is deliberately shaped like an operator tidying up rather than\n * like a bad page: at most 10% of what is stored, with a floor of 5 so a small\n * table stays workable. Over that, refuse the whole table and let parity red\n * the run — a wedged-red sync is recoverable, an emptied Turso is not. */\nconst REAP_FLOOR = 5;\nconst REAP_FRACTION = 0.1;\n\nexport function reapAllowance(stored: number): number {\n return Math.max(REAP_FLOOR, Math.ceil(stored * REAP_FRACTION));\n}\n\n/** Human lines plus the FLEET_REAP machine line, emitted on EVERY pass (nothing\n * reaped included) — an absent line must read as \"the reap never ran\", never as\n * \"it ran clean\", the same contract FLEET_PARITY and FLEET_SYNC hold. Shared by\n * `db sync` and the one-shot `db import-airtable`: both can delete, so a second\n * hand-rolled copy is how one of them ends up doing it quietly. */\nexport function formatReapSummary(reaped: ReapSummary): string[] {\n const lines: string[] = [];\n for (const id of reaped.sites) {\n lines.push(`✂ reaped sites ${id} (+ its health/schedule rows) — no longer in Airtable`);\n }\n for (const id of reaped.reports) {\n lines.push(`✂ reaped reports ${id} — no longer in Airtable`);\n }\n for (const reason of reaped.refusals) lines.push(`⚠ ${reason}`);\n lines.push(\n `FLEET_REAP sites=${reaped.sites.length} reports=${reaped.reports.length} ` +\n `refused=${reaped.refusals.length}`,\n );\n return lines;\n}\n\n/** Null when the reap may proceed, else the human reason it was refused. */\nexport function reapRefusal(\n table: string,\n incoming: number,\n stored: number,\n toReap: number,\n): string | null {\n if (incoming === 0 && stored > 0) {\n return `${table}: REFUSED to reap — Airtable returned 0 rows while ${stored} are stored; that is a failed read, not an empty fleet. Nothing deleted.`;\n }\n const allowed = reapAllowance(stored);\n if (toReap > allowed) {\n return `${table}: REFUSED to reap — ${toReap} rows are missing from Airtable, over the ${allowed}-row allowance for ${stored} stored. Nothing deleted; re-run, and if the deletions are real, remove them in smaller batches.`;\n }\n return null;\n}\n\nexport type ImportSummary = {\n sites: number;\n reports: number;\n /** Rows removed (or refused) because their Airtable record is gone. */\n reaped: ReapSummary;\n /** Reports whose Rendered HTML attachment could not be fetched (URL expired /\n * network) — imported with rendered_html null, named so the run is honest. */\n renderedHtmlMisses: string[];\n /** Attachment fetches attempted and succeeded this run. */\n renderedHtmlFetched: number;\n /** Fetches skipped because the stored row already carries rendered_html\n * (only in reportHtml: \"when-missing\" mode — the hourly sync's mode). */\n renderedHtmlSkipped: number;\n};\n\nexport type ImportOptions = {\n /** \"always\" (default): fetch every report's Rendered HTML attachment — the\n * one-shot full import's mode. \"when-missing\": fetch only for reports whose\n * stored row has no rendered_html yet — the hourly sync's mode, so 24 runs a\n * day don't re-download every attachment every hour. Safe for Phase 2:\n * nothing reads reports.rendered_html from Turso until the Phase 4 report\n * review lands; revisit the staleness story there (a re-rendered report's\n * stored HTML goes stale under \"when-missing\"). Parity is unaffected —\n * rendered_html is a parity SKIP_COLUMN. */\n reportHtml?: \"always\" | \"when-missing\";\n};\n\nexport type ImportIo = {\n listWebsiteRecords: () => Promise<RawRecord[]>;\n listReportRecords: () => Promise<RawRecord[]>;\n /** Fetch an attachment body; null on failure (the miss is reported, not fatal). */\n fetchAttachment: (url: string) => Promise<string | null>;\n now: () => Date;\n};\n\n/**\n * Import both tables, idempotently: upsert by rec id, so a re-run converges\n * instead of duplicating. On conflict the sites upsert deliberately does NOT\n * touch the header_image* columns — Airtable stopped being their source (D5),\n * so a re-import must never wipe a regenerated image. rendered_html is only\n * overwritten when the fetch succeeded, for the same reason: a re-run with an\n * expired signed URL must not erase a body captured while it was valid.\n */\nexport async function importFleetState(\n db: Db,\n io: ImportIo,\n opts: ImportOptions = {},\n): Promise<ImportSummary> {\n const computedAt = io.now().toISOString();\n const websites = await io.listWebsiteRecords();\n\n const mapped = websites.map((r) => mapWebsiteRecord(r, computedAt));\n const slugOwner = new Map<string, string>();\n for (const m of mapped) {\n const prior = slugOwner.get(m.site.slug);\n if (prior)\n throw new Error(`slug collision: ${prior} and ${m.site.id} both map to '${m.site.slug}'`);\n slugOwner.set(m.site.slug, m.site.id);\n }\n\n for (const { site, health, schedule } of mapped) {\n await db\n .insertInto(\"sites\")\n .values(site)\n .onConflict((oc) => oc.column(\"id\").doUpdateSet(site))\n .execute();\n await db\n .insertInto(\"site_health\")\n .values(health)\n .onConflict((oc) => oc.column(\"site_id\").doUpdateSet(health))\n .execute();\n await db\n .insertInto(\"site_schedule\")\n .values(schedule)\n .onConflict((oc) => oc.column(\"site_id\").doUpdateSet(schedule))\n .execute();\n }\n\n const reports = await io.listReportRecords();\n // \"when-missing\": one pre-read of which report rows already hold a body, so\n // the loop can skip their attachment fetches. A skipped fetch keeps the\n // stored body (the upsert falls into the rowSansHtml branch) and is counted,\n // not treated as a miss.\n const alreadyStored =\n opts.reportHtml === \"when-missing\"\n ? new Set(\n (\n await db\n .selectFrom(\"reports\")\n .select(\"id\")\n .where(\"rendered_html\", \"is not\", null)\n .execute()\n ).map((r) => r.id),\n )\n : null;\n const misses: string[] = [];\n let fetched = 0;\n let skipped = 0;\n for (const rec of reports) {\n const url = renderedHtmlUrl(rec);\n const skip = url !== null && alreadyStored !== null && alreadyStored.has(rec.id);\n if (skip) skipped++;\n const html = url && !skip ? await io.fetchAttachment(url) : null;\n if (url && !skip && html === null) misses.push(rec.id);\n if (html !== null) fetched++;\n const row = mapReportRecord(rec, html);\n const { rendered_html: _rh, ...rowSansHtml } = row;\n void _rh;\n await db\n .insertInto(\"reports\")\n .values(row)\n .onConflict((oc) => oc.column(\"id\").doUpdateSet(html !== null ? row : rowSansHtml))\n .execute();\n }\n\n const reaped: ReapSummary = { sites: [], reports: [], refusals: [] };\n\n // Sites first. No foreign keys are declared, so the health and schedule rows\n // must be removed explicitly — parity only reverse-checks `sites`, so an\n // orphan left here would linger forever, unnoticed and still readable.\n const liveSiteIds = new Set(mapped.map((m) => m.site.id));\n const storedSites = (await db.selectFrom(\"sites\").select(\"id\").execute()).map((r) =>\n String(r.id),\n );\n const staleSites = storedSites.filter((id) => !liveSiteIds.has(id)).sort();\n const siteRefusal = reapRefusal(\"sites\", websites.length, storedSites.length, staleSites.length);\n if (siteRefusal) reaped.refusals.push(siteRefusal);\n else if (staleSites.length > 0) {\n await db.deleteFrom(\"site_health\").where(\"site_id\", \"in\", staleSites).execute();\n await db.deleteFrom(\"site_schedule\").where(\"site_id\", \"in\", staleSites).execute();\n await db.deleteFrom(\"sites\").where(\"id\", \"in\", staleSites).execute();\n reaped.sites = staleSites;\n }\n\n const liveReportIds = new Set(reports.map((r) => r.id));\n const storedReports = (await db.selectFrom(\"reports\").select(\"id\").execute()).map((r) =>\n String(r.id),\n );\n const staleReports = storedReports.filter((id) => !liveReportIds.has(id)).sort();\n const reportRefusal = reapRefusal(\n \"reports\",\n reports.length,\n storedReports.length,\n staleReports.length,\n );\n if (reportRefusal) reaped.refusals.push(reportRefusal);\n else if (staleReports.length > 0) {\n await db.deleteFrom(\"reports\").where(\"id\", \"in\", staleReports).execute();\n reaped.reports = staleReports;\n }\n\n return {\n sites: mapped.length,\n reports: reports.length,\n renderedHtmlMisses: misses,\n renderedHtmlFetched: fetched,\n renderedHtmlSkipped: skipped,\n reaped,\n };\n}\n"],"mappings":";;;;;;;;;AA8BO,IAAM,0BAA+C,oBAAI,IAAI;AAAA;AAAA,EAElE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA;AAAA;AAAA,EAGA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA;AAAA,EAEA;AAAA,EACA;AAAA,EACA;AAAA;AAAA;AAAA,EAGA;AACF,CAAC;AAED,IAAM,IAAI,CAAC,MAA8B;AACvC,MAAI,MAAM,UAAa,MAAM,KAAM,QAAO;AAC1C,MAAI,OAAO,MAAM,SAAU,QAAO,EAAE,KAAK,MAAM,KAAK,OAAO;AAC3D,SAAO,OAAO,CAAC;AACjB;AACA,IAAM,IAAI,CAAC,MAA+B,OAAO,MAAM,WAAW,IAAI;AACtE,IAAM,MAAM,CAAC,MAAwB,MAAM,OAAO,IAAI;AACtD,IAAM,OAAO,CAAC,MAA+B,OAAO,MAAM,YAAa,IAAI,IAAI,IAAK;AACpF,IAAM,OAAO,CAAC,MAA+B,MAAM,SAAY,OAAO,KAAK,UAAU,CAAC;AAK/E,IAAM,cAAgD;AAAA,EAC3D,MAAM;AAAA,EACN,KAAK;AAAA,EACL,QAAQ;AAAA,EACR,oBAAoB;AAAA,EACpB,oBAAoB;AAAA;AAAA,EACpB,gBAAgB;AAAA,EAChB,mBAAmB;AAAA,EACnB,eAAe;AAAA,EACf,mBAAmB;AAAA,EACnB,gBAAgB;AAAA,EAChB,2BAA2B;AAAA,EAC3B,YAAY;AAAA,EACZ,cAAc;AAAA,EACd,0BAA0B;AAAA,EAC1B,0BAA0B;AAAA,EAC1B,qBAAgB;AAAA,EAChB,uBAAkB;AAAA,EAClB,sBAAiB;AAAA,EACjB,sBAAsB;AAAA,EACtB,qBAAqB;AAAA,EACrB,yBAAyB;AAAA,EACzB,kBAAkB;AAAA,EAClB,qBAAqB;AAAA,EACrB,eAAe;AAAA;AAAA;AAAA;AAAA,EAIf,qBAAqB;AAAA,EACrB,6BAA6B;AAC/B;AAIA,SAAS,aAAa,KAAwB;AAC5C,MAAI,MAAM,QAAQ,GAAG,GAAG;AACtB,WAAO,IACJ,OAAO,CAAC,MAAmB,OAAO,MAAM,QAAQ,EAChD,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,EACnB,OAAO,OAAO;AAAA,EACnB;AACA,MAAI,OAAO,QAAQ,UAAU;AAC3B,WAAO,IACJ,MAAM,OAAO,EACb,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,EACnB,OAAO,OAAO;AAAA,EACnB;AACA,SAAO,CAAC;AACV;AAYO,SAAS,aAAa,QAA0B,KAAsC;AAC3F,MAAI,WAAW,oBAAqB,QAAO,IAAI,GAAG;AAClD,MAAI,WAAW,6BAA6B;AAC1C,UAAM,MAAM,aAAa,GAAG;AAC5B,WAAO,IAAI,SAAS,IAAI,KAAK,UAAU,GAAG,IAAI;AAAA,EAChD;AACA,SAAO,EAAE,GAAG;AACd;AAMO,IAAM,gBAAuD;AAAA,EAClE,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,UAAU;AAAA,EACV,4BAA4B;AAAA,EAC5B,mBAAmB;AAAA,EACnB,gBAAgB;AAAA,EAChB,qBAAqB;AAAA,EACrB,iBAAiB;AAAA,EACjB,uBAAuB;AAAA,EACvB,2BAA2B;AAAA,EAC3B,uBAAuB;AAAA,EACvB,2BAA2B;AAAA,EAC3B,sBAAsB;AAAA,EACtB,0BAA0B;AAAA,EAC1B,uBAAuB;AAAA,EACvB,8BAA8B;AAAA,EAC9B,0BAA0B;AAAA,EAC1B,uBAAuB;AAAA,EACvB,qBAAqB;AAAA,EACrB,iBAAiB;AAAA,EACjB,kBAAkB;AAAA,EAClB,kBAAkB;AAAA,EAClB,qBAAqB;AAAA,EACrB,mBAAmB;AAAA,EACnB,iBAAiB;AAAA,EACjB,oBAAoB;AAAA,EACpB,8BAA8B;AAAA,EAC9B,gBAAgB;AAAA,EAChB,sBAAsB;AAAA,EACtB,oBAAoB;AAAA,EACpB,oBAAoB;AAAA,EACpB,YAAY;AAAA,EACZ,iBAAiB;AAAA,EACjB,eAAe;AAAA,EACf,uBAAuB;AAAA,EACvB,wBAAwB;AAAA,EACxB,qBAAqB;AAAA,EACrB,kBAAkB;AAAA,EAClB,qBAAqB;AAAA,EACrB,kBAAkB;AAAA,EAClB,6BAA6B;AAAA,EAC7B,wBAAwB;AAC1B;AAGA,IAAM,iBAAqD,oBAAI,IAAI;AAAA,EACjE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAGM,IAAM,iBAAwD;AAAA,EACnE,mBAAmB;AAAA,EACnB,aAAa;AAAA,EACb,YAAY;AACd;AAGO,IAAM,kBAA2D;AAAA,EACtE,uBAAuB;AAAA,EACvB,mBAAmB;AACrB;AAQO,SAAS,gBACd,OACuF;AACvF,QAAM,SAAS,cAAc,KAAK;AAClC,MAAI,OAAQ,QAAO,EAAE,KAAK,QAAQ,QAAQ,eAAe,IAAI,MAAM,IAAI,IAAI,EAAE;AAC7E,QAAM,OAAO,eAAe,KAAK;AACjC,MAAI,KAAM,QAAO,EAAE,KAAK,MAAM,QAAQ,KAAK;AAC3C,SAAO;AACT;AAGO,SAAS,kBACd,OACgF;AAChF,QAAM,MAAM,gBAAgB,KAAK;AACjC,SAAO,MAAM,EAAE,KAAK,QAAQ,EAAE,IAAI;AACpC;AAYO,SAAS,iBAAiB,KAAgB,YAAmC;AAClF,QAAM,IAAI,IAAI;AACd,QAAM,OAAO,EAAE,EAAE,MAAM,CAAC;AACxB,MAAI,CAAC,KAAM,OAAM,IAAI,MAAM,YAAY,IAAI,EAAE,0CAAqC;AAClF,QAAM,OAAO,SAAS,IAAI;AAC1B,MAAI,CAAC,KAAM,OAAM,IAAI,MAAM,YAAY,IAAI,EAAE,WAAW,IAAI,wBAAwB;AAEpF,QAAM,OAA8B;AAAA,IAClC,IAAI,IAAI;AAAA,IACR;AAAA,IACA;AAAA,IACA,KAAK;AAAA,IACL,QAAQ;AAAA,IACR,kBAAkB;AAAA,IAClB,kBAAkB;AAAA,IAClB,cAAc;AAAA,IACd,iBAAiB;AAAA,IACjB,aAAa;AAAA,IACb,iBAAiB;AAAA,IACjB,cAAc;AAAA,IACd,yBAAyB;AAAA,IACzB,UAAU;AAAA,IACV,YAAY;AAAA,IACZ,sBAAsB;AAAA,IACtB,sBAAsB;AAAA,IACtB,YAAY;AAAA,IACZ,cAAc;AAAA,IACd,aAAa;AAAA,IACb,oBAAoB;AAAA,IACpB,mBAAmB;AAAA,IACnB,uBAAuB;AAAA,IACvB,gBAAgB;AAAA,IAChB,mBAAmB;AAAA,IACnB,2BAA2B;AAAA,IAC3B,mBAAmB;AAAA,IACnB,aAAa;AAAA,IACb,QAAQ;AAAA,EACV;AAKA,aAAW,CAAC,OAAO,GAAG,KAAK,OAAO,QAAQ,WAAW,GAAG;AACtD,QAAI,QAAQ,OAAQ;AACpB,IAAC,KAA4C,GAAG,IAAI,aAAa,KAAK,EAAE,KAAK,CAAC;AAAA,EAChF;AAEA,QAAM,SAAS,EAAE,SAAS,IAAI,GAAG;AAGjC,aAAW,SAAS,CAAC,GAAG,OAAO,KAAK,aAAa,GAAG,GAAG,OAAO,KAAK,cAAc,CAAC,GAAG;AACnF,UAAM,IAAI,gBAAgB,KAAK;AAC/B,QAAI,EAAG,CAAC,OAA8C,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,CAAC;AAAA,EAClF;AAEA,QAAM,WAA8B;AAAA,IAClC,SAAS,IAAI;AAAA,IACb,qBAAqB,EAAE,EAAE,qBAAqB,CAAC;AAAA,IAC/C,iBAAiB,EAAE,EAAE,iBAAiB,CAAC;AAAA,IACvC,aAAa;AAAA,EACf;AAGA,QAAM,UAAU,oBAAI,IAAY;AAAA,IAC9B;AAAA,IACA,GAAG,OAAO,KAAK,WAAW;AAAA,IAC1B,GAAG,OAAO,KAAK,aAAa;AAAA,IAC5B,GAAG,OAAO,KAAK,cAAc;AAAA,IAC7B,GAAG,OAAO,KAAK,eAAe;AAAA,EAChC,CAAC;AACD,QAAM,SAAkC,CAAC;AACzC,aAAW,CAAC,OAAO,KAAK,KAAK,OAAO,QAAQ,CAAC,GAAG;AAC9C,QAAI,QAAQ,IAAI,KAAK,KAAK,wBAAwB,IAAI,KAAK,EAAG;AAC9D,WAAO,KAAK,IAAI;AAAA,EAClB;AACA,OAAK,SAAS,OAAO,KAAK,MAAM,EAAE,SAAS,IAAI,KAAK,UAAU,MAAM,IAAI;AAExE,SAAO,EAAE,MAAM,QAAQ,SAAS;AAClC;AAGA,IAAM,qBAAoE;AAAA,EACxE,GAAG;AAAA,EACH,GAAG;AACL,EAAE,IAAI,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,KAAK,EAAE,IAAI,EAAE;AAKtC,SAAS,gBAAgB,KAAgB,cAA2C;AACzF,QAAM,IAAI,IAAI;AACd,QAAM,YAAa,EAAE,MAAM,KAA8B,CAAC;AAC1D,QAAM,YAAqC,CAAC;AAC5C,aAAW,EAAE,OAAO,IAAI,KAAK,mBAAoB,WAAU,GAAG,IAAI,QAAQ,EAAE,KAAK,CAAC;AAElF,SAAO;AAAA,IACL,IAAI,IAAI;AAAA,IACR,SAAS,UAAU,CAAC,KAAK;AAAA,IACzB,WAAW,EAAE,EAAE,WAAW,CAAC;AAAA,IAC3B,aAAa,EAAE,EAAE,aAAa,CAAC;AAAA,IAC/B,QAAQ,EAAE,EAAE,QAAQ,CAAC;AAAA,IACrB,cAAc,EAAE,EAAE,cAAc,CAAC;AAAA,IACjC,YAAY,EAAE,EAAE,YAAY,CAAC;AAAA,IAC7B,cAAc,EAAE,EAAE,cAAc,CAAC;AAAA,IACjC,wBAAwB,EAAE,EAAE,+BAA0B,CAAC;AAAA,IACvD,0BAA0B,EAAE,EAAE,iCAA4B,CAAC;AAAA,IAC3D,2BAA2B,EAAE,EAAE,kCAA6B,CAAC;AAAA,IAC7D,gBAAgB,EAAE,EAAE,uBAAkB,CAAC;AAAA,IACvC,kBAAkB,EAAE,EAAE,mBAAmB,CAAC;AAAA,IAC1C,mBAAmB,EAAE,EAAE,wBAAwB,CAAC;AAAA,IAChD,oBAAoB,KAAK,EAAE,qBAAqB,CAAC;AAAA,IACjD,iBAAiB,EAAE,EAAE,iBAAiB,CAAC;AAAA,IACvC,kBAAkB,EAAE,EAAE,kBAAkB,CAAC;AAAA,IACzC,YAAY,EAAE,EAAE,YAAY,CAAC;AAAA,IAC7B,kBAAkB,EAAE,EAAE,kBAAkB,CAAC;AAAA,IACzC,aAAa,IAAI,EAAE,aAAa,CAAC;AAAA,IACjC,kBAAkB,IAAI,EAAE,kBAAkB,CAAC;AAAA,IAC3C,aAAa,EAAE,EAAE,aAAa,CAAC;AAAA,IAC/B,aAAa,EAAE,EAAE,aAAa,CAAC;AAAA,IAC/B,eAAe,IAAI,EAAE,eAAe,CAAC;AAAA,IACrC,iBAAiB,EAAE,EAAE,iBAAiB,CAAC;AAAA,IACvC,aAAa,EAAE,EAAE,aAAa,CAAC;AAAA,IAC/B,aAAa,EAAE,EAAE,aAAa,CAAC;AAAA,IAC/B,SAAS,EAAE,EAAE,SAAS,CAAC;AAAA,IACvB,iBAAiB,EAAE,EAAE,iBAAiB,CAAC,KAAK;AAAA,IAC5C,mBAAmB,EAAE,EAAE,mBAAmB,CAAC;AAAA,IAC3C,WAAW,KAAK,UAAU,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAMnC,yBACE,OAAO,EAAE,yBAAyB,MAAM,WACpC,EAAE,EAAE,yBAAyB,CAAC,IAC9B,KAAK,EAAE,yBAAyB,KAAK,MAAS;AAAA,IACpD,eAAe;AAAA,EACjB;AACF;AAIO,SAAS,gBAAgB,KAA+B;AAC7D,QAAM,OAAO,IAAI,OAAO,eAAe;AACvC,QAAM,MAAM,OAAO,CAAC,GAAG;AACvB,SAAO,OAAO,QAAQ,YAAY,IAAI,SAAS,IAAI,MAAM;AAC3D;AAmBA,IAAM,aAAa;AACnB,IAAM,gBAAgB;AAEf,SAAS,cAAc,QAAwB;AACpD,SAAO,KAAK,IAAI,YAAY,KAAK,KAAK,SAAS,aAAa,CAAC;AAC/D;AAOO,SAAS,kBAAkB,QAA+B;AAC/D,QAAM,QAAkB,CAAC;AACzB,aAAW,MAAM,OAAO,OAAO;AAC7B,UAAM,KAAK,uBAAkB,EAAE,4DAAuD;AAAA,EACxF;AACA,aAAW,MAAM,OAAO,SAAS;AAC/B,UAAM,KAAK,yBAAoB,EAAE,+BAA0B;AAAA,EAC7D;AACA,aAAW,UAAU,OAAO,SAAU,OAAM,KAAK,UAAK,MAAM,EAAE;AAC9D,QAAM;AAAA,IACJ,oBAAoB,OAAO,MAAM,MAAM,YAAY,OAAO,QAAQ,MAAM,YAC3D,OAAO,SAAS,MAAM;AAAA,EACrC;AACA,SAAO;AACT;AAGO,SAAS,YACd,OACA,UACA,QACA,QACe;AACf,MAAI,aAAa,KAAK,SAAS,GAAG;AAChC,WAAO,GAAG,KAAK,2DAAsD,MAAM;AAAA,EAC7E;AACA,QAAM,UAAU,cAAc,MAAM;AACpC,MAAI,SAAS,SAAS;AACpB,WAAO,GAAG,KAAK,4BAAuB,MAAM,6CAA6C,OAAO,sBAAsB,MAAM;AAAA,EAC9H;AACA,SAAO;AACT;AA6CA,eAAsB,iBACpB,IACA,IACA,OAAsB,CAAC,GACC;AACxB,QAAM,aAAa,GAAG,IAAI,EAAE,YAAY;AACxC,QAAM,WAAW,MAAM,GAAG,mBAAmB;AAE7C,QAAM,SAAS,SAAS,IAAI,CAAC,MAAM,iBAAiB,GAAG,UAAU,CAAC;AAClE,QAAM,YAAY,oBAAI,IAAoB;AAC1C,aAAW,KAAK,QAAQ;AACtB,UAAM,QAAQ,UAAU,IAAI,EAAE,KAAK,IAAI;AACvC,QAAI;AACF,YAAM,IAAI,MAAM,mBAAmB,KAAK,QAAQ,EAAE,KAAK,EAAE,iBAAiB,EAAE,KAAK,IAAI,GAAG;AAC1F,cAAU,IAAI,EAAE,KAAK,MAAM,EAAE,KAAK,EAAE;AAAA,EACtC;AAEA,aAAW,EAAE,MAAM,QAAQ,SAAS,KAAK,QAAQ;AAC/C,UAAM,GACH,WAAW,OAAO,EAClB,OAAO,IAAI,EACX,WAAW,CAAC,OAAO,GAAG,OAAO,IAAI,EAAE,YAAY,IAAI,CAAC,EACpD,QAAQ;AACX,UAAM,GACH,WAAW,aAAa,EACxB,OAAO,MAAM,EACb,WAAW,CAAC,OAAO,GAAG,OAAO,SAAS,EAAE,YAAY,MAAM,CAAC,EAC3D,QAAQ;AACX,UAAM,GACH,WAAW,eAAe,EAC1B,OAAO,QAAQ,EACf,WAAW,CAAC,OAAO,GAAG,OAAO,SAAS,EAAE,YAAY,QAAQ,CAAC,EAC7D,QAAQ;AAAA,EACb;AAEA,QAAM,UAAU,MAAM,GAAG,kBAAkB;AAK3C,QAAM,gBACJ,KAAK,eAAe,iBAChB,IAAI;AAAA,KAEA,MAAM,GACH,WAAW,SAAS,EACpB,OAAO,IAAI,EACX,MAAM,iBAAiB,UAAU,IAAI,EACrC,QAAQ,GACX,IAAI,CAAC,MAAM,EAAE,EAAE;AAAA,EACnB,IACA;AACN,QAAM,SAAmB,CAAC;AAC1B,MAAI,UAAU;AACd,MAAI,UAAU;AACd,aAAW,OAAO,SAAS;AACzB,UAAM,MAAM,gBAAgB,GAAG;AAC/B,UAAM,OAAO,QAAQ,QAAQ,kBAAkB,QAAQ,cAAc,IAAI,IAAI,EAAE;AAC/E,QAAI,KAAM;AACV,UAAM,OAAO,OAAO,CAAC,OAAO,MAAM,GAAG,gBAAgB,GAAG,IAAI;AAC5D,QAAI,OAAO,CAAC,QAAQ,SAAS,KAAM,QAAO,KAAK,IAAI,EAAE;AACrD,QAAI,SAAS,KAAM;AACnB,UAAM,MAAM,gBAAgB,KAAK,IAAI;AACrC,UAAM,EAAE,eAAe,KAAK,GAAG,YAAY,IAAI;AAC/C,SAAK;AACL,UAAM,GACH,WAAW,SAAS,EACpB,OAAO,GAAG,EACV,WAAW,CAAC,OAAO,GAAG,OAAO,IAAI,EAAE,YAAY,SAAS,OAAO,MAAM,WAAW,CAAC,EACjF,QAAQ;AAAA,EACb;AAEA,QAAM,SAAsB,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC,GAAG,UAAU,CAAC,EAAE;AAKnE,QAAM,cAAc,IAAI,IAAI,OAAO,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,CAAC;AACxD,QAAM,eAAe,MAAM,GAAG,WAAW,OAAO,EAAE,OAAO,IAAI,EAAE,QAAQ,GAAG;AAAA,IAAI,CAAC,MAC7E,OAAO,EAAE,EAAE;AAAA,EACb;AACA,QAAM,aAAa,YAAY,OAAO,CAAC,OAAO,CAAC,YAAY,IAAI,EAAE,CAAC,EAAE,KAAK;AACzE,QAAM,cAAc,YAAY,SAAS,SAAS,QAAQ,YAAY,QAAQ,WAAW,MAAM;AAC/F,MAAI,YAAa,QAAO,SAAS,KAAK,WAAW;AAAA,WACxC,WAAW,SAAS,GAAG;AAC9B,UAAM,GAAG,WAAW,aAAa,EAAE,MAAM,WAAW,MAAM,UAAU,EAAE,QAAQ;AAC9E,UAAM,GAAG,WAAW,eAAe,EAAE,MAAM,WAAW,MAAM,UAAU,EAAE,QAAQ;AAChF,UAAM,GAAG,WAAW,OAAO,EAAE,MAAM,MAAM,MAAM,UAAU,EAAE,QAAQ;AACnE,WAAO,QAAQ;AAAA,EACjB;AAEA,QAAM,gBAAgB,IAAI,IAAI,QAAQ,IAAI,CAAC,MAAM,EAAE,EAAE,CAAC;AACtD,QAAM,iBAAiB,MAAM,GAAG,WAAW,SAAS,EAAE,OAAO,IAAI,EAAE,QAAQ,GAAG;AAAA,IAAI,CAAC,MACjF,OAAO,EAAE,EAAE;AAAA,EACb;AACA,QAAM,eAAe,cAAc,OAAO,CAAC,OAAO,CAAC,cAAc,IAAI,EAAE,CAAC,EAAE,KAAK;AAC/E,QAAM,gBAAgB;AAAA,IACpB;AAAA,IACA,QAAQ;AAAA,IACR,cAAc;AAAA,IACd,aAAa;AAAA,EACf;AACA,MAAI,cAAe,QAAO,SAAS,KAAK,aAAa;AAAA,WAC5C,aAAa,SAAS,GAAG;AAChC,UAAM,GAAG,WAAW,SAAS,EAAE,MAAM,MAAM,MAAM,YAAY,EAAE,QAAQ;AACvE,WAAO,UAAU;AAAA,EACnB;AAEA,SAAO;AAAA,IACL,OAAO,OAAO;AAAA,IACd,SAAS,QAAQ;AAAA,IACjB,oBAAoB;AAAA,IACpB,qBAAqB;AAAA,IACrB,qBAAqB;AAAA,IACrB;AAAA,EACF;AACF;","names":[]}
|
|
@@ -5,10 +5,10 @@ import {
|
|
|
5
5
|
import {
|
|
6
6
|
isProbablyEmail,
|
|
7
7
|
parseAddresses
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-NDWYJLRF.js";
|
|
9
9
|
import {
|
|
10
10
|
listAllReports
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-F3JWOPTG.js";
|
|
12
12
|
import {
|
|
13
13
|
gatingHealth,
|
|
14
14
|
isSendOverridden
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
import {
|
|
21
21
|
listWebsites,
|
|
22
22
|
siteSlug
|
|
23
|
-
} from "./chunk-
|
|
23
|
+
} from "./chunk-7NYN2P6I.js";
|
|
24
24
|
|
|
25
25
|
// src/reports/preflight.ts
|
|
26
26
|
var OPERATOR_DOMAINS = ["reddoorla.com", "tuckerlemos.com"];
|
|
@@ -341,4 +341,4 @@ export {
|
|
|
341
341
|
preflight,
|
|
342
342
|
approveBlockers
|
|
343
343
|
};
|
|
344
|
-
//# sourceMappingURL=chunk-
|
|
344
|
+
//# sourceMappingURL=chunk-SDDPUAWH.js.map
|
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
convertToPnpm
|
|
3
|
-
} from "./chunk-XIDUYLSR.js";
|
|
4
|
-
import {
|
|
5
|
-
svelteCodemods
|
|
6
|
-
} from "./chunk-IYSEC543.js";
|
|
7
1
|
import {
|
|
8
2
|
healthEndpoint
|
|
9
3
|
} from "./chunk-BQRFQSGY.js";
|
|
@@ -13,15 +7,21 @@ import {
|
|
|
13
7
|
import {
|
|
14
8
|
onboard
|
|
15
9
|
} from "./chunk-7RJ2HMMJ.js";
|
|
10
|
+
import {
|
|
11
|
+
convertToPnpm
|
|
12
|
+
} from "./chunk-XIDUYLSR.js";
|
|
13
|
+
import {
|
|
14
|
+
svelteCodemods
|
|
15
|
+
} from "./chunk-IYSEC543.js";
|
|
16
16
|
import {
|
|
17
17
|
syncConfigs
|
|
18
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-7UAYMAKR.js";
|
|
19
19
|
import {
|
|
20
20
|
withRecipe
|
|
21
21
|
} from "./chunk-3G25KIWW.js";
|
|
22
22
|
import {
|
|
23
23
|
runAudits
|
|
24
|
-
} from "./chunk-
|
|
24
|
+
} from "./chunk-3JQ2FO3D.js";
|
|
25
25
|
import {
|
|
26
26
|
siteLabel
|
|
27
27
|
} from "./chunk-XXTZBPUY.js";
|
|
@@ -143,4 +143,4 @@ export {
|
|
|
143
143
|
DEFAULT_INIT_STEPS,
|
|
144
144
|
init
|
|
145
145
|
};
|
|
146
|
-
//# sourceMappingURL=chunk-
|
|
146
|
+
//# sourceMappingURL=chunk-TD2NNXLZ.js.map
|
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
isPendingApproval,
|
|
3
3
|
listReportsForSite,
|
|
4
4
|
setDraftReady
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-F3JWOPTG.js";
|
|
6
6
|
|
|
7
7
|
// src/reports/queue.ts
|
|
8
8
|
var REPORT_TIER = {
|
|
@@ -14,20 +14,24 @@ var REPORT_TIER = {
|
|
|
14
14
|
function reportTier(type) {
|
|
15
15
|
return REPORT_TIER[type];
|
|
16
16
|
}
|
|
17
|
-
async function queueDraft(base, report) {
|
|
17
|
+
async function queueDraft(base, report, mirror) {
|
|
18
|
+
const setReady = async (id, ready) => {
|
|
19
|
+
await setDraftReady(base, id, ready);
|
|
20
|
+
await mirror?.patch(id, { draft_ready: ready ? 1 : 0 });
|
|
21
|
+
};
|
|
18
22
|
const newTier = reportTier(report.reportType);
|
|
19
23
|
const others = (await listReportsForSite(base, report.siteId)).filter(isPendingApproval).filter((r) => r.id !== report.id);
|
|
20
24
|
const blocker = others.find((r) => reportTier(r.reportType) >= newTier);
|
|
21
25
|
if (blocker) {
|
|
22
|
-
await
|
|
26
|
+
await setReady(report.id, false);
|
|
23
27
|
return { queued: false, blockedBy: blocker.reportType, supersededIds: [] };
|
|
24
28
|
}
|
|
25
29
|
const supersededIds = [];
|
|
26
30
|
for (const r of others) {
|
|
27
|
-
await
|
|
31
|
+
await setReady(r.id, false);
|
|
28
32
|
supersededIds.push(r.id);
|
|
29
33
|
}
|
|
30
|
-
await
|
|
34
|
+
await setReady(report.id, true);
|
|
31
35
|
return { queued: true, supersededIds };
|
|
32
36
|
}
|
|
33
37
|
|
|
@@ -35,4 +39,4 @@ export {
|
|
|
35
39
|
reportTier,
|
|
36
40
|
queueDraft
|
|
37
41
|
};
|
|
38
|
-
//# sourceMappingURL=chunk-
|
|
42
|
+
//# sourceMappingURL=chunk-YFAXOD7Z.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/reports/queue.ts"],"sourcesContent":["import type { ReportType } from \"./types.js\";\nimport type { AirtableBase } from \"./airtable/client.js\";\nimport { listReportsForSite, isPendingApproval, setDraftReady } from \"./airtable/reports.js\";\nimport type { ReportMirror } from \"./report-mirror.js\";\n\n/**\n * Report tiers — a higher tier is a SUPERSET of the lower ones, so only the highest-tier\n * un-sent draft is worth keeping in a site's approve queue. Maintenance ⊂ Testing ⊂\n * {Announcement, Launch}. Announcement and Launch share tier 3 (neither supersets the other).\n */\nexport const REPORT_TIER: Record<ReportType, number> = {\n Maintenance: 1,\n Testing: 2,\n Announcement: 3,\n Launch: 3,\n};\n\nexport function reportTier(type: ReportType): number {\n return REPORT_TIER[type];\n}\n\nexport type QueueOutcome = {\n /** Whether `report` was placed in (kept in) the approve queue. */\n queued: boolean;\n /** When `queued` is false, the queued report type that subsumes this one. */\n blockedBy?: ReportType;\n /** Ids of lower-tier queued reports that this one superseded (un-queued). */\n supersededIds: string[];\n};\n\n/**\n * Enforce \"one report queued per site, highest tier wins\" at draft time. Given a freshly\n * created/reused report, look at the OTHER reports already pending approval for the same site\n * (Draft ready ∧ ¬Approved ∧ ¬Sent) and:\n *\n * - If any is of an EQUAL-OR-HIGHER tier → the new report is subsumed: leave it un-queued\n * (Draft ready = false) and report `blockedBy`. (Equal-tier, e.g. a queued Launch when an\n * Announcement is drafted, keeps the existing one rather than silently replacing it.)\n * - Otherwise the new report is strictly the highest → un-queue every (strictly lower) pending\n * report for the site (superseded, not deleted — the row is kept) and queue the new one.\n *\n * Returns what happened so the caller can surface it. PURE side effects are all `setDraftReady`.\n *\n * #539 Phase 5: each flag is mirrored into Turso. The SUPERSEDED rows matter as\n * much as the new one — un-queueing them is the whole point of this function, so\n * a mirror covering only `report.id` would leave the console showing a site with\n * two queued reports until the next hourly sync.\n */\nexport async function queueDraft(\n base: AirtableBase,\n report: { id: string; siteId: string; reportType: ReportType },\n mirror?: ReportMirror,\n): Promise<QueueOutcome> {\n const setReady = async (id: string, ready: boolean): Promise<void> => {\n await setDraftReady(base, id, ready);\n await mirror?.patch(id, { draft_ready: ready ? 1 : 0 });\n };\n const newTier = reportTier(report.reportType);\n const others = (await listReportsForSite(base, report.siteId))\n .filter(isPendingApproval)\n .filter((r) => r.id !== report.id);\n\n const blocker = others.find((r) => reportTier(r.reportType) >= newTier);\n if (blocker) {\n // A queued report already covers this one. Make sure the new draft is NOT queued (the reuse\n // path may hand us a row that was Draft-ready from a prior run) and stand down.\n await setReady(report.id, false);\n return { queued: false, blockedBy: blocker.reportType, supersededIds: [] };\n }\n\n // The new report is strictly the highest-tier pending — supersede the rest (un-queue), keep it.\n const supersededIds: string[] = [];\n for (const r of others) {\n await setReady(r.id, false);\n supersededIds.push(r.id);\n }\n await setReady(report.id, true);\n return { queued: true, supersededIds };\n}\n"],"mappings":";;;;;;;AAUO,IAAM,cAA0C;AAAA,EACrD,aAAa;AAAA,EACb,SAAS;AAAA,EACT,cAAc;AAAA,EACd,QAAQ;AACV;AAEO,SAAS,WAAW,MAA0B;AACnD,SAAO,YAAY,IAAI;AACzB;AA6BA,eAAsB,WACpB,MACA,QACA,QACuB;AACvB,QAAM,WAAW,OAAO,IAAY,UAAkC;AACpE,UAAM,cAAc,MAAM,IAAI,KAAK;AACnC,UAAM,QAAQ,MAAM,IAAI,EAAE,aAAa,QAAQ,IAAI,EAAE,CAAC;AAAA,EACxD;AACA,QAAM,UAAU,WAAW,OAAO,UAAU;AAC5C,QAAM,UAAU,MAAM,mBAAmB,MAAM,OAAO,MAAM,GACzD,OAAO,iBAAiB,EACxB,OAAO,CAAC,MAAM,EAAE,OAAO,OAAO,EAAE;AAEnC,QAAM,UAAU,OAAO,KAAK,CAAC,MAAM,WAAW,EAAE,UAAU,KAAK,OAAO;AACtE,MAAI,SAAS;AAGX,UAAM,SAAS,OAAO,IAAI,KAAK;AAC/B,WAAO,EAAE,QAAQ,OAAO,WAAW,QAAQ,YAAY,eAAe,CAAC,EAAE;AAAA,EAC3E;AAGA,QAAM,gBAA0B,CAAC;AACjC,aAAW,KAAK,QAAQ;AACtB,UAAM,SAAS,EAAE,IAAI,KAAK;AAC1B,kBAAc,KAAK,EAAE,EAAE;AAAA,EACzB;AACA,QAAM,SAAS,OAAO,IAAI,IAAI;AAC9B,SAAO,EAAE,QAAQ,MAAM,cAAc;AACvC;","names":[]}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
mapReportRecord,
|
|
3
3
|
mapWebsiteRecord
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-P6AORG32.js";
|
|
5
5
|
|
|
6
6
|
// src/db/parity.ts
|
|
7
7
|
var SKIP_COLUMNS = /* @__PURE__ */ new Set([
|
|
@@ -126,4 +126,4 @@ export {
|
|
|
126
126
|
checkFleetParity,
|
|
127
127
|
formatParityResult
|
|
128
128
|
};
|
|
129
|
-
//# sourceMappingURL=chunk-
|
|
129
|
+
//# sourceMappingURL=chunk-ZBDZTJUO.js.map
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defaultReportSubject
|
|
3
|
+
} from "./chunk-GECESMVS.js";
|
|
4
|
+
import {
|
|
5
|
+
announcementSiteExtras,
|
|
6
|
+
loadBundledImages,
|
|
7
|
+
renderReportHtml,
|
|
8
|
+
resolveCopy
|
|
9
|
+
} from "./chunk-MT2JINSS.js";
|
|
10
|
+
import {
|
|
11
|
+
applyReportTypeHeadline
|
|
12
|
+
} from "./chunk-5UINJNAJ.js";
|
|
13
|
+
import {
|
|
14
|
+
siteSlug
|
|
15
|
+
} from "./chunk-7NYN2P6I.js";
|
|
16
|
+
|
|
17
|
+
// src/reports/send/render-email.ts
|
|
18
|
+
function toInlineAttachment(a) {
|
|
19
|
+
return {
|
|
20
|
+
filename: a.filename,
|
|
21
|
+
content: Buffer.from(a.bytes).toString("base64"),
|
|
22
|
+
contentType: a.contentType,
|
|
23
|
+
inlineContentId: a.cid
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
async function renderReportEmail(reportData, ctx) {
|
|
27
|
+
const { html } = await renderReportHtml(reportData);
|
|
28
|
+
const bundled = await loadBundledImages();
|
|
29
|
+
const attachments = [
|
|
30
|
+
toInlineAttachment({
|
|
31
|
+
bytes: ctx.header.bytes,
|
|
32
|
+
filename: `${ctx.cidName}.jpg`,
|
|
33
|
+
contentType: ctx.header.contentType,
|
|
34
|
+
cid: ctx.cidName
|
|
35
|
+
})
|
|
36
|
+
];
|
|
37
|
+
for (const img of [bundled.check, bundled.blurred]) {
|
|
38
|
+
if (html.includes(`cid:${img.cid}`)) {
|
|
39
|
+
attachments.push(
|
|
40
|
+
toInlineAttachment({
|
|
41
|
+
bytes: img.bytes,
|
|
42
|
+
filename: img.filename,
|
|
43
|
+
contentType: img.contentType,
|
|
44
|
+
cid: img.cid
|
|
45
|
+
})
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const subject = ctx.subjectOverride ?? defaultReportSubject({
|
|
50
|
+
name: reportData.siteName,
|
|
51
|
+
url: reportData.siteUrl,
|
|
52
|
+
type: reportData.reportType,
|
|
53
|
+
date: reportData.completedOn
|
|
54
|
+
});
|
|
55
|
+
return { html, attachments, subject };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/reports/maintenance-email/header-image.ts
|
|
59
|
+
import sharp from "sharp";
|
|
60
|
+
var DEFAULT_DISPLAY_WIDTH = 600;
|
|
61
|
+
var RETINA_SCALE = 2;
|
|
62
|
+
var JPEG_QUALITY = 82;
|
|
63
|
+
function channelToHex(value) {
|
|
64
|
+
return Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, "0");
|
|
65
|
+
}
|
|
66
|
+
async function prepareHeaderImage(bytes, options = {}) {
|
|
67
|
+
const requestedDisplayWidth = options.displayWidth ?? DEFAULT_DISPLAY_WIDTH;
|
|
68
|
+
const input = Buffer.from(bytes);
|
|
69
|
+
const meta = await sharp(input).metadata();
|
|
70
|
+
const origWidth = meta.width;
|
|
71
|
+
const origHeight = meta.height;
|
|
72
|
+
if (!origWidth || !origHeight) {
|
|
73
|
+
throw new Error("prepareHeaderImage: could not read source image dimensions");
|
|
74
|
+
}
|
|
75
|
+
const displayWidth = Math.min(requestedDisplayWidth, origWidth);
|
|
76
|
+
const displayHeight = Math.round(displayWidth * origHeight / origWidth);
|
|
77
|
+
const targetSourceWidth = Math.min(origWidth, displayWidth * RETINA_SCALE);
|
|
78
|
+
const out = await sharp(input).resize({ width: targetSourceWidth, withoutEnlargement: true }).flatten({ background: "#ffffff" }).jpeg({ quality: JPEG_QUALITY }).toBuffer();
|
|
79
|
+
const { dominant } = await sharp(out).stats();
|
|
80
|
+
const placeholderColor = `#${channelToHex(dominant.r)}${channelToHex(dominant.g)}${channelToHex(dominant.b)}`;
|
|
81
|
+
return {
|
|
82
|
+
bytes: new Uint8Array(out),
|
|
83
|
+
contentType: "image/jpeg",
|
|
84
|
+
displayWidth,
|
|
85
|
+
displayHeight,
|
|
86
|
+
placeholderColor
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/reports/send/render-from-row.ts
|
|
91
|
+
function requireLighthouse(report) {
|
|
92
|
+
if (!report.lighthouse) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Report ${report.reportId} has no Lighthouse scores \u2014 all four cells (Lighthouse \u2014 Performance / Accessibility / Best Practices / SEO) must be numeric on the Reports row; one non-numeric or blank cell nulls all four`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return report.lighthouse;
|
|
98
|
+
}
|
|
99
|
+
async function renderReportFromRow(site, report, headerPlateBytes) {
|
|
100
|
+
const withHeadline = await applyReportTypeHeadline(headerPlateBytes, report.reportType);
|
|
101
|
+
const header = await prepareHeaderImage(withHeadline);
|
|
102
|
+
const slug = siteSlug(site.name);
|
|
103
|
+
const cidName = `${slug}-header`;
|
|
104
|
+
const gaPeriodDays = report.reportType === "Announcement" ? 30 : windowDays(report.periodStart, report.periodEnd);
|
|
105
|
+
const reportData = {
|
|
106
|
+
siteName: site.name,
|
|
107
|
+
siteUrl: site.url,
|
|
108
|
+
reportType: report.reportType,
|
|
109
|
+
completedOn: report.completedOn ? new Date(report.completedOn) : /* @__PURE__ */ new Date(),
|
|
110
|
+
lighthouse: requireLighthouse(report),
|
|
111
|
+
gaUsersCurrent: report.gaUsersCurrent ?? void 0,
|
|
112
|
+
gaUsersPrevious: report.gaUsersPrevious ?? void 0,
|
|
113
|
+
gaPeriodDays,
|
|
114
|
+
searchPosition: report.searchFoundPage1 && report.searchPosition !== null ? report.searchPosition : void 0,
|
|
115
|
+
lastTestedDate: report.lastTestedDate ? new Date(report.lastTestedDate) : null,
|
|
116
|
+
commentary: report.commentary,
|
|
117
|
+
copy: resolveCopy(site),
|
|
118
|
+
headerImageCid: cidName,
|
|
119
|
+
headerWidth: header.displayWidth,
|
|
120
|
+
headerHeight: header.displayHeight,
|
|
121
|
+
headerBgColor: header.placeholderColor,
|
|
122
|
+
// Announcement-only: re-derive cadence + improvements from the site row so the SENT email
|
|
123
|
+
// keeps its cadence copy + improvement callouts (not stored on the Reports row).
|
|
124
|
+
...report.reportType === "Announcement" ? announcementSiteExtras(site) : {}
|
|
125
|
+
};
|
|
126
|
+
return renderReportEmail(reportData, {
|
|
127
|
+
header,
|
|
128
|
+
cidName,
|
|
129
|
+
subjectOverride: report.subjectOverride ?? void 0
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
function windowDays(start, end) {
|
|
133
|
+
if (!start || !end) return void 0;
|
|
134
|
+
const ms = new Date(end).getTime() - new Date(start).getTime();
|
|
135
|
+
if (!Number.isFinite(ms) || ms <= 0) return void 0;
|
|
136
|
+
return Math.round(ms / (24 * 60 * 60 * 1e3));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export {
|
|
140
|
+
renderReportEmail,
|
|
141
|
+
prepareHeaderImage,
|
|
142
|
+
requireLighthouse,
|
|
143
|
+
renderReportFromRow,
|
|
144
|
+
windowDays
|
|
145
|
+
};
|
|
146
|
+
//# sourceMappingURL=chunk-ZGBEIWSC.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/reports/send/render-email.ts","../src/reports/maintenance-email/header-image.ts","../src/reports/send/render-from-row.ts"],"sourcesContent":["import { renderReportHtml } from \"../render.js\";\nimport { loadBundledImages } from \"../maintenance-email/assets/index.js\";\nimport { defaultReportSubject } from \"../subject.js\";\nimport type { ReportData } from \"../types.js\";\nimport type { ResendSendInput } from \"./resend.js\";\n\n/** A single Resend inline attachment (CID-referenced). */\nexport type InlineAttachment = NonNullable<ResendSendInput[\"attachments\"]>[number];\n\n/** The downscaled header image + display metadata produced by `prepareHeaderImage`. */\nexport type PreparedHeader = {\n bytes: Uint8Array;\n contentType: string;\n displayWidth: number;\n displayHeight: number;\n placeholderColor: string;\n};\n\nexport type RenderedReportEmail = {\n html: string;\n attachments: InlineAttachment[];\n subject: string;\n};\n\n/** Build a Resend inline (CID-referenced) attachment from raw bytes — the header image and both\n * bundled images share this exact shape. */\nfunction toInlineAttachment(a: {\n bytes: Uint8Array;\n filename: string;\n contentType: string;\n cid: string;\n}): InlineAttachment {\n return {\n filename: a.filename,\n content: Buffer.from(a.bytes).toString(\"base64\"),\n contentType: a.contentType,\n inlineContentId: a.cid,\n };\n}\n\n/**\n * Render a report email from fully-assembled `ReportData`: produce the HTML, the gated inline\n * attachments, and the subject. The per-site header attaches always; the two bundled images\n * (`rd-check-png`, `rd-blurred-tests-jpg`) attach only when their cid actually appears in the\n * rendered HTML (a dangling inline part shows as a stray download in some clients). The subject\n * is `subjectOverride` when given, else `defaultReportSubject`. Shared by the production send path\n * (`sendOne`) and the `selftest` command so the rendered email, attachments, and subject can't\n * drift between them. The only I/O is `loadBundledImages` (a disk read of two bundled images).\n */\nexport async function renderReportEmail(\n reportData: ReportData,\n ctx: { header: PreparedHeader; cidName: string; subjectOverride?: string | undefined },\n): Promise<RenderedReportEmail> {\n const { html } = await renderReportHtml(reportData);\n const bundled = await loadBundledImages();\n const attachments: InlineAttachment[] = [\n toInlineAttachment({\n bytes: ctx.header.bytes,\n filename: `${ctx.cidName}.jpg`,\n contentType: ctx.header.contentType,\n cid: ctx.cidName,\n }),\n ];\n for (const img of [bundled.check, bundled.blurred]) {\n if (html.includes(`cid:${img.cid}`)) {\n attachments.push(\n toInlineAttachment({\n bytes: img.bytes,\n filename: img.filename,\n contentType: img.contentType,\n cid: img.cid,\n }),\n );\n }\n }\n const subject =\n ctx.subjectOverride ??\n defaultReportSubject({\n name: reportData.siteName,\n url: reportData.siteUrl,\n type: reportData.reportType,\n date: reportData.completedOn,\n });\n return { html, attachments, subject };\n}\n","import sharp from \"sharp\";\n\nexport type PreparedHeaderImage = {\n /** Resized JPEG bytes to attach inline (CID) in place of the Airtable original. */\n bytes: Uint8Array;\n /** Always \"image/jpeg\" — we re-encode for predictable size and a flat white background. */\n contentType: string;\n /** CSS display width in px (≤ requested, never wider than the source has pixels for). */\n displayWidth: number;\n /** CSS display height in px, source aspect ratio preserved (no distortion). */\n displayHeight: number;\n /** Dominant-color hex (e.g. \"#cfc3a8\"), used as the loading/blocked placeholder box. */\n placeholderColor: string;\n};\n\nexport type PrepareHeaderImageOptions = {\n /** Intended CSS display width. The email body is 600px, so that's the default. */\n displayWidth?: number;\n};\n\nconst DEFAULT_DISPLAY_WIDTH = 600;\n/** Encode the source at 2× display width so it stays crisp on retina screens. */\nconst RETINA_SCALE = 2;\n/** Quality is for *resized* pixels — at 1200px the texture/text read as sharp; bytes are tiny. */\nconst JPEG_QUALITY = 82;\n\nfunction channelToHex(value: number): string {\n return Math.max(0, Math.min(255, Math.round(value)))\n .toString(16)\n .padStart(2, \"0\");\n}\n\n/**\n * Downscale an oversized header image for email: 2× the display width (retina) at most,\n * never upscaled, re-encoded as JPEG on a flat white background. Also reports the display\n * dimensions (so the template can reserve the box and stop reflow) and a dominant color\n * (so the reserved box shows a matched placeholder while the image loads).\n *\n * Root cause this addresses: Airtable headers can be multi-MB / 2400px+ while the email\n * renders them at ~600px — shipping ~16× more pixels than the display can use.\n */\nexport async function prepareHeaderImage(\n bytes: Uint8Array,\n options: PrepareHeaderImageOptions = {},\n): Promise<PreparedHeaderImage> {\n const requestedDisplayWidth = options.displayWidth ?? DEFAULT_DISPLAY_WIDTH;\n const input = Buffer.from(bytes);\n\n const meta = await sharp(input).metadata();\n const origWidth = meta.width;\n const origHeight = meta.height;\n if (!origWidth || !origHeight) {\n throw new Error(\"prepareHeaderImage: could not read source image dimensions\");\n }\n\n // Never claim a wider display than the source can fill at 1×.\n const displayWidth = Math.min(requestedDisplayWidth, origWidth);\n const displayHeight = Math.round((displayWidth * origHeight) / origWidth);\n\n // Encode at 2× display for retina, but never enlarge a smaller original.\n const targetSourceWidth = Math.min(origWidth, displayWidth * RETINA_SCALE);\n\n const out = await sharp(input)\n .resize({ width: targetSourceWidth, withoutEnlargement: true })\n .flatten({ background: \"#ffffff\" })\n .jpeg({ quality: JPEG_QUALITY })\n .toBuffer();\n\n const { dominant } = await sharp(out).stats();\n const placeholderColor = `#${channelToHex(dominant.r)}${channelToHex(dominant.g)}${channelToHex(dominant.b)}`;\n\n return {\n bytes: new Uint8Array(out),\n contentType: \"image/jpeg\",\n displayWidth,\n displayHeight,\n placeholderColor,\n };\n}\n","import { siteSlug } from \"../airtable/websites.js\";\nimport type { WebsiteRow } from \"../airtable/websites.js\";\nimport type { ReportRow } from \"../airtable/reports.js\";\nimport { resolveCopy } from \"../copy.js\";\nimport { announcementSiteExtras } from \"../announcement-email/template.js\";\nimport { renderReportEmail, type RenderedReportEmail } from \"./render-email.js\";\nimport type { ReportData } from \"../types.js\";\nimport { prepareHeaderImage } from \"../maintenance-email/header-image.js\";\nimport { applyReportTypeHeadline } from \"../header-image/index.js\";\n\n/**\n * The scores a render cannot proceed without, or a message naming the exact\n * Airtable cause. Exported so `sendOne` can fail fast on it BEFORE the header\n * fetch and the sharp downscale, while the renderer stays independently safe for\n * a caller that did not — one message, one rule, two call sites.\n */\nexport function requireLighthouse(report: ReportRow): NonNullable<ReportRow[\"lighthouse\"]> {\n if (!report.lighthouse) {\n throw new Error(\n `Report ${report.reportId} has no Lighthouse scores — all four cells ` +\n `(Lighthouse — Performance / Accessibility / Best Practices / SEO) must be numeric ` +\n `on the Reports row; one non-numeric or blank cell nulls all four`,\n );\n }\n return report.lighthouse;\n}\n\n/**\n * Assemble and render EXACTLY the email a send would produce, from a stored\n * report row plus the site's clean header plate.\n *\n * Lifted verbatim out of `sendOne`, which is the point: it is now the ONE path\n * from a row to rendered output, so the on-demand re-render behind the console's\n * report preview cannot drift from what the client actually receives. A preview\n * whose only job is fidelity is worthless if it renders through a second code\n * path that agrees with the sender by coincidence.\n *\n * Deliberately NOT included: recipient resolution, the health gate, and the\n * Resend call. Those are send concerns — a preview must be renderable for a\n * report that is not yet approvable, and must never be able to email anyone.\n *\n * `headerPlateBytes` is the CLEAN plate, not a finished header: this stamps the\n * report type's headline onto it and downscales the result, which is why the\n * caller can hand over Airtable's attachment bytes or Turso's stored BLOB\n * interchangeably.\n */\nexport async function renderReportFromRow(\n site: WebsiteRow,\n report: ReportRow,\n headerPlateBytes: Uint8Array,\n): Promise<RenderedReportEmail> {\n // The stored header is the CLEAN plate; stamp this report type's headline\n // onto it (all four types are registered — see assets/index.ts HEADLINE_FILES),\n // then downscale the (often multi-MB / 2400px+) result to email display\n // size, getting back display dims + a placeholder color so the template can\n // reserve the box.\n const withHeadline = await applyReportTypeHeadline(headerPlateBytes, report.reportType);\n const header = await prepareHeaderImage(withHeadline);\n\n const slug = siteSlug(site.name);\n const cidName = `${slug}-header`;\n const gaPeriodDays =\n report.reportType === \"Announcement\" ? 30 : windowDays(report.periodStart, report.periodEnd);\n const reportData: ReportData = {\n siteName: site.name,\n siteUrl: site.url,\n reportType: report.reportType,\n completedOn: report.completedOn ? new Date(report.completedOn) : new Date(),\n lighthouse: requireLighthouse(report),\n gaUsersCurrent: report.gaUsersCurrent ?? undefined,\n gaUsersPrevious: report.gaUsersPrevious ?? undefined,\n gaPeriodDays,\n searchPosition:\n report.searchFoundPage1 && report.searchPosition !== null ? report.searchPosition : undefined,\n lastTestedDate: report.lastTestedDate ? new Date(report.lastTestedDate) : null,\n commentary: report.commentary,\n copy: resolveCopy(site),\n headerImageCid: cidName,\n headerWidth: header.displayWidth,\n headerHeight: header.displayHeight,\n headerBgColor: header.placeholderColor,\n // Announcement-only: re-derive cadence + improvements from the site row so the SENT email\n // keeps its cadence copy + improvement callouts (not stored on the Reports row).\n ...(report.reportType === \"Announcement\" ? announcementSiteExtras(site) : {}),\n };\n\n return renderReportEmail(reportData, {\n header,\n cidName,\n subjectOverride: report.subjectOverride ?? undefined,\n });\n}\n\n/** Whole days spanned by an ISO period window, or undefined when either bound is missing or the\n * span isn't positive. Drives the analytics trend's \"vs the previous N days\" label. */\nexport function windowDays(start: string | null, end: string | null): number | undefined {\n if (!start || !end) return undefined;\n const ms = new Date(end).getTime() - new Date(start).getTime();\n if (!Number.isFinite(ms) || ms <= 0) return undefined;\n return Math.round(ms / (24 * 60 * 60 * 1000));\n}\n"],"mappings":"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","names":[]}
|