@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.
Files changed (247) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +6 -4
  3. package/packages/bundled-features/src/account-security/changes.json +6 -0
  4. package/packages/bundled-features/src/account-security/constants.ts +1 -0
  5. package/packages/bundled-features/src/account-security/index.ts +1 -0
  6. package/packages/bundled-features/src/admin-shell/web/client-plugin.tsx +1 -1
  7. package/packages/bundled-features/src/audit/web/client-plugin.tsx +1 -1
  8. package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +1 -1
  9. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +1 -1
  10. package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +1 -1
  11. package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +3 -3
  12. package/packages/bundled-features/src/auth-email-password/web/default-topbar-actions.tsx +1 -1
  13. package/packages/bundled-features/src/auth-email-password/web/index.ts +1 -1
  14. package/packages/bundled-features/src/auth-email-password/web/session.tsx +1 -1
  15. package/packages/bundled-features/src/auth-email-password/web/tenant-menu-items.tsx +1 -1
  16. package/packages/bundled-features/src/auth-email-password/web/tenant-switcher.tsx +1 -1
  17. package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
  18. package/packages/bundled-features/src/auth-foundation/changes.json +6 -0
  19. package/packages/bundled-features/src/auth-foundation/env.ts +27 -0
  20. package/packages/bundled-features/src/auth-foundation/index.ts +1 -0
  21. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +1 -1
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +1 -1
  24. package/packages/bundled-features/src/billing-foundation/contact-card.ts +14 -0
  25. package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +1 -1
  26. package/packages/bundled-features/src/billing-foundation/index.ts +1 -0
  27. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
  28. package/packages/bundled-features/src/billing-foundation/types.ts +12 -0
  29. package/packages/bundled-features/src/billing-foundation/validate-options.ts +16 -0
  30. package/packages/bundled-features/src/billing-foundation/web/billing-contact-plan.tsx +32 -0
  31. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +5 -15
  32. package/packages/bundled-features/src/billing-foundation/web/cancel-contract-dialog.tsx +2 -2
  33. package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +1 -1
  34. package/packages/bundled-features/src/billing-foundation/web/client-plugin.tsx +1 -1
  35. package/packages/bundled-features/src/billing-foundation/web/localized-params.ts +14 -0
  36. package/packages/bundled-features/src/cap-counter/changes.json +12 -0
  37. package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +9 -0
  38. package/packages/bundled-features/src/cap-overview/web/cap-cards-panel.tsx +3 -3
  39. package/packages/bundled-features/src/cap-overview/web/client-plugin.tsx +1 -1
  40. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  41. package/packages/bundled-features/src/channel-email/email-channel.ts +2 -1
  42. package/packages/bundled-features/src/compliance-profiles/web/client-plugin.tsx +1 -1
  43. package/packages/bundled-features/src/config/feature.ts +2 -2
  44. package/packages/bundled-features/src/config/web/i18n.ts +1 -0
  45. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  46. package/packages/bundled-features/src/custom-fields/web/client-plugin.tsx +1 -1
  47. package/packages/bundled-features/src/delivery/unsubscribe-page.ts +1 -1
  48. package/packages/bundled-features/src/delivery/web/client-plugin.tsx +1 -1
  49. package/packages/bundled-features/src/delivery/web/delivery-status-cell.tsx +1 -1
  50. package/packages/bundled-features/src/feature-toggles/web/client-plugin.tsx +1 -1
  51. package/packages/bundled-features/src/file-provider-s3/changes.json +6 -0
  52. package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +45 -0
  53. package/packages/bundled-features/src/file-provider-s3/env-schema.ts +20 -13
  54. package/packages/bundled-features/src/file-provider-s3/index.ts +4 -0
  55. package/packages/bundled-features/src/folders/web/client-plugin.tsx +1 -1
  56. package/packages/bundled-features/src/folders/web/folder-section.tsx +3 -4
  57. package/packages/bundled-features/src/host-routing/README.md +7 -0
  58. package/packages/bundled-features/src/host-routing/changes.json +6 -0
  59. package/packages/bundled-features/src/host-routing/constants.ts +4 -0
  60. package/packages/bundled-features/src/host-routing/dispatch.ts +34 -26
  61. package/packages/bundled-features/src/host-routing/feature.ts +122 -41
  62. package/packages/bundled-features/src/host-routing/host.ts +28 -1
  63. package/packages/bundled-features/src/host-routing/index.ts +3 -0
  64. package/packages/bundled-features/src/host-routing/options.ts +45 -2
  65. package/packages/bundled-features/src/host-routing/redirects.ts +33 -0
  66. package/packages/bundled-features/src/host-routing/tenant-lookup.ts +65 -0
  67. package/packages/bundled-features/src/host-routing/validate-options.ts +48 -1
  68. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +13 -0
  69. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +8 -0
  70. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +11 -4
  71. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +42 -0
  72. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +4 -0
  73. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +100 -0
  74. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +2 -2
  75. package/packages/bundled-features/src/jobs/web/client-plugin.tsx +1 -1
  76. package/packages/bundled-features/src/legal-pages/changes.json +6 -0
  77. package/packages/bundled-features/src/legal-pages/feature.ts +14 -1
  78. package/packages/bundled-features/src/legal-pages/layout.ts +6 -6
  79. package/packages/bundled-features/src/legal-pages/web/client-plugin.ts +1 -1
  80. package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
  81. package/packages/bundled-features/src/mail-foundation/feature.ts +21 -22
  82. package/packages/bundled-features/src/mail-foundation/i18n.ts +3 -2
  83. package/packages/bundled-features/src/mail-transport-smtp/i18n.ts +1 -1
  84. package/packages/bundled-features/src/managed-pages/web/client-plugin.tsx +1 -1
  85. package/packages/bundled-features/src/notes-history/web/client-plugin.tsx +1 -1
  86. package/packages/bundled-features/src/page-render/branding.ts +1 -1
  87. package/packages/bundled-features/src/page-render/layout.ts +2 -2
  88. package/packages/bundled-features/src/page-render/markdown.ts +1 -1
  89. package/packages/bundled-features/src/personal-access-tokens/i18n.ts +1 -1
  90. package/packages/bundled-features/src/personal-access-tokens/screens.ts +1 -1
  91. package/packages/bundled-features/src/secrets/feature.ts +4 -4
  92. package/packages/bundled-features/src/seo/sitemap.ts +1 -1
  93. package/packages/bundled-features/src/shared/mail-template-body.ts +1 -1
  94. package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +127 -2
  95. package/packages/bundled-features/src/tags/web/client-plugin.tsx +1 -1
  96. package/packages/bundled-features/src/template-resolver/changes.json +38 -0
  97. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +61 -3
  98. package/packages/bundled-features/src/template-resolver/constants.ts +3 -0
  99. package/packages/bundled-features/src/template-resolver/document-blocks.ts +36 -0
  100. package/packages/bundled-features/src/template-resolver/document-images.ts +29 -0
  101. package/packages/bundled-features/src/template-resolver/document-ops.ts +515 -0
  102. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +44 -0
  103. package/packages/bundled-features/src/template-resolver/document-schema.ts +5 -0
  104. package/packages/bundled-features/src/template-resolver/feature.ts +79 -34
  105. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +1 -0
  106. package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +1 -1
  107. package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +1 -1
  108. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +2 -6
  109. package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +0 -6
  110. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +67 -58
  111. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +36 -13
  112. package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +86 -0
  113. package/packages/bundled-features/src/template-resolver/handlers/document-blocks.query.ts +48 -0
  114. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +11 -3
  115. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +37 -28
  116. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +66 -62
  117. package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +134 -0
  118. package/packages/bundled-features/src/template-resolver/handlers/preview.query.ts +2 -1
  119. package/packages/bundled-features/src/template-resolver/handlers/reset-to-system.write.ts +62 -49
  120. package/packages/bundled-features/src/template-resolver/handlers/resource-link-pdf.write.ts +44 -29
  121. package/packages/bundled-features/src/template-resolver/handlers/resource-remove.write.ts +52 -36
  122. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +126 -106
  123. package/packages/bundled-features/src/template-resolver/handlers/send-test-shared.ts +38 -0
  124. package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +31 -0
  125. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +105 -110
  126. package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +110 -103
  127. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +22 -1
  128. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +56 -28
  129. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +38 -29
  130. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +24 -9
  131. package/packages/bundled-features/src/template-resolver/index.ts +8 -1
  132. package/packages/bundled-features/src/template-resolver/{web/placeholder-text.ts → placeholder-text.ts} +1 -1
  133. package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
  134. package/packages/bundled-features/src/template-resolver/seeding.ts +3 -1
  135. package/packages/bundled-features/src/template-resolver/template-bundle.ts +6 -1
  136. package/packages/bundled-features/src/template-resolver/template-edit-policy.ts +91 -0
  137. package/packages/bundled-features/src/template-resolver/template-preview.ts +32 -10
  138. package/packages/bundled-features/src/template-resolver/template-validation.ts +32 -11
  139. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +3 -3
  140. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +29 -0
  141. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +43 -5
  142. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +9 -33
  143. package/packages/bundled-features/src/template-resolver/web/i18n.ts +30 -0
  144. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +2 -2
  145. package/packages/bundled-features/src/tenant/web/client-plugin.tsx +1 -1
  146. package/packages/bundled-features/src/tenant/web/member-status-cell.tsx +1 -1
  147. package/packages/bundled-features/src/tier-engine/web/client-plugin.tsx +1 -1
  148. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  149. package/packages/bundled-features/src/user-data-rights/env.ts +15 -0
  150. package/packages/bundled-features/src/user-data-rights/feature.ts +4 -2
  151. package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
  152. package/packages/bundled-features/src/user-data-rights/lib/mail-transport-resolver.ts +1 -1
  153. package/packages/bundled-features/src/user-data-rights/lib/storage-provider-resolver.ts +1 -1
  154. package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +1 -1
  155. package/packages/bundled-features/src/user-data-rights/web/client-plugin.tsx +1 -1
  156. package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +2 -2
  157. package/packages/bundled-features/src/waitlist/changes.json +6 -0
  158. package/packages/bundled-features/src/waitlist/legacy-migrate.ts +51 -2
  159. package/samples/apps/admin-console/package.json +0 -3
  160. package/samples/apps/admin-console/src/app/client.tsx +1 -1
  161. package/samples/apps/admin-console/src/app/shell.tsx +1 -1
  162. package/samples/apps/config-demo/package.json +1 -3
  163. package/samples/apps/config-demo/src/app/client.tsx +1 -1
  164. package/samples/apps/config-demo/src/app/shell.tsx +2 -2
  165. package/samples/apps/config-demo/src/features/demo/web/index.ts +1 -1
  166. package/samples/apps/config-demo/tsconfig.json +0 -12
  167. package/samples/apps/marketing-demo/package.json +1 -3
  168. package/samples/apps/marketing-demo/src/app/client.tsx +1 -1
  169. package/samples/apps/marketing-demo/src/app/shell.tsx +1 -1
  170. package/samples/apps/marketing-demo/src/features/assets/web/index.ts +1 -1
  171. package/samples/apps/marketing-demo/src/features/helpdesk/web/index.ts +1 -1
  172. package/samples/apps/showcase/package.json +1 -3
  173. package/samples/apps/showcase/src/app/client.tsx +1 -1
  174. package/samples/apps/showcase/src/app/shell.tsx +1 -1
  175. package/samples/apps/showcase/src/features/demos/web/index.ts +1 -1
  176. package/samples/apps/showcase/src/features/demos/web/pages/sidebar-panel.tsx +1 -1
  177. package/samples/apps/showcase/src/features/demos/web/pages/toast.tsx +1 -1
  178. package/samples/apps/showcase/src/features/items/web/index.ts +1 -1
  179. package/samples/apps/styleguide/package.json +1 -3
  180. package/samples/apps/styleguide/src/app/client.tsx +1 -1
  181. package/samples/apps/styleguide/src/app/shell.tsx +2 -2
  182. package/samples/apps/styleguide/src/features/ai-settings/web.ts +1 -1
  183. package/samples/apps/styleguide/src/features/content/web.tsx +2 -2
  184. package/samples/apps/styleguide/src/features/demo/web.ts +1 -1
  185. package/samples/apps/styleguide/src/features/examples/web.ts +1 -1
  186. package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +1 -1
  187. package/samples/apps/styleguide/src/features/gallery/web/index.ts +1 -1
  188. package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +1 -1
  189. package/samples/apps/styleguide/src/features/widgets/lib/format.ts +1 -1
  190. package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +1 -1
  191. package/samples/apps/styleguide/src/features/widgets/web/FormExamples.tsx +1 -1
  192. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +1 -1
  193. package/samples/apps/styleguide/src/features/widgets/web/index.ts +1 -1
  194. package/samples/apps/styleguide/tsconfig.json +0 -12
  195. package/samples/apps/ui-walkthrough/package.json +1 -3
  196. package/samples/apps/ui-walkthrough/src/app/client.tsx +1 -1
  197. package/samples/apps/ui-walkthrough/src/app/help-action.tsx +37 -0
  198. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +2 -0
  199. package/samples/apps/ui-walkthrough/src/app/shell.tsx +7 -1
  200. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +20 -0
  201. package/samples/apps/ui-walkthrough/src/features/billing/web/index.ts +1 -1
  202. package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +65 -1
  203. package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +70 -0
  204. package/samples/apps/ui-walkthrough/src/features/rental/invoice-receive.ts +20 -0
  205. package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +15 -0
  206. package/samples/apps/ui-walkthrough/src/features/rental/qualified-names.ts +2 -0
  207. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +79 -0
  208. package/samples/apps/ui-walkthrough/src/features/rental/web/index.ts +3 -1
  209. package/samples/apps/ui-walkthrough/src/features/rental/web/upload-standard-layout.tsx +61 -0
  210. package/samples/apps/ui-walkthrough/src/features/tasks/i18n.ts +8 -0
  211. package/samples/apps/ui-walkthrough/src/features/tasks/web/index.ts +1 -1
  212. package/samples/apps/ui-walkthrough/src/features/vehicles/web/index.ts +1 -1
  213. package/samples/apps/ui-walkthrough/src/run-config.ts +32 -3
  214. package/samples/apps/use-all-bundled/bin/main.ts +3 -2
  215. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +12 -0
  216. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +1 -1
  217. package/samples/apps/use-all-bundled/feature-manifest.json +3 -2
  218. package/samples/apps/use-all-bundled/package.json +1 -3
  219. package/samples/apps/use-all-bundled/src/app/client.tsx +1 -1
  220. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +5 -4
  221. package/samples/apps/use-all-bundled/src/app/shell.tsx +1 -1
  222. package/samples/apps/use-all-bundled/src/run-config.ts +10 -1
  223. package/samples/apps/workspaces/package.json +1 -3
  224. package/samples/apps/workspaces/src/app/client.tsx +1 -1
  225. package/samples/apps/workspaces/src/app/shell.tsx +1 -1
  226. package/samples/apps/workspaces/src/features/demo/web/index.ts +1 -1
  227. package/samples/apps/workspaces/src/features/demo-driver/web/index.ts +1 -1
  228. package/samples/recipes/apex-landing/package.json +1 -1
  229. package/samples/recipes/apex-landing/src/feature.ts +1 -1
  230. package/samples/recipes/apex-surface-auth/README.md +1 -1
  231. package/samples/recipes/apex-surface-auth/src/feature.ts +2 -2
  232. package/samples/recipes/compose-stacks/README.md +7 -4
  233. package/samples/recipes/delivery-notifications/README.md +1 -1
  234. package/samples/recipes/embedded/README.md +8 -0
  235. package/samples/recipes/embedded/src/feature.ts +8 -0
  236. package/samples/recipes/embedded/src/handlers/invoice-receive.write.ts +38 -0
  237. package/samples/recipes/embedded/src/screens/invoice-receive.screen.ts +59 -0
  238. package/samples/recipes/embedded-entity-form/package.json +0 -1
  239. package/samples/recipes/embedded-entity-form/src/web/accept-suggestion-drawer.tsx +1 -1
  240. package/samples/recipes/notes-history-basic/README.md +1 -1
  241. package/samples/recipes/record-detail-layout/package.json +0 -1
  242. package/samples/recipes/tags-basic/README.md +1 -1
  243. package/samples/recipes/user-profile/README.md +1 -1
  244. package/samples/recipes/wizard-form/package.json +0 -1
  245. package/samples/recipes/wizard-form/src/web/index.ts +2 -2
  246. package/samples/recipes/wizard-form/src/web/listing-review-section.tsx +2 -2
  247. 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,