@reddoorla/maintenance 0.85.2 → 0.86.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/README.md +8 -0
  2. package/dist/airtable-G7WBQQMI.js +9 -0
  3. package/dist/{announce-GIAYN67S.js → announce-MXTUQ6C7.js} +26 -19
  4. package/dist/announce-MXTUQ6C7.js.map +1 -0
  5. package/dist/{bump-deps-SWJB6H5V.js → bump-deps-EC74CPLS.js} +9 -9
  6. package/dist/chunk-2EVEFOZY.js +129 -0
  7. package/dist/chunk-2EVEFOZY.js.map +1 -0
  8. package/dist/{chunk-COZPL62X.js → chunk-2NDRHXMR.js} +25 -17
  9. package/dist/chunk-2NDRHXMR.js.map +1 -0
  10. package/dist/{chunk-SDUJIANJ.js → chunk-2NJQLSEU.js} +3 -3
  11. package/dist/chunk-2NQUCS7O.js +243 -0
  12. package/dist/chunk-2NQUCS7O.js.map +1 -0
  13. package/dist/{chunk-DTTIFYAM.js → chunk-2ZPT7GLV.js} +42 -403
  14. package/dist/chunk-2ZPT7GLV.js.map +1 -0
  15. package/dist/{chunk-ZTPPCHDB.js → chunk-3G25KIWW.js} +4 -4
  16. package/dist/chunk-3SF7D4DY.js +359 -0
  17. package/dist/chunk-3SF7D4DY.js.map +1 -0
  18. package/dist/{chunk-Q276MZ7E.js → chunk-3VQWSE3F.js} +4 -4
  19. package/dist/{chunk-37S6L6G4.js → chunk-5UINJNAJ.js} +72 -2
  20. package/dist/chunk-5UINJNAJ.js.map +1 -0
  21. package/dist/{chunk-THEFTZ7F.js → chunk-62CMMGEO.js} +5 -5
  22. package/dist/{chunk-FDIFTIXC.js → chunk-6TWWY7ZU.js} +2 -2
  23. package/dist/{chunk-UYCAF77E.js → chunk-7RJ2HMMJ.js} +3 -3
  24. package/dist/chunk-ACBRLLFT.js +198 -0
  25. package/dist/chunk-ACBRLLFT.js.map +1 -0
  26. package/dist/chunk-BNM5POSJ.js +284 -0
  27. package/dist/chunk-BNM5POSJ.js.map +1 -0
  28. package/dist/{chunk-R6FPFMOS.js → chunk-BQRFQSGY.js} +3 -3
  29. package/dist/{chunk-MGR2H7F4.js → chunk-BYK66EWO.js} +19 -12
  30. package/dist/chunk-BYK66EWO.js.map +1 -0
  31. package/dist/{chunk-6QRLRT3P.js → chunk-CQR4LIM7.js} +12 -265
  32. package/dist/chunk-CQR4LIM7.js.map +1 -0
  33. package/dist/{chunk-SEEA6RXF.js → chunk-DJZWYIPP.js} +2 -2
  34. package/dist/chunk-DJZWYIPP.js.map +1 -0
  35. package/dist/chunk-EVBCK2KC.js +68 -0
  36. package/dist/chunk-EVBCK2KC.js.map +1 -0
  37. package/dist/{chunk-TP5M2NQB.js → chunk-F5KM4LEQ.js} +3 -3
  38. package/dist/{chunk-62VF32IC.js → chunk-ILUNZHYV.js} +2 -2
  39. package/dist/chunk-ITIOFN7A.js +396 -0
  40. package/dist/chunk-ITIOFN7A.js.map +1 -0
  41. package/dist/{chunk-LS6QPANO.js → chunk-IYSEC543.js} +2 -2
  42. package/dist/{chunk-DC5H7KEQ.js → chunk-JHGHLKFS.js} +2 -2
  43. package/dist/{chunk-PBWDDDQH.js → chunk-KTH5ZODA.js} +2 -2
  44. package/dist/{chunk-4DHQCN5H.js → chunk-LBYOLBW7.js} +17 -2
  45. package/dist/chunk-LBYOLBW7.js.map +1 -0
  46. package/dist/{chunk-577HCGZO.js → chunk-LDWZSLSU.js} +38 -23
  47. package/dist/chunk-LDWZSLSU.js.map +1 -0
  48. package/dist/{chunk-LTB6KVJW.js → chunk-N45M3FDK.js} +13 -11
  49. package/dist/{chunk-LTB6KVJW.js.map → chunk-N45M3FDK.js.map} +1 -1
  50. package/dist/chunk-NYUYNYQL.js +48 -0
  51. package/dist/chunk-NYUYNYQL.js.map +1 -0
  52. package/dist/{chunk-7J53SUT6.js → chunk-P2FE5AI6.js} +4 -4
  53. package/dist/{chunk-3YXDSCM6.js → chunk-PPE5KZJK.js} +29 -11
  54. package/dist/chunk-PPE5KZJK.js.map +1 -0
  55. package/dist/chunk-PTDMVGKL.js +31 -0
  56. package/dist/chunk-PTDMVGKL.js.map +1 -0
  57. package/dist/{chunk-D4FVAIEJ.js → chunk-PZR4EBPY.js} +5 -2
  58. package/dist/chunk-PZR4EBPY.js.map +1 -0
  59. package/dist/{chunk-NWB4O5EY.js → chunk-SLUQ7R6I.js} +3 -3
  60. package/dist/chunk-SLUQ7R6I.js.map +1 -0
  61. package/dist/{chunk-GZNBCHAZ.js → chunk-TERE5SDF.js} +4 -4
  62. package/dist/{chunk-ZMIPG6TR.js → chunk-TVL5MVUF.js} +4 -4
  63. package/dist/chunk-UDRHAQT7.js +172 -0
  64. package/dist/chunk-UDRHAQT7.js.map +1 -0
  65. package/dist/chunk-VAD43MOR.js +72 -0
  66. package/dist/chunk-VAD43MOR.js.map +1 -0
  67. package/dist/chunk-VSBFM7DS.js +229 -0
  68. package/dist/chunk-VSBFM7DS.js.map +1 -0
  69. package/dist/chunk-W7Z3BGCI.js +274 -0
  70. package/dist/chunk-W7Z3BGCI.js.map +1 -0
  71. package/dist/chunk-X3B723J4.js +1209 -0
  72. package/dist/chunk-X3B723J4.js.map +1 -0
  73. package/dist/{chunk-VRWUL4TR.js → chunk-XHHMM5HX.js} +2 -2
  74. package/dist/{chunk-RJB3QUSP.js → chunk-XIDUYLSR.js} +3 -3
  75. package/dist/{chunk-QG6OSL4M.js → chunk-XUGSZP7Z.js} +2 -2
  76. package/dist/{chunk-S56JQJ4F.js → chunk-Y53WOQIJ.js} +3 -3
  77. package/dist/{chunk-ICXV25N7.js → chunk-Z3KYFUN6.js} +3 -3
  78. package/dist/{chunk-MPBXOJ3N.js → chunk-Z7AXYKQB.js} +33 -2
  79. package/dist/chunk-Z7AXYKQB.js.map +1 -0
  80. package/dist/{chunk-WYYQK4D3.js → chunk-ZKXSX4LO.js} +11 -11
  81. package/dist/cli/bin.js +46 -28
  82. package/dist/cli/bin.js.map +1 -1
  83. package/dist/cli/commands/audit.d.ts +30 -1
  84. package/dist/cli/commands/audit.js +62 -30
  85. package/dist/cli/commands/audit.js.map +1 -1
  86. package/dist/client-AUE3XUPO.js +11 -0
  87. package/dist/{convert-to-pnpm-AT52NWZB.js → convert-to-pnpm-6L6P74WR.js} +9 -9
  88. package/dist/db-B35OGV3G.js +217 -0
  89. package/dist/db-B35OGV3G.js.map +1 -0
  90. package/dist/{digest-UVHYZUYE.js → digest-BL6UJYKH.js} +23 -20
  91. package/dist/{digest-UVHYZUYE.js.map → digest-BL6UJYKH.js.map} +1 -1
  92. package/dist/dump-P6EBSIOH.js +68 -0
  93. package/dist/dump-P6EBSIOH.js.map +1 -0
  94. package/dist/email-VTTTKKL5.js +195 -0
  95. package/dist/email-VTTTKKL5.js.map +1 -0
  96. package/dist/{ensure-site-S25MDDHW.js → ensure-site-DFTB7MN3.js} +8 -7
  97. package/dist/ensure-site-DFTB7MN3.js.map +1 -0
  98. package/dist/fleet-events-cF2Om88w.d.ts +24 -0
  99. package/dist/{forms-notify-target-TWN4EH6K.js → forms-notify-target-75U6OU7L.js} +38 -132
  100. package/dist/forms-notify-target-75U6OU7L.js.map +1 -0
  101. package/dist/{github-signals-G4ILZAJ6.js → github-signals-IJUPGCUX.js} +40 -19
  102. package/dist/github-signals-IJUPGCUX.js.map +1 -0
  103. package/dist/{header-image-T7XD37JL.js → header-image-AWLLQA4L.js} +34 -11
  104. package/dist/header-image-AWLLQA4L.js.map +1 -0
  105. package/dist/header-images-335L2AKB.js +68 -0
  106. package/dist/header-images-335L2AKB.js.map +1 -0
  107. package/dist/{health-endpoint-242WG2OJ.js → health-endpoint-WJC2UMYS.js} +9 -9
  108. package/dist/health-mirror-4B2L7T2Y.js +75 -0
  109. package/dist/health-mirror-4B2L7T2Y.js.map +1 -0
  110. package/dist/import-airtable-HE43FB4H.js +35 -0
  111. package/dist/index.d.ts +175 -21
  112. package/dist/index.js +115 -73
  113. package/dist/index.js.map +1 -1
  114. package/dist/{init-JN26NTWF.js → init-NJEOEO7I.js} +20 -17
  115. package/dist/{init-JN26NTWF.js.map → init-NJEOEO7I.js.map} +1 -1
  116. package/dist/{launch-JA7Z4CIM.js → launch-XNAX427U.js} +31 -25
  117. package/dist/{launch-JA7Z4CIM.js.map → launch-XNAX427U.js.map} +1 -1
  118. package/dist/lighthouse-VHI77PFB.js +11 -0
  119. package/dist/{lighthouse-airtable-BIPM26SZ.js → lighthouse-airtable-LBVIYYP5.js} +3 -3
  120. package/dist/mailchimp-XYCTEEJY.js +72 -0
  121. package/dist/mailchimp-XYCTEEJY.js.map +1 -0
  122. package/dist/migrate-GQH237A6.js +7 -0
  123. package/dist/notify-C645PGD5.js +22 -0
  124. package/dist/{onboard-53CITIM7.js → onboard-GK4PHPNI.js} +9 -9
  125. package/dist/orchestrate-77UO4BYJ.js +30 -0
  126. package/dist/orchestrate-77UO4BYJ.js.map +1 -0
  127. package/dist/parity-MWS24A3H.js +12 -0
  128. package/dist/parity-MWS24A3H.js.map +1 -0
  129. package/dist/pipeline-S6CVHDGB.js +12 -0
  130. package/dist/pipeline-S6CVHDGB.js.map +1 -0
  131. package/dist/{preflight-3XPDUHFJ.js → preflight-6WBNU2GZ.js} +16 -13
  132. package/dist/{preflight-3XPDUHFJ.js.map → preflight-6WBNU2GZ.js.map} +1 -1
  133. package/dist/{prismic-ci-UTQ6LRR3.js → prismic-ci-3RHOZUUT.js} +15 -15
  134. package/dist/{prismic-models-PVES6R5V.js → prismic-models-QBGUT2Q3.js} +10 -10
  135. package/dist/prospect-audit-IUNCJ2LV.js +174 -0
  136. package/dist/prospect-audit-IUNCJ2LV.js.map +1 -0
  137. package/dist/prospect-audits-3PMIO73D.js +19 -0
  138. package/dist/prospect-audits-3PMIO73D.js.map +1 -0
  139. package/dist/{protection-audit-64PLLBLI.js → protection-audit-SJO53MEQ.js} +3 -3
  140. package/dist/recipes/sync-configs.js +3 -3
  141. package/dist/render-AQAVQDPZ.js +10 -0
  142. package/dist/render-AQAVQDPZ.js.map +1 -0
  143. package/dist/{renovate-dispatch-ICJGSFCE.js → renovate-dispatch-QJY75ODR.js} +8 -8
  144. package/dist/replay-C2LXMJZP.js +66 -0
  145. package/dist/replay-C2LXMJZP.js.map +1 -0
  146. package/dist/{report-6CKSWJNG.js → report-4EVNDHOZ.js} +55 -29
  147. package/dist/report-4EVNDHOZ.js.map +1 -0
  148. package/dist/reports/header-image/assets/headline-announcement.png +0 -0
  149. package/dist/reports/header-image/assets/headline-launch.png +0 -0
  150. package/dist/reports/header-image/assets/headline-maintenance.png +0 -0
  151. package/dist/reports/header-image/assets/headline-testing.png +0 -0
  152. package/dist/reports/header-image/assets/plate-clean.png +0 -0
  153. package/dist/{self-updating-PPQVV6UA.js → self-updating-6DWI2Z56.js} +9 -9
  154. package/dist/{selftest-CQ6NOX52.js → selftest-RC36MTKP.js} +25 -20
  155. package/dist/selftest-RC36MTKP.js.map +1 -0
  156. package/dist/smoke-B2GQOSIZ.js +20 -0
  157. package/dist/smoke-B2GQOSIZ.js.map +1 -0
  158. package/dist/{smoke-suite-TMCSOYRK.js → smoke-suite-VYY2LXM2.js} +9 -9
  159. package/dist/{submissions-GJ6ITBRH.js → submissions-Q7DRFOCD.js} +3 -3
  160. package/dist/{svelte-codemods-ZOBDHIES.js → svelte-codemods-25J2FO22.js} +9 -9
  161. package/dist/sync-34HNZZ56.js +44 -0
  162. package/dist/sync-34HNZZ56.js.map +1 -0
  163. package/dist/{sync-configs-CJXUX6MD.js → sync-configs-3SE6PSCO.js} +9 -9
  164. package/dist/{upgrade-WLX27UPC.js → upgrade-WBRQ62FX.js} +9 -9
  165. package/dist/{webflow-624FEBFD.js → webflow-IRKOIT7S.js} +4 -4
  166. package/dist/webhook-VOGOW3W6.js +38 -0
  167. package/dist/webhook-VOGOW3W6.js.map +1 -0
  168. package/dist/{websites-SADZR4XQ.js → websites-SXWTP72S.js} +6 -2
  169. package/dist/websites-SXWTP72S.js.map +1 -0
  170. package/dist/{write-audits-to-airtable-7PH75YWY.js → write-audits-to-airtable-MBEILLPU.js} +4 -4
  171. package/dist/write-audits-to-airtable-MBEILLPU.js.map +1 -0
  172. package/package.json +5 -2
  173. package/dist/airtable-HRKSA7V4.js +0 -9
  174. package/dist/announce-GIAYN67S.js.map +0 -1
  175. package/dist/chunk-37S6L6G4.js.map +0 -1
  176. package/dist/chunk-3YXDSCM6.js.map +0 -1
  177. package/dist/chunk-4DHQCN5H.js.map +0 -1
  178. package/dist/chunk-4SOCPK7A.js +0 -108
  179. package/dist/chunk-4SOCPK7A.js.map +0 -1
  180. package/dist/chunk-577HCGZO.js.map +0 -1
  181. package/dist/chunk-6QRLRT3P.js.map +0 -1
  182. package/dist/chunk-COZPL62X.js.map +0 -1
  183. package/dist/chunk-D4FVAIEJ.js.map +0 -1
  184. package/dist/chunk-DTTIFYAM.js.map +0 -1
  185. package/dist/chunk-MGR2H7F4.js.map +0 -1
  186. package/dist/chunk-MPBXOJ3N.js.map +0 -1
  187. package/dist/chunk-NWB4O5EY.js.map +0 -1
  188. package/dist/chunk-SEEA6RXF.js.map +0 -1
  189. package/dist/chunk-U5SZ4FOP.js +0 -26
  190. package/dist/chunk-U5SZ4FOP.js.map +0 -1
  191. package/dist/client-X5WUJWDE.js +0 -11
  192. package/dist/db-JSIZYT23.js +0 -23
  193. package/dist/db-JSIZYT23.js.map +0 -1
  194. package/dist/ensure-site-S25MDDHW.js.map +0 -1
  195. package/dist/forms-notify-target-TWN4EH6K.js.map +0 -1
  196. package/dist/github-signals-G4ILZAJ6.js.map +0 -1
  197. package/dist/header-image-T7XD37JL.js.map +0 -1
  198. package/dist/migrate-LJUFGXQZ.js +0 -7
  199. package/dist/orchestrate-KDNXZQX7.js +0 -27
  200. package/dist/report-6CKSWJNG.js.map +0 -1
  201. package/dist/reports/header-image/assets/plate.png +0 -0
  202. package/dist/selftest-CQ6NOX52.js.map +0 -1
  203. /package/dist/{airtable-HRKSA7V4.js.map → airtable-G7WBQQMI.js.map} +0 -0
  204. /package/dist/{bump-deps-SWJB6H5V.js.map → bump-deps-EC74CPLS.js.map} +0 -0
  205. /package/dist/{chunk-SDUJIANJ.js.map → chunk-2NJQLSEU.js.map} +0 -0
  206. /package/dist/{chunk-ZTPPCHDB.js.map → chunk-3G25KIWW.js.map} +0 -0
  207. /package/dist/{chunk-Q276MZ7E.js.map → chunk-3VQWSE3F.js.map} +0 -0
  208. /package/dist/{chunk-THEFTZ7F.js.map → chunk-62CMMGEO.js.map} +0 -0
  209. /package/dist/{chunk-FDIFTIXC.js.map → chunk-6TWWY7ZU.js.map} +0 -0
  210. /package/dist/{chunk-UYCAF77E.js.map → chunk-7RJ2HMMJ.js.map} +0 -0
  211. /package/dist/{chunk-R6FPFMOS.js.map → chunk-BQRFQSGY.js.map} +0 -0
  212. /package/dist/{chunk-TP5M2NQB.js.map → chunk-F5KM4LEQ.js.map} +0 -0
  213. /package/dist/{chunk-62VF32IC.js.map → chunk-ILUNZHYV.js.map} +0 -0
  214. /package/dist/{chunk-LS6QPANO.js.map → chunk-IYSEC543.js.map} +0 -0
  215. /package/dist/{chunk-DC5H7KEQ.js.map → chunk-JHGHLKFS.js.map} +0 -0
  216. /package/dist/{chunk-PBWDDDQH.js.map → chunk-KTH5ZODA.js.map} +0 -0
  217. /package/dist/{chunk-7J53SUT6.js.map → chunk-P2FE5AI6.js.map} +0 -0
  218. /package/dist/{chunk-GZNBCHAZ.js.map → chunk-TERE5SDF.js.map} +0 -0
  219. /package/dist/{chunk-ZMIPG6TR.js.map → chunk-TVL5MVUF.js.map} +0 -0
  220. /package/dist/{chunk-VRWUL4TR.js.map → chunk-XHHMM5HX.js.map} +0 -0
  221. /package/dist/{chunk-RJB3QUSP.js.map → chunk-XIDUYLSR.js.map} +0 -0
  222. /package/dist/{chunk-QG6OSL4M.js.map → chunk-XUGSZP7Z.js.map} +0 -0
  223. /package/dist/{chunk-S56JQJ4F.js.map → chunk-Y53WOQIJ.js.map} +0 -0
  224. /package/dist/{chunk-ICXV25N7.js.map → chunk-Z3KYFUN6.js.map} +0 -0
  225. /package/dist/{chunk-WYYQK4D3.js.map → chunk-ZKXSX4LO.js.map} +0 -0
  226. /package/dist/{client-X5WUJWDE.js.map → client-AUE3XUPO.js.map} +0 -0
  227. /package/dist/{convert-to-pnpm-AT52NWZB.js.map → convert-to-pnpm-6L6P74WR.js.map} +0 -0
  228. /package/dist/{health-endpoint-242WG2OJ.js.map → health-endpoint-WJC2UMYS.js.map} +0 -0
  229. /package/dist/{lighthouse-airtable-BIPM26SZ.js.map → import-airtable-HE43FB4H.js.map} +0 -0
  230. /package/dist/{migrate-LJUFGXQZ.js.map → lighthouse-VHI77PFB.js.map} +0 -0
  231. /package/dist/{orchestrate-KDNXZQX7.js.map → lighthouse-airtable-LBVIYYP5.js.map} +0 -0
  232. /package/dist/{websites-SADZR4XQ.js.map → migrate-GQH237A6.js.map} +0 -0
  233. /package/dist/{write-audits-to-airtable-7PH75YWY.js.map → notify-C645PGD5.js.map} +0 -0
  234. /package/dist/{onboard-53CITIM7.js.map → onboard-GK4PHPNI.js.map} +0 -0
  235. /package/dist/{prismic-ci-UTQ6LRR3.js.map → prismic-ci-3RHOZUUT.js.map} +0 -0
  236. /package/dist/{prismic-models-PVES6R5V.js.map → prismic-models-QBGUT2Q3.js.map} +0 -0
  237. /package/dist/{protection-audit-64PLLBLI.js.map → protection-audit-SJO53MEQ.js.map} +0 -0
  238. /package/dist/{renovate-dispatch-ICJGSFCE.js.map → renovate-dispatch-QJY75ODR.js.map} +0 -0
  239. /package/dist/{self-updating-PPQVV6UA.js.map → self-updating-6DWI2Z56.js.map} +0 -0
  240. /package/dist/{smoke-suite-TMCSOYRK.js.map → smoke-suite-VYY2LXM2.js.map} +0 -0
  241. /package/dist/{submissions-GJ6ITBRH.js.map → submissions-Q7DRFOCD.js.map} +0 -0
  242. /package/dist/{svelte-codemods-ZOBDHIES.js.map → svelte-codemods-25J2FO22.js.map} +0 -0
  243. /package/dist/{sync-configs-CJXUX6MD.js.map → sync-configs-3SE6PSCO.js.map} +0 -0
  244. /package/dist/{upgrade-WLX27UPC.js.map → upgrade-WBRQ62FX.js.map} +0 -0
  245. /package/dist/{webflow-624FEBFD.js.map → webflow-IRKOIT7S.js.map} +0 -0
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  escapeHtml
3
- } from "./chunk-SEEA6RXF.js";
3
+ } from "./chunk-DJZWYIPP.js";
4
4
  import {
5
5
  isHttpUrl
6
- } from "./chunk-U5SZ4FOP.js";
6
+ } from "./chunk-EVBCK2KC.js";
7
7
 
8
8
  // src/reports/copy.ts
9
9
  var DEFAULT_COPY = {
@@ -41,7 +41,7 @@ var DEFAULT_COPY = {
41
41
  announceHeading: "YOUR ONGOING SITE CARE",
42
42
  announceBody: "We've completed a full test of your site and set it up for ongoing care to keep it fast, secure, and healthy. Here's what you can expect from us going forward:",
43
43
  announceImprovementResend: "Your contact forms now deliver straight to your inbox through reliable infrastructure, so no inquiry slips through the cracks.",
44
- announceImprovementSvelte5: "We've modernized your site to the latest framework \u2014 it's faster, more secure, and built to last.",
44
+ announceImprovementSvelte5: "We've modernized your site to our latest framework \u2014 it's faster, more secure, and built to last.",
45
45
  announceCadence: "After each one we'll send you a short report like this \u2014",
46
46
  announceCadenceNote: "there's nothing you need to do.",
47
47
  announceOpenDoor: "And if you'd ever like to expand the scope, add features, or freshen anything up, just let us know."
@@ -196,8 +196,11 @@ function trendLine(color, text) {
196
196
  function footnoteLine(text) {
197
197
  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>`;
198
198
  }
199
+ function gaCountVisible(opts) {
200
+ return opts.current !== void 0 && opts.previous !== 0;
201
+ }
199
202
  function hasAnalyticsData(opts) {
200
- return opts.current !== void 0 || (opts.bodyLines?.length ?? 0) > 0;
203
+ return gaCountVisible(opts) || (opts.bodyLines?.length ?? 0) > 0;
201
204
  }
202
205
  function analyticsSection(opts) {
203
206
  if (!hasAnalyticsData(opts)) return "";
@@ -205,7 +208,7 @@ function analyticsSection(opts) {
205
208
  const footnotes = (opts.footnoteLines ?? []).map(footnoteLine).join("\n ");
206
209
  const sectionPad = opts.pad ? ` padding-top="${opts.pad}" padding-bottom="${opts.pad}"` : "";
207
210
  const labelTop = opts.pad ?? "75px";
208
- const usersBlock = opts.current !== void 0 ? `
211
+ const usersBlock = opts.current !== void 0 && gaCountVisible(opts) ? `
209
212
  <mj-text color="${RED}" font-size="44px" font-weight="400">${fmtUsers(opts.current)} Users</mj-text>
210
213
  ${analyticsTrendLine(opts.current, opts.previous, opts.periodDays)}` : "";
211
214
  return `
@@ -369,7 +372,7 @@ function announcementSiteExtras(site) {
369
372
  }
370
373
  function buildAnnouncementMjml(data) {
371
374
  const copy = data.copy ?? DEFAULT_COPY;
372
- const previewText = "Your monthly report from Reddoor";
375
+ const previewText = `Your ongoing site care for ${data.siteName}`;
373
376
  const cad = data.cadence;
374
377
  const hasMaint = Boolean(cad && cad.maintenance !== "None");
375
378
  const hasTesting = Boolean(cad && cad.testing !== "None");
@@ -380,6 +383,7 @@ function buildAnnouncementMjml(data) {
380
383
  const analyticsBodyLines = data.searchPosition !== void 0 ? [`Page 1 Google result (#${data.searchPosition}) for your brand search`] : [];
381
384
  const hasAnalytics = hasAnalyticsData({
382
385
  current: data.gaUsersCurrent,
386
+ previous: data.gaUsersPrevious,
383
387
  bodyLines: analyticsBodyLines
384
388
  });
385
389
  const BANDS = ["white", "#F4F4F4"];
@@ -544,269 +548,12 @@ async function renderReportHtml(data) {
544
548
  return { html: out.html, warnings: out.errors ?? [] };
545
549
  }
546
550
 
547
- // src/reports/checklist.ts
548
- var MAINTENANCE_CHECKLIST = [
549
- { key: "deploy", label: "Deploy & Function Health", field: "Maint: Deploy & Function Health" },
550
- { key: "cms", label: "CMS Checked", field: "Maint: CMS Checked" },
551
- { key: "domain", label: "Domain, DNS & SSL", field: "Maint: Domain, DNS & SSL" },
552
- { key: "google", label: "Google Indexed", field: "Maint: Google Indexed" },
553
- { key: "security", label: "Security Updates", field: "Maint: Security Updates" },
554
- { key: "uptime", label: "Uptime Checked", field: "Maint: Uptime Checked" }
555
- ];
556
- var TESTING_CHECKLIST = [
557
- { key: "desktop", label: "Desktop Browsers", field: "Test: Desktop Browsers" },
558
- { key: "mobile", label: "Mobile Browsers", field: "Test: Mobile Browsers" },
559
- { key: "titles", label: "Page Titles & Meta", field: "Test: Page Titles & Meta" },
560
- { key: "links", label: "Links & Navigation", field: "Test: Links & Navigation" },
561
- { key: "forms", label: "Form Functionality", field: "Test: Form Functionality" },
562
- {
563
- key: "interactions",
564
- label: "Interactions & Animations",
565
- field: "Test: Interactions & Animations"
566
- },
567
- // `field` keeps its original Airtable column name ("…Verified After Updates") even though
568
- // the client-facing label is now "Tested After Updates" — the column holds operator data,
569
- // so renaming the label is display-only and avoids a live-base column migration.
570
- { key: "updates", label: "Tested After Updates", field: "Test: Verified After Updates" }
571
- ];
572
- var ALL_CHECKLIST_FIELDS = [...MAINTENANCE_CHECKLIST, ...TESTING_CHECKLIST].map(
573
- (i) => i.field
574
- );
575
- function checklistFor(type) {
576
- if (type === "Maintenance") return MAINTENANCE_CHECKLIST;
577
- if (type === "Testing") return [...MAINTENANCE_CHECKLIST, ...TESTING_CHECKLIST];
578
- return [];
579
- }
580
- var MAINTENANCE_GATING_FIELDS = [
581
- "Maint: Deploy & Function Health",
582
- "Maint: CMS Checked",
583
- "Maint: Domain, DNS & SSL",
584
- "Maint: Security Updates",
585
- "Maint: Uptime Checked"
586
- ];
587
- function gatingFields(type) {
588
- if (type === "Maintenance") return MAINTENANCE_GATING_FIELDS;
589
- if (type === "Testing") return checklistFor("Testing").map((i) => i.field);
590
- return [];
591
- }
592
- function isHealthGateClear(report) {
593
- return gatingFields(report.reportType).every((field) => {
594
- const r = report.autoEvidence[field]?.result;
595
- return r === "pass" || r === "n/a";
596
- });
597
- }
598
- function gatingHealth(report) {
599
- return gatingFields(report.reportType).map((field) => ({
600
- field,
601
- status: report.autoEvidence[field]?.result ?? "unknown"
602
- }));
603
- }
604
- function isSendOverridden(report) {
605
- return report.sendOverride && (report.overrideReason ?? "").trim() !== "";
606
- }
607
-
608
- // src/reports/airtable/reports.ts
609
- var REPORTS_TABLE = "Reports";
610
- var REPORT_TYPES = ["Maintenance", "Testing", "Launch", "Announcement"];
611
- function toReportType(raw) {
612
- if (raw && REPORT_TYPES.includes(raw)) return raw;
613
- if (raw)
614
- console.warn(`[reports] unknown Report type ${JSON.stringify(raw)} \u2014 treating as Maintenance`);
615
- return "Maintenance";
616
- }
617
- function isPendingApproval(r) {
618
- return r.draftReady && !r.approvedToSend && r.sentAt === null;
619
- }
620
- function mapRow(rec) {
621
- const f = rec.fields;
622
- const linkSites = f["Site"] ?? [];
623
- const html = (f["Rendered HTML"] ?? [])[0] ?? null;
624
- return {
625
- id: rec.id,
626
- reportId: String(f["Report ID"] ?? ""),
627
- siteId: linkSites[0] ?? "",
628
- reportType: toReportType(f["Report type"]),
629
- period: f["Period"] ?? null,
630
- periodStart: f["Period start"] ?? null,
631
- periodEnd: f["Period end"] ?? null,
632
- completedOn: f["Completed on"] ?? null,
633
- lighthouse: lighthouseFromFields(f),
634
- gaUsersCurrent: f["GA users (period)"] ?? null,
635
- gaUsersPrevious: f["GA users (prev period)"] ?? null,
636
- searchFoundPage1: typeof f["Search found page 1"] === "boolean" ? f["Search found page 1"] : null,
637
- searchPosition: f["Search position"] ?? null,
638
- lastTestedDate: f["Last tested date"] ?? null,
639
- commentary: f["Commentary"] ?? null,
640
- subjectOverride: f["Subject override"] ?? null,
641
- draftReady: Boolean(f["Draft ready"]),
642
- approvedToSend: Boolean(f["Approved to send"]),
643
- sentAt: f["Sent at"] ?? null,
644
- approvedAt: f["Approved At"] ?? null,
645
- approvedBy: f["Approved By"] ?? null,
646
- deliveryStatus: f["Delivery status"] ?? "pending",
647
- renderedHtmlAttachment: html,
648
- resendMessageId: f["Resend message ID"] ?? null,
649
- checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])])),
650
- autoEvidence: parseAutoEvidence(f["Checklist auto-evidence"]),
651
- sendOverride: Boolean(f["Send override"]),
652
- overrideReason: f["Override reason"] ?? null,
653
- overrideBy: f["Override by"] ?? null,
654
- overrideAt: f["Override at"] ?? null
655
- };
656
- }
657
- function parseAutoEvidence(raw) {
658
- if (typeof raw !== "string" || !raw.trim()) return null;
659
- let parsed;
660
- try {
661
- parsed = JSON.parse(raw);
662
- } catch {
663
- return null;
664
- }
665
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
666
- const out = {};
667
- for (const [field, v] of Object.entries(parsed)) {
668
- if (!v || typeof v !== "object") continue;
669
- const o = v;
670
- if (o.result !== "pass" && o.result !== "fail" && o.result !== "unknown" && o.result !== "n/a")
671
- continue;
672
- out[field] = {
673
- result: o.result,
674
- checkedAt: typeof o.checkedAt === "string" ? o.checkedAt : null,
675
- note: typeof o.note === "string" ? o.note : ""
676
- };
677
- }
678
- return Object.keys(out).length > 0 ? out : null;
679
- }
680
- function lighthouseFromFields(f) {
681
- const p = f["Lighthouse \u2014 Performance"];
682
- const a = f["Lighthouse \u2014 Accessibility"];
683
- const b = f["Lighthouse \u2014 Best Practices"];
684
- const s = f["Lighthouse \u2014 SEO"];
685
- if (typeof p !== "number" || typeof a !== "number" || typeof b !== "number" || typeof s !== "number")
686
- return null;
687
- return { performance: p, accessibility: a, bestPractices: b, seo: s };
688
- }
689
- function ymd(d) {
690
- return d.toISOString().slice(0, 10);
691
- }
692
- function escapeFormulaString(s) {
693
- return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
694
- }
695
- async function createDraft(base, input) {
696
- const fields = {
697
- "Report ID": input.reportId,
698
- Site: [input.siteId],
699
- "Report type": input.reportType,
700
- "Period start": ymd(input.periodStart),
701
- "Period end": ymd(input.periodEnd),
702
- "Completed on": ymd(input.completedOn),
703
- "Lighthouse \u2014 Performance": input.lighthouse.performance,
704
- "Lighthouse \u2014 Accessibility": input.lighthouse.accessibility,
705
- "Lighthouse \u2014 Best Practices": input.lighthouse.bestPractices,
706
- "Lighthouse \u2014 SEO": input.lighthouse.seo,
707
- "Delivery status": "pending"
708
- };
709
- if (input.lastTestedDate) fields["Last tested date"] = ymd(input.lastTestedDate);
710
- if (input.gaUsersCurrent !== void 0) fields["GA users (period)"] = input.gaUsersCurrent;
711
- if (input.gaUsersPrevious !== void 0) fields["GA users (prev period)"] = input.gaUsersPrevious;
712
- if (input.searchFoundPage1 !== void 0) fields["Search found page 1"] = input.searchFoundPage1;
713
- if (input.searchPosition !== void 0) fields["Search position"] = input.searchPosition;
714
- if (input.period !== void 0) fields["Period"] = input.period;
715
- if (input.subjectOverride !== void 0) fields["Subject override"] = input.subjectOverride;
716
- for (const field of input.checklistTicks ?? []) fields[field] = true;
717
- if (input.autoEvidence && Object.keys(input.autoEvidence).length > 0) {
718
- fields["Checklist auto-evidence"] = JSON.stringify(input.autoEvidence);
719
- }
720
- const created = await base(REPORTS_TABLE).create([{ fields }]);
721
- const rec = created[0];
722
- if (!rec) throw new Error("Airtable create returned no records");
723
- return mapRow({ id: rec.id, fields: rec.fields });
724
- }
725
- async function setDraftReady(base, recordId, ready) {
726
- await base(REPORTS_TABLE).update([{ id: recordId, fields: { "Draft ready": ready } }]);
727
- }
728
- async function updateReportScores(base, recordId, scores, completedOn, enrichment) {
729
- const fields = {
730
- "Lighthouse \u2014 Performance": scores.performance,
731
- "Lighthouse \u2014 Accessibility": scores.accessibility,
732
- "Lighthouse \u2014 Best Practices": scores.bestPractices,
733
- "Lighthouse \u2014 SEO": scores.seo
734
- };
735
- if (completedOn) fields["Completed on"] = ymd(completedOn);
736
- if (enrichment?.gaUsersCurrent !== void 0)
737
- fields["GA users (period)"] = enrichment.gaUsersCurrent;
738
- if (enrichment?.gaUsersPrevious !== void 0)
739
- fields["GA users (prev period)"] = enrichment.gaUsersPrevious;
740
- if (enrichment?.searchFoundPage1 !== void 0)
741
- fields["Search found page 1"] = enrichment.searchFoundPage1;
742
- if (enrichment?.searchPosition !== void 0)
743
- fields["Search position"] = enrichment.searchPosition;
744
- await base(REPORTS_TABLE).update([{ id: recordId, fields }]);
745
- }
746
- async function listSendableReports(base) {
747
- const out = [];
748
- await base(REPORTS_TABLE).select({
749
- filterByFormula: "AND({Draft ready} = TRUE(), {Approved to send} = TRUE(), {Sent at} = BLANK())",
750
- pageSize: 100
751
- }).eachPage((records, fetchNextPage) => {
752
- for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));
753
- fetchNextPage();
754
- });
755
- return out;
756
- }
757
- async function listAllReports(base) {
758
- const out = [];
759
- await base(REPORTS_TABLE).select({ pageSize: 100 }).eachPage((records, fetchNextPage) => {
760
- for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));
761
- fetchNextPage();
762
- });
763
- return out;
764
- }
765
- async function listReportsForSite(base, siteId) {
766
- return (await listAllReports(base)).filter((r) => r.siteId === siteId);
767
- }
768
- async function stampSent(base, recordId, sentAt, messageId) {
769
- const fields = { "Sent at": sentAt.toISOString() };
770
- if (messageId !== null) fields["Resend message ID"] = messageId;
771
- await base(REPORTS_TABLE).update([
772
- {
773
- id: recordId,
774
- fields
775
- }
776
- ]);
777
- }
778
- async function findReportByPeriod(base, siteId, reportType, period) {
779
- const safeType = escapeFormulaString(reportType);
780
- const safePeriod = escapeFormulaString(period);
781
- const formula = `AND({Report type} = "${safeType}", {Period} = "${safePeriod}")`;
782
- const rows = [];
783
- await base(REPORTS_TABLE).select({ filterByFormula: formula, pageSize: 100 }).eachPage((records, fetchNextPage) => {
784
- for (const rec of records) rows.push(mapRow({ id: rec.id, fields: rec.fields }));
785
- fetchNextPage();
786
- });
787
- return rows.find((r) => r.siteId === siteId) ?? null;
788
- }
789
-
790
551
  export {
791
552
  resolveCopy,
792
553
  CHECK_CID,
793
554
  BLURRED_CID,
794
555
  loadBundledImages,
795
556
  announcementSiteExtras,
796
- renderReportHtml,
797
- checklistFor,
798
- gatingFields,
799
- isHealthGateClear,
800
- gatingHealth,
801
- isSendOverridden,
802
- isPendingApproval,
803
- createDraft,
804
- setDraftReady,
805
- updateReportScores,
806
- listSendableReports,
807
- listAllReports,
808
- listReportsForSite,
809
- stampSent,
810
- findReportByPeriod
557
+ renderReportHtml
811
558
  };
812
- //# sourceMappingURL=chunk-6QRLRT3P.js.map
559
+ //# sourceMappingURL=chunk-CQR4LIM7.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/copy.ts","../src/reports/maintenance-email/assets/index.ts","../src/reports/email-sections.ts","../src/reports/maintenance-email/template.ts","../src/reports/announcement-email/template.ts","../src/reports/render.ts","../src/reports/launch-email/template.ts"],"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"]}
@@ -5,7 +5,7 @@ function escapeHtml(s) {
5
5
  function safeUrl(raw) {
6
6
  try {
7
7
  const u = new URL(raw);
8
- if (u.protocol === "http:" || u.protocol === "https:") return raw;
8
+ if (u.protocol === "http:" || u.protocol === "https:") return u.href;
9
9
  } catch {
10
10
  }
11
11
  return "#";
@@ -15,4 +15,4 @@ export {
15
15
  escapeHtml,
16
16
  safeUrl
17
17
  };
18
- //# sourceMappingURL=chunk-SEEA6RXF.js.map
18
+ //# sourceMappingURL=chunk-DJZWYIPP.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/util/html.ts"],"sourcesContent":["/**\n * Shared HTML/XML escape. One implementation behind the dashboard renderers\n * (`src/dashboard/render.ts`, `fleet-render.ts`), the daily digest\n * (`src/reports/digest.ts`), and the MJML email templates\n * (`src/reports/*-email/template.ts`).\n *\n * The set is the strict-XML set (`& < > \" '`), which is exactly what MJML's\n * `validationLevel: \"strict\"` parser needs and a superset of what plain HTML text\n * interpolation needs — so the SAME function serves both sinks. Site names\n * (e.g. \"Brown & Co\"), URLs, and operator commentary must not break the markup or\n * inject. The MJML templates re-export this as `escapeXml` for their callers.\n */\nexport function escapeHtml(s: string): string {\n return s\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#39;\");\n}\n\n/** Allow only http(s) URLs in an href context; everything else collapses to \"#\".\n *\n * Returns the PARSED, percent-encoded `u.href` — never the caller's raw string.\n * `new URL()` only validates the scheme; it does not sanitize the rest of the\n * string, so a well-formed https URL carrying a quote-breakout payload\n * (`https://x.example/\"><script>...`) parses fine and, if handed back\n * verbatim, produces a live injection wherever a caller interpolates the\n * result into an href without a further escapeHtml pass. Returning `u.href`\n * closes that gap: the URL parser itself percent-encodes `\"`, `<`, `>`, etc.\n * in the path/query. This also normalizes a bare-origin URL to carry a\n * trailing slash (`https://x.example` → `https://x.example/`) — a caller\n * that additionally wraps the result in escapeHtml (the majority pattern in\n * this repo) sees no behavior change beyond that encoding. */\nexport function safeUrl(raw: string): string {\n try {\n const u = new URL(raw);\n if (u.protocol === \"http:\" || u.protocol === \"https:\") return u.href;\n } catch {\n // fall through\n }\n return \"#\";\n}\n"],"mappings":";AAYO,SAAS,WAAW,GAAmB;AAC5C,SAAO,EACJ,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,QAAQ,EACtB,QAAQ,MAAM,OAAO;AAC1B;AAeO,SAAS,QAAQ,KAAqB;AAC3C,MAAI;AACF,UAAM,IAAI,IAAI,IAAI,GAAG;AACrB,QAAI,EAAE,aAAa,WAAW,EAAE,aAAa,SAAU,QAAO,EAAE;AAAA,EAClE,QAAQ;AAAA,EAER;AACA,SAAO;AACT;","names":[]}
@@ -0,0 +1,68 @@
1
+ // src/util/url.ts
2
+ function isHttpUrl(s) {
3
+ let parsed;
4
+ try {
5
+ parsed = new URL(s);
6
+ } catch {
7
+ return false;
8
+ }
9
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
10
+ }
11
+ function hostnameOf(s) {
12
+ try {
13
+ return new URL(s).hostname;
14
+ } catch {
15
+ return s;
16
+ }
17
+ }
18
+ function isNetlifyAppUrl(s) {
19
+ let parsed;
20
+ try {
21
+ parsed = new URL(s);
22
+ } catch {
23
+ return false;
24
+ }
25
+ const host = parsed.hostname.toLowerCase();
26
+ return host === "netlify.app" || host.endsWith(".netlify.app");
27
+ }
28
+ function isPrivateOrLoopbackHost(hostname) {
29
+ let host = hostname.toLowerCase();
30
+ if (host === "localhost" || host.endsWith(".localhost")) return true;
31
+ if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
32
+ if (host.includes(":")) {
33
+ if (host === "::1" || host === "::") return true;
34
+ if (/^fe[89ab]/.test(host)) return true;
35
+ if (/^f[cd]/.test(host)) return true;
36
+ if (host.startsWith("::ffff:") || host.startsWith("64:ff9b:")) return true;
37
+ return false;
38
+ }
39
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
40
+ const [a, b] = host.split(".").map(Number);
41
+ if (a === 0 || a === 10 || a === 127) return true;
42
+ if (a === 169 && b === 254) return true;
43
+ if (a === 172 && b !== void 0 && b >= 16 && b <= 31) return true;
44
+ if (a === 192 && b === 168) return true;
45
+ if (a === 100 && b !== void 0 && b >= 64 && b <= 127) return true;
46
+ return false;
47
+ }
48
+ return false;
49
+ }
50
+ function isPublicHttpsUrl(s) {
51
+ let parsed;
52
+ try {
53
+ parsed = new URL(s);
54
+ } catch {
55
+ return false;
56
+ }
57
+ if (parsed.protocol !== "https:") return false;
58
+ return !isPrivateOrLoopbackHost(parsed.hostname);
59
+ }
60
+
61
+ export {
62
+ isHttpUrl,
63
+ hostnameOf,
64
+ isNetlifyAppUrl,
65
+ isPrivateOrLoopbackHost,
66
+ isPublicHttpsUrl
67
+ };
68
+ //# sourceMappingURL=chunk-EVBCK2KC.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/util/url.ts"],"sourcesContent":["/**\n * True when `s` parses as an absolute URL whose scheme is `http:` or `https:`.\n *\n * The single allowlist gate for any value we hand to Chrome/Lighthouse. A\n * deployed-audit URL flows in from Airtable's `url` column (or a JSON\n * inventory's `deployedUrl`), so a `file://`/`gopher://`/`data:` value — or a\n * value pointing at an internal host — would otherwise become a local-file read\n * or SSRF when lhci drives a headless browser at it. Restricting to http(s)\n * keeps the audit to the real, network-reachable site.\n */\nexport function isHttpUrl(s: string): boolean {\n let parsed: URL;\n try {\n parsed = new URL(s);\n } catch {\n return false;\n }\n return parsed.protocol === \"http:\" || parsed.protocol === \"https:\";\n}\n\n/**\n * The hostname of `s`, or `s` itself when it doesn't parse as a URL — a\n * human-facing fallback label, never a thrown error. Used anywhere a short\n * site label is wanted and the full URL would be noisy: the prospect-audit\n * CLI's summary line and its report's <h1>/title both fall back to this when\n * no business name was resolved.\n */\nexport function hostnameOf(s: string): string {\n try {\n return new URL(s).hostname;\n } catch {\n return s;\n }\n}\n\n/**\n * True when `s` is a URL served from Netlify's default `*.netlify.app` host —\n * i.e. the site has no custom domain. Matches the apex `netlify.app` and any\n * subdomain of it (including deploy-preview hosts like `branch--site.netlify.app`),\n * but is not fooled by a look-alike such as `foo.netlify.app.evil.com` (the host\n * must END at `.netlify.app`). An unparseable/empty value is not a match.\n */\nexport function isNetlifyAppUrl(s: string): boolean {\n let parsed: URL;\n try {\n parsed = new URL(s);\n } catch {\n return false;\n }\n const host = parsed.hostname.toLowerCase();\n return host === \"netlify.app\" || host.endsWith(\".netlify.app\");\n}\n\n/**\n * True when `hostname` is a loopback, private, link-local, unique-local, or\n * CGNAT literal address (or the `localhost` name) — the address-literal core\n * that used to live only inside `isPublicHttpsUrl`, pulled out so a caller\n * that legitimately handles non-https schemes (the prospect-audit crawler\n * audits plain http sites too) can still refuse to follow a redirect onto an\n * internal target. Scheme-agnostic on purpose: the caller decides which\n * protocols are acceptable, this only judges the host.\n *\n * Best-effort by literal address — it does NOT resolve DNS, so a hostname\n * that resolves to a private IP is not caught. That is a deliberate,\n * proportionate bound for an operator-run CLI, not a complete SSRF guard.\n */\nexport function isPrivateOrLoopbackHost(hostname: string): boolean {\n let host = hostname.toLowerCase();\n if (host === \"localhost\" || host.endsWith(\".localhost\")) return true;\n // Strip IPv6 brackets (`[::1]` → `::1`).\n if (host.startsWith(\"[\") && host.endsWith(\"]\")) host = host.slice(1, -1);\n if (host.includes(\":\")) {\n // IPv6 literal: block loopback (::1), unspecified (::), link-local (fe80::/10),\n // and unique-local (fc00::/7).\n if (host === \"::1\" || host === \"::\") return true;\n if (/^fe[89ab]/.test(host)) return true;\n if (/^f[cd]/.test(host)) return true;\n // IPv4-mapped (`::ffff:a.b.c.d`, normalized by URL to `::ffff:aabb:ccdd`) and\n // NAT64 (`64:ff9b::/96`) embed a v4 address the dotted-quad block below never\n // sees — refuse both wholesale (no legitimate target uses these forms).\n if (host.startsWith(\"::ffff:\") || host.startsWith(\"64:ff9b:\")) return true;\n return false;\n }\n // IPv4 dotted-quad: block the private/loopback/link-local/CGNAT ranges.\n if (/^\\d{1,3}(\\.\\d{1,3}){3}$/.test(host)) {\n const [a, b] = host.split(\".\").map(Number);\n if (a === 0 || a === 10 || a === 127) return true;\n if (a === 169 && b === 254) return true; // link-local\n if (a === 172 && b !== undefined && b >= 16 && b <= 31) return true; // 172.16/12\n if (a === 192 && b === 168) return true; // 192.168/16\n if (a === 100 && b !== undefined && b >= 64 && b <= 127) return true; // CGNAT 100.64/10\n return false;\n }\n // A registered hostname (not a literal IP) — accept; DNS isn't resolved here.\n return false;\n}\n\n/**\n * True when `s` is an `https:` URL whose host is NOT an obviously-internal target\n * (loopback / private / link-local / unique-local / CGNAT). The newsletter\n * webhook URL is operator-set in Airtable but fires server-side, so this blocks\n * the SSRF vector of pointing it at `127.0.0.1` / `10.x` / `169.254.x` / `::1`.\n *\n * Best-effort by host literal — it does NOT resolve DNS, so a hostname that\n * resolves to a private IP is not caught. Defense-in-depth, not a complete SSRF\n * guard (the response is never returned to the caller either, so it's a log\n * oracle at most).\n */\nexport function isPublicHttpsUrl(s: string): boolean {\n let parsed: URL;\n try {\n parsed = new URL(s);\n } catch {\n return false;\n }\n if (parsed.protocol !== \"https:\") return false;\n return !isPrivateOrLoopbackHost(parsed.hostname);\n}\n"],"mappings":";AAUO,SAAS,UAAU,GAAoB;AAC5C,MAAI;AACJ,MAAI;AACF,aAAS,IAAI,IAAI,CAAC;AAAA,EACpB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,SAAO,OAAO,aAAa,WAAW,OAAO,aAAa;AAC5D;AASO,SAAS,WAAW,GAAmB;AAC5C,MAAI;AACF,WAAO,IAAI,IAAI,CAAC,EAAE;AAAA,EACpB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AASO,SAAS,gBAAgB,GAAoB;AAClD,MAAI;AACJ,MAAI;AACF,aAAS,IAAI,IAAI,CAAC;AAAA,EACpB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,QAAM,OAAO,OAAO,SAAS,YAAY;AACzC,SAAO,SAAS,iBAAiB,KAAK,SAAS,cAAc;AAC/D;AAeO,SAAS,wBAAwB,UAA2B;AACjE,MAAI,OAAO,SAAS,YAAY;AAChC,MAAI,SAAS,eAAe,KAAK,SAAS,YAAY,EAAG,QAAO;AAEhE,MAAI,KAAK,WAAW,GAAG,KAAK,KAAK,SAAS,GAAG,EAAG,QAAO,KAAK,MAAM,GAAG,EAAE;AACvE,MAAI,KAAK,SAAS,GAAG,GAAG;AAGtB,QAAI,SAAS,SAAS,SAAS,KAAM,QAAO;AAC5C,QAAI,YAAY,KAAK,IAAI,EAAG,QAAO;AACnC,QAAI,SAAS,KAAK,IAAI,EAAG,QAAO;AAIhC,QAAI,KAAK,WAAW,SAAS,KAAK,KAAK,WAAW,UAAU,EAAG,QAAO;AACtE,WAAO;AAAA,EACT;AAEA,MAAI,0BAA0B,KAAK,IAAI,GAAG;AACxC,UAAM,CAAC,GAAG,CAAC,IAAI,KAAK,MAAM,GAAG,EAAE,IAAI,MAAM;AACzC,QAAI,MAAM,KAAK,MAAM,MAAM,MAAM,IAAK,QAAO;AAC7C,QAAI,MAAM,OAAO,MAAM,IAAK,QAAO;AACnC,QAAI,MAAM,OAAO,MAAM,UAAa,KAAK,MAAM,KAAK,GAAI,QAAO;AAC/D,QAAI,MAAM,OAAO,MAAM,IAAK,QAAO;AACnC,QAAI,MAAM,OAAO,MAAM,UAAa,KAAK,MAAM,KAAK,IAAK,QAAO;AAChE,WAAO;AAAA,EACT;AAEA,SAAO;AACT;AAaO,SAAS,iBAAiB,GAAoB;AACnD,MAAI;AACJ,MAAI;AACF,aAAS,IAAI,IAAI,CAAC;AAAA,EACpB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI,OAAO,aAAa,SAAU,QAAO;AACzC,SAAO,CAAC,wBAAwB,OAAO,QAAQ;AACjD;","names":[]}
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  approveBlockers
3
- } from "./chunk-LTB6KVJW.js";
3
+ } from "./chunk-N45M3FDK.js";
4
4
  import {
5
5
  ACTIVE_STATUSES,
6
6
  allActionableVulnsTransitive,
7
7
  isPreLaunch,
8
8
  siteSlug
9
- } from "./chunk-577HCGZO.js";
9
+ } from "./chunk-LDWZSLSU.js";
10
10
 
11
11
  // src/alerts/digest-collectors.ts
12
12
  function dashboardUrl(baseUrl, siteName) {
@@ -363,4 +363,4 @@ export {
363
363
  readDigestState,
364
364
  writeDigestState
365
365
  };
366
- //# sourceMappingURL=chunk-TP5M2NQB.js.map
366
+ //# sourceMappingURL=chunk-F5KM4LEQ.js.map
@@ -2,7 +2,7 @@ import {
2
2
  isPendingApproval,
3
3
  listReportsForSite,
4
4
  setDraftReady
5
- } from "./chunk-6QRLRT3P.js";
5
+ } from "./chunk-ACBRLLFT.js";
6
6
 
7
7
  // src/reports/queue.ts
8
8
  var REPORT_TIER = {
@@ -35,4 +35,4 @@ export {
35
35
  reportTier,
36
36
  queueDraft
37
37
  };
38
- //# sourceMappingURL=chunk-62VF32IC.js.map
38
+ //# sourceMappingURL=chunk-ILUNZHYV.js.map