@cosmicdrift/kumiko-samples 0.363.0 → 0.365.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 (237) hide show
  1. package/LICENSE +1 -1
  2. package/package.json +1 -1
  3. package/packages/bundled-features/package.json +7 -1
  4. package/packages/bundled-features/src/audit/changes.json +18 -0
  5. package/packages/bundled-features/src/audit/feature.ts +1 -1
  6. package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
  7. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
  8. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +30 -11
  9. package/packages/bundled-features/src/auth-email-password/changes.json +55 -0
  10. package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
  11. package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
  12. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
  13. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
  14. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
  15. package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
  16. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
  17. package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
  18. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
  19. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
  20. package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
  21. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
  22. package/packages/bundled-features/src/billing-foundation/changes.json +48 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
  28. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
  29. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +18 -2
  30. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
  31. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
  32. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
  33. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
  34. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
  35. package/packages/bundled-features/src/billing-foundation/i18n.ts +9 -0
  36. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +27 -7
  37. package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
  38. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
  39. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
  40. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
  41. package/packages/bundled-features/src/cap-counter/changes.json +19 -0
  42. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
  43. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
  44. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  45. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
  46. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  47. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
  48. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
  49. package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
  50. package/packages/bundled-features/src/channel-email/changes.json +18 -0
  51. package/packages/bundled-features/src/channel-email/email-channel.ts +47 -24
  52. package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
  53. package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
  54. package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
  55. package/packages/bundled-features/src/config/changes.json +6 -0
  56. package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
  57. package/packages/bundled-features/src/config/resolver.ts +15 -12
  58. package/packages/bundled-features/src/config/write-helpers.ts +23 -0
  59. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  60. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +54 -27
  61. package/packages/bundled-features/src/custom-fields/changes.json +6 -0
  62. package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
  63. package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
  64. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
  65. package/packages/bundled-features/src/delivery/attachment-ref.ts +39 -1
  66. package/packages/bundled-features/src/delivery/changes.json +30 -0
  67. package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
  68. package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
  69. package/packages/bundled-features/src/delivery/index.ts +2 -0
  70. package/packages/bundled-features/src/delivery/jobs.ts +51 -14
  71. package/packages/bundled-features/src/delivery/types.ts +7 -1
  72. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  73. package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
  74. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
  75. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
  76. package/packages/bundled-features/src/file-foundation/changes.json +6 -0
  77. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
  78. package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
  79. package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
  80. package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
  81. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
  82. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  83. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  84. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
  85. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
  86. package/packages/bundled-features/src/jobs/changes.json +31 -0
  87. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
  88. package/packages/bundled-features/src/jobs/feature.ts +7 -4
  89. package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
  90. package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
  91. package/packages/bundled-features/src/jobs/job-run-logger.ts +56 -18
  92. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
  93. package/packages/bundled-features/src/ledger/changes.json +7 -0
  94. package/packages/bundled-features/src/ledger/entity.ts +3 -1
  95. package/packages/bundled-features/src/metrics/changes.json +6 -0
  96. package/packages/bundled-features/src/metrics/filters.ts +28 -12
  97. package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
  98. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
  99. package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
  100. package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
  101. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
  102. package/packages/bundled-features/src/secrets/changes.json +12 -0
  103. package/packages/bundled-features/src/secrets/feature.ts +4 -3
  104. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
  105. package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
  106. package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
  107. package/packages/bundled-features/src/shared/changes.json +6 -0
  108. package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
  109. package/packages/bundled-features/src/shared/index.ts +8 -0
  110. package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
  111. package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
  112. package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
  113. package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -2
  114. package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
  115. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
  116. package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
  117. package/packages/bundled-features/src/template-resolver/README.md +4 -2
  118. package/packages/bundled-features/src/template-resolver/api.ts +3 -2
  119. package/packages/bundled-features/src/template-resolver/changes.json +149 -0
  120. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +148 -18
  121. package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
  122. package/packages/bundled-features/src/template-resolver/constants.ts +23 -3
  123. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
  124. package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -5
  125. package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
  126. package/packages/bundled-features/src/template-resolver/feature.ts +16 -1
  127. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
  128. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
  129. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +121 -53
  130. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +6 -44
  131. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
  132. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
  133. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
  134. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
  135. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
  136. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
  137. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
  138. package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
  139. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +102 -19
  140. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +49 -10
  141. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
  142. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
  143. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
  144. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
  145. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
  146. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
  147. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
  148. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
  149. package/packages/bundled-features/src/template-resolver/index.ts +18 -2
  150. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +56 -0
  151. package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
  152. package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
  153. package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
  154. package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
  155. package/packages/bundled-features/src/template-resolver/seeding.ts +27 -4
  156. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
  157. package/packages/bundled-features/src/template-resolver/table.ts +13 -1
  158. package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
  159. package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
  160. package/packages/bundled-features/src/template-resolver/template-meta.ts +95 -24
  161. package/packages/bundled-features/src/template-resolver/template-preview.ts +160 -57
  162. package/packages/bundled-features/src/template-resolver/template-resources.ts +3 -4
  163. package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
  164. package/packages/bundled-features/src/template-resolver/template-validation.ts +16 -9
  165. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
  166. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +97 -10
  167. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +15 -5
  168. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +405 -145
  169. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +17 -3
  170. package/packages/bundled-features/src/template-resolver/web/i18n.ts +32 -4
  171. package/packages/bundled-features/src/template-resolver/web/index.ts +8 -0
  172. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +23 -7
  173. package/packages/bundled-features/src/tenant/changes.json +6 -0
  174. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
  175. package/packages/bundled-features/src/tenant/seeding.ts +33 -27
  176. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
  177. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  178. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
  179. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -2
  180. package/packages/bundled-features/src/tier-engine/changes.json +18 -0
  181. package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
  182. package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
  183. package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
  184. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
  185. package/packages/bundled-features/src/tier-engine/index.ts +2 -0
  186. package/packages/bundled-features/src/user/changes.json +6 -0
  187. package/packages/bundled-features/src/user/constants.ts +1 -0
  188. package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
  189. package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
  190. package/packages/bundled-features/src/user/seeding.ts +19 -5
  191. package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
  192. package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
  193. package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
  194. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
  195. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
  196. package/packages/bundled-features/src/user-profile/changes.json +1 -1
  197. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  198. package/packages/bundled-features/src/waitlist/feature.ts +1 -1
  199. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
  200. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
  201. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
  202. package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
  203. package/packages/bundled-features/src/waitlist/lib.ts +20 -0
  204. package/packages/bundled-features/src/waitlist/options.ts +1 -1
  205. package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
  206. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
  207. package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
  208. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
  209. package/samples/apps/ui-walkthrough/package.json +9 -0
  210. package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
  211. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
  212. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
  213. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
  214. package/samples/apps/ui-walkthrough/src/run-config.ts +18 -1
  215. package/samples/apps/use-all-bundled/README.md +3 -13
  216. package/samples/apps/use-all-bundled/feature-manifest.json +7 -5
  217. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
  218. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
  219. package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
  220. package/samples/recipes/auth-mfa/README.md +2 -1
  221. package/samples/recipes/auth-mfa/src/feature.ts +1 -0
  222. package/samples/recipes/custom-handlers/README.md +4 -3
  223. package/samples/recipes/custom-handlers/src/feature.ts +21 -12
  224. package/samples/recipes/dashboard-stats/README.md +38 -0
  225. package/samples/recipes/dashboard-stats/package.json +8 -0
  226. package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
  227. package/samples/recipes/legal-pages/README.md +2 -2
  228. package/samples/recipes/managed-pages/README.md +2 -2
  229. package/samples/recipes/pdf-audit-export/README.md +2 -1
  230. package/samples/recipes/pdf-audit-export/package.json +1 -1
  231. package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
  232. package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
  233. package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
  234. package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
  235. package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
  236. package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
  237. package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
@@ -5,22 +5,21 @@
5
5
  // to be an OOM primitive for a single request.
6
6
  export const MAX_INPUT_PIXELS = 100_000_000;
7
7
  // sequentialRead trades random pixel access for lower peak memory on
8
- // streamed formats — applied at every decode entry point below since none
9
- // of them need non-sequential access to the *original* bytes (extract runs
10
- // against a single already-buffered region per call).
8
+ // streamed formats. Shared by every sharp caller since none of them need
9
+ // non-sequential access to the original bytes.
11
10
  export const SHARP_INPUT_OPTIONS = {
12
11
  limitInputPixels: MAX_INPUT_PIXELS,
13
12
  sequentialRead: true,
14
13
  } as const;
15
14
 
16
- // Shared trust boundary for every image byte source this renderer decodes —
17
- // the original upload (renderImage) and an overlay layer's bytes (applyOverlays)
18
- // alike: a base64 string in a field declaration isn't more trustworthy than
19
- // an upload just because it came from app code.
15
+ // Shared trust boundary for every image byte source decoded through sharp:
16
+ // uploads, overlay layers and template resources alike. A base64 string in a
17
+ // field declaration isn't more trustworthy than an upload just because it
18
+ // came from app code.
20
19
  export function assertNotSvg(format: string | undefined): void {
21
20
  if (format === "svg") {
22
21
  throw new Error(
23
- "derivatives-sharp: SVG is not supported — rendering untrusted SVG through libvips is a trust boundary this renderer doesn't need to cross.",
22
+ "SVG is not supported — rendering untrusted SVG through libvips is a trust boundary this code doesn't need to cross.",
24
23
  );
25
24
  }
26
25
  }
@@ -118,8 +118,8 @@ export type WebhookDispatchDeps = {
118
118
  readonly requestTimeoutMs?: number;
119
119
  };
120
120
 
121
- // One hanging tenant-controlled receiver must not stall the shared
122
- // step-dispatcher consumer for every tenant.
121
+ // Bounds a single hanging tenant-controlled receiver to this long. The consumer
122
+ // is sequential and shared, so N hanging requests still delay other tenants by N times this.
123
123
  export const WEBHOOK_REQUEST_TIMEOUT_MS = 10_000;
124
124
 
125
125
  // Never includes the secret name or value — spec.auth.secret is a
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "fix",
5
+ "title": "A stale Stripe customer id no longer turns the customer portal into a 500",
6
+ "detail": "createPortalSession now maps Stripe's \"No such customer\" rejection (account switch, customer deleted in the dashboard) to an UnprocessableError with the i18n key billing-foundation.errors.providerCustomerUnknown, translated in German and Spanish, instead of letting the raw Stripe error surface as a server error."
7
+ },
2
8
  {
3
9
  "version": "0.338.0",
4
10
  "type": "improvement",
@@ -109,12 +109,10 @@ export function mapAppLocaleToStripeLocale(
109
109
  return knownLocales.find((known) => known === language) ?? "auto";
110
110
  }
111
111
 
112
- function isUnknownCustomerStripeError(error: unknown): boolean {
113
- return (
114
- isResourceMissingStripeError(error) &&
115
- error instanceof Stripe.errors.StripeInvalidRequestError &&
116
- error.param === "customer"
117
- );
112
+ function isUnknownCustomerStripeError(
113
+ error: unknown,
114
+ ): error is Stripe.errors.StripeInvalidRequestError {
115
+ return isResourceMissingStripeError(error) && error.param === "customer";
118
116
  }
119
117
 
120
118
  export function createStripeCheckoutSession(
@@ -203,11 +201,21 @@ export type StripePortalOptions = Parameters<
203
201
  export function createStripePortalSession(runtime: StripeCtxRuntime) {
204
202
  return async (ctx: HandlerContext, options: StripePortalOptions): Promise<{ url: string }> => {
205
203
  const stripe = await runtime.clientForCtx(ctx);
206
- const session = await stripe.billingPortal.sessions.create({
207
- customer: options.providerCustomerId,
208
- return_url: options.returnUrl,
209
- });
210
- return { url: session.url };
204
+ try {
205
+ const session = await stripe.billingPortal.sessions.create({
206
+ customer: options.providerCustomerId,
207
+ return_url: options.returnUrl,
208
+ });
209
+ return { url: session.url };
210
+ } catch (error) {
211
+ // A portal needs an existing customer, so unlike checkout there is no retry without one.
212
+ if (!isUnknownCustomerStripeError(error)) throw error;
213
+ throw new UnprocessableError("provider_customer_unknown", {
214
+ i18nKey: "billing-foundation.errors.providerCustomerUnknown",
215
+ message: `subscription-stripe: Stripe does not know customer "${options.providerCustomerId}" (account switch or customer deleted).`,
216
+ cause: error,
217
+ });
218
+ }
211
219
  };
212
220
  }
213
221
 
@@ -54,7 +54,9 @@ import type { StripeWebhookRuntime } from "./runtime.js";
54
54
 
55
55
  const log = createFallbackLogger("subscription-stripe");
56
56
 
57
- export function isResourceMissingStripeError(error: unknown): boolean {
57
+ export function isResourceMissingStripeError(
58
+ error: unknown,
59
+ ): error is Stripe.errors.StripeInvalidRequestError {
58
60
  return (
59
61
  error instanceof Stripe.errors.StripeInvalidRequestError && error.code === "resource_missing"
60
62
  );
@@ -50,7 +50,7 @@ async function someHandler(ctx) {
50
50
 
51
51
  | kind | contentFormat | meta |
52
52
  |---|---|---|
53
- | `mail` | `html` (Dokument-Schema, wird serverseitig neu aufgebaut) | Pflicht: `subject`; optional `previewText`, `layoutSlug`, `category` (`transactional`/`marketing`), `sender` |
53
+ | `mail` | `html` (Dokument-Schema, wird serverseitig neu aufgebaut) | Pflicht: `subject`; optional `previewText`, `layoutSlug`, `category` (`transactional`/`marketing`), `senderName`, `replyTo` |
54
54
  | `mail-layout` | `mjml` | leer (`{}`) oder fehlend |
55
55
  | `pdf-layout` | `html` | leer (`{}`) oder fehlend |
56
56
  | `document-pdf` | `html` | fehlt |
@@ -137,10 +137,12 @@ upsertSystem ──┐
137
137
  ├──► status: "active" (System-Default sofort aktiv)
138
138
  upsertTenant ──┴──► status: "draft" (Default) | "active" (explizit)
139
139
 
140
- publish ───────► status: "active"
140
+ publish ───────► status: "active" (übernimmt einen offenen Entwurf)
141
141
  archive ───────► status: "archived"
142
+ discardDraft ──► verwirft den Entwurf neben der aktiven Fassung
142
143
  ```
143
144
 
145
+ Ein Entwurf über einer aktiven Zeile (`upsertTenant` ohne `status`, `copy-locale`, `import-bundle`) liegt als JSON in `draft` neben der Zeile; die Hauptfelder bleiben die ausgelieferte Fassung. `find-by-id` und `layout-source` liefern die Arbeitsfassung (Entwurf, sonst gespeichert) mit `active { version, since }`.
144
146
  Resolver returnt **nur** Templates mit `status: "active"`. draft/archived werden ignoriert.
145
147
 
146
148
  ## Consumer Conformance
@@ -18,6 +18,7 @@ import {
18
18
  type TemplatePdfAttachmentLink,
19
19
  } from "./linked-resources.js";
20
20
  import { type TemplateResourceRow, templateResourcesTable } from "./table.js";
21
+ import { activeVersionOf, servedVersionOf } from "./template-draft.js";
21
22
  import { parseTemplateMeta, type TemplateMeta } from "./template-meta.js";
22
23
 
23
24
  // Public TemplateResource: what consumers see. Hides DB-internal columns
@@ -161,7 +162,7 @@ function pickActiveLayoutRow(
161
162
  ): TemplateResourceRow | null {
162
163
  const active = rows
163
164
  .filter((row) => row.status === "active")
164
- .sort((a, b) => b.version - a.version || a.locale.localeCompare(b.locale));
165
+ .sort((a, b) => activeVersionOf(b) - activeVersionOf(a) || a.locale.localeCompare(b.locale));
165
166
  return (
166
167
  active.find((row) => row.locale === locale) ??
167
168
  active.find((row) => row.locale === FALLBACK_LOCALE) ??
@@ -202,7 +203,7 @@ function toPublic(row: TemplateResourceRow): TemplateResource {
202
203
  const linkedEntries = parseLinkedResourceEntries(row.linkedResources);
203
204
  return {
204
205
  id: String(row.id),
205
- version: row.version,
206
+ version: servedVersionOf(row),
206
207
  tenantId: row.tenantId,
207
208
  slug: row.slug,
208
209
  kind,
@@ -1,4 +1,153 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "breaking",
5
+ "title": "copy-locale copies the system default's files in a beforeTransaction phase and can no longer run as a nested write",
6
+ "detail": "When a tenant copies a locale from the system default, the image and attachment files are now read and written to the tenant's storage in a beforeTransaction phase. The transaction only creates the fileRef rows, and the uploaded binaries are released when it does not commit. Before, the storage I/O held the database transaction open.",
7
+ "migration": "Dispatch copy-locale as a top-level command, or from a perCommand handler via ctx.write. A handler that called it nested moves that call into a client or job, or becomes a perCommand handler."
8
+ },
9
+ {
10
+ "version": "0.365.0",
11
+ "type": "fix",
12
+ "title": "Opening a document or mail editor with an empty body no longer marks the form as changed",
13
+ "detail": "Switching the editor's editable state on mount emitted an update, so DocumentTiptapEditor called onChange(\"<p></p>\") before the user typed anything and the surrounding form counted as dirty."
14
+ },
15
+ {
16
+ "version": "0.365.0",
17
+ "type": "breaking",
18
+ "title": "onEditorReady receives null when the editor is destroyed",
19
+ "detail": "DocumentTiptapEditor and PlaceholderLineEditor call onEditorReady(null) on destroy, so a held handle no longer points at a destroyed editor. The callback type is (handle: DocumentEditorHandle | null) => void. The document designer drops the handle of an unmounted field, so placeholder insertion no longer targets a destroyed editor.",
20
+ "migration": "A custom onEditorReady callback must accept null and drop its stored handle when it receives it."
21
+ },
22
+ {
23
+ "version": "0.365.0",
24
+ "type": "fix",
25
+ "title": "Document editor no longer blanks the app when late placeholders rebuild it",
26
+ "detail": "When the system placeholders or resource previews arrived in the same render as a new value (layout editor after opening or saving), the editor rebuilt itself and the value sync then read the destroyed editor. The TypeError unmounted the whole app and left a blank page. Both sync effects now skip a destroyed editor; the replacement editor picks up the value on its next render."
27
+ },
28
+ {
29
+ "version": "0.365.0",
30
+ "type": "improvement",
31
+ "title": "Steps block in mail and PDF templates",
32
+ "detail": "The document editor offers a steps block in the toolbar block menu. It stores a fixed list of stages (value=label, at most 8) and the variable that names the current stage. Mail and PDF render it as a row of bars and labels; stages up to the current one are coloured, the current label is bold. Invalid blocks are dropped by the sanitizer and render nothing. Layout regions do not offer the block."
33
+ },
34
+ {
35
+ "version": "0.365.0",
36
+ "type": "fix",
37
+ "title": "A paragraph or heading that only holds an empty placeholder is left out of the rendered document",
38
+ "detail": "compileDocumentBodyToHandlebars wraps a p or h1 to h4 whose only content is one placeholder in an if block, so an empty optional value no longer leaves a blank line in mails and PDFs. Zero numbers and money values still render. Blocks with static text, images or several placeholders are unchanged."
39
+ },
40
+ {
41
+ "version": "0.365.0",
42
+ "type": "improvement",
43
+ "title": "Mail layout buttons follow the branding accent colour",
44
+ "detail": "mailLayoutAppearance takes the tenant branding accent colour. When the layout uses the branding accent line, buttons in the mail body use that colour instead of the fixed dark teal. Invalid colours fall back to the previous default."
45
+ },
46
+ {
47
+ "version": "0.365.0",
48
+ "type": "fix",
49
+ "title": "A placeholder inside a button label is kept",
50
+ "detail": "The document schema allows placeholder chips inside a button label. Before, saving or seeding a body such as a button reading \"View {{pageName}}\" split the button: the label stopped at the placeholder and the rest landed in a new paragraph outside the button."
51
+ },
52
+ {
53
+ "version": "0.365.0",
54
+ "type": "improvement",
55
+ "title": "compileDocumentBodyToHandlebars takes a height cap for resource images",
56
+ "detail": "The new resourceImageMaxHeightPx option adds max-height and width:auto to resource images. The mail renderer caps images in a layout header at 64 px, so a square logo stays small while a wide wordmark keeps its 180 px width cap."
57
+ },
58
+ {
59
+ "version": "0.365.0",
60
+ "type": "fix",
61
+ "title": "resource-upload refuses key collisions between images and attachments and linking a non-active PDF template",
62
+ "detail": "Uploading an attachment under a key that holds an inline image, or an image under a key that holds an attachment or linked PDF, fails with template_attachment_invalid (code key_in_use) instead of silently replacing the entry. resource-link-pdf only accepts an active document-pdf template, so draft or archived templates no longer produce mails that fail at send time. When the link write fails after the file was stored, the orphaned storage object is deleted."
63
+ },
64
+ {
65
+ "version": "0.365.0",
66
+ "type": "fix",
67
+ "title": "collection kinds fail at boot; validate, diff-to-system, overview, seeding, bundle import and placeholder checks follow the write rules",
68
+ "detail": "createTemplateResolverFeature throws for a collection whose kind is mail, mail-layout or pdf-layout instead of failing on every save. The validate query ignores scope system for non-SystemAdmins. diff-to-system compares only against an active system template like reset-to-system. The overview reads rows in a stable order and reports truncated when its row limit is hit. Re-seeding notification templates reactivates an archived system row and applies a changed parentTemplateId. A bundle holds at most 50 locales. Dotted placeholders under a declared variable or list item field are reported as unknown because they never render a value. The document editor fallbacks are read-only and the placeholder line editor recolors chips when systemVariables change."
69
+ },
70
+ {
71
+ "version": "0.365.0",
72
+ "type": "fix",
73
+ "title": "Resource image formats come from one list",
74
+ "detail": "PNG, JPEG, GIF and WebP content types and extensions are defined once in the shared module and reused by the resource upload, the document body compiler and the email channel."
75
+ },
76
+ {
77
+ "version": "0.365.0",
78
+ "type": "breaking",
79
+ "title": "resource-upload, import-bundle and import-bundle-system upload in a beforeTransaction phase and can no longer run as nested writes",
80
+ "detail": "The three handlers now validate and upload their files before the write transaction opens and only create the fileRef and template rows inside it, so no database connection sits idle in a transaction during storage I/O. A rolled-back transaction releases the uploaded bytes. Because they now have a pre-transaction phase, the dispatcher rejects a nested ctx.write, ctx.writeAs or handler-to-handler call of them (\"has a pre-transaction phase\"). The exported storeTemplateResource is replaced by prepareTemplateResourceBeforeTransaction (beforeTransaction half) and storePreparedTemplateResource (in-transaction half).",
81
+ "migration": "Dispatch resource-upload, import-bundle and import-bundle-system as top-level commands, or from a perCommand handler via ctx.write. A handler that called them nested moves that call into a client or job, or becomes a perCommand handler. Code that called storeTemplateResource calls prepareTemplateResourceBeforeTransaction in a beforeTransaction phase and passes the result to storePreparedTemplateResource in the handler."
82
+ },
83
+ {
84
+ "version": "0.364.0",
85
+ "type": "fix",
86
+ "title": "PDF collections show Page break instead of Button, and placeholder links no longer insert {{name}} text",
87
+ "detail": "The content editor for a `document-pdf` collection now offers the PDF block set (including Page break) instead of the mail set, chosen from the collection's kind. A link to a placeholder href such as `{{invoice.url}}` without a selection now links a variable node instead of inserting the placeholder as plain text."
88
+ },
89
+ {
90
+ "version": "0.364.0",
91
+ "type": "fix",
92
+ "title": "Document sanitizer rejects prototype-style placeholder hrefs",
93
+ "detail": "`{{__proto__}}` and `{{constructor.prototype}}` are no longer accepted as link or button placeholder hrefs; only valid reference names pass."
94
+ },
95
+ {
96
+ "version": "0.364.0",
97
+ "type": "breaking",
98
+ "title": "DocumentRepeatRow is removed, use DocumentTableRow",
99
+ "detail": "Removed the DocumentRepeatRow alias",
100
+ "migration": "Replace `DocumentRepeatRow` with `DocumentTableRow` (identical shape)."
101
+ },
102
+ {
103
+ "version": "0.364.0",
104
+ "type": "fix",
105
+ "title": "export-bundle for a system template with resources returns a validation error outside the system tenant",
106
+ "detail": "A TenantAdmin, or a SystemAdmin outside the system tenant, exporting a system template that links resource files now gets the `system_resources_need_system_tenant` validation error, the same code import-bundle-system uses, instead of a NotFound for the resource file."
107
+ },
108
+ {
109
+ "version": "0.364.0",
110
+ "type": "fix",
111
+ "title": "send-test logs failed test mails with error class and tenant",
112
+ "detail": "A transport that throws or reports not sent is logged with the error class/code and tenant id (no address, no provider body) and still answers `template_test_mail_failed`."
113
+ },
114
+ {
115
+ "version": "0.364.0",
116
+ "type": "fix",
117
+ "title": "copy-locale ignores archived tenant sources and duplicates linked files of system sources; export-bundle refuses locales with differing format",
118
+ "detail": "copy-locale no longer copies from an archived tenant template, and copying a system template duplicates its linked resource files into the target tenant. export-bundle rejects a bundle whose locales differ in content format or variable schema with `bundleLocalesDiffer`."
119
+ },
120
+ {
121
+ "version": "0.364.0",
122
+ "type": "fix",
123
+ "title": "Template preview counts encoding overhead and reports missing attachments",
124
+ "detail": "The mail size estimate counts base64 size plus a fixed per-part overhead, an attachment whose file reference is missing fails the resources_resolved check, and the document draft scope is derived once per request."
125
+ },
126
+ {
127
+ "version": "0.364.0",
128
+ "type": "breaking",
129
+ "title": "A draft no longer replaces the active template",
130
+ "detail": "Saving a draft (upsert-tenant default, copy-locale, import-bundle with status draft) over an active template stores it beside the active version in the new column draft instead of overwriting content, meta and status; resolveTemplate, resolveLayout, preview without content and sends keep using the active version. publish and publish-system promote an open draft (the publish gate checks the draft) and record activeVersion and activeSince; the new handlers discard-draft and discard-draft-system drop it and refuse with no_draft when none is open. find-by-id and layout-source return the working version (the open draft with status draft, else the stored one) plus active { version, since }, null when nothing is served. overview keeps status and version of the served version and adds draftVersion and activeSince. A draft over a row that is not active (new, draft or archived) behaves as before. A tenant never sees system draft content: find-by-id, layout-source, overview, copy-locale and diff-to-system apply a system draft only for a SystemAdmin and hide system rows that were never served; list leaves those rows out too. Version numbers shown for an active row are the served version (activeVersion): resolveTemplate / resolveLayout results, tenant-overrides rows and the override diff no longer show the save counter that drafts raise.",
131
+ "migration": "Run kumiko schema generate and apply the migration before deploying: it adds the nullable columns draft, active_version and active_since to read_template_resources. No rebuild needed."
132
+ },
133
+ {
134
+ "version": "0.364.0",
135
+ "type": "fix",
136
+ "title": "template-resolver:query:overview accepts layoutSlug to list only the mails and PDFs using that layout",
137
+ "detail": "The optional layoutSlug payload field keeps only documents whose layout slug matches; total counts the filtered rows. Without it the response is unchanged."
138
+ },
139
+ {
140
+ "version": "0.364.0",
141
+ "type": "fix",
142
+ "title": "Layout usage follows the effective template per notification and locale",
143
+ "detail": "Mail layout usage counts a notification only when it declares a template and resolves the effective row per notification and locale, so tenant overrides are reflected."
144
+ },
145
+ {
146
+ "version": "0.364.0",
147
+ "type": "improvement",
148
+ "title": "templateVariable.richText for notification variables that carry formatted HTML",
149
+ "detail": "Notification variables are escaped, so a value holding HTML (for example a document written in a rich text editor) showed up as literal tags in the mail. A variable declared with templateVariable.richText({ required }) is run through the document sanitizer at render time and inserted as HTML; bold, italic, lists and safe links stay, script, event handlers and javascript links are not emitted as markup. If the sanitizer rejects a value, it is rendered as escaped text and a warning with the violation codes is logged. All other variable types stay escaped."
150
+ },
2
151
  {
3
152
  "version": "0.363.0",
4
153
  "type": "improvement",
@@ -1,6 +1,8 @@
1
1
  import type { TemplateVariableDef, TemplateVariables } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
3
3
  import type { Element as ScanElement } from "happy-dom";
4
+ import { isResourceImageContentType } from "../shared/index.js";
5
+ import { rewriteStepsBlock } from "./compile-steps-block.js";
4
6
  import { withParsedDocument } from "./document-sanitizer.js";
5
7
  import {
6
8
  DOCUMENT_TEXT_ALIGNMENTS,
@@ -60,6 +62,7 @@ function resolveReference(
60
62
  function valueExpression(path: string, def: TemplateVariableDef | undefined): string {
61
63
  if (def?.type === "money") return `{{money ${path}}}`;
62
64
  if (def?.type === "date") return `{{date ${path}}}`;
65
+ if (def?.type === "richText") return `{{richText ${path}}}`;
63
66
  return `{{${path}}}`;
64
67
  }
65
68
 
@@ -82,6 +85,39 @@ function pathAndDef(
82
85
  return [path, def];
83
86
  }
84
87
 
88
+ const VARIABLE_ONLY_BLOCK_SELECTOR = "p, h1, h2, h3, h4";
89
+
90
+ function soleVariableName(block: ScanElement): string | null {
91
+ const names = new Set(
92
+ Array.from(block.querySelectorAll("span[data-variable]")).map(
93
+ (span) => span.getAttribute("data-variable") ?? "",
94
+ ),
95
+ );
96
+ const [name] = [...names];
97
+ if (names.size !== 1 || name === undefined || !isDocumentReferenceName(name)) return null;
98
+ const withoutVariables = block.cloneNode(true);
99
+ for (const span of Array.from(withoutVariables.querySelectorAll("span[data-variable]"))) {
100
+ span.remove();
101
+ }
102
+ const hasStaticContent =
103
+ (withoutVariables.textContent ?? "").trim() !== "" ||
104
+ withoutVariables.querySelector("img") !== null;
105
+ return hasStaticContent ? null : name;
106
+ }
107
+
108
+ // A line that holds nothing but one optional placeholder would otherwise leave
109
+ // an empty paragraph (a visible gap) when the value is missing. includeZero
110
+ // keeps an amount of 0.
111
+ function wrapVariableOnlyBlock(block: ScanElement, state: CompileState): void {
112
+ const name = soleVariableName(block);
113
+ // skip: a line with static content or several placeholders stays as it is
114
+ if (name === null) return;
115
+ const [path] = pathAndDef(name, block, state);
116
+ const document = block.ownerDocument;
117
+ block.before(document.createTextNode(marker(state, `{{#if ${path} includeZero=true}}`)));
118
+ block.after(document.createTextNode(marker(state, "{{/if}}")));
119
+ }
120
+
85
121
  function rewriteLink(element: ScanElement, state: CompileState): void {
86
122
  const href = element.getAttribute("href") ?? "";
87
123
  if (isDocumentPlaceholderHref(href)) {
@@ -123,16 +159,43 @@ function neutralizeBraces(html: string): string {
123
159
  export type CompileDocumentBodyOptions = {
124
160
  // Returns the src for an `img[data-resource]` key; undefined drops the image.
125
161
  readonly resourceSrc?: (key: string) => string | undefined;
162
+ // Mail clients ignore document CSS, so a full-width logo needs an inline width cap.
163
+ readonly resourceImageMaxWidthPx?: number;
164
+ // Keeps a square logo in a mail header from growing to the full width cap.
165
+ readonly resourceImageMaxHeightPx?: number;
126
166
  // Mail clients ignore document CSS, so mail turns data-align into an inline style.
127
167
  readonly alignmentAsInlineStyle?: boolean;
168
+ // Same reason: a[data-button] only looks like a button through editor/print CSS.
169
+ readonly buttonAsInlineStyle?: boolean;
170
+ // Same reason: cell padding and table width only exist in editor/print CSS, so without
171
+ // inline styles a mail table renders its cells flush against each other.
172
+ readonly tableAsInlineStyle?: boolean;
173
+ // Replaces the default button look; only values from the layout appearance belong here.
174
+ readonly buttonInlineStyle?: string;
175
+ // Mail clients ignore document CSS, so headings get their size and weight inline.
176
+ readonly headingAsInlineStyle?: boolean;
177
+ // Bar color of reached stages in a steps block; ignored unless it is a #rrggbb hex.
178
+ readonly stepsColor?: string;
179
+ };
180
+
181
+ const MAIL_BUTTON_STYLE =
182
+ "display:inline-block;padding:10px 20px;border-radius:6px;background-color:#1f5468;color:#ffffff;font-weight:600;text-decoration:none";
183
+
184
+ const MAIL_TABLE_STYLE = "width:100%;border-collapse:collapse";
185
+ const MAIL_TABLE_CELL_STYLE = "padding:6px 12px;vertical-align:top;border-bottom:1px solid #e5e5e5";
186
+ const MAIL_TABLE_HEAD_STYLE = "font-size:12px;font-weight:600;color:#6b7280;text-align:left";
187
+ const MAIL_SMALL_STYLE = "font-size:13px;color:#4A515E";
188
+ const MAIL_HEADING_STYLES: Readonly<Record<string, string>> = {
189
+ h1: "margin:0 0 12px 0;font-size:30px;line-height:1.15;font-weight:700;letter-spacing:-0.035em;color:#0B0D12",
190
+ h2: "margin:0 0 12px 0;font-size:24px;line-height:1.2;font-weight:700;letter-spacing:-0.02em;color:#0B0D12",
191
+ h3: "margin:20px 0 8px 0;font-size:17px;line-height:1.3;font-weight:600;color:#0B0D12",
128
192
  };
129
193
 
130
- const RESOURCE_IMAGE_CONTENT_TYPES: ReadonlySet<string> = new Set([
131
- "image/png",
132
- "image/jpeg",
133
- "image/gif",
134
- "image/webp",
135
- ]);
194
+ // Appended after an existing style (data-align) so the alignment survives.
195
+ function appendStyle(element: ScanElement, style: string): void {
196
+ const existing = element.getAttribute("style");
197
+ element.setAttribute("style", existing === null ? style : `${existing};${style}`);
198
+ }
136
199
 
137
200
  // Unique valid resource keys in document order.
138
201
  export function collectDocumentResourceKeys(html: string): readonly string[] {
@@ -147,7 +210,7 @@ export function collectDocumentResourceKeys(html: string): readonly string[] {
147
210
  }
148
211
 
149
212
  export function toResourceDataUri(contentType: string, bytes: Uint8Array): string {
150
- if (!RESOURCE_IMAGE_CONTENT_TYPES.has(contentType)) {
213
+ if (!isResourceImageContentType(contentType)) {
151
214
  throw new Error(`resource content type "${contentType}" cannot be embedded as an image`);
152
215
  }
153
216
  return `data:${contentType};base64,${Buffer.from(bytes).toString("base64")}`;
@@ -163,6 +226,8 @@ function rewriteAlignment(element: ScanElement): void {
163
226
  function rewriteResourceImage(
164
227
  element: ScanElement,
165
228
  resourceSrc: CompileDocumentBodyOptions["resourceSrc"],
229
+ maxWidthPx: number | undefined,
230
+ maxHeightPx: number | undefined,
166
231
  ): void {
167
232
  const key = element.getAttribute("data-resource") ?? "";
168
233
  const src = isDocumentResourceKey(key) ? resourceSrc?.(key) : undefined;
@@ -173,6 +238,69 @@ function rewriteResourceImage(
173
238
  }
174
239
  element.setAttribute("src", src);
175
240
  element.removeAttribute("data-resource");
241
+ const caps = [
242
+ ...(maxWidthPx === undefined ? [] : [`max-width:${maxWidthPx}px`]),
243
+ ...(maxHeightPx === undefined ? [] : [`max-height:${maxHeightPx}px;width:auto`]),
244
+ ];
245
+ if (caps.length > 0) element.setAttribute("style", `${caps.join(";")};height:auto`);
246
+ }
247
+
248
+ function stripScriptsAndActiveContent(
249
+ root: ScanElement,
250
+ options: CompileDocumentBodyOptions,
251
+ ): void {
252
+ for (const element of Array.from(root.querySelectorAll("script"))) element.remove();
253
+ for (const element of Array.from(root.querySelectorAll("img[data-resource]"))) {
254
+ rewriteResourceImage(
255
+ element,
256
+ options.resourceSrc,
257
+ options.resourceImageMaxWidthPx,
258
+ options.resourceImageMaxHeightPx,
259
+ );
260
+ }
261
+ for (const element of Array.from(root.querySelectorAll("*"))) stripActiveContent(element);
262
+ }
263
+
264
+ function applyAlignmentInlineStyles(root: ScanElement): void {
265
+ for (const element of Array.from(root.querySelectorAll("[data-align]"))) {
266
+ rewriteAlignment(element);
267
+ }
268
+ for (const element of Array.from(root.querySelectorAll("[data-size]"))) {
269
+ element.removeAttribute("data-size");
270
+ appendStyle(element, MAIL_SMALL_STYLE);
271
+ }
272
+ }
273
+
274
+ function applyHeadingInlineStyles(root: ScanElement): void {
275
+ for (const heading of Array.from(root.querySelectorAll("h1, h2, h3"))) {
276
+ const style = MAIL_HEADING_STYLES[heading.tagName.toLowerCase()];
277
+ if (style !== undefined) appendStyle(heading, style);
278
+ }
279
+ }
280
+
281
+ function applyTableInlineStyles(root: ScanElement): void {
282
+ for (const table of Array.from(root.querySelectorAll("table"))) {
283
+ appendStyle(table, MAIL_TABLE_STYLE);
284
+ }
285
+ for (const cell of Array.from(root.querySelectorAll("td, th"))) {
286
+ appendStyle(cell, MAIL_TABLE_CELL_STYLE);
287
+ }
288
+ for (const head of Array.from(root.querySelectorAll("th"))) {
289
+ appendStyle(head, MAIL_TABLE_HEAD_STYLE);
290
+ }
291
+ }
292
+
293
+ function rewriteLinks(
294
+ root: ScanElement,
295
+ state: CompileState,
296
+ options: CompileDocumentBodyOptions,
297
+ ): void {
298
+ for (const element of Array.from(root.querySelectorAll("a[href]"))) {
299
+ rewriteLink(element, state);
300
+ if (options.buttonAsInlineStyle === true && element.hasAttribute("data-button")) {
301
+ element.setAttribute("style", options.buttonInlineStyle ?? MAIL_BUTTON_STYLE);
302
+ }
303
+ }
176
304
  }
177
305
 
178
306
  // Turns a sanitized document body into Handlebars source. Rows and links are
@@ -186,22 +314,24 @@ export function compileDocumentBodyToHandlebars(
186
314
  const state: CompileState = { variables, expressions: [] };
187
315
  const scrubbed = html.replaceAll(EXPRESSION_OPEN, "").replaceAll(EXPRESSION_CLOSE, "");
188
316
  const compiled = withParsedDocument(scrubbed, (root) => {
189
- for (const element of Array.from(root.querySelectorAll("script"))) element.remove();
190
- for (const element of Array.from(root.querySelectorAll("img[data-resource]"))) {
191
- rewriteResourceImage(element, options.resourceSrc);
317
+ stripScriptsAndActiveContent(root, options);
318
+ if (options.alignmentAsInlineStyle === true) applyAlignmentInlineStyles(root);
319
+ if (options.headingAsInlineStyle === true) applyHeadingInlineStyles(root);
320
+ if (options.tableAsInlineStyle === true) applyTableInlineStyles(root);
321
+ for (const element of Array.from(root.querySelectorAll("div[data-steps]"))) {
322
+ rewriteStepsBlock(element, {
323
+ expression: (source) => marker(state, source),
324
+ pathOf: (name, at) => pathAndDef(name, at, state)[0],
325
+ color: options.stepsColor,
326
+ });
192
327
  }
193
- for (const element of Array.from(root.querySelectorAll("*"))) stripActiveContent(element);
194
- if (options.alignmentAsInlineStyle === true) {
195
- for (const element of Array.from(root.querySelectorAll("[data-align]"))) {
196
- rewriteAlignment(element);
197
- }
328
+ for (const block of Array.from(root.querySelectorAll(VARIABLE_ONLY_BLOCK_SELECTOR))) {
329
+ wrapVariableOnlyBlock(block, state);
198
330
  }
199
331
  for (const element of Array.from(root.querySelectorAll("span[data-variable]"))) {
200
332
  rewriteVariable(element, state);
201
333
  }
202
- for (const element of Array.from(root.querySelectorAll("a[href]"))) {
203
- rewriteLink(element, state);
204
- }
334
+ rewriteLinks(root, state, options);
205
335
  for (const element of Array.from(root.querySelectorAll("tr[data-each]"))) {
206
336
  rewriteRepeatRow(element, state);
207
337
  }
@@ -0,0 +1,68 @@
1
+ import type { Element as ScanElement } from "happy-dom";
2
+ import { isDocumentReferenceName } from "./document-schema.js";
3
+ import { readDocumentSteps } from "./document-steps.js";
4
+ import { isHexColor } from "./layout-appearance.js";
5
+
6
+ export const DEFAULT_STEPS_COLOR = "#1f5468";
7
+ const TODO_BAR_COLOR = "#ECEDEA";
8
+ const REACHED_LABEL_COLOR = "#0B0D12";
9
+ const TODO_LABEL_COLOR = "#5A6170";
10
+
11
+ export type StepsCompileHooks = {
12
+ /** Registers a Handlebars expression and returns the marker that stands for it. */
13
+ readonly expression: (source: string) => string;
14
+ /** Resolves a variable name to its Handlebars path at the element's position. */
15
+ readonly pathOf: (name: string, element: ScanElement) => string;
16
+ readonly color: string | undefined;
17
+ };
18
+
19
+ // Inline-styled table in every output (mail, PDF, preview): mail clients ignore
20
+ // document CSS, and one rendering keeps the three outputs identical. The labels
21
+ // stay static text; only the reached/current switch is a Handlebars block, and
22
+ // its arguments are a validated path and validated keys.
23
+ export function rewriteStepsBlock(element: ScanElement, hooks: StepsCompileHooks): void {
24
+ const name = element.getAttribute("data-steps") ?? "";
25
+ const steps = readDocumentSteps(element);
26
+ if (!isDocumentReferenceName(name) || steps === null) {
27
+ element.remove();
28
+ // skip: an invalid steps block renders nothing
29
+ return;
30
+ }
31
+ const path = hooks.pathOf(name, element);
32
+ const keys = steps.map((step) => step.key).join(",");
33
+ const color =
34
+ hooks.color !== undefined && isHexColor(hooks.color) ? hooks.color : DEFAULT_STEPS_COLOR;
35
+ const when = (index: number, state: "reached" | "current", yes: string, no: string): string =>
36
+ `${hooks.expression(`{{#stepIs ${path} ${index} "${keys}" "${state}"}}`)}${yes}${hooks.expression("{{else}}")}${no}${hooks.expression("{{/stepIs}}")}`;
37
+
38
+ const document = element.ownerDocument;
39
+ const table = document.createElement("table");
40
+ table.setAttribute("role", "presentation");
41
+ table.setAttribute("width", "100%");
42
+ table.setAttribute("cellpadding", "0");
43
+ table.setAttribute("cellspacing", "0");
44
+ table.setAttribute("style", "table-layout:fixed;border-collapse:collapse;margin:4px 0 24px 0");
45
+ const row = document.createElement("tr");
46
+ steps.forEach((step, index) => {
47
+ const cell = document.createElement("td");
48
+ cell.setAttribute(
49
+ "style",
50
+ `padding:0 ${index === steps.length - 1 ? 0 : 4}px 0 0;vertical-align:top`,
51
+ );
52
+ const bar = document.createElement("div");
53
+ bar.setAttribute(
54
+ "style",
55
+ `height:4px;line-height:4px;font-size:0;border-radius:2px;background-color:${when(index, "reached", color, TODO_BAR_COLOR)}`,
56
+ );
57
+ const label = document.createElement("div");
58
+ label.setAttribute(
59
+ "style",
60
+ `padding-top:8px;font-size:12px;line-height:1.3;color:${when(index, "reached", REACHED_LABEL_COLOR, TODO_LABEL_COLOR)};font-weight:${when(index, "current", "600", "400")}`,
61
+ );
62
+ label.textContent = step.label;
63
+ cell.append(bar, label);
64
+ row.append(cell);
65
+ });
66
+ table.append(row);
67
+ element.replaceWith(table);
68
+ }