@cosmicdrift/kumiko-samples 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/package.json +1 -1
- package/packages/bundled-features/package.json +17 -6
- package/packages/bundled-features/src/admin-shell/changes.json +6 -0
- package/packages/bundled-features/src/admin-shell/feature.ts +8 -1
- package/packages/bundled-features/src/agent-tools/agent-manifest.ts +3 -4
- package/packages/bundled-features/src/agent-tools/filter-ops.ts +1 -3
- package/packages/bundled-features/src/agent-tools/tool-catalog.ts +1 -3
- package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +2 -1
- package/packages/bundled-features/src/auth-email-password/changes.json +14 -0
- package/packages/bundled-features/src/auth-email-password/email-templates.ts +12 -138
- package/packages/bundled-features/src/auth-email-password/feature.ts +30 -9
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +4 -5
- package/packages/bundled-features/src/auth-email-password/handlers/request-account-unlock.write.ts +2 -5
- package/packages/bundled-features/src/auth-email-password/handlers/request-email-verification.write.ts +2 -5
- package/packages/bundled-features/src/auth-email-password/handlers/request-password-reset.write.ts +2 -5
- package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +3 -4
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +3 -12
- package/packages/bundled-features/src/auth-email-password/index.ts +2 -17
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +30 -22
- package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +158 -0
- package/packages/bundled-features/src/auth-email-password/signup-activation.ts +2 -3
- package/packages/bundled-features/src/auth-foundation/types.ts +8 -18
- package/packages/bundled-features/src/auth-mfa/feature.ts +8 -10
- package/packages/bundled-features/src/auth-mfa/hmac-token-codec.ts +4 -4
- package/packages/bundled-features/src/auth-mfa/mfa-challenge-token.ts +3 -3
- package/packages/bundled-features/src/auth-mfa/mfa-preauth-setup-token.ts +3 -3
- package/packages/bundled-features/src/auth-mfa/mfa-setup-token.ts +8 -8
- package/packages/bundled-features/src/billing-foundation/changes.json +7 -0
- package/packages/bundled-features/src/billing-foundation/constants.ts +5 -4
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +38 -44
- package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +3 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +115 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/register-consent.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +30 -30
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +2 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +1 -4
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +2 -2
- package/packages/bundled-features/src/billing-foundation/get-subscription-for-tenant.ts +2 -2
- package/packages/bundled-features/src/billing-foundation/handlers/create-portal-session.write.ts +4 -9
- package/packages/bundled-features/src/billing-foundation/handlers/list-subscriptions.query.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +2 -2
- package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +3 -6
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
- package/packages/bundled-features/src/cap-overview/web/cap-usage-cell.tsx +6 -4
- package/packages/bundled-features/src/channel-email/changes.json +19 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +122 -29
- package/packages/bundled-features/src/channel-email/feature.ts +6 -3
- package/packages/bundled-features/src/channel-email/index.ts +7 -1
- package/packages/bundled-features/src/channel-email/pii-guard.ts +13 -0
- package/packages/bundled-features/src/channel-email/smtp-transport.ts +15 -1
- package/packages/bundled-features/src/channel-email/types.ts +23 -0
- package/packages/bundled-features/src/channel-in-app/handlers/mark-read.write.ts +2 -2
- package/packages/bundled-features/src/config/handlers/set.write.ts +1 -5
- package/packages/bundled-features/src/custom-fields/db/queries/user-data-rights.ts +2 -2
- package/packages/bundled-features/src/custom-fields/lib/value-schema.ts +10 -11
- package/packages/bundled-features/src/custom-fields/wire-user-data-rights.ts +7 -6
- package/packages/bundled-features/src/data-retention/changes.json +6 -0
- package/packages/bundled-features/src/data-retention/index.ts +1 -0
- package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +8 -6
- package/packages/bundled-features/src/delivery/attachment-ref.ts +84 -0
- package/packages/bundled-features/src/delivery/changes.json +48 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +129 -4
- package/packages/bundled-features/src/delivery/constants.ts +2 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +104 -26
- package/packages/bundled-features/src/delivery/feature.ts +1 -1
- package/packages/bundled-features/src/delivery/index.ts +17 -2
- package/packages/bundled-features/src/delivery/jobs.ts +48 -5
- package/packages/bundled-features/src/delivery/notify-factory.ts +14 -9
- package/packages/bundled-features/src/delivery/testing.ts +9 -1
- package/packages/bundled-features/src/delivery/types.ts +28 -18
- package/packages/bundled-features/src/delivery/unsubscribe.ts +5 -2
- package/packages/bundled-features/src/derivatives-sharp/render.ts +1 -22
- package/packages/bundled-features/src/document-ingest-foundation/pages.ts +6 -3
- package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +4 -4
- package/packages/bundled-features/src/file-foundation/index.ts +7 -0
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +86 -0
- package/packages/bundled-features/src/folders/web/folder-manager.tsx +5 -2
- package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +11 -11
- package/packages/bundled-features/src/form-draft/db/queries/draft-count.ts +2 -2
- package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +2 -2
- package/packages/bundled-features/src/form-draft/release-file-refs.ts +3 -2
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +6 -6
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/scope-visibility.ts +2 -2
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-state.ts +7 -7
- package/packages/bundled-features/src/inbound-mail-foundation/retention-sweep.ts +2 -6
- package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +5 -13
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -6
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +40 -25
- package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +4 -3
- package/packages/bundled-features/src/jobs/handlers/catalog.query.ts +1 -1
- package/packages/bundled-features/src/ledger/recurring.ts +1 -3
- package/packages/bundled-features/src/managed-pages/branding.ts +33 -9
- package/packages/bundled-features/src/managed-pages/changes.json +6 -0
- package/packages/bundled-features/src/managed-pages/feature.ts +1 -0
- package/packages/bundled-features/src/managed-pages/i18n.ts +1 -0
- package/packages/bundled-features/src/managed-pages/screens/branding-screen.ts +8 -0
- package/packages/bundled-features/src/page-render/branding.ts +3 -0
- package/packages/bundled-features/src/page-render/markdown.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/feature.ts +2 -4
- package/packages/bundled-features/src/personal-access-tokens/handlers/list.query.ts +2 -1
- package/packages/bundled-features/src/personal-access-tokens/index.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +2 -1
- package/packages/bundled-features/src/renderer-foundation/README.md +11 -10
- package/packages/bundled-features/src/renderer-foundation/api.ts +14 -14
- package/packages/bundled-features/src/renderer-foundation/changes.json +39 -0
- package/packages/bundled-features/src/renderer-foundation/constants.ts +6 -8
- package/packages/bundled-features/src/renderer-foundation/feature.ts +7 -7
- package/packages/bundled-features/src/renderer-foundation/index.ts +13 -2
- package/packages/bundled-features/src/renderer-foundation/types.ts +132 -35
- package/packages/bundled-features/src/renderer-pdf/feature.ts +7 -1
- package/packages/bundled-features/src/sessions/db/queries/cleanup.ts +2 -2
- package/packages/bundled-features/src/sessions/feature.ts +2 -4
- package/packages/bundled-features/src/sessions/handlers/list.query.ts +2 -1
- package/packages/bundled-features/src/shared/attachment-file-name.ts +39 -0
- package/packages/bundled-features/src/shared/index.ts +6 -0
- package/packages/bundled-features/src/shared/is-tenant-db.ts +1 -1
- package/packages/bundled-features/src/shared/mail-template-body.ts +25 -0
- package/packages/bundled-features/src/shared/sharp-input-limits.ts +26 -0
- package/packages/bundled-features/src/shared/signup-handover.ts +4 -4
- package/packages/bundled-features/src/tags/handlers/assign-tag.write.ts +5 -1
- package/packages/bundled-features/src/tags/web/tag-chip.tsx +2 -1
- package/packages/bundled-features/src/template-resolver/README.md +63 -7
- package/packages/bundled-features/src/template-resolver/api.ts +83 -8
- package/packages/bundled-features/src/template-resolver/changes.json +118 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +214 -0
- package/packages/bundled-features/src/template-resolver/constants.ts +39 -11
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +335 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +323 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +56 -6
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +188 -0
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +30 -2
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +142 -0
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +72 -0
- package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +83 -0
- package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +3 -3
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +42 -0
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +22 -0
- package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +108 -0
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +5 -7
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +250 -0
- package/packages/bundled-features/src/template-resolver/handlers/preview.query.ts +25 -0
- package/packages/bundled-features/src/template-resolver/handlers/reset-to-system.write.ts +58 -0
- package/packages/bundled-features/src/template-resolver/handlers/resource-link-pdf.write.ts +34 -0
- package/packages/bundled-features/src/template-resolver/handlers/resource-remove.write.ts +50 -0
- package/packages/bundled-features/src/template-resolver/handlers/resource-shared.ts +24 -0
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +135 -0
- package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +106 -0
- package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +32 -3
- package/packages/bundled-features/src/template-resolver/handlers/shared.ts +7 -2
- package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +184 -0
- package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +183 -0
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +55 -0
- package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +57 -10
- package/packages/bundled-features/src/template-resolver/handlers/upsert-system.write.ts +19 -71
- package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +15 -76
- package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +17 -0
- package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +116 -0
- package/packages/bundled-features/src/template-resolver/index.ts +100 -0
- package/packages/bundled-features/src/template-resolver/layout-regions.ts +206 -0
- package/packages/bundled-features/src/template-resolver/linked-resources.ts +102 -0
- package/packages/bundled-features/src/template-resolver/preview-display-html.ts +36 -0
- package/packages/bundled-features/src/template-resolver/print-document.ts +58 -0
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +30 -0
- package/packages/bundled-features/src/template-resolver/record-mapping.ts +26 -0
- package/packages/bundled-features/src/template-resolver/resource-image.ts +47 -0
- package/packages/bundled-features/src/template-resolver/seeding.ts +147 -17
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +416 -0
- package/packages/bundled-features/src/template-resolver/table.ts +14 -0
- package/packages/bundled-features/src/template-resolver/template-bundle.ts +79 -0
- package/packages/bundled-features/src/template-resolver/template-diff.ts +78 -0
- package/packages/bundled-features/src/template-resolver/template-meta.ts +129 -0
- package/packages/bundled-features/src/template-resolver/template-preview.ts +573 -0
- package/packages/bundled-features/src/template-resolver/template-resources.ts +74 -0
- package/packages/bundled-features/src/template-resolver/template-usage.ts +48 -0
- package/packages/bundled-features/src/template-resolver/template-validation.ts +229 -0
- package/packages/bundled-features/src/template-resolver/testing.ts +5 -5
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +9 -6
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +162 -0
- package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +28 -0
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +850 -0
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +61 -0
- package/packages/bundled-features/src/template-resolver/web/editor-handle.ts +26 -0
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +100 -0
- package/packages/bundled-features/src/template-resolver/web/index.ts +19 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor-lazy.tsx +36 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +169 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-text.ts +37 -0
- package/packages/bundled-features/src/template-resolver/web/use-stable-by-key.ts +14 -0
- package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +3 -2
- package/packages/bundled-features/src/tenant/seeding.ts +41 -7
- package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +1 -1
- package/packages/bundled-features/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +2 -1
- package/packages/bundled-features/src/user/db/queries/stream-tenant-backfill.ts +4 -4
- package/packages/bundled-features/src/user/handlers/detail.query.ts +3 -5
- package/packages/bundled-features/src/user/roles-input-schema.ts +2 -2
- package/packages/bundled-features/src/user-data-rights/changes.json +7 -0
- package/packages/bundled-features/src/user-data-rights/db/queries/forget-cleanup.ts +2 -2
- package/packages/bundled-features/src/user-data-rights/email-templates.ts +5 -149
- package/packages/bundled-features/src/user-data-rights/feature.ts +72 -35
- package/packages/bundled-features/src/user-data-rights/handlers/cancel-deletion.write.ts +2 -1
- package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +5 -11
- package/packages/bundled-features/src/user-data-rights/lib/default-mailers.ts +134 -53
- package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +2 -2
- package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +157 -0
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
- package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +2 -1
- package/packages/bundled-features/src/waitlist/changes.json +7 -0
- package/packages/bundled-features/src/waitlist/feature.ts +17 -0
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +4 -5
- package/packages/bundled-features/src/waitlist/mail.ts +69 -18
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +1 -2
- package/samples/apps/ui-walkthrough/package.json +2 -0
- package/samples/apps/ui-walkthrough/src/app/auth-constants.ts +9 -0
- package/samples/apps/ui-walkthrough/src/app/client.tsx +6 -0
- package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +154 -0
- package/samples/apps/ui-walkthrough/src/app/server.ts +5 -1
- package/samples/apps/ui-walkthrough/src/app/shell.tsx +12 -1
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +354 -0
- package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +68 -0
- package/samples/apps/ui-walkthrough/src/features/billing/i18n.ts +18 -0
- package/samples/apps/ui-walkthrough/src/features/billing/index.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/billing/web/index.ts +7 -0
- package/samples/apps/ui-walkthrough/src/run-config.ts +37 -1
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +0 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +115 -20
- package/samples/apps/use-all-bundled/kumiko/migrations/0045_template-resource-meta.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/kumiko/migrations/0046_pdf-rendered.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/package.json +2 -0
- package/samples/apps/use-all-bundled/playwright.config.ts +38 -3
- package/samples/apps/use-all-bundled/schema/generate.ts +13 -3
- package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
- package/samples/apps/use-all-bundled/scripts/check-coverage.ts +3 -0
- package/samples/apps/use-all-bundled/src/app/auth-constants.ts +3 -0
- package/samples/apps/use-all-bundled/src/app/client.tsx +4 -0
- package/samples/apps/use-all-bundled/src/app/seed-documents.ts +128 -0
- package/samples/apps/use-all-bundled/src/app/seed.ts +4 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +11 -11
- package/samples/recipes/delivery-notifications/README.md +26 -25
- package/samples/recipes/delivery-notifications/package.json +2 -1
- package/samples/recipes/delivery-notifications/src/feature.ts +64 -58
- package/samples/recipes/seed-migration/README.md +1 -1
- package/packages/bundled-features/src/renderer-simple/changes.json +0 -27
- package/packages/bundled-features/src/renderer-simple/feature.ts +0 -66
- package/packages/bundled-features/src/renderer-simple/index.ts +0 -8
- package/packages/bundled-features/src/renderer-simple/mail-blocks.ts +0 -138
- package/packages/bundled-features/src/renderer-simple/resolve-variables.ts +0 -57
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +0 -304
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Canonical document model for template bodies (kinds mail, document-pdf).
|
|
3
|
+
// Universal on purpose: the web editor and the server sanitizer load the same
|
|
4
|
+
// extension list, so what the editor can produce is exactly what the server
|
|
5
|
+
// keeps. No DOM access at module level, no happy-dom here.
|
|
6
|
+
import { isOneOf, isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
7
|
+
import { Extension, type Extensions, Node } from "@tiptap/core";
|
|
8
|
+
import { Link } from "@tiptap/extension-link";
|
|
9
|
+
import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table";
|
|
10
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
11
|
+
|
|
12
|
+
const PLACEHOLDER_HREF = /^\{\{[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*\}\}$/;
|
|
13
|
+
const REFERENCE_NAME = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
14
|
+
const RESOURCE_KEY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
15
|
+
const ALLOWED_LINK_SCHEMES = ["https://", "http://", "mailto:", "tel:"] as const;
|
|
16
|
+
|
|
17
|
+
export function isDocumentPlaceholderHref(href: string): boolean {
|
|
18
|
+
return PLACEHOLDER_HREF.test(href);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const UNSAFE_URL_CHARACTERS = new Set(["<", ">", '"']);
|
|
22
|
+
const FORBIDDEN_REFERENCE_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
|
|
23
|
+
|
|
24
|
+
function hasUnsafeUrlCharacter(value: string): boolean {
|
|
25
|
+
return Array.from(value).some((character) => {
|
|
26
|
+
const code = character.charCodeAt(0);
|
|
27
|
+
return UNSAFE_URL_CHARACTERS.has(character) || code <= 0x1f || code === 0x7f;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isParsableWebUrl(href: string, protocol: "https:" | "http:"): boolean {
|
|
32
|
+
try {
|
|
33
|
+
return new URL(href).protocol === protocol;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function hasAllowedLinkScheme(href: string): boolean {
|
|
40
|
+
if (href.startsWith("https://")) return isParsableWebUrl(href, "https:");
|
|
41
|
+
if (href.startsWith("http://")) return isParsableWebUrl(href, "http:");
|
|
42
|
+
return ALLOWED_LINK_SCHEMES.some((scheme) => href.startsWith(scheme));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Exact lowercase prefix on the unchanged value: no trim, no case folding, so
|
|
46
|
+
// "JaVaScRiPt:", "HTTPS://" or a leading tab can never slip through as a
|
|
47
|
+
// scheme variant. Markup and control characters are refused outright because
|
|
48
|
+
// downstream parsers (MJML, PDF renderer) re-read the serialized attribute.
|
|
49
|
+
export function isAllowedDocumentLinkHref(href: string): boolean {
|
|
50
|
+
if (hasUnsafeUrlCharacter(href)) return false;
|
|
51
|
+
return hasAllowedLinkScheme(href) || isDocumentPlaceholderHref(href);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Segments that hit the prototype chain are refused: the renderer later walks
|
|
55
|
+
// the dotted path into a data object.
|
|
56
|
+
export function isDocumentReferenceName(value: string): boolean {
|
|
57
|
+
return (
|
|
58
|
+
REFERENCE_NAME.test(value) &&
|
|
59
|
+
!value.split(".").some((segment) => FORBIDDEN_REFERENCE_SEGMENTS.has(segment))
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isDocumentResourceKey(value: string): boolean {
|
|
64
|
+
return RESOURCE_KEY.test(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const DOCUMENT_NODE_NAMES = [
|
|
68
|
+
"variable",
|
|
69
|
+
"resource",
|
|
70
|
+
"button",
|
|
71
|
+
"divider",
|
|
72
|
+
"pageBreak",
|
|
73
|
+
] as const;
|
|
74
|
+
|
|
75
|
+
export const DocumentVariable = Node.create({
|
|
76
|
+
name: "variable",
|
|
77
|
+
group: "inline",
|
|
78
|
+
inline: true,
|
|
79
|
+
atom: true,
|
|
80
|
+
addAttributes() {
|
|
81
|
+
return { name: { default: null } };
|
|
82
|
+
},
|
|
83
|
+
parseHTML() {
|
|
84
|
+
return [
|
|
85
|
+
{
|
|
86
|
+
tag: "span[data-variable]",
|
|
87
|
+
getAttrs: (element) => {
|
|
88
|
+
const name = element.getAttribute("data-variable");
|
|
89
|
+
return name !== null && isDocumentReferenceName(name) ? { name } : false;
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
},
|
|
94
|
+
renderHTML({ node }) {
|
|
95
|
+
return ["span", { "data-variable": node.attrs["name"] }];
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export const DocumentResource = Node.create({
|
|
100
|
+
name: "resource",
|
|
101
|
+
group: "block",
|
|
102
|
+
atom: true,
|
|
103
|
+
addAttributes() {
|
|
104
|
+
return { resource: { default: null }, alt: { default: null } };
|
|
105
|
+
},
|
|
106
|
+
parseHTML() {
|
|
107
|
+
return [
|
|
108
|
+
{
|
|
109
|
+
tag: "img[data-resource]",
|
|
110
|
+
getAttrs: (element) => {
|
|
111
|
+
const resource = element.getAttribute("data-resource");
|
|
112
|
+
if (resource === null || !isDocumentResourceKey(resource)) return false;
|
|
113
|
+
return { resource, alt: element.getAttribute("alt") };
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
},
|
|
118
|
+
// Never emits src: the renderer resolves the resource key at render time.
|
|
119
|
+
renderHTML({ node }) {
|
|
120
|
+
const alt = node.attrs["alt"];
|
|
121
|
+
return [
|
|
122
|
+
"img",
|
|
123
|
+
alt === null || alt === undefined
|
|
124
|
+
? { "data-resource": node.attrs["resource"] }
|
|
125
|
+
: { "data-resource": node.attrs["resource"], alt },
|
|
126
|
+
];
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
export const DocumentButton = Node.create({
|
|
131
|
+
name: "button",
|
|
132
|
+
group: "block",
|
|
133
|
+
content: "text*",
|
|
134
|
+
marks: "",
|
|
135
|
+
defining: true,
|
|
136
|
+
addAttributes() {
|
|
137
|
+
return { href: { default: null } };
|
|
138
|
+
},
|
|
139
|
+
parseHTML() {
|
|
140
|
+
return [
|
|
141
|
+
{
|
|
142
|
+
tag: "a[data-button]",
|
|
143
|
+
// Must win against the Link mark's `a[href]` rule.
|
|
144
|
+
priority: 1000,
|
|
145
|
+
getAttrs: (element) => {
|
|
146
|
+
const href = element.getAttribute("href");
|
|
147
|
+
return href !== null && isDocumentPlaceholderHref(href) ? { href } : false;
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
];
|
|
151
|
+
},
|
|
152
|
+
renderHTML({ node }) {
|
|
153
|
+
return ["a", { "data-button": "", href: node.attrs["href"] }, 0];
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
export const DocumentDivider = Node.create({
|
|
158
|
+
name: "divider",
|
|
159
|
+
group: "block",
|
|
160
|
+
atom: true,
|
|
161
|
+
parseHTML() {
|
|
162
|
+
return [{ tag: "hr" }];
|
|
163
|
+
},
|
|
164
|
+
renderHTML() {
|
|
165
|
+
return ["hr"];
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
export const DocumentPageBreak = Node.create({
|
|
170
|
+
name: "pageBreak",
|
|
171
|
+
group: "block",
|
|
172
|
+
atom: true,
|
|
173
|
+
parseHTML() {
|
|
174
|
+
return [{ tag: "div[data-page-break]" }];
|
|
175
|
+
},
|
|
176
|
+
renderHTML() {
|
|
177
|
+
return ["div", { "data-page-break": "" }];
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// A repeat row is a table row carrying `each`: prosemirror-tables requires
|
|
182
|
+
// the tableRow node type, so a separate node type is not an option.
|
|
183
|
+
export const DocumentTableRow = TableRow.extend({
|
|
184
|
+
addAttributes() {
|
|
185
|
+
return {
|
|
186
|
+
each: {
|
|
187
|
+
default: null,
|
|
188
|
+
parseHTML: (element) => {
|
|
189
|
+
const each = element.getAttribute("data-each");
|
|
190
|
+
return each !== null && isDocumentReferenceName(each) ? each : null;
|
|
191
|
+
},
|
|
192
|
+
renderHTML: (attributes) => {
|
|
193
|
+
const each: unknown = attributes["each"];
|
|
194
|
+
return typeof each === "string" && each ? { "data-each": each } : {};
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
repeatHeader: {
|
|
198
|
+
default: false,
|
|
199
|
+
parseHTML: (element) => element.hasAttribute("data-repeat-header"),
|
|
200
|
+
renderHTML: (attributes) =>
|
|
201
|
+
attributes["repeatHeader"] ? { "data-repeat-header": "" } : {},
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
export const DocumentRepeatRow = DocumentTableRow;
|
|
208
|
+
|
|
209
|
+
export const DocumentTable = Table.extend({
|
|
210
|
+
renderHTML() {
|
|
211
|
+
return ["table", ["tbody", 0]];
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Span attributes are rendered only when they differ from the default, and
|
|
216
|
+
// colwidth (an editor-resize artefact) never, so `<td><p>x</p></td>` is the
|
|
217
|
+
// canonical cell.
|
|
218
|
+
function plainObjectOrEmpty(value: unknown): Record<string, unknown> {
|
|
219
|
+
return isPlainObject(value) ? value : {};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function canonicalCellAttributes(attributes: Record<string, unknown>) {
|
|
223
|
+
return {
|
|
224
|
+
...attributes,
|
|
225
|
+
colspan: {
|
|
226
|
+
...plainObjectOrEmpty(attributes["colspan"]),
|
|
227
|
+
renderHTML: (attrs: Record<string, unknown>) =>
|
|
228
|
+
attrs["colspan"] && attrs["colspan"] !== 1 ? { colspan: attrs["colspan"] } : {},
|
|
229
|
+
},
|
|
230
|
+
rowspan: {
|
|
231
|
+
...plainObjectOrEmpty(attributes["rowspan"]),
|
|
232
|
+
renderHTML: (attrs: Record<string, unknown>) =>
|
|
233
|
+
attrs["rowspan"] && attrs["rowspan"] !== 1 ? { rowspan: attrs["rowspan"] } : {},
|
|
234
|
+
},
|
|
235
|
+
colwidth: {
|
|
236
|
+
...plainObjectOrEmpty(attributes["colwidth"]),
|
|
237
|
+
renderHTML: () => ({}),
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export const DocumentTableCell = TableCell.extend({
|
|
243
|
+
addAttributes() {
|
|
244
|
+
return canonicalCellAttributes(this.parent?.() ?? {});
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
export const DocumentTableHeader = TableHeader.extend({
|
|
249
|
+
addAttributes() {
|
|
250
|
+
return canonicalCellAttributes(this.parent?.() ?? {});
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Only href survives a parse: target, rel, class and title from the input would
|
|
255
|
+
// otherwise be kept verbatim, and the renderer decides those itself.
|
|
256
|
+
export const DocumentLink = Link.extend({
|
|
257
|
+
addAttributes() {
|
|
258
|
+
return { href: { default: null, parseHTML: (element) => element.getAttribute("href") } };
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
export const DOCUMENT_TEXT_ALIGNMENTS = ["left", "center", "right", "justify"] as const;
|
|
263
|
+
export type DocumentTextAlignment = (typeof DOCUMENT_TEXT_ALIGNMENTS)[number];
|
|
264
|
+
|
|
265
|
+
// Serialized as data-align, never as a style attribute: the sanitizer keeps
|
|
266
|
+
// styles out of the model, and each renderer maps the value to its own CSS.
|
|
267
|
+
export const DocumentTextAlign = Extension.create({
|
|
268
|
+
name: "documentTextAlign",
|
|
269
|
+
addGlobalAttributes() {
|
|
270
|
+
return [
|
|
271
|
+
{
|
|
272
|
+
types: ["paragraph", "heading"],
|
|
273
|
+
attributes: {
|
|
274
|
+
textAlign: {
|
|
275
|
+
default: null,
|
|
276
|
+
parseHTML: (element) => {
|
|
277
|
+
const align = element.getAttribute("data-align");
|
|
278
|
+
return isOneOf(DOCUMENT_TEXT_ALIGNMENTS, align) ? align : null;
|
|
279
|
+
},
|
|
280
|
+
renderHTML: (attributes) => {
|
|
281
|
+
const align: unknown = attributes["textAlign"];
|
|
282
|
+
return isOneOf(DOCUMENT_TEXT_ALIGNMENTS, align) ? { "data-align": align } : {};
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
];
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
export const documentExtensions: Extensions = [
|
|
292
|
+
StarterKit.configure({
|
|
293
|
+
blockquote: false,
|
|
294
|
+
code: false,
|
|
295
|
+
codeBlock: false,
|
|
296
|
+
strike: false,
|
|
297
|
+
horizontalRule: false,
|
|
298
|
+
dropcursor: false,
|
|
299
|
+
gapcursor: false,
|
|
300
|
+
undoRedo: false,
|
|
301
|
+
trailingNode: false,
|
|
302
|
+
listKeymap: false,
|
|
303
|
+
heading: { levels: [1, 2, 3] },
|
|
304
|
+
link: false,
|
|
305
|
+
}),
|
|
306
|
+
DocumentLink.configure({
|
|
307
|
+
openOnClick: false,
|
|
308
|
+
autolink: false,
|
|
309
|
+
linkOnPaste: false,
|
|
310
|
+
HTMLAttributes: { target: null, rel: null, class: null },
|
|
311
|
+
isAllowedUri: (url) => isAllowedDocumentLinkHref(url),
|
|
312
|
+
}),
|
|
313
|
+
DocumentTable,
|
|
314
|
+
DocumentTableRow,
|
|
315
|
+
DocumentTableHeader,
|
|
316
|
+
DocumentTableCell,
|
|
317
|
+
DocumentVariable,
|
|
318
|
+
DocumentResource,
|
|
319
|
+
DocumentButton,
|
|
320
|
+
DocumentDivider,
|
|
321
|
+
DocumentPageBreak,
|
|
322
|
+
DocumentTextAlign,
|
|
323
|
+
];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type ContentCollectionDefinition,
|
|
3
|
+
createSystemConfig,
|
|
3
4
|
defineFeature,
|
|
4
5
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
5
6
|
import { bySlugQuery } from "./handlers/by-slug.query.js";
|
|
@@ -7,12 +8,33 @@ import { byTenantQuery } from "./handlers/by-tenant.query.js";
|
|
|
7
8
|
import { makeCollectionItemQuery } from "./handlers/collection-item.query.js";
|
|
8
9
|
import { makeCollectionListQuery } from "./handlers/collection-list.query.js";
|
|
9
10
|
import { makeCollectionSetWrite } from "./handlers/collection-set.write.js";
|
|
11
|
+
import { copyLocaleWrite } from "./handlers/copy-locale.write.js";
|
|
12
|
+
import { diffToSystemQuery } from "./handlers/diff-to-system.query.js";
|
|
13
|
+
import { exportBundleQuery } from "./handlers/export-bundle.query.js";
|
|
10
14
|
import { findByIdQuery } from "./handlers/find-by-id.query.js";
|
|
15
|
+
import { importBundleWrite } from "./handlers/import-bundle.write.js";
|
|
16
|
+
import { importBundleSystemWrite } from "./handlers/import-bundle-system.write.js";
|
|
17
|
+
import { layoutSourceQuery } from "./handlers/layout-source.query.js";
|
|
11
18
|
import { listQuery } from "./handlers/list.query.js";
|
|
19
|
+
import { overviewQuery } from "./handlers/overview.query.js";
|
|
20
|
+
import { previewQuery } from "./handlers/preview.query.js";
|
|
21
|
+
import { resetToSystemWrite } from "./handlers/reset-to-system.write.js";
|
|
22
|
+
import { resourceLinkPdfWrite } from "./handlers/resource-link-pdf.write.js";
|
|
23
|
+
import { resourceRemoveWrite } from "./handlers/resource-remove.write.js";
|
|
24
|
+
import { resourceUploadWrite } from "./handlers/resource-upload.write.js";
|
|
25
|
+
import { sendTestWrite } from "./handlers/send-test.write.js";
|
|
12
26
|
import { setWrite } from "./handlers/set.write.js";
|
|
13
|
-
import {
|
|
27
|
+
import { tenantOverridesQuery } from "./handlers/tenant-overrides.query.js";
|
|
28
|
+
import {
|
|
29
|
+
archiveSystemWrite,
|
|
30
|
+
archiveWrite,
|
|
31
|
+
publishSystemWrite,
|
|
32
|
+
publishWrite,
|
|
33
|
+
} from "./handlers/toggle-status.write.js";
|
|
14
34
|
import { upsertSystemWrite } from "./handlers/upsert-system.write.js";
|
|
15
35
|
import { upsertTenantWrite } from "./handlers/upsert-tenant.write.js";
|
|
36
|
+
import { usageQuery } from "./handlers/usage.query.js";
|
|
37
|
+
import { validateQuery } from "./handlers/validate.query.js";
|
|
16
38
|
import { templateResourceEntity } from "./table.js";
|
|
17
39
|
import { userContentEntryEntity } from "./user-content-table.js";
|
|
18
40
|
|
|
@@ -22,7 +44,7 @@ import { userContentEntryEntity } from "./user-content-table.js";
|
|
|
22
44
|
//
|
|
23
45
|
// Consumption paths:
|
|
24
46
|
// - Render time: ctx.templateResolver.resolveTemplate(...) (see api.ts)
|
|
25
|
-
// - Admin UI: write/query handlers (upsertSystem, upsertTenant, publish, archive, findById, list)
|
|
47
|
+
// - Admin UI: write/query handlers (upsertSystem, upsertTenant, publish, archive, publishSystem, archiveSystem, resetToSystem, findById, list, usage, tenantOverrides, diffToSystem)
|
|
26
48
|
// - Cross-feature: requireTemplateResolver(ctx, callerName)
|
|
27
49
|
export type TemplateResolverOptions = {
|
|
28
50
|
/** Content collections this app mounts. Declared here rather than inside the
|
|
@@ -43,11 +65,14 @@ export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}
|
|
|
43
65
|
return defineFeature("template-resolver", (r) => {
|
|
44
66
|
r.describe(
|
|
45
67
|
[
|
|
46
|
-
"Every piece of editable text lives here, in one entity: mail bodies, notification texts, PDF document templates, AI prompts and plain text blocks. What a record is used for is the `kind` (`notification`, `mail-
|
|
47
|
-
"Reading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes. Writing programmatically goes through the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers
|
|
68
|
+
"Every piece of editable text lives here, in one entity: mail bodies, notification texts, PDF document templates, AI prompts and plain text blocks. What a record is used for is the `kind` (`notification`, `mail`, `mail-layout`, `document-pdf`, `pdf-layout`, `ai-prompt`, `text-block`, `snippet`, `image-snapshot`).",
|
|
69
|
+
"Reading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes. Writing programmatically goes through the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers; a SystemAdmin retires or restores a system default with `publishSystem` and `archiveSystem`, and a tenant drops its override with `resetToSystem`.",
|
|
48
70
|
"Text an editor should be able to change belongs in a collection, declared at mount: `createTemplateResolverFeature({ collections: [{ id, kind, access: { roles }, nav }] })`. It appears in the navigation, and `access` is part of the mount because a bundled feature cannot know the host's roles. Each collection gets its own `<id>-list` / `<id>-item` / `<id>-set` handlers, so the dispatcher enforces the separation.",
|
|
49
71
|
"How a collection is edited follows from `contentFormat`: `plain` gives a text area, `rich` a small WYSIWYG (bold, italic, headings, lists, links), `markdown` a text area that stores markdown text (same insertable chips as `plain`). All three offer the collection's `variableSchema` as insertable chips and a preview rendered with sample data — an editor sees what `{{firstName}}` becomes without sending a mail. An app can register its own editor for a format; the last `clientFeature` that registers a format wins (a conflict logs a warning).",
|
|
50
72
|
'A collection is tenant-wide by default. With `ownership: "user"` every user keeps their own entries — mail signatures being the obvious case. Those rows live in the separate `user-content-entry` entity and count as user data, so mounting one also requires the `template-resolver-user-data` feature and a migration on the app side.',
|
|
73
|
+
"`validate` dry-runs the write pipeline for a draft (sanitizer, layout rules, placeholders) and reports what the template uses and misses. `export-bundle` / `import-bundle` (and `import-bundle-system`) move one template with all locales and resource files between tenants or environments; `copy-locale` starts a translation from an existing locale. Layouts are structured: a mail or PDF layout is `data-header` / `data-content` / `data-signature` / `data-footer` regions, and mjml layout source is system-only.",
|
|
74
|
+
"`preview` renders a draft (or the stored template) with sample values taken from the `example` of each declared variable, through the same gates and the same mail renderer as a real send, and returns subject, html, `displayHtml` (the html with cid images as data URIs for an editor), text, attachments (inline images and files) and `checks` (images resolved, plain text present, size under 10 MiB, required placeholders used); nothing is saved or sent. `send-test` mails that rendering to the calling user's own address only, subject prefixed with `[TEST]`, at most 10 per user and hour; it needs `delivery`, `channel-email` and `renderer-mail-html` to be mounted.",
|
|
75
|
+
'Images used by a template (`<img data-resource="logo">`) are uploaded with `resourceUpload`, which re-encodes them, stores a file and links it under the key; `resourceRemove` unlinks it. `linkedResources` is written only through these two handlers. With `purpose: "attachment"` and a `fileName`, `resourceUpload` stores a PDF, PNG or JPEG as a file attachment of a mail template (original bytes, at most 10) that the mail renderer attaches after the inline images. `resourceLinkPdf` instead links a `document-pdf` template under a key: every mail sent from the template then carries that PDF, rendered at send time with the mail\'s own variables and locale, under a file name pattern such as `invoice-{{number}}.pdf`; files and linked PDFs share the limit of 10.',
|
|
51
76
|
"Replaces the former `text-content` feature; its blocks now live here as kind `text-block`.",
|
|
52
77
|
].join("\n\n"),
|
|
53
78
|
);
|
|
@@ -56,6 +81,11 @@ export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}
|
|
|
56
81
|
category: "notifications",
|
|
57
82
|
recommended: false,
|
|
58
83
|
});
|
|
84
|
+
r.config({
|
|
85
|
+
keys: {
|
|
86
|
+
resourceMaxBytes: createSystemConfig("number", { default: 2_097_152, bounds: { min: 1 } }),
|
|
87
|
+
},
|
|
88
|
+
});
|
|
59
89
|
r.entity("template-resource", templateResourceEntity);
|
|
60
90
|
// Only when the app actually mounts a user-owned collection: the entity's
|
|
61
91
|
// `content` is `userOwned`, which makes it subject data — the boot guard
|
|
@@ -68,7 +98,17 @@ export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}
|
|
|
68
98
|
upsertTenant: r.writeHandler(upsertTenantWrite),
|
|
69
99
|
publish: r.writeHandler(publishWrite),
|
|
70
100
|
archive: r.writeHandler(archiveWrite),
|
|
101
|
+
publishSystem: r.writeHandler(publishSystemWrite),
|
|
102
|
+
archiveSystem: r.writeHandler(archiveSystemWrite),
|
|
103
|
+
resetToSystem: r.writeHandler(resetToSystemWrite),
|
|
71
104
|
set: r.writeHandler(setWrite),
|
|
105
|
+
importBundle: r.writeHandler(importBundleWrite),
|
|
106
|
+
importBundleSystem: r.writeHandler(importBundleSystemWrite),
|
|
107
|
+
copyLocale: r.writeHandler(copyLocaleWrite),
|
|
108
|
+
resourceUpload: r.writeHandler(resourceUploadWrite),
|
|
109
|
+
resourceRemove: r.writeHandler(resourceRemoveWrite),
|
|
110
|
+
resourceLinkPdf: r.writeHandler(resourceLinkPdfWrite),
|
|
111
|
+
sendTest: r.writeHandler(sendTestWrite),
|
|
72
112
|
};
|
|
73
113
|
|
|
74
114
|
const queries = {
|
|
@@ -76,6 +116,14 @@ export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}
|
|
|
76
116
|
list: r.queryHandler(listQuery),
|
|
77
117
|
bySlug: r.queryHandler(bySlugQuery),
|
|
78
118
|
byTenant: r.queryHandler(byTenantQuery),
|
|
119
|
+
usage: r.queryHandler(usageQuery),
|
|
120
|
+
overview: r.queryHandler(overviewQuery),
|
|
121
|
+
layoutSource: r.queryHandler(layoutSourceQuery),
|
|
122
|
+
tenantOverrides: r.queryHandler(tenantOverridesQuery),
|
|
123
|
+
diffToSystem: r.queryHandler(diffToSystemQuery),
|
|
124
|
+
validate: r.queryHandler(validateQuery),
|
|
125
|
+
preview: r.queryHandler(previewQuery),
|
|
126
|
+
exportBundle: r.queryHandler(exportBundleQuery),
|
|
79
127
|
};
|
|
80
128
|
|
|
81
129
|
for (const collection of collections) {
|
|
@@ -88,10 +136,12 @@ export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}
|
|
|
88
136
|
// Visual-tree actions for the text-block content tree. The handle is
|
|
89
137
|
// propagated through the setup export so other features can build
|
|
90
138
|
// compile-time-typed cross-feature edit targets.
|
|
139
|
+
// r.treeActions keeps literal types (const generic); the args only declare string-typed slots.
|
|
140
|
+
const emptyString: string = "";
|
|
91
141
|
const treeHandle = r.treeActions({
|
|
92
|
-
edit: { args: { slug:
|
|
142
|
+
edit: { args: { slug: emptyString, locale: emptyString } },
|
|
93
143
|
list: {},
|
|
94
|
-
create: { args: { folder:
|
|
144
|
+
create: { args: { folder: emptyString } },
|
|
95
145
|
});
|
|
96
146
|
|
|
97
147
|
return { handlers, queries, treeHandle };
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import type {
|
|
3
|
+
HandlerContext,
|
|
4
|
+
SessionUser,
|
|
5
|
+
TenantId,
|
|
6
|
+
WriteResult,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import {
|
|
9
|
+
type FieldIssue,
|
|
10
|
+
ValidationError,
|
|
11
|
+
writeFailure,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
13
|
+
import { requireForTenant } from "../../shared/index.js";
|
|
14
|
+
import type { TemplateWriteScope } from "../layout-regions.js";
|
|
15
|
+
import type { LinkedResourceEntry } from "../linked-resources.js";
|
|
16
|
+
import {
|
|
17
|
+
attachmentLimitFailure,
|
|
18
|
+
attachmentNeedsMailTemplate,
|
|
19
|
+
readTemplateResourceMaxBytes,
|
|
20
|
+
storeBundleAttachment,
|
|
21
|
+
storeTemplateResource,
|
|
22
|
+
} from "../store-template-resource.js";
|
|
23
|
+
import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
|
|
24
|
+
import type { TemplateBundle } from "../template-bundle.js";
|
|
25
|
+
import { attachTemplateResources } from "../template-resources.js";
|
|
26
|
+
import { resolveTemplateVariables } from "../template-validation.js";
|
|
27
|
+
import { MAX_FILE_ATTACHMENTS_PER_TEMPLATE } from "./resource-shared.js";
|
|
28
|
+
import { evaluateTemplateWrite } from "./template-gates.js";
|
|
29
|
+
import {
|
|
30
|
+
refuseTenantScopeUnderSystemTenant,
|
|
31
|
+
type TemplateRowPayload,
|
|
32
|
+
writeTemplateRow,
|
|
33
|
+
} from "./template-write.js";
|
|
34
|
+
|
|
35
|
+
export function targetExistsIssue(locale: string): FieldIssue {
|
|
36
|
+
return {
|
|
37
|
+
path: "locale",
|
|
38
|
+
code: "target_exists",
|
|
39
|
+
i18nKey: "templateResolver.errors.template.targetExists",
|
|
40
|
+
params: { detail: locale },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type BundleImport = {
|
|
45
|
+
readonly ctx: HandlerContext;
|
|
46
|
+
readonly user: SessionUser;
|
|
47
|
+
readonly tenantId: TenantId;
|
|
48
|
+
readonly scope: TemplateWriteScope;
|
|
49
|
+
readonly bundle: TemplateBundle;
|
|
50
|
+
readonly overwrite: boolean;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type BundleImportStatus = "draft" | "active";
|
|
54
|
+
|
|
55
|
+
async function collectBundleIssues(
|
|
56
|
+
args: BundleImport,
|
|
57
|
+
status: BundleImportStatus,
|
|
58
|
+
): Promise<readonly FieldIssue[]> {
|
|
59
|
+
const { ctx, tenantId, scope, bundle, overwrite } = args;
|
|
60
|
+
const { db } = requireForTenant(ctx, tenantId);
|
|
61
|
+
const variables = resolveTemplateVariables(
|
|
62
|
+
ctx.registry,
|
|
63
|
+
bundle.slug,
|
|
64
|
+
bundle.variableSchema ?? {},
|
|
65
|
+
);
|
|
66
|
+
const issues: FieldIssue[] = [];
|
|
67
|
+
for (const [locale, entry] of Object.entries(bundle.locales)) {
|
|
68
|
+
const evaluated = evaluateTemplateWrite({
|
|
69
|
+
kind: bundle.kind,
|
|
70
|
+
scope,
|
|
71
|
+
contentFormat: bundle.contentFormat,
|
|
72
|
+
content: entry.body,
|
|
73
|
+
rawMeta: entry.meta,
|
|
74
|
+
variables,
|
|
75
|
+
});
|
|
76
|
+
const found = evaluated.ok
|
|
77
|
+
? status === "active"
|
|
78
|
+
? evaluated.publishIssues
|
|
79
|
+
: []
|
|
80
|
+
: evaluated.issues;
|
|
81
|
+
issues.push(...found.map((issue) => ({ ...issue, path: `locales.${locale}.${issue.path}` })));
|
|
82
|
+
if (!overwrite) {
|
|
83
|
+
const existing = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
84
|
+
tenantId,
|
|
85
|
+
slug: bundle.slug,
|
|
86
|
+
kind: bundle.kind,
|
|
87
|
+
locale,
|
|
88
|
+
});
|
|
89
|
+
if (existing) issues.push(targetExistsIssue(locale));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return issues;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function storeBundleResources(
|
|
96
|
+
args: BundleImport,
|
|
97
|
+
): Promise<WriteResult<Record<string, LinkedResourceEntry>>> {
|
|
98
|
+
const linked: Record<string, LinkedResourceEntry> = {};
|
|
99
|
+
const pdfLinks = Object.entries(args.bundle.pdfAttachments);
|
|
100
|
+
const attachmentCount =
|
|
101
|
+
Object.values(args.bundle.resources).filter((resource) => resource.purpose === "attachment")
|
|
102
|
+
.length + pdfLinks.length;
|
|
103
|
+
if (attachmentCount > 0 && args.bundle.kind !== "mail") return attachmentNeedsMailTemplate();
|
|
104
|
+
if (attachmentCount > MAX_FILE_ATTACHMENTS_PER_TEMPLATE) return attachmentLimitFailure();
|
|
105
|
+
// The document template is not looked up: a bundle may carry it later than the mail.
|
|
106
|
+
for (const [key, link] of pdfLinks) linked[key] = { kind: "rendered-pdf", ...link };
|
|
107
|
+
const maxBytes = await readTemplateResourceMaxBytes(args.ctx.config, args.ctx.registry);
|
|
108
|
+
// A bundle is untrusted input like an upload: the same re-encoding decides the stored
|
|
109
|
+
// content type, so a declared image/png can't smuggle SVG or HTML into a mail or PDF.
|
|
110
|
+
for (const [key, resource] of Object.entries(args.bundle.resources)) {
|
|
111
|
+
const bytes = Uint8Array.from(Buffer.from(resource.base64, "base64"));
|
|
112
|
+
if (resource.purpose === "attachment") {
|
|
113
|
+
const attachment = await storeBundleAttachment({
|
|
114
|
+
ctx: args.ctx,
|
|
115
|
+
user: args.user,
|
|
116
|
+
tenantId: args.tenantId,
|
|
117
|
+
bytes,
|
|
118
|
+
maxBytes,
|
|
119
|
+
key,
|
|
120
|
+
fileName: resource.fileName,
|
|
121
|
+
});
|
|
122
|
+
if (!attachment.isSuccess) return attachment;
|
|
123
|
+
linked[key] = attachment.data;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const stored = await storeTemplateResource({
|
|
127
|
+
ctx: args.ctx,
|
|
128
|
+
user: args.user,
|
|
129
|
+
tenantId: args.tenantId,
|
|
130
|
+
bytes,
|
|
131
|
+
maxBytes,
|
|
132
|
+
key,
|
|
133
|
+
});
|
|
134
|
+
if (!stored.isSuccess) return stored;
|
|
135
|
+
linked[key] = stored.data.fileRefId;
|
|
136
|
+
}
|
|
137
|
+
return { isSuccess: true, data: linked };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Validates every locale first, stores the resource files, then writes one row
|
|
141
|
+
// per locale, so a bad locale never leaves files or half a bundle behind. An image
|
|
142
|
+
// that fails re-encoding stops the import before any row is written; files stored
|
|
143
|
+
// for the resources before it stay as unlinked FileRefs, like a replaced upload.
|
|
144
|
+
export async function importTemplateBundle(
|
|
145
|
+
args: BundleImport & { readonly status: BundleImportStatus },
|
|
146
|
+
): Promise<WriteResult<{ ids: Record<string, string> }>> {
|
|
147
|
+
const refused = refuseTenantScopeUnderSystemTenant(args.scope, args.tenantId);
|
|
148
|
+
if (refused !== null) return refused;
|
|
149
|
+
const issues = await collectBundleIssues(args, args.status);
|
|
150
|
+
if (issues.length > 0) return writeFailure(new ValidationError({ fields: [...issues] }));
|
|
151
|
+
|
|
152
|
+
const linked = await storeBundleResources(args);
|
|
153
|
+
if (!linked.isSuccess) return linked;
|
|
154
|
+
|
|
155
|
+
const variableSchema = args.bundle.variableSchema ?? {};
|
|
156
|
+
const variables = resolveTemplateVariables(args.ctx.registry, args.bundle.slug, variableSchema);
|
|
157
|
+
const ids: Record<string, string> = {};
|
|
158
|
+
for (const [locale, entry] of Object.entries(args.bundle.locales)) {
|
|
159
|
+
const payload: TemplateRowPayload = {
|
|
160
|
+
slug: args.bundle.slug,
|
|
161
|
+
kind: args.bundle.kind,
|
|
162
|
+
locale,
|
|
163
|
+
content: entry.body,
|
|
164
|
+
contentFormat: args.bundle.contentFormat,
|
|
165
|
+
variableSchema,
|
|
166
|
+
meta: entry.meta,
|
|
167
|
+
};
|
|
168
|
+
const written = await writeTemplateRow({
|
|
169
|
+
ctx: args.ctx,
|
|
170
|
+
user: args.user,
|
|
171
|
+
tenantId: args.tenantId,
|
|
172
|
+
scope: args.scope,
|
|
173
|
+
status: args.status,
|
|
174
|
+
variables,
|
|
175
|
+
payload,
|
|
176
|
+
});
|
|
177
|
+
if (!written.isSuccess) return written;
|
|
178
|
+
const attached = await attachTemplateResources(args.ctx, {
|
|
179
|
+
user: args.user,
|
|
180
|
+
tenantId: args.tenantId,
|
|
181
|
+
templateId: written.data.id,
|
|
182
|
+
resources: linked.data,
|
|
183
|
+
});
|
|
184
|
+
if (!attached.isSuccess) return attached;
|
|
185
|
+
ids[locale] = written.data.id;
|
|
186
|
+
}
|
|
187
|
+
return { isSuccess: true, data: { ids } };
|
|
188
|
+
}
|