@cosmicdrift/kumiko-bundled-features 0.362.0 → 0.363.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 (307) hide show
  1. package/dist/admin-shell/feature.d.ts +3 -0
  2. package/dist/admin-shell/feature.js +6 -1
  3. package/dist/agent-tools/agent-manifest.js +2 -1
  4. package/dist/agent-tools/filter-ops.d.ts +1 -1
  5. package/dist/agent-tools/filter-ops.js +1 -3
  6. package/dist/agent-tools/tool-dispatch.js +2 -1
  7. package/dist/auth-email-password/email-templates.d.ts +4 -26
  8. package/dist/auth-email-password/email-templates.js +9 -101
  9. package/dist/auth-email-password/feature.js +24 -6
  10. package/dist/auth-email-password/handlers/invite-create.write.js +4 -4
  11. package/dist/auth-email-password/handlers/request-account-unlock.write.js +2 -4
  12. package/dist/auth-email-password/handlers/request-email-verification.write.js +2 -4
  13. package/dist/auth-email-password/handlers/request-password-reset.write.js +2 -4
  14. package/dist/auth-email-password/handlers/signup-confirm.write.js +3 -4
  15. package/dist/auth-email-password/handlers/token-request-handler.d.ts +1 -2
  16. package/dist/auth-email-password/handlers/token-request-handler.js +0 -1
  17. package/dist/auth-email-password/index.d.ts +2 -2
  18. package/dist/auth-email-password/index.js +1 -1
  19. package/dist/auth-email-password/magic-link-mail.d.ts +3 -2
  20. package/dist/auth-email-password/magic-link-mail.js +19 -14
  21. package/dist/auth-email-password/mail-notifications.d.ts +32 -0
  22. package/dist/auth-email-password/mail-notifications.js +116 -0
  23. package/dist/auth-email-password/signup-activation.d.ts +1 -1
  24. package/dist/auth-email-password/signup-activation.js +2 -3
  25. package/dist/auth-foundation/types.js +2 -8
  26. package/dist/auth-mfa/feature.js +4 -4
  27. package/dist/auth-mfa/hmac-token-codec.js +4 -4
  28. package/dist/auth-mfa/mfa-challenge-token.js +3 -3
  29. package/dist/auth-mfa/mfa-preauth-setup-token.js +3 -3
  30. package/dist/auth-mfa/mfa-setup-token.js +8 -8
  31. package/dist/billing-foundation/constants.d.ts +3 -3
  32. package/dist/billing-foundation/constants.js +5 -4
  33. package/dist/billing-foundation/consumer-protection/confirmation-mail.d.ts +32 -14
  34. package/dist/billing-foundation/consumer-protection/confirmation-mail.js +27 -32
  35. package/dist/billing-foundation/consumer-protection/issue-confirmation.js +3 -3
  36. package/dist/billing-foundation/consumer-protection/mail-notifications.d.ts +8 -0
  37. package/dist/billing-foundation/consumer-protection/mail-notifications.js +95 -0
  38. package/dist/billing-foundation/consumer-protection/register-consent.js +2 -0
  39. package/dist/billing-foundation/consumer-protection/termination-mail.d.ts +20 -10
  40. package/dist/billing-foundation/consumer-protection/termination-mail.js +10 -21
  41. package/dist/billing-foundation/consumer-protection/termination-notify.js +2 -2
  42. package/dist/billing-foundation/consumer-protection/termination-pages.js +1 -3
  43. package/dist/billing-foundation/consumer-protection/termination-request.js +2 -2
  44. package/dist/billing-foundation/handlers/process-event.write.js +2 -2
  45. package/dist/billing-foundation/handlers/process-payment-event.write.js +1 -1
  46. package/dist/billing-foundation/subscription-tier-sync.js +3 -4
  47. package/dist/cap-overview/handlers/tenant-caps-list.query.js +2 -1
  48. package/dist/cap-overview/web/cap-usage-cell.js +5 -4
  49. package/dist/channel-email/email-channel.d.ts +2 -2
  50. package/dist/channel-email/email-channel.js +89 -26
  51. package/dist/channel-email/feature.js +3 -3
  52. package/dist/channel-email/index.d.ts +1 -1
  53. package/dist/channel-email/index.js +1 -1
  54. package/dist/channel-email/pii-guard.js +10 -0
  55. package/dist/channel-email/smtp-transport.js +13 -0
  56. package/dist/channel-email/types.d.ts +8 -0
  57. package/dist/config/handlers/set.write.js +1 -2
  58. package/dist/custom-fields/lib/value-schema.js +10 -11
  59. package/dist/custom-fields/wire-user-data-rights.js +7 -6
  60. package/dist/data-retention/index.d.ts +1 -0
  61. package/dist/data-retention/index.js +1 -0
  62. package/dist/data-retention/run-retention-cleanup.js +6 -6
  63. package/dist/delivery/attachment-ref.d.ts +39 -0
  64. package/dist/delivery/attachment-ref.js +64 -0
  65. package/dist/delivery/channel-context.d.ts +8 -4
  66. package/dist/delivery/channel-context.js +76 -2
  67. package/dist/delivery/constants.d.ts +1 -0
  68. package/dist/delivery/constants.js +1 -0
  69. package/dist/delivery/delivery-service.d.ts +4 -1
  70. package/dist/delivery/delivery-service.js +38 -32
  71. package/dist/delivery/feature.js +1 -1
  72. package/dist/delivery/index.d.ts +5 -2
  73. package/dist/delivery/index.js +4 -1
  74. package/dist/delivery/jobs.js +25 -5
  75. package/dist/delivery/notify-factory.js +4 -1
  76. package/dist/delivery/testing.d.ts +4 -1
  77. package/dist/delivery/types.d.ts +20 -14
  78. package/dist/delivery/unsubscribe.js +4 -1
  79. package/dist/derivatives-sharp/render.js +1 -18
  80. package/dist/document-ingest-foundation/pages.js +4 -4
  81. package/dist/feature-toggles/toggle-sync-signal.js +4 -4
  82. package/dist/file-foundation/index.d.ts +1 -0
  83. package/dist/file-foundation/index.js +1 -0
  84. package/dist/file-foundation/read-file-ref-content.d.ts +27 -0
  85. package/dist/file-foundation/read-file-ref-content.js +49 -0
  86. package/dist/folders/web/folder-manager.js +3 -2
  87. package/dist/form-draft/db/queries/cleanup.js +4 -4
  88. package/dist/form-draft/db/queries/draft-count.js +2 -2
  89. package/dist/form-draft/db/queries/owned-file-refs.js +2 -2
  90. package/dist/form-draft/release-file-refs.js +3 -2
  91. package/dist/inbound-mail-foundation/handlers/connect-account.write.js +1 -1
  92. package/dist/inbound-mail-foundation/handlers/ingest-message.write.js +6 -6
  93. package/dist/inbound-mail-foundation/handlers/scope-visibility.d.ts +1 -1
  94. package/dist/inbound-mail-foundation/oauth-state.js +7 -7
  95. package/dist/inbound-mail-foundation/types.js +4 -4
  96. package/dist/inbound-mail-foundation/watch-supervisor.js +22 -13
  97. package/dist/inbound-provider-imap/imap-client.js +4 -3
  98. package/dist/ledger/recurring.js +1 -3
  99. package/dist/managed-pages/branding.d.ts +2 -0
  100. package/dist/managed-pages/branding.js +24 -2
  101. package/dist/managed-pages/feature.js +1 -0
  102. package/dist/managed-pages/i18n.js +1 -0
  103. package/dist/managed-pages/screens/branding-screen.js +8 -1
  104. package/dist/page-render/branding.d.ts +1 -0
  105. package/dist/page-render/branding.js +1 -0
  106. package/dist/page-render/markdown.js +1 -1
  107. package/dist/personal-access-tokens/feature.js +1 -1
  108. package/dist/personal-access-tokens/handlers/list.query.js +2 -1
  109. package/dist/personal-access-tokens/revoke-for-user.js +2 -1
  110. package/dist/renderer-foundation/api.d.ts +13 -13
  111. package/dist/renderer-foundation/api.js +1 -1
  112. package/dist/renderer-foundation/constants.js +2 -4
  113. package/dist/renderer-foundation/feature.js +7 -7
  114. package/dist/renderer-foundation/index.d.ts +2 -1
  115. package/dist/renderer-foundation/index.js +1 -0
  116. package/dist/renderer-foundation/types.d.ts +92 -32
  117. package/dist/renderer-pdf/feature.js +7 -1
  118. package/dist/sessions/db/queries/cleanup.js +2 -2
  119. package/dist/sessions/feature.js +1 -1
  120. package/dist/sessions/handlers/list.query.js +2 -1
  121. package/dist/shared/attachment-file-name.d.ts +3 -0
  122. package/dist/shared/attachment-file-name.js +35 -0
  123. package/dist/shared/index.d.ts +3 -0
  124. package/dist/shared/index.js +3 -0
  125. package/dist/shared/is-tenant-db.js +1 -1
  126. package/dist/shared/mail-template-body.d.ts +4 -0
  127. package/dist/shared/mail-template-body.js +17 -0
  128. package/dist/shared/sharp-input-limits.d.ts +6 -0
  129. package/dist/shared/sharp-input-limits.js +22 -0
  130. package/dist/shared/signup-handover.js +2 -2
  131. package/dist/tags/handlers/assign-tag.write.js +3 -1
  132. package/dist/tags/web/tag-chip.js +2 -0
  133. package/dist/template-resolver/api.d.ts +30 -2
  134. package/dist/template-resolver/api.js +40 -5
  135. package/dist/template-resolver/compile-document-body.d.ts +8 -0
  136. package/dist/template-resolver/compile-document-body.js +163 -0
  137. package/dist/template-resolver/constants.d.ts +13 -4
  138. package/dist/template-resolver/constants.js +30 -10
  139. package/dist/template-resolver/document-sanitizer.d.ts +32 -0
  140. package/dist/template-resolver/document-sanitizer.js +201 -0
  141. package/dist/template-resolver/document-schema.d.ts +21 -0
  142. package/dist/template-resolver/document-schema.js +296 -0
  143. package/dist/template-resolver/feature.d.ts +18 -0
  144. package/dist/template-resolver/feature.js +50 -6
  145. package/dist/template-resolver/handlers/bundle-import.d.ts +20 -0
  146. package/dist/template-resolver/handlers/bundle-import.js +147 -0
  147. package/dist/template-resolver/handlers/collection-set.write.js +25 -2
  148. package/dist/template-resolver/handlers/copy-locale.write.d.ts +19 -0
  149. package/dist/template-resolver/handlers/copy-locale.write.js +116 -0
  150. package/dist/template-resolver/handlers/diff-to-system.query.d.ts +22 -0
  151. package/dist/template-resolver/handlers/diff-to-system.query.js +64 -0
  152. package/dist/template-resolver/handlers/export-bundle.query.d.ts +35 -0
  153. package/dist/template-resolver/handlers/export-bundle.query.js +68 -0
  154. package/dist/template-resolver/handlers/find-by-id.query.d.ts +1 -0
  155. package/dist/template-resolver/handlers/find-by-id.query.js +3 -3
  156. package/dist/template-resolver/handlers/import-bundle-system.write.d.ts +39 -0
  157. package/dist/template-resolver/handlers/import-bundle-system.write.js +36 -0
  158. package/dist/template-resolver/handlers/import-bundle.write.d.ts +40 -0
  159. package/dist/template-resolver/handlers/import-bundle.write.js +19 -0
  160. package/dist/template-resolver/handlers/layout-source.query.d.ts +26 -0
  161. package/dist/template-resolver/handlers/layout-source.query.js +64 -0
  162. package/dist/template-resolver/handlers/list.query.d.ts +4 -1
  163. package/dist/template-resolver/handlers/list.query.js +5 -7
  164. package/dist/template-resolver/handlers/overview.query.d.ts +59 -0
  165. package/dist/template-resolver/handlers/overview.query.js +165 -0
  166. package/dist/template-resolver/handlers/preview.query.d.ts +12 -0
  167. package/dist/template-resolver/handlers/preview.query.js +24 -0
  168. package/dist/template-resolver/handlers/reset-to-system.write.d.ts +8 -0
  169. package/dist/template-resolver/handlers/reset-to-system.write.js +50 -0
  170. package/dist/template-resolver/handlers/resource-link-pdf.write.d.ts +8 -0
  171. package/dist/template-resolver/handlers/resource-link-pdf.write.js +32 -0
  172. package/dist/template-resolver/handlers/resource-remove.write.d.ts +9 -0
  173. package/dist/template-resolver/handlers/resource-remove.write.js +44 -0
  174. package/dist/template-resolver/handlers/resource-shared.d.ts +9 -0
  175. package/dist/template-resolver/handlers/resource-shared.js +14 -0
  176. package/dist/template-resolver/handlers/resource-upload.write.d.ts +13 -0
  177. package/dist/template-resolver/handlers/resource-upload.write.js +98 -0
  178. package/dist/template-resolver/handlers/send-test.write.d.ts +11 -0
  179. package/dist/template-resolver/handlers/send-test.write.js +81 -0
  180. package/dist/template-resolver/handlers/set.write.d.ts +4 -1
  181. package/dist/template-resolver/handlers/set.write.js +25 -3
  182. package/dist/template-resolver/handlers/shared.d.ts +9 -5
  183. package/dist/template-resolver/handlers/shared.js +6 -2
  184. package/dist/template-resolver/handlers/template-gates.d.ts +36 -0
  185. package/dist/template-resolver/handlers/template-gates.js +131 -0
  186. package/dist/template-resolver/handlers/template-write.d.ts +45 -0
  187. package/dist/template-resolver/handlers/template-write.js +109 -0
  188. package/dist/template-resolver/handlers/tenant-overrides.query.d.ts +17 -0
  189. package/dist/template-resolver/handlers/tenant-overrides.query.js +48 -0
  190. package/dist/template-resolver/handlers/toggle-status.write.d.ts +12 -0
  191. package/dist/template-resolver/handlers/toggle-status.write.js +27 -11
  192. package/dist/template-resolver/handlers/upsert-system.write.d.ts +5 -8
  193. package/dist/template-resolver/handlers/upsert-system.write.js +14 -59
  194. package/dist/template-resolver/handlers/upsert-tenant.write.d.ts +5 -8
  195. package/dist/template-resolver/handlers/upsert-tenant.write.js +10 -61
  196. package/dist/template-resolver/handlers/usage.query.d.ts +17 -0
  197. package/dist/template-resolver/handlers/usage.query.js +15 -0
  198. package/dist/template-resolver/handlers/validate.query.d.ts +74 -0
  199. package/dist/template-resolver/handlers/validate.query.js +80 -0
  200. package/dist/template-resolver/index.d.ts +18 -1
  201. package/dist/template-resolver/index.js +16 -1
  202. package/dist/template-resolver/layout-regions.d.ts +23 -0
  203. package/dist/template-resolver/layout-regions.js +125 -0
  204. package/dist/template-resolver/linked-resources.d.ts +17 -0
  205. package/dist/template-resolver/linked-resources.js +75 -0
  206. package/dist/template-resolver/preview-display-html.d.ts +7 -0
  207. package/dist/template-resolver/preview-display-html.js +27 -0
  208. package/dist/template-resolver/print-document.d.ts +1 -0
  209. package/dist/template-resolver/print-document.js +55 -0
  210. package/dist/template-resolver/qualified-names.d.ts +30 -0
  211. package/dist/template-resolver/qualified-names.js +30 -0
  212. package/dist/template-resolver/record-mapping.d.ts +2 -0
  213. package/dist/template-resolver/record-mapping.js +21 -0
  214. package/dist/template-resolver/resource-image.d.ts +15 -0
  215. package/dist/template-resolver/resource-image.js +34 -0
  216. package/dist/template-resolver/seeding.d.ts +14 -1
  217. package/dist/template-resolver/seeding.js +105 -15
  218. package/dist/template-resolver/store-template-resource.d.ts +64 -0
  219. package/dist/template-resolver/store-template-resource.js +264 -0
  220. package/dist/template-resolver/table.d.ts +47 -8
  221. package/dist/template-resolver/table.js +11 -0
  222. package/dist/template-resolver/template-bundle.d.ts +36 -0
  223. package/dist/template-resolver/template-bundle.js +60 -0
  224. package/dist/template-resolver/template-diff.d.ts +7 -0
  225. package/dist/template-resolver/template-diff.js +68 -0
  226. package/dist/template-resolver/template-meta.d.ts +60 -0
  227. package/dist/template-resolver/template-meta.js +101 -0
  228. package/dist/template-resolver/template-preview.d.ts +86 -0
  229. package/dist/template-resolver/template-preview.js +374 -0
  230. package/dist/template-resolver/template-resources.d.ts +14 -0
  231. package/dist/template-resolver/template-resources.js +42 -0
  232. package/dist/template-resolver/template-usage.d.ts +12 -0
  233. package/dist/template-resolver/template-usage.js +30 -0
  234. package/dist/template-resolver/template-validation.d.ts +22 -0
  235. package/dist/template-resolver/template-validation.js +172 -0
  236. package/dist/template-resolver/testing.js +5 -5
  237. package/dist/template-resolver/user-content-table.d.ts +14 -8
  238. package/dist/template-resolver/web/client-plugin.d.ts +1 -0
  239. package/dist/template-resolver/web/client-plugin.js +9 -6
  240. package/dist/template-resolver/web/document-editor-extensions.d.ts +15 -0
  241. package/dist/template-resolver/web/document-editor-extensions.js +107 -0
  242. package/dist/template-resolver/web/document-editor.d.ts +3 -0
  243. package/dist/template-resolver/web/document-editor.js +11 -0
  244. package/dist/template-resolver/web/document-tiptap-editor.d.ts +3 -0
  245. package/dist/template-resolver/web/document-tiptap-editor.js +376 -0
  246. package/dist/template-resolver/web/document-toolbar-blocks.d.ts +33 -0
  247. package/dist/template-resolver/web/document-toolbar-blocks.js +15 -0
  248. package/dist/template-resolver/web/editor-handle.d.ts +3 -0
  249. package/dist/template-resolver/web/editor-handle.js +24 -0
  250. package/dist/template-resolver/web/i18n.js +76 -0
  251. package/dist/template-resolver/web/index.d.ts +4 -0
  252. package/dist/template-resolver/web/index.js +4 -0
  253. package/dist/template-resolver/web/placeholder-line-editor-lazy.d.ts +4 -0
  254. package/dist/template-resolver/web/placeholder-line-editor-lazy.js +16 -0
  255. package/dist/template-resolver/web/placeholder-line-editor.d.ts +15 -0
  256. package/dist/template-resolver/web/placeholder-line-editor.js +94 -0
  257. package/dist/template-resolver/web/placeholder-text.d.ts +4 -0
  258. package/dist/template-resolver/web/placeholder-text.js +34 -0
  259. package/dist/template-resolver/web/use-stable-by-key.d.ts +1 -0
  260. package/dist/template-resolver/web/use-stable-by-key.js +13 -0
  261. package/dist/tenant/handlers/team-list.query.js +3 -2
  262. package/dist/tenant/seeding.js +31 -6
  263. package/dist/tenant-lifecycle/handlers/cancel-destruction.write.d.ts +1 -1
  264. package/dist/tenant-lifecycle/handlers/cancel-destruction.write.js +2 -1
  265. package/dist/user/db/queries/stream-tenant-backfill.js +4 -4
  266. package/dist/user/handlers/detail.query.js +2 -1
  267. package/dist/user-data-rights/db/queries/forget-cleanup.d.ts +2 -2
  268. package/dist/user-data-rights/email-templates.d.ts +1 -29
  269. package/dist/user-data-rights/email-templates.js +5 -106
  270. package/dist/user-data-rights/feature.js +53 -35
  271. package/dist/user-data-rights/handlers/cancel-deletion.write.d.ts +1 -1
  272. package/dist/user-data-rights/handlers/cancel-deletion.write.js +2 -1
  273. package/dist/user-data-rights/handlers/request-deletion.write.js +5 -7
  274. package/dist/user-data-rights/lib/default-mailers.d.ts +15 -7
  275. package/dist/user-data-rights/lib/default-mailers.js +100 -48
  276. package/dist/user-data-rights/lib/update-user-lifecycle.js +1 -1
  277. package/dist/user-data-rights/mail-notifications.d.ts +13 -0
  278. package/dist/user-data-rights/mail-notifications.js +118 -0
  279. package/dist/user-data-rights/web/privacy-center-screen.js +2 -1
  280. package/dist/user-profile/web/profile-screen.js +2 -1
  281. package/dist/waitlist/feature.js +10 -0
  282. package/dist/waitlist/handlers/submit.write.js +3 -4
  283. package/dist/waitlist/mail.d.ts +19 -6
  284. package/dist/waitlist/mail.js +50 -14
  285. package/package.json +28 -17
  286. package/src/admin-shell/changes.json +6 -0
  287. package/src/auth-email-password/changes.json +14 -0
  288. package/src/billing-foundation/changes.json +7 -0
  289. package/src/channel-email/changes.json +19 -0
  290. package/src/data-retention/changes.json +6 -0
  291. package/src/delivery/changes.json +48 -0
  292. package/src/managed-pages/changes.json +6 -0
  293. package/src/renderer-foundation/changes.json +39 -0
  294. package/src/template-resolver/changes.json +118 -0
  295. package/src/user-data-rights/changes.json +7 -0
  296. package/src/waitlist/changes.json +7 -0
  297. package/dist/renderer-simple/feature.d.ts +0 -10
  298. package/dist/renderer-simple/feature.js +0 -42
  299. package/dist/renderer-simple/index.d.ts +0 -3
  300. package/dist/renderer-simple/index.js +0 -2
  301. package/dist/renderer-simple/mail-blocks.d.ts +0 -21
  302. package/dist/renderer-simple/mail-blocks.js +0 -107
  303. package/dist/renderer-simple/resolve-variables.d.ts +0 -7
  304. package/dist/renderer-simple/resolve-variables.js +0 -47
  305. package/dist/renderer-simple/simple-renderer.d.ts +0 -24
  306. package/dist/renderer-simple/simple-renderer.js +0 -200
  307. package/src/renderer-simple/changes.json +0 -27
@@ -0,0 +1,374 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { NotFoundError, UnprocessableError, ValidationError, } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
4
+ import { createEmailAttachmentReader, createTemplateMailRenderer } from "../delivery/index.js";
5
+ import { fileContextForTenant } from "../file-foundation/index.js";
6
+ import { collectRendererPlugins, createRendererFoundationApi, RendererError, } from "../renderer-foundation/index.js";
7
+ import { createTemplateResolverApi, TemplateNotFoundError } from "./api.js";
8
+ import { collectDocumentResourceKeys } from "./compile-document-body.js";
9
+ import { MAIL_PREVIEW_MAX_BYTES, SYSTEM_TENANT_ID } from "./constants.js";
10
+ import { parseStoredJson } from "./handlers/template-gates.js";
11
+ import { validateTemplateDraft } from "./handlers/validate.query.js";
12
+ import { readLayoutRegions, sanitizeLayoutHtml } from "./layout-regions.js";
13
+ import { parseLinkedResourceEntries } from "./linked-resources.js";
14
+ import { buildDisplayHtml } from "./preview-display-html.js";
15
+ import { TemplateResolverErrors } from "./qualified-names.js";
16
+ import { templateResourcesTable } from "./table.js";
17
+ import { isMailTemplateMeta, parseTemplateMeta, readPdfTemplateMeta, } from "./template-meta.js";
18
+ const SAMPLE_LINK = "https://example.com";
19
+ const SAMPLE_DATE = "2026-01-15";
20
+ const SAMPLE_LIST_ROWS = 2;
21
+ const DEFAULT_PDF_LAYOUT_SLUG = "default";
22
+ function sampleScalar(name, def) {
23
+ if (def.example !== undefined)
24
+ return def.example;
25
+ switch (def.type) {
26
+ case "text":
27
+ return `[${def.label ?? name}]`;
28
+ case "link":
29
+ return SAMPLE_LINK;
30
+ case "money":
31
+ return 0;
32
+ case "date":
33
+ return SAMPLE_DATE;
34
+ }
35
+ }
36
+ function sampleRow(item, given = {}) {
37
+ const row = {};
38
+ for (const [name, def] of Object.entries(item))
39
+ row[name] = sampleScalar(name, def);
40
+ return { ...row, ...given };
41
+ }
42
+ function sampleList(def) {
43
+ const rows = (def.example ?? [])
44
+ .slice(0, SAMPLE_LIST_ROWS)
45
+ .map((given) => sampleRow(def.item, given));
46
+ while (rows.length < SAMPLE_LIST_ROWS)
47
+ rows.push(sampleRow(def.item));
48
+ return rows;
49
+ }
50
+ // Declared examples win; the fallbacks are deterministic so a preview never differs between calls.
51
+ export function sampleValuesFor(variables) {
52
+ const values = {};
53
+ for (const [name, def] of Object.entries(variables)) {
54
+ values[name] = def.type === "list" ? sampleList(def) : sampleScalar(name, def);
55
+ }
56
+ return values;
57
+ }
58
+ const utf8Bytes = (value) => new TextEncoder().encode(value).length;
59
+ const failed = (error) => ({ ok: false, error });
60
+ async function loadStoredDraft(ctx, user, input) {
61
+ try {
62
+ return await createTemplateResolverApi(ctx.db).resolveTemplate({
63
+ tenantId: user.tenantId,
64
+ slug: input.slug,
65
+ kind: input.kind,
66
+ locale: input.locale,
67
+ });
68
+ }
69
+ catch (e) {
70
+ if (e instanceof TemplateNotFoundError)
71
+ return null;
72
+ throw e;
73
+ }
74
+ }
75
+ async function describeAttachments(ctx, refs) {
76
+ const fileRefIds = refs.flatMap((ref) => (ref.kind === "rendered-pdf" ? [] : [ref.fileRefId]));
77
+ const rows = fileRefIds.length === 0
78
+ ? []
79
+ : await selectMany(ctx.db, fileRefsTable, {
80
+ id: fileRefIds,
81
+ });
82
+ const sizeById = new Map(rows.map((row) => [row.id, Number(row.size)]));
83
+ // An inline image is named by its content id because the stored file name is personal data and
84
+ // may be ciphertext; a file attachment carries the name the admin gave it.
85
+ return refs.map((ref) => previewAttachmentOf(ref, sizeById));
86
+ }
87
+ function previewAttachmentOf(ref, sizeById) {
88
+ switch (ref.kind) {
89
+ case "inline":
90
+ return {
91
+ kind: "inline",
92
+ cid: ref.cid,
93
+ name: ref.cid,
94
+ sizeBytes: sizeById.get(ref.fileRefId) ?? 0,
95
+ };
96
+ case "file":
97
+ return { kind: "file", name: ref.fileName, sizeBytes: sizeById.get(ref.fileRefId) ?? 0 };
98
+ case "rendered-pdf":
99
+ return { kind: "rendered-pdf", name: ref.fileName, templateSlug: ref.templateSlug };
100
+ }
101
+ }
102
+ const attachmentSizeOf = (attachment) => attachment.kind === "rendered-pdf" ? 0 : attachment.sizeBytes;
103
+ // The row an editor is working on, whatever its status: the stored template only resolves when
104
+ // active, so a draft would otherwise lose the images and attachments uploaded to it.
105
+ async function loadEditedRowResources(ctx, user, input) {
106
+ const rows = await selectMany(ctx.db, templateResourcesTable, { tenantId: user.tenantId, slug: input.slug, kind: "mail", locale: input.locale }, { limit: 1 });
107
+ const row = rows[0];
108
+ return row === undefined ? undefined : parseLinkedResourceEntries(row.linkedResources);
109
+ }
110
+ async function resolvePdfLayout(ctx, user, slug, locale) {
111
+ const api = createTemplateResolverApi(ctx.db);
112
+ const find = async (tenantId) => {
113
+ try {
114
+ return await api.resolveLayout({ tenantId, slug, kind: "pdf-layout", locale });
115
+ }
116
+ catch (e) {
117
+ if (e instanceof TemplateNotFoundError)
118
+ return null;
119
+ throw e;
120
+ }
121
+ };
122
+ let layout = await find(user.tenantId);
123
+ let html = layout?.content ?? "";
124
+ if (layout !== null && layout.scope !== "system") {
125
+ // Re-checked like the mail renderer does: a tenant row that predates the write gate must not pass.
126
+ const sanitized = sanitizeLayoutHtml(html);
127
+ if (sanitized.ok)
128
+ html = sanitized.html;
129
+ else {
130
+ layout = await find(SYSTEM_TENANT_ID);
131
+ html = layout?.content ?? "";
132
+ }
133
+ }
134
+ if (layout === null)
135
+ return null;
136
+ return { slug, scope: layout.scope, html, regions: readLayoutRegions(html) };
137
+ }
138
+ function missingResourceCheck(missing) {
139
+ return {
140
+ code: "resources_resolved",
141
+ ok: missing.length === 0,
142
+ i18nKey: "templateResolver.checks.resourcesResolved",
143
+ params: { missing },
144
+ };
145
+ }
146
+ function requiredPlaceholdersCheck(missing) {
147
+ return {
148
+ code: "required_placeholders",
149
+ ok: missing.length === 0,
150
+ i18nKey: "templateResolver.checks.requiredPlaceholders",
151
+ params: { missing },
152
+ };
153
+ }
154
+ function toRenderFailure(e) {
155
+ if (!(e instanceof RendererError))
156
+ throw e;
157
+ return failed(new UnprocessableError(TemplateResolverErrors.previewRenderFailed, {
158
+ i18nKey: "templateResolver.errors.preview.renderFailed",
159
+ details: { code: e.code },
160
+ }));
161
+ }
162
+ // Only a plugin that prints documents can show the filled-in page; without one the editor
163
+ // still gets the body and the raw layout, just without placeholder values.
164
+ function documentPreviewPlugin(ctx, tenantId) {
165
+ try {
166
+ const plugin = createRendererFoundationApi(collectRendererPlugins(ctx.registry)).createRendererForTenant({ tenantId, kind: "document-pdf" });
167
+ return plugin.previewDocument === undefined ? null : plugin;
168
+ }
169
+ catch (e) {
170
+ if (e instanceof RendererError && e.code === "no_plugin_for_kind")
171
+ return null;
172
+ throw e;
173
+ }
174
+ }
175
+ async function renderDocumentPreview(ctx, user, args) {
176
+ const { input, content, meta, validation, sampleValues } = args;
177
+ const pdfMeta = meta.ok ? readPdfTemplateMeta(meta.meta) : null;
178
+ const requiredCheck = requiredPlaceholdersCheck(validation.missingRequired);
179
+ const plugin = documentPreviewPlugin(ctx, user.tenantId);
180
+ if (plugin?.previewDocument === undefined) {
181
+ return {
182
+ ok: true,
183
+ mailAttachments: [],
184
+ preview: {
185
+ kind: "document-pdf",
186
+ subject: null,
187
+ previewText: null,
188
+ html: content,
189
+ text: null,
190
+ attachments: [],
191
+ layout: await resolvePdfLayout(ctx, user, pdfMeta?.layoutSlug ?? DEFAULT_PDF_LAYOUT_SLUG, input.locale),
192
+ sampleValues,
193
+ checks: [missingResourceCheck(validation.warnings.map((w) => w.detail)), requiredCheck],
194
+ validation,
195
+ },
196
+ };
197
+ }
198
+ let document;
199
+ try {
200
+ document = await plugin.previewDocument({
201
+ slug: input.slug,
202
+ locale: input.locale,
203
+ variables: sampleValues,
204
+ draft: { content, meta: pdfMeta, variableSchema: args.variableSchema },
205
+ }, {
206
+ db: ctx.db,
207
+ registry: ctx.registry,
208
+ tenantId: user.tenantId,
209
+ ...(ctx.config !== undefined && { config: ctx.config }),
210
+ filesForTenant: (fileTenantId) => fileContextForTenant(ctx, fileTenantId),
211
+ });
212
+ }
213
+ catch (e) {
214
+ return toRenderFailure(e);
215
+ }
216
+ const layout = document.layout;
217
+ return {
218
+ ok: true,
219
+ mailAttachments: [],
220
+ preview: {
221
+ kind: "document-pdf",
222
+ subject: null,
223
+ previewText: null,
224
+ html: document.bodyHtml,
225
+ text: null,
226
+ attachments: [],
227
+ layout: layout === null
228
+ ? null
229
+ : {
230
+ slug: layout.slug,
231
+ scope: layout.scope,
232
+ html: `${layout.headerHtml}${layout.signatureHtml}${layout.footerHtml}`,
233
+ regions: {
234
+ header: layout.headerHtml,
235
+ signature: layout.signatureHtml,
236
+ footer: layout.footerHtml,
237
+ },
238
+ },
239
+ sampleValues,
240
+ checks: [missingResourceCheck(document.missingResourceKeys), requiredCheck],
241
+ validation,
242
+ },
243
+ };
244
+ }
245
+ // A document draft is always tenant-scoped here, like in the preview: it passes the write gates
246
+ // and a failing draft is rejected with the same problems as a save.
247
+ export async function evaluateDocumentDraft(ctx, user, draft) {
248
+ const { response, evaluation } = await validateTemplateDraft(ctx, user, {
249
+ slug: draft.slug,
250
+ kind: "document-pdf",
251
+ contentFormat: "html",
252
+ content: draft.content,
253
+ meta: draft.meta,
254
+ variableSchema: draft.variableSchema,
255
+ locale: draft.locale,
256
+ scope: "tenant",
257
+ });
258
+ if (!evaluation.ok) {
259
+ return { ok: false, error: new ValidationError({ fields: [...evaluation.issues] }) };
260
+ }
261
+ const meta = parseTemplateMeta("document-pdf", parseStoredJson(evaluation.metaJson));
262
+ return {
263
+ ok: true,
264
+ content: evaluation.content ?? "",
265
+ meta: meta.ok ? readPdfTemplateMeta(meta.meta) : null,
266
+ variableSchema: draft.variableSchema,
267
+ sampleValues: sampleValuesFor(response.variables),
268
+ };
269
+ }
270
+ // Renders a draft (or, without content, the stored template) with sample values, going through
271
+ // the same write gates and the same mail renderer as a real send. Nothing is stored or sent.
272
+ export async function renderTemplatePreview(ctx, user, input) {
273
+ let draftContent = input.content;
274
+ let draftMeta = input.meta;
275
+ let draftVariableSchema = input.variableSchema;
276
+ if (draftContent === undefined) {
277
+ const stored = await loadStoredDraft(ctx, user, input);
278
+ if (stored === null)
279
+ return failed(new NotFoundError("template-resource", input.slug));
280
+ draftContent = stored.content;
281
+ draftMeta = stored.meta;
282
+ draftVariableSchema = stored.variableSchema;
283
+ }
284
+ const { response: validation, evaluation } = await validateTemplateDraft(ctx, user, {
285
+ slug: input.slug,
286
+ kind: input.kind,
287
+ contentFormat: "html",
288
+ content: draftContent,
289
+ meta: draftMeta,
290
+ variableSchema: draftVariableSchema,
291
+ locale: input.locale,
292
+ scope: "tenant",
293
+ });
294
+ if (!evaluation.ok)
295
+ return failed(new ValidationError({ fields: [...evaluation.issues] }));
296
+ const content = evaluation.content ?? "";
297
+ const meta = parseTemplateMeta(input.kind, parseStoredJson(evaluation.metaJson));
298
+ const sampleValues = sampleValuesFor(validation.variables);
299
+ if (input.kind === "document-pdf") {
300
+ return renderDocumentPreview(ctx, user, {
301
+ input,
302
+ content,
303
+ meta,
304
+ variableSchema: draftVariableSchema,
305
+ sampleValues,
306
+ validation,
307
+ });
308
+ }
309
+ if (!meta.ok || !isMailTemplateMeta(meta.meta)) {
310
+ return failed(new UnprocessableError(TemplateResolverErrors.previewRenderFailed, {
311
+ i18nKey: "templateResolver.errors.preview.renderFailed",
312
+ details: { code: "invalid_meta" },
313
+ }));
314
+ }
315
+ const mailMeta = meta.meta;
316
+ const editedResources = input.content === undefined ? undefined : await loadEditedRowResources(ctx, user, input);
317
+ let rendered;
318
+ try {
319
+ rendered = await createTemplateMailRenderer(ctx.db, ctx.registry, user.tenantId, ctx.config)({
320
+ slug: input.slug,
321
+ locale: input.locale,
322
+ variables: sampleValues,
323
+ draft: {
324
+ content,
325
+ meta: mailMeta,
326
+ variableSchema: draftVariableSchema,
327
+ ...(editedResources !== undefined && { linkedResources: editedResources }),
328
+ },
329
+ });
330
+ }
331
+ catch (e) {
332
+ return toRenderFailure(e);
333
+ }
334
+ const mailAttachments = rendered.attachments ?? [];
335
+ const inline = mailAttachments.filter((ref) => ref.kind === "inline");
336
+ const attachments = await describeAttachments(ctx, mailAttachments);
337
+ const displayHtml = await buildDisplayHtml(rendered.html, inline, createEmailAttachmentReader(ctx.db, ctx.registry, user.tenantId, ctx.config, (tenantId) => fileContextForTenant(ctx, tenantId)));
338
+ const resolvedCids = new Set(inline.map((ref) => ref.cid));
339
+ const unresolved = collectDocumentResourceKeys(content).filter((key) => !resolvedCids.has(key));
340
+ const totalBytes = utf8Bytes(rendered.html) +
341
+ utf8Bytes(rendered.text) +
342
+ attachments.reduce((sum, attachment) => sum + attachmentSizeOf(attachment), 0);
343
+ return {
344
+ ok: true,
345
+ mailAttachments,
346
+ preview: {
347
+ kind: "mail",
348
+ subject: rendered.subject,
349
+ previewText: rendered.previewText ?? null,
350
+ html: rendered.html,
351
+ displayHtml,
352
+ text: rendered.text,
353
+ attachments,
354
+ layout: null,
355
+ sampleValues,
356
+ checks: [
357
+ missingResourceCheck(unresolved),
358
+ {
359
+ code: "text_present",
360
+ ok: rendered.text.trim() !== "",
361
+ i18nKey: "templateResolver.checks.textPresent",
362
+ },
363
+ {
364
+ code: "size_under_cap",
365
+ ok: totalBytes <= MAIL_PREVIEW_MAX_BYTES,
366
+ i18nKey: "templateResolver.checks.sizeUnderCap",
367
+ params: { totalBytes, maxBytes: MAIL_PREVIEW_MAX_BYTES },
368
+ },
369
+ requiredPlaceholdersCheck(validation.missingRequired),
370
+ ],
371
+ validation,
372
+ },
373
+ };
374
+ }
@@ -0,0 +1,14 @@
1
+ import type { HandlerContext, SessionUser, TenantId, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { LinkedResourceEntry } from "./linked-resources.js";
3
+ export type TemplateResourceFile = {
4
+ readonly fileName: string;
5
+ readonly contentType: string;
6
+ readonly bytes: Uint8Array;
7
+ };
8
+ export declare function readTemplateResource(ctx: Pick<HandlerContext, "db" | "files" | "user">, fileRefId: string): Promise<TemplateResourceFile | null>;
9
+ export declare function attachTemplateResources(ctx: Pick<HandlerContext, "forTenant">, args: {
10
+ readonly user: SessionUser;
11
+ readonly tenantId: TenantId;
12
+ readonly templateId: string;
13
+ readonly resources: Readonly<Record<string, LinkedResourceEntry>>;
14
+ }): Promise<WriteResult<null>>;
@@ -0,0 +1,42 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
4
+ import { requireForTenant } from "../shared/index.js";
5
+ import { executor } from "./handlers/shared.js";
6
+ import { templateResourcesTable } from "./table.js";
7
+ // Reads the file behind a template resource. Null when the fileRef is unknown
8
+ // to the caller's tenant or has no bytes.
9
+ export async function readTemplateResource(ctx, fileRefId) {
10
+ if (!ctx.files)
11
+ return null;
12
+ const row = await fetchOne(ctx.db, fileRefsTable, {
13
+ id: fileRefId,
14
+ isDeleted: false,
15
+ });
16
+ // ctx.files only reaches the caller's own key space.
17
+ if (!row?.storageKey.startsWith(`${ctx.user.tenantId}/`))
18
+ return null;
19
+ const handle = ctx.files.ref(row.storageKey);
20
+ if (!(await handle.exists()))
21
+ return null;
22
+ return { fileName: row.fileName, contentType: row.mimeType, bytes: await handle.read() };
23
+ }
24
+ // Replaces the whole key map of one row with files that are already stored (bundle import,
25
+ // copy-locale). Single keys go through storeTemplateResource and resource-remove.
26
+ export async function attachTemplateResources(ctx, args) {
27
+ if (Object.keys(args.resources).length === 0)
28
+ return { isSuccess: true, data: null };
29
+ const { db, streamTenantId } = requireForTenant(ctx, args.tenantId);
30
+ const row = await fetchOne(db, templateResourcesTable, {
31
+ id: args.templateId,
32
+ tenantId: args.tenantId,
33
+ });
34
+ if (!row)
35
+ return writeFailure(new NotFoundError("template-resource", args.templateId));
36
+ const result = await executor.update({
37
+ id: row.id,
38
+ version: row.version,
39
+ changes: { linkedResources: JSON.stringify(args.resources) },
40
+ }, args.user, db, { streamTenantId });
41
+ return result.isSuccess ? { isSuccess: true, data: null } : result;
42
+ }
@@ -0,0 +1,12 @@
1
+ import { type Registry } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type TemplateKind } from "./constants.js";
3
+ type UsageRole = "template" | "layout";
4
+ export type TemplateUsageEntry = {
5
+ readonly qualifiedName: string;
6
+ readonly feature: string;
7
+ readonly notification: string;
8
+ readonly trigger: string | null;
9
+ readonly role: UsageRole;
10
+ };
11
+ export declare function collectTemplateUsage(registry: Registry, slug: string, kind: TemplateKind): TemplateUsageEntry[];
12
+ export {};
@@ -0,0 +1,30 @@
1
+ import { parseQn } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { DEFAULT_MAIL_LAYOUT_SLUG } from "./constants.js";
3
+ export function collectTemplateUsage(registry, slug, kind) {
4
+ const usedBy = [];
5
+ if (kind === "mail" || kind === "mail-layout") {
6
+ for (const [qualifiedName, notification] of registry.getAllNotifications()) {
7
+ const role = kind === "mail" ? "template" : "layout";
8
+ const isUsed = kind === "mail"
9
+ ? qualifiedName === slug
10
+ : [
11
+ ...new Set([
12
+ ...Object.keys(notification.template ?? {}),
13
+ ...Object.keys(notification.meta ?? {}),
14
+ ]),
15
+ ].some((locale) => (notification.meta?.[locale]?.layoutSlug ?? DEFAULT_MAIL_LAYOUT_SLUG) === slug);
16
+ if (!isUsed)
17
+ continue;
18
+ const parsed = parseQn(qualifiedName);
19
+ usedBy.push({
20
+ qualifiedName,
21
+ feature: parsed.scope,
22
+ notification: parsed.name,
23
+ trigger: notification.trigger?.on ?? null,
24
+ role,
25
+ });
26
+ }
27
+ }
28
+ usedBy.sort((a, b) => a.qualifiedName.localeCompare(b.qualifiedName));
29
+ return usedBy;
30
+ }
@@ -0,0 +1,22 @@
1
+ import type { Registry, TemplateVariables } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { TemplateMeta } from "./template-meta.js";
3
+ export declare const SYSTEM_PLACEHOLDERS: ReadonlySet<string>;
4
+ export declare function systemPlaceholdersFor(kind: string): ReadonlySet<string>;
5
+ export type TemplateValidation = {
6
+ readonly unknown: readonly string[];
7
+ readonly missingRequired: readonly string[];
8
+ readonly used: readonly string[];
9
+ /** data-resource keys with no uploaded file; a warning, never a blocker. */
10
+ readonly unknownResources: readonly string[];
11
+ };
12
+ export declare function resolveTemplateVariables(registry: Registry, slug: string, variableSchema: Readonly<Record<string, unknown>>): TemplateVariables;
13
+ export declare function variablesFromSchema(variableSchema: Readonly<Record<string, unknown>>): TemplateVariables;
14
+ export declare function validateTemplatePlaceholders(input: {
15
+ readonly kind: string;
16
+ readonly body: string;
17
+ readonly meta: TemplateMeta | null;
18
+ readonly variables: TemplateVariables;
19
+ /** data-resource keys with an uploaded file besides the built-in `logo`. */
20
+ readonly resourceKeys?: readonly string[];
21
+ }): TemplateValidation;
22
+ export declare function hasPlaceholderProblems(validation: TemplateValidation): boolean;
@@ -0,0 +1,172 @@
1
+ import * as z from "zod";
2
+ import { withParsedDocument } from "./document-sanitizer.js";
3
+ import { isDocumentPlaceholderHref, isDocumentReferenceName } from "./document-schema.js";
4
+ export const SYSTEM_PLACEHOLDERS = new Set([
5
+ "tenantName",
6
+ "signature",
7
+ "imprint",
8
+ "unsubscribeUrl",
9
+ ]);
10
+ const PAGE_PLACEHOLDERS = ["page", "pages"];
11
+ const PAGED_KINDS = new Set(["document-pdf", "pdf-layout"]);
12
+ export function systemPlaceholdersFor(kind) {
13
+ return PAGED_KINDS.has(kind)
14
+ ? new Set([...SYSTEM_PLACEHOLDERS, ...PAGE_PLACEHOLDERS])
15
+ : SYSTEM_PLACEHOLDERS;
16
+ }
17
+ const BASE_RESOURCE_KEYS = ["logo"];
18
+ // Mirrors TemplateVariableDef: a stored or draft schema keeps its label and example, which the
19
+ // preview uses as sample values.
20
+ const variableCommon = {
21
+ label: z.string().optional(),
22
+ required: z.boolean().optional(),
23
+ };
24
+ const textLikeExample = z.string().optional();
25
+ const scalarVariableSchema = z.discriminatedUnion("type", [
26
+ z.object({ ...variableCommon, type: z.literal("text"), example: textLikeExample }),
27
+ z.object({ ...variableCommon, type: z.literal("link"), example: textLikeExample }),
28
+ z.object({
29
+ ...variableCommon,
30
+ type: z.literal("money"),
31
+ example: z
32
+ .union([z.number(), z.object({ amount: z.number(), currency: z.string() })])
33
+ .optional(),
34
+ }),
35
+ z.object({ ...variableCommon, type: z.literal("date"), example: textLikeExample }),
36
+ ]);
37
+ const variableDefSchema = z.union([
38
+ scalarVariableSchema,
39
+ z.object({
40
+ ...variableCommon,
41
+ type: z.literal("list"),
42
+ item: z.record(z.string(), scalarVariableSchema),
43
+ example: z.array(z.record(z.string(), z.unknown())).optional(),
44
+ }),
45
+ ]);
46
+ // A stored variableSchema value is either a bare string (legacy "this is a text
47
+ // variable") or a TemplateVariableDef object; anything else is not declared and
48
+ // therefore shows up as an unknown placeholder instead of being guessed at.
49
+ function toVariableDef(raw) {
50
+ if (typeof raw === "string")
51
+ return { type: "text" };
52
+ const parsed = variableDefSchema.safeParse(raw);
53
+ return parsed.success ? parsed.data : null;
54
+ }
55
+ export function resolveTemplateVariables(registry, slug, variableSchema) {
56
+ const declared = registry.getAllNotifications().get(slug)?.variables;
57
+ return declared ?? variablesFromSchema(variableSchema);
58
+ }
59
+ export function variablesFromSchema(variableSchema) {
60
+ const variables = {};
61
+ for (const [name, raw] of Object.entries(variableSchema)) {
62
+ const def = toVariableDef(raw);
63
+ if (def !== null)
64
+ variables[name] = def;
65
+ }
66
+ return variables;
67
+ }
68
+ function rootName(name) {
69
+ return name.split(".")[0] ?? name;
70
+ }
71
+ function listItemFields(variables, listName) {
72
+ const def = listName !== null && Object.hasOwn(variables, listName) ? variables[listName] : undefined;
73
+ return def?.type === "list" ? def.item : {};
74
+ }
75
+ function isKnownName(name, scope, itemFields) {
76
+ const { variables } = scope;
77
+ const root = rootName(name);
78
+ if (Object.hasOwn(itemFields, name) || Object.hasOwn(itemFields, root)) {
79
+ return { known: true, used: "" };
80
+ }
81
+ if (scope.systemPlaceholders.has(root))
82
+ return { known: true, used: "" };
83
+ if (Object.hasOwn(variables, name))
84
+ return { known: true, used: name };
85
+ if (Object.hasOwn(variables, root))
86
+ return { known: true, used: root };
87
+ return { known: false, used: "" };
88
+ }
89
+ function recordName(name, itemFields, scope) {
90
+ const resolution = isKnownName(name, scope, itemFields);
91
+ if (!resolution.known)
92
+ scope.unknown.add(name);
93
+ else if (resolution.used !== "")
94
+ scope.used.add(resolution.used);
95
+ }
96
+ function enclosingEachName(element) {
97
+ return element.closest("tr[data-each]")?.getAttribute("data-each") ?? null;
98
+ }
99
+ function scanBodyPlaceholders(body, scope) {
100
+ withParsedDocument(body, (root) => {
101
+ for (const element of Array.from(root.querySelectorAll("tr[data-each]"))) {
102
+ const listName = element.getAttribute("data-each") ?? "";
103
+ const def = Object.hasOwn(scope.variables, listName) ? scope.variables[listName] : undefined;
104
+ if (def?.type === "list")
105
+ scope.used.add(listName);
106
+ else
107
+ scope.unknown.add(listName);
108
+ }
109
+ for (const element of Array.from(root.querySelectorAll("span[data-variable], a[href], img[data-resource]"))) {
110
+ const itemFields = listItemFields(scope.variables, enclosingEachName(element));
111
+ const tag = element.tagName.toLowerCase();
112
+ if (tag === "span") {
113
+ recordName(element.getAttribute("data-variable") ?? "", itemFields, scope);
114
+ }
115
+ else if (tag === "a") {
116
+ const href = element.getAttribute("href") ?? "";
117
+ if (isDocumentPlaceholderHref(href))
118
+ recordName(href.slice(2, -2), itemFields, scope);
119
+ }
120
+ else {
121
+ const key = element.getAttribute("data-resource") ?? "";
122
+ if (!scope.resourceKeys.has(key))
123
+ scope.unknownResources.add(key);
124
+ }
125
+ }
126
+ });
127
+ }
128
+ const HANDLEBARS_EXPRESSION = /\{\{([^{}]*)\}\}/g;
129
+ // Meta text supports only plain `{{name}}` / `{{name.field}}`; every other
130
+ // Handlebars construct is reported as unknown so it can never reach the compiler.
131
+ function scanMetaPlaceholders(text, scope) {
132
+ const leftover = text.replace(HANDLEBARS_EXPRESSION, (_match, inner) => {
133
+ const name = inner.trim();
134
+ if (isDocumentReferenceName(name))
135
+ recordName(name, {}, scope);
136
+ else
137
+ scope.unknown.add(`{{${inner}}}`);
138
+ return "";
139
+ });
140
+ if (/[{}]/.test(leftover))
141
+ scope.unknown.add("{");
142
+ }
143
+ function metaTexts(meta) {
144
+ if (meta === null || !("subject" in meta))
145
+ return [];
146
+ return [meta.subject, ...(meta.previewText === undefined ? [] : [meta.previewText])];
147
+ }
148
+ export function validateTemplatePlaceholders(input) {
149
+ const scope = {
150
+ variables: input.variables,
151
+ systemPlaceholders: systemPlaceholdersFor(input.kind),
152
+ resourceKeys: new Set([...BASE_RESOURCE_KEYS, ...(input.resourceKeys ?? [])]),
153
+ used: new Set(),
154
+ unknown: new Set(),
155
+ unknownResources: new Set(),
156
+ };
157
+ scanBodyPlaceholders(input.body, scope);
158
+ for (const text of metaTexts(input.meta))
159
+ scanMetaPlaceholders(text, scope);
160
+ const missingRequired = Object.entries(input.variables)
161
+ .filter(([name, def]) => def.required === true && !scope.used.has(name))
162
+ .map(([name]) => name);
163
+ return {
164
+ unknown: [...scope.unknown].sort(),
165
+ missingRequired: missingRequired.sort(),
166
+ used: [...scope.used].sort(),
167
+ unknownResources: [...scope.unknownResources].sort(),
168
+ };
169
+ }
170
+ export function hasPlaceholderProblems(validation) {
171
+ return validation.unknown.length > 0 || validation.missingRequired.length > 0;
172
+ }