@cosmicdrift/kumiko-samples 0.374.0 → 0.378.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 +6 -4
- package/packages/bundled-features/src/account-security/changes.json +6 -0
- package/packages/bundled-features/src/account-security/constants.ts +1 -0
- package/packages/bundled-features/src/account-security/index.ts +1 -0
- package/packages/bundled-features/src/admin-shell/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/audit/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +3 -3
- package/packages/bundled-features/src/auth-email-password/web/default-topbar-actions.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/index.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/web/session.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/tenant-menu-items.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/tenant-switcher.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
- package/packages/bundled-features/src/auth-foundation/changes.json +6 -0
- package/packages/bundled-features/src/auth-foundation/env.ts +27 -0
- package/packages/bundled-features/src/auth-foundation/index.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/contact-card.ts +14 -0
- package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/index.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/types.ts +12 -0
- package/packages/bundled-features/src/billing-foundation/validate-options.ts +16 -0
- package/packages/bundled-features/src/billing-foundation/web/billing-contact-plan.tsx +32 -0
- package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +5 -15
- package/packages/bundled-features/src/billing-foundation/web/cancel-contract-dialog.tsx +2 -2
- package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +1 -1
- package/packages/bundled-features/src/billing-foundation/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/billing-foundation/web/localized-params.ts +14 -0
- package/packages/bundled-features/src/cap-counter/changes.json +12 -0
- package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +9 -0
- package/packages/bundled-features/src/cap-overview/web/cap-cards-panel.tsx +3 -3
- package/packages/bundled-features/src/cap-overview/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/channel-email/changes.json +6 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +2 -1
- package/packages/bundled-features/src/compliance-profiles/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/config/feature.ts +2 -2
- package/packages/bundled-features/src/config/web/i18n.ts +1 -0
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/custom-fields/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/delivery/unsubscribe-page.ts +1 -1
- package/packages/bundled-features/src/delivery/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/delivery/web/delivery-status-cell.tsx +1 -1
- package/packages/bundled-features/src/feature-toggles/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/file-provider-s3/changes.json +6 -0
- package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +45 -0
- package/packages/bundled-features/src/file-provider-s3/env-schema.ts +20 -13
- package/packages/bundled-features/src/file-provider-s3/index.ts +4 -0
- package/packages/bundled-features/src/folders/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/folders/web/folder-section.tsx +3 -4
- package/packages/bundled-features/src/host-routing/README.md +7 -0
- package/packages/bundled-features/src/host-routing/changes.json +6 -0
- package/packages/bundled-features/src/host-routing/constants.ts +4 -0
- package/packages/bundled-features/src/host-routing/dispatch.ts +34 -26
- package/packages/bundled-features/src/host-routing/feature.ts +122 -41
- package/packages/bundled-features/src/host-routing/host.ts +28 -1
- package/packages/bundled-features/src/host-routing/index.ts +3 -0
- package/packages/bundled-features/src/host-routing/options.ts +45 -2
- package/packages/bundled-features/src/host-routing/redirects.ts +33 -0
- package/packages/bundled-features/src/host-routing/tenant-lookup.ts +65 -0
- package/packages/bundled-features/src/host-routing/validate-options.ts +48 -1
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +13 -0
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +8 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +11 -4
- package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +42 -0
- package/packages/bundled-features/src/inbound-mail-foundation/index.ts +4 -0
- package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +100 -0
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +2 -2
- package/packages/bundled-features/src/jobs/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/legal-pages/changes.json +6 -0
- package/packages/bundled-features/src/legal-pages/feature.ts +14 -1
- package/packages/bundled-features/src/legal-pages/layout.ts +6 -6
- package/packages/bundled-features/src/legal-pages/web/client-plugin.ts +1 -1
- package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/mail-foundation/feature.ts +21 -22
- package/packages/bundled-features/src/mail-foundation/i18n.ts +3 -2
- package/packages/bundled-features/src/mail-transport-smtp/i18n.ts +1 -1
- package/packages/bundled-features/src/managed-pages/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/notes-history/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/page-render/branding.ts +1 -1
- package/packages/bundled-features/src/page-render/layout.ts +2 -2
- package/packages/bundled-features/src/page-render/markdown.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/i18n.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/screens.ts +1 -1
- package/packages/bundled-features/src/secrets/feature.ts +4 -4
- package/packages/bundled-features/src/seo/sitemap.ts +1 -1
- package/packages/bundled-features/src/shared/mail-template-body.ts +1 -1
- package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +127 -2
- package/packages/bundled-features/src/tags/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/template-resolver/changes.json +38 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +61 -3
- package/packages/bundled-features/src/template-resolver/constants.ts +3 -0
- package/packages/bundled-features/src/template-resolver/document-blocks.ts +36 -0
- package/packages/bundled-features/src/template-resolver/document-images.ts +29 -0
- package/packages/bundled-features/src/template-resolver/document-ops.ts +515 -0
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +44 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +5 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +79 -34
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +1 -0
- package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +2 -6
- package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +0 -6
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +67 -58
- package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +36 -13
- package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +86 -0
- package/packages/bundled-features/src/template-resolver/handlers/document-blocks.query.ts +48 -0
- package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +11 -3
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +37 -28
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +66 -62
- package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +134 -0
- package/packages/bundled-features/src/template-resolver/handlers/preview.query.ts +2 -1
- package/packages/bundled-features/src/template-resolver/handlers/reset-to-system.write.ts +62 -49
- package/packages/bundled-features/src/template-resolver/handlers/resource-link-pdf.write.ts +44 -29
- package/packages/bundled-features/src/template-resolver/handlers/resource-remove.write.ts +52 -36
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +126 -106
- package/packages/bundled-features/src/template-resolver/handlers/send-test-shared.ts +38 -0
- package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +31 -0
- package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +105 -110
- package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +110 -103
- package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +22 -1
- package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +56 -28
- package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +38 -29
- package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +24 -9
- package/packages/bundled-features/src/template-resolver/index.ts +8 -1
- package/packages/bundled-features/src/template-resolver/{web/placeholder-text.ts → placeholder-text.ts} +1 -1
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
- package/packages/bundled-features/src/template-resolver/seeding.ts +3 -1
- package/packages/bundled-features/src/template-resolver/template-bundle.ts +6 -1
- package/packages/bundled-features/src/template-resolver/template-edit-policy.ts +91 -0
- package/packages/bundled-features/src/template-resolver/template-preview.ts +32 -10
- package/packages/bundled-features/src/template-resolver/template-validation.ts +32 -11
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +3 -3
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +29 -0
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +43 -5
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +9 -33
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +30 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +2 -2
- package/packages/bundled-features/src/tenant/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/tenant/web/member-status-cell.tsx +1 -1
- package/packages/bundled-features/src/tier-engine/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-data-rights/env.ts +15 -0
- package/packages/bundled-features/src/user-data-rights/feature.ts +4 -2
- package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/lib/mail-transport-resolver.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/lib/storage-provider-resolver.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +2 -2
- package/packages/bundled-features/src/waitlist/changes.json +6 -0
- package/packages/bundled-features/src/waitlist/legacy-migrate.ts +51 -2
- package/samples/apps/admin-console/package.json +0 -3
- package/samples/apps/admin-console/src/app/client.tsx +1 -1
- package/samples/apps/admin-console/src/app/shell.tsx +1 -1
- package/samples/apps/config-demo/package.json +1 -3
- package/samples/apps/config-demo/src/app/client.tsx +1 -1
- package/samples/apps/config-demo/src/app/shell.tsx +2 -2
- package/samples/apps/config-demo/src/features/demo/web/index.ts +1 -1
- package/samples/apps/config-demo/tsconfig.json +0 -12
- package/samples/apps/marketing-demo/package.json +1 -3
- package/samples/apps/marketing-demo/src/app/client.tsx +1 -1
- package/samples/apps/marketing-demo/src/app/shell.tsx +1 -1
- package/samples/apps/marketing-demo/src/features/assets/web/index.ts +1 -1
- package/samples/apps/marketing-demo/src/features/helpdesk/web/index.ts +1 -1
- package/samples/apps/showcase/package.json +1 -3
- package/samples/apps/showcase/src/app/client.tsx +1 -1
- package/samples/apps/showcase/src/app/shell.tsx +1 -1
- package/samples/apps/showcase/src/features/demos/web/index.ts +1 -1
- package/samples/apps/showcase/src/features/demos/web/pages/sidebar-panel.tsx +1 -1
- package/samples/apps/showcase/src/features/demos/web/pages/toast.tsx +1 -1
- package/samples/apps/showcase/src/features/items/web/index.ts +1 -1
- package/samples/apps/styleguide/package.json +1 -3
- package/samples/apps/styleguide/src/app/client.tsx +1 -1
- package/samples/apps/styleguide/src/app/shell.tsx +2 -2
- package/samples/apps/styleguide/src/features/ai-settings/web.ts +1 -1
- package/samples/apps/styleguide/src/features/content/web.tsx +2 -2
- package/samples/apps/styleguide/src/features/demo/web.ts +1 -1
- package/samples/apps/styleguide/src/features/examples/web.ts +1 -1
- package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +1 -1
- package/samples/apps/styleguide/src/features/gallery/web/index.ts +1 -1
- package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +1 -1
- package/samples/apps/styleguide/src/features/widgets/lib/format.ts +1 -1
- package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +1 -1
- package/samples/apps/styleguide/src/features/widgets/web/FormExamples.tsx +1 -1
- package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +1 -1
- package/samples/apps/styleguide/src/features/widgets/web/index.ts +1 -1
- package/samples/apps/styleguide/tsconfig.json +0 -12
- package/samples/apps/ui-walkthrough/package.json +1 -3
- package/samples/apps/ui-walkthrough/src/app/client.tsx +1 -1
- package/samples/apps/ui-walkthrough/src/app/help-action.tsx +37 -0
- package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +2 -0
- package/samples/apps/ui-walkthrough/src/app/shell.tsx +7 -1
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +20 -0
- package/samples/apps/ui-walkthrough/src/features/billing/web/index.ts +1 -1
- package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +65 -1
- package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +70 -0
- package/samples/apps/ui-walkthrough/src/features/rental/invoice-receive.ts +20 -0
- package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +15 -0
- package/samples/apps/ui-walkthrough/src/features/rental/qualified-names.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +79 -0
- package/samples/apps/ui-walkthrough/src/features/rental/web/index.ts +3 -1
- package/samples/apps/ui-walkthrough/src/features/rental/web/upload-standard-layout.tsx +61 -0
- package/samples/apps/ui-walkthrough/src/features/tasks/i18n.ts +8 -0
- package/samples/apps/ui-walkthrough/src/features/tasks/web/index.ts +1 -1
- package/samples/apps/ui-walkthrough/src/features/vehicles/web/index.ts +1 -1
- package/samples/apps/ui-walkthrough/src/run-config.ts +32 -3
- package/samples/apps/use-all-bundled/bin/main.ts +3 -2
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +12 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +1 -1
- package/samples/apps/use-all-bundled/feature-manifest.json +3 -2
- package/samples/apps/use-all-bundled/package.json +1 -3
- package/samples/apps/use-all-bundled/src/app/client.tsx +1 -1
- package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +5 -4
- package/samples/apps/use-all-bundled/src/app/shell.tsx +1 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +10 -1
- package/samples/apps/workspaces/package.json +1 -3
- package/samples/apps/workspaces/src/app/client.tsx +1 -1
- package/samples/apps/workspaces/src/app/shell.tsx +1 -1
- package/samples/apps/workspaces/src/features/demo/web/index.ts +1 -1
- package/samples/apps/workspaces/src/features/demo-driver/web/index.ts +1 -1
- package/samples/recipes/apex-landing/package.json +1 -1
- package/samples/recipes/apex-landing/src/feature.ts +1 -1
- package/samples/recipes/apex-surface-auth/README.md +1 -1
- package/samples/recipes/apex-surface-auth/src/feature.ts +2 -2
- package/samples/recipes/compose-stacks/README.md +7 -4
- package/samples/recipes/delivery-notifications/README.md +1 -1
- package/samples/recipes/embedded/README.md +8 -0
- package/samples/recipes/embedded/src/feature.ts +8 -0
- package/samples/recipes/embedded/src/handlers/invoice-receive.write.ts +38 -0
- package/samples/recipes/embedded/src/screens/invoice-receive.screen.ts +59 -0
- package/samples/recipes/embedded-entity-form/package.json +0 -1
- package/samples/recipes/embedded-entity-form/src/web/accept-suggestion-drawer.tsx +1 -1
- package/samples/recipes/notes-history-basic/README.md +1 -1
- package/samples/recipes/record-detail-layout/package.json +0 -1
- package/samples/recipes/tags-basic/README.md +1 -1
- package/samples/recipes/user-profile/README.md +1 -1
- package/samples/recipes/wizard-form/package.json +0 -1
- package/samples/recipes/wizard-form/src/web/index.ts +2 -2
- package/samples/recipes/wizard-form/src/web/listing-review-section.tsx +2 -2
- package/samples/recipes/writeform-section/package.json +0 -1
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import type { FieldIssue } from "@cosmicdrift/kumiko-framework/errors";
|
|
2
|
+
import { getSchema, type JSONContent } from "@tiptap/core";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
import type { DocumentHtmlKind } from "./constants.ts";
|
|
5
|
+
import { documentBlocksForKind } from "./document-blocks.ts";
|
|
6
|
+
import {
|
|
7
|
+
DOCUMENT_TEXT_ALIGNMENTS,
|
|
8
|
+
documentExtensions,
|
|
9
|
+
isDocumentPlaceholderHref,
|
|
10
|
+
isDocumentReferenceName,
|
|
11
|
+
isDocumentResourceKey,
|
|
12
|
+
} from "./document-schema.ts";
|
|
13
|
+
import { contentToLine, lineToContent, singleLine } from "./placeholder-text.ts";
|
|
14
|
+
import { toVariableDef } from "./template-validation.ts";
|
|
15
|
+
|
|
16
|
+
export const MAX_DOCUMENT_OPS = 50;
|
|
17
|
+
|
|
18
|
+
// Assigning these keys would reach the prototype of the meta object.
|
|
19
|
+
const FORBIDDEN_META_KEYS: ReadonlySet<string> = new Set(["__proto__", "prototype", "constructor"]);
|
|
20
|
+
|
|
21
|
+
export type DocumentBlockType =
|
|
22
|
+
| "paragraph"
|
|
23
|
+
| "heading"
|
|
24
|
+
| "list"
|
|
25
|
+
| "table"
|
|
26
|
+
| "repeat"
|
|
27
|
+
| "image"
|
|
28
|
+
| "button"
|
|
29
|
+
| "divider"
|
|
30
|
+
| "pageBreak"
|
|
31
|
+
| "steps";
|
|
32
|
+
|
|
33
|
+
export type DocumentBlock = {
|
|
34
|
+
readonly id: string;
|
|
35
|
+
readonly type: DocumentBlockType;
|
|
36
|
+
/** Inline content with `{{name}}` for placeholders; containers list one line per text block. */
|
|
37
|
+
readonly text?: string;
|
|
38
|
+
readonly level?: number;
|
|
39
|
+
readonly align?: string;
|
|
40
|
+
readonly showIf?: string;
|
|
41
|
+
readonly resource?: string;
|
|
42
|
+
readonly alt?: string;
|
|
43
|
+
readonly href?: string;
|
|
44
|
+
readonly each?: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const TEXT_BLOCK_TYPES: ReadonlySet<string> = new Set(["paragraph", "heading", "button"]);
|
|
48
|
+
|
|
49
|
+
function blockTypeOf(node: JSONContent): DocumentBlockType | null {
|
|
50
|
+
switch (node.type) {
|
|
51
|
+
case "paragraph":
|
|
52
|
+
case "heading":
|
|
53
|
+
case "button":
|
|
54
|
+
case "divider":
|
|
55
|
+
case "pageBreak":
|
|
56
|
+
case "steps":
|
|
57
|
+
return node.type;
|
|
58
|
+
case "bulletList":
|
|
59
|
+
case "orderedList":
|
|
60
|
+
return "list";
|
|
61
|
+
case "table":
|
|
62
|
+
return eachOf(node) === undefined ? "table" : "repeat";
|
|
63
|
+
case "resource":
|
|
64
|
+
return "image";
|
|
65
|
+
default:
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function stringAttr(node: JSONContent, name: string): string | undefined {
|
|
71
|
+
const value: unknown = node.attrs?.[name];
|
|
72
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function eachOf(table: JSONContent): string | undefined {
|
|
76
|
+
for (const row of table.content ?? []) {
|
|
77
|
+
const each = stringAttr(row, "each");
|
|
78
|
+
if (each !== undefined) return each;
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function textLinesOf(node: JSONContent): string[] {
|
|
84
|
+
if (TEXT_BLOCK_TYPES.has(node.type ?? "")) return [contentToLine(node.content)];
|
|
85
|
+
return (node.content ?? []).flatMap(textLinesOf);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
type MutableBlock = { -readonly [K in keyof DocumentBlock]: DocumentBlock[K] };
|
|
89
|
+
|
|
90
|
+
const NON_TEXT_BLOCK_TYPES: ReadonlySet<DocumentBlockType> = new Set([
|
|
91
|
+
"image",
|
|
92
|
+
"divider",
|
|
93
|
+
"pageBreak",
|
|
94
|
+
"steps",
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
function setOptionalAttrs(block: MutableBlock, node: JSONContent): void {
|
|
98
|
+
const align = stringAttr(node, "textAlign");
|
|
99
|
+
if (align !== undefined) block.align = align;
|
|
100
|
+
const showIf = stringAttr(node, "showIf");
|
|
101
|
+
if (showIf !== undefined) block.showIf = showIf;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function setTypeSpecificAttrs(block: MutableBlock, node: JSONContent): void {
|
|
105
|
+
const level: unknown = node.attrs?.["level"];
|
|
106
|
+
if (block.type === "heading" && typeof level === "number") block.level = level;
|
|
107
|
+
const resource = stringAttr(node, "resource");
|
|
108
|
+
if (block.type === "image" && resource !== undefined) block.resource = resource;
|
|
109
|
+
const alt = stringAttr(node, "alt");
|
|
110
|
+
if (block.type === "image" && alt !== undefined) block.alt = alt;
|
|
111
|
+
const href = stringAttr(node, "href");
|
|
112
|
+
if (block.type === "button" && href !== undefined) block.href = href;
|
|
113
|
+
const each = eachOf(node);
|
|
114
|
+
if (block.type === "repeat" && each !== undefined) block.each = each;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function blockOf(node: JSONContent, id: string): DocumentBlock | null {
|
|
118
|
+
const type = blockTypeOf(node);
|
|
119
|
+
if (type === null) return null;
|
|
120
|
+
const block: MutableBlock = { id, type };
|
|
121
|
+
if (!NON_TEXT_BLOCK_TYPES.has(type)) block.text = textLinesOf(node).join("\n");
|
|
122
|
+
setOptionalAttrs(block, node);
|
|
123
|
+
setTypeSpecificAttrs(block, node);
|
|
124
|
+
return block;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function documentBlockId(index: number): string {
|
|
128
|
+
return `b${index}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function documentBlocksOf(doc: JSONContent): DocumentBlock[] {
|
|
132
|
+
return (doc.content ?? []).flatMap((node, index) => blockOf(node, documentBlockId(index)) ?? []);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const blockIdSchema = z.string().min(1).max(20);
|
|
136
|
+
const singleLineText = z.string().max(2000);
|
|
137
|
+
|
|
138
|
+
const blockInputSchema = z.discriminatedUnion("type", [
|
|
139
|
+
z.object({
|
|
140
|
+
type: z.literal("paragraph"),
|
|
141
|
+
text: singleLineText,
|
|
142
|
+
align: z.enum(DOCUMENT_TEXT_ALIGNMENTS).optional(),
|
|
143
|
+
}),
|
|
144
|
+
z.object({
|
|
145
|
+
type: z.literal("heading"),
|
|
146
|
+
level: z.number().int().min(1).max(3),
|
|
147
|
+
text: singleLineText,
|
|
148
|
+
}),
|
|
149
|
+
z.object({
|
|
150
|
+
type: z.literal("image"),
|
|
151
|
+
resource: z.string().min(1).max(200),
|
|
152
|
+
alt: z.string().max(500).optional(),
|
|
153
|
+
}),
|
|
154
|
+
z.object({ type: z.literal("button"), text: singleLineText, href: z.string().max(300) }),
|
|
155
|
+
z.object({ type: z.literal("divider") }),
|
|
156
|
+
z.object({ type: z.literal("pageBreak") }),
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
export type BlockInput = z.infer<typeof blockInputSchema>;
|
|
160
|
+
|
|
161
|
+
export const documentOpSchema = z.discriminatedUnion("op", [
|
|
162
|
+
z.object({
|
|
163
|
+
op: z.literal("insertBlock"),
|
|
164
|
+
after: blockIdSchema.nullable(),
|
|
165
|
+
block: blockInputSchema,
|
|
166
|
+
}),
|
|
167
|
+
z.object({
|
|
168
|
+
op: z.literal("replaceText"),
|
|
169
|
+
blockId: blockIdSchema,
|
|
170
|
+
find: z.string().min(1).max(2000),
|
|
171
|
+
replace: z.string().max(2000),
|
|
172
|
+
}),
|
|
173
|
+
z.object({ op: z.literal("removeBlock"), blockId: blockIdSchema }),
|
|
174
|
+
z.object({
|
|
175
|
+
op: z.literal("setResource"),
|
|
176
|
+
blockId: blockIdSchema,
|
|
177
|
+
resource: z.string().min(1).max(200),
|
|
178
|
+
alt: z.string().max(500).nullable().optional(),
|
|
179
|
+
}),
|
|
180
|
+
z.object({ op: z.literal("setMeta"), meta: z.record(z.string(), z.unknown()) }),
|
|
181
|
+
z.object({
|
|
182
|
+
op: z.literal("setVariable"),
|
|
183
|
+
name: z.string().min(1).max(100),
|
|
184
|
+
definition: z.unknown().nullable(),
|
|
185
|
+
}),
|
|
186
|
+
]);
|
|
187
|
+
|
|
188
|
+
export const documentOpsSchema = z.array(documentOpSchema).min(1).max(MAX_DOCUMENT_OPS);
|
|
189
|
+
|
|
190
|
+
export type DocumentOp = z.infer<typeof documentOpSchema>;
|
|
191
|
+
|
|
192
|
+
export type DocumentOpsInput = {
|
|
193
|
+
readonly doc: JSONContent;
|
|
194
|
+
readonly meta: Readonly<Record<string, unknown>>;
|
|
195
|
+
readonly variableSchema: Readonly<Record<string, unknown>>;
|
|
196
|
+
readonly kind: DocumentHtmlKind;
|
|
197
|
+
readonly ops: readonly DocumentOp[];
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export type DocumentOpsResult =
|
|
201
|
+
| {
|
|
202
|
+
readonly ok: true;
|
|
203
|
+
readonly doc: JSONContent;
|
|
204
|
+
readonly meta: Record<string, unknown>;
|
|
205
|
+
readonly variableSchema: Record<string, unknown>;
|
|
206
|
+
}
|
|
207
|
+
| { readonly ok: false; readonly issues: FieldIssue[] };
|
|
208
|
+
|
|
209
|
+
type Entry = { node: JSONContent; removed: boolean; readonly insertedAfter: JSONContent[] };
|
|
210
|
+
|
|
211
|
+
function patchIssue(
|
|
212
|
+
path: string,
|
|
213
|
+
code: string,
|
|
214
|
+
params?: Readonly<Record<string, unknown>>,
|
|
215
|
+
): FieldIssue {
|
|
216
|
+
const camel = code.replace(/_([a-z])/g, (_match, letter: string) => letter.toUpperCase());
|
|
217
|
+
return {
|
|
218
|
+
path,
|
|
219
|
+
code,
|
|
220
|
+
i18nKey: `templateResolver.errors.patch.${camel}`,
|
|
221
|
+
...(params === undefined ? {} : { params }),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function textNodesIn(nodes: readonly JSONContent[]): JSONContent[] {
|
|
226
|
+
return nodes.flatMap((node) => (node.type === "text" ? [node] : textNodesIn(node.content ?? [])));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function occurrencesOf(text: string, find: string): number {
|
|
230
|
+
return text.split(find).length - 1;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function replacementNodes(textNode: JSONContent, find: string, replace: string): JSONContent[] {
|
|
234
|
+
const text = textNode.text ?? "";
|
|
235
|
+
const at = text.indexOf(find);
|
|
236
|
+
const withMarks = (node: JSONContent): JSONContent =>
|
|
237
|
+
node.type === "text" && textNode.marks !== undefined
|
|
238
|
+
? { ...node, marks: textNode.marks }
|
|
239
|
+
: node;
|
|
240
|
+
const before = text.slice(0, at);
|
|
241
|
+
const after = text.slice(at + find.length);
|
|
242
|
+
return [
|
|
243
|
+
...(before === "" ? [] : [{ ...textNode, text: before }]),
|
|
244
|
+
...lineToContent(singleLine(replace)).map(withMarks),
|
|
245
|
+
...(after === "" ? [] : [{ ...textNode, text: after }]),
|
|
246
|
+
];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function replaceInContent(
|
|
250
|
+
content: readonly JSONContent[],
|
|
251
|
+
find: string,
|
|
252
|
+
replace: string,
|
|
253
|
+
): JSONContent[] {
|
|
254
|
+
return content.flatMap((node) => {
|
|
255
|
+
if (node.type === "text") {
|
|
256
|
+
return (node.text ?? "").includes(find) ? replacementNodes(node, find, replace) : [node];
|
|
257
|
+
}
|
|
258
|
+
return [
|
|
259
|
+
node.content === undefined
|
|
260
|
+
? node
|
|
261
|
+
: { ...node, content: replaceInContent(node.content, find, replace) },
|
|
262
|
+
];
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function nodeOfInput(input: BlockInput): JSONContent {
|
|
267
|
+
switch (input.type) {
|
|
268
|
+
case "paragraph":
|
|
269
|
+
return {
|
|
270
|
+
type: "paragraph",
|
|
271
|
+
...(input.align === undefined ? {} : { attrs: { textAlign: input.align } }),
|
|
272
|
+
content: lineToContent(singleLine(input.text)),
|
|
273
|
+
};
|
|
274
|
+
case "heading":
|
|
275
|
+
return {
|
|
276
|
+
type: "heading",
|
|
277
|
+
attrs: { level: input.level },
|
|
278
|
+
content: lineToContent(singleLine(input.text)),
|
|
279
|
+
};
|
|
280
|
+
case "image":
|
|
281
|
+
return { type: "resource", attrs: { resource: input.resource, alt: input.alt ?? null } };
|
|
282
|
+
case "button":
|
|
283
|
+
return {
|
|
284
|
+
type: "button",
|
|
285
|
+
attrs: { href: input.href },
|
|
286
|
+
content: lineToContent(singleLine(input.text)),
|
|
287
|
+
};
|
|
288
|
+
case "divider":
|
|
289
|
+
case "pageBreak":
|
|
290
|
+
return { type: input.type };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const TOOLBAR_BLOCK_OF_INPUT = {
|
|
295
|
+
image: "image",
|
|
296
|
+
button: "button",
|
|
297
|
+
divider: "divider",
|
|
298
|
+
pageBreak: "pageBreak",
|
|
299
|
+
} as const;
|
|
300
|
+
|
|
301
|
+
function isBlockAllowed(input: BlockInput, kind: DocumentHtmlKind): boolean {
|
|
302
|
+
if (input.type === "paragraph" || input.type === "heading") return true;
|
|
303
|
+
return documentBlocksForKind(kind).includes(TOOLBAR_BLOCK_OF_INPUT[input.type]);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function inputIssue(input: BlockInput, path: string): FieldIssue | null {
|
|
307
|
+
if (input.type === "image" && !isDocumentResourceKey(input.resource)) {
|
|
308
|
+
return patchIssue(`${path}.block.resource`, "invalid_resource_key", {
|
|
309
|
+
resource: input.resource,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
if (input.type === "button" && !isDocumentPlaceholderHref(input.href)) {
|
|
313
|
+
return patchIssue(`${path}.block.href`, "invalid_button_href", { href: input.href });
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let cachedSchema: ReturnType<typeof getSchema> | null = null;
|
|
319
|
+
|
|
320
|
+
function documentSchema(): ReturnType<typeof getSchema> {
|
|
321
|
+
cachedSchema ??= getSchema(documentExtensions);
|
|
322
|
+
return cachedSchema;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function isValidDocument(doc: JSONContent): boolean {
|
|
326
|
+
try {
|
|
327
|
+
documentSchema().nodeFromJSON(doc).check();
|
|
328
|
+
return true;
|
|
329
|
+
} catch {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
type OpState = {
|
|
335
|
+
readonly kind: DocumentHtmlKind;
|
|
336
|
+
readonly entries: Map<string, Entry>;
|
|
337
|
+
readonly insertedAtStart: JSONContent[];
|
|
338
|
+
readonly meta: Record<string, unknown>;
|
|
339
|
+
readonly variableSchema: Record<string, unknown>;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
type EntryLookup = { readonly entry: Entry } | { readonly issue: FieldIssue };
|
|
343
|
+
|
|
344
|
+
function lookupEntry(state: OpState, blockId: string, fieldPath: string): EntryLookup {
|
|
345
|
+
const entry = state.entries.get(blockId);
|
|
346
|
+
if (entry === undefined) return { issue: patchIssue(fieldPath, "unknown_block", { blockId }) };
|
|
347
|
+
if (entry.removed) return { issue: patchIssue(fieldPath, "block_removed", { blockId }) };
|
|
348
|
+
return { entry };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
type InsertBlockOp = Extract<DocumentOp, { op: "insertBlock" }>;
|
|
352
|
+
|
|
353
|
+
function blockInputIssue(
|
|
354
|
+
op: InsertBlockOp,
|
|
355
|
+
path: string,
|
|
356
|
+
kind: DocumentHtmlKind,
|
|
357
|
+
): FieldIssue | null {
|
|
358
|
+
if (!isBlockAllowed(op.block, kind)) {
|
|
359
|
+
return patchIssue(`${path}.block.type`, "block_not_allowed", { type: op.block.type, kind });
|
|
360
|
+
}
|
|
361
|
+
if (
|
|
362
|
+
op.block.type === "paragraph" &&
|
|
363
|
+
op.block.align !== undefined &&
|
|
364
|
+
!documentBlocksForKind(kind).includes("align")
|
|
365
|
+
) {
|
|
366
|
+
return patchIssue(`${path}.block.align`, "block_not_allowed", { type: "align", kind });
|
|
367
|
+
}
|
|
368
|
+
return inputIssue(op.block, path);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function insertBlockOp(state: OpState, op: InsertBlockOp, path: string): FieldIssue | null {
|
|
372
|
+
const anchor = op.after === null ? null : lookupEntry(state, op.after, `${path}.after`);
|
|
373
|
+
if (anchor !== null && "issue" in anchor) return anchor.issue;
|
|
374
|
+
const invalid = blockInputIssue(op, path, state.kind);
|
|
375
|
+
if (invalid !== null) return invalid;
|
|
376
|
+
const target = anchor === null ? state.insertedAtStart : anchor.entry.insertedAfter;
|
|
377
|
+
target.push(nodeOfInput(op.block));
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function removeBlockOp(
|
|
382
|
+
state: OpState,
|
|
383
|
+
op: Extract<DocumentOp, { op: "removeBlock" }>,
|
|
384
|
+
path: string,
|
|
385
|
+
): FieldIssue | null {
|
|
386
|
+
const found = lookupEntry(state, op.blockId, `${path}.blockId`);
|
|
387
|
+
if ("issue" in found) return found.issue;
|
|
388
|
+
found.entry.removed = true;
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function replaceTextOp(
|
|
393
|
+
state: OpState,
|
|
394
|
+
op: Extract<DocumentOp, { op: "replaceText" }>,
|
|
395
|
+
path: string,
|
|
396
|
+
): FieldIssue | null {
|
|
397
|
+
const found = lookupEntry(state, op.blockId, `${path}.blockId`);
|
|
398
|
+
if ("issue" in found) return found.issue;
|
|
399
|
+
const { entry } = found;
|
|
400
|
+
const count = textNodesIn([entry.node]).reduce(
|
|
401
|
+
(sum, node) => sum + occurrencesOf(node.text ?? "", op.find),
|
|
402
|
+
0,
|
|
403
|
+
);
|
|
404
|
+
if (count === 0) return patchIssue(`${path}.find`, "text_not_found", { find: op.find });
|
|
405
|
+
if (count > 1) return patchIssue(`${path}.find`, "text_ambiguous", { find: op.find, count });
|
|
406
|
+
entry.node = replaceInContent([entry.node], op.find, op.replace)[0] ?? entry.node;
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function setResourceOp(
|
|
411
|
+
state: OpState,
|
|
412
|
+
op: Extract<DocumentOp, { op: "setResource" }>,
|
|
413
|
+
path: string,
|
|
414
|
+
): FieldIssue | null {
|
|
415
|
+
const found = lookupEntry(state, op.blockId, `${path}.blockId`);
|
|
416
|
+
if ("issue" in found) return found.issue;
|
|
417
|
+
const { entry } = found;
|
|
418
|
+
if (entry.node.type !== "resource") {
|
|
419
|
+
return patchIssue(`${path}.blockId`, "block_type_mismatch", {
|
|
420
|
+
expected: "image",
|
|
421
|
+
actual: entry.node.type ?? "unknown",
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
if (!isDocumentResourceKey(op.resource)) {
|
|
425
|
+
return patchIssue(`${path}.resource`, "invalid_resource_key", { resource: op.resource });
|
|
426
|
+
}
|
|
427
|
+
const alt = op.alt === undefined ? (entry.node.attrs?.["alt"] ?? null) : op.alt;
|
|
428
|
+
entry.node = { ...entry.node, attrs: { resource: op.resource, alt } };
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function setMetaOp(
|
|
433
|
+
state: OpState,
|
|
434
|
+
op: Extract<DocumentOp, { op: "setMeta" }>,
|
|
435
|
+
path: string,
|
|
436
|
+
): FieldIssue | null {
|
|
437
|
+
const forbidden = Object.keys(op.meta).find((key) => FORBIDDEN_META_KEYS.has(key));
|
|
438
|
+
if (forbidden !== undefined) {
|
|
439
|
+
return patchIssue(`${path}.meta`, "invalid_meta_key", { key: forbidden });
|
|
440
|
+
}
|
|
441
|
+
for (const [key, value] of Object.entries(op.meta)) {
|
|
442
|
+
if (value === null) delete state.meta[key];
|
|
443
|
+
else state.meta[key] = value;
|
|
444
|
+
}
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function setVariableOp(
|
|
449
|
+
state: OpState,
|
|
450
|
+
op: Extract<DocumentOp, { op: "setVariable" }>,
|
|
451
|
+
path: string,
|
|
452
|
+
): FieldIssue | null {
|
|
453
|
+
if (!isDocumentReferenceName(op.name)) {
|
|
454
|
+
return patchIssue(`${path}.name`, "invalid_variable_name", { name: op.name });
|
|
455
|
+
}
|
|
456
|
+
if (op.definition === null) {
|
|
457
|
+
delete state.variableSchema[op.name];
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
const definition = toVariableDef(op.definition);
|
|
461
|
+
if (definition === null) {
|
|
462
|
+
return patchIssue(`${path}.definition`, "invalid_variable_definition", { name: op.name });
|
|
463
|
+
}
|
|
464
|
+
state.variableSchema[op.name] = definition;
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function applyOp(state: OpState, op: DocumentOp, path: string): FieldIssue | null {
|
|
469
|
+
switch (op.op) {
|
|
470
|
+
case "insertBlock":
|
|
471
|
+
return insertBlockOp(state, op, path);
|
|
472
|
+
case "removeBlock":
|
|
473
|
+
return removeBlockOp(state, op, path);
|
|
474
|
+
case "replaceText":
|
|
475
|
+
return replaceTextOp(state, op, path);
|
|
476
|
+
case "setResource":
|
|
477
|
+
return setResourceOp(state, op, path);
|
|
478
|
+
case "setMeta":
|
|
479
|
+
return setMetaOp(state, op, path);
|
|
480
|
+
case "setVariable":
|
|
481
|
+
return setVariableOp(state, op, path);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Ops address blocks by the ids of the document as it was read, so inserted blocks are
|
|
486
|
+
// collected per anchor and the list is assembled once at the end.
|
|
487
|
+
export function applyDocumentOps(input: DocumentOpsInput): DocumentOpsResult {
|
|
488
|
+
const entries = new Map<string, Entry>();
|
|
489
|
+
(input.doc.content ?? []).forEach((node, index) => {
|
|
490
|
+
entries.set(documentBlockId(index), {
|
|
491
|
+
node: structuredClone(node),
|
|
492
|
+
removed: false,
|
|
493
|
+
insertedAfter: [],
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
const state: OpState = {
|
|
497
|
+
kind: input.kind,
|
|
498
|
+
entries,
|
|
499
|
+
insertedAtStart: [],
|
|
500
|
+
meta: { ...input.meta },
|
|
501
|
+
variableSchema: { ...input.variableSchema },
|
|
502
|
+
};
|
|
503
|
+
const issues = input.ops.flatMap((op, index) => applyOp(state, op, `ops.${index}`) ?? []);
|
|
504
|
+
if (issues.length > 0) return { ok: false, issues };
|
|
505
|
+
|
|
506
|
+
const content = [
|
|
507
|
+
...state.insertedAtStart,
|
|
508
|
+
...[...entries.values()].flatMap((entry) =>
|
|
509
|
+
entry.removed ? entry.insertedAfter : [entry.node, ...entry.insertedAfter],
|
|
510
|
+
),
|
|
511
|
+
];
|
|
512
|
+
const doc: JSONContent = { ...input.doc, content };
|
|
513
|
+
if (!isValidDocument(doc)) return { ok: false, issues: [patchIssue("ops", "invalid_document")] };
|
|
514
|
+
return { ok: true, doc, meta: state.meta, variableSchema: state.variableSchema };
|
|
515
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// of client barrels and the document-schema subpath export.
|
|
3
3
|
import type { FieldIssue } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
5
|
+
import type { JSONContent } from "@tiptap/core";
|
|
5
6
|
import { generateHTML, generateJSON } from "@tiptap/html/server";
|
|
6
7
|
import { type Element as ScanElement, Window } from "happy-dom";
|
|
7
8
|
import type { ContentFormat } from "./constants.ts";
|
|
@@ -25,6 +26,7 @@ export type DocumentViolationCode =
|
|
|
25
26
|
| "invalid_reference_name"
|
|
26
27
|
| "embedded_content"
|
|
27
28
|
| "unsafe_attribute_value"
|
|
29
|
+
| "images_block_pdf_only"
|
|
28
30
|
| "button_href_not_placeholder"
|
|
29
31
|
| "document_requires_html_format"
|
|
30
32
|
| "layout_requires_mjml_format"
|
|
@@ -142,6 +144,7 @@ const REFERENCE_ATTRIBUTES: ReadonlyArray<readonly [string, (value: string) => b
|
|
|
142
144
|
[DOCUMENT_CONDITION_ATTRIBUTE, isDocumentReferenceName],
|
|
143
145
|
["data-resource", isDocumentResourceKey],
|
|
144
146
|
["data-steps", isDocumentReferenceName],
|
|
147
|
+
["data-images", isDocumentReferenceName],
|
|
145
148
|
["data-step", isDocumentStepKey],
|
|
146
149
|
];
|
|
147
150
|
|
|
@@ -272,6 +275,14 @@ export function sanitizeDocumentHtml(html: string): SanitizeHtmlResult {
|
|
|
272
275
|
};
|
|
273
276
|
}
|
|
274
277
|
|
|
278
|
+
export function parseDocumentJson(html: string): JSONContent {
|
|
279
|
+
return generateJSON(html, documentExtensions);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function serializeDocumentJson(doc: JSONContent): string {
|
|
283
|
+
return generateHTML(doc, documentExtensions);
|
|
284
|
+
}
|
|
285
|
+
|
|
275
286
|
// For a sanitized value placed inside a line of text: paragraphs become line
|
|
276
287
|
// breaks. Other blocks (lists, headings) cannot be made inline and stay as they are.
|
|
277
288
|
export function flattenParagraphsToLineBreaks(sanitizedHtml: string): string {
|
|
@@ -304,6 +315,36 @@ function requiredFormatViolation(
|
|
|
304
315
|
return null;
|
|
305
316
|
}
|
|
306
317
|
|
|
318
|
+
const IMAGES_BLOCK_KIND = "document-pdf";
|
|
319
|
+
|
|
320
|
+
// The node is part of the shared document model, so a mail body would keep it after the
|
|
321
|
+
// rebuild; only a PDF resolves the record's images.
|
|
322
|
+
function imagesBlockViolation(input: {
|
|
323
|
+
readonly kind: string;
|
|
324
|
+
readonly contentFormat: ContentFormat;
|
|
325
|
+
readonly content: string | null;
|
|
326
|
+
}): DocumentViolation | null {
|
|
327
|
+
if (
|
|
328
|
+
input.kind === IMAGES_BLOCK_KIND ||
|
|
329
|
+
!isSanitizedHtmlKind(input.kind) ||
|
|
330
|
+
input.contentFormat !== "html" ||
|
|
331
|
+
input.content === null
|
|
332
|
+
) {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
const hasImagesBlock = withParsedDocument(
|
|
336
|
+
input.content,
|
|
337
|
+
(body) => body.querySelector("div[data-images]") !== null,
|
|
338
|
+
);
|
|
339
|
+
return hasImagesBlock
|
|
340
|
+
? violation(
|
|
341
|
+
"images_block_pdf_only",
|
|
342
|
+
input.kind,
|
|
343
|
+
`Record images blocks are only allowed in templates of kind ${IMAGES_BLOCK_KIND}`,
|
|
344
|
+
)
|
|
345
|
+
: null;
|
|
346
|
+
}
|
|
347
|
+
|
|
307
348
|
export function sanitizeTemplateBody(input: {
|
|
308
349
|
readonly kind: string;
|
|
309
350
|
readonly contentFormat: ContentFormat;
|
|
@@ -311,6 +352,8 @@ export function sanitizeTemplateBody(input: {
|
|
|
311
352
|
}): SanitizeBodyResult {
|
|
312
353
|
const formatViolation = requiredFormatViolation(input.kind, input.contentFormat);
|
|
313
354
|
if (formatViolation !== null) return { ok: false, violations: [formatViolation] };
|
|
355
|
+
const imagesViolation = imagesBlockViolation(input);
|
|
356
|
+
if (imagesViolation !== null) return { ok: false, violations: [imagesViolation] };
|
|
314
357
|
if (
|
|
315
358
|
!isSanitizedHtmlKind(input.kind) ||
|
|
316
359
|
input.contentFormat !== "html" ||
|
|
@@ -331,6 +374,7 @@ const VIOLATION_I18N_KEYS: Readonly<Record<DocumentViolationCode, string>> = {
|
|
|
331
374
|
invalid_reference_name: "templateResolver.errors.document.invalidReferenceName",
|
|
332
375
|
embedded_content: "templateResolver.errors.document.embeddedContent",
|
|
333
376
|
unsafe_attribute_value: "templateResolver.errors.document.unsafeAttributeValue",
|
|
377
|
+
images_block_pdf_only: "templateResolver.errors.document.imagesBlockPdfOnly",
|
|
334
378
|
button_href_not_placeholder: "templateResolver.errors.document.buttonHrefNotPlaceholder",
|
|
335
379
|
document_requires_html_format: "templateResolver.errors.document.documentRequiresHtmlFormat",
|
|
336
380
|
layout_requires_mjml_format: "templateResolver.errors.document.layoutRequiresMjmlFormat",
|
|
@@ -8,6 +8,7 @@ import { Extension, type Extensions, Node } from "@tiptap/core";
|
|
|
8
8
|
import { Link } from "@tiptap/extension-link";
|
|
9
9
|
import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table";
|
|
10
10
|
import StarterKit from "@tiptap/starter-kit";
|
|
11
|
+
import { createDocumentImagesNode } from "./document-images.ts";
|
|
11
12
|
import { createDocumentStepsNode } from "./document-steps.ts";
|
|
12
13
|
|
|
13
14
|
const PLACEHOLDER_HREF = /^\{\{(.+)\}\}$/;
|
|
@@ -73,6 +74,7 @@ export const DOCUMENT_NODE_NAMES = [
|
|
|
73
74
|
"divider",
|
|
74
75
|
"pageBreak",
|
|
75
76
|
"steps",
|
|
77
|
+
"images",
|
|
76
78
|
] as const;
|
|
77
79
|
|
|
78
80
|
export const DocumentVariable = Node.create({
|
|
@@ -184,6 +186,8 @@ export const DocumentPageBreak = Node.create({
|
|
|
184
186
|
|
|
185
187
|
export const DocumentSteps = createDocumentStepsNode(isDocumentReferenceName);
|
|
186
188
|
|
|
189
|
+
export const DocumentImages = createDocumentImagesNode(isDocumentReferenceName);
|
|
190
|
+
|
|
187
191
|
// A repeat row is a table row carrying `each`: prosemirror-tables requires
|
|
188
192
|
// the tableRow node type, so a separate node type is not an option.
|
|
189
193
|
export const DocumentTableRow = TableRow.extend({
|
|
@@ -378,6 +382,7 @@ export const documentExtensions: Extensions = [
|
|
|
378
382
|
DocumentDivider,
|
|
379
383
|
DocumentPageBreak,
|
|
380
384
|
DocumentSteps,
|
|
385
|
+
DocumentImages,
|
|
381
386
|
DocumentTextAlign,
|
|
382
387
|
DocumentTextSize,
|
|
383
388
|
DocumentCondition,
|