@reddoorla/maintenance 0.86.0 → 0.88.2
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-QG5ICI3Y.js +9 -0
- package/dist/{announce-MXTUQ6C7.js → announce-GKD5YJNB.js} +49 -28
- package/dist/announce-GKD5YJNB.js.map +1 -0
- package/dist/attachments-NLZIJ5NU.js +9 -0
- package/dist/{blux-ZGDSHI3U.js → blux-DN4MI532.js} +5 -5
- package/dist/{bump-deps-EC74CPLS.js → bump-deps-KGH5UYSF.js} +4 -4
- package/dist/{chunk-N45M3FDK.js → chunk-6I6L3CMX.js} +4 -4
- package/dist/{chunk-KTH5ZODA.js → chunk-7BZUOZPU.js} +2 -2
- package/dist/{chunk-2EVEFOZY.js → chunk-7FFUBF4I.js} +6 -3
- package/dist/chunk-7FFUBF4I.js.map +1 -0
- 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-X3B723J4.js → chunk-7TMWBXX7.js} +184 -25
- package/dist/chunk-7TMWBXX7.js.map +1 -0
- package/dist/{chunk-3VQWSE3F.js → chunk-7UAYMAKR.js} +4 -4
- package/dist/{chunk-W7Z3BGCI.js → chunk-A2UNJIKH.js} +15 -7
- package/dist/chunk-A2UNJIKH.js.map +1 -0
- package/dist/{chunk-F5KM4LEQ.js → chunk-ANJNTAMM.js} +4 -70
- package/dist/chunk-ANJNTAMM.js.map +1 -0
- package/dist/{chunk-2ZPT7GLV.js → chunk-B23FFWU4.js} +4 -4
- package/dist/chunk-B23FFWU4.js.map +1 -0
- package/dist/{chunk-DJZWYIPP.js → chunk-BGTYPVLT.js} +1 -1
- package/dist/chunk-BGTYPVLT.js.map +1 -0
- package/dist/chunk-BRYXFH77.js +70 -0
- package/dist/chunk-BRYXFH77.js.map +1 -0
- package/dist/{chunk-2NDRHXMR.js → chunk-DGZWFI2G.js} +18 -141
- package/dist/chunk-DGZWFI2G.js.map +1 -0
- package/dist/{chunk-ACBRLLFT.js → chunk-F3JWOPTG.js} +7 -3
- package/dist/chunk-F3JWOPTG.js.map +1 -0
- package/dist/chunk-GQDDWYU2.js +372 -0
- package/dist/chunk-GQDDWYU2.js.map +1 -0
- package/dist/{chunk-Z7AXYKQB.js → chunk-IY6256GK.js} +1 -1
- package/dist/{chunk-BYK66EWO.js → chunk-J3WEZGIN.js} +52 -46
- package/dist/chunk-J3WEZGIN.js.map +1 -0
- package/dist/{chunk-BNM5POSJ.js → chunk-JRHIFA5R.js} +2 -2
- package/dist/{chunk-ZKXSX4LO.js → chunk-K6SLF6GI.js} +6 -6
- package/dist/{chunk-CQR4LIM7.js → chunk-KJGKZLCT.js} +7 -3
- package/dist/{chunk-CQR4LIM7.js.map → chunk-KJGKZLCT.js.map} +1 -1
- package/dist/{chunk-PPE5KZJK.js → chunk-KMCZA7CH.js} +13 -3
- package/dist/chunk-KMCZA7CH.js.map +1 -0
- package/dist/{chunk-3SF7D4DY.js → chunk-MFHS7IQ2.js} +36 -1
- package/dist/{chunk-3SF7D4DY.js.map → chunk-MFHS7IQ2.js.map} +1 -1
- package/dist/{chunk-SNSTJLQW.js → chunk-NO74DXDP.js} +48 -2
- package/dist/chunk-NO74DXDP.js.map +1 -0
- package/dist/{chunk-C6B4RHXR.js → chunk-O3GT46R2.js} +8 -2
- package/dist/chunk-O3GT46R2.js.map +1 -0
- package/dist/{chunk-ITIOFN7A.js → chunk-P6AORG32.js} +29 -10
- package/dist/chunk-P6AORG32.js.map +1 -0
- package/dist/{chunk-SLUQ7R6I.js → chunk-PR343K7F.js} +5 -5
- package/dist/{chunk-P2FE5AI6.js → chunk-Q47DU255.js} +4 -4
- package/dist/{chunk-EVBCK2KC.js → chunk-RARREDQE.js} +2 -1
- package/dist/{chunk-EVBCK2KC.js.map → chunk-RARREDQE.js.map} +1 -1
- package/dist/chunk-RUPPWAD6.js +7 -0
- package/dist/chunk-RUPPWAD6.js.map +1 -0
- package/dist/{chunk-VSBFM7DS.js → chunk-SJOXWJ5Y.js} +3 -3
- package/dist/{chunk-TERE5SDF.js → chunk-ULHPALI5.js} +4 -4
- package/dist/{chunk-XUGSZP7Z.js → chunk-W45FZEGE.js} +2 -2
- package/dist/{chunk-JHGHLKFS.js → chunk-WS6NU475.js} +2 -2
- package/dist/chunk-XP5WM2LV.js +146 -0
- package/dist/chunk-XP5WM2LV.js.map +1 -0
- package/dist/{chunk-ILUNZHYV.js → chunk-YFAXOD7Z.js} +10 -6
- package/dist/chunk-YFAXOD7Z.js.map +1 -0
- package/dist/{chunk-6TWWY7ZU.js → chunk-Z3JXLULD.js} +2 -2
- package/dist/cli/bin.js +34 -29
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +4 -0
- package/dist/cli/commands/audit.js +25 -18
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/client-JYKPVCJX.js +11 -0
- package/dist/configs/playwright-a11y.js +1 -1
- package/dist/{convert-to-pnpm-6L6P74WR.js → convert-to-pnpm-NWB5FCQ2.js} +4 -4
- package/dist/{db-B35OGV3G.js → db-V6UIS433.js} +124 -29
- package/dist/db-V6UIS433.js.map +1 -0
- package/dist/{digest-BL6UJYKH.js → digest-2JEAEJK6.js} +60 -35
- package/dist/digest-2JEAEJK6.js.map +1 -0
- package/dist/digest-state-CRZRNGDI.js +25 -0
- package/dist/digest-state-CRZRNGDI.js.map +1 -0
- package/dist/digest-state-F37XUCS5.js +13 -0
- package/dist/{dump-P6EBSIOH.js → dump-EPL7AZFR.js} +38 -13
- package/dist/dump-EPL7AZFR.js.map +1 -0
- package/dist/{email-VTTTKKL5.js → email-SL7464YI.js} +16 -6
- package/dist/{email-VTTTKKL5.js.map → email-SL7464YI.js.map} +1 -1
- package/dist/{ensure-site-DFTB7MN3.js → ensure-site-FU6527OU.js} +19 -11
- package/dist/ensure-site-FU6527OU.js.map +1 -0
- package/dist/fleet-state-R5LU7IC3.js +41 -0
- package/dist/{forms-notify-target-75U6OU7L.js → forms-notify-target-SGDGXXK5.js} +10 -7
- package/dist/forms-notify-target-SGDGXXK5.js.map +1 -0
- package/dist/{github-signals-IJUPGCUX.js → github-signals-GWGCKEE2.js} +13 -12
- package/dist/{github-signals-IJUPGCUX.js.map → github-signals-GWGCKEE2.js.map} +1 -1
- package/dist/{header-image-AWLLQA4L.js → header-image-7A7XLVEU.js} +8 -8
- 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-UVX6R5N5.js} +4 -4
- package/dist/health-mirror-GCDN7BJL.js +46 -0
- package/dist/health-mirror-GCDN7BJL.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 +245 -108
- package/dist/index.js.map +1 -1
- package/dist/{init-NJEOEO7I.js → init-FGTYNGUI.js} +11 -11
- package/dist/{launch-XNAX427U.js → launch-5H7LPEGP.js} +59 -29
- package/dist/launch-5H7LPEGP.js.map +1 -0
- package/dist/{lighthouse-airtable-LBVIYYP5.js → lighthouse-airtable-3GUWHETW.js} +3 -3
- package/dist/migrate-YITCXBLS.js +7 -0
- package/dist/{notify-C645PGD5.js → notify-4HP4GZ2H.js} +5 -5
- package/dist/{onboard-GK4PHPNI.js → onboard-BKCYBAVW.js} +4 -4
- package/dist/{orchestrate-77UO4BYJ.js → orchestrate-JNKCWU5K.js} +14 -13
- package/dist/parity-NBYZUBFU.js +14 -0
- package/dist/pdf-N5QLQ5ZQ.js +38 -0
- package/dist/pdf-N5QLQ5ZQ.js.map +1 -0
- package/dist/{pipeline-S6CVHDGB.js → pipeline-WYTXSLHX.js} +3 -3
- package/dist/{preflight-6WBNU2GZ.js → preflight-IMTYDDD4.js} +15 -14
- package/dist/{preflight-6WBNU2GZ.js.map → preflight-IMTYDDD4.js.map} +1 -1
- package/dist/{prismic-ci-3RHOZUUT.js → prismic-ci-MY2U6NN7.js} +7 -7
- package/dist/{prismic-models-QBGUT2Q3.js → prismic-models-NJN5JORF.js} +15 -10
- package/dist/prismic-models-NJN5JORF.js.map +1 -0
- package/dist/prospect/types.d.ts +281 -0
- package/dist/prospect/types.js +1 -0
- package/dist/{prospect-audit-IUNCJ2LV.js → prospect-audit-JMBRVXSG.js} +32 -13
- package/dist/prospect-audit-JMBRVXSG.js.map +1 -0
- package/dist/recipes/sync-configs.js +2 -2
- package/dist/render-VX72AESY.js +10 -0
- package/dist/render-VX72AESY.js.map +1 -0
- package/dist/render-from-row-DOYJDO2L.js +17 -0
- package/dist/render-from-row-DOYJDO2L.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/{replay-C2LXMJZP.js → replay-4S6ENYCH.js} +3 -3
- package/dist/{report-4EVNDHOZ.js → report-TALY5P3L.js} +63 -21
- package/dist/report-TALY5P3L.js.map +1 -0
- package/dist/report-mirror-AAK3MXHY.js +55 -0
- package/dist/report-mirror-AAK3MXHY.js.map +1 -0
- package/dist/rerender-3FM6DNRH.js +32 -0
- package/dist/rerender-3FM6DNRH.js.map +1 -0
- package/dist/schema-75PJECAE.js +18 -0
- package/dist/schema-75PJECAE.js.map +1 -0
- package/dist/{self-updating-6DWI2Z56.js → self-updating-NTCCWJYZ.js} +4 -4
- package/dist/{selftest-RC36MTKP.js → selftest-NZTR4SM2.js} +26 -24
- package/dist/{selftest-RC36MTKP.js.map → selftest-NZTR4SM2.js.map} +1 -1
- package/dist/site-mirror-Q763PLGG.js +62 -0
- package/dist/site-mirror-Q763PLGG.js.map +1 -0
- package/dist/{smoke-suite-VYY2LXM2.js → smoke-suite-4JFZZYVW.js} +4 -4
- package/dist/{spam-classifier-XLBFCO7G.js → spam-classifier-T5H4VTP6.js} +4 -2
- package/dist/spam-classifier-T5H4VTP6.js.map +1 -0
- package/dist/{submissions-Q7DRFOCD.js → submissions-TEHPBSZJ.js} +5 -5
- package/dist/{svelte-codemods-25J2FO22.js → svelte-codemods-QAPDZC6F.js} +4 -4
- package/dist/{sync-34HNZZ56.js → sync-GBFV45WX.js} +6 -4
- package/dist/{sync-34HNZZ56.js.map → sync-GBFV45WX.js.map} +1 -1
- package/dist/{sync-configs-3SE6PSCO.js → sync-configs-34JIA6KZ.js} +6 -6
- package/dist/{upgrade-WBRQ62FX.js → upgrade-IR2OO3KP.js} +4 -4
- package/dist/{webflow-IRKOIT7S.js → webflow-624FEBFD.js} +4 -4
- package/dist/{webhook-VOGOW3W6.js → webhook-XM6EV4XT.js} +2 -2
- package/dist/{websites-SXWTP72S.js → websites-D6WYFAES.js} +2 -2
- package/dist/websites-D6WYFAES.js.map +1 -0
- package/dist/write-audits-to-airtable-OBHGR7DQ.js +17 -0
- package/dist/write-audits-to-airtable-OBHGR7DQ.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-2EVEFOZY.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-C6B4RHXR.js.map +0 -1
- package/dist/chunk-DJZWYIPP.js.map +0 -1
- package/dist/chunk-F5KM4LEQ.js.map +0 -1
- package/dist/chunk-ILUNZHYV.js.map +0 -1
- package/dist/chunk-ITIOFN7A.js.map +0 -1
- package/dist/chunk-PPE5KZJK.js.map +0 -1
- package/dist/chunk-SNSTJLQW.js.map +0 -1
- package/dist/chunk-W7Z3BGCI.js.map +0 -1
- package/dist/chunk-X3B723J4.js.map +0 -1
- package/dist/client-AUE3XUPO.js +0 -11
- package/dist/db-B35OGV3G.js.map +0 -1
- package/dist/digest-BL6UJYKH.js.map +0 -1
- package/dist/dump-P6EBSIOH.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/migrate-GQH237A6.js +0 -7
- package/dist/parity-MWS24A3H.js +0 -12
- package/dist/prismic-models-QBGUT2Q3.js.map +0 -1
- package/dist/prospect-audit-IUNCJ2LV.js.map +0 -1
- package/dist/render-AQAVQDPZ.js +0 -10
- package/dist/report-4EVNDHOZ.js.map +0 -1
- package/dist/write-audits-to-airtable-MBEILLPU.js +0 -14
- /package/dist/{airtable-G7WBQQMI.js.map → airtable-QG5ICI3Y.js.map} +0 -0
- /package/dist/{client-AUE3XUPO.js.map → attachments-NLZIJ5NU.js.map} +0 -0
- /package/dist/{blux-ZGDSHI3U.js.map → blux-DN4MI532.js.map} +0 -0
- /package/dist/{bump-deps-EC74CPLS.js.map → bump-deps-KGH5UYSF.js.map} +0 -0
- /package/dist/{chunk-N45M3FDK.js.map → chunk-6I6L3CMX.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-Z7AXYKQB.js.map → chunk-IY6256GK.js.map} +0 -0
- /package/dist/{chunk-BNM5POSJ.js.map → chunk-JRHIFA5R.js.map} +0 -0
- /package/dist/{chunk-ZKXSX4LO.js.map → chunk-K6SLF6GI.js.map} +0 -0
- /package/dist/{chunk-SLUQ7R6I.js.map → chunk-PR343K7F.js.map} +0 -0
- /package/dist/{chunk-P2FE5AI6.js.map → chunk-Q47DU255.js.map} +0 -0
- /package/dist/{chunk-VSBFM7DS.js.map → chunk-SJOXWJ5Y.js.map} +0 -0
- /package/dist/{chunk-TERE5SDF.js.map → chunk-ULHPALI5.js.map} +0 -0
- /package/dist/{chunk-XUGSZP7Z.js.map → chunk-W45FZEGE.js.map} +0 -0
- /package/dist/{chunk-JHGHLKFS.js.map → chunk-WS6NU475.js.map} +0 -0
- /package/dist/{chunk-6TWWY7ZU.js.map → chunk-Z3JXLULD.js.map} +0 -0
- /package/dist/{import-airtable-HE43FB4H.js.map → client-JYKPVCJX.js.map} +0 -0
- /package/dist/{convert-to-pnpm-6L6P74WR.js.map → convert-to-pnpm-NWB5FCQ2.js.map} +0 -0
- /package/dist/{lighthouse-airtable-LBVIYYP5.js.map → digest-state-F37XUCS5.js.map} +0 -0
- /package/dist/{migrate-GQH237A6.js.map → fleet-state-R5LU7IC3.js.map} +0 -0
- /package/dist/{header-image-AWLLQA4L.js.map → header-image-7A7XLVEU.js.map} +0 -0
- /package/dist/{health-endpoint-WJC2UMYS.js.map → health-endpoint-UVX6R5N5.js.map} +0 -0
- /package/dist/{notify-C645PGD5.js.map → import-airtable-OHVY344G.js.map} +0 -0
- /package/dist/{init-NJEOEO7I.js.map → init-FGTYNGUI.js.map} +0 -0
- /package/dist/{orchestrate-77UO4BYJ.js.map → lighthouse-airtable-3GUWHETW.js.map} +0 -0
- /package/dist/{parity-MWS24A3H.js.map → migrate-YITCXBLS.js.map} +0 -0
- /package/dist/{pipeline-S6CVHDGB.js.map → notify-4HP4GZ2H.js.map} +0 -0
- /package/dist/{onboard-GK4PHPNI.js.map → onboard-BKCYBAVW.js.map} +0 -0
- /package/dist/{render-AQAVQDPZ.js.map → orchestrate-JNKCWU5K.js.map} +0 -0
- /package/dist/{spam-classifier-XLBFCO7G.js.map → parity-NBYZUBFU.js.map} +0 -0
- /package/dist/{websites-SXWTP72S.js.map → pipeline-WYTXSLHX.js.map} +0 -0
- /package/dist/{prismic-ci-3RHOZUUT.js.map → prismic-ci-MY2U6NN7.js.map} +0 -0
- /package/dist/{write-audits-to-airtable-MBEILLPU.js.map → prospect/types.js.map} +0 -0
- /package/dist/{replay-C2LXMJZP.js.map → replay-4S6ENYCH.js.map} +0 -0
- /package/dist/{self-updating-6DWI2Z56.js.map → self-updating-NTCCWJYZ.js.map} +0 -0
- /package/dist/{smoke-suite-VYY2LXM2.js.map → smoke-suite-4JFZZYVW.js.map} +0 -0
- /package/dist/{submissions-Q7DRFOCD.js.map → submissions-TEHPBSZJ.js.map} +0 -0
- /package/dist/{svelte-codemods-25J2FO22.js.map → svelte-codemods-QAPDZC6F.js.map} +0 -0
- /package/dist/{sync-configs-3SE6PSCO.js.map → sync-configs-34JIA6KZ.js.map} +0 -0
- /package/dist/{upgrade-WBRQ62FX.js.map → upgrade-IR2OO3KP.js.map} +0 -0
- /package/dist/{webflow-IRKOIT7S.js.map → webflow-624FEBFD.js.map} +0 -0
- /package/dist/{webhook-VOGOW3W6.js.map → webhook-XM6EV4XT.js.map} +0 -0
|
@@ -1 +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"],"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 our latest framework — it's faster, more secure, and built to last.\",\n announceCadence: \"After each one we'll send you a short report like this —\",\n announceCadenceNote: \"there's nothing you need to do.\",\n announceOpenDoor:\n \"And if you'd ever like to expand the scope, add features, or freshen anything up, just let us know.\",\n};\n\n/** Trim an override to null when blank (mirrors the trim-to-null handling). */\nfunction override(v: string | null): string | null {\n if (typeof v !== \"string\") return null;\n const t = v.trim();\n return t.length > 0 ? t : null;\n}\n\n/**\n * Resolve a site's effective copy: DEFAULT_COPY with the three per-site narrative\n * overrides applied. Only maintenanceIntro/contact/footer are per-site (M6a §2);\n * everything else is the shared default. PURE.\n */\n/** Split an operator override into lines: tolerate CRLF, drop blank lines (a stray\n * blank in the Airtable cell shouldn't render an empty address row). */\nfunction splitLines(s: string): string[] {\n return s.split(/\\r?\\n/).filter((l) => l.trim().length > 0);\n}\n\nexport function resolveCopy(site: WebsiteRow): ResolvedCopy {\n const intro = override(site.copyIntro);\n const contact = override(site.copyContact);\n const footer = override(site.copyFooter);\n const footerLines = footer ? splitLines(footer) : null;\n return {\n ...DEFAULT_COPY,\n maintenanceIntro: intro ?? DEFAULT_COPY.maintenanceIntro,\n contact: contact ? splitLines(contact) : DEFAULT_COPY.contact,\n footerOrg: footerLines?.[0] ?? DEFAULT_COPY.footerOrg,\n footerAddress: footerLines ? footerLines.slice(1) : DEFAULT_COPY.footerAddress,\n };\n}\n","import { readFile } from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nexport const CHECK_CID = \"rd-check-png\";\nexport const BLURRED_CID = \"rd-blurred-tests-jpg\";\n\nexport type BundledImage = {\n bytes: Uint8Array;\n contentType: string;\n cid: string;\n filename: string;\n};\n\n// Walk up from the current module's URL looking for the assets dir in either\n// the dev layout (src/reports/maintenance-email/assets/) or the published\n// layout (dist/reports/maintenance-email/assets/). REQUIRED because tsup\n// inlines this module into dist/cli/bin.js — so `import.meta.url`-based\n// sibling resolution looks in dist/cli/ for the PNGs and fails with ENOENT.\n// Regression that shipped in 0.10.0–0.10.1; tests passed in dev because\n// vitest evaluates the source file where import.meta.url is already correct.\nlet cachedAssetsDir: string | null = null;\nfunction resolveAssetsDir(): string {\n if (cachedAssetsDir) return cachedAssetsDir;\n let dir = dirname(fileURLToPath(import.meta.url));\n while (true) {\n // Source layout preferred — single source of truth in the workspace\n // and the only one present in dev/test environments.\n const srcCandidate = join(dir, \"src\", \"reports\", \"maintenance-email\", \"assets\", \"check.png\");\n if (existsSync(srcCandidate)) {\n cachedAssetsDir = dirname(srcCandidate);\n return cachedAssetsDir;\n }\n // Published layout — only `dist/` ships per package.json#files, so\n // consumers fall through to here.\n const distCandidate = join(dir, \"dist\", \"reports\", \"maintenance-email\", \"assets\", \"check.png\");\n if (existsSync(distCandidate)) {\n cachedAssetsDir = dirname(distCandidate);\n return cachedAssetsDir;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n throw new Error(\n `loadBundledImages: could not locate maintenance-email assets dir by walking up from ${fileURLToPath(import.meta.url)}. Checked both src/ and dist/ layouts.`,\n );\n }\n dir = parent;\n }\n}\n\n/**\n * Read the bundled image bytes from disk. Both Maintenance and Testing\n * variants reference `check.png`; only the Maintenance variant references\n * `blurredTests.jpg`.\n */\nexport async function loadBundledImages(): Promise<{\n check: BundledImage;\n blurred: BundledImage;\n}> {\n const assetsDir = resolveAssetsDir();\n const [check, blurred] = await Promise.all([\n readFile(join(assetsDir, \"check.png\")),\n readFile(join(assetsDir, \"blurredTests.jpg\")),\n ]);\n return {\n check: {\n bytes: new Uint8Array(check),\n contentType: \"image/png\",\n cid: CHECK_CID,\n filename: \"check.png\",\n },\n blurred: {\n bytes: new Uint8Array(blurred),\n contentType: \"image/jpeg\",\n cid: BLURRED_CID,\n filename: \"blurredTests.jpg\",\n },\n };\n}\n","import { escapeHtml } from \"../util/html.js\";\nimport { CHECK_CID } from \"./maintenance-email/assets/index.js\";\nimport type { ReportData } from \"./types.js\";\n\n/**\n * Shared MJML section builders for the report family of emails (maintenance/testing report,\n * announcement). Centralizing them here guarantees the announcement renders the SAME polished\n * components as the monthly report — the checklist rows, the full Lighthouse block, and the\n * analytics block — so the two can't drift in design. PURE string builders; no I/O.\n *\n * Escaping: callers pass already-trusted copy for fixed labels, but any site/operator string\n * (check labels, trailing notes) is escaped here via `escapeXml`.\n */\nexport const escapeXml = escapeHtml;\n\n// Brand palette shared across the report-email family (maintenance/testing,\n// launch, announcement) so a template can't drift onto hand-typed literals.\nexport const RED = \"#C00\";\nexport const GREY = \"#757575\";\nconst BORDER = \"#CCCCCC\";\nconst TREND_UP = \"#2E7D32\"; // positive green — growth reads as good\nconst TREND_NEUTRAL = GREY; // muted grey — dips/flat aren't failures (brand red is reserved)\n\n// The report's bundled green check (cid:rd-check-png), attached inline by orchestrate.ts at\n// send time. Standalone previews (no attachments) show the image's alt instead.\nconst CHECK_PNG = `cid:${CHECK_CID}`;\n\n/** Thousands-grouped user/visitor count. */\nexport function fmtUsers(n: number): string {\n return n.toLocaleString(\"en-US\");\n}\n\n/**\n * A checklist \"table\": one ruled row per label, the label on the left and the green check\n * right-aligned, matching the monthly report. `background` tints the band (white for the\n * maintenance list, #F4F4F4 for the testing list); `lastPaddingBottom` is the trailing gap\n * under the final row. Labels are escaped. PURE.\n */\nexport function checklistRowsSection(\n rows: string[],\n opts: { background: string; lastPaddingBottom: string },\n): string {\n return rows\n .map((label, i) => {\n const isLast = i === rows.length - 1;\n const border = isLast ? \"\" : ` border-bottom=\"solid ${BORDER} 1px\"`;\n const lastPad = isLast ? ` padding-bottom=\"${opts.lastPaddingBottom}\"` : \"\";\n return `\n <mj-section background-color=\"${opts.background}\" padding=\"0px\"${lastPad}>\n <mj-group>\n <mj-column padding-left=\"0px\" width=\"90%\"${border}>\n <mj-text height=\"25px\" padding-left=\"0px\" color=\"${GREY}\" padding-top=\"20px\" padding-bottom=\"7.5px\" font-size=\"16px\">${escapeXml(label)}</mj-text>\n </mj-column>\n <mj-column width=\"10%\"${border} padding-top=\"15px\">\n <mj-image align=\"right\" padding-right=\"0px\" width=\"20px\" height=\"20px\" padding-top=\"2.5px\" padding-bottom=\"15px\" src=\"${CHECK_PNG}\" />\n </mj-column>\n </mj-group>\n </mj-section>`;\n })\n .join(\"\");\n}\n\n/** The four Lighthouse scores with their client-facing labels and acceptable/ideal bands.\n * Ideal always tops at 100 (the metric's ceiling). */\nconst LIGHTHOUSE_ROWS: ReadonlyArray<{\n label: string;\n key: keyof ReportData[\"lighthouse\"];\n range: string;\n}> = [\n { label: \"Performance\", key: \"performance\", range: \"Acceptable 50–89 // Ideal 90–100\" },\n { label: \"Readability (A11y)\", key: \"accessibility\", range: \"Acceptable 80–99 // Ideal 100\" },\n { label: \"Best Practices\", key: \"bestPractices\", range: \"Acceptable 60–79 // Ideal 80–100\" },\n { label: \"Site Structure\", key: \"seo\", range: \"Acceptable 50–89 // Ideal 90–100\" },\n];\n\n/**\n * The full \"LIGHTHOUSE SCORES*\" block: each score as a big red number under its label with the\n * acceptable/ideal band beneath, ruled between scores, closed by the explanatory footnote.\n * `background` tints the section (default #F4F4F4, matching the report). When `pad` is given the\n * band's top/bottom padding moves to the section (symmetric bands for the announcement); omitting\n * it keeps the report's original inner paddings. PURE.\n */\nexport function lighthouseScoresSection(\n lighthouse: ReportData[\"lighthouse\"],\n opts: { background?: string; pad?: string } = {},\n): string {\n const background = opts.background ?? \"#F4F4F4\";\n const sectionPad = opts.pad ? ` padding-top=\"${opts.pad}\" padding-bottom=\"${opts.pad}\"` : \"\";\n const labelTop = opts.pad ?? \"55px\";\n const footnoteBottom = opts.pad ? \"0px\" : \"36px\";\n const rows = LIGHTHOUSE_ROWS.map(\n ({ label, key, range }, i) => `\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"300\" padding-top=\"25px\">${label}</mj-text>\n <mj-text color=\"${RED}\" font-size=\"44px\" font-weight=\"400\" padding-top=\"0px\">${lighthouse[key]}</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" padding-top=\"0px\" padding-bottom=\"36px\">${range}</mj-text>${\n i < LIGHTHOUSE_ROWS.length - 1\n ? `\n <mj-divider border-width=\"1px\" border-style=\"solid\" border-color=\"${BORDER}\" padding=\"0\" />`\n : \"\"\n }`,\n ).join(\"\");\n return `\n <mj-section background-color=\"${background}\"${sectionPad}>\n <mj-column>\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"${labelTop}\">LIGHTHOUSE SCORES*</mj-text>${rows}\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" padding-top=\"24px\" padding-bottom=\"${footnoteBottom}\" line-height=\"20px\">*A Lighthouse score is a numerical measure provided by <a href=\"https://developer.chrome.com/docs/lighthouse/overview\" style=\"color:${RED}; text-decoration:underline;\">Google's Lighthouse tool</a>, which evaluates various aspects of a web page's quality.</mj-text>\n </mj-column>\n </mj-section>`;\n}\n\n/** The line under \"{N} Users\": a directional trend vs the previous period when both numbers\n * are real, else a graceful fallback. `undefined` = GA unavailable (distinct from a real 0).\n * Up = green; down/flat = muted grey (a traffic dip isn't a failure). PURE. */\nexport function analyticsTrendLine(\n cur: number | undefined,\n prev: number | undefined,\n periodDays?: number,\n): string {\n // The prior window the trend compares against: a concrete \"the previous N days\" when the\n // caller knows the window length, else the generic \"last period\" (keeps the label honest for\n // callers — and tests — that don't supply it).\n const priorLabel =\n periodDays && periodDays > 0 ? `the previous ${periodDays} days` : \"last period\";\n if (cur === undefined || prev === undefined) {\n return trendLine(TREND_NEUTRAL, `Last Period: ${prev !== undefined ? fmtUsers(prev) : \"—\"}`);\n }\n if (prev === 0) {\n return cur > 0\n ? trendLine(TREND_UP, \"▲ New this period (0 last period)\")\n : trendLine(TREND_NEUTRAL, \"Last Period: 0\");\n }\n const pct = Math.round(((cur - prev) / prev) * 100);\n const range = `(${fmtUsers(prev)} → ${fmtUsers(cur)})`;\n if (pct > 0) return trendLine(TREND_UP, `▲ ${pct}% vs ${priorLabel} ${range}`);\n if (pct < 0) return trendLine(TREND_NEUTRAL, `▼ ${Math.abs(pct)}% vs ${priorLabel} ${range}`);\n return trendLine(TREND_NEUTRAL, `No change vs ${priorLabel} (${fmtUsers(prev)})`);\n}\n\n/** A 16px coloured line (the trend, and the announcement's search line). */\nfunction trendLine(color: string, text: string): string {\n return `<mj-text color=\"${color}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\">${text}</mj-text>`;\n}\n\n/** A 12px muted footnote line (the SEO call-to-action / Lighthouse note style). */\nfunction footnoteLine(text: string): string {\n return `<mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" padding-top=\"24px\" padding-bottom=\"36px\" line-height=\"20px\">${text}</mj-text>`;\n}\n\n/** True when the GA user count is worth showing: a count exists AND the previous window is not a\n * literal 0. A zero last period means the tag wasn't collecting for a full window yet (new\n * property, mid-window install), so any count/trend is partial-window noise — suppress it until\n * a real comparison exists. `previous === undefined` (GA gave no prior window) still shows. PURE. */\nexport function gaCountVisible(opts: {\n current?: number | undefined;\n previous?: number | undefined;\n}): boolean {\n return opts.current !== undefined && opts.previous !== 0;\n}\n\n/** True when the analytics block has at least one real datum to show — a visible GA user count\n * ({@link gaCountVisible}) or a body line (e.g. the announcement's page-1 search callout). The\n * static SEO call-to-action footnote alone does NOT qualify: a block with no data is hidden\n * rather than rendered empty. PURE. */\nexport function hasAnalyticsData(opts: {\n current?: number | undefined;\n previous?: number | undefined;\n bodyLines?: string[];\n}): boolean {\n return gaCountVisible(opts) || (opts.bodyLines?.length ?? 0) > 0;\n}\n\n/**\n * The \"ANALYTICS\" block: a big red user count, the trend line, then any 16px `bodyLines`\n * (e.g. the announcement's Google-position line) and 12px muted `footnoteLines` (e.g. the\n * report's SEO call-to-action). Callers pass already-escaped line text. PURE.\n *\n * Returns \"\" when there's no data ({@link hasAnalyticsData} false) — an empty \"— Users\" block\n * reads as broken, so the section (and its data-contextual SEO footnote) is omitted. When a body\n * line exists but no GA count (a GA-less site still ranking on page 1), the user count + trend\n * are suppressed and only the body shows under the label.\n */\nexport function analyticsSection(opts: {\n current?: number | undefined;\n previous?: number | undefined;\n /** Length in days of the current window; drives the trend's \"vs the previous N days\" label. */\n periodDays?: number | undefined;\n background: string;\n bodyLines?: string[];\n footnoteLines?: string[];\n pad?: string;\n}): string {\n if (!hasAnalyticsData(opts)) return \"\";\n const body = (opts.bodyLines ?? []).map((l) => trendLine(TREND_NEUTRAL, l)).join(\"\\n \");\n const footnotes = (opts.footnoteLines ?? []).map(footnoteLine).join(\"\\n \");\n const sectionPad = opts.pad ? ` padding-top=\"${opts.pad}\" padding-bottom=\"${opts.pad}\"` : \"\";\n const labelTop = opts.pad ?? \"75px\";\n const usersBlock =\n opts.current !== undefined && gaCountVisible(opts)\n ? `\n <mj-text color=\"${RED}\" font-size=\"44px\" font-weight=\"400\">${fmtUsers(opts.current)} Users</mj-text>\n ${analyticsTrendLine(opts.current, opts.previous, opts.periodDays)}`\n : \"\";\n return `\n <mj-section background-color=\"${opts.background}\"${sectionPad}>\n <mj-column>\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"${labelTop}\">ANALYTICS</mj-text>${usersBlock}\n ${body}\n ${footnotes}\n </mj-column>\n </mj-section>`;\n}\n","import type { ReportData } from \"../types.js\";\nimport { DEFAULT_COPY, type ResolvedCopy } from \"../copy.js\";\nimport { BLURRED_CID } from \"./assets/index.js\";\nimport {\n checklistRowsSection,\n lighthouseScoresSection,\n analyticsSection,\n RED,\n GREY,\n} from \"../email-sections.js\";\nimport { escapeHtml } from \"../../util/html.js\";\nimport { isHttpUrl } from \"../../util/url.js\";\n\n/**\n * Escape operator/site-controlled strings before interpolating into the MJML markup.\n * MJML parses as XML with `validationLevel: \"strict\"`. Under mjml@4.18 a raw `&`, `<`,\n * or `>` does NOT throw — it passes straight through into the rendered output, so an\n * unescaped value (e.g. a site name \"Brown & Co\", a URL, or commentary) silently\n * injects HTML/markup into the email. A raw `\"` inside an ATTRIBUTE value (e.g. the\n * image `href`/`alt`) is the one that throws — it terminates the attribute and trips a\n * parse error that blocks the send. So we escape for two reasons: prevent\n * HTML/markup injection in text, and prevent the attribute-quote parse error. Apply\n * to every interpolation of siteName / siteUrl / commentary / copy.\n *\n * This IS `src/util/html.ts`'s `escapeHtml` (the strict-XML set is identical),\n * re-exported under the name the email templates import (the launch template imports\n * `escapeXml` from here).\n */\nexport const escapeXml = escapeHtml;\n\n// Bundled images: shipped in dist/ via tsup onSuccess copy, attached inline via\n// CID by orchestrate.ts at send time. No external CDN dependency. (The green check\n// image lives in the shared email-sections checklist component.)\nconst BLURRED_TESTS = `cid:${BLURRED_CID}`;\n\nexport function fmtDate(d: Date | null): string {\n // Guard BOTH null AND an Invalid Date — `new Date(\"not-a-date\")` (a malformed\n // Airtable date string) is a truthy Date whose getUTC* accessors all return\n // NaN, which would render \"NaN.NaN.NaN\" into a real client email. `!d` alone\n // misses it; `Number.isNaN(d.getTime())` catches it.\n if (!d || Number.isNaN(d.getTime())) return \"\";\n // Airtable date fields are wall-clock YYYY-MM-DD strings parsed as UTC midnight.\n // Use UTC accessors so the rendered date matches what the operator entered.\n // US format: MM.DD.YYYY (Reddoor is Texas-based, clients are US).\n const mm = String(d.getUTCMonth() + 1).padStart(2, \"0\");\n const dd = String(d.getUTCDate()).padStart(2, \"0\");\n const yyyy = d.getUTCFullYear();\n return `${mm}.${dd}.${yyyy}`;\n}\n\nfunction maintenanceChecksSection(copy: ResolvedCopy, searchPosition?: number): string {\n // The Google row shows the live search position when available, else the plain label.\n const googleLabel =\n searchPosition !== undefined\n ? `Page 1 Google Result (#${searchPosition})`\n : (copy.maintenanceChecks[3] ?? \"\");\n const rows = copy.maintenanceChecks.map((label, i) => (i === 3 ? googleLabel : label));\n return checklistRowsSection(rows, { background: \"white\", lastPaddingBottom: \"36px\" });\n}\n\nfunction testingChecklistSection(copy: ResolvedCopy): string {\n return checklistRowsSection(copy.testingChecklist, {\n background: \"#F4F4F4\",\n lastPaddingBottom: \"60px\",\n });\n}\n\nfunction maintenanceTestingPlaceholder(): string {\n return `\n <mj-section background-color=\"#F4F4F4\">\n <mj-column>\n <mj-image href=\"mailto:info@reddoorla.com\" src=\"${BLURRED_TESTS}\" />\n </mj-column>\n </mj-section>`;\n}\n\nfunction testingIntroSection(copy: ResolvedCopy): string {\n return `\n <mj-section background-color=\"#F4F4F4\">\n <mj-column>\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"75px\">TESTING</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\">${escapeXml(copy.testingIntro)}</mj-text>\n </mj-column>\n </mj-section>`;\n}\n\nfunction commentarySection(text: string, copy: ResolvedCopy): string {\n return `\n <mj-section background-color=\"white\">\n <mj-column>\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"55px\">${escapeXml(copy.notesHeader)}</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\">${escapeXml(text).replace(/\\r\\n?|\\n/g, \"<br/>\")}</mj-text>\n </mj-column>\n </mj-section>`;\n}\n\nfunction hasHeaderDims(\n data: ReportData,\n): data is ReportData & { headerWidth: number; headerHeight: number; headerBgColor: string } {\n return Boolean(data.headerWidth && data.headerHeight && data.headerBgColor);\n}\n\nexport function headerImageTag(data: ReportData): string {\n const src = `cid:${data.headerImageCid}`;\n const alt = `${escapeXml(data.siteName)} maintenance report`;\n // escapeXml only escapes markup chars — it does NOT neutralize a dangerous URL\n // scheme. A `javascript:`/`data:` siteUrl would survive escaping and become a live\n // header href. Gate on isHttpUrl (the same http(s) allowlist the audit path uses)\n // and DROP a non-http(s) href entirely (fall back to \"#\") rather than linking it.\n const href = isHttpUrl(data.siteUrl) ? escapeXml(data.siteUrl) : \"#\";\n // Reserve the box and show a matched placeholder while the image loads / if blocked.\n // Critically, we do NOT set an mj-image `height` — MJML would emit `height:<px>` while\n // keeping `width:100%`, locking the height while the width scales and distorting the\n // image at any rendered width != the design width (mobile, narrow panes). Instead the\n // image stays `height:auto` (proportional) and the box is reserved via `aspect-ratio`\n // in the head <mj-style> below (see headerStyleBlock). `container-background-color` is\n // the placeholder; the bare fallback (no dims, e.g. local preview) keeps today's behavior.\n if (hasHeaderDims(data)) {\n return `<mj-image href=\"${href}\" src=\"${src}\" alt=\"${alt}\" width=\"${data.headerWidth}px\" css-class=\"rd-header\" container-background-color=\"${data.headerBgColor}\" />`;\n }\n return `<mj-image href=\"${href}\" src=\"${src}\" alt=\"${alt}\" />`;\n}\n\nexport function headerStyleBlock(data: ReportData): string {\n if (!hasHeaderDims(data)) return \"\";\n // Reserve the header's vertical space by aspect ratio so it scales proportionally with\n // its fluid (width:100%) width — no fixed pixel height, so it never squishes.\n // `height:auto !important` defends against any client honoring MJML's inline height.\n return `<mj-style>.rd-header img { height: auto !important; aspect-ratio: ${data.headerWidth} / ${data.headerHeight}; }</mj-style>`;\n}\n\nexport function buildMjml(data: ReportData): string {\n const copy = data.copy ?? DEFAULT_COPY;\n const isTesting = data.reportType === \"Testing\";\n const previewText = `Checked up on ${escapeXml(data.siteName)}`;\n\n return `<mjml>\n <mj-head>\n <mj-attributes>\n <mj-text font-family=\"helvetica, sans-serif\" padding-left=\"5px\" padding-right=\"5px\" />\n <mj-section padding-left=\"11%\" padding-right=\"11%\"/>\n <mj-image padding=\"0px\" />\n </mj-attributes>\n <mj-preview>${previewText}</mj-preview>\n ${headerStyleBlock(data)}\n </mj-head>\n <mj-body background-color=\"white\">\n <mj-section background-color=\"#F4F4F4\" padding-top=\"0px\" padding-bottom=\"0px\" padding-left=\"0px\" padding-right=\"0px\">\n <mj-column>\n ${headerImageTag(data)}\n </mj-column>\n </mj-section>\n <mj-section background-color=\"white\">\n <mj-column>\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"75px\">COMPLETED ON</mj-text>\n <mj-text color=\"${RED}\" font-size=\"44px\" font-weight=\"400\">${fmtDate(data.completedOn)}</mj-text>\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"75px\">MAINTENANCE CHECKS</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\">${escapeXml(copy.maintenanceIntro)}</mj-text>\n </mj-column>\n </mj-section>\n ${maintenanceChecksSection(copy, data.searchPosition)}\n ${lighthouseScoresSection(data.lighthouse)}\n ${analyticsSection({\n current: data.gaUsersCurrent,\n previous: data.gaUsersPrevious,\n periodDays: data.gaPeriodDays,\n background: \"white\",\n footnoteLines: [escapeXml(copy.seoCta)],\n })}\n ${isTesting ? testingIntroSection(copy) + testingChecklistSection(copy) : maintenanceTestingPlaceholder()}\n ${data.commentary ? commentarySection(data.commentary, copy) : \"\"}\n <mj-section background-color=\"white\">\n <mj-column padding-top=\"36px\">\n <mj-text color=\"${RED}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"700\" padding-top=\"36px\" line-height=\"36px\">Any questions, concerns or requests?</mj-text>\n ${copy.contact\n .map((line, i) => {\n // First line (\"Just hit reply.\") renders as a red bold heading, matching the\n // \"Any questions, concerns or requests?\" title above it; later lines are grey\n // (matching the launch/announcement templates); the last line keeps its\n // closing padding.\n const isLast = i === copy.contact.length - 1;\n const emphasis =\n i === 0\n ? `color=\"${RED}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"700\"`\n : `color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"300\"`;\n return isLast\n ? `<mj-text ${emphasis} padding-top=\"0px\" line-height=\"30px\" padding-bottom=\"36px\">${escapeXml(line)}</mj-text>`\n : `<mj-text ${emphasis} line-height=\"30px\">${escapeXml(line)}</mj-text>`;\n })\n .join(\"\\n \")}\n <mj-divider border-width=\"1px\" border-style=\"solid\" border-color=\"#CCCCCC\" padding=\"0\" />\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" padding-top=\"24px\" line-height=\"20px\" font-style=\"italic\">Copyright ${new Date().getUTCFullYear()} ${escapeXml(copy.footerOrg)}. All rights reserved.</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"700\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">Our mailing address is:</mj-text>\n ${[copy.footerOrg, ...copy.footerAddress]\n .map(\n (line) =>\n `<mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">${escapeXml(line)}</mj-text>`,\n )\n .join(\"\\n \")}\n </mj-column>\n </mj-section>\n </mj-body>\n</mjml>`;\n}\n","import type { ReportData, ReportFrequency } from \"../types.js\";\nimport type { WebsiteRow } from \"../airtable/websites.js\";\nimport { DEFAULT_COPY, type ResolvedCopy } from \"../copy.js\";\nimport { escapeXml, headerImageTag, headerStyleBlock } from \"../maintenance-email/template.js\";\nimport {\n checklistRowsSection,\n lighthouseScoresSection,\n analyticsSection,\n hasAnalyticsData,\n} from \"../email-sections.js\";\n\n/** Frequency → client-facing phrase. \"None\" is never rendered (the line is omitted). */\nconst FREQ_PHRASE: Record<Exclude<ReportFrequency, \"None\">, string> = {\n Monthly: \"every month\",\n Quarterly: \"every quarter\",\n Yearly: \"every year\",\n};\n\nconst RED = \"#C00\";\nconst GREY = \"#757575\";\n\n// Equal top/bottom padding for every alternating-background band, applied at the mj-section\n// level so each colored band has symmetric breathing room. (Starting baseline — easy to tune.)\nconst SECTION_PAD = \"40px\";\n\n/** A red all-caps section label. Top spacing comes from the section padding, so this is flush. */\nfunction sectionLabel(text: string): string {\n return `<mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"0px\">${escapeXml(text)}</mj-text>`;\n}\n\n/** A grey 16px body paragraph whose caller passes already-escaped HTML, so partial inline markup\n * (e.g. the cadence line's italic <em>…</em> tail) survives. */\nfunction bodyLineHtml(html: string, paddingTop = \"8px\"): string {\n return `<mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\" padding-top=\"${paddingTop}\">${html}</mj-text>`;\n}\n\n/** A grey 16px body paragraph, matching the report's section intros. Escapes `text`. */\nfunction bodyLine(text: string, paddingTop = \"8px\"): string {\n return bodyLineHtml(escapeXml(text), paddingTop);\n}\n\n/** The go-forward cadence sentence as HTML: the lead-in followed by its reassurance tail in\n * italics (\"…short report like this — <em>there's nothing you need to do.</em>\"). Both escaped. */\nfunction cadenceHtml(copy: ResolvedCopy): string {\n return `${escapeXml(copy.announceCadence)} <em>${escapeXml(copy.announceCadenceNote)}</em>`;\n}\n\n/**\n * The announcement-only ReportData extras derived from the Websites row: the go-forward\n * cadence and the default-on improvement callouts. Used by BOTH the draft (announce recipe)\n * and the send-time re-render (orchestrate) so the sent email matches the reviewed preview —\n * `renderReportHtml` in the send path otherwise omits these, dropping the cadence + improvements.\n */\nexport function announcementSiteExtras(\n site: WebsiteRow,\n): Pick<ReportData, \"cadence\" | \"improvements\"> {\n return {\n cadence: { maintenance: site.maintenanceFreq, testing: site.testingFreq },\n improvements: { resendForms: true, svelte5: true },\n };\n}\n\n/**\n * One-time onboarding announcement, built from the SAME components as the monthly report so it\n * reads as a testing report with extra explanation: header · intro (\"your ongoing care\") ·\n * MAINTENANCE CHECKS · TESTING (each: intro with the cadence baked into the copy + the report's\n * checklist rows) · LIGHTHOUSE SCORES · ANALYTICS (users + trend + the Google-position line) ·\n * RECENT IMPROVEMENTS (conditional; closes with the open-door invitation). Reuses the M6a copy\n * layer (contact/footer honor per-site overrides). No pricing. A pace set to None omits its\n * checklist section. Every band carries equal top/bottom padding (SECTION_PAD).\n */\nexport function buildAnnouncementMjml(data: ReportData): string {\n const copy = data.copy ?? DEFAULT_COPY;\n // Site-interpolated, matching the launch (\"<site> is live\") and maintenance\n // (\"Checked up on <site>\") templates. The old text read \"Your monthly report\n // from Reddoor\", which was wrong twice over: this is the announcement, not a\n // report, and it hard-coded a monthly cadence the body contradicts for every\n // client on a quarterly/yearly pace.\n const previewText = `Your ongoing site care for ${data.siteName}`;\n const cad = data.cadence;\n\n const hasMaint = Boolean(cad && cad.maintenance !== \"None\");\n const hasTesting = Boolean(cad && cad.testing !== \"None\");\n const improvementItems: string[] = [];\n if (data.improvements?.resendForms) improvementItems.push(copy.announceImprovementResend);\n if (data.improvements?.svelte5) improvementItems.push(copy.announceImprovementSvelte5);\n const hasImpr = improvementItems.length > 0;\n\n // ANALYTICS renders only with real data — a GA user count or a page-1 search callout (the latter\n // keeps the block alive for a GA-less site that still ranks). Computed up here so the band\n // counter below can SKIP its background slot when analytics is hidden; otherwise a consumed-but-\n // invisible slot would push the next band to the wrong color and two same-color bands would abut.\n const analyticsBodyLines =\n data.searchPosition !== undefined\n ? [`Page 1 Google result (#${data.searchPosition}) for your brand search`]\n : [];\n const hasAnalytics = hasAnalyticsData({\n current: data.gaUsersCurrent,\n previous: data.gaUsersPrevious,\n bodyLines: analyticsBodyLines,\n });\n\n // Alternating band backgrounds assigned in render order, so the white/#F4F4F4 pattern holds\n // no matter which optional sections (maintenance / testing / improvements) actually render —\n // a counter that only advances for bands that appear avoids two same-colored bands abutting.\n const BANDS = [\"white\", \"#F4F4F4\"] as const;\n let bandN = 0;\n const nextBg = (): string => BANDS[bandN++ % 2]!;\n const introBg = nextBg();\n const maintBg = hasMaint ? nextBg() : \"\";\n const testBg = hasTesting ? nextBg() : \"\";\n const lighthouseBg = nextBg();\n const analyticsBg = hasAnalytics ? nextBg() : \"\";\n const improvementsBg = hasImpr ? nextBg() : \"\";\n const contactBg = nextBg();\n\n // MAINTENANCE CHECKS (first) — intro with the maintenance cadence baked into the copy, then the\n // report's checklist rows. The report-frequency reassurance (announceCadence) trails the LAST\n // check block, so it sits here only when there's no testing block after it. Omitted when None.\n const maintenanceSection =\n cad && cad.maintenance !== \"None\"\n ? `\n <mj-section background-color=\"${maintBg}\" padding-top=\"${SECTION_PAD}\" padding-bottom=\"0px\">\n <mj-column>\n ${sectionLabel(\"MAINTENANCE CHECKS\")}\n ${bodyLineHtml(`${escapeXml(`${copy.maintenanceIntro} We do this ${FREQ_PHRASE[cad.maintenance]}.`)}${cad.testing === \"None\" ? ` ${cadenceHtml(copy)}` : \"\"}`)}\n </mj-column>\n </mj-section>${checklistRowsSection(copy.maintenanceChecks, {\n background: maintBg,\n lastPaddingBottom: SECTION_PAD,\n })}`\n : \"\";\n\n // TESTING (second) — intro with the testing cadence + the report-frequency note baked in, then\n // the report's checklist rows. Omitted when None.\n const testingSection =\n cad && cad.testing !== \"None\"\n ? `\n <mj-section background-color=\"${testBg}\" padding-top=\"${SECTION_PAD}\" padding-bottom=\"0px\">\n <mj-column>\n ${sectionLabel(\"TESTING\")}\n ${bodyLineHtml(`${escapeXml(`${copy.testingIntro} We run a full test ${FREQ_PHRASE[cad.testing]}.`)} ${cadenceHtml(copy)}`)}\n </mj-column>\n </mj-section>${checklistRowsSection(copy.testingChecklist, {\n background: testBg,\n lastPaddingBottom: SECTION_PAD,\n })}`\n : \"\";\n\n // ANALYTICS — the report's big user count + trend, plus the Google search-position line. Hidden\n // entirely when there's no traffic or search data (hasAnalytics false), so it never shows empty.\n const analytics = hasAnalytics\n ? analyticsSection({\n current: data.gaUsersCurrent,\n previous: data.gaUsersPrevious,\n periodDays: data.gaPeriodDays,\n background: analyticsBg,\n pad: SECTION_PAD,\n bodyLines: analyticsBodyLines,\n })\n : \"\";\n\n // RECENT IMPROVEMENTS — the toggled callouts, closed by the open-door invitation. Omitted\n // entirely when there are no improvements (the open-door rides along with this block).\n const improvementsSection = hasImpr\n ? `\n <mj-section background-color=\"${improvementsBg}\" padding-top=\"${SECTION_PAD}\" padding-bottom=\"${SECTION_PAD}\">\n <mj-column>\n ${sectionLabel(\"RECENT IMPROVEMENTS\")}\n ${improvementItems.map((item) => bodyLine(item)).join(\"\\n \")}\n ${bodyLine(copy.announceOpenDoor, \"16px\")}\n </mj-column>\n </mj-section>`\n : \"\";\n\n // First contact line (\"Just hit reply.\") renders as a red bold heading, matching the\n // \"Questions, concerns or requests?\" title above it; any following lines (e.g. \"We're\n // here to help in any way we can.\") render in muted grey.\n const contactRows = copy.contact\n .map((line, i) =>\n i === 0\n ? `\n <mj-text color=\"${RED}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"700\" line-height=\"30px\">${escapeXml(line)}</mj-text>`\n : `\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"300\" line-height=\"30px\">${escapeXml(line)}</mj-text>`,\n )\n .join(\"\");\n const footerAddressRows = copy.footerAddress\n .map(\n (line) => `\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">${escapeXml(line)}</mj-text>`,\n )\n .join(\"\");\n\n return `<mjml>\n <mj-head>\n <mj-attributes>\n <mj-text font-family=\"helvetica, sans-serif\" padding-left=\"5px\" padding-right=\"5px\" />\n <mj-section padding-left=\"11%\" padding-right=\"11%\"/>\n <mj-image padding=\"0px\" />\n </mj-attributes>\n <mj-preview>${escapeXml(previewText)}</mj-preview>\n ${headerStyleBlock(data)}\n </mj-head>\n <mj-body background-color=\"white\">\n <mj-section background-color=\"#F4F4F4\" padding-top=\"0px\" padding-bottom=\"0px\" padding-left=\"0px\" padding-right=\"0px\">\n <mj-column>${headerImageTag(data)}</mj-column>\n </mj-section>\n <mj-section background-color=\"${introBg}\" padding-top=\"${SECTION_PAD}\" padding-bottom=\"${SECTION_PAD}\">\n <mj-column>\n ${sectionLabel(copy.announceHeading)}\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\" padding-top=\"20px\">Prepared for ${escapeXml(data.siteName)}</mj-text>\n ${bodyLine(copy.announceBody)}\n </mj-column>\n </mj-section>\n ${maintenanceSection}\n ${testingSection}\n ${lighthouseScoresSection(data.lighthouse, { background: lighthouseBg, pad: SECTION_PAD })}\n ${analytics}\n ${improvementsSection}\n <mj-section background-color=\"${contactBg}\" padding-top=\"${SECTION_PAD}\" padding-bottom=\"${SECTION_PAD}\">\n <mj-column>\n <mj-text color=\"${RED}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"700\" padding-top=\"0px\" line-height=\"36px\">Questions, concerns or requests?</mj-text>\n ${contactRows}\n <mj-divider border-width=\"1px\" border-style=\"solid\" border-color=\"#CCCCCC\" padding=\"0\" />\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" padding-top=\"24px\" line-height=\"20px\" font-style=\"italic\">Copyright ${new Date().getUTCFullYear()} ${escapeXml(copy.footerOrg)}. All rights reserved.</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"700\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">Our mailing address is:</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">${escapeXml(copy.footerOrg)}</mj-text>\n ${footerAddressRows}\n </mj-column>\n </mj-section>\n </mj-body>\n</mjml>`;\n}\n","import mjml2html from \"mjml\";\nimport type { ReportData } from \"./types.js\";\nimport { buildMjml } from \"./maintenance-email/template.js\";\nimport { buildLaunchMjml } from \"./launch-email/template.js\";\nimport { buildAnnouncementMjml } from \"./announcement-email/template.js\";\n\nexport type RenderResult = {\n html: string;\n warnings: Array<{ line: number; message: string }>;\n};\n\nexport async function renderReportHtml(data: ReportData): Promise<RenderResult> {\n const mjml =\n data.reportType === \"Launch\"\n ? buildLaunchMjml(data)\n : data.reportType === \"Announcement\"\n ? buildAnnouncementMjml(data)\n : buildMjml(data);\n const out = await mjml2html(mjml, { validationLevel: \"strict\" });\n return { html: out.html, warnings: out.errors ?? [] };\n}\n","import type { ReportData } from \"../types.js\";\nimport { DEFAULT_COPY } from \"../copy.js\";\nimport {\n escapeXml,\n fmtDate,\n headerImageTag,\n headerStyleBlock,\n} from \"../maintenance-email/template.js\";\n\nconst RED = \"#C00\";\nconst GREY = \"#757575\";\n\n/** Purpose-built go-live email: header · LAUNCHED + date · message · what-we-set-up\n * · contact · footer. Reuses the M6a copy layer (contact/footer honor per-site\n * overrides). No maintenance checklist / Lighthouse / analytics. */\nexport function buildLaunchMjml(data: ReportData): string {\n const copy = data.copy ?? DEFAULT_COPY;\n const previewText = `${escapeXml(data.siteName)} is live`;\n // All copy — launchHeading/launchBody/launchSetupItems included — is escaped\n // (spec §3.3: all copy escaped). It keeps strict MJML from choking on a stray\n // `&`/`<` if the default copy ever gains one, matching contact/footer below.\n const setupRows = copy.launchSetupItems\n .map(\n (item) => `\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\" padding-top=\"4px\" padding-bottom=\"4px\">• ${escapeXml(item)}</mj-text>`,\n )\n .join(\"\");\n // First contact line (\"Just hit reply.\") renders as a red bold heading, matching the\n // \"Any questions, concerns or requests?\" title above it; following lines stay default.\n const contactRows = copy.contact\n .map((line, i) =>\n i === 0\n ? `\n <mj-text color=\"${RED}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"700\" line-height=\"30px\">${escapeXml(line)}</mj-text>`\n : `\n <mj-text font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"300\" line-height=\"30px\">${escapeXml(line)}</mj-text>`,\n )\n .join(\"\");\n const footerAddressRows = copy.footerAddress\n .map(\n (line) => `\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">${escapeXml(line)}</mj-text>`,\n )\n .join(\"\");\n\n return `<mjml>\n <mj-head>\n <mj-attributes>\n <mj-text font-family=\"helvetica, sans-serif\" padding-left=\"5px\" padding-right=\"5px\" />\n <mj-section padding-left=\"11%\" padding-right=\"11%\"/>\n <mj-image padding=\"0px\" />\n </mj-attributes>\n <mj-preview>${previewText}</mj-preview>\n ${headerStyleBlock(data)}\n </mj-head>\n <mj-body background-color=\"white\">\n <mj-section background-color=\"#F4F4F4\" padding-top=\"0px\" padding-bottom=\"0px\" padding-left=\"0px\" padding-right=\"0px\">\n <mj-column>${headerImageTag(data)}</mj-column>\n </mj-section>\n <mj-section background-color=\"white\">\n <mj-column>\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"75px\">${escapeXml(copy.launchHeading)}</mj-text>\n <mj-text color=\"${RED}\" font-size=\"44px\" font-weight=\"400\">${fmtDate(data.completedOn)}</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\" padding-top=\"20px\">${escapeXml(copy.launchBody)}</mj-text>\n ${setupRows}\n </mj-column>\n </mj-section>\n <mj-section background-color=\"white\">\n <mj-column padding-top=\"36px\">\n <mj-text color=\"${RED}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"700\" padding-top=\"36px\" line-height=\"36px\">Any questions, concerns or requests?</mj-text>\n ${contactRows}\n <mj-divider border-width=\"1px\" border-style=\"solid\" border-color=\"#CCCCCC\" padding=\"0\" />\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" padding-top=\"24px\" line-height=\"20px\" font-style=\"italic\">Copyright ${new Date().getUTCFullYear()} ${escapeXml(copy.footerOrg)}. All rights reserved.</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"700\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">Our mailing address is:</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">${escapeXml(copy.footerOrg)}</mj-text>\n ${footerAddressRows}\n </mj-column>\n </mj-section>\n </mj-body>\n</mjml>`;\n}\n"],"mappings":"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","names":["escapeXml","RED","GREY","escapeXml","RED","GREY","escapeXml"]}
|
|
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"],"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 our latest framework — it's faster, more secure, and built to last.\",\n announceCadence: \"After each one we'll send you a short report like this —\",\n announceCadenceNote: \"there's nothing you need to do.\",\n announceOpenDoor:\n \"And if you'd ever like to expand the scope, add features, or freshen anything up, just let us know.\",\n};\n\n/** Trim an override to null when blank (mirrors the trim-to-null handling). */\nfunction override(v: string | null): string | null {\n if (typeof v !== \"string\") return null;\n const t = v.trim();\n return t.length > 0 ? t : null;\n}\n\n/**\n * Resolve a site's effective copy: DEFAULT_COPY with the three per-site narrative\n * overrides applied. Only maintenanceIntro/contact/footer are per-site (M6a §2);\n * everything else is the shared default. PURE.\n */\n/** Split an operator override into lines: tolerate CRLF, drop blank lines (a stray\n * blank in the Airtable cell shouldn't render an empty address row). */\nfunction splitLines(s: string): string[] {\n return s.split(/\\r?\\n/).filter((l) => l.trim().length > 0);\n}\n\nexport function resolveCopy(site: WebsiteRow): ResolvedCopy {\n const intro = override(site.copyIntro);\n const contact = override(site.copyContact);\n const footer = override(site.copyFooter);\n const footerLines = footer ? splitLines(footer) : null;\n return {\n ...DEFAULT_COPY,\n maintenanceIntro: intro ?? DEFAULT_COPY.maintenanceIntro,\n contact: contact ? splitLines(contact) : DEFAULT_COPY.contact,\n footerOrg: footerLines?.[0] ?? DEFAULT_COPY.footerOrg,\n footerAddress: footerLines ? footerLines.slice(1) : DEFAULT_COPY.footerAddress,\n };\n}\n","import { readFile } from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nexport const CHECK_CID = \"rd-check-png\";\nexport const BLURRED_CID = \"rd-blurred-tests-jpg\";\n\nexport type BundledImage = {\n bytes: Uint8Array;\n contentType: string;\n cid: string;\n filename: string;\n};\n\n// Walk up from the current module's URL looking for the assets dir in either\n// the dev layout (src/reports/maintenance-email/assets/) or the published\n// layout (dist/reports/maintenance-email/assets/). REQUIRED because tsup\n// inlines this module into dist/cli/bin.js — so `import.meta.url`-based\n// sibling resolution looks in dist/cli/ for the PNGs and fails with ENOENT.\n// Regression that shipped in 0.10.0–0.10.1; tests passed in dev because\n// vitest evaluates the source file where import.meta.url is already correct.\nlet cachedAssetsDir: string | null = null;\nfunction resolveAssetsDir(): string {\n if (cachedAssetsDir) return cachedAssetsDir;\n let dir = dirname(fileURLToPath(import.meta.url));\n while (true) {\n // Source layout preferred — single source of truth in the workspace\n // and the only one present in dev/test environments.\n const srcCandidate = join(dir, \"src\", \"reports\", \"maintenance-email\", \"assets\", \"check.png\");\n if (existsSync(srcCandidate)) {\n cachedAssetsDir = dirname(srcCandidate);\n return cachedAssetsDir;\n }\n // Published layout — only `dist/` ships per package.json#files, so\n // consumers fall through to here.\n const distCandidate = join(dir, \"dist\", \"reports\", \"maintenance-email\", \"assets\", \"check.png\");\n if (existsSync(distCandidate)) {\n cachedAssetsDir = dirname(distCandidate);\n return cachedAssetsDir;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n throw new Error(\n `loadBundledImages: could not locate maintenance-email assets dir by walking up from ${fileURLToPath(import.meta.url)}. Checked both src/ and dist/ layouts.`,\n );\n }\n dir = parent;\n }\n}\n\n/**\n * Read the bundled image bytes from disk. Both Maintenance and Testing\n * variants reference `check.png`; only the Maintenance variant references\n * `blurredTests.jpg`.\n */\nexport async function loadBundledImages(): Promise<{\n check: BundledImage;\n blurred: BundledImage;\n}> {\n const assetsDir = resolveAssetsDir();\n const [check, blurred] = await Promise.all([\n readFile(join(assetsDir, \"check.png\")),\n readFile(join(assetsDir, \"blurredTests.jpg\")),\n ]);\n return {\n check: {\n bytes: new Uint8Array(check),\n contentType: \"image/png\",\n cid: CHECK_CID,\n filename: \"check.png\",\n },\n blurred: {\n bytes: new Uint8Array(blurred),\n contentType: \"image/jpeg\",\n cid: BLURRED_CID,\n filename: \"blurredTests.jpg\",\n },\n };\n}\n","import { escapeHtml } from \"../util/html.js\";\nimport { CHECK_CID } from \"./maintenance-email/assets/index.js\";\nimport type { ReportData } from \"./types.js\";\n\n/**\n * Shared MJML section builders for the report family of emails (maintenance/testing report,\n * announcement). Centralizing them here guarantees the announcement renders the SAME polished\n * components as the monthly report — the checklist rows, the full Lighthouse block, and the\n * analytics block — so the two can't drift in design. PURE string builders; no I/O.\n *\n * Escaping: callers pass already-trusted copy for fixed labels, but any site/operator string\n * (check labels, trailing notes) is escaped here via `escapeXml`.\n */\nexport const escapeXml = escapeHtml;\n\n// Brand palette shared across the report-email family (maintenance/testing,\n// launch, announcement) so a template can't drift onto hand-typed literals.\nexport const RED = \"#C00\";\nexport const GREY = \"#757575\";\nconst BORDER = \"#CCCCCC\";\nconst TREND_UP = \"#2E7D32\"; // positive green — growth reads as good\nconst TREND_NEUTRAL = GREY; // muted grey — dips/flat aren't failures (brand red is reserved)\n\n// The report's bundled green check (cid:rd-check-png), attached inline by orchestrate.ts at\n// send time. Standalone previews (no attachments) show the image's alt instead.\nconst CHECK_PNG = `cid:${CHECK_CID}`;\n\n/** Thousands-grouped user/visitor count. */\nexport function fmtUsers(n: number): string {\n return n.toLocaleString(\"en-US\");\n}\n\n/**\n * A checklist \"table\": one ruled row per label, the label on the left and the green check\n * right-aligned, matching the monthly report. `background` tints the band (white for the\n * maintenance list, #F4F4F4 for the testing list); `lastPaddingBottom` is the trailing gap\n * under the final row. Labels are escaped. PURE.\n */\nexport function checklistRowsSection(\n rows: string[],\n opts: { background: string; lastPaddingBottom: string },\n): string {\n return rows\n .map((label, i) => {\n const isLast = i === rows.length - 1;\n const border = isLast ? \"\" : ` border-bottom=\"solid ${BORDER} 1px\"`;\n const lastPad = isLast ? ` padding-bottom=\"${opts.lastPaddingBottom}\"` : \"\";\n return `\n <mj-section background-color=\"${opts.background}\" padding=\"0px\"${lastPad}>\n <mj-group>\n <mj-column padding-left=\"0px\" width=\"90%\"${border}>\n <mj-text height=\"25px\" padding-left=\"0px\" color=\"${GREY}\" padding-top=\"20px\" padding-bottom=\"7.5px\" font-size=\"16px\">${escapeXml(label)}</mj-text>\n </mj-column>\n <mj-column width=\"10%\"${border} padding-top=\"15px\">\n <mj-image align=\"right\" padding-right=\"0px\" width=\"20px\" height=\"20px\" padding-top=\"2.5px\" padding-bottom=\"15px\" src=\"${CHECK_PNG}\" />\n </mj-column>\n </mj-group>\n </mj-section>`;\n })\n .join(\"\");\n}\n\n/** The four Lighthouse scores with their client-facing labels and acceptable/ideal bands.\n * Ideal always tops at 100 (the metric's ceiling). */\nconst LIGHTHOUSE_ROWS: ReadonlyArray<{\n label: string;\n key: keyof ReportData[\"lighthouse\"];\n range: string;\n}> = [\n { label: \"Performance\", key: \"performance\", range: \"Acceptable 50–89 // Ideal 90–100\" },\n { label: \"Readability (A11y)\", key: \"accessibility\", range: \"Acceptable 80–99 // Ideal 100\" },\n { label: \"Best Practices\", key: \"bestPractices\", range: \"Acceptable 60–79 // Ideal 80–100\" },\n { label: \"Site Structure\", key: \"seo\", range: \"Acceptable 50–89 // Ideal 90–100\" },\n];\n\n/**\n * The full \"LIGHTHOUSE SCORES*\" block: each score as a big red number under its label with the\n * acceptable/ideal band beneath, ruled between scores, closed by the explanatory footnote.\n * `background` tints the section (default #F4F4F4, matching the report). When `pad` is given the\n * band's top/bottom padding moves to the section (symmetric bands for the announcement); omitting\n * it keeps the report's original inner paddings. PURE.\n */\nexport function lighthouseScoresSection(\n lighthouse: ReportData[\"lighthouse\"],\n opts: { background?: string; pad?: string } = {},\n): string {\n const background = opts.background ?? \"#F4F4F4\";\n const sectionPad = opts.pad ? ` padding-top=\"${opts.pad}\" padding-bottom=\"${opts.pad}\"` : \"\";\n const labelTop = opts.pad ?? \"55px\";\n const footnoteBottom = opts.pad ? \"0px\" : \"36px\";\n const rows = LIGHTHOUSE_ROWS.map(\n ({ label, key, range }, i) => `\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"300\" padding-top=\"25px\">${label}</mj-text>\n <mj-text color=\"${RED}\" font-size=\"44px\" font-weight=\"400\" padding-top=\"0px\">${lighthouse[key]}</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" padding-top=\"0px\" padding-bottom=\"36px\">${range}</mj-text>${\n i < LIGHTHOUSE_ROWS.length - 1\n ? `\n <mj-divider border-width=\"1px\" border-style=\"solid\" border-color=\"${BORDER}\" padding=\"0\" />`\n : \"\"\n }`,\n ).join(\"\");\n return `\n <mj-section background-color=\"${background}\"${sectionPad}>\n <mj-column>\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"${labelTop}\">LIGHTHOUSE SCORES*</mj-text>${rows}\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" padding-top=\"24px\" padding-bottom=\"${footnoteBottom}\" line-height=\"20px\">*A Lighthouse score is a numerical measure provided by <a href=\"https://developer.chrome.com/docs/lighthouse/overview\" style=\"color:${RED}; text-decoration:underline;\">Google's Lighthouse tool</a>, which evaluates various aspects of a web page's quality.</mj-text>\n </mj-column>\n </mj-section>`;\n}\n\n/** The line under \"{N} Users\": a directional trend vs the previous period when both numbers\n * are real, else a graceful fallback. `undefined` = GA unavailable (distinct from a real 0).\n * Up = green; down/flat = muted grey (a traffic dip isn't a failure). PURE. */\nexport function analyticsTrendLine(\n cur: number | undefined,\n prev: number | undefined,\n periodDays?: number,\n): string {\n // The prior window the trend compares against: a concrete \"the previous N days\" when the\n // caller knows the window length, else the generic \"last period\" (keeps the label honest for\n // callers — and tests — that don't supply it).\n const priorLabel =\n periodDays && periodDays > 0 ? `the previous ${periodDays} days` : \"last period\";\n if (cur === undefined || prev === undefined) {\n return trendLine(TREND_NEUTRAL, `Last Period: ${prev !== undefined ? fmtUsers(prev) : \"—\"}`);\n }\n if (prev === 0) {\n return cur > 0\n ? trendLine(TREND_UP, \"▲ New this period (0 last period)\")\n : trendLine(TREND_NEUTRAL, \"Last Period: 0\");\n }\n const pct = Math.round(((cur - prev) / prev) * 100);\n const range = `(${fmtUsers(prev)} → ${fmtUsers(cur)})`;\n if (pct > 0) return trendLine(TREND_UP, `▲ ${pct}% vs ${priorLabel} ${range}`);\n if (pct < 0) return trendLine(TREND_NEUTRAL, `▼ ${Math.abs(pct)}% vs ${priorLabel} ${range}`);\n return trendLine(TREND_NEUTRAL, `No change vs ${priorLabel} (${fmtUsers(prev)})`);\n}\n\n/** A 16px coloured line (the trend, and the announcement's search line). */\nfunction trendLine(color: string, text: string): string {\n return `<mj-text color=\"${color}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\">${text}</mj-text>`;\n}\n\n/** A 12px muted footnote line (the SEO call-to-action / Lighthouse note style). */\nfunction footnoteLine(text: string): string {\n return `<mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" padding-top=\"24px\" padding-bottom=\"36px\" line-height=\"20px\">${text}</mj-text>`;\n}\n\n/** True when the GA user count is worth showing: a count exists AND the previous window is not a\n * literal 0. A zero last period means the tag wasn't collecting for a full window yet (new\n * property, mid-window install), so any count/trend is partial-window noise — suppress it until\n * a real comparison exists. `previous === undefined` (GA gave no prior window) still shows. PURE. */\nexport function gaCountVisible(opts: {\n current?: number | undefined;\n previous?: number | undefined;\n}): boolean {\n return opts.current !== undefined && opts.previous !== 0;\n}\n\n/** True when the analytics block has at least one real datum to show — a visible GA user count\n * ({@link gaCountVisible}) or a body line (e.g. the announcement's page-1 search callout). The\n * static SEO call-to-action footnote alone does NOT qualify: a block with no data is hidden\n * rather than rendered empty. PURE. */\nexport function hasAnalyticsData(opts: {\n current?: number | undefined;\n previous?: number | undefined;\n bodyLines?: string[];\n}): boolean {\n return gaCountVisible(opts) || (opts.bodyLines?.length ?? 0) > 0;\n}\n\n/**\n * The \"ANALYTICS\" block: a big red user count, the trend line, then any 16px `bodyLines`\n * (e.g. the announcement's Google-position line) and 12px muted `footnoteLines` (e.g. the\n * report's SEO call-to-action). Callers pass already-escaped line text. PURE.\n *\n * Returns \"\" when there's no data ({@link hasAnalyticsData} false) — an empty \"— Users\" block\n * reads as broken, so the section (and its data-contextual SEO footnote) is omitted. When a body\n * line exists but no GA count (a GA-less site still ranking on page 1), the user count + trend\n * are suppressed and only the body shows under the label.\n */\nexport function analyticsSection(opts: {\n current?: number | undefined;\n previous?: number | undefined;\n /** Length in days of the current window; drives the trend's \"vs the previous N days\" label. */\n periodDays?: number | undefined;\n background: string;\n bodyLines?: string[];\n footnoteLines?: string[];\n pad?: string;\n}): string {\n if (!hasAnalyticsData(opts)) return \"\";\n const body = (opts.bodyLines ?? []).map((l) => trendLine(TREND_NEUTRAL, l)).join(\"\\n \");\n const footnotes = (opts.footnoteLines ?? []).map(footnoteLine).join(\"\\n \");\n const sectionPad = opts.pad ? ` padding-top=\"${opts.pad}\" padding-bottom=\"${opts.pad}\"` : \"\";\n const labelTop = opts.pad ?? \"75px\";\n const usersBlock =\n opts.current !== undefined && gaCountVisible(opts)\n ? `\n <mj-text color=\"${RED}\" font-size=\"44px\" font-weight=\"400\">${fmtUsers(opts.current)} Users</mj-text>\n ${analyticsTrendLine(opts.current, opts.previous, opts.periodDays)}`\n : \"\";\n return `\n <mj-section background-color=\"${opts.background}\"${sectionPad}>\n <mj-column>\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"${labelTop}\">ANALYTICS</mj-text>${usersBlock}\n ${body}\n ${footnotes}\n </mj-column>\n </mj-section>`;\n}\n","import type { ReportData } from \"../types.js\";\nimport { DEFAULT_COPY, type ResolvedCopy } from \"../copy.js\";\nimport { BLURRED_CID } from \"./assets/index.js\";\nimport {\n checklistRowsSection,\n lighthouseScoresSection,\n analyticsSection,\n RED,\n GREY,\n} from \"../email-sections.js\";\nimport { escapeHtml } from \"../../util/html.js\";\nimport { isHttpUrl } from \"../../util/url.js\";\n\n/**\n * Escape operator/site-controlled strings before interpolating into the MJML markup.\n * MJML parses as XML with `validationLevel: \"strict\"`. Under mjml@4.18 a raw `&`, `<`,\n * or `>` does NOT throw — it passes straight through into the rendered output, so an\n * unescaped value (e.g. a site name \"Brown & Co\", a URL, or commentary) silently\n * injects HTML/markup into the email. A raw `\"` inside an ATTRIBUTE value (e.g. the\n * image `href`/`alt`) is the one that throws — it terminates the attribute and trips a\n * parse error that blocks the send. So we escape for two reasons: prevent\n * HTML/markup injection in text, and prevent the attribute-quote parse error. Apply\n * to every interpolation of siteName / siteUrl / commentary / copy.\n *\n * This IS `src/util/html.ts`'s `escapeHtml` (the strict-XML set is identical),\n * re-exported under the name the email templates import (the launch template imports\n * `escapeXml` from here).\n */\nexport const escapeXml = escapeHtml;\n\n// Bundled images: shipped in dist/ via tsup onSuccess copy, attached inline via\n// CID by orchestrate.ts at send time. No external CDN dependency. (The green check\n// image lives in the shared email-sections checklist component.)\nconst BLURRED_TESTS = `cid:${BLURRED_CID}`;\n\nexport function fmtDate(d: Date | null): string {\n // Guard BOTH null AND an Invalid Date — `new Date(\"not-a-date\")` (a malformed\n // Airtable date string) is a truthy Date whose getUTC* accessors all return\n // NaN, which would render \"NaN.NaN.NaN\" into a real client email. `!d` alone\n // misses it; `Number.isNaN(d.getTime())` catches it.\n if (!d || Number.isNaN(d.getTime())) return \"\";\n // Airtable date fields are wall-clock YYYY-MM-DD strings parsed as UTC midnight.\n // Use UTC accessors so the rendered date matches what the operator entered.\n // US format: MM.DD.YYYY (Reddoor is Texas-based, clients are US).\n const mm = String(d.getUTCMonth() + 1).padStart(2, \"0\");\n const dd = String(d.getUTCDate()).padStart(2, \"0\");\n const yyyy = d.getUTCFullYear();\n return `${mm}.${dd}.${yyyy}`;\n}\n\nfunction maintenanceChecksSection(copy: ResolvedCopy, searchPosition?: number): string {\n // The Google row shows the live search position when available, else the plain label.\n const googleLabel =\n searchPosition !== undefined\n ? `Page 1 Google Result (#${searchPosition})`\n : (copy.maintenanceChecks[3] ?? \"\");\n const rows = copy.maintenanceChecks.map((label, i) => (i === 3 ? googleLabel : label));\n return checklistRowsSection(rows, { background: \"white\", lastPaddingBottom: \"36px\" });\n}\n\nfunction testingChecklistSection(copy: ResolvedCopy): string {\n return checklistRowsSection(copy.testingChecklist, {\n background: \"#F4F4F4\",\n lastPaddingBottom: \"60px\",\n });\n}\n\nfunction maintenanceTestingPlaceholder(): string {\n return `\n <mj-section background-color=\"#F4F4F4\">\n <mj-column>\n <mj-image href=\"mailto:info@reddoorla.com\" src=\"${BLURRED_TESTS}\" />\n </mj-column>\n </mj-section>`;\n}\n\nfunction testingIntroSection(copy: ResolvedCopy): string {\n return `\n <mj-section background-color=\"#F4F4F4\">\n <mj-column>\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"75px\">TESTING</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\">${escapeXml(copy.testingIntro)}</mj-text>\n </mj-column>\n </mj-section>`;\n}\n\nfunction commentarySection(text: string, copy: ResolvedCopy): string {\n return `\n <mj-section background-color=\"white\">\n <mj-column>\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"55px\">${escapeXml(copy.notesHeader)}</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\">${escapeXml(text).replace(/\\r\\n?|\\n/g, \"<br/>\")}</mj-text>\n </mj-column>\n </mj-section>`;\n}\n\nfunction hasHeaderDims(\n data: ReportData,\n): data is ReportData & { headerWidth: number; headerHeight: number; headerBgColor: string } {\n return Boolean(data.headerWidth && data.headerHeight && data.headerBgColor);\n}\n\nexport function headerImageTag(data: ReportData): string {\n const src = `cid:${data.headerImageCid}`;\n const alt = `${escapeXml(data.siteName)} maintenance report`;\n // escapeXml only escapes markup chars — it does NOT neutralize a dangerous URL\n // scheme. A `javascript:`/`data:` siteUrl would survive escaping and become a live\n // header href. Gate on isHttpUrl (the same http(s) allowlist the audit path uses)\n // and DROP a non-http(s) href entirely (fall back to \"#\") rather than linking it.\n const href = isHttpUrl(data.siteUrl) ? escapeXml(data.siteUrl) : \"#\";\n // Reserve the box and show a matched placeholder while the image loads / if blocked.\n // Critically, we do NOT set an mj-image `height` — MJML would emit `height:<px>` while\n // keeping `width:100%`, locking the height while the width scales and distorting the\n // image at any rendered width != the design width (mobile, narrow panes). Instead the\n // image stays `height:auto` (proportional) and the box is reserved via `aspect-ratio`\n // in the head <mj-style> below (see headerStyleBlock). `container-background-color` is\n // the placeholder; the bare fallback (no dims, e.g. local preview) keeps today's behavior.\n if (hasHeaderDims(data)) {\n return `<mj-image href=\"${href}\" src=\"${src}\" alt=\"${alt}\" width=\"${data.headerWidth}px\" css-class=\"rd-header\" container-background-color=\"${data.headerBgColor}\" />`;\n }\n return `<mj-image href=\"${href}\" src=\"${src}\" alt=\"${alt}\" />`;\n}\n\nexport function headerStyleBlock(data: ReportData): string {\n if (!hasHeaderDims(data)) return \"\";\n // Reserve the header's vertical space by aspect ratio so it scales proportionally with\n // its fluid (width:100%) width — no fixed pixel height, so it never squishes.\n // `height:auto !important` defends against any client honoring MJML's inline height.\n return `<mj-style>.rd-header img { height: auto !important; aspect-ratio: ${data.headerWidth} / ${data.headerHeight}; }</mj-style>`;\n}\n\nexport function buildMjml(data: ReportData): string {\n const copy = data.copy ?? DEFAULT_COPY;\n const isTesting = data.reportType === \"Testing\";\n const previewText = `Checked up on ${escapeXml(data.siteName)}`;\n\n return `<mjml>\n <mj-head>\n <mj-attributes>\n <mj-text font-family=\"helvetica, sans-serif\" padding-left=\"5px\" padding-right=\"5px\" />\n <mj-section padding-left=\"11%\" padding-right=\"11%\"/>\n <mj-image padding=\"0px\" />\n </mj-attributes>\n <mj-preview>${previewText}</mj-preview>\n ${headerStyleBlock(data)}\n </mj-head>\n <mj-body background-color=\"white\">\n <mj-section background-color=\"#F4F4F4\" padding-top=\"0px\" padding-bottom=\"0px\" padding-left=\"0px\" padding-right=\"0px\">\n <mj-column>\n ${headerImageTag(data)}\n </mj-column>\n </mj-section>\n <mj-section background-color=\"white\">\n <mj-column>\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"75px\">COMPLETED ON</mj-text>\n <mj-text color=\"${RED}\" font-size=\"44px\" font-weight=\"400\">${fmtDate(data.completedOn)}</mj-text>\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"75px\">MAINTENANCE CHECKS</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\">${escapeXml(copy.maintenanceIntro)}</mj-text>\n </mj-column>\n </mj-section>\n ${maintenanceChecksSection(copy, data.searchPosition)}\n ${lighthouseScoresSection(data.lighthouse)}\n ${analyticsSection({\n current: data.gaUsersCurrent,\n previous: data.gaUsersPrevious,\n periodDays: data.gaPeriodDays,\n background: \"white\",\n footnoteLines: [escapeXml(copy.seoCta)],\n })}\n ${isTesting ? testingIntroSection(copy) + testingChecklistSection(copy) : maintenanceTestingPlaceholder()}\n ${data.commentary ? commentarySection(data.commentary, copy) : \"\"}\n <mj-section background-color=\"white\">\n <mj-column padding-top=\"36px\">\n <mj-text color=\"${RED}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"700\" padding-top=\"36px\" line-height=\"36px\">Any questions, concerns or requests?</mj-text>\n ${copy.contact\n .map((line, i) => {\n // First line (\"Just hit reply.\") renders as a red bold heading, matching the\n // \"Any questions, concerns or requests?\" title above it; later lines are grey\n // (matching the launch/announcement templates); the last line keeps its\n // closing padding.\n const isLast = i === copy.contact.length - 1;\n const emphasis =\n i === 0\n ? `color=\"${RED}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"700\"`\n : `color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"300\"`;\n return isLast\n ? `<mj-text ${emphasis} padding-top=\"0px\" line-height=\"30px\" padding-bottom=\"36px\">${escapeXml(line)}</mj-text>`\n : `<mj-text ${emphasis} line-height=\"30px\">${escapeXml(line)}</mj-text>`;\n })\n .join(\"\\n \")}\n <mj-divider border-width=\"1px\" border-style=\"solid\" border-color=\"#CCCCCC\" padding=\"0\" />\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" padding-top=\"24px\" line-height=\"20px\" font-style=\"italic\">Copyright ${new Date().getUTCFullYear()} ${escapeXml(copy.footerOrg)}. All rights reserved.</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"700\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">Our mailing address is:</mj-text>\n ${[copy.footerOrg, ...copy.footerAddress]\n .map(\n (line) =>\n `<mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">${escapeXml(line)}</mj-text>`,\n )\n .join(\"\\n \")}\n </mj-column>\n </mj-section>\n </mj-body>\n</mjml>`;\n}\n","import type { ReportData, ReportFrequency } from \"../types.js\";\nimport type { WebsiteRow } from \"../airtable/websites.js\";\nimport { DEFAULT_COPY, type ResolvedCopy } from \"../copy.js\";\nimport { escapeXml, headerImageTag, headerStyleBlock } from \"../maintenance-email/template.js\";\nimport {\n checklistRowsSection,\n lighthouseScoresSection,\n analyticsSection,\n hasAnalyticsData,\n} from \"../email-sections.js\";\n\n/** Frequency → client-facing phrase. \"None\" is never rendered (the line is omitted). */\nconst FREQ_PHRASE: Record<Exclude<ReportFrequency, \"None\">, string> = {\n Monthly: \"every month\",\n Quarterly: \"every quarter\",\n Yearly: \"every year\",\n};\n\nconst RED = \"#C00\";\nconst GREY = \"#757575\";\n\n// Equal top/bottom padding for every alternating-background band, applied at the mj-section\n// level so each colored band has symmetric breathing room. (Starting baseline — easy to tune.)\nconst SECTION_PAD = \"40px\";\n\n/** A red all-caps section label. Top spacing comes from the section padding, so this is flush. */\nfunction sectionLabel(text: string): string {\n return `<mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"0px\">${escapeXml(text)}</mj-text>`;\n}\n\n/** A grey 16px body paragraph whose caller passes already-escaped HTML, so partial inline markup\n * (e.g. the cadence line's italic <em>…</em> tail) survives. */\nfunction bodyLineHtml(html: string, paddingTop = \"8px\"): string {\n return `<mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\" padding-top=\"${paddingTop}\">${html}</mj-text>`;\n}\n\n/** A grey 16px body paragraph, matching the report's section intros. Escapes `text`. */\nfunction bodyLine(text: string, paddingTop = \"8px\"): string {\n return bodyLineHtml(escapeXml(text), paddingTop);\n}\n\n/** The go-forward cadence sentence as HTML: the lead-in followed by its reassurance tail in\n * italics (\"…short report like this — <em>there's nothing you need to do.</em>\"). Both escaped. */\nfunction cadenceHtml(copy: ResolvedCopy): string {\n return `${escapeXml(copy.announceCadence)} <em>${escapeXml(copy.announceCadenceNote)}</em>`;\n}\n\n/**\n * The announcement-only ReportData extras derived from the Websites row: the go-forward\n * cadence and the default-on improvement callouts. Used by BOTH the draft (announce recipe)\n * and the send-time re-render (orchestrate) so the sent email matches the reviewed preview —\n * `renderReportHtml` in the send path otherwise omits these, dropping the cadence + improvements.\n */\nexport function announcementSiteExtras(\n site: WebsiteRow,\n): Pick<ReportData, \"cadence\" | \"improvements\"> {\n return {\n cadence: { maintenance: site.maintenanceFreq, testing: site.testingFreq },\n improvements: { resendForms: true, svelte5: true },\n };\n}\n\n/**\n * One-time onboarding announcement, built from the SAME components as the monthly report so it\n * reads as a testing report with extra explanation: header · intro (\"your ongoing care\") ·\n * MAINTENANCE CHECKS · TESTING (each: intro with the cadence baked into the copy + the report's\n * checklist rows) · LIGHTHOUSE SCORES · ANALYTICS (users + trend + the Google-position line) ·\n * RECENT IMPROVEMENTS (conditional; closes with the open-door invitation). Reuses the M6a copy\n * layer (contact/footer honor per-site overrides). No pricing. A pace set to None omits its\n * checklist section. Every band carries equal top/bottom padding (SECTION_PAD).\n */\nexport function buildAnnouncementMjml(data: ReportData): string {\n const copy = data.copy ?? DEFAULT_COPY;\n // Site-interpolated, matching the launch (\"<site> is live\") and maintenance\n // (\"Checked up on <site>\") templates. The old text read \"Your monthly report\n // from Reddoor\", which was wrong twice over: this is the announcement, not a\n // report, and it hard-coded a monthly cadence the body contradicts for every\n // client on a quarterly/yearly pace.\n const previewText = `Your ongoing site care for ${data.siteName}`;\n const cad = data.cadence;\n\n const hasMaint = Boolean(cad && cad.maintenance !== \"None\");\n const hasTesting = Boolean(cad && cad.testing !== \"None\");\n const improvementItems: string[] = [];\n if (data.improvements?.resendForms) improvementItems.push(copy.announceImprovementResend);\n if (data.improvements?.svelte5) improvementItems.push(copy.announceImprovementSvelte5);\n const hasImpr = improvementItems.length > 0;\n\n // ANALYTICS renders only with real data — a GA user count or a page-1 search callout (the latter\n // keeps the block alive for a GA-less site that still ranks). Computed up here so the band\n // counter below can SKIP its background slot when analytics is hidden; otherwise a consumed-but-\n // invisible slot would push the next band to the wrong color and two same-color bands would abut.\n const analyticsBodyLines =\n data.searchPosition !== undefined\n ? [`Page 1 Google result (#${data.searchPosition}) for your brand search`]\n : [];\n const hasAnalytics = hasAnalyticsData({\n current: data.gaUsersCurrent,\n previous: data.gaUsersPrevious,\n bodyLines: analyticsBodyLines,\n });\n\n // Alternating band backgrounds assigned in render order, so the white/#F4F4F4 pattern holds\n // no matter which optional sections (maintenance / testing / improvements) actually render —\n // a counter that only advances for bands that appear avoids two same-colored bands abutting.\n const BANDS = [\"white\", \"#F4F4F4\"] as const;\n let bandN = 0;\n const nextBg = (): string => BANDS[bandN++ % 2]!;\n const introBg = nextBg();\n const maintBg = hasMaint ? nextBg() : \"\";\n const testBg = hasTesting ? nextBg() : \"\";\n const lighthouseBg = nextBg();\n const analyticsBg = hasAnalytics ? nextBg() : \"\";\n const improvementsBg = hasImpr ? nextBg() : \"\";\n const contactBg = nextBg();\n\n // MAINTENANCE CHECKS (first) — intro with the maintenance cadence baked into the copy, then the\n // report's checklist rows. The report-frequency reassurance (announceCadence) trails the LAST\n // check block, so it sits here only when there's no testing block after it. Omitted when None.\n const maintenanceSection =\n cad && cad.maintenance !== \"None\"\n ? `\n <mj-section background-color=\"${maintBg}\" padding-top=\"${SECTION_PAD}\" padding-bottom=\"0px\">\n <mj-column>\n ${sectionLabel(\"MAINTENANCE CHECKS\")}\n ${bodyLineHtml(`${escapeXml(`${copy.maintenanceIntro} We do this ${FREQ_PHRASE[cad.maintenance]}.`)}${cad.testing === \"None\" ? ` ${cadenceHtml(copy)}` : \"\"}`)}\n </mj-column>\n </mj-section>${checklistRowsSection(copy.maintenanceChecks, {\n background: maintBg,\n lastPaddingBottom: SECTION_PAD,\n })}`\n : \"\";\n\n // TESTING (second) — intro with the testing cadence + the report-frequency note baked in, then\n // the report's checklist rows. Omitted when None.\n const testingSection =\n cad && cad.testing !== \"None\"\n ? `\n <mj-section background-color=\"${testBg}\" padding-top=\"${SECTION_PAD}\" padding-bottom=\"0px\">\n <mj-column>\n ${sectionLabel(\"TESTING\")}\n ${bodyLineHtml(`${escapeXml(`${copy.testingIntro} We run a full test ${FREQ_PHRASE[cad.testing]}.`)} ${cadenceHtml(copy)}`)}\n </mj-column>\n </mj-section>${checklistRowsSection(copy.testingChecklist, {\n background: testBg,\n lastPaddingBottom: SECTION_PAD,\n })}`\n : \"\";\n\n // ANALYTICS — the report's big user count + trend, plus the Google search-position line. Hidden\n // entirely when there's no traffic or search data (hasAnalytics false), so it never shows empty.\n const analytics = hasAnalytics\n ? analyticsSection({\n current: data.gaUsersCurrent,\n previous: data.gaUsersPrevious,\n periodDays: data.gaPeriodDays,\n background: analyticsBg,\n pad: SECTION_PAD,\n bodyLines: analyticsBodyLines,\n })\n : \"\";\n\n // RECENT IMPROVEMENTS — the toggled callouts, closed by the open-door invitation. Omitted\n // entirely when there are no improvements (the open-door rides along with this block).\n const improvementsSection = hasImpr\n ? `\n <mj-section background-color=\"${improvementsBg}\" padding-top=\"${SECTION_PAD}\" padding-bottom=\"${SECTION_PAD}\">\n <mj-column>\n ${sectionLabel(\"RECENT IMPROVEMENTS\")}\n ${improvementItems.map((item) => bodyLine(item)).join(\"\\n \")}\n ${bodyLine(copy.announceOpenDoor, \"16px\")}\n </mj-column>\n </mj-section>`\n : \"\";\n\n // First contact line (\"Just hit reply.\") renders as a red bold heading, matching the\n // \"Questions, concerns or requests?\" title above it; any following lines (e.g. \"We're\n // here to help in any way we can.\") render in muted grey.\n const contactRows = copy.contact\n .map((line, i) =>\n i === 0\n ? `\n <mj-text color=\"${RED}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"700\" line-height=\"30px\">${escapeXml(line)}</mj-text>`\n : `\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"300\" line-height=\"30px\">${escapeXml(line)}</mj-text>`,\n )\n .join(\"\");\n const footerAddressRows = copy.footerAddress\n .map(\n (line) => `\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">${escapeXml(line)}</mj-text>`,\n )\n .join(\"\");\n\n return `<mjml>\n <mj-head>\n <mj-attributes>\n <mj-text font-family=\"helvetica, sans-serif\" padding-left=\"5px\" padding-right=\"5px\" />\n <mj-section padding-left=\"11%\" padding-right=\"11%\"/>\n <mj-image padding=\"0px\" />\n </mj-attributes>\n <mj-preview>${escapeXml(previewText)}</mj-preview>\n ${headerStyleBlock(data)}\n </mj-head>\n <mj-body background-color=\"white\">\n <mj-section background-color=\"#F4F4F4\" padding-top=\"0px\" padding-bottom=\"0px\" padding-left=\"0px\" padding-right=\"0px\">\n <mj-column>${headerImageTag(data)}</mj-column>\n </mj-section>\n <mj-section background-color=\"${introBg}\" padding-top=\"${SECTION_PAD}\" padding-bottom=\"${SECTION_PAD}\">\n <mj-column>\n ${sectionLabel(copy.announceHeading)}\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\" padding-top=\"20px\">Prepared for ${escapeXml(data.siteName)}</mj-text>\n ${bodyLine(copy.announceBody)}\n </mj-column>\n </mj-section>\n ${maintenanceSection}\n ${testingSection}\n ${lighthouseScoresSection(data.lighthouse, { background: lighthouseBg, pad: SECTION_PAD })}\n ${analytics}\n ${improvementsSection}\n <mj-section background-color=\"${contactBg}\" padding-top=\"${SECTION_PAD}\" padding-bottom=\"${SECTION_PAD}\">\n <mj-column>\n <mj-text color=\"${RED}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"700\" padding-top=\"0px\" line-height=\"36px\">Questions, concerns or requests?</mj-text>\n ${contactRows}\n <mj-divider border-width=\"1px\" border-style=\"solid\" border-color=\"#CCCCCC\" padding=\"0\" />\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" padding-top=\"24px\" line-height=\"20px\" font-style=\"italic\">Copyright ${new Date().getUTCFullYear()} ${escapeXml(copy.footerOrg)}. All rights reserved.</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"700\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">Our mailing address is:</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">${escapeXml(copy.footerOrg)}</mj-text>\n ${footerAddressRows}\n </mj-column>\n </mj-section>\n </mj-body>\n</mjml>`;\n}\n","import mjml2html from \"mjml\";\nimport type { ReportData } from \"./types.js\";\nimport { buildMjml } from \"./maintenance-email/template.js\";\nimport { buildLaunchMjml } from \"./launch-email/template.js\";\nimport { buildAnnouncementMjml } from \"./announcement-email/template.js\";\n\nexport type RenderResult = {\n html: string;\n warnings: Array<{ line: number; message: string }>;\n};\n\n/**\n * True when this report type's template actually renders `commentary`.\n *\n * Deliberately expressed as the INVERSE of the dispatch below, and sitting right\n * next to it, so the two cannot drift: `buildLaunchMjml` and\n * `buildAnnouncementMjml` never reference the field, and everything else routes\n * to `buildMjml`, which does. tests/reports/renders-commentary.test.ts renders\n * every type through the real MJML pipeline and fails if that stops being true.\n *\n * Consumers use it to avoid OFFERING commentary where it would be silently\n * dropped — the console shipped an editor on every unsent report before this\n * existed, so an operator could write commentary on an Announcement, save it,\n * and never see it reach the client.\n */\nexport function rendersCommentary(type: ReportData[\"reportType\"]): boolean {\n return type !== \"Launch\" && type !== \"Announcement\";\n}\n\nexport async function renderReportHtml(data: ReportData): Promise<RenderResult> {\n const mjml =\n data.reportType === \"Launch\"\n ? buildLaunchMjml(data)\n : data.reportType === \"Announcement\"\n ? buildAnnouncementMjml(data)\n : buildMjml(data);\n const out = await mjml2html(mjml, { validationLevel: \"strict\" });\n return { html: out.html, warnings: out.errors ?? [] };\n}\n","import type { ReportData } from \"../types.js\";\nimport { DEFAULT_COPY } from \"../copy.js\";\nimport {\n escapeXml,\n fmtDate,\n headerImageTag,\n headerStyleBlock,\n} from \"../maintenance-email/template.js\";\n\nconst RED = \"#C00\";\nconst GREY = \"#757575\";\n\n/** Purpose-built go-live email: header · LAUNCHED + date · message · what-we-set-up\n * · contact · footer. Reuses the M6a copy layer (contact/footer honor per-site\n * overrides). No maintenance checklist / Lighthouse / analytics. */\nexport function buildLaunchMjml(data: ReportData): string {\n const copy = data.copy ?? DEFAULT_COPY;\n const previewText = `${escapeXml(data.siteName)} is live`;\n // All copy — launchHeading/launchBody/launchSetupItems included — is escaped\n // (spec §3.3: all copy escaped). It keeps strict MJML from choking on a stray\n // `&`/`<` if the default copy ever gains one, matching contact/footer below.\n const setupRows = copy.launchSetupItems\n .map(\n (item) => `\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\" padding-top=\"4px\" padding-bottom=\"4px\">• ${escapeXml(item)}</mj-text>`,\n )\n .join(\"\");\n // First contact line (\"Just hit reply.\") renders as a red bold heading, matching the\n // \"Any questions, concerns or requests?\" title above it; following lines stay default.\n const contactRows = copy.contact\n .map((line, i) =>\n i === 0\n ? `\n <mj-text color=\"${RED}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"700\" line-height=\"30px\">${escapeXml(line)}</mj-text>`\n : `\n <mj-text font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"300\" line-height=\"30px\">${escapeXml(line)}</mj-text>`,\n )\n .join(\"\");\n const footerAddressRows = copy.footerAddress\n .map(\n (line) => `\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">${escapeXml(line)}</mj-text>`,\n )\n .join(\"\");\n\n return `<mjml>\n <mj-head>\n <mj-attributes>\n <mj-text font-family=\"helvetica, sans-serif\" padding-left=\"5px\" padding-right=\"5px\" />\n <mj-section padding-left=\"11%\" padding-right=\"11%\"/>\n <mj-image padding=\"0px\" />\n </mj-attributes>\n <mj-preview>${previewText}</mj-preview>\n ${headerStyleBlock(data)}\n </mj-head>\n <mj-body background-color=\"white\">\n <mj-section background-color=\"#F4F4F4\" padding-top=\"0px\" padding-bottom=\"0px\" padding-left=\"0px\" padding-right=\"0px\">\n <mj-column>${headerImageTag(data)}</mj-column>\n </mj-section>\n <mj-section background-color=\"white\">\n <mj-column>\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"75px\">${escapeXml(copy.launchHeading)}</mj-text>\n <mj-text color=\"${RED}\" font-size=\"44px\" font-weight=\"400\">${fmtDate(data.completedOn)}</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\" padding-top=\"20px\">${escapeXml(copy.launchBody)}</mj-text>\n ${setupRows}\n </mj-column>\n </mj-section>\n <mj-section background-color=\"white\">\n <mj-column padding-top=\"36px\">\n <mj-text color=\"${RED}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"700\" padding-top=\"36px\" line-height=\"36px\">Any questions, concerns or requests?</mj-text>\n ${contactRows}\n <mj-divider border-width=\"1px\" border-style=\"solid\" border-color=\"#CCCCCC\" padding=\"0\" />\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" padding-top=\"24px\" line-height=\"20px\" font-style=\"italic\">Copyright ${new Date().getUTCFullYear()} ${escapeXml(copy.footerOrg)}. All rights reserved.</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"700\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">Our mailing address is:</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">${escapeXml(copy.footerOrg)}</mj-text>\n ${footerAddressRows}\n </mj-column>\n </mj-section>\n </mj-body>\n</mjml>`;\n}\n"],"mappings":"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","names":["escapeXml","RED","GREY","escapeXml","RED","GREY","escapeXml"]}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
hasRealScores,
|
|
3
3
|
lighthouseScoresFromResult
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-7BZUOZPU.js";
|
|
5
|
+
import {
|
|
6
|
+
TURSO_IS_AUTHORITATIVE
|
|
7
|
+
} from "./chunk-RUPPWAD6.js";
|
|
5
8
|
import {
|
|
6
9
|
detectAuditEvents
|
|
7
10
|
} from "./chunk-5L3FPQIJ.js";
|
|
@@ -9,7 +12,7 @@ import {
|
|
|
9
12
|
normalizeSecurityAdvisory,
|
|
10
13
|
siteSlug,
|
|
11
14
|
updateAuditFields
|
|
12
|
-
} from "./chunk-
|
|
15
|
+
} from "./chunk-7NYN2P6I.js";
|
|
13
16
|
|
|
14
17
|
// src/audits/a11y-airtable.ts
|
|
15
18
|
function hasA11yCounts(result) {
|
|
@@ -278,6 +281,12 @@ async function writeAuditsToAirtable(args) {
|
|
|
278
281
|
}
|
|
279
282
|
return { siteName: target.name, writes, events, siteId: target.id, fields };
|
|
280
283
|
}
|
|
284
|
+
function fleetWriteFailed(result, strict = TURSO_IS_AUTHORITATIVE) {
|
|
285
|
+
if (result.failed.length > 0) return true;
|
|
286
|
+
if (!strict) return false;
|
|
287
|
+
if (result.mirrored === void 0) return true;
|
|
288
|
+
return (result.mirrorFailed ?? 0) > 0 || (result.mirrorMissed ?? 0) > 0;
|
|
289
|
+
}
|
|
281
290
|
function formatFleetWriteSummary(result) {
|
|
282
291
|
const wrote = result.written.length;
|
|
283
292
|
const failed = result.failed.length;
|
|
@@ -330,7 +339,8 @@ async function writeFleetAuditsToAirtable(args) {
|
|
|
330
339
|
|
|
331
340
|
export {
|
|
332
341
|
writeAuditsToAirtable,
|
|
342
|
+
fleetWriteFailed,
|
|
333
343
|
formatFleetWriteSummary,
|
|
334
344
|
writeFleetAuditsToAirtable
|
|
335
345
|
};
|
|
336
|
-
//# sourceMappingURL=chunk-
|
|
346
|
+
//# sourceMappingURL=chunk-KMCZA7CH.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/function-health-airtable.ts","../src/audits/smoke-airtable.ts","../src/audits/form-e2e-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 majorOutdated: 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 // How many of those outdated installs are a *major* behind the registry's\n // latest — surfaced separately from the total so the cockpit can show\n // \"N major\" available, distinct from the baseline-drift \"major\" count. Shares\n // the outdated signal's null-when-undetermined semantics.\n const majorOutdated = details.outdated?.major ?? null;\n return { drifted, majorBehind, outdated, majorOutdated };\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 reachableOk: boolean;\n titleMetaOk: 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 reachableOk: d?.reachableOk === true,\n titleMetaOk: d?.titleMetaOk === 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 { FunctionHealthResult } from \"../reports/airtable/websites.js\";\n\ntype FunctionHealthDetails = {\n ok: boolean;\n prismic: \"ok\" | \"error\" | \"skipped\" | null;\n forms: unknown;\n checkedAt: string;\n};\n\n/** True when an AuditResult is a `function-health` audit carrying a usable details payload (i.e. it\n * actually ran — a self-skip for an unreachable / non-JSON `/health`, or a site with no deployed\n * URL, has no details, so the writer preserves the prior verdict). */\nexport function hasFunctionHealthResult(result: AuditResult): boolean {\n if (result.audit !== \"function-health\") return false;\n const d = result.details as FunctionHealthDetails | undefined;\n return !!d && typeof d.checkedAt === \"string\";\n}\n\n/** Extract the Airtable-writable function-health verdicts. `functionHealth` from `ok`; `cmsReachable`\n * from the same body's `prismic` sub-status (R2.2): `\"ok\"` → `\"pass\"`, `\"error\"` → `\"fail\"`, and\n * anything else (`\"skipped\"` — a placeholder repo, or a raw `null` from a \"deployed but erroring\"\n * body / an unrecognized value) → `null` — the CMS probe never actually ran, so it must not red CMS\n * reachability for a site that simply hasn't wired Prismic yet. Only `functionHealth` keys off\n * `ok`. */\nexport function functionHealthResultFromAudit(result: AuditResult): FunctionHealthResult {\n if (result.audit !== \"function-health\") {\n throw new Error(`Expected a 'function-health' AuditResult, got '${result.audit}'`);\n }\n const d = result.details as FunctionHealthDetails | undefined;\n const cmsReachable: \"pass\" | \"fail\" | null =\n d?.prismic === \"ok\" ? \"pass\" : d?.prismic === \"error\" ? \"fail\" : null;\n // `forms` is untyped on the details payload (unknown) — validate defensively. A boolean\n // `turnstile` inside an object → concrete verdict; anything else (null forms from an older\n // site package or a \"deployed but erroring\" body, a malformed shape) → null: the widget\n // state is simply unknown this run, never a fail. Powers the Require-Turnstile guardrail.\n const formsRaw = d?.forms;\n const turnstileFlag =\n formsRaw && typeof formsRaw === \"object\"\n ? (formsRaw as Record<string, unknown>)[\"turnstile\"]\n : undefined;\n const turnstileWidget: \"pass\" | \"fail\" | null =\n typeof turnstileFlag === \"boolean\" ? (turnstileFlag ? \"pass\" : \"fail\") : null;\n return {\n functionHealth: d?.ok === true ? \"pass\" : \"fail\",\n cmsReachable,\n turnstileWidget,\n checkedAt: typeof d?.checkedAt === \"string\" ? d.checkedAt : new Date().toISOString(),\n };\n}\n","import type { AuditResult } from \"../types.js\";\nimport type { SmokeResult } from \"../reports/airtable/websites.js\";\n\ntype SmokeDetails = { ok: \"pass\" | \"fail\"; checkedAt: string };\n\n/** True when an AuditResult is a `smoke` audit that actually ran (has details with\n * a checkedAt — a \"skip\" for a missing `pnpm`/`test:smoke` script has none, so the\n * writer preserves the prior verdict). */\nexport function hasSmokeResult(result: AuditResult): boolean {\n if (result.audit !== \"smoke\") return false;\n const d = result.details as SmokeDetails | undefined;\n return !!d && typeof d.checkedAt === \"string\";\n}\n\n/** Extract the Airtable-writable smoke verdict (pass/fail + checked-at). */\nexport function smokeResultFromAudit(result: AuditResult): SmokeResult {\n if (result.audit !== \"smoke\") {\n throw new Error(`Expected a 'smoke' AuditResult, got '${result.audit}'`);\n }\n const d = result.details as SmokeDetails | undefined;\n return {\n ok: d?.ok === \"fail\" ? \"fail\" : \"pass\",\n checkedAt: typeof d?.checkedAt === \"string\" ? d.checkedAt : new Date().toISOString(),\n };\n}\n","import type { AuditResult } from \"../types.js\";\nimport type { FormE2eResult } from \"../reports/airtable/websites.js\";\n\ntype FormE2eDetails = { ok: \"pass\" | \"fail\" | null; formPresent: boolean; checkedAt: string };\n\n/** True when an AuditResult is a `form-e2e` audit that actually RAN (has details\n * with a checkedAt). Both a real verdict AND the no-form n/a case carry a checkedAt,\n * so both persist; only a no-deployed-URL skip (no details) is preserved-prior. */\nexport function hasFormE2eResult(result: AuditResult): boolean {\n if (result.audit !== \"form-e2e\") return false;\n const d = result.details as FormE2eDetails | undefined;\n return !!d && typeof d.checkedAt === \"string\";\n}\n\n/** Extract the Airtable-writable form-e2e verdict (pass/fail/null + checked-at). */\nexport function formE2eResultFromAudit(result: AuditResult): FormE2eResult {\n if (result.audit !== \"form-e2e\") {\n throw new Error(`Expected a 'form-e2e' AuditResult, got '${result.audit}'`);\n }\n const d = result.details as FormE2eDetails | undefined;\n const ok = d?.ok === \"pass\" ? \"pass\" : d?.ok === \"fail\" ? \"fail\" : null;\n return {\n ok,\n checkedAt: typeof d?.checkedAt === \"string\" ? d.checkedAt : new Date().toISOString(),\n };\n}\n","import type { FieldSet } from \"airtable\";\nimport type { AuditResult } from \"../types.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport type { HealthMirror } from \"./health-mirror.js\";\nimport { TURSO_IS_AUTHORITATIVE } from \"../db/freeze.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 FunctionHealthResult,\n SmokeResult,\n FormE2eResult,\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 {\n hasFunctionHealthResult,\n functionHealthResultFromAudit,\n} from \"./function-health-airtable.js\";\nimport { hasSmokeResult, smokeResultFromAudit } from \"./smoke-airtable.js\";\nimport { hasFormE2eResult, formE2eResultFromAudit } from \"./form-e2e-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 | \"function-health\"\n | \"smoke\"\n | \"form-e2e\"\n // Not an audit: the nightly Prismic model sweep reuses this result shape so\n // its write-back reports through the same `FLEET_WRITE_SUMMARY` line every\n // other fleet writer emits, rather than inventing a second contract for CI\n // to grep.\n | \"prismic-models\";\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 /** The Websites rec id + the merged FieldSet actually written — what the\n * Phase 3 Turso mirror consumes, so mirror and Airtable write share ONE\n * payload. Populated by writeAuditsToAirtable; other WriteSummary\n * producers (prismic-models, github-signals) don't carry them. */\n siteId?: string;\n fields?: FieldSet;\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 functionHealth?: FunctionHealthResult;\n smoke?: SmokeResult;\n formE2e?: FormE2eResult;\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 const functionHealth = results.find((r) => r.audit === \"function-health\");\n if (functionHealth && hasFunctionHealthResult(functionHealth)) {\n const result = functionHealthResultFromAudit(functionHealth);\n audits.functionHealth = result;\n writes.push({ audit: \"function-health\", counts: result });\n }\n\n const smoke = results.find((r) => r.audit === \"smoke\");\n if (smoke && hasSmokeResult(smoke)) {\n const result = smokeResultFromAudit(smoke);\n audits.smoke = result;\n writes.push({ audit: \"smoke\", counts: result });\n }\n\n const formE2e = results.find((r) => r.audit === \"form-e2e\");\n if (formE2e && hasFormE2eResult(formE2e)) {\n const result = formE2eResultFromAudit(formE2e);\n audits.formE2e = result;\n writes.push({ audit: \"form-e2e\", 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 let fields: FieldSet = {};\n if (Object.keys(audits).length > 0) {\n fields = 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, siteId: target.id, fields };\n}\n\nexport type FleetWriteResult = {\n written: WriteSummary[];\n failed: Array<{ slug: string; error: string }>;\n /** Turso write-through counts (#539 Phase 3 dual-write). Present ONLY when a\n * mirror was wired — absent means mirroring was not attempted (no libSQL\n * creds), which must stay distinguishable from `mirrored=0` (a wired mirror\n * that landed nothing = the fleet-wide outage alarm). */\n mirrored?: number;\n mirrorFailed?: number;\n /** Mirror UPDATEs that matched no site_health row (site created in Airtable\n * after the last hourly import). Not a failure — the row converges on the\n * next sync — but not mirrored either: counted apart so `mirrored=` stays\n * an honest Phase 5 cutover signal instead of overcounting by the no-row\n * sites. */\n mirrorMissed?: number;\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. */\n/** Did this sweep fail, for exit-code purposes (#612)?\n *\n * Pre-freeze, only an Airtable write failure counts. Mirror failures and missed\n * rows are real but survivable: the hourly import converges them, and going red\n * over a transient the system already handles would train the alarm to be\n * ignored.\n *\n * Post-freeze there is no import to converge anything, so all three become\n * fatal — including an ABSENT mirror, which is the worst of the three and the\n * easiest to misread as success: no counters at all means no libSQL creds,\n * which means the sweep wrote nothing to the only store there is.\n *\n * Note what this deliberately does NOT change: `writeFleetAuditsToAirtable`\n * still catches per-site mirror failures rather than throwing. One bad site\n * must not abort a 44-site sweep. This gates the RUN, not the loop. */\nexport function fleetWriteFailed(\n result: FleetWriteResult,\n strict: boolean = TURSO_IS_AUTHORITATIVE,\n): boolean {\n if (result.failed.length > 0) return true;\n if (!strict) return false;\n if (result.mirrored === undefined) return true; // no mirror was wired at all\n return (result.mirrorFailed ?? 0) > 0 || (result.mirrorMissed ?? 0) > 0;\n}\n\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 // Mirror counts APPEND so the fleet workflows' `grep -oE \"FLEET_WRITE_SUMMARY\n // wrote=... total=[0-9]+\"` extraction still matches its prefix untouched.\n if (result.mirrored !== undefined) {\n out += ` mirrored=${result.mirrored} mirror_failed=${result.mirrorFailed ?? 0}`;\n out += ` mirror_missed=${result.mirrorMissed ?? 0}`;\n }\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 /** Optional Turso write-through (#539 Phase 3 dual-write). Each site's\n * just-written FieldSet mirrors into site_health right after its Airtable\n * write; a mirror failure is counted, never thrown — Airtable stays\n * authoritative and the hourly sync converges what the mirror missed.\n * (The lighthouse-miss partial write lands in `failed` and is deliberately\n * NOT mirrored — same convergence path.) */\n mirror?: HealthMirror;\n}): Promise<FleetWriteResult> {\n const { base, websites, results, mirror } = 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 let mirrored = 0;\n let mirrorFailed = 0;\n let mirrorMissed = 0;\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 const summary = await writeAuditsToAirtable({ base, websites, slug, results: siteResults });\n written.push(summary);\n if (mirror && summary.siteId && summary.fields && Object.keys(summary.fields).length > 0) {\n try {\n if (await mirror(summary.siteId, summary.fields)) mirrored++;\n else mirrorMissed++;\n } catch (e) {\n mirrorFailed++;\n console.error(`[health-mirror] ${slug}: ${(e as Error).message}`);\n }\n }\n } catch (e) {\n failed.push({ slug, error: (e as Error).message });\n }\n }\n return { written, failed, ...(mirror ? { mirrored, mirrorFailed, mirrorMissed } : {}) };\n}\n"],"mappings":"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","names":["fields"]}
|
|
@@ -322,6 +322,41 @@ var MIGRATIONS = [
|
|
|
322
322
|
CREATE INDEX IF NOT EXISTS idx_prospect_audits_created
|
|
323
323
|
ON prospect_audits (created_at DESC);
|
|
324
324
|
`
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
// #609 (#539 Phase 5): the digest's prior-run snapshot moves off Airtable.
|
|
328
|
+
// Unlike the rest of Phase 5 this is a MIGRATION, not a dual-write — there
|
|
329
|
+
// is no Airtable counterpart left afterwards, so parity does not cover it.
|
|
330
|
+
//
|
|
331
|
+
// A single row holding the whole snapshot as JSON, keyed by a constant id.
|
|
332
|
+
// Both readers (runDigest's diff and the fleet homepage's NEW badges) need
|
|
333
|
+
// the ENTIRE map, so a keyed table would buy nothing on reads and its
|
|
334
|
+
// "give me everything" query would be a raw scan needing a justified entry
|
|
335
|
+
// in the EXPLAIN gate's allowlist. One row by primary key needs neither.
|
|
336
|
+
id: "0011_digest_state",
|
|
337
|
+
sql: `
|
|
338
|
+
CREATE TABLE IF NOT EXISTS digest_state (
|
|
339
|
+
id TEXT PRIMARY KEY,
|
|
340
|
+
snapshot TEXT NOT NULL,
|
|
341
|
+
updated_at TEXT
|
|
342
|
+
);
|
|
343
|
+
`
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
// form_type was the one SubmissionFilter key with an equality predicate and no
|
|
347
|
+
// index — countSubmissionsFiltered({formType}) raw-scanned `submissions`, twice
|
|
348
|
+
// per load of the submissions page. It shipped green because the EXPLAIN gate
|
|
349
|
+
// named its scenarios by hand and never wrote one for this shape; the gate is
|
|
350
|
+
// now driven by a filter matrix, which is what surfaced it.
|
|
351
|
+
//
|
|
352
|
+
// `submissions` is the one unbounded-growth table in the schema — append-only,
|
|
353
|
+
// one row per fleet lead forever — and Turso meters ROW SCANS, so this is the
|
|
354
|
+
// cost that grows without a ceiling.
|
|
355
|
+
id: "0012_submissions_form_type_index",
|
|
356
|
+
sql: `
|
|
357
|
+
CREATE INDEX IF NOT EXISTS idx_submissions_form_type
|
|
358
|
+
ON submissions (form_type);
|
|
359
|
+
`
|
|
325
360
|
}
|
|
326
361
|
];
|
|
327
362
|
|
|
@@ -356,4 +391,4 @@ async function runMigrations(client) {
|
|
|
356
391
|
export {
|
|
357
392
|
runMigrations
|
|
358
393
|
};
|
|
359
|
-
//# sourceMappingURL=chunk-
|
|
394
|
+
//# sourceMappingURL=chunk-MFHS7IQ2.js.map
|