@reddoorla/maintenance 0.75.1 → 0.78.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 (157) hide show
  1. package/README.md +34 -7
  2. package/dist/{announce-V6EYPPUR.js → announce-RUT247HS.js} +9 -6
  3. package/dist/{announce-V6EYPPUR.js.map → announce-RUT247HS.js.map} +1 -1
  4. package/dist/{blux-C24OVDDW.js → blux-2P4FGCUL.js} +1867 -277
  5. package/dist/blux-2P4FGCUL.js.map +1 -0
  6. package/dist/{bump-deps-OQR3IXLN.js → bump-deps-XB2LJTVN.js} +3 -3
  7. package/dist/{chunk-RHPDQW6D.js → chunk-2SLED3NB.js} +1 -1
  8. package/dist/{chunk-RHPDQW6D.js.map → chunk-2SLED3NB.js.map} +1 -1
  9. package/dist/chunk-37S6L6G4.js +170 -0
  10. package/dist/chunk-37S6L6G4.js.map +1 -0
  11. package/dist/{chunk-HIN3P4ZN.js → chunk-4SOCPK7A.js} +10 -1
  12. package/dist/chunk-4SOCPK7A.js.map +1 -0
  13. package/dist/{run-migration-LJNNZWIV.js → chunk-6DOFNDXU.js} +5 -3
  14. package/dist/chunk-6DOFNDXU.js.map +1 -0
  15. package/dist/{chunk-NUIBQPB3.js → chunk-6SZBNALD.js} +62 -9
  16. package/dist/chunk-6SZBNALD.js.map +1 -0
  17. package/dist/{chunk-CASCWQQX.js → chunk-73UFRZKY.js} +34 -19
  18. package/dist/chunk-73UFRZKY.js.map +1 -0
  19. package/dist/{chunk-D3DQFC32.js → chunk-ATJ6ZA2V.js} +2 -2
  20. package/dist/{chunk-ZO7WNY57.js → chunk-B5ZUQZFL.js} +2 -2
  21. package/dist/{chunk-PH3AGF4Z.js → chunk-DC5H7KEQ.js} +2 -2
  22. package/dist/chunk-DRDAEELJ.js +197 -0
  23. package/dist/chunk-DRDAEELJ.js.map +1 -0
  24. package/dist/{chunk-IDBMYDVH.js → chunk-FVVGDIGX.js} +6 -4
  25. package/dist/{chunk-IDBMYDVH.js.map → chunk-FVVGDIGX.js.map} +1 -1
  26. package/dist/{chunk-7MJBTV2R.js → chunk-FX7RAMGC.js} +2 -2
  27. package/dist/{chunk-2AUUAJ5X.js → chunk-GWAOX3CU.js} +9 -9
  28. package/dist/{chunk-PKVZMFEB.js → chunk-K5JZ5KLO.js} +2 -2
  29. package/dist/{chunk-6OYICQSJ.js → chunk-KPQXB5CR.js} +9 -4
  30. package/dist/chunk-KPQXB5CR.js.map +1 -0
  31. package/dist/{chunk-BTEBRTW4.js → chunk-LS6QPANO.js} +2 -2
  32. package/dist/{chunk-2X322ZES.js → chunk-MGJ72BA4.js} +2 -2
  33. package/dist/chunk-MNDIED4M.js +137 -0
  34. package/dist/chunk-MNDIED4M.js.map +1 -0
  35. package/dist/{chunk-JCITWZQR.js → chunk-MNE374CZ.js} +2 -50
  36. package/dist/chunk-MNE374CZ.js.map +1 -0
  37. package/dist/chunk-MPBXOJ3N.js +51 -0
  38. package/dist/chunk-MPBXOJ3N.js.map +1 -0
  39. package/dist/{chunk-ZYBOT5PM.js → chunk-NT4WIP7I.js} +2 -2
  40. package/dist/{chunk-JNXCDUYB.js → chunk-Q276MZ7E.js} +3 -4
  41. package/dist/chunk-Q276MZ7E.js.map +1 -0
  42. package/dist/{chunk-GYNRZCM6.js → chunk-QG6OSL4M.js} +2 -2
  43. package/dist/{chunk-IRRZG66U.js → chunk-QMXQUL5Z.js} +34 -18
  44. package/dist/chunk-QMXQUL5Z.js.map +1 -0
  45. package/dist/{chunk-I55YWV6T.js → chunk-QWFHC3VH.js} +3 -3
  46. package/dist/{chunk-KLVMJYZZ.js → chunk-SEQA7MC3.js} +2 -2
  47. package/dist/chunk-UWJZXL2D.js +9 -0
  48. package/dist/chunk-UWJZXL2D.js.map +1 -0
  49. package/dist/{chunk-YEGHUODU.js → chunk-VSHC4HOI.js} +96 -1
  50. package/dist/chunk-VSHC4HOI.js.map +1 -0
  51. package/dist/{chunk-HBFMTM2E.js → chunk-YIQ7IWE3.js} +3 -3
  52. package/dist/{chunk-TDG5Z5OT.js → chunk-YSPA5AUT.js} +86 -13
  53. package/dist/chunk-YSPA5AUT.js.map +1 -0
  54. package/dist/{chunk-XKOZUVK2.js → chunk-ZTPPCHDB.js} +1 -1
  55. package/dist/chunk-ZTPPCHDB.js.map +1 -0
  56. package/dist/cli/bin.js +62 -24
  57. package/dist/cli/bin.js.map +1 -1
  58. package/dist/cli/commands/audit.d.ts +1 -1
  59. package/dist/cli/commands/audit.js +5 -5
  60. package/dist/client-X5WUJWDE.js +11 -0
  61. package/dist/configs/eslint.js +10 -0
  62. package/dist/configs/eslint.js.map +1 -1
  63. package/dist/{convert-to-pnpm-OVMASATZ.js → convert-to-pnpm-FZHSLUQB.js} +3 -3
  64. package/dist/{db-3QW2XEXA.js → db-JSIZYT23.js} +3 -3
  65. package/dist/{digest-LEMZS5ZK.js → digest-NQAQI34P.js} +16 -13
  66. package/dist/{digest-LEMZS5ZK.js.map → digest-NQAQI34P.js.map} +1 -1
  67. package/dist/favicon-SRHSJZ62.js +42 -0
  68. package/dist/favicon-SRHSJZ62.js.map +1 -0
  69. package/dist/forms/index.js.map +1 -1
  70. package/dist/freeze-Y5OJFOST.js +442 -0
  71. package/dist/freeze-Y5OJFOST.js.map +1 -0
  72. package/dist/{github-signals-TF3VSNIN.js → github-signals-4LWQGD5V.js} +5 -5
  73. package/dist/header-image-H4N54DHN.js +95 -0
  74. package/dist/header-image-H4N54DHN.js.map +1 -0
  75. package/dist/{health-endpoint-JZGYCMTV.js → health-endpoint-LFVE26QS.js} +3 -3
  76. package/dist/index.d.ts +65 -3
  77. package/dist/index.js +86 -31
  78. package/dist/index.js.map +1 -1
  79. package/dist/{init-Y2DMKKE7.js → init-KF4QHMWQ.js} +12 -12
  80. package/dist/{launch-ZDX7UA3Y.js → launch-CVCIP5NU.js} +11 -8
  81. package/dist/launch-CVCIP5NU.js.map +1 -0
  82. package/dist/migrate-LJUFGXQZ.js +7 -0
  83. package/dist/{onboard-PX36YDZA.js → onboard-RDGHVCNG.js} +3 -3
  84. package/dist/{orchestrate-NYRYEKQM.js → orchestrate-QOQXOJLI.js} +7 -6
  85. package/dist/{preflight-JCQFKB6M.js → preflight-2PWVVIRR.js} +8 -7
  86. package/dist/{preflight-JCQFKB6M.js.map → preflight-2PWVVIRR.js.map} +1 -1
  87. package/dist/protection-audit-X3BHT2MB.js +140 -0
  88. package/dist/protection-audit-X3BHT2MB.js.map +1 -0
  89. package/dist/recipes/sync-configs.d.ts +1 -1
  90. package/dist/recipes/sync-configs.js +3 -3
  91. package/dist/{renovate-dispatch-6II2JVOX.js → renovate-dispatch-2VYCEH42.js} +2 -2
  92. package/dist/renovate-dispatch-2VYCEH42.js.map +1 -0
  93. package/dist/{report-POHQEV6F.js → report-IU4CLJS5.js} +8 -6
  94. package/dist/{report-POHQEV6F.js.map → report-IU4CLJS5.js.map} +1 -1
  95. package/dist/reports/header-image/assets/plate.png +0 -0
  96. package/dist/run-migration-WP57U5XO.js +9 -0
  97. package/dist/run-migration-WP57U5XO.js.map +1 -0
  98. package/dist/{self-updating-2YSVNMPP.js → self-updating-ALMUWJNX.js} +5 -4
  99. package/dist/{self-updating-2YSVNMPP.js.map → self-updating-ALMUWJNX.js.map} +1 -1
  100. package/dist/{selftest-OO2WDSDM.js → selftest-PJFABZDM.js} +11 -9
  101. package/dist/{selftest-OO2WDSDM.js.map → selftest-PJFABZDM.js.map} +1 -1
  102. package/dist/{smoke-suite-CSP23NZM.js → smoke-suite-SUEJFBYP.js} +3 -3
  103. package/dist/{submissions-MDKWJYMM.js → submissions-3I3GAQAX.js} +13 -5
  104. package/dist/submissions-3I3GAQAX.js.map +1 -0
  105. package/dist/{submissions-BNRGY3JQ.js → submissions-SURN7BV5.js} +3 -3
  106. package/dist/{svelte-codemods-V54KOBWU.js → svelte-codemods-IWO7X3F3.js} +3 -3
  107. package/dist/{sync-configs-UBCJRASZ.js → sync-configs-7XGHFC6P.js} +4 -4
  108. package/dist/{types-0jreMCF5.d.ts → types-DnEL3NRn.d.ts} +1 -1
  109. package/dist/{upgrade-WR6ICPAG.js → upgrade-AM4S7JF3.js} +3 -3
  110. package/dist/webflow-624FEBFD.js +548 -0
  111. package/dist/webflow-624FEBFD.js.map +1 -0
  112. package/package.json +3 -3
  113. package/dist/blux-C24OVDDW.js.map +0 -1
  114. package/dist/chunk-6OYICQSJ.js.map +0 -1
  115. package/dist/chunk-CASCWQQX.js.map +0 -1
  116. package/dist/chunk-HIN3P4ZN.js.map +0 -1
  117. package/dist/chunk-IRRZG66U.js.map +0 -1
  118. package/dist/chunk-JCITWZQR.js.map +0 -1
  119. package/dist/chunk-JNXCDUYB.js.map +0 -1
  120. package/dist/chunk-NUIBQPB3.js.map +0 -1
  121. package/dist/chunk-TDG5Z5OT.js.map +0 -1
  122. package/dist/chunk-XKOZUVK2.js.map +0 -1
  123. package/dist/chunk-YEGHUODU.js.map +0 -1
  124. package/dist/client-NVVQK3HH.js +0 -11
  125. package/dist/launch-ZDX7UA3Y.js.map +0 -1
  126. package/dist/migrate-35DNNF3H.js +0 -7
  127. package/dist/renovate-dispatch-6II2JVOX.js.map +0 -1
  128. package/dist/run-migration-LJNNZWIV.js.map +0 -1
  129. package/dist/submissions-MDKWJYMM.js.map +0 -1
  130. /package/dist/{bump-deps-OQR3IXLN.js.map → bump-deps-XB2LJTVN.js.map} +0 -0
  131. /package/dist/{chunk-D3DQFC32.js.map → chunk-ATJ6ZA2V.js.map} +0 -0
  132. /package/dist/{chunk-ZO7WNY57.js.map → chunk-B5ZUQZFL.js.map} +0 -0
  133. /package/dist/{chunk-PH3AGF4Z.js.map → chunk-DC5H7KEQ.js.map} +0 -0
  134. /package/dist/{chunk-7MJBTV2R.js.map → chunk-FX7RAMGC.js.map} +0 -0
  135. /package/dist/{chunk-2AUUAJ5X.js.map → chunk-GWAOX3CU.js.map} +0 -0
  136. /package/dist/{chunk-PKVZMFEB.js.map → chunk-K5JZ5KLO.js.map} +0 -0
  137. /package/dist/{chunk-BTEBRTW4.js.map → chunk-LS6QPANO.js.map} +0 -0
  138. /package/dist/{chunk-2X322ZES.js.map → chunk-MGJ72BA4.js.map} +0 -0
  139. /package/dist/{chunk-ZYBOT5PM.js.map → chunk-NT4WIP7I.js.map} +0 -0
  140. /package/dist/{chunk-GYNRZCM6.js.map → chunk-QG6OSL4M.js.map} +0 -0
  141. /package/dist/{chunk-I55YWV6T.js.map → chunk-QWFHC3VH.js.map} +0 -0
  142. /package/dist/{chunk-KLVMJYZZ.js.map → chunk-SEQA7MC3.js.map} +0 -0
  143. /package/dist/{chunk-HBFMTM2E.js.map → chunk-YIQ7IWE3.js.map} +0 -0
  144. /package/dist/{client-NVVQK3HH.js.map → client-X5WUJWDE.js.map} +0 -0
  145. /package/dist/{convert-to-pnpm-OVMASATZ.js.map → convert-to-pnpm-FZHSLUQB.js.map} +0 -0
  146. /package/dist/{db-3QW2XEXA.js.map → db-JSIZYT23.js.map} +0 -0
  147. /package/dist/{github-signals-TF3VSNIN.js.map → github-signals-4LWQGD5V.js.map} +0 -0
  148. /package/dist/{health-endpoint-JZGYCMTV.js.map → health-endpoint-LFVE26QS.js.map} +0 -0
  149. /package/dist/{init-Y2DMKKE7.js.map → init-KF4QHMWQ.js.map} +0 -0
  150. /package/dist/{migrate-35DNNF3H.js.map → migrate-LJUFGXQZ.js.map} +0 -0
  151. /package/dist/{onboard-PX36YDZA.js.map → onboard-RDGHVCNG.js.map} +0 -0
  152. /package/dist/{orchestrate-NYRYEKQM.js.map → orchestrate-QOQXOJLI.js.map} +0 -0
  153. /package/dist/{smoke-suite-CSP23NZM.js.map → smoke-suite-SUEJFBYP.js.map} +0 -0
  154. /package/dist/{submissions-BNRGY3JQ.js.map → submissions-SURN7BV5.js.map} +0 -0
  155. /package/dist/{svelte-codemods-V54KOBWU.js.map → svelte-codemods-IWO7X3F3.js.map} +0 -0
  156. /package/dist/{sync-configs-UBCJRASZ.js.map → sync-configs-7XGHFC6P.js.map} +0 -0
  157. /package/dist/{upgrade-WR6ICPAG.js.map → upgrade-AM4S7JF3.js.map} +0 -0
@@ -11,8 +11,8 @@ import {
11
11
  } from "./chunk-4BHNP6Y2.js";
12
12
  import {
13
13
  bumpDeps
14
- } from "./chunk-D3DQFC32.js";
15
- import "./chunk-XKOZUVK2.js";
14
+ } from "./chunk-ATJ6ZA2V.js";
15
+ import "./chunk-ZTPPCHDB.js";
16
16
  import "./chunk-FDIFTIXC.js";
17
17
  import "./chunk-HVOOCK6L.js";
18
18
  import "./chunk-XXTZBPUY.js";
@@ -57,4 +57,4 @@ async function runBumpDepsCommand(site, opts) {
57
57
  export {
58
58
  runBumpDepsCommand
59
59
  };
60
- //# sourceMappingURL=bump-deps-OQR3IXLN.js.map
60
+ //# sourceMappingURL=bump-deps-XB2LJTVN.js.map
@@ -26,4 +26,4 @@ export {
26
26
  toStatus,
27
27
  toNotifyStatus
28
28
  };
29
- //# sourceMappingURL=chunk-RHPDQW6D.js.map
29
+ //# sourceMappingURL=chunk-2SLED3NB.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/reports/submission-row.ts"],"sourcesContent":["import { SUBMISSION_FORM_TYPES, type FormType } from \"../forms/types.js\";\n\nexport { SUBMISSION_FORM_TYPES };\nexport type { FormType };\n\nexport const SUBMISSION_STATUSES = [\"new\", \"read\", \"archived\", \"spam\", \"spam_auto\"] as const;\nexport type SubmissionStatus = (typeof SUBMISSION_STATUSES)[number];\n\n// \"bounced\" (2026-07-16): Resend ACCEPTED the notification (\"sent\") but later\n// reported a bounce/complaint via webhook — the lead never reached the client\n// (the Espada failure mode: 4 of 8 notifications bounced with nothing alarming).\n// The resend-webhook maps the event back onto the submission by resend_message_id.\nexport const NOTIFY_STATUSES = [\"sent\", \"failed\", \"skipped\", \"bounced\"] as const;\nexport type NotifyStatus = (typeof NOTIFY_STATUSES)[number];\n\nexport function toFormType(raw: string | undefined): FormType {\n if (raw && (SUBMISSION_FORM_TYPES as readonly string[]).includes(raw)) return raw as FormType;\n if (raw)\n console.warn(`[submissions] unknown Form type ${JSON.stringify(raw)} — treating as contact`);\n return \"contact\";\n}\n\nexport function toStatus(raw: string | undefined): SubmissionStatus {\n if (raw && (SUBMISSION_STATUSES as readonly string[]).includes(raw))\n return raw as SubmissionStatus;\n return \"new\";\n}\n\nexport function toNotifyStatus(raw: string | undefined): NotifyStatus {\n if (raw && (NOTIFY_STATUSES as readonly string[]).includes(raw)) return raw as NotifyStatus;\n return \"skipped\";\n}\n\nexport type SubmissionRow = {\n id: string;\n submissionId: number | null;\n siteId: string;\n formType: FormType;\n name: string;\n email: string;\n phone: string | null;\n message: string | null;\n /** Raw JSON string of any site-specific fields the typed columns didn't claim. */\n extraFields: string | null;\n sourceUrl: string | null;\n utm: string | null;\n submittedAt: string | null;\n status: SubmissionStatus;\n notifyStatus: NotifyStatus;\n resendMessageId: string | null;\n /** Heuristic spam score at ingest time; null for pre-classifier / un-scored rows. */\n spamScore?: number | null;\n /** Comma-joined classifier reason codes (e.g. \"links:3,disposable-email\"); null when unscored. */\n spamReason?: string | null;\n};\n\nexport type SubmissionInput = {\n siteId: string;\n formType: FormType;\n name: string;\n email: string;\n phone?: string;\n message?: string;\n extraFields?: Record<string, unknown>;\n sourceUrl?: string;\n utm?: string;\n submittedAt: Date;\n status?: SubmissionStatus;\n spamScore?: number | null;\n spamReason?: string | null;\n};\n"],"mappings":";;;;;AAKO,IAAM,sBAAsB,CAAC,OAAO,QAAQ,YAAY,QAAQ,WAAW;AAO3E,IAAM,kBAAkB,CAAC,QAAQ,UAAU,WAAW,SAAS;AAG/D,SAAS,WAAW,KAAmC;AAC5D,MAAI,OAAQ,sBAA4C,SAAS,GAAG,EAAG,QAAO;AAC9E,MAAI;AACF,YAAQ,KAAK,mCAAmC,KAAK,UAAU,GAAG,CAAC,6BAAwB;AAC7F,SAAO;AACT;AAEO,SAAS,SAAS,KAA2C;AAClE,MAAI,OAAQ,oBAA0C,SAAS,GAAG;AAChE,WAAO;AACT,SAAO;AACT;AAEO,SAAS,eAAe,KAAuC;AACpE,MAAI,OAAQ,gBAAsC,SAAS,GAAG,EAAG,QAAO;AACxE,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../src/reports/submission-row.ts"],"sourcesContent":["import { SUBMISSION_FORM_TYPES, type FormType } from \"../forms/types.js\";\n\nexport { SUBMISSION_FORM_TYPES };\nexport type { FormType };\n\nexport const SUBMISSION_STATUSES = [\"new\", \"read\", \"archived\", \"spam\", \"spam_auto\"] as const;\nexport type SubmissionStatus = (typeof SUBMISSION_STATUSES)[number];\n\n// \"bounced\" (2026-07-16): Resend ACCEPTED the notification (\"sent\") but later\n// reported a bounce/complaint via webhook — the lead never reached the client\n// (the Espada failure mode: 4 of 8 notifications bounced with nothing alarming).\n// The resend-webhook maps the event back onto the submission by resend_message_id.\nexport const NOTIFY_STATUSES = [\"sent\", \"failed\", \"skipped\", \"bounced\"] as const;\nexport type NotifyStatus = (typeof NOTIFY_STATUSES)[number];\n\nexport function toFormType(raw: string | undefined): FormType {\n if (raw && (SUBMISSION_FORM_TYPES as readonly string[]).includes(raw)) return raw as FormType;\n if (raw)\n console.warn(`[submissions] unknown Form type ${JSON.stringify(raw)} — treating as contact`);\n return \"contact\";\n}\n\nexport function toStatus(raw: string | undefined): SubmissionStatus {\n if (raw && (SUBMISSION_STATUSES as readonly string[]).includes(raw))\n return raw as SubmissionStatus;\n return \"new\";\n}\n\nexport function toNotifyStatus(raw: string | undefined): NotifyStatus {\n if (raw && (NOTIFY_STATUSES as readonly string[]).includes(raw)) return raw as NotifyStatus;\n return \"skipped\";\n}\n\nexport type SubmissionRow = {\n id: string;\n submissionId: number | null;\n siteId: string;\n formType: FormType;\n name: string;\n email: string;\n phone: string | null;\n message: string | null;\n /** Raw JSON string of any site-specific fields the typed columns didn't claim. */\n extraFields: string | null;\n sourceUrl: string | null;\n utm: string | null;\n submittedAt: string | null;\n status: SubmissionStatus;\n notifyStatus: NotifyStatus;\n resendMessageId: string | null;\n /** Heuristic spam score at ingest time; null for pre-classifier / un-scored rows. */\n spamScore?: number | null;\n /** Comma-joined classifier reason codes (e.g. \"links:3,disposable-email\"); null when unscored. */\n spamReason?: string | null;\n /** Comma-joined newsletter fan-out outcome per destination — `webhook:ok`,\n * `mailchimp:401`, `mailchimp:threw`, `mailchimp-tags:failed`. Null means nothing\n * was attempted: a non-newsletter form, a spam row, a site with no destination\n * configured, or a row written before 0005. See ingest.ts for the token grammar. */\n fanoutStatus?: string | null;\n};\n\nexport type SubmissionInput = {\n siteId: string;\n formType: FormType;\n name: string;\n email: string;\n phone?: string;\n message?: string;\n extraFields?: Record<string, unknown>;\n sourceUrl?: string;\n utm?: string;\n submittedAt: Date;\n status?: SubmissionStatus;\n spamScore?: number | null;\n spamReason?: string | null;\n};\n"],"mappings":";;;;;AAKO,IAAM,sBAAsB,CAAC,OAAO,QAAQ,YAAY,QAAQ,WAAW;AAO3E,IAAM,kBAAkB,CAAC,QAAQ,UAAU,WAAW,SAAS;AAG/D,SAAS,WAAW,KAAmC;AAC5D,MAAI,OAAQ,sBAA4C,SAAS,GAAG,EAAG,QAAO;AAC9E,MAAI;AACF,YAAQ,KAAK,mCAAmC,KAAK,UAAU,GAAG,CAAC,6BAAwB;AAC7F,SAAO;AACT;AAEO,SAAS,SAAS,KAA2C;AAClE,MAAI,OAAQ,oBAA0C,SAAS,GAAG;AAChE,WAAO;AACT,SAAO;AACT;AAEO,SAAS,eAAe,KAAuC;AACpE,MAAI,OAAQ,gBAAsC,SAAS,GAAG,EAAG,QAAO;AACxE,SAAO;AACT;","names":[]}
@@ -0,0 +1,170 @@
1
+ // src/reports/header-image/assets/index.ts
2
+ import { readFile } from "fs/promises";
3
+ import { existsSync } from "fs";
4
+ import { dirname, join } from "path";
5
+ import { fileURLToPath } from "url";
6
+ var PLATE = "plate.png";
7
+ var cachedAssetsDir = null;
8
+ function resolveAssetsDir() {
9
+ if (cachedAssetsDir) return cachedAssetsDir;
10
+ let dir = dirname(fileURLToPath(import.meta.url));
11
+ for (; ; ) {
12
+ for (const layout of ["src", "dist"]) {
13
+ const candidate = join(dir, layout, "reports", "header-image", "assets", PLATE);
14
+ if (existsSync(candidate)) {
15
+ cachedAssetsDir = dirname(candidate);
16
+ return cachedAssetsDir;
17
+ }
18
+ }
19
+ const parent = dirname(dir);
20
+ if (parent === dir) {
21
+ throw new Error(
22
+ `loadPlate: could not locate the header-image assets dir by walking up from ${fileURLToPath(import.meta.url)}. Checked both src/ and dist/ layouts.`
23
+ );
24
+ }
25
+ dir = parent;
26
+ }
27
+ }
28
+ async function loadPlate() {
29
+ return new Uint8Array(await readFile(join(resolveAssetsDir(), PLATE)));
30
+ }
31
+
32
+ // src/reports/header-image/index.ts
33
+ import sharp2 from "sharp";
34
+
35
+ // src/reports/header-image/capture.ts
36
+ var VIEWPORT = { width: 1600, height: 1e3 };
37
+ var DEVICE_SCALE_FACTOR = 2;
38
+ var DEFAULT_SETTLE_MS = 2500;
39
+ var NAV_TIMEOUT_MS = 6e4;
40
+ var IDLE_BUDGET_MS = 3e3;
41
+ async function captureHomepage(url, options = {}) {
42
+ const shooter = options.shooter ?? await defaultShooter();
43
+ return shooter.shoot({
44
+ url,
45
+ width: VIEWPORT.width,
46
+ height: VIEWPORT.height,
47
+ deviceScaleFactor: DEVICE_SCALE_FACTOR,
48
+ settleMs: options.settleMs ?? DEFAULT_SETTLE_MS
49
+ });
50
+ }
51
+ async function defaultShooter() {
52
+ const { chromium } = await import("@playwright/test");
53
+ return {
54
+ async shoot(opts) {
55
+ const browser = await chromium.launch();
56
+ try {
57
+ const page = await browser.newPage({
58
+ viewport: { width: opts.width, height: opts.height },
59
+ deviceScaleFactor: opts.deviceScaleFactor
60
+ });
61
+ await page.goto(opts.url, { waitUntil: "load", timeout: NAV_TIMEOUT_MS });
62
+ await page.waitForLoadState("networkidle", { timeout: IDLE_BUDGET_MS }).catch(() => {
63
+ });
64
+ await page.evaluate("document.fonts && document.fonts.ready");
65
+ await page.waitForTimeout(opts.settleMs);
66
+ const buf = await page.screenshot({ type: "png" });
67
+ return new Uint8Array(buf);
68
+ } finally {
69
+ await browser.close();
70
+ }
71
+ }
72
+ };
73
+ }
74
+
75
+ // src/reports/header-image/compose.ts
76
+ import sharp from "sharp";
77
+
78
+ // src/reports/header-image/geometry.ts
79
+ var CANVAS = { width: 2400, height: 3200 };
80
+ var SCREEN = { x: 302, y: 1913, w: 1349, h: 844 };
81
+ var DOMAIN = {
82
+ x: 280,
83
+ baseline: 3020,
84
+ size: 80,
85
+ weight: 400,
86
+ color: "#747474"
87
+ };
88
+ var PAPER = "#fcfcfc";
89
+
90
+ // src/reports/header-image/compose.ts
91
+ var JPEG_QUALITY = 88;
92
+ function escapeXml(s) {
93
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
94
+ }
95
+ function domainSvg(domain) {
96
+ return Buffer.from(
97
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${CANVAS.width}" height="${CANVAS.height}">
98
+ <text x="${DOMAIN.x}" y="${DOMAIN.baseline}"
99
+ font-family="Helvetica Neue, Helvetica, Nimbus Sans, Arial, sans-serif"
100
+ font-size="${DOMAIN.size}" font-weight="${DOMAIN.weight}"
101
+ fill="${DOMAIN.color}">${escapeXml(domain)}</text>
102
+ </svg>`,
103
+ "utf-8"
104
+ );
105
+ }
106
+ async function composeHeaderImage(input) {
107
+ const screen = await sharp(Buffer.from(input.screenshot)).resize(SCREEN.w, SCREEN.h, { fit: "cover", position: "top" }).flatten({ background: PAPER }).toBuffer();
108
+ const layers = [{ input: screen, left: SCREEN.x, top: SCREEN.y }];
109
+ if (input.domain.trim().length > 0) {
110
+ layers.push({ input: domainSvg(input.domain), left: 0, top: 0 });
111
+ }
112
+ const out = await sharp(Buffer.from(input.plate)).composite(layers).jpeg({ quality: JPEG_QUALITY }).toBuffer();
113
+ return new Uint8Array(out);
114
+ }
115
+
116
+ // src/reports/header-image/index.ts
117
+ function domainFromUrl(url) {
118
+ return new URL(url).hostname.replace(/^www\./, "");
119
+ }
120
+ async function assertNotBlank(shot) {
121
+ const { data, info } = await sharp2(Buffer.from(shot)).resize(64, 40, { fit: "fill" }).removeAlpha().raw().toBuffer({ resolveWithObject: true });
122
+ const total = info.width * info.height;
123
+ const at = (i) => data[i] ?? 0;
124
+ let sumR = 0;
125
+ let sumG = 0;
126
+ let sumB = 0;
127
+ for (let i = 0; i < data.length; i += info.channels) {
128
+ sumR += at(i);
129
+ sumG += at(i + 1);
130
+ sumB += at(i + 2);
131
+ }
132
+ const meanR = sumR / total;
133
+ const meanG = sumG / total;
134
+ const meanB = sumB / total;
135
+ let near = 0;
136
+ for (let i = 0; i < data.length; i += info.channels) {
137
+ if (Math.abs(at(i) - meanR) < 8 && Math.abs(at(i + 1) - meanG) < 8 && Math.abs(at(i + 2) - meanB) < 8) {
138
+ near++;
139
+ }
140
+ }
141
+ const uniform = near / total > 0.95;
142
+ const nearWhite = meanR > 240 && meanG > 240 && meanB > 240;
143
+ if (uniform && nearWhite) {
144
+ throw new Error(
145
+ "header-image: capture looks blank (near-white, >95% one colour) \u2014 keeping the existing header"
146
+ );
147
+ }
148
+ }
149
+ async function generateHeaderImage(input) {
150
+ const screenshot = await captureHomepage(input.url, {
151
+ ...input.shooter !== void 0 ? { shooter: input.shooter } : {},
152
+ ...input.settleMs !== void 0 ? { settleMs: input.settleMs } : {}
153
+ });
154
+ await assertNotBlank(screenshot);
155
+ const domain = domainFromUrl(input.url);
156
+ const bytes = await composeHeaderImage({
157
+ plate: await loadPlate(),
158
+ screenshot,
159
+ domain
160
+ });
161
+ const base = input.slug ?? domain.split(".")[0];
162
+ return { bytes, domain, filename: `${base}Header.jpg`, contentType: "image/jpeg" };
163
+ }
164
+
165
+ export {
166
+ loadPlate,
167
+ domainFromUrl,
168
+ generateHeaderImage
169
+ };
170
+ //# sourceMappingURL=chunk-37S6L6G4.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.png\";\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 plate. */\nexport async function loadPlate(): Promise<Uint8Array> {\n return new Uint8Array(await readFile(join(resolveAssetsDir(), PLATE)));\n}\n","import sharp from \"sharp\";\nimport { captureHomepage, type Shooter } from \"./capture.js\";\nimport { composeHeaderImage } from \"./compose.js\";\nimport { loadPlate } from \"./assets/index.js\";\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/** 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 { CANVAS, SCREEN, DOMAIN, PAPER } 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","// 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/** 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":";AAAA,SAAS,gBAAgB;AACzB,SAAS,kBAAkB;AAC3B,SAAS,SAAS,YAAY;AAC9B,SAAS,qBAAqB;AAE9B,IAAM,QAAQ;AAUd,IAAI,kBAAiC;AACrC,SAAS,mBAA2B;AAClC,MAAI,gBAAiB,QAAO;AAC5B,MAAI,MAAM,QAAQ,cAAc,YAAY,GAAG,CAAC;AAChD,aAAS;AACP,eAAW,UAAU,CAAC,OAAO,MAAM,GAAG;AACpC,YAAM,YAAY,KAAK,KAAK,QAAQ,WAAW,gBAAgB,UAAU,KAAK;AAC9E,UAAI,WAAW,SAAS,GAAG;AACzB,0BAAkB,QAAQ,SAAS;AACnC,eAAO;AAAA,MACT;AAAA,IACF;AACA,UAAM,SAAS,QAAQ,GAAG;AAC1B,QAAI,WAAW,KAAK;AAClB,YAAM,IAAI;AAAA,QACR,8EAA8E,cAAc,YAAY,GAAG,CAAC;AAAA,MAC9G;AAAA,IACF;AACA,UAAM;AAAA,EACR;AACF;AAGA,eAAsB,YAAiC;AACrD,SAAO,IAAI,WAAW,MAAM,SAAS,KAAK,iBAAiB,GAAG,KAAK,CAAC,CAAC;AACvE;;;ACxCA,OAAOA,YAAW;;;ACElB,IAAM,WAAW,EAAE,OAAO,MAAM,QAAQ,IAAK;AAE7C,IAAM,sBAAsB;AAI5B,IAAM,oBAAoB;AAS1B,IAAM,iBAAiB;AAOvB,IAAM,iBAAiB;AA2BvB,eAAsB,gBACpB,KACA,UAA0B,CAAC,GACN;AACrB,QAAM,UAAU,QAAQ,WAAY,MAAM,eAAe;AACzD,SAAO,QAAQ,MAAM;AAAA,IACnB;AAAA,IACA,OAAO,SAAS;AAAA,IAChB,QAAQ,SAAS;AAAA,IACjB,mBAAmB;AAAA,IACnB,UAAU,QAAQ,YAAY;AAAA,EAChC,CAAC;AACH;AAIA,eAAsB,iBAAmC;AACvD,QAAM,EAAE,SAAS,IAAI,MAAM,OAAO,kBAAkB;AACpD,SAAO;AAAA,IACL,MAAM,MAAM,MAAM;AAChB,YAAM,UAAU,MAAM,SAAS,OAAO;AACtC,UAAI;AACF,cAAM,OAAO,MAAM,QAAQ,QAAQ;AAAA,UACjC,UAAU,EAAE,OAAO,KAAK,OAAO,QAAQ,KAAK,OAAO;AAAA,UACnD,mBAAmB,KAAK;AAAA,QAC1B,CAAC;AACD,cAAM,KAAK,KAAK,KAAK,KAAK,EAAE,WAAW,QAAQ,SAAS,eAAe,CAAC;AAExE,cAAM,KAAK,iBAAiB,eAAe,EAAE,SAAS,eAAe,CAAC,EAAE,MAAM,MAAM;AAAA,QAAC,CAAC;AACtF,cAAM,KAAK,SAAS,wCAAwC;AAC5D,cAAM,KAAK,eAAe,KAAK,QAAQ;AACvC,cAAM,MAAM,MAAM,KAAK,WAAW,EAAE,MAAM,MAAM,CAAC;AACjD,eAAO,IAAI,WAAW,GAAG;AAAA,MAC3B,UAAE;AACA,cAAM,QAAQ,MAAM;AAAA,MACtB;AAAA,IACF;AAAA,EACF;AACF;;;ACzFA,OAAO,WAAW;;;ACgBX,IAAM,SAAS,EAAE,OAAO,MAAM,QAAQ,KAAK;AAK3C,IAAM,SAAS,EAAE,GAAG,KAAK,GAAG,MAAM,GAAG,MAAM,GAAG,IAAI;AAWlD,IAAM,SAAS;AAAA,EACpB,GAAG;AAAA,EACH,UAAU;AAAA,EACV,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,OAAO;AACT;AAIO,IAAM,QAAQ;;;AD3BrB,IAAM,eAAe;AAIrB,SAAS,UAAU,GAAmB;AACpC,SAAO,EACJ,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,QAAQ,EACtB,QAAQ,MAAM,QAAQ;AAC3B;AAqBA,SAAS,UAAU,QAAwB;AACzC,SAAO,OAAO;AAAA,IACZ,kDAAkD,OAAO,KAAK,aAAa,OAAO,MAAM;AAAA,kBAC1E,OAAO,CAAC,QAAQ,OAAO,QAAQ;AAAA;AAAA,0BAEvB,OAAO,IAAI,kBAAkB,OAAO,MAAM;AAAA,qBAC/C,OAAO,KAAK,KAAK,UAAU,MAAM,CAAC;AAAA;AAAA,IAEnD;AAAA,EACF;AACF;AAcA,eAAsB,mBAAmB,OAA0C;AACjF,QAAM,SAAS,MAAM,MAAM,OAAO,KAAK,MAAM,UAAU,CAAC,EACrD,OAAO,OAAO,GAAG,OAAO,GAAG,EAAE,KAAK,SAAS,UAAU,MAAM,CAAC,EAC5D,QAAQ,EAAE,YAAY,MAAM,CAAC,EAC7B,SAAS;AAEZ,QAAM,SAA2B,CAAC,EAAE,OAAO,QAAQ,MAAM,OAAO,GAAG,KAAK,OAAO,EAAE,CAAC;AAClF,MAAI,MAAM,OAAO,KAAK,EAAE,SAAS,GAAG;AAClC,WAAO,KAAK,EAAE,OAAO,UAAU,MAAM,MAAM,GAAG,MAAM,GAAG,KAAK,EAAE,CAAC;AAAA,EACjE;AAEA,QAAM,MAAM,MAAM,MAAM,OAAO,KAAK,MAAM,KAAK,CAAC,EAC7C,UAAU,MAAM,EAChB,KAAK,EAAE,SAAS,aAAa,CAAC,EAC9B,SAAS;AACZ,SAAO,IAAI,WAAW,GAAG;AAC3B;;;AF9DO,SAAS,cAAc,KAAqB;AACjD,SAAO,IAAI,IAAI,GAAG,EAAE,SAAS,QAAQ,UAAU,EAAE;AACnD;AAeA,eAAe,eAAe,MAAiC;AAC7D,QAAM,EAAE,MAAM,KAAK,IAAI,MAAMC,OAAM,OAAO,KAAK,IAAI,CAAC,EACjD,OAAO,IAAI,IAAI,EAAE,KAAK,OAAO,CAAC,EAC9B,YAAY,EACZ,IAAI,EACJ,SAAS,EAAE,mBAAmB,KAAK,CAAC;AACvC,QAAM,QAAQ,KAAK,QAAQ,KAAK;AAChC,QAAM,KAAK,CAAC,MAAsB,KAAK,CAAC,KAAK;AAE7C,MAAI,OAAO;AACX,MAAI,OAAO;AACX,MAAI,OAAO;AACX,WAAS,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK,KAAK,UAAU;AACnD,YAAQ,GAAG,CAAC;AACZ,YAAQ,GAAG,IAAI,CAAC;AAChB,YAAQ,GAAG,IAAI,CAAC;AAAA,EAClB;AACA,QAAM,QAAQ,OAAO;AACrB,QAAM,QAAQ,OAAO;AACrB,QAAM,QAAQ,OAAO;AAErB,MAAI,OAAO;AACX,WAAS,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK,KAAK,UAAU;AACnD,QACE,KAAK,IAAI,GAAG,CAAC,IAAI,KAAK,IAAI,KAC1B,KAAK,IAAI,GAAG,IAAI,CAAC,IAAI,KAAK,IAAI,KAC9B,KAAK,IAAI,GAAG,IAAI,CAAC,IAAI,KAAK,IAAI,GAC9B;AACA;AAAA,IACF;AAAA,EACF;AAEA,QAAM,UAAU,OAAO,QAAQ;AAC/B,QAAM,YAAY,QAAQ,OAAO,QAAQ,OAAO,QAAQ;AACxD,MAAI,WAAW,WAAW;AACxB,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACF;AAGA,eAAsB,oBAAoB,OAAqD;AAC7F,QAAM,aAAa,MAAM,gBAAgB,MAAM,KAAK;AAAA,IAClD,GAAI,MAAM,YAAY,SAAY,EAAE,SAAS,MAAM,QAAQ,IAAI,CAAC;AAAA,IAChE,GAAI,MAAM,aAAa,SAAY,EAAE,UAAU,MAAM,SAAS,IAAI,CAAC;AAAA,EACrE,CAAC;AACD,QAAM,eAAe,UAAU;AAE/B,QAAM,SAAS,cAAc,MAAM,GAAG;AACtC,QAAM,QAAQ,MAAM,mBAAmB;AAAA,IACrC,OAAO,MAAM,UAAU;AAAA,IACvB;AAAA,IACA;AAAA,EACF,CAAC;AACD,QAAM,OAAO,MAAM,QAAQ,OAAO,MAAM,GAAG,EAAE,CAAC;AAC9C,SAAO,EAAE,OAAO,QAAQ,UAAU,GAAG,IAAI,cAAc,aAAa,aAAa;AACnF;","names":["sharp","sharp"]}
@@ -62,6 +62,15 @@ var MIGRATIONS = [
62
62
  {
63
63
  id: "0004_add_spam_reason",
64
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;`
65
74
  }
66
75
  ];
67
76
 
@@ -96,4 +105,4 @@ async function runMigrations(client) {
96
105
  export {
97
106
  runMigrations
98
107
  };
99
- //# sourceMappingURL=chunk-HIN3P4ZN.js.map
108
+ //# sourceMappingURL=chunk-4SOCPK7A.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","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":";AAUO,IAAM,aAA0B;AAAA,EACrC;AAAA,IACE,IAAI;AAAA,IACJ,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EA+BP;AAAA,EACA;AAAA,IACE,IAAI;AAAA,IACJ,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAaP;AAAA,EACA;AAAA,IACE,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAMJ,KAAK;AAAA,EACP;AAAA,EACA;AAAA,IACE,IAAI;AAAA,IACJ,KAAK;AAAA,EACP;AAAA,EACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAME,IAAI;AAAA,IACJ,KAAK;AAAA,EACP;AACF;;;AC1EA,SAAS,sBAAsB,KAAuB;AACpD,QAAM,UAAU,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAC/D,SAAO,yBAAyB,KAAK,OAAO;AAC9C;AASA,eAAsB,cAAc,QAAmC;AACrE,QAAM,OAAO;AAAA,IACX;AAAA,EACF;AACA,QAAM,WAAW,MAAM,OAAO,QAAQ,4BAA4B;AAClE,QAAM,UAAU,IAAI,IAAI,SAAS,KAAK,IAAI,CAAC,MAAM,OAAO,EAAE,EAAE,CAAC,CAAC;AAC9D,QAAM,MAAgB,CAAC;AACvB,aAAW,KAAK,YAAY;AAC1B,QAAI,QAAQ,IAAI,EAAE,EAAE,EAAG;AACvB,QAAI;AACF,YAAM,OAAO,gBAAgB,EAAE,GAAG;AAAA,IACpC,SAAS,KAAK;AAIZ,UAAI,CAAC,sBAAsB,GAAG,EAAG,OAAM;AAAA,IACzC;AACA,UAAM,OAAO,QAAQ;AAAA,MACnB,KAAK;AAAA,MACL,MAAM,CAAC,EAAE,KAAI,oBAAI,KAAK,GAAE,YAAY,CAAC;AAAA,IACvC,CAAC;AACD,QAAI,KAAK,EAAE,EAAE;AAAA,EACf;AACA,SAAO;AACT;","names":[]}
@@ -91,6 +91,7 @@ async function listAssetsByFilename(repo, token) {
91
91
  cursor = `&cursor=${encodeURIComponent(page.cursor)}`;
92
92
  }
93
93
  }
94
+ var docRefKey = (type, uid) => `${type}::${uid}`;
94
95
  async function lookupDocIds(repo) {
95
96
  const access = process.env.PRISMIC_ACCESS_TOKEN;
96
97
  const qs = access ? `?access_token=${access}` : "";
@@ -107,7 +108,7 @@ async function lookupDocIds(repo) {
107
108
  while (url) {
108
109
  const res = await expectOk(await fetchWithRetry(url), "Document API search");
109
110
  const page = await res.json();
110
- for (const d of page.results) if (d.uid) map.set(d.uid, d.id);
111
+ for (const d of page.results) if (d.uid) map.set(docRefKey(d.type, d.uid), d.id);
111
112
  url = page.next_page;
112
113
  }
113
114
  return map;
@@ -176,7 +177,7 @@ async function runMigration(plan, log = console.log) {
176
177
  throw new Error(`create ${doc.uid}: ${res.status} ${text}`);
177
178
  }
178
179
  docIds ??= await lookupDocIds(repo);
179
- const id = docIds.get(doc.uid);
180
+ const id = docIds.get(docRefKey(doc.type, doc.uid));
180
181
  if (!id) {
181
182
  throw new Error(
182
183
  `update ${doc.uid}: the uid exists but is not visible on the master ref \u2014 most likely it sits in an UNPUBLISHED migration release; publish the release in the Prismic dashboard, then re-run to update` + (process.env.PRISMIC_ACCESS_TOKEN ? "" : " (if the repo's Document API is private, also set PRISMIC_ACCESS_TOKEN)")
@@ -198,8 +199,9 @@ async function runMigration(plan, log = console.log) {
198
199
  }
199
200
  return { assetsUploaded, assetsReused, docsCreated, docsUpdated, missingAssets, assetUrlByCdn };
200
201
  }
202
+
201
203
  export {
202
204
  pushCustomTypes,
203
205
  runMigration
204
206
  };
205
- //# sourceMappingURL=run-migration-LJNNZWIV.js.map
207
+ //# sourceMappingURL=chunk-6DOFNDXU.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/blux/emit/resolve-doc.ts","../src/blux/emit/run-migration.ts"],"sourcesContent":["import { htmlAsRichText } from \"@prismicio/migrate\";\n\n/** Resolve a PlanDocument's plain-JSON markers into Migration API values:\n * `__richtext_html` → rich text nodes, `__asset_id` → `{ id }` using the\n * uuid → Prismic-asset-id map (missing assets are dropped and reported).\n * Pure, so the network shell in run-migration.ts stays untested-thin. */\nexport function resolveDocData(\n data: Record<string, unknown>,\n assetIdByUuid: Map<string, string>,\n): { data: Record<string, unknown>; missingAssets: string[] } {\n const missing: string[] = [];\n const resolve = (v: unknown): unknown => {\n if (v && typeof v === \"object\") {\n if (\"__richtext_html\" in v) {\n return htmlAsRichText((v as { __richtext_html: string }).__richtext_html).result;\n }\n if (\"__asset_id\" in v) {\n const uuid = (v as { __asset_id: string }).__asset_id;\n const id = assetIdByUuid.get(uuid);\n if (!id) {\n missing.push(uuid);\n return undefined;\n }\n return { id };\n }\n if (Array.isArray(v)) return v.map(resolve);\n return Object.fromEntries(\n Object.entries(v)\n .map(([k, val]) => [k, resolve(val)] as const)\n .filter(([, val]) => val !== undefined),\n );\n }\n return v;\n };\n return { data: resolve(data) as Record<string, unknown>, missingAssets: missing };\n}\n","import type { MigrationPlan, PlanCustomType } from \"./plan.js\";\nimport { resolveDocData } from \"./resolve-doc.js\";\n\n/** LIVE runner on the raw Prismic APIs. Learned from the Pointe run\n * (2026-07-06): the js client's migrate() creates docs hollow, PATCHes data\n * in a later pass, swallows validation `details[]`, re-uploads every asset on\n * retry, and cannot update existing docs. This runner: skips assets already\n * in the library (by filename), POSTs documents and falls back to PUT when\n * the uid exists (id looked up via the Document API), and surfaces full error\n * bodies. Creds: PRISMIC_REPOSITORY_NAME + PRISMIC_WRITE_TOKEN (+ optional\n * PRISMIC_ACCESS_TOKEN when the repo's Document API is private).\n * Live I/O — excluded from unit coverage; resolveDocData carries the tested\n * pure logic and Plan 4 Task 10 is the live acceptance run. */\n\nconst THROTTLE_MS = 1200; // migration API limit ~1 req/s\nconst sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));\n\n/** fetch with backoff on 429 — the Asset/Migration APIs rate-limit hard and a\n * single 429 must not abort a minutes-long migration. */\nasync function fetchWithRetry(url: string, init?: RequestInit, tries = 4): Promise<Response> {\n for (let i = 0; ; i++) {\n const res = await fetch(url, init);\n if (res.status !== 429 || i >= tries - 1) return res;\n await sleep(1500 * (i + 1));\n }\n}\n\nfunction readCreds(): { repo: string; token: string } {\n const repo = process.env.PRISMIC_REPOSITORY_NAME;\n const token = process.env.PRISMIC_WRITE_TOKEN;\n if (!repo || !token) throw new Error(\"Set PRISMIC_REPOSITORY_NAME and PRISMIC_WRITE_TOKEN\");\n return { repo, token };\n}\n\nconst apiHeaders = (repo: string, token: string) => ({\n repository: repo,\n Authorization: `Bearer ${token}`,\n});\n\nasync function expectOk(res: Response, what: string): Promise<Response> {\n if (res.ok) return res;\n throw new Error(`${what}: ${res.status} ${await res.text()}`);\n}\n\n/** Push the plan's repeatable custom types via the Custom Types API\n * (insert, falling back to update when the type already exists). */\nexport async function pushCustomTypes(types: PlanCustomType[]): Promise<string[]> {\n const { repo, token } = readCreds();\n const headers = { ...apiHeaders(repo, token), \"Content-Type\": \"application/json\" };\n const post = (path: \"insert\" | \"update\", body: string) =>\n fetchWithRetry(`https://customtypes.prismic.io/customtypes/${path}`, {\n method: \"POST\",\n headers,\n body,\n });\n const pushed: string[] = [];\n for (const t of types) {\n const body = JSON.stringify(t.json);\n const ins = await post(\"insert\", body);\n if (!ins.ok) {\n const insErr = `insert ${ins.status} ${await ins.text()}`;\n // 409/400 usually means \"type exists\" — but keep the insert body: when\n // the update ALSO fails, the insert's validation detail is the real clue\n if (ins.status !== 409 && ins.status !== 400)\n throw new Error(`custom type ${t.id}: ${insErr}`);\n const upd = await post(\"update\", body);\n if (!upd.ok) {\n throw new Error(`custom type ${t.id}: ${insErr}; update ${upd.status} ${await upd.text()}`);\n }\n }\n pushed.push(t.id);\n }\n return pushed;\n}\n\n/** filename → { id, url } for every asset already in the media library. `url`\n * is the servable CDN url (the Asset API asset object's `url` field, e.g.\n * https://images.prismic.io/...) that the render side loads. */\nasync function listAssetsByFilename(\n repo: string,\n token: string,\n): Promise<Map<string, { id: string; url: string }>> {\n const map = new Map<string, { id: string; url: string }>();\n let cursor = \"\";\n for (;;) {\n const res = await expectOk(\n await fetchWithRetry(`https://asset-api.prismic.io/assets?limit=500${cursor}`, {\n headers: apiHeaders(repo, token),\n }),\n \"asset list\",\n );\n const page = (await res.json()) as {\n items: { id: string; filename: string; url: string }[];\n cursor?: string;\n };\n for (const a of page.items) map.set(a.filename, { id: a.id, url: a.url });\n if (!page.cursor || !page.items.length) return map;\n cursor = `&cursor=${encodeURIComponent(page.cursor)}`;\n }\n}\n\n/** Composite key for the update-lookup map. Prismic scopes uid-uniqueness PER\n * TYPE, so a repo can hold the same uid under two types — e.g. a cloned\n * starter's `page:home` and the migration's `catalog_page:home`. Keying the\n * lookup by uid alone let one clobber the other (last-writer-wins), so an\n * \"already exists\" update could PUT a doc's data onto the WRONG-typed id. The\n * key carries the type to keep the two apart. */\nconst docRefKey = (type: string, uid: string): string => `${type}::${uid}`;\n\n/** type+uid → document id on the target repo, for the PUT-update path. Reads the\n * MASTER ref, so it only sees PUBLISHED documents — docs sitting in an\n * unpublished migration release are invisible here (the miss error explains\n * that). Follows next_page so >100-doc repos resolve fully. */\nasync function lookupDocIds(repo: string): Promise<Map<string, string>> {\n const access = process.env.PRISMIC_ACCESS_TOKEN;\n const qs = access ? `?access_token=${access}` : \"\";\n const apiRes = await expectOk(\n await fetchWithRetry(`https://${repo}.prismic.io/api/v2${qs}`),\n \"Document API /api/v2 (set PRISMIC_ACCESS_TOKEN for a private repo)\",\n );\n const api = (await apiRes.json()) as { refs?: { id: string; ref: string }[] };\n const master = api.refs?.find((r) => r.id === \"master\")?.ref;\n if (!master) throw new Error(`Document API for ${repo} returned no master ref`);\n\n const map = new Map<string, string>();\n const sep = qs ? \"&\" : \"?\";\n let url: string | null =\n `https://${repo}.prismic.io/api/v2/documents/search${qs}${sep}ref=${master}&pageSize=100`;\n while (url) {\n const res = await expectOk(await fetchWithRetry(url), \"Document API search\");\n const page = (await res.json()) as {\n results: { id: string; uid: string | null; type: string }[];\n next_page: string | null;\n };\n for (const d of page.results) if (d.uid) map.set(docRefKey(d.type, d.uid), d.id);\n url = page.next_page;\n }\n return map;\n}\n\nexport type MigrationResult = {\n assetsUploaded: number;\n assetsReused: number;\n docsCreated: number;\n docsUpdated: number;\n missingAssets: string[];\n /** cdn url (the manifest/plan-asset url) → resolved Prismic servable url, for\n * every asset uploaded or reused. Lets the caller rewrite the render manifest\n * onto durable Prismic-hosted media. */\n assetUrlByCdn: Map<string, string>;\n};\n\n/** Execute a MigrationPlan: upload missing assets (reusing existing ones by\n * filename), then create-or-update each document by uid. Documents land in a\n * migration release the operator publishes in the dashboard. */\nexport async function runMigration(\n plan: MigrationPlan,\n log: (line: string) => void = console.log,\n): Promise<MigrationResult> {\n const { repo, token } = readCreds();\n const existing = await listAssetsByFilename(repo, token);\n const assetIdByUuid = new Map<string, string>();\n const assetUrlByCdn = new Map<string, string>();\n let assetsUploaded = 0;\n let assetsReused = 0;\n\n for (const a of plan.assets) {\n // Blux filenames are `<uuid>.<ext>` — globally unique, so filename keying\n // is a sound dedup. Reused assets keep their existing alt (trade-off:\n // alt from the plan is only applied on first upload).\n const filename = (a.url.split(\"/\").pop() ?? a.id).split(\"?\")[0]!;\n const known = existing.get(filename);\n if (known) {\n assetIdByUuid.set(a.id, known.id);\n assetUrlByCdn.set(a.url, known.url);\n assetsReused++;\n continue;\n }\n const blob = await (await expectOk(await fetch(a.url), `fetch asset ${filename}`)).blob();\n const form = new FormData();\n form.append(\"file\", blob, filename);\n if (a.alt) form.append(\"alt\", a.alt);\n const res = await expectOk(\n await fetchWithRetry(\"https://asset-api.prismic.io/assets\", {\n method: \"POST\",\n headers: apiHeaders(repo, token),\n body: form,\n }),\n `upload asset ${filename}`,\n );\n const created = (await res.json()) as { id: string; url: string };\n assetIdByUuid.set(a.id, created.id);\n assetUrlByCdn.set(a.url, created.url);\n assetsUploaded++;\n log(`asset ${assetsUploaded + assetsReused}/${plan.assets.length} ${filename}`);\n await sleep(THROTTLE_MS);\n }\n\n let docIds: Map<string, string> | null = null;\n let docsCreated = 0;\n let docsUpdated = 0;\n const missingAssets: string[] = [];\n\n for (const doc of plan.documents) {\n const { data, missingAssets: miss } = resolveDocData(doc.data, assetIdByUuid);\n missingAssets.push(...miss);\n const body = JSON.stringify({\n type: doc.type,\n uid: doc.uid,\n lang: \"en-us\",\n title: doc.uid,\n data,\n });\n const headers = { ...apiHeaders(repo, token), \"Content-Type\": \"application/json\" };\n const res = await fetchWithRetry(\"https://migration.prismic.io/documents\", {\n method: \"POST\",\n headers,\n body,\n });\n if (res.ok) {\n docsCreated++;\n log(`created ${doc.uid}`);\n } else {\n const text = await res.text();\n if (!/already exists/i.test(text)) {\n throw new Error(`create ${doc.uid}: ${res.status} ${text}`);\n }\n docIds ??= await lookupDocIds(repo);\n const id = docIds.get(docRefKey(doc.type, doc.uid));\n if (!id) {\n throw new Error(\n `update ${doc.uid}: the uid exists but is not visible on the master ref — ` +\n `most likely it sits in an UNPUBLISHED migration release; publish the release ` +\n `in the Prismic dashboard, then re-run to update` +\n (process.env.PRISMIC_ACCESS_TOKEN\n ? \"\"\n : \" (if the repo's Document API is private, also set PRISMIC_ACCESS_TOKEN)\"),\n );\n }\n await sleep(THROTTLE_MS); // the failed POST already hit the migration API\n await expectOk(\n await fetchWithRetry(`https://migration.prismic.io/documents/${id}`, {\n method: \"PUT\",\n headers,\n body,\n }),\n `update ${doc.uid}`,\n );\n docsUpdated++;\n log(`updated ${doc.uid}`);\n }\n await sleep(THROTTLE_MS);\n }\n return { assetsUploaded, assetsReused, docsCreated, docsUpdated, missingAssets, assetUrlByCdn };\n}\n"],"mappings":"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","names":[]}
@@ -1,15 +1,20 @@
1
1
  import {
2
2
  queueDraft
3
- } from "./chunk-KLVMJYZZ.js";
3
+ } from "./chunk-SEQA7MC3.js";
4
+ import {
5
+ generateHeaderImage
6
+ } from "./chunk-37S6L6G4.js";
4
7
  import {
5
8
  checklistFor,
6
9
  createDraft,
7
10
  gatingFields,
8
11
  listReportsForSite,
9
12
  renderReportHtml,
10
- resolveCopy,
13
+ resolveCopy
14
+ } from "./chunk-MNE374CZ.js";
15
+ import {
11
16
  uploadAttachment
12
- } from "./chunk-JCITWZQR.js";
17
+ } from "./chunk-MPBXOJ3N.js";
13
18
  import {
14
19
  isNetlifyAppUrl
15
20
  } from "./chunk-U5SZ4FOP.js";
@@ -134,6 +139,13 @@ function googleEvidence(now, search) {
134
139
  if (search.softFailed) {
135
140
  return { result: "unknown", checkedAt: at, note: "Search Console unavailable this run" };
136
141
  }
142
+ if (search.notConfigured) {
143
+ return {
144
+ result: "unknown",
145
+ checkedAt: at,
146
+ note: "Search Console not configured in the environment that drafted this report"
147
+ };
148
+ }
137
149
  if (search.value === null) return null;
138
150
  if (search.value.foundOnPage1) {
139
151
  const pos2 = search.value.position;
@@ -474,6 +486,21 @@ async function fetchSearchPresence(q, periodStart, periodEnd) {
474
486
  }
475
487
 
476
488
  // src/reports/draft.ts
489
+ async function refreshHeaderImage(site, deps = {}) {
490
+ if (!site.url) return false;
491
+ const generate = deps.generate ?? generateHeaderImage;
492
+ const upload = deps.upload ?? uploadAttachment;
493
+ try {
494
+ const gen = await generate({ url: site.url, slug: siteSlug(site.name) });
495
+ await upload(site.id, "Header image", gen.bytes, gen.filename, gen.contentType);
496
+ return true;
497
+ } catch (err) {
498
+ console.warn(
499
+ `\u26A0 header-image refresh skipped for ${site.name}: ${err instanceof Error ? err.message : String(err)} \u2014 keeping the stored image`
500
+ );
501
+ return false;
502
+ }
503
+ }
477
504
  function scoresFromWebsite(siteRow) {
478
505
  const { pScore, rScore, bpScore, seoScore } = siteRow;
479
506
  if (pScore === null || rScore === null || bpScore === null || seoScore === null) {
@@ -497,13 +524,25 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
497
524
  const completedOn = today;
498
525
  const lastTestedDate = reportType === "Maintenance" && siteRow.lastLighthouseAuditAt ? new Date(siteRow.lastLighthouseAuditAt) : null;
499
526
  const gaResult = base !== null ? await fetchGaUsers(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;
500
- const searchResult = base !== null ? await fetchSearch(siteRow, periodStart, periodEnd) : { ...NO_ENRICHMENT, defaultQueryMissed: false, propertyMissing: false };
527
+ const searchResult = base !== null ? await fetchSearch(siteRow, periodStart, periodEnd) : (
528
+ // No-IO render path: nothing was attempted, so this is not an environment gap
529
+ // (`notConfigured` would wrongly tell the operator to go wire a secret).
530
+ {
531
+ ...NO_ENRICHMENT,
532
+ defaultQueryMissed: false,
533
+ propertyMissing: false,
534
+ notConfigured: false
535
+ }
536
+ );
501
537
  const gaUsers = gaResult.value;
502
538
  const search = searchResult.value;
503
539
  const softFailures = [
504
540
  ...gaResult.softFailed ? ["ga"] : [],
505
541
  ...searchResult.softFailed ? ["search"] : []
506
542
  ];
543
+ if (base !== null && options.refreshHeader !== false) {
544
+ await refreshHeaderImage(siteRow, options.refreshHeader ?? {});
545
+ }
507
546
  const cidName = `${slug}-header`;
508
547
  const { html } = await renderReportHtml({
509
548
  siteName: siteRow.name,
@@ -623,8 +662,16 @@ async function fetchGaUsers(siteRow, periodStart, periodEnd) {
623
662
  }
624
663
  async function fetchSearch(siteRow, periodStart, periodEnd) {
625
664
  const cfg = readGaConfig();
626
- if (!cfg || !(siteRow.ga4PropertyId || siteRow.searchQuery))
627
- return { ...NO_ENRICHMENT, defaultQueryMissed: false, propertyMissing: false };
665
+ const enrolled = Boolean(siteRow.ga4PropertyId || siteRow.searchQuery);
666
+ if (!cfg || !enrolled) {
667
+ const notConfigured = enrolled && !cfg;
668
+ if (notConfigured) {
669
+ console.warn(
670
+ `\u2691 Search: no GA/Search Console credentials in this environment (GA_SUBJECT unset) \u2014 the Google Indexed check could not run for ${siteRow.name}.`
671
+ );
672
+ }
673
+ return { ...NO_ENRICHMENT, defaultQueryMissed: false, propertyMissing: false, notConfigured };
674
+ }
628
675
  const explicit = siteRow.searchQuery?.trim();
629
676
  const query = explicit || siteRow.name;
630
677
  const usedDefault = !explicit;
@@ -652,10 +699,16 @@ async function fetchSearch(siteRow, periodStart, periodEnd) {
652
699
  `\u2691 Search: site-name default "${query}" found no Search Console data for ${siteRow.name} \u2014 set an explicit "Search query" in Airtable to track brand presence.`
653
700
  );
654
701
  }
655
- return { value, softFailed: false, defaultQueryMissed, propertyMissing };
702
+ return { value, softFailed: false, defaultQueryMissed, propertyMissing, notConfigured: false };
656
703
  } catch (e) {
657
704
  console.warn(`\u26A0 Search presence skipped for ${siteRow.name}: ${e.message}`);
658
- return { value: null, softFailed: true, defaultQueryMissed: false, propertyMissing: false };
705
+ return {
706
+ value: null,
707
+ softFailed: true,
708
+ defaultQueryMissed: false,
709
+ propertyMissing: false,
710
+ notConfigured: false
711
+ };
659
712
  }
660
713
  }
661
714
  async function derivePeriodStart(base, siteRow, reportType, today) {
@@ -674,4 +727,4 @@ export {
674
727
  fetchGaUsers,
675
728
  fetchSearch
676
729
  };
677
- //# sourceMappingURL=chunk-NUIBQPB3.js.map
730
+ //# sourceMappingURL=chunk-6SZBNALD.js.map