@reddoorla/maintenance 0.85.1 → 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 (252) 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-QRDB4NLB.js → chunk-2ZPT7GLV.js} +43 -404
  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-N4MOEF32.js → chunk-C6B4RHXR.js} +36 -8
  32. package/dist/chunk-C6B4RHXR.js.map +1 -0
  33. package/dist/{chunk-6QRLRT3P.js → chunk-CQR4LIM7.js} +12 -265
  34. package/dist/chunk-CQR4LIM7.js.map +1 -0
  35. package/dist/{chunk-SEEA6RXF.js → chunk-DJZWYIPP.js} +2 -2
  36. package/dist/chunk-DJZWYIPP.js.map +1 -0
  37. package/dist/chunk-EVBCK2KC.js +68 -0
  38. package/dist/chunk-EVBCK2KC.js.map +1 -0
  39. package/dist/{chunk-TP5M2NQB.js → chunk-F5KM4LEQ.js} +3 -3
  40. package/dist/{chunk-62VF32IC.js → chunk-ILUNZHYV.js} +2 -2
  41. package/dist/chunk-ITIOFN7A.js +396 -0
  42. package/dist/chunk-ITIOFN7A.js.map +1 -0
  43. package/dist/{chunk-LS6QPANO.js → chunk-IYSEC543.js} +2 -2
  44. package/dist/{chunk-DC5H7KEQ.js → chunk-JHGHLKFS.js} +2 -2
  45. package/dist/{chunk-PBWDDDQH.js → chunk-KTH5ZODA.js} +2 -2
  46. package/dist/{chunk-4DHQCN5H.js → chunk-LBYOLBW7.js} +17 -2
  47. package/dist/chunk-LBYOLBW7.js.map +1 -0
  48. package/dist/{chunk-577HCGZO.js → chunk-LDWZSLSU.js} +38 -23
  49. package/dist/chunk-LDWZSLSU.js.map +1 -0
  50. package/dist/{chunk-LTB6KVJW.js → chunk-N45M3FDK.js} +13 -11
  51. package/dist/{chunk-LTB6KVJW.js.map → chunk-N45M3FDK.js.map} +1 -1
  52. package/dist/chunk-NYUYNYQL.js +48 -0
  53. package/dist/chunk-NYUYNYQL.js.map +1 -0
  54. package/dist/{chunk-7J53SUT6.js → chunk-P2FE5AI6.js} +4 -4
  55. package/dist/{chunk-3YXDSCM6.js → chunk-PPE5KZJK.js} +29 -11
  56. package/dist/chunk-PPE5KZJK.js.map +1 -0
  57. package/dist/chunk-PTDMVGKL.js +31 -0
  58. package/dist/chunk-PTDMVGKL.js.map +1 -0
  59. package/dist/{chunk-D4FVAIEJ.js → chunk-PZR4EBPY.js} +5 -2
  60. package/dist/chunk-PZR4EBPY.js.map +1 -0
  61. package/dist/{chunk-NWB4O5EY.js → chunk-SLUQ7R6I.js} +3 -3
  62. package/dist/chunk-SLUQ7R6I.js.map +1 -0
  63. package/dist/{chunk-GZNBCHAZ.js → chunk-TERE5SDF.js} +4 -4
  64. package/dist/{chunk-ZMIPG6TR.js → chunk-TVL5MVUF.js} +4 -4
  65. package/dist/chunk-UDRHAQT7.js +172 -0
  66. package/dist/chunk-UDRHAQT7.js.map +1 -0
  67. package/dist/chunk-VAD43MOR.js +72 -0
  68. package/dist/chunk-VAD43MOR.js.map +1 -0
  69. package/dist/chunk-VSBFM7DS.js +229 -0
  70. package/dist/chunk-VSBFM7DS.js.map +1 -0
  71. package/dist/chunk-W7Z3BGCI.js +274 -0
  72. package/dist/chunk-W7Z3BGCI.js.map +1 -0
  73. package/dist/chunk-X3B723J4.js +1209 -0
  74. package/dist/chunk-X3B723J4.js.map +1 -0
  75. package/dist/{chunk-VRWUL4TR.js → chunk-XHHMM5HX.js} +2 -2
  76. package/dist/{chunk-RJB3QUSP.js → chunk-XIDUYLSR.js} +3 -3
  77. package/dist/{chunk-QG6OSL4M.js → chunk-XUGSZP7Z.js} +2 -2
  78. package/dist/{chunk-R4AB6GCT.js → chunk-Y53WOQIJ.js} +7 -4
  79. package/dist/{chunk-R4AB6GCT.js.map → chunk-Y53WOQIJ.js.map} +1 -1
  80. package/dist/{chunk-ICXV25N7.js → chunk-Z3KYFUN6.js} +3 -3
  81. package/dist/{chunk-MPBXOJ3N.js → chunk-Z7AXYKQB.js} +33 -2
  82. package/dist/chunk-Z7AXYKQB.js.map +1 -0
  83. package/dist/{chunk-6V7565O2.js → chunk-ZKXSX4LO.js} +11 -11
  84. package/dist/cli/bin.js +46 -28
  85. package/dist/cli/bin.js.map +1 -1
  86. package/dist/cli/commands/audit.d.ts +30 -1
  87. package/dist/cli/commands/audit.js +63 -31
  88. package/dist/cli/commands/audit.js.map +1 -1
  89. package/dist/client-AUE3XUPO.js +11 -0
  90. package/dist/configs/playwright-a11y.js +1 -1
  91. package/dist/configs/svelte.d.ts +30 -1
  92. package/dist/configs/svelte.js +9 -1
  93. package/dist/configs/svelte.js.map +1 -1
  94. package/dist/{convert-to-pnpm-AT52NWZB.js → convert-to-pnpm-6L6P74WR.js} +9 -9
  95. package/dist/db-B35OGV3G.js +217 -0
  96. package/dist/db-B35OGV3G.js.map +1 -0
  97. package/dist/{digest-UVHYZUYE.js → digest-BL6UJYKH.js} +23 -20
  98. package/dist/{digest-UVHYZUYE.js.map → digest-BL6UJYKH.js.map} +1 -1
  99. package/dist/dump-P6EBSIOH.js +68 -0
  100. package/dist/dump-P6EBSIOH.js.map +1 -0
  101. package/dist/email-VTTTKKL5.js +195 -0
  102. package/dist/email-VTTTKKL5.js.map +1 -0
  103. package/dist/{ensure-site-S25MDDHW.js → ensure-site-DFTB7MN3.js} +8 -7
  104. package/dist/ensure-site-DFTB7MN3.js.map +1 -0
  105. package/dist/fleet-events-cF2Om88w.d.ts +24 -0
  106. package/dist/{forms-notify-target-TWN4EH6K.js → forms-notify-target-75U6OU7L.js} +38 -132
  107. package/dist/forms-notify-target-75U6OU7L.js.map +1 -0
  108. package/dist/{github-signals-G4ILZAJ6.js → github-signals-IJUPGCUX.js} +40 -19
  109. package/dist/github-signals-IJUPGCUX.js.map +1 -0
  110. package/dist/{header-image-T7XD37JL.js → header-image-AWLLQA4L.js} +34 -11
  111. package/dist/header-image-AWLLQA4L.js.map +1 -0
  112. package/dist/header-images-335L2AKB.js +68 -0
  113. package/dist/header-images-335L2AKB.js.map +1 -0
  114. package/dist/{health-endpoint-242WG2OJ.js → health-endpoint-WJC2UMYS.js} +9 -9
  115. package/dist/health-mirror-4B2L7T2Y.js +75 -0
  116. package/dist/health-mirror-4B2L7T2Y.js.map +1 -0
  117. package/dist/import-airtable-HE43FB4H.js +35 -0
  118. package/dist/index.d.ts +175 -21
  119. package/dist/index.js +116 -74
  120. package/dist/index.js.map +1 -1
  121. package/dist/{init-TFVAN5OY.js → init-NJEOEO7I.js} +21 -18
  122. package/dist/{init-TFVAN5OY.js.map → init-NJEOEO7I.js.map} +1 -1
  123. package/dist/{launch-DWY2MF54.js → launch-XNAX427U.js} +32 -26
  124. package/dist/{launch-DWY2MF54.js.map → launch-XNAX427U.js.map} +1 -1
  125. package/dist/lighthouse-VHI77PFB.js +11 -0
  126. package/dist/{lighthouse-airtable-BIPM26SZ.js → lighthouse-airtable-LBVIYYP5.js} +3 -3
  127. package/dist/mailchimp-XYCTEEJY.js +72 -0
  128. package/dist/mailchimp-XYCTEEJY.js.map +1 -0
  129. package/dist/migrate-GQH237A6.js +7 -0
  130. package/dist/notify-C645PGD5.js +22 -0
  131. package/dist/{onboard-53CITIM7.js → onboard-GK4PHPNI.js} +9 -9
  132. package/dist/orchestrate-77UO4BYJ.js +30 -0
  133. package/dist/orchestrate-77UO4BYJ.js.map +1 -0
  134. package/dist/parity-MWS24A3H.js +12 -0
  135. package/dist/parity-MWS24A3H.js.map +1 -0
  136. package/dist/pipeline-S6CVHDGB.js +12 -0
  137. package/dist/pipeline-S6CVHDGB.js.map +1 -0
  138. package/dist/{preflight-3XPDUHFJ.js → preflight-6WBNU2GZ.js} +16 -13
  139. package/dist/{preflight-3XPDUHFJ.js.map → preflight-6WBNU2GZ.js.map} +1 -1
  140. package/dist/{prismic-ci-UTQ6LRR3.js → prismic-ci-3RHOZUUT.js} +15 -15
  141. package/dist/{prismic-models-PVES6R5V.js → prismic-models-QBGUT2Q3.js} +10 -10
  142. package/dist/prospect-audit-IUNCJ2LV.js +174 -0
  143. package/dist/prospect-audit-IUNCJ2LV.js.map +1 -0
  144. package/dist/prospect-audits-3PMIO73D.js +19 -0
  145. package/dist/prospect-audits-3PMIO73D.js.map +1 -0
  146. package/dist/{protection-audit-64PLLBLI.js → protection-audit-SJO53MEQ.js} +3 -3
  147. package/dist/recipes/sync-configs.js +3 -3
  148. package/dist/render-AQAVQDPZ.js +10 -0
  149. package/dist/render-AQAVQDPZ.js.map +1 -0
  150. package/dist/{renovate-dispatch-ICJGSFCE.js → renovate-dispatch-QJY75ODR.js} +8 -8
  151. package/dist/replay-C2LXMJZP.js +66 -0
  152. package/dist/replay-C2LXMJZP.js.map +1 -0
  153. package/dist/{report-6CKSWJNG.js → report-4EVNDHOZ.js} +55 -29
  154. package/dist/report-4EVNDHOZ.js.map +1 -0
  155. package/dist/reports/header-image/assets/headline-announcement.png +0 -0
  156. package/dist/reports/header-image/assets/headline-launch.png +0 -0
  157. package/dist/reports/header-image/assets/headline-maintenance.png +0 -0
  158. package/dist/reports/header-image/assets/headline-testing.png +0 -0
  159. package/dist/reports/header-image/assets/plate-clean.png +0 -0
  160. package/dist/{self-updating-PPQVV6UA.js → self-updating-6DWI2Z56.js} +9 -9
  161. package/dist/{selftest-CQ6NOX52.js → selftest-RC36MTKP.js} +25 -20
  162. package/dist/selftest-RC36MTKP.js.map +1 -0
  163. package/dist/smoke-B2GQOSIZ.js +20 -0
  164. package/dist/smoke-B2GQOSIZ.js.map +1 -0
  165. package/dist/{smoke-suite-OBWNPALQ.js → smoke-suite-VYY2LXM2.js} +9 -9
  166. package/dist/{submissions-GJ6ITBRH.js → submissions-Q7DRFOCD.js} +3 -3
  167. package/dist/{svelte-codemods-ZOBDHIES.js → svelte-codemods-25J2FO22.js} +9 -9
  168. package/dist/sync-34HNZZ56.js +44 -0
  169. package/dist/sync-34HNZZ56.js.map +1 -0
  170. package/dist/{sync-configs-CJXUX6MD.js → sync-configs-3SE6PSCO.js} +9 -9
  171. package/dist/{upgrade-WLX27UPC.js → upgrade-WBRQ62FX.js} +9 -9
  172. package/dist/{webflow-624FEBFD.js → webflow-IRKOIT7S.js} +4 -4
  173. package/dist/webhook-VOGOW3W6.js +38 -0
  174. package/dist/webhook-VOGOW3W6.js.map +1 -0
  175. package/dist/{websites-SADZR4XQ.js → websites-SXWTP72S.js} +6 -2
  176. package/dist/websites-SXWTP72S.js.map +1 -0
  177. package/dist/{write-audits-to-airtable-7PH75YWY.js → write-audits-to-airtable-MBEILLPU.js} +4 -4
  178. package/dist/write-audits-to-airtable-MBEILLPU.js.map +1 -0
  179. package/package.json +5 -2
  180. package/dist/airtable-HRKSA7V4.js +0 -9
  181. package/dist/announce-GIAYN67S.js.map +0 -1
  182. package/dist/chunk-37S6L6G4.js.map +0 -1
  183. package/dist/chunk-3YXDSCM6.js.map +0 -1
  184. package/dist/chunk-4DHQCN5H.js.map +0 -1
  185. package/dist/chunk-4SOCPK7A.js +0 -108
  186. package/dist/chunk-4SOCPK7A.js.map +0 -1
  187. package/dist/chunk-577HCGZO.js.map +0 -1
  188. package/dist/chunk-6QRLRT3P.js.map +0 -1
  189. package/dist/chunk-COZPL62X.js.map +0 -1
  190. package/dist/chunk-D4FVAIEJ.js.map +0 -1
  191. package/dist/chunk-MGR2H7F4.js.map +0 -1
  192. package/dist/chunk-MPBXOJ3N.js.map +0 -1
  193. package/dist/chunk-N4MOEF32.js.map +0 -1
  194. package/dist/chunk-NWB4O5EY.js.map +0 -1
  195. package/dist/chunk-QRDB4NLB.js.map +0 -1
  196. package/dist/chunk-SEEA6RXF.js.map +0 -1
  197. package/dist/chunk-U5SZ4FOP.js +0 -26
  198. package/dist/chunk-U5SZ4FOP.js.map +0 -1
  199. package/dist/client-X5WUJWDE.js +0 -11
  200. package/dist/db-JSIZYT23.js +0 -23
  201. package/dist/db-JSIZYT23.js.map +0 -1
  202. package/dist/ensure-site-S25MDDHW.js.map +0 -1
  203. package/dist/forms-notify-target-TWN4EH6K.js.map +0 -1
  204. package/dist/github-signals-G4ILZAJ6.js.map +0 -1
  205. package/dist/header-image-T7XD37JL.js.map +0 -1
  206. package/dist/migrate-LJUFGXQZ.js +0 -7
  207. package/dist/orchestrate-KDNXZQX7.js +0 -27
  208. package/dist/report-6CKSWJNG.js.map +0 -1
  209. package/dist/reports/header-image/assets/plate.png +0 -0
  210. package/dist/selftest-CQ6NOX52.js.map +0 -1
  211. /package/dist/{airtable-HRKSA7V4.js.map → airtable-G7WBQQMI.js.map} +0 -0
  212. /package/dist/{bump-deps-SWJB6H5V.js.map → bump-deps-EC74CPLS.js.map} +0 -0
  213. /package/dist/{chunk-SDUJIANJ.js.map → chunk-2NJQLSEU.js.map} +0 -0
  214. /package/dist/{chunk-ZTPPCHDB.js.map → chunk-3G25KIWW.js.map} +0 -0
  215. /package/dist/{chunk-Q276MZ7E.js.map → chunk-3VQWSE3F.js.map} +0 -0
  216. /package/dist/{chunk-THEFTZ7F.js.map → chunk-62CMMGEO.js.map} +0 -0
  217. /package/dist/{chunk-FDIFTIXC.js.map → chunk-6TWWY7ZU.js.map} +0 -0
  218. /package/dist/{chunk-UYCAF77E.js.map → chunk-7RJ2HMMJ.js.map} +0 -0
  219. /package/dist/{chunk-R6FPFMOS.js.map → chunk-BQRFQSGY.js.map} +0 -0
  220. /package/dist/{chunk-TP5M2NQB.js.map → chunk-F5KM4LEQ.js.map} +0 -0
  221. /package/dist/{chunk-62VF32IC.js.map → chunk-ILUNZHYV.js.map} +0 -0
  222. /package/dist/{chunk-LS6QPANO.js.map → chunk-IYSEC543.js.map} +0 -0
  223. /package/dist/{chunk-DC5H7KEQ.js.map → chunk-JHGHLKFS.js.map} +0 -0
  224. /package/dist/{chunk-PBWDDDQH.js.map → chunk-KTH5ZODA.js.map} +0 -0
  225. /package/dist/{chunk-7J53SUT6.js.map → chunk-P2FE5AI6.js.map} +0 -0
  226. /package/dist/{chunk-GZNBCHAZ.js.map → chunk-TERE5SDF.js.map} +0 -0
  227. /package/dist/{chunk-ZMIPG6TR.js.map → chunk-TVL5MVUF.js.map} +0 -0
  228. /package/dist/{chunk-VRWUL4TR.js.map → chunk-XHHMM5HX.js.map} +0 -0
  229. /package/dist/{chunk-RJB3QUSP.js.map → chunk-XIDUYLSR.js.map} +0 -0
  230. /package/dist/{chunk-QG6OSL4M.js.map → chunk-XUGSZP7Z.js.map} +0 -0
  231. /package/dist/{chunk-ICXV25N7.js.map → chunk-Z3KYFUN6.js.map} +0 -0
  232. /package/dist/{chunk-6V7565O2.js.map → chunk-ZKXSX4LO.js.map} +0 -0
  233. /package/dist/{client-X5WUJWDE.js.map → client-AUE3XUPO.js.map} +0 -0
  234. /package/dist/{convert-to-pnpm-AT52NWZB.js.map → convert-to-pnpm-6L6P74WR.js.map} +0 -0
  235. /package/dist/{health-endpoint-242WG2OJ.js.map → health-endpoint-WJC2UMYS.js.map} +0 -0
  236. /package/dist/{lighthouse-airtable-BIPM26SZ.js.map → import-airtable-HE43FB4H.js.map} +0 -0
  237. /package/dist/{migrate-LJUFGXQZ.js.map → lighthouse-VHI77PFB.js.map} +0 -0
  238. /package/dist/{orchestrate-KDNXZQX7.js.map → lighthouse-airtable-LBVIYYP5.js.map} +0 -0
  239. /package/dist/{websites-SADZR4XQ.js.map → migrate-GQH237A6.js.map} +0 -0
  240. /package/dist/{write-audits-to-airtable-7PH75YWY.js.map → notify-C645PGD5.js.map} +0 -0
  241. /package/dist/{onboard-53CITIM7.js.map → onboard-GK4PHPNI.js.map} +0 -0
  242. /package/dist/{prismic-ci-UTQ6LRR3.js.map → prismic-ci-3RHOZUUT.js.map} +0 -0
  243. /package/dist/{prismic-models-PVES6R5V.js.map → prismic-models-QBGUT2Q3.js.map} +0 -0
  244. /package/dist/{protection-audit-64PLLBLI.js.map → protection-audit-SJO53MEQ.js.map} +0 -0
  245. /package/dist/{renovate-dispatch-ICJGSFCE.js.map → renovate-dispatch-QJY75ODR.js.map} +0 -0
  246. /package/dist/{self-updating-PPQVV6UA.js.map → self-updating-6DWI2Z56.js.map} +0 -0
  247. /package/dist/{smoke-suite-OBWNPALQ.js.map → smoke-suite-VYY2LXM2.js.map} +0 -0
  248. /package/dist/{submissions-GJ6ITBRH.js.map → submissions-Q7DRFOCD.js.map} +0 -0
  249. /package/dist/{svelte-codemods-ZOBDHIES.js.map → svelte-codemods-25J2FO22.js.map} +0 -0
  250. /package/dist/{sync-configs-CJXUX6MD.js.map → sync-configs-3SE6PSCO.js.map} +0 -0
  251. /package/dist/{upgrade-WLX27UPC.js.map → upgrade-WBRQ62FX.js.map} +0 -0
  252. /package/dist/{webflow-624FEBFD.js.map → webflow-IRKOIT7S.js.map} +0 -0
@@ -0,0 +1,359 @@
1
+ // src/db/migrations.ts
2
+ var MIGRATIONS = [
3
+ {
4
+ id: "0001_init",
5
+ sql: `
6
+ CREATE TABLE IF NOT EXISTS submissions (
7
+ id TEXT PRIMARY KEY,
8
+ submission_id INTEGER,
9
+ site_id TEXT NOT NULL,
10
+ form_type TEXT NOT NULL,
11
+ name TEXT NOT NULL,
12
+ email TEXT NOT NULL,
13
+ phone TEXT,
14
+ message TEXT,
15
+ extra_fields TEXT,
16
+ source_url TEXT,
17
+ utm TEXT,
18
+ submitted_at TEXT,
19
+ status TEXT NOT NULL DEFAULT 'new',
20
+ notify_status TEXT NOT NULL DEFAULT 'skipped',
21
+ resend_message_id TEXT
22
+ );
23
+ CREATE INDEX IF NOT EXISTS idx_submissions_site_submitted
24
+ ON submissions (site_id, submitted_at DESC);
25
+ CREATE INDEX IF NOT EXISTS idx_submissions_status
26
+ ON submissions (status);
27
+ CREATE TABLE IF NOT EXISTS spam_screenouts (
28
+ site_id TEXT NOT NULL,
29
+ date TEXT NOT NULL,
30
+ honeypot INTEGER NOT NULL DEFAULT 0,
31
+ too_fast INTEGER NOT NULL DEFAULT 0,
32
+ marked_spam INTEGER NOT NULL DEFAULT 0,
33
+ PRIMARY KEY (site_id, date)
34
+ );
35
+ `
36
+ },
37
+ {
38
+ id: "0002_fleet_events",
39
+ sql: `
40
+ CREATE TABLE IF NOT EXISTS fleet_events (
41
+ id TEXT PRIMARY KEY,
42
+ ts TEXT NOT NULL,
43
+ type TEXT NOT NULL,
44
+ site_id TEXT,
45
+ site_name TEXT,
46
+ summary TEXT NOT NULL,
47
+ data TEXT,
48
+ created_at TEXT NOT NULL
49
+ );
50
+ CREATE INDEX IF NOT EXISTS idx_fleet_events_ts ON fleet_events (ts);
51
+ `
52
+ },
53
+ {
54
+ id: "0003_add_spam_score",
55
+ // SQLite `ADD COLUMN` has no `IF NOT EXISTS`, so it is NOT self-idempotent: if the
56
+ // `_migrations` marker is lost after the column is added (crash between statement
57
+ // and marker write), a re-run would throw `duplicate column name`. `migrate.ts`
58
+ // recognizes that error as already-applied and records the marker, keeping the
59
+ // runner idempotent (see `isAlreadyAppliedError`).
60
+ sql: `ALTER TABLE submissions ADD COLUMN spam_score REAL;`
61
+ },
62
+ {
63
+ id: "0004_add_spam_reason",
64
+ sql: `ALTER TABLE submissions ADD COLUMN spam_reason TEXT;`
65
+ },
66
+ {
67
+ // Outcome of the newsletter fan-out (site webhook + Mailchimp). Before this the
68
+ // results were console.error-only, so a rotated Mailchimp key or an outage would
69
+ // silently stop signups reaching the audience with no trace anywhere the operator
70
+ // looks — the row still read `notify=sent`, healthy. Same non-idempotent
71
+ // ADD COLUMN caveat as 0003.
72
+ id: "0005_add_fanout_status",
73
+ sql: `ALTER TABLE submissions ADD COLUMN fanout_status TEXT;`
74
+ },
75
+ {
76
+ // Phase 0 of the Airtable → Turso migration (#539): a lead whose SITE LOOKUP
77
+ // fails must still land somewhere durable. On 2026-08-17 the Airtable quota
78
+ // outage made `getWebsiteBySlug` throw before `createSubmission` ran, so every
79
+ // lead in the window 502'd away unrecorded — while THIS store (which holds
80
+ // submissions) was healthy the whole time. Rows here are written only on that
81
+ // path and replayed through the normal pipeline once the lookup recovers
82
+ // (`db replay-deadletters`), producing an ordinary submissions row with real
83
+ // spam classification and notify. Deliberately a separate table rather than a
84
+ // nullable `submissions.site_id`: site_id is NOT NULL, every reader assumes a
85
+ // real rec id, and a sentinel would collapse dashboard grouping (the "" key
86
+ // hazard audit.ts already documents).
87
+ //
88
+ // `turnstile` stores the verification COMPUTED AT RECEIPT ({outcome, hostname}
89
+ // JSON) — tokens expire in 300s, so replay could never re-verify; it replays
90
+ // with the answer we already had.
91
+ id: "0006_submission_deadletter",
92
+ sql: `
93
+ CREATE TABLE IF NOT EXISTS submission_deadletter (
94
+ id TEXT PRIMARY KEY,
95
+ site_slug TEXT NOT NULL,
96
+ payload TEXT NOT NULL,
97
+ turnstile TEXT NOT NULL,
98
+ error TEXT NOT NULL,
99
+ received_at TEXT NOT NULL,
100
+ replayed_at TEXT,
101
+ replay_outcome TEXT,
102
+ replay_submission_id TEXT
103
+ );
104
+ CREATE INDEX IF NOT EXISTS idx_deadletter_unreplayed
105
+ ON submission_deadletter (replayed_at);
106
+ `
107
+ },
108
+ {
109
+ // Phase 1.2 of the Airtable → Turso migration (#539): the fleet-state tables.
110
+ // Split by WRITER, not topic (design D2, confirmed by the 2026-08-23 writer
111
+ // map — every code-written column has exactly one writer):
112
+ // sites — operator (dashboard editor / console / launch flow)
113
+ // site_health — the nightly audit write-back, one batched upsert
114
+ // site_schedule — updateNextDueDates (report cron, derived)
115
+ // reports — report drafting + the operator approve flow
116
+ // PKs are the Airtable `rec…` ids (design D1): 278+ submissions rows already
117
+ // reference them, and the parity harness diffs the two stores row-for-row.
118
+ //
119
+ // Naming: snake_case throughout; Airtable's misspellings ("maintenence") and
120
+ // display quirks die here. `sites.legacy` is a JSON object holding the 33
121
+ // populated-but-code-unreferenced columns (launch-era checklist, hosting
122
+ // reference cells) keyed by their original Airtable column name — data kept,
123
+ // schema not fossilized. The plaintext DNS/cms credential cells deliberately
124
+ // do NOT migrate at all (operator ruling 2026-08-23); they live on only in
125
+ // the frozen base. `site_health.analytics_soft_fail_at` is the column code
126
+ // always wanted ("Analytics soft-fail at") but no operator ever created in
127
+ // Airtable — it ships real here.
128
+ //
129
+ // reports.checklist is JSON keyed by the STABLE checklist key ("deploy",
130
+ // "cms", …, from src/reports/checklist.ts), not the Airtable column name —
131
+ // the importer translates, so "Test: Verified After Updates" stops leaking
132
+ // its legacy name into a second store.
133
+ id: "0007_fleet_state",
134
+ sql: `
135
+ CREATE TABLE IF NOT EXISTS sites (
136
+ id TEXT PRIMARY KEY,
137
+ slug TEXT NOT NULL UNIQUE,
138
+ name TEXT NOT NULL,
139
+ url TEXT,
140
+ status TEXT,
141
+ point_of_contact TEXT,
142
+ maintenance_freq TEXT,
143
+ testing_freq TEXT,
144
+ maintenance_day TEXT,
145
+ testing_day TEXT,
146
+ ga4_property_id TEXT,
147
+ search_query TEXT,
148
+ search_console_property TEXT,
149
+ git_repo TEXT,
150
+ netlify_id TEXT,
151
+ report_recipients_to TEXT,
152
+ report_recipients_cc TEXT,
153
+ copy_intro TEXT,
154
+ copy_contact TEXT,
155
+ copy_footer TEXT,
156
+ newsletter_webhook TEXT,
157
+ mailchimp_api_key TEXT,
158
+ mailchimp_audience_id TEXT,
159
+ notify_routing TEXT,
160
+ require_turnstile INTEGER NOT NULL DEFAULT 0,
161
+ accepted_watch_conditions TEXT,
162
+ prismic_ack_until TEXT,
163
+ launched_at TEXT,
164
+ header_image BLOB,
165
+ header_image_filename TEXT,
166
+ header_image_type TEXT,
167
+ header_image_generated_at TEXT,
168
+ legacy TEXT
169
+ );
170
+ CREATE INDEX IF NOT EXISTS idx_sites_status ON sites (status);
171
+
172
+ CREATE TABLE IF NOT EXISTS site_health (
173
+ site_id TEXT PRIMARY KEY,
174
+ p_score INTEGER,
175
+ r_score INTEGER,
176
+ bp_score INTEGER,
177
+ seo_score INTEGER,
178
+ lighthouse_at TEXT,
179
+ a11y_violations INTEGER,
180
+ deps_drifted INTEGER,
181
+ deps_major_behind INTEGER,
182
+ deps_outdated INTEGER,
183
+ deps_major_outdated INTEGER,
184
+ vulns_critical INTEGER,
185
+ vulns_high INTEGER,
186
+ vulns_moderate INTEGER,
187
+ vulns_low INTEGER,
188
+ security_audit_at TEXT,
189
+ security_advisories TEXT,
190
+ auto_fix_attempts INTEGER,
191
+ analytics_soft_fail_at TEXT,
192
+ cert_days_remaining INTEGER,
193
+ domain_checked_at TEXT,
194
+ deploy_status TEXT,
195
+ last_deploy_at TEXT,
196
+ deploy_log_url TEXT,
197
+ deploy_checked_at TEXT,
198
+ function_health TEXT,
199
+ cms_reachable TEXT,
200
+ turnstile_widget TEXT,
201
+ function_health_checked_at TEXT,
202
+ crossbrowser_ok INTEGER,
203
+ mobile_ok INTEGER,
204
+ links_ok INTEGER,
205
+ broken_links INTEGER,
206
+ browser_checked_at TEXT,
207
+ uptime_reachable TEXT,
208
+ titles_meta_ok TEXT,
209
+ smoke_ok TEXT,
210
+ last_smoke_at TEXT,
211
+ form_e2e_ok TEXT,
212
+ form_e2e_checked_at TEXT,
213
+ renovate_failing_cis INTEGER,
214
+ default_branch_ci TEXT,
215
+ last_commit_at TEXT,
216
+ github_signals_at TEXT,
217
+ prismic_models TEXT,
218
+ prismic_models_checked_at TEXT,
219
+ prismic_models_drift TEXT
220
+ );
221
+
222
+ CREATE TABLE IF NOT EXISTS site_schedule (
223
+ site_id TEXT PRIMARY KEY,
224
+ next_maintenance_at TEXT,
225
+ next_testing_at TEXT,
226
+ computed_at TEXT
227
+ );
228
+
229
+ CREATE TABLE IF NOT EXISTS reports (
230
+ id TEXT PRIMARY KEY,
231
+ site_id TEXT,
232
+ report_id TEXT,
233
+ report_type TEXT,
234
+ period TEXT,
235
+ period_start TEXT,
236
+ period_end TEXT,
237
+ completed_on TEXT,
238
+ lighthouse_performance INTEGER,
239
+ lighthouse_accessibility INTEGER,
240
+ lighthouse_best_practices INTEGER,
241
+ lighthouse_seo INTEGER,
242
+ ga_users_current INTEGER,
243
+ ga_users_previous INTEGER,
244
+ search_found_page1 INTEGER,
245
+ search_position REAL,
246
+ last_tested_date TEXT,
247
+ commentary TEXT,
248
+ subject_override TEXT,
249
+ draft_ready INTEGER NOT NULL DEFAULT 0,
250
+ approved_to_send INTEGER NOT NULL DEFAULT 0,
251
+ approved_at TEXT,
252
+ approved_by TEXT,
253
+ send_override INTEGER NOT NULL DEFAULT 0,
254
+ override_reason TEXT,
255
+ override_by TEXT,
256
+ override_at TEXT,
257
+ sent_at TEXT,
258
+ delivery_status TEXT,
259
+ resend_message_id TEXT,
260
+ checklist TEXT,
261
+ checklist_auto_evidence TEXT,
262
+ rendered_html TEXT
263
+ );
264
+ CREATE INDEX IF NOT EXISTS idx_reports_site ON reports (site_id, period_start DESC);
265
+ `
266
+ },
267
+ {
268
+ // Indexes the EXPLAIN-query-plan gate (tests/db/query-plans.test.ts) proved the
269
+ // hot paths need — each serves a named query shape that otherwise full-scans
270
+ // submissions, the one unbounded-growth table:
271
+ // submitted_at — every windowed read (/submissions default page, digest
272
+ // per-site counts, duplicate/repeat-sender scans)
273
+ // resend_message_id — the resend-webhook bounce lookup (markNotifyBouncedByMessageId)
274
+ // submission_id — createSubmission's MAX(submission_id)+1 display number,
275
+ // O(1) off the index instead of a scan per insert
276
+ // spam_reason (partial, covering) — listSpamReasonsFiltered's facet tally
277
+ // reads ONLY spam_reason WHERE spam_reason IS NOT NULL;
278
+ // partial keeps it to the spam subset
279
+ // spam_screenouts(date) — listScreenOutsSince windows on date, which the
280
+ // (site_id, date) PK can't serve
281
+ id: "0008_query_plan_indexes",
282
+ sql: `
283
+ CREATE INDEX IF NOT EXISTS idx_submissions_submitted
284
+ ON submissions (submitted_at DESC);
285
+ CREATE INDEX IF NOT EXISTS idx_submissions_spam_reason
286
+ ON submissions (spam_reason) WHERE spam_reason IS NOT NULL;
287
+ CREATE INDEX IF NOT EXISTS idx_submissions_resend_message
288
+ ON submissions (resend_message_id);
289
+ CREATE INDEX IF NOT EXISTS idx_submissions_display_number
290
+ ON submissions (submission_id);
291
+ CREATE INDEX IF NOT EXISTS idx_screenouts_date
292
+ ON spam_screenouts (date);
293
+ `
294
+ },
295
+ {
296
+ // Prospect audits (spec 2026-08-24): one row per `prospect-audit` CLI run.
297
+ // `token` is the unguessable public handle for GET /r/{token}; `result_json`
298
+ // is the full ProspectAuditResult, re-rendered on every request so report
299
+ // styling updates apply to already-shared links.
300
+ id: "0009_prospect_audits",
301
+ sql: `
302
+ CREATE TABLE IF NOT EXISTS prospect_audits (
303
+ id TEXT PRIMARY KEY,
304
+ token TEXT NOT NULL UNIQUE,
305
+ url TEXT NOT NULL,
306
+ business TEXT,
307
+ created_at TEXT NOT NULL,
308
+ status TEXT NOT NULL DEFAULT 'complete',
309
+ result_json TEXT NOT NULL
310
+ );
311
+ `
312
+ },
313
+ {
314
+ // Cockpit /audits listing page (spec 2026-08-25, Piece 5):
315
+ // listRecentProspectAudits orders by created_at DESC with no WHERE clause —
316
+ // without this index that's a raw SCAN of prospect_audits followed by a temp
317
+ // b-tree sort, which the EXPLAIN-query-plan gate (tests/db/query-plans.test.ts)
318
+ // fails the build on. DESC matches the query's own ORDER BY so SQLite can walk
319
+ // the index directly instead of sorting.
320
+ id: "0010_prospect_audits_created_index",
321
+ sql: `
322
+ CREATE INDEX IF NOT EXISTS idx_prospect_audits_created
323
+ ON prospect_audits (created_at DESC);
324
+ `
325
+ }
326
+ ];
327
+
328
+ // src/db/migrate.ts
329
+ function isAlreadyAppliedError(err) {
330
+ const message = err instanceof Error ? err.message : String(err);
331
+ return /duplicate column name/i.test(message);
332
+ }
333
+ async function runMigrations(client) {
334
+ await client.execute(
335
+ "CREATE TABLE IF NOT EXISTS _migrations (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)"
336
+ );
337
+ const existing = await client.execute("SELECT id FROM _migrations");
338
+ const applied = new Set(existing.rows.map((r) => String(r.id)));
339
+ const ran = [];
340
+ for (const m of MIGRATIONS) {
341
+ if (applied.has(m.id)) continue;
342
+ try {
343
+ await client.executeMultiple(m.sql);
344
+ } catch (err) {
345
+ if (!isAlreadyAppliedError(err)) throw err;
346
+ }
347
+ await client.execute({
348
+ sql: "INSERT INTO _migrations (id, applied_at) VALUES (?, ?)",
349
+ args: [m.id, (/* @__PURE__ */ new Date()).toISOString()]
350
+ });
351
+ ran.push(m.id);
352
+ }
353
+ return ran;
354
+ }
355
+
356
+ export {
357
+ runMigrations
358
+ };
359
+ //# sourceMappingURL=chunk-3SF7D4DY.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/db/migrations.ts","../src/db/migrate.ts"],"sourcesContent":["/** Ordered, append-only list of standard-SQL migration scripts. Each runs once,\n * tracked by `id` in the `_migrations` table (see migrate.ts). Statements use\n * IF NOT EXISTS so even a partial re-apply is safe. Never edit a shipped script —\n * add a new one. Every statement in a script MUST be independently idempotent:\n * `migrate.ts` runs each script via `executeMultiple`, which is NOT transactional,\n * so a mid-script failure can leave earlier statements applied and the id unrecorded\n * — a re-run then re-executes the whole script. Standard SQLite SQL only (no\n * Turso-specific syntax) so the host stays a connection-string swap. */\nexport type Migration = { id: string; sql: string };\n\nexport const MIGRATIONS: Migration[] = [\n {\n id: \"0001_init\",\n sql: `\n CREATE TABLE IF NOT EXISTS submissions (\n id TEXT PRIMARY KEY,\n submission_id INTEGER,\n site_id TEXT NOT NULL,\n form_type TEXT NOT NULL,\n name TEXT NOT NULL,\n email TEXT NOT NULL,\n phone TEXT,\n message TEXT,\n extra_fields TEXT,\n source_url TEXT,\n utm TEXT,\n submitted_at TEXT,\n status TEXT NOT NULL DEFAULT 'new',\n notify_status TEXT NOT NULL DEFAULT 'skipped',\n resend_message_id TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_submissions_site_submitted\n ON submissions (site_id, submitted_at DESC);\n CREATE INDEX IF NOT EXISTS idx_submissions_status\n ON submissions (status);\n CREATE TABLE IF NOT EXISTS spam_screenouts (\n site_id TEXT NOT NULL,\n date TEXT NOT NULL,\n honeypot INTEGER NOT NULL DEFAULT 0,\n too_fast INTEGER NOT NULL DEFAULT 0,\n marked_spam INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (site_id, date)\n );\n `,\n },\n {\n id: \"0002_fleet_events\",\n sql: `\n CREATE TABLE IF NOT EXISTS fleet_events (\n id TEXT PRIMARY KEY,\n ts TEXT NOT NULL,\n type TEXT NOT NULL,\n site_id TEXT,\n site_name TEXT,\n summary TEXT NOT NULL,\n data TEXT,\n created_at TEXT NOT NULL\n );\n CREATE INDEX IF NOT EXISTS idx_fleet_events_ts ON fleet_events (ts);\n `,\n },\n {\n id: \"0003_add_spam_score\",\n // SQLite `ADD COLUMN` has no `IF NOT EXISTS`, so it is NOT self-idempotent: if the\n // `_migrations` marker is lost after the column is added (crash between statement\n // and marker write), a re-run would throw `duplicate column name`. `migrate.ts`\n // recognizes that error as already-applied and records the marker, keeping the\n // runner idempotent (see `isAlreadyAppliedError`).\n sql: `ALTER TABLE submissions ADD COLUMN spam_score REAL;`,\n },\n {\n id: \"0004_add_spam_reason\",\n sql: `ALTER TABLE submissions ADD COLUMN spam_reason TEXT;`,\n },\n {\n // Outcome of the newsletter fan-out (site webhook + Mailchimp). Before this the\n // results were console.error-only, so a rotated Mailchimp key or an outage would\n // silently stop signups reaching the audience with no trace anywhere the operator\n // looks — the row still read `notify=sent`, healthy. Same non-idempotent\n // ADD COLUMN caveat as 0003.\n id: \"0005_add_fanout_status\",\n sql: `ALTER TABLE submissions ADD COLUMN fanout_status TEXT;`,\n },\n {\n // Phase 0 of the Airtable → Turso migration (#539): a lead whose SITE LOOKUP\n // fails must still land somewhere durable. On 2026-08-17 the Airtable quota\n // outage made `getWebsiteBySlug` throw before `createSubmission` ran, so every\n // lead in the window 502'd away unrecorded — while THIS store (which holds\n // submissions) was healthy the whole time. Rows here are written only on that\n // path and replayed through the normal pipeline once the lookup recovers\n // (`db replay-deadletters`), producing an ordinary submissions row with real\n // spam classification and notify. Deliberately a separate table rather than a\n // nullable `submissions.site_id`: site_id is NOT NULL, every reader assumes a\n // real rec id, and a sentinel would collapse dashboard grouping (the \"\" key\n // hazard audit.ts already documents).\n //\n // `turnstile` stores the verification COMPUTED AT RECEIPT ({outcome, hostname}\n // JSON) — tokens expire in 300s, so replay could never re-verify; it replays\n // with the answer we already had.\n id: \"0006_submission_deadletter\",\n sql: `\n CREATE TABLE IF NOT EXISTS submission_deadletter (\n id TEXT PRIMARY KEY,\n site_slug TEXT NOT NULL,\n payload TEXT NOT NULL,\n turnstile TEXT NOT NULL,\n error TEXT NOT NULL,\n received_at TEXT NOT NULL,\n replayed_at TEXT,\n replay_outcome TEXT,\n replay_submission_id TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_deadletter_unreplayed\n ON submission_deadletter (replayed_at);\n `,\n },\n {\n // Phase 1.2 of the Airtable → Turso migration (#539): the fleet-state tables.\n // Split by WRITER, not topic (design D2, confirmed by the 2026-08-23 writer\n // map — every code-written column has exactly one writer):\n // sites — operator (dashboard editor / console / launch flow)\n // site_health — the nightly audit write-back, one batched upsert\n // site_schedule — updateNextDueDates (report cron, derived)\n // reports — report drafting + the operator approve flow\n // PKs are the Airtable `rec…` ids (design D1): 278+ submissions rows already\n // reference them, and the parity harness diffs the two stores row-for-row.\n //\n // Naming: snake_case throughout; Airtable's misspellings (\"maintenence\") and\n // display quirks die here. `sites.legacy` is a JSON object holding the 33\n // populated-but-code-unreferenced columns (launch-era checklist, hosting\n // reference cells) keyed by their original Airtable column name — data kept,\n // schema not fossilized. The plaintext DNS/cms credential cells deliberately\n // do NOT migrate at all (operator ruling 2026-08-23); they live on only in\n // the frozen base. `site_health.analytics_soft_fail_at` is the column code\n // always wanted (\"Analytics soft-fail at\") but no operator ever created in\n // Airtable — it ships real here.\n //\n // reports.checklist is JSON keyed by the STABLE checklist key (\"deploy\",\n // \"cms\", …, from src/reports/checklist.ts), not the Airtable column name —\n // the importer translates, so \"Test: Verified After Updates\" stops leaking\n // its legacy name into a second store.\n id: \"0007_fleet_state\",\n sql: `\n CREATE TABLE IF NOT EXISTS sites (\n id TEXT PRIMARY KEY,\n slug TEXT NOT NULL UNIQUE,\n name TEXT NOT NULL,\n url TEXT,\n status TEXT,\n point_of_contact TEXT,\n maintenance_freq TEXT,\n testing_freq TEXT,\n maintenance_day TEXT,\n testing_day TEXT,\n ga4_property_id TEXT,\n search_query TEXT,\n search_console_property TEXT,\n git_repo TEXT,\n netlify_id TEXT,\n report_recipients_to TEXT,\n report_recipients_cc TEXT,\n copy_intro TEXT,\n copy_contact TEXT,\n copy_footer TEXT,\n newsletter_webhook TEXT,\n mailchimp_api_key TEXT,\n mailchimp_audience_id TEXT,\n notify_routing TEXT,\n require_turnstile INTEGER NOT NULL DEFAULT 0,\n accepted_watch_conditions TEXT,\n prismic_ack_until TEXT,\n launched_at TEXT,\n header_image BLOB,\n header_image_filename TEXT,\n header_image_type TEXT,\n header_image_generated_at TEXT,\n legacy TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_sites_status ON sites (status);\n\n CREATE TABLE IF NOT EXISTS site_health (\n site_id TEXT PRIMARY KEY,\n p_score INTEGER,\n r_score INTEGER,\n bp_score INTEGER,\n seo_score INTEGER,\n lighthouse_at TEXT,\n a11y_violations INTEGER,\n deps_drifted INTEGER,\n deps_major_behind INTEGER,\n deps_outdated INTEGER,\n deps_major_outdated INTEGER,\n vulns_critical INTEGER,\n vulns_high INTEGER,\n vulns_moderate INTEGER,\n vulns_low INTEGER,\n security_audit_at TEXT,\n security_advisories TEXT,\n auto_fix_attempts INTEGER,\n analytics_soft_fail_at TEXT,\n cert_days_remaining INTEGER,\n domain_checked_at TEXT,\n deploy_status TEXT,\n last_deploy_at TEXT,\n deploy_log_url TEXT,\n deploy_checked_at TEXT,\n function_health TEXT,\n cms_reachable TEXT,\n turnstile_widget TEXT,\n function_health_checked_at TEXT,\n crossbrowser_ok INTEGER,\n mobile_ok INTEGER,\n links_ok INTEGER,\n broken_links INTEGER,\n browser_checked_at TEXT,\n uptime_reachable TEXT,\n titles_meta_ok TEXT,\n smoke_ok TEXT,\n last_smoke_at TEXT,\n form_e2e_ok TEXT,\n form_e2e_checked_at TEXT,\n renovate_failing_cis INTEGER,\n default_branch_ci TEXT,\n last_commit_at TEXT,\n github_signals_at TEXT,\n prismic_models TEXT,\n prismic_models_checked_at TEXT,\n prismic_models_drift TEXT\n );\n\n CREATE TABLE IF NOT EXISTS site_schedule (\n site_id TEXT PRIMARY KEY,\n next_maintenance_at TEXT,\n next_testing_at TEXT,\n computed_at TEXT\n );\n\n CREATE TABLE IF NOT EXISTS reports (\n id TEXT PRIMARY KEY,\n site_id TEXT,\n report_id TEXT,\n report_type TEXT,\n period TEXT,\n period_start TEXT,\n period_end TEXT,\n completed_on TEXT,\n lighthouse_performance INTEGER,\n lighthouse_accessibility INTEGER,\n lighthouse_best_practices INTEGER,\n lighthouse_seo INTEGER,\n ga_users_current INTEGER,\n ga_users_previous INTEGER,\n search_found_page1 INTEGER,\n search_position REAL,\n last_tested_date TEXT,\n commentary TEXT,\n subject_override TEXT,\n draft_ready INTEGER NOT NULL DEFAULT 0,\n approved_to_send INTEGER NOT NULL DEFAULT 0,\n approved_at TEXT,\n approved_by TEXT,\n send_override INTEGER NOT NULL DEFAULT 0,\n override_reason TEXT,\n override_by TEXT,\n override_at TEXT,\n sent_at TEXT,\n delivery_status TEXT,\n resend_message_id TEXT,\n checklist TEXT,\n checklist_auto_evidence TEXT,\n rendered_html TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_reports_site ON reports (site_id, period_start DESC);\n `,\n },\n {\n // Indexes the EXPLAIN-query-plan gate (tests/db/query-plans.test.ts) proved the\n // hot paths need — each serves a named query shape that otherwise full-scans\n // submissions, the one unbounded-growth table:\n // submitted_at — every windowed read (/submissions default page, digest\n // per-site counts, duplicate/repeat-sender scans)\n // resend_message_id — the resend-webhook bounce lookup (markNotifyBouncedByMessageId)\n // submission_id — createSubmission's MAX(submission_id)+1 display number,\n // O(1) off the index instead of a scan per insert\n // spam_reason (partial, covering) — listSpamReasonsFiltered's facet tally\n // reads ONLY spam_reason WHERE spam_reason IS NOT NULL;\n // partial keeps it to the spam subset\n // spam_screenouts(date) — listScreenOutsSince windows on date, which the\n // (site_id, date) PK can't serve\n id: \"0008_query_plan_indexes\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_submissions_submitted\n ON submissions (submitted_at DESC);\n CREATE INDEX IF NOT EXISTS idx_submissions_spam_reason\n ON submissions (spam_reason) WHERE spam_reason IS NOT NULL;\n CREATE INDEX IF NOT EXISTS idx_submissions_resend_message\n ON submissions (resend_message_id);\n CREATE INDEX IF NOT EXISTS idx_submissions_display_number\n ON submissions (submission_id);\n CREATE INDEX IF NOT EXISTS idx_screenouts_date\n ON spam_screenouts (date);\n `,\n },\n {\n // Prospect audits (spec 2026-08-24): one row per `prospect-audit` CLI run.\n // `token` is the unguessable public handle for GET /r/{token}; `result_json`\n // is the full ProspectAuditResult, re-rendered on every request so report\n // styling updates apply to already-shared links.\n id: \"0009_prospect_audits\",\n sql: `\n CREATE TABLE IF NOT EXISTS prospect_audits (\n id TEXT PRIMARY KEY,\n token TEXT NOT NULL UNIQUE,\n url TEXT NOT NULL,\n business TEXT,\n created_at TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'complete',\n result_json TEXT NOT NULL\n );\n `,\n },\n {\n // Cockpit /audits listing page (spec 2026-08-25, Piece 5):\n // listRecentProspectAudits orders by created_at DESC with no WHERE clause —\n // without this index that's a raw SCAN of prospect_audits followed by a temp\n // b-tree sort, which the EXPLAIN-query-plan gate (tests/db/query-plans.test.ts)\n // fails the build on. DESC matches the query's own ORDER BY so SQLite can walk\n // the index directly instead of sorting.\n id: \"0010_prospect_audits_created_index\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_prospect_audits_created\n ON prospect_audits (created_at DESC);\n `,\n },\n];\n","import type { Client } from \"@libsql/client\";\nimport { MIGRATIONS } from \"./migrations.js\";\n\n/** True when an error means \"this DDL was already applied\" and re-applying is a\n * no-op we can safely ignore. `CREATE ... IF NOT EXISTS` never throws, but SQLite\n * `ALTER TABLE ... ADD COLUMN` has no `IF NOT EXISTS`, so a re-run after a lost\n * `_migrations` marker throws `duplicate column name: …`. Treating that as\n * already-applied keeps `runMigrations` idempotent for `ADD COLUMN` migrations —\n * without it a lost marker would boot-loop `openDb()` on every call. */\nfunction isAlreadyAppliedError(err: unknown): boolean {\n const message = err instanceof Error ? err.message : String(err);\n return /duplicate column name/i.test(message);\n}\n\n/** Apply every not-yet-applied migration in order against a raw libSQL client,\n * tracking applied ids in `_migrations`. Idempotent: ids already present are\n * skipped, the DDL uses IF NOT EXISTS where it can, and an already-applied\n * `ADD COLUMN` (recognized by its error) is swallowed so the marker is still\n * recorded. Returns the ids applied this run. Runs on every openDb() — cheap\n * (one indexed SELECT) and the gate every fresh Turso database needs before its\n * first write. */\nexport async function runMigrations(client: Client): Promise<string[]> {\n await client.execute(\n \"CREATE TABLE IF NOT EXISTS _migrations (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)\",\n );\n const existing = await client.execute(\"SELECT id FROM _migrations\");\n const applied = new Set(existing.rows.map((r) => String(r.id)));\n const ran: string[] = [];\n for (const m of MIGRATIONS) {\n if (applied.has(m.id)) continue;\n try {\n await client.executeMultiple(m.sql);\n } catch (err) {\n // A re-run after a lost marker can re-execute a non-idempotent `ADD COLUMN`;\n // treat \"already applied\" as success so we still record the marker below and\n // never boot-loop. Any other error propagates unchanged.\n if (!isAlreadyAppliedError(err)) throw err;\n }\n await client.execute({\n sql: \"INSERT INTO _migrations (id, applied_at) VALUES (?, ?)\",\n args: [m.id, new Date().toISOString()],\n });\n ran.push(m.id);\n }\n return ran;\n}\n"],"mappings":"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","names":[]}
@@ -1,10 +1,10 @@
1
+ import {
2
+ withRecipe
3
+ } from "./chunk-3G25KIWW.js";
1
4
  import {
2
5
  ALL_TEMPLATES,
3
6
  templatesByName
4
7
  } from "./chunk-73UFRZKY.js";
5
- import {
6
- withRecipe
7
- } from "./chunk-ZTPPCHDB.js";
8
8
  import {
9
9
  listTrackedFiles,
10
10
  removeFromIndex
@@ -207,4 +207,4 @@ export {
207
207
  isConfigName,
208
208
  syncConfigs
209
209
  };
210
- //# sourceMappingURL=chunk-Q276MZ7E.js.map
210
+ //# sourceMappingURL=chunk-3VQWSE3F.js.map
@@ -3,7 +3,16 @@ import { readFile } from "fs/promises";
3
3
  import { existsSync } from "fs";
4
4
  import { dirname, join } from "path";
5
5
  import { fileURLToPath } from "url";
6
- var PLATE = "plate.png";
6
+ var PLATE = "plate-clean.png";
7
+ var HEADLINE_FILES = {
8
+ Maintenance: "headline-maintenance.png",
9
+ Testing: "headline-testing.png",
10
+ Announcement: "headline-announcement.png",
11
+ Launch: "headline-launch.png"
12
+ };
13
+ function headlineKindFor(reportType) {
14
+ return Object.hasOwn(HEADLINE_FILES, reportType) ? reportType : null;
15
+ }
7
16
  var cachedAssetsDir = null;
8
17
  function resolveAssetsDir() {
9
18
  if (cachedAssetsDir) return cachedAssetsDir;
@@ -28,6 +37,9 @@ function resolveAssetsDir() {
28
37
  async function loadPlate() {
29
38
  return new Uint8Array(await readFile(join(resolveAssetsDir(), PLATE)));
30
39
  }
40
+ async function loadHeadline(kind) {
41
+ return new Uint8Array(await readFile(join(resolveAssetsDir(), HEADLINE_FILES[kind])));
42
+ }
31
43
 
32
44
  // src/reports/header-image/index.ts
33
45
  import sharp2 from "sharp";
@@ -78,6 +90,9 @@ import sharp from "sharp";
78
90
  // src/reports/header-image/geometry.ts
79
91
  var CANVAS = { width: 2400, height: 3200 };
80
92
  var SCREEN = { x: 302, y: 1913, w: 1349, h: 844 };
93
+ var HEADLINE = { x: 277, y: 998 };
94
+ var HEADLINE_BAND = { x: HEADLINE.x, y: HEADLINE.y, w: 1715, h: 664 };
95
+ var HEADLINE_INK = { r: 209, g: 34, b: 62 };
81
96
  var DOMAIN = {
82
97
  x: 280,
83
98
  baseline: 3020,
@@ -112,8 +127,35 @@ async function composeHeaderImage(input) {
112
127
  const out = await sharp(Buffer.from(input.plate)).composite(layers).jpeg({ quality: JPEG_QUALITY }).toBuffer();
113
128
  return new Uint8Array(out);
114
129
  }
130
+ async function headlineInkCount(header) {
131
+ const { data, info } = await sharp(Buffer.from(header)).extract({
132
+ left: HEADLINE_BAND.x,
133
+ top: HEADLINE_BAND.y,
134
+ width: HEADLINE_BAND.w,
135
+ height: HEADLINE_BAND.h
136
+ }).removeAlpha().raw().toBuffer({ resolveWithObject: true });
137
+ let n = 0;
138
+ for (let i = 0; i < data.length; i += info.channels) {
139
+ const r = data[i] ?? 0;
140
+ const g = data[i + 1] ?? 0;
141
+ const b = data[i + 2] ?? 0;
142
+ if (r > HEADLINE_INK.r - 60 && g < HEADLINE_INK.g + 60 && b < HEADLINE_INK.b + 60) n++;
143
+ }
144
+ return n;
145
+ }
146
+ async function stampHeadline(header, headline) {
147
+ const meta = await sharp(Buffer.from(header)).metadata();
148
+ if (meta.width !== CANVAS.width || meta.height !== CANVAS.height) {
149
+ throw new Error(
150
+ `stampHeadline: header is ${meta.width}x${meta.height}, expected ${CANVAS.width}x${CANVAS.height}`
151
+ );
152
+ }
153
+ const out = await sharp(Buffer.from(header)).composite([{ input: Buffer.from(headline), left: HEADLINE.x, top: HEADLINE.y }]).jpeg({ quality: JPEG_QUALITY }).toBuffer();
154
+ return new Uint8Array(out);
155
+ }
115
156
 
116
157
  // src/reports/header-image/index.ts
158
+ var HEADLINE_INK_TOLERANCE = 500;
117
159
  function domainFromUrl(url) {
118
160
  return new URL(url).hostname.replace(/^www\./, "");
119
161
  }
@@ -146,6 +188,32 @@ async function assertNotBlank(shot) {
146
188
  );
147
189
  }
148
190
  }
191
+ async function applyReportTypeHeadline(header, reportType) {
192
+ const kind = headlineKindFor(reportType);
193
+ if (!kind) return header;
194
+ try {
195
+ const meta = await sharp2(Buffer.from(header)).metadata();
196
+ if (meta.width !== CANVAS.width || meta.height !== CANVAS.height) {
197
+ console.warn(
198
+ `\u2691 header headline skipped: stored header is ${meta.width}x${meta.height}, not ${CANVAS.width}x${CANVAS.height} \u2014 sending it as stored`
199
+ );
200
+ return header;
201
+ }
202
+ const existing = await headlineInkCount(header);
203
+ if (existing > HEADLINE_INK_TOLERANCE) {
204
+ console.warn(
205
+ `\u2691 header headline skipped: the stored header already carries a headline (${existing} ink px in the band) \u2014 it predates the clean-plate switch, so stamping would overprint. Sending it as stored; run \`header-image ${"<site>"} --write-airtable\` to regenerate it clean.`
206
+ );
207
+ return header;
208
+ }
209
+ return await stampHeadline(header, await loadHeadline(kind));
210
+ } catch (err) {
211
+ console.warn(
212
+ `\u2691 header headline skipped (${err instanceof Error ? err.message : String(err)}) \u2014 sending the header as stored`
213
+ );
214
+ return header;
215
+ }
216
+ }
149
217
  async function generateHeaderImage(input) {
150
218
  const screenshot = await captureHomepage(input.url, {
151
219
  ...input.shooter !== void 0 ? { shooter: input.shooter } : {},
@@ -164,7 +232,9 @@ async function generateHeaderImage(input) {
164
232
 
165
233
  export {
166
234
  loadPlate,
235
+ loadHeadline,
167
236
  domainFromUrl,
237
+ applyReportTypeHeadline,
168
238
  generateHeaderImage
169
239
  };
170
- //# sourceMappingURL=chunk-37S6L6G4.js.map
240
+ //# sourceMappingURL=chunk-5UINJNAJ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/header-image/assets/index.ts","../src/reports/header-image/index.ts","../src/reports/header-image/capture.ts","../src/reports/header-image/compose.ts","../src/reports/header-image/geometry.ts"],"sourcesContent":["import { readFile } from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst PLATE = \"plate-clean.png\";\n\n/** Per-report-type headline overlays, stamped over the CLEAN plate at send time\n * (see orchestrate.ts). Types with no entry go out on the clean plate.\n *\n * Source: Figma \"Web-Maintenance-Email\" (mQ3hy2d9JnOG9ljCzbZS8j) —\n * Maintenance is node 158:15, Testing is 153:721 (\"maintenance & testing\",\n * which is what a Testing report actually covers). Export each at 4x; Figma\n * crops a text export to its ink box, which is why these are 1328x660 /\n * 1715x664 rather than the 1840x720 text box.\n *\n * ⚠️ A Figma MCP `download_assets` export arrives FLATTENED — never\n * transparent — and it flattens onto WHATEVER SITS BEHIND THE NODE, which is\n * not one fixed colour: Maintenance/Testing live inside a white Header frame\n * and come back on white, while Announcement/Launch sit on the bare canvas and\n * come back on Figma's canvas grey (30,30,30). Stamping either unprocessed\n * paints a solid slab over the paper texture — that, not a bad design file, is\n * what made the 2026-08-20 Testing asset unusable.\n *\n * Recover the alpha instead of re-exporting by hand: for ink C over backdrop B,\n * a = (px - B) / (C - B). DETECT B (the export's most common colour) rather\n * than assuming white, and pick the channel with the largest |C - B| — green\n * separates the brand red from white by 221 levels but from canvas grey by only\n * 4, which would quantise the alpha to noise. Verified against the known-good\n * Maintenance asset at mean abs alpha diff 0.022, 0.0037% of pixels off by\n * more than 8.\n *\n * Editing this copy needs Figma desktop, not the Plugin API: `characters`\n * requires loadFontAsync and the MCP font environment carries no Helvetica at\n * all, so the licensed Helvetica Neue LT Std can never load there. */\nconst HEADLINE_FILES = {\n Maintenance: \"headline-maintenance.png\",\n Testing: \"headline-testing.png\",\n Announcement: \"headline-announcement.png\",\n Launch: \"headline-launch.png\",\n} as const;\n\nexport type HeadlineKind = keyof typeof HEADLINE_FILES;\n\n/** The headline kind for a report type, or null for types that ship clean. */\nexport function headlineKindFor(reportType: string): HeadlineKind | null {\n return Object.hasOwn(HEADLINE_FILES, reportType) ? (reportType as HeadlineKind) : null;\n}\n\n// Walk up from this module's URL looking for the assets dir in either the dev\n// layout (src/reports/header-image/assets/) or the published layout\n// (dist/reports/header-image/assets/). REQUIRED because tsup inlines this module\n// into dist/cli/bin.js — so import.meta.url-based sibling resolution looks in\n// dist/cli/ and fails with ENOENT. This exact bug shipped in 0.10.0-0.10.1 for\n// the maintenance-email assets; tests passed in dev because vitest evaluates the\n// source file, where import.meta.url is already correct. Only `pnpm test:dist`\n// catches a regression here.\nlet cachedAssetsDir: string | null = null;\nfunction resolveAssetsDir(): string {\n if (cachedAssetsDir) return cachedAssetsDir;\n let dir = dirname(fileURLToPath(import.meta.url));\n for (;;) {\n for (const layout of [\"src\", \"dist\"]) {\n const candidate = join(dir, layout, \"reports\", \"header-image\", \"assets\", PLATE);\n if (existsSync(candidate)) {\n cachedAssetsDir = dirname(candidate);\n return cachedAssetsDir;\n }\n }\n const parent = dirname(dir);\n if (parent === dir) {\n throw new Error(\n `loadPlate: could not locate the header-image 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/** Read the bundled 2400x3200 CLEAN plate (no headline — the report type's\n * headline is stamped at send time, so one stored image serves every type). */\nexport async function loadPlate(): Promise<Uint8Array> {\n return new Uint8Array(await readFile(join(resolveAssetsDir(), PLATE)));\n}\n\n/** Read a headline overlay (transparent PNG, ink-box cropped). */\nexport async function loadHeadline(kind: HeadlineKind): Promise<Uint8Array> {\n return new Uint8Array(await readFile(join(resolveAssetsDir(), HEADLINE_FILES[kind])));\n}\n","import sharp from \"sharp\";\nimport { captureHomepage, type Shooter } from \"./capture.js\";\nimport { composeHeaderImage, stampHeadline, headlineInkCount } from \"./compose.js\";\nimport { loadPlate, loadHeadline, headlineKindFor } from \"./assets/index.js\";\nimport { CANVAS } from \"./geometry.js\";\n\n/** Headline-red pixels tolerated in the band before a stored header is treated\n * as already carrying a headline. A clean-plate header measures 0 and a baked\n * one ~62,000, so this sits far from both — it exists only to absorb stray\n * JPEG artefacts, not to tune a boundary. */\nconst HEADLINE_INK_TOLERANCE = 500;\n\nexport type GenerateInput = {\n /** The site's production URL. */\n url: string;\n /** Airtable site slug; drives the attachment filename. */\n slug?: string;\n /** Injected browser IO (tests). */\n shooter?: Shooter;\n /** Per-site settle override for animation-heavy or consent-gated homepages. */\n settleMs?: number;\n};\n\nexport type GeneratedHeaderImage = {\n bytes: Uint8Array;\n /** The domain as printed on the plate. */\n domain: string;\n filename: string;\n contentType: \"image/jpeg\";\n};\n\n/** Strip scheme, leading www and any path — the plate prints a bare domain. */\nexport function domainFromUrl(url: string): string {\n return new URL(url).hostname.replace(/^www\\./, \"\");\n}\n\n/**\n * A capture is \"blank\" when it is almost entirely near-white — a consent gate,\n * a failed render (blank canvas), or a shot fired before first paint. The\n * operator reviews the rendered email before sending, so this is a backstop\n * rather than the only gate; its job is to stop an obviously broken shot from\n * replacing a good stored header.\n *\n * Uniformity alone is NOT the signal: a homepage with a large solid-colour\n * hero (any brand colour) is legitimately uniform but not blank. Only\n * near-white uniformity is flagged, since every real failure mode here\n * (consent overlay, unrendered canvas, pre-paint flash) lands on a white\n * background.\n */\nasync function assertNotBlank(shot: Uint8Array): Promise<void> {\n const { data, info } = await sharp(Buffer.from(shot))\n .resize(64, 40, { fit: \"fill\" })\n .removeAlpha()\n .raw()\n .toBuffer({ resolveWithObject: true });\n const total = info.width * info.height;\n const at = (i: number): number => data[i] ?? 0;\n\n let sumR = 0;\n let sumG = 0;\n let sumB = 0;\n for (let i = 0; i < data.length; i += info.channels) {\n sumR += at(i);\n sumG += at(i + 1);\n sumB += at(i + 2);\n }\n const meanR = sumR / total;\n const meanG = sumG / total;\n const meanB = sumB / total;\n\n let near = 0;\n for (let i = 0; i < data.length; i += info.channels) {\n if (\n Math.abs(at(i) - meanR) < 8 &&\n Math.abs(at(i + 1) - meanG) < 8 &&\n Math.abs(at(i + 2) - meanB) < 8\n ) {\n near++;\n }\n }\n\n const uniform = near / total > 0.95;\n const nearWhite = meanR > 240 && meanG > 240 && meanB > 240;\n if (uniform && nearWhite) {\n throw new Error(\n \"header-image: capture looks blank (near-white, >95% one colour) — keeping the existing header\",\n );\n }\n}\n\n/**\n * Stamp the report type's headline onto a stored (clean) header at send time.\n * Types with no headline registered pass through untouched, as does any header\n * that must not be stamped:\n *\n * - wrong dimensions (a legacy hand-made header), where fixed coordinates\n * would misplace the text;\n * - a header that ALREADY CARRIES A HEADLINE, i.e. one built on the old baked\n * plate. Stamping those prints the new headline directly over the baked one\n * and the two overprint into unreadable pulp — which shipped in a real\n * announcement on 2026-08-24. An earlier version of this comment claimed\n * such headers were safe because they'd \"keep their baked headline\"; that\n * was wrong. They are canvas-sized, so nothing stopped them being stamped.\n *\n * The band is empty on a clean-plate header and holds ~62k red px on a baked\n * one, so the check is unambiguous rather than a tuned threshold. A skipped\n * header still goes out looking correct (its baked headline is a real headline)\n * and self-heals on the site's next draft, which regenerates it clean;\n * `header-image --all --force` does the whole fleet at once.\n */\nexport async function applyReportTypeHeadline(\n header: Uint8Array,\n reportType: string,\n): Promise<Uint8Array> {\n const kind = headlineKindFor(reportType);\n if (!kind) return header;\n // Best-effort by design: the headline is cosmetic, so anything unexpected —\n // undecodable bytes included — warns and sends the header as stored rather\n // than failing the send. Mandatory processing (and its hard failures) lives\n // in prepareHeaderImage downstream.\n try {\n const meta = await sharp(Buffer.from(header)).metadata();\n if (meta.width !== CANVAS.width || meta.height !== CANVAS.height) {\n console.warn(\n `⚑ header headline skipped: stored header is ${meta.width}x${meta.height}, not ${CANVAS.width}x${CANVAS.height} — sending it as stored`,\n );\n return header;\n }\n const existing = await headlineInkCount(header);\n if (existing > HEADLINE_INK_TOLERANCE) {\n console.warn(\n `⚑ header headline skipped: the stored header already carries a headline (${existing} ink px in the band) — it predates the clean-plate switch, so stamping would overprint. Sending it as stored; run \\`header-image ${\"<site>\"} --write-airtable\\` to regenerate it clean.`,\n );\n return header;\n }\n return await stampHeadline(header, await loadHeadline(kind));\n } catch (err) {\n console.warn(\n `⚑ header headline skipped (${err instanceof Error ? err.message : String(err)}) — sending the header as stored`,\n );\n return header;\n }\n}\n\n/** Capture a site's homepage and composite its header image. */\nexport async function generateHeaderImage(input: GenerateInput): Promise<GeneratedHeaderImage> {\n const screenshot = await captureHomepage(input.url, {\n ...(input.shooter !== undefined ? { shooter: input.shooter } : {}),\n ...(input.settleMs !== undefined ? { settleMs: input.settleMs } : {}),\n });\n await assertNotBlank(screenshot);\n\n const domain = domainFromUrl(input.url);\n const bytes = await composeHeaderImage({\n plate: await loadPlate(),\n screenshot,\n domain,\n });\n const base = input.slug ?? domain.split(\".\")[0];\n return { bytes, domain, filename: `${base}Header.jpg`, contentType: \"image/jpeg\" };\n}\n","/** Viewport matching the plate's MacBook screen aspect (16:10), so the crop in\n * compose.ts is a no-op for a well-behaved homepage. */\nconst VIEWPORT = { width: 1600, height: 1000 } as const;\n/** 2x so the 1349px-wide screen rect is fed real pixels, not upscaled ones. */\nconst DEVICE_SCALE_FACTOR = 2;\n/** Entrance animations and webfonts settle before the shutter. Measured against\n * live fleet sites; overridable per site because consent gates and long\n * animations vary. */\nconst DEFAULT_SETTLE_MS = 2500;\n/** Budget for the `load` milestone — the navigation we actually gate on.\n *\n * Do NOT put `waitUntil: \"networkidle\"` back on the `goto`. Measured across all\n * 14 live fleet sites (30s timeout): `load` succeeded on 14/14 in 346–1205ms,\n * while `networkidle` NEVER fired on 4 of them (ERP Industrials, Vineyard,\n * Revogen, 1836dig) — chat widgets, analytics polling and websockets hold the\n * connection open forever, so `goto` threw and those sites could never get a\n * header image at all. */\nconst NAV_TIMEOUT_MS = 60_000;\n/** Best-effort extra wait for network idle, applied *after* `load` and with its\n * rejection swallowed. The 10 sites that do settle still get the benefit; the 4\n * that never will just proceed. 3s is sized from the measurement above: the\n * slowest site that does reach idle took 4078ms from `goto` start, and by the\n * time this runs the `load` milestone (~0.3–1.2s) has already passed, so 3s\n * covers the real remaining settle time without stalling the never-idle sites. */\nconst IDLE_BUDGET_MS = 3_000;\n\nexport type ShootOptions = {\n url: string;\n width: number;\n height: number;\n deviceScaleFactor: number;\n settleMs: number;\n};\n\n/** Injected browser IO. The real impl drives Playwright; tests pass a fake. */\nexport type Shooter = {\n shoot: (opts: ShootOptions) => Promise<Uint8Array>;\n};\n\nexport type CaptureOptions = {\n shooter?: Shooter;\n settleMs?: number;\n};\n\n/**\n * Screenshot a site's homepage for the header image.\n *\n * Viewport-only, never `fullPage`: the plate shows a laptop screen, not a\n * scroll. Failures propagate — a caller must be able to keep the site's existing\n * header rather than overwrite it with a broken shot.\n */\nexport async function captureHomepage(\n url: string,\n options: CaptureOptions = {},\n): Promise<Uint8Array> {\n const shooter = options.shooter ?? (await defaultShooter());\n return shooter.shoot({\n url,\n width: VIEWPORT.width,\n height: VIEWPORT.height,\n deviceScaleFactor: DEVICE_SCALE_FACTOR,\n settleMs: options.settleMs ?? DEFAULT_SETTLE_MS,\n });\n}\n\n/** Real Playwright shooter. Lazily imported so unit tests never load it and the\n * static import graph stays central-dep-free for `test:dist`. */\nexport async function defaultShooter(): Promise<Shooter> {\n const { chromium } = await import(\"@playwright/test\");\n return {\n async shoot(opts) {\n const browser = await chromium.launch();\n try {\n const page = await browser.newPage({\n viewport: { width: opts.width, height: opts.height },\n deviceScaleFactor: opts.deviceScaleFactor,\n });\n await page.goto(opts.url, { waitUntil: \"load\", timeout: NAV_TIMEOUT_MS });\n // Best-effort only — a site that never idles must still be captured.\n await page.waitForLoadState(\"networkidle\", { timeout: IDLE_BUDGET_MS }).catch(() => {});\n await page.evaluate(\"document.fonts && document.fonts.ready\");\n await page.waitForTimeout(opts.settleMs);\n const buf = await page.screenshot({ type: \"png\" });\n return new Uint8Array(buf);\n } finally {\n await browser.close();\n }\n },\n };\n}\n","import sharp from \"sharp\";\nimport type { OverlayOptions } from \"sharp\";\nimport {\n CANVAS,\n SCREEN,\n DOMAIN,\n PAPER,\n HEADLINE,\n HEADLINE_BAND,\n HEADLINE_INK,\n} from \"./geometry.js\";\n\nexport type ComposeInput = {\n /** The bundled 2400x3200 plate. */\n plate: Uint8Array;\n /** A homepage screenshot, any size. Cropped to fill the screen rect. */\n screenshot: Uint8Array;\n /** The site's public domain, drawn bottom-left (e.g. \"gallerysonder.com\"). */\n domain: string;\n};\n\n/** JPEG quality. The hand-made headers are ~2.4MB; 88 lands near 0.7MB with no\n * visible difference at the 600px the email actually renders. */\nconst JPEG_QUALITY = 88;\n\n/** Escape the five XML metacharacters. The domain is interpolated into an SVG,\n * and a bare `&` yields invalid XML that sharp rejects outright. */\nfunction escapeXml(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, \"&apos;\");\n}\n\n/**\n * The domain line as a transparent SVG overlay the size of the full canvas, so\n * it can be composited at 0,0 and positioned by its own coordinates.\n *\n * ⚠️ FONT AVAILABILITY IS ENVIRONMENT-DEPENDENT. sharp renders SVG through\n * librsvg + fontconfig, so the family below resolves against INSTALLED fonts.\n * On macOS it resolves to real Helvetica Neue, which reproduces the hand-made\n * reference exactly (verified: ink box dx=0 dy=0 dw=0 dh=0). A stock\n * ubuntu-latest runner has none of Helvetica Neue / Helvetica / Arial, so it\n * falls through to `sans-serif` — typically DejaVu Sans, which is wider and\n * would render a visibly different domain line.\n *\n * This matters because report drafting (which regenerates the header) runs on\n * ubuntu-latest in `.github/workflows/daily-reports.yml`. That workflow now\n * installs `fonts-urw-base35`, which provides Nimbus Sans — metrically\n * equivalent to Helvetica and third in the stack below — so a CI-generated\n * header's domain line matches a locally-generated one. Any OTHER environment\n * that runs this code still needs a Helvetica-metric font installed.\n */\nfunction domainSvg(domain: string): Buffer {\n return Buffer.from(\n `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"${CANVAS.width}\" height=\"${CANVAS.height}\">\n <text x=\"${DOMAIN.x}\" y=\"${DOMAIN.baseline}\"\n font-family=\"Helvetica Neue, Helvetica, Nimbus Sans, Arial, sans-serif\"\n font-size=\"${DOMAIN.size}\" font-weight=\"${DOMAIN.weight}\"\n fill=\"${DOMAIN.color}\">${escapeXml(domain)}</text>\n </svg>`,\n \"utf-8\",\n );\n}\n\n/**\n * Composite one site's header image: paste its homepage screenshot into the\n * plate's laptop screen and draw its domain underneath.\n *\n * PURE — no browser, no network, no filesystem. Fidelity lives here, so it must\n * be verifiable in a unit test.\n *\n * The screenshot is cropped to FILL the 16:10 screen from the top: a taller\n * homepage loses its fold rather than being squashed, which would be obvious\n * and wrong. `flatten` guards against a transparent source punching a hole in\n * the plate.\n */\nexport async function composeHeaderImage(input: ComposeInput): Promise<Uint8Array> {\n const screen = await sharp(Buffer.from(input.screenshot))\n .resize(SCREEN.w, SCREEN.h, { fit: \"cover\", position: \"top\" })\n .flatten({ background: PAPER })\n .toBuffer();\n\n const layers: OverlayOptions[] = [{ input: screen, left: SCREEN.x, top: SCREEN.y }];\n if (input.domain.trim().length > 0) {\n layers.push({ input: domainSvg(input.domain), left: 0, top: 0 });\n }\n\n const out = await sharp(Buffer.from(input.plate))\n .composite(layers)\n .jpeg({ quality: JPEG_QUALITY })\n .toBuffer();\n return new Uint8Array(out);\n}\n\n/**\n * Count headline-red pixels already present in the headline band.\n *\n * A header composed on the clean plate has EXACTLY ZERO (measured), because the\n * band sits between the logo and the laptop and nothing else paints there. A\n * header built on the old baked plate has ~62,000 — its headline is part of the\n * image. That gap is what lets {@link stampHeadline}'s caller refuse to print a\n * second headline over the first. PURE.\n */\nexport async function headlineInkCount(header: Uint8Array): Promise<number> {\n const { data, info } = await sharp(Buffer.from(header))\n .extract({\n left: HEADLINE_BAND.x,\n top: HEADLINE_BAND.y,\n width: HEADLINE_BAND.w,\n height: HEADLINE_BAND.h,\n })\n .removeAlpha()\n .raw()\n .toBuffer({ resolveWithObject: true });\n let n = 0;\n for (let i = 0; i < data.length; i += info.channels) {\n const r = data[i] ?? 0;\n const g = data[i + 1] ?? 0;\n const b = data[i + 2] ?? 0;\n // Generous band around the brand red rather than an exact match: the stored\n // header is a JPEG, so its ink has been through lossy re-encoding.\n if (r > HEADLINE_INK.r - 60 && g < HEADLINE_INK.g + 60 && b < HEADLINE_INK.b + 60) n++;\n }\n return n;\n}\n\n/**\n * Stamp a report-type headline overlay onto a finished (clean-plate) header at\n * the measured HEADLINE origin. PURE and strict: the header must be exactly\n * CANVAS-sized — a legacy hand-made header at other dimensions would misplace\n * the text, so the caller (applyReportTypeHeadline) size-guards and skips\n * instead of calling in. One extra JPEG generation at quality 88 is invisible\n * at the email's 600px display size.\n */\nexport async function stampHeadline(header: Uint8Array, headline: Uint8Array): Promise<Uint8Array> {\n const meta = await sharp(Buffer.from(header)).metadata();\n if (meta.width !== CANVAS.width || meta.height !== CANVAS.height) {\n throw new Error(\n `stampHeadline: header is ${meta.width}x${meta.height}, expected ${CANVAS.width}x${CANVAS.height}`,\n );\n }\n const out = await sharp(Buffer.from(header))\n .composite([{ input: Buffer.from(headline), left: HEADLINE.x, top: HEADLINE.y }])\n .jpeg({ quality: JPEG_QUALITY })\n .toBuffer();\n return new Uint8Array(out);\n}\n","// Measured off the real plate, not guessed.\n//\n// SCREEN is the hole INSIDE the laptop bezel. It is derived from the bezel —\n// a perfectly flat black frame (luminance 0.0) — because that is\n// content-independent. Deriving it from content instead (diffing two headers for\n// pixels that vary) yields x=282 y=1856 w=1394 h=871, which is WRONG: that rect\n// spans the laptop's outer panel, covering the bezel on three sides while\n// falling ~29px short at the bottom, so the plate's baked-in screenshot shows\n// as a strip under every site's content.\n//\n// Cross-checked: the photo-to-flat-black transition at the bottom of the plate\n// lands at y=2756, exactly SCREEN.y + SCREEN.h - 1.\n//\n// Re-measure only if the Figma template changes — and if you do, find the bezel.\n\n/** The plate's pixel dimensions — Figma frame 600x800 exported at 4x. */\nexport const CANVAS = { width: 2400, height: 3200 } as const;\n\n/** The MacBook Pro screen the site screenshot is pasted into — the area inside\n * the bezel, so the bezel stays visible around the content exactly as it does\n * in the hand-made headers. Aspect 1.5983 (16:10 to within 0.1%). */\nexport const SCREEN = { x: 302, y: 1913, w: 1349, h: 844 } as const;\n\n/** Where a report-type headline overlay lands on the clean plate.\n *\n * Measured, not guessed: red ink present in the baked maintenance plate but\n * absent from plate-clean spans x=277 y=998 w=1326 h=658 — and\n * headline-maintenance.png is that ink box cropped to its own edges\n * (1328x660, ±1px of anti-aliasing at the threshold), so compositing the\n * asset at this origin reproduces the baked plate's headline exactly. */\nexport const HEADLINE = { x: 277, y: 998 } as const;\n\n/** The band a headline occupies, sized to the WIDEST registered overlay\n * (Testing, 1715x664). Used to detect a header that already carries a baked-in\n * headline, so it can't be stamped a second time. Deliberately clear of the\n * logo above (ends y≈506) and the laptop screen below (starts y=1913), so the\n * only ink that can land here is a headline. */\nexport const HEADLINE_BAND = { x: HEADLINE.x, y: HEADLINE.y, w: 1715, h: 664 } as const;\n\n/** The headline ink colour (brand red), and how far a pixel may drift from it\n * and still count — JPEG re-encoding moves it by a level or two. */\nexport const HEADLINE_INK = { r: 209, g: 34, b: 62 } as const;\n\n/** The per-site domain line, bottom left.\n *\n * Solved against the hand-made reference rather than guessed: rendering\n * \"gallerysonder.com\" with these values reproduces Sonder's original ink box\n * (x=283 y=2963 w=645 h=74) EXACTLY — dx=0 dy=0 dw=0 dh=0. A sweep over sizes\n * 62-88 at weights 300/400 has a single exact solution at size 80 / weight 400.\n *\n * `baseline` is the text baseline in canvas pixels; the ink box sits above it\n * by the cap height and below by the descender. */\nexport const DOMAIN = {\n x: 280,\n baseline: 3020,\n size: 80,\n weight: 400,\n color: \"#747474\",\n} as const;\n\n/** Flat paper tone, used as the compositing background so a transparent or\n * short source can never punch a hole in the plate. */\nexport const PAPER = \"#fcfcfc\" as const;\n"],"mappings":"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","names":["sharp","sharp"]}
@@ -12,10 +12,7 @@ import {
12
12
  } from "./chunk-GASBX52O.js";
13
13
  import {
14
14
  makeGitHub
15
- } from "./chunk-VRWUL4TR.js";
16
- import {
17
- siteLabel
18
- } from "./chunk-XXTZBPUY.js";
15
+ } from "./chunk-XHHMM5HX.js";
19
16
  import {
20
17
  branchName,
21
18
  checkoutBranch,
@@ -28,6 +25,9 @@ import {
28
25
  parseOwnerRepo,
29
26
  push
30
27
  } from "./chunk-XTK5VIQB.js";
28
+ import {
29
+ siteLabel
30
+ } from "./chunk-XXTZBPUY.js";
31
31
 
32
32
  // src/recipes/self-updating/index.ts
33
33
  import { mkdir, writeFile } from "fs/promises";
@@ -175,4 +175,4 @@ async function selfUpdating(site, deps = {}) {
175
175
  export {
176
176
  selfUpdating
177
177
  };
178
- //# sourceMappingURL=chunk-THEFTZ7F.js.map
178
+ //# sourceMappingURL=chunk-62CMMGEO.js.map
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  isHttpUrl
3
- } from "./chunk-U5SZ4FOP.js";
3
+ } from "./chunk-EVBCK2KC.js";
4
4
 
5
5
  // src/inventory/local.ts
6
6
  import { basename } from "path";
@@ -67,4 +67,4 @@ export {
67
67
  localPath,
68
68
  fromJsonFile
69
69
  };
70
- //# sourceMappingURL=chunk-FDIFTIXC.js.map
70
+ //# sourceMappingURL=chunk-6TWWY7ZU.js.map