@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.
- package/dist/admin-shell/feature.d.ts +3 -0
- package/dist/admin-shell/feature.js +6 -1
- package/dist/agent-tools/agent-manifest.js +2 -1
- package/dist/agent-tools/filter-ops.d.ts +1 -1
- package/dist/agent-tools/filter-ops.js +1 -3
- package/dist/agent-tools/tool-dispatch.js +2 -1
- package/dist/auth-email-password/email-templates.d.ts +4 -26
- package/dist/auth-email-password/email-templates.js +9 -101
- package/dist/auth-email-password/feature.js +24 -6
- package/dist/auth-email-password/handlers/invite-create.write.js +4 -4
- package/dist/auth-email-password/handlers/request-account-unlock.write.js +2 -4
- package/dist/auth-email-password/handlers/request-email-verification.write.js +2 -4
- package/dist/auth-email-password/handlers/request-password-reset.write.js +2 -4
- package/dist/auth-email-password/handlers/signup-confirm.write.js +3 -4
- package/dist/auth-email-password/handlers/token-request-handler.d.ts +1 -2
- package/dist/auth-email-password/handlers/token-request-handler.js +0 -1
- package/dist/auth-email-password/index.d.ts +2 -2
- package/dist/auth-email-password/index.js +1 -1
- package/dist/auth-email-password/magic-link-mail.d.ts +3 -2
- package/dist/auth-email-password/magic-link-mail.js +19 -14
- package/dist/auth-email-password/mail-notifications.d.ts +32 -0
- package/dist/auth-email-password/mail-notifications.js +116 -0
- package/dist/auth-email-password/signup-activation.d.ts +1 -1
- package/dist/auth-email-password/signup-activation.js +2 -3
- package/dist/auth-foundation/types.js +2 -8
- package/dist/auth-mfa/feature.js +4 -4
- package/dist/auth-mfa/hmac-token-codec.js +4 -4
- package/dist/auth-mfa/mfa-challenge-token.js +3 -3
- package/dist/auth-mfa/mfa-preauth-setup-token.js +3 -3
- package/dist/auth-mfa/mfa-setup-token.js +8 -8
- package/dist/billing-foundation/constants.d.ts +3 -3
- package/dist/billing-foundation/constants.js +5 -4
- package/dist/billing-foundation/consumer-protection/confirmation-mail.d.ts +32 -14
- package/dist/billing-foundation/consumer-protection/confirmation-mail.js +27 -32
- package/dist/billing-foundation/consumer-protection/issue-confirmation.js +3 -3
- package/dist/billing-foundation/consumer-protection/mail-notifications.d.ts +8 -0
- package/dist/billing-foundation/consumer-protection/mail-notifications.js +95 -0
- package/dist/billing-foundation/consumer-protection/register-consent.js +2 -0
- package/dist/billing-foundation/consumer-protection/termination-mail.d.ts +20 -10
- package/dist/billing-foundation/consumer-protection/termination-mail.js +10 -21
- package/dist/billing-foundation/consumer-protection/termination-notify.js +2 -2
- package/dist/billing-foundation/consumer-protection/termination-pages.js +1 -3
- package/dist/billing-foundation/consumer-protection/termination-request.js +2 -2
- package/dist/billing-foundation/handlers/process-event.write.js +2 -2
- package/dist/billing-foundation/handlers/process-payment-event.write.js +1 -1
- package/dist/billing-foundation/subscription-tier-sync.js +3 -4
- package/dist/cap-overview/handlers/tenant-caps-list.query.js +2 -1
- package/dist/cap-overview/web/cap-usage-cell.js +5 -4
- package/dist/channel-email/email-channel.d.ts +2 -2
- package/dist/channel-email/email-channel.js +89 -26
- package/dist/channel-email/feature.js +3 -3
- package/dist/channel-email/index.d.ts +1 -1
- package/dist/channel-email/index.js +1 -1
- package/dist/channel-email/pii-guard.js +10 -0
- package/dist/channel-email/smtp-transport.js +13 -0
- package/dist/channel-email/types.d.ts +8 -0
- package/dist/config/handlers/set.write.js +1 -2
- package/dist/custom-fields/lib/value-schema.js +10 -11
- package/dist/custom-fields/wire-user-data-rights.js +7 -6
- package/dist/data-retention/index.d.ts +1 -0
- package/dist/data-retention/index.js +1 -0
- package/dist/data-retention/run-retention-cleanup.js +6 -6
- package/dist/delivery/attachment-ref.d.ts +39 -0
- package/dist/delivery/attachment-ref.js +64 -0
- package/dist/delivery/channel-context.d.ts +8 -4
- package/dist/delivery/channel-context.js +76 -2
- package/dist/delivery/constants.d.ts +1 -0
- package/dist/delivery/constants.js +1 -0
- package/dist/delivery/delivery-service.d.ts +4 -1
- package/dist/delivery/delivery-service.js +38 -32
- package/dist/delivery/feature.js +1 -1
- package/dist/delivery/index.d.ts +5 -2
- package/dist/delivery/index.js +4 -1
- package/dist/delivery/jobs.js +25 -5
- package/dist/delivery/notify-factory.js +4 -1
- package/dist/delivery/testing.d.ts +4 -1
- package/dist/delivery/types.d.ts +20 -14
- package/dist/delivery/unsubscribe.js +4 -1
- package/dist/derivatives-sharp/render.js +1 -18
- package/dist/document-ingest-foundation/pages.js +4 -4
- package/dist/feature-toggles/toggle-sync-signal.js +4 -4
- package/dist/file-foundation/index.d.ts +1 -0
- package/dist/file-foundation/index.js +1 -0
- package/dist/file-foundation/read-file-ref-content.d.ts +27 -0
- package/dist/file-foundation/read-file-ref-content.js +49 -0
- package/dist/folders/web/folder-manager.js +3 -2
- package/dist/form-draft/db/queries/cleanup.js +4 -4
- package/dist/form-draft/db/queries/draft-count.js +2 -2
- package/dist/form-draft/db/queries/owned-file-refs.js +2 -2
- package/dist/form-draft/release-file-refs.js +3 -2
- package/dist/inbound-mail-foundation/handlers/connect-account.write.js +1 -1
- package/dist/inbound-mail-foundation/handlers/ingest-message.write.js +6 -6
- package/dist/inbound-mail-foundation/handlers/scope-visibility.d.ts +1 -1
- package/dist/inbound-mail-foundation/oauth-state.js +7 -7
- package/dist/inbound-mail-foundation/types.js +4 -4
- package/dist/inbound-mail-foundation/watch-supervisor.js +22 -13
- package/dist/inbound-provider-imap/imap-client.js +4 -3
- package/dist/ledger/recurring.js +1 -3
- package/dist/managed-pages/branding.d.ts +2 -0
- package/dist/managed-pages/branding.js +24 -2
- package/dist/managed-pages/feature.js +1 -0
- package/dist/managed-pages/i18n.js +1 -0
- package/dist/managed-pages/screens/branding-screen.js +8 -1
- package/dist/page-render/branding.d.ts +1 -0
- package/dist/page-render/branding.js +1 -0
- package/dist/page-render/markdown.js +1 -1
- package/dist/personal-access-tokens/feature.js +1 -1
- package/dist/personal-access-tokens/handlers/list.query.js +2 -1
- package/dist/personal-access-tokens/revoke-for-user.js +2 -1
- package/dist/renderer-foundation/api.d.ts +13 -13
- package/dist/renderer-foundation/api.js +1 -1
- package/dist/renderer-foundation/constants.js +2 -4
- package/dist/renderer-foundation/feature.js +7 -7
- package/dist/renderer-foundation/index.d.ts +2 -1
- package/dist/renderer-foundation/index.js +1 -0
- package/dist/renderer-foundation/types.d.ts +92 -32
- package/dist/renderer-pdf/feature.js +7 -1
- package/dist/sessions/db/queries/cleanup.js +2 -2
- package/dist/sessions/feature.js +1 -1
- package/dist/sessions/handlers/list.query.js +2 -1
- package/dist/shared/attachment-file-name.d.ts +3 -0
- package/dist/shared/attachment-file-name.js +35 -0
- package/dist/shared/index.d.ts +3 -0
- package/dist/shared/index.js +3 -0
- package/dist/shared/is-tenant-db.js +1 -1
- package/dist/shared/mail-template-body.d.ts +4 -0
- package/dist/shared/mail-template-body.js +17 -0
- package/dist/shared/sharp-input-limits.d.ts +6 -0
- package/dist/shared/sharp-input-limits.js +22 -0
- package/dist/shared/signup-handover.js +2 -2
- package/dist/tags/handlers/assign-tag.write.js +3 -1
- package/dist/tags/web/tag-chip.js +2 -0
- package/dist/template-resolver/api.d.ts +30 -2
- package/dist/template-resolver/api.js +40 -5
- package/dist/template-resolver/compile-document-body.d.ts +8 -0
- package/dist/template-resolver/compile-document-body.js +163 -0
- package/dist/template-resolver/constants.d.ts +13 -4
- package/dist/template-resolver/constants.js +30 -10
- package/dist/template-resolver/document-sanitizer.d.ts +32 -0
- package/dist/template-resolver/document-sanitizer.js +201 -0
- package/dist/template-resolver/document-schema.d.ts +21 -0
- package/dist/template-resolver/document-schema.js +296 -0
- package/dist/template-resolver/feature.d.ts +18 -0
- package/dist/template-resolver/feature.js +50 -6
- package/dist/template-resolver/handlers/bundle-import.d.ts +20 -0
- package/dist/template-resolver/handlers/bundle-import.js +147 -0
- package/dist/template-resolver/handlers/collection-set.write.js +25 -2
- package/dist/template-resolver/handlers/copy-locale.write.d.ts +19 -0
- package/dist/template-resolver/handlers/copy-locale.write.js +116 -0
- package/dist/template-resolver/handlers/diff-to-system.query.d.ts +22 -0
- package/dist/template-resolver/handlers/diff-to-system.query.js +64 -0
- package/dist/template-resolver/handlers/export-bundle.query.d.ts +35 -0
- package/dist/template-resolver/handlers/export-bundle.query.js +68 -0
- package/dist/template-resolver/handlers/find-by-id.query.d.ts +1 -0
- package/dist/template-resolver/handlers/find-by-id.query.js +3 -3
- package/dist/template-resolver/handlers/import-bundle-system.write.d.ts +39 -0
- package/dist/template-resolver/handlers/import-bundle-system.write.js +36 -0
- package/dist/template-resolver/handlers/import-bundle.write.d.ts +40 -0
- package/dist/template-resolver/handlers/import-bundle.write.js +19 -0
- package/dist/template-resolver/handlers/layout-source.query.d.ts +26 -0
- package/dist/template-resolver/handlers/layout-source.query.js +64 -0
- package/dist/template-resolver/handlers/list.query.d.ts +4 -1
- package/dist/template-resolver/handlers/list.query.js +5 -7
- package/dist/template-resolver/handlers/overview.query.d.ts +59 -0
- package/dist/template-resolver/handlers/overview.query.js +165 -0
- package/dist/template-resolver/handlers/preview.query.d.ts +12 -0
- package/dist/template-resolver/handlers/preview.query.js +24 -0
- package/dist/template-resolver/handlers/reset-to-system.write.d.ts +8 -0
- package/dist/template-resolver/handlers/reset-to-system.write.js +50 -0
- package/dist/template-resolver/handlers/resource-link-pdf.write.d.ts +8 -0
- package/dist/template-resolver/handlers/resource-link-pdf.write.js +32 -0
- package/dist/template-resolver/handlers/resource-remove.write.d.ts +9 -0
- package/dist/template-resolver/handlers/resource-remove.write.js +44 -0
- package/dist/template-resolver/handlers/resource-shared.d.ts +9 -0
- package/dist/template-resolver/handlers/resource-shared.js +14 -0
- package/dist/template-resolver/handlers/resource-upload.write.d.ts +13 -0
- package/dist/template-resolver/handlers/resource-upload.write.js +98 -0
- package/dist/template-resolver/handlers/send-test.write.d.ts +11 -0
- package/dist/template-resolver/handlers/send-test.write.js +81 -0
- package/dist/template-resolver/handlers/set.write.d.ts +4 -1
- package/dist/template-resolver/handlers/set.write.js +25 -3
- package/dist/template-resolver/handlers/shared.d.ts +9 -5
- package/dist/template-resolver/handlers/shared.js +6 -2
- package/dist/template-resolver/handlers/template-gates.d.ts +36 -0
- package/dist/template-resolver/handlers/template-gates.js +131 -0
- package/dist/template-resolver/handlers/template-write.d.ts +45 -0
- package/dist/template-resolver/handlers/template-write.js +109 -0
- package/dist/template-resolver/handlers/tenant-overrides.query.d.ts +17 -0
- package/dist/template-resolver/handlers/tenant-overrides.query.js +48 -0
- package/dist/template-resolver/handlers/toggle-status.write.d.ts +12 -0
- package/dist/template-resolver/handlers/toggle-status.write.js +27 -11
- package/dist/template-resolver/handlers/upsert-system.write.d.ts +5 -8
- package/dist/template-resolver/handlers/upsert-system.write.js +14 -59
- package/dist/template-resolver/handlers/upsert-tenant.write.d.ts +5 -8
- package/dist/template-resolver/handlers/upsert-tenant.write.js +10 -61
- package/dist/template-resolver/handlers/usage.query.d.ts +17 -0
- package/dist/template-resolver/handlers/usage.query.js +15 -0
- package/dist/template-resolver/handlers/validate.query.d.ts +74 -0
- package/dist/template-resolver/handlers/validate.query.js +80 -0
- package/dist/template-resolver/index.d.ts +18 -1
- package/dist/template-resolver/index.js +16 -1
- package/dist/template-resolver/layout-regions.d.ts +23 -0
- package/dist/template-resolver/layout-regions.js +125 -0
- package/dist/template-resolver/linked-resources.d.ts +17 -0
- package/dist/template-resolver/linked-resources.js +75 -0
- package/dist/template-resolver/preview-display-html.d.ts +7 -0
- package/dist/template-resolver/preview-display-html.js +27 -0
- package/dist/template-resolver/print-document.d.ts +1 -0
- package/dist/template-resolver/print-document.js +55 -0
- package/dist/template-resolver/qualified-names.d.ts +30 -0
- package/dist/template-resolver/qualified-names.js +30 -0
- package/dist/template-resolver/record-mapping.d.ts +2 -0
- package/dist/template-resolver/record-mapping.js +21 -0
- package/dist/template-resolver/resource-image.d.ts +15 -0
- package/dist/template-resolver/resource-image.js +34 -0
- package/dist/template-resolver/seeding.d.ts +14 -1
- package/dist/template-resolver/seeding.js +105 -15
- package/dist/template-resolver/store-template-resource.d.ts +64 -0
- package/dist/template-resolver/store-template-resource.js +264 -0
- package/dist/template-resolver/table.d.ts +47 -8
- package/dist/template-resolver/table.js +11 -0
- package/dist/template-resolver/template-bundle.d.ts +36 -0
- package/dist/template-resolver/template-bundle.js +60 -0
- package/dist/template-resolver/template-diff.d.ts +7 -0
- package/dist/template-resolver/template-diff.js +68 -0
- package/dist/template-resolver/template-meta.d.ts +60 -0
- package/dist/template-resolver/template-meta.js +101 -0
- package/dist/template-resolver/template-preview.d.ts +86 -0
- package/dist/template-resolver/template-preview.js +374 -0
- package/dist/template-resolver/template-resources.d.ts +14 -0
- package/dist/template-resolver/template-resources.js +42 -0
- package/dist/template-resolver/template-usage.d.ts +12 -0
- package/dist/template-resolver/template-usage.js +30 -0
- package/dist/template-resolver/template-validation.d.ts +22 -0
- package/dist/template-resolver/template-validation.js +172 -0
- package/dist/template-resolver/testing.js +5 -5
- package/dist/template-resolver/user-content-table.d.ts +14 -8
- package/dist/template-resolver/web/client-plugin.d.ts +1 -0
- package/dist/template-resolver/web/client-plugin.js +9 -6
- package/dist/template-resolver/web/document-editor-extensions.d.ts +15 -0
- package/dist/template-resolver/web/document-editor-extensions.js +107 -0
- package/dist/template-resolver/web/document-editor.d.ts +3 -0
- package/dist/template-resolver/web/document-editor.js +11 -0
- package/dist/template-resolver/web/document-tiptap-editor.d.ts +3 -0
- package/dist/template-resolver/web/document-tiptap-editor.js +376 -0
- package/dist/template-resolver/web/document-toolbar-blocks.d.ts +33 -0
- package/dist/template-resolver/web/document-toolbar-blocks.js +15 -0
- package/dist/template-resolver/web/editor-handle.d.ts +3 -0
- package/dist/template-resolver/web/editor-handle.js +24 -0
- package/dist/template-resolver/web/i18n.js +76 -0
- package/dist/template-resolver/web/index.d.ts +4 -0
- package/dist/template-resolver/web/index.js +4 -0
- package/dist/template-resolver/web/placeholder-line-editor-lazy.d.ts +4 -0
- package/dist/template-resolver/web/placeholder-line-editor-lazy.js +16 -0
- package/dist/template-resolver/web/placeholder-line-editor.d.ts +15 -0
- package/dist/template-resolver/web/placeholder-line-editor.js +94 -0
- package/dist/template-resolver/web/placeholder-text.d.ts +4 -0
- package/dist/template-resolver/web/placeholder-text.js +34 -0
- package/dist/template-resolver/web/use-stable-by-key.d.ts +1 -0
- package/dist/template-resolver/web/use-stable-by-key.js +13 -0
- package/dist/tenant/handlers/team-list.query.js +3 -2
- package/dist/tenant/seeding.js +31 -6
- package/dist/tenant-lifecycle/handlers/cancel-destruction.write.d.ts +1 -1
- package/dist/tenant-lifecycle/handlers/cancel-destruction.write.js +2 -1
- package/dist/user/db/queries/stream-tenant-backfill.js +4 -4
- package/dist/user/handlers/detail.query.js +2 -1
- package/dist/user-data-rights/db/queries/forget-cleanup.d.ts +2 -2
- package/dist/user-data-rights/email-templates.d.ts +1 -29
- package/dist/user-data-rights/email-templates.js +5 -106
- package/dist/user-data-rights/feature.js +53 -35
- package/dist/user-data-rights/handlers/cancel-deletion.write.d.ts +1 -1
- package/dist/user-data-rights/handlers/cancel-deletion.write.js +2 -1
- package/dist/user-data-rights/handlers/request-deletion.write.js +5 -7
- package/dist/user-data-rights/lib/default-mailers.d.ts +15 -7
- package/dist/user-data-rights/lib/default-mailers.js +100 -48
- package/dist/user-data-rights/lib/update-user-lifecycle.js +1 -1
- package/dist/user-data-rights/mail-notifications.d.ts +13 -0
- package/dist/user-data-rights/mail-notifications.js +118 -0
- package/dist/user-data-rights/web/privacy-center-screen.js +2 -1
- package/dist/user-profile/web/profile-screen.js +2 -1
- package/dist/waitlist/feature.js +10 -0
- package/dist/waitlist/handlers/submit.write.js +3 -4
- package/dist/waitlist/mail.d.ts +19 -6
- package/dist/waitlist/mail.js +50 -14
- package/package.json +28 -17
- package/src/admin-shell/changes.json +6 -0
- package/src/auth-email-password/changes.json +14 -0
- package/src/billing-foundation/changes.json +7 -0
- package/src/channel-email/changes.json +19 -0
- package/src/data-retention/changes.json +6 -0
- package/src/delivery/changes.json +48 -0
- package/src/managed-pages/changes.json +6 -0
- package/src/renderer-foundation/changes.json +39 -0
- package/src/template-resolver/changes.json +118 -0
- package/src/user-data-rights/changes.json +7 -0
- package/src/waitlist/changes.json +7 -0
- package/dist/renderer-simple/feature.d.ts +0 -10
- package/dist/renderer-simple/feature.js +0 -42
- package/dist/renderer-simple/index.d.ts +0 -3
- package/dist/renderer-simple/index.js +0 -2
- package/dist/renderer-simple/mail-blocks.d.ts +0 -21
- package/dist/renderer-simple/mail-blocks.js +0 -107
- package/dist/renderer-simple/resolve-variables.d.ts +0 -7
- package/dist/renderer-simple/resolve-variables.js +0 -47
- package/dist/renderer-simple/simple-renderer.d.ts +0 -24
- package/dist/renderer-simple/simple-renderer.js +0 -200
- 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
|
+
}
|