@cosmicdrift/kumiko-samples 0.364.0 → 0.365.1

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 (148) hide show
  1. package/LICENSE +1 -1
  2. package/package.json +1 -1
  3. package/packages/bundled-features/package.json +1 -1
  4. package/packages/bundled-features/src/audit/changes.json +12 -0
  5. package/packages/bundled-features/src/audit/feature.ts +1 -1
  6. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
  7. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +1 -3
  8. package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
  9. package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
  10. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
  12. package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
  13. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
  14. package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
  15. package/packages/bundled-features/src/billing-foundation/changes.json +12 -0
  16. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +8 -1
  17. package/packages/bundled-features/src/billing-foundation/i18n.ts +6 -0
  18. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +10 -2
  19. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
  20. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
  21. package/packages/bundled-features/src/cap-counter/changes.json +13 -0
  22. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
  23. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  24. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
  25. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
  26. package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
  27. package/packages/bundled-features/src/channel-email/changes.json +18 -0
  28. package/packages/bundled-features/src/channel-email/email-channel.ts +30 -20
  29. package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
  30. package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
  31. package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
  32. package/packages/bundled-features/src/crypto-shredding/changes.json +7 -0
  33. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +7 -1
  34. package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
  35. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
  36. package/packages/bundled-features/src/delivery/attachment-ref.ts +18 -1
  37. package/packages/bundled-features/src/delivery/changes.json +12 -0
  38. package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
  39. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  40. package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
  41. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
  42. package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
  43. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
  44. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  45. package/packages/bundled-features/src/jobs/changes.json +12 -0
  46. package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -2
  47. package/packages/bundled-features/src/metrics/changes.json +6 -0
  48. package/packages/bundled-features/src/metrics/filters.ts +28 -12
  49. package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
  50. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
  51. package/packages/bundled-features/src/secrets/changes.json +6 -0
  52. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
  53. package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
  54. package/packages/bundled-features/src/shared/index.ts +8 -0
  55. package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
  56. package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
  57. package/packages/bundled-features/src/step-dispatcher/changes.json +8 -1
  58. package/packages/bundled-features/src/step-dispatcher/constants.ts +22 -0
  59. package/packages/bundled-features/src/step-dispatcher/deliver-dispatch-request.ts +236 -0
  60. package/packages/bundled-features/src/step-dispatcher/feature.ts +65 -118
  61. package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +40 -14
  62. package/packages/bundled-features/src/template-resolver/changes.json +81 -0
  63. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +78 -12
  64. package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
  65. package/packages/bundled-features/src/template-resolver/constants.ts +11 -1
  66. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
  67. package/packages/bundled-features/src/template-resolver/document-schema.ts +7 -1
  68. package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
  69. package/packages/bundled-features/src/template-resolver/feature.ts +12 -0
  70. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
  71. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
  72. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +111 -61
  73. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +1 -0
  74. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
  75. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
  76. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +12 -2
  77. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +48 -9
  78. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
  79. package/packages/bundled-features/src/template-resolver/index.ts +6 -1
  80. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +25 -9
  81. package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
  82. package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
  83. package/packages/bundled-features/src/template-resolver/seeding.ts +2 -0
  84. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
  85. package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
  86. package/packages/bundled-features/src/template-resolver/template-draft.ts +1 -1
  87. package/packages/bundled-features/src/template-resolver/template-meta.ts +8 -0
  88. package/packages/bundled-features/src/template-resolver/template-preview.ts +1 -1
  89. package/packages/bundled-features/src/template-resolver/template-resources.ts +2 -43
  90. package/packages/bundled-features/src/template-resolver/template-validation.ts +15 -9
  91. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +34 -0
  92. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +3 -2
  93. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +56 -11
  94. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +6 -3
  95. package/packages/bundled-features/src/template-resolver/web/i18n.ts +7 -0
  96. package/packages/bundled-features/src/template-resolver/web/index.ts +1 -0
  97. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +14 -5
  98. package/packages/bundled-features/src/tenant/changes.json +6 -0
  99. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
  100. package/packages/bundled-features/src/tenant/seeding.ts +33 -27
  101. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
  102. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  103. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
  104. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +9 -3
  105. package/packages/bundled-features/src/tier-engine/changes.json +12 -0
  106. package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
  107. package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
  108. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
  109. package/packages/bundled-features/src/tier-engine/index.ts +2 -0
  110. package/packages/bundled-features/src/user/constants.ts +1 -0
  111. package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
  112. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  113. package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
  114. package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
  115. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
  116. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
  117. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  118. package/packages/bundled-features/src/waitlist/feature.ts +1 -1
  119. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
  120. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
  121. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
  122. package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
  123. package/packages/bundled-features/src/waitlist/lib.ts +20 -0
  124. package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
  125. package/samples/apps/ui-walkthrough/src/run-config.ts +14 -1
  126. package/samples/apps/use-all-bundled/README.md +3 -13
  127. package/samples/apps/use-all-bundled/feature-manifest.json +4 -4
  128. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
  129. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
  130. package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
  131. package/samples/recipes/auth-mfa/README.md +2 -1
  132. package/samples/recipes/auth-mfa/src/feature.ts +1 -0
  133. package/samples/recipes/custom-handlers/README.md +4 -3
  134. package/samples/recipes/custom-handlers/src/feature.ts +21 -12
  135. package/samples/recipes/dashboard-stats/README.md +38 -0
  136. package/samples/recipes/dashboard-stats/package.json +8 -0
  137. package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
  138. package/samples/recipes/legal-pages/README.md +2 -2
  139. package/samples/recipes/managed-pages/README.md +2 -2
  140. package/samples/recipes/pdf-audit-export/README.md +2 -1
  141. package/samples/recipes/pdf-audit-export/package.json +1 -1
  142. package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
  143. package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
  144. package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
  145. package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
  146. package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
  147. package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
  148. package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
@@ -1,4 +1,85 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "breaking",
5
+ "title": "copy-locale copies the system default's files in a beforeTransaction phase and can no longer run as a nested write",
6
+ "detail": "When a tenant copies a locale from the system default, the image and attachment files are now read and written to the tenant's storage in a beforeTransaction phase. The transaction only creates the fileRef rows, and the uploaded binaries are released when it does not commit. Before, the storage I/O held the database transaction open.",
7
+ "migration": "Dispatch copy-locale as a top-level command, or from a perCommand handler via ctx.write. A handler that called it nested moves that call into a client or job, or becomes a perCommand handler."
8
+ },
9
+ {
10
+ "version": "0.365.0",
11
+ "type": "fix",
12
+ "title": "Opening a document or mail editor with an empty body no longer marks the form as changed",
13
+ "detail": "Switching the editor's editable state on mount emitted an update, so DocumentTiptapEditor called onChange(\"<p></p>\") before the user typed anything and the surrounding form counted as dirty."
14
+ },
15
+ {
16
+ "version": "0.365.0",
17
+ "type": "breaking",
18
+ "title": "onEditorReady receives null when the editor is destroyed",
19
+ "detail": "DocumentTiptapEditor and PlaceholderLineEditor call onEditorReady(null) on destroy, so a held handle no longer points at a destroyed editor. The callback type is (handle: DocumentEditorHandle | null) => void. The document designer drops the handle of an unmounted field, so placeholder insertion no longer targets a destroyed editor.",
20
+ "migration": "A custom onEditorReady callback must accept null and drop its stored handle when it receives it."
21
+ },
22
+ {
23
+ "version": "0.365.0",
24
+ "type": "fix",
25
+ "title": "Document editor no longer blanks the app when late placeholders rebuild it",
26
+ "detail": "When the system placeholders or resource previews arrived in the same render as a new value (layout editor after opening or saving), the editor rebuilt itself and the value sync then read the destroyed editor. The TypeError unmounted the whole app and left a blank page. Both sync effects now skip a destroyed editor; the replacement editor picks up the value on its next render."
27
+ },
28
+ {
29
+ "version": "0.365.0",
30
+ "type": "improvement",
31
+ "title": "Steps block in mail and PDF templates",
32
+ "detail": "The document editor offers a steps block in the toolbar block menu. It stores a fixed list of stages (value=label, at most 8) and the variable that names the current stage. Mail and PDF render it as a row of bars and labels; stages up to the current one are coloured, the current label is bold. Invalid blocks are dropped by the sanitizer and render nothing. Layout regions do not offer the block."
33
+ },
34
+ {
35
+ "version": "0.365.0",
36
+ "type": "fix",
37
+ "title": "A paragraph or heading that only holds an empty placeholder is left out of the rendered document",
38
+ "detail": "compileDocumentBodyToHandlebars wraps a p or h1 to h4 whose only content is one placeholder in an if block, so an empty optional value no longer leaves a blank line in mails and PDFs. Zero numbers and money values still render. Blocks with static text, images or several placeholders are unchanged."
39
+ },
40
+ {
41
+ "version": "0.365.0",
42
+ "type": "improvement",
43
+ "title": "Mail layout buttons follow the branding accent colour",
44
+ "detail": "mailLayoutAppearance takes the tenant branding accent colour. When the layout uses the branding accent line, buttons in the mail body use that colour instead of the fixed dark teal. Invalid colours fall back to the previous default."
45
+ },
46
+ {
47
+ "version": "0.365.0",
48
+ "type": "fix",
49
+ "title": "A placeholder inside a button label is kept",
50
+ "detail": "The document schema allows placeholder chips inside a button label. Before, saving or seeding a body such as a button reading \"View {{pageName}}\" split the button: the label stopped at the placeholder and the rest landed in a new paragraph outside the button."
51
+ },
52
+ {
53
+ "version": "0.365.0",
54
+ "type": "improvement",
55
+ "title": "compileDocumentBodyToHandlebars takes a height cap for resource images",
56
+ "detail": "The new resourceImageMaxHeightPx option adds max-height and width:auto to resource images. The mail renderer caps images in a layout header at 64 px, so a square logo stays small while a wide wordmark keeps its 180 px width cap."
57
+ },
58
+ {
59
+ "version": "0.365.0",
60
+ "type": "fix",
61
+ "title": "resource-upload refuses key collisions between images and attachments and linking a non-active PDF template",
62
+ "detail": "Uploading an attachment under a key that holds an inline image, or an image under a key that holds an attachment or linked PDF, fails with template_attachment_invalid (code key_in_use) instead of silently replacing the entry. resource-link-pdf only accepts an active document-pdf template, so draft or archived templates no longer produce mails that fail at send time. When the link write fails after the file was stored, the orphaned storage object is deleted."
63
+ },
64
+ {
65
+ "version": "0.365.0",
66
+ "type": "fix",
67
+ "title": "collection kinds fail at boot; validate, diff-to-system, overview, seeding, bundle import and placeholder checks follow the write rules",
68
+ "detail": "createTemplateResolverFeature throws for a collection whose kind is mail, mail-layout or pdf-layout instead of failing on every save. The validate query ignores scope system for non-SystemAdmins. diff-to-system compares only against an active system template like reset-to-system. The overview reads rows in a stable order and reports truncated when its row limit is hit. Re-seeding notification templates reactivates an archived system row and applies a changed parentTemplateId. A bundle holds at most 50 locales. Dotted placeholders under a declared variable or list item field are reported as unknown because they never render a value. The document editor fallbacks are read-only and the placeholder line editor recolors chips when systemVariables change."
69
+ },
70
+ {
71
+ "version": "0.365.0",
72
+ "type": "fix",
73
+ "title": "Resource image formats come from one list",
74
+ "detail": "PNG, JPEG, GIF and WebP content types and extensions are defined once in the shared module and reused by the resource upload, the document body compiler and the email channel."
75
+ },
76
+ {
77
+ "version": "0.365.0",
78
+ "type": "breaking",
79
+ "title": "resource-upload, import-bundle and import-bundle-system upload in a beforeTransaction phase and can no longer run as nested writes",
80
+ "detail": "The three handlers now validate and upload their files before the write transaction opens and only create the fileRef and template rows inside it, so no database connection sits idle in a transaction during storage I/O. A rolled-back transaction releases the uploaded bytes. Because they now have a pre-transaction phase, the dispatcher rejects a nested ctx.write, ctx.writeAs or handler-to-handler call of them (\"has a pre-transaction phase\"). The exported storeTemplateResource is replaced by prepareTemplateResourceBeforeTransaction (beforeTransaction half) and storePreparedTemplateResource (in-transaction half).",
81
+ "migration": "Dispatch resource-upload, import-bundle and import-bundle-system as top-level commands, or from a perCommand handler via ctx.write. A handler that called them nested moves that call into a client or job, or becomes a perCommand handler. Code that called storeTemplateResource calls prepareTemplateResourceBeforeTransaction in a beforeTransaction phase and passes the result to storePreparedTemplateResource in the handler."
82
+ },
2
83
  {
3
84
  "version": "0.364.0",
4
85
  "type": "fix",
@@ -1,6 +1,8 @@
1
1
  import type { TemplateVariableDef, TemplateVariables } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
3
3
  import type { Element as ScanElement } from "happy-dom";
4
+ import { isResourceImageContentType } from "../shared/index.js";
5
+ import { rewriteStepsBlock } from "./compile-steps-block.js";
4
6
  import { withParsedDocument } from "./document-sanitizer.js";
5
7
  import {
6
8
  DOCUMENT_TEXT_ALIGNMENTS,
@@ -83,6 +85,39 @@ function pathAndDef(
83
85
  return [path, def];
84
86
  }
85
87
 
88
+ const VARIABLE_ONLY_BLOCK_SELECTOR = "p, h1, h2, h3, h4";
89
+
90
+ function soleVariableName(block: ScanElement): string | null {
91
+ const names = new Set(
92
+ Array.from(block.querySelectorAll("span[data-variable]")).map(
93
+ (span) => span.getAttribute("data-variable") ?? "",
94
+ ),
95
+ );
96
+ const [name] = [...names];
97
+ if (names.size !== 1 || name === undefined || !isDocumentReferenceName(name)) return null;
98
+ const withoutVariables = block.cloneNode(true);
99
+ for (const span of Array.from(withoutVariables.querySelectorAll("span[data-variable]"))) {
100
+ span.remove();
101
+ }
102
+ const hasStaticContent =
103
+ (withoutVariables.textContent ?? "").trim() !== "" ||
104
+ withoutVariables.querySelector("img") !== null;
105
+ return hasStaticContent ? null : name;
106
+ }
107
+
108
+ // A line that holds nothing but one optional placeholder would otherwise leave
109
+ // an empty paragraph (a visible gap) when the value is missing. includeZero
110
+ // keeps an amount of 0.
111
+ function wrapVariableOnlyBlock(block: ScanElement, state: CompileState): void {
112
+ const name = soleVariableName(block);
113
+ // skip: a line with static content or several placeholders stays as it is
114
+ if (name === null) return;
115
+ const [path] = pathAndDef(name, block, state);
116
+ const document = block.ownerDocument;
117
+ block.before(document.createTextNode(marker(state, `{{#if ${path} includeZero=true}}`)));
118
+ block.after(document.createTextNode(marker(state, "{{/if}}")));
119
+ }
120
+
86
121
  function rewriteLink(element: ScanElement, state: CompileState): void {
87
122
  const href = element.getAttribute("href") ?? "";
88
123
  if (isDocumentPlaceholderHref(href)) {
@@ -126,6 +161,8 @@ export type CompileDocumentBodyOptions = {
126
161
  readonly resourceSrc?: (key: string) => string | undefined;
127
162
  // Mail clients ignore document CSS, so a full-width logo needs an inline width cap.
128
163
  readonly resourceImageMaxWidthPx?: number;
164
+ // Keeps a square logo in a mail header from growing to the full width cap.
165
+ readonly resourceImageMaxHeightPx?: number;
129
166
  // Mail clients ignore document CSS, so mail turns data-align into an inline style.
130
167
  readonly alignmentAsInlineStyle?: boolean;
131
168
  // Same reason: a[data-button] only looks like a button through editor/print CSS.
@@ -135,6 +172,10 @@ export type CompileDocumentBodyOptions = {
135
172
  readonly tableAsInlineStyle?: boolean;
136
173
  // Replaces the default button look; only values from the layout appearance belong here.
137
174
  readonly buttonInlineStyle?: string;
175
+ // Mail clients ignore document CSS, so headings get their size and weight inline.
176
+ readonly headingAsInlineStyle?: boolean;
177
+ // Bar color of reached stages in a steps block; ignored unless it is a #rrggbb hex.
178
+ readonly stepsColor?: string;
138
179
  };
139
180
 
140
181
  const MAIL_BUTTON_STYLE =
@@ -143,7 +184,12 @@ const MAIL_BUTTON_STYLE =
143
184
  const MAIL_TABLE_STYLE = "width:100%;border-collapse:collapse";
144
185
  const MAIL_TABLE_CELL_STYLE = "padding:6px 12px;vertical-align:top;border-bottom:1px solid #e5e5e5";
145
186
  const MAIL_TABLE_HEAD_STYLE = "font-size:12px;font-weight:600;color:#6b7280;text-align:left";
146
- const MAIL_SMALL_STYLE = "font-size:12px;color:#6b7280";
187
+ const MAIL_SMALL_STYLE = "font-size:13px;color:#4A515E";
188
+ const MAIL_HEADING_STYLES: Readonly<Record<string, string>> = {
189
+ h1: "margin:0 0 12px 0;font-size:30px;line-height:1.15;font-weight:700;letter-spacing:-0.035em;color:#0B0D12",
190
+ h2: "margin:0 0 12px 0;font-size:24px;line-height:1.2;font-weight:700;letter-spacing:-0.02em;color:#0B0D12",
191
+ h3: "margin:20px 0 8px 0;font-size:17px;line-height:1.3;font-weight:600;color:#0B0D12",
192
+ };
147
193
 
148
194
  // Appended after an existing style (data-align) so the alignment survives.
149
195
  function appendStyle(element: ScanElement, style: string): void {
@@ -151,13 +197,6 @@ function appendStyle(element: ScanElement, style: string): void {
151
197
  element.setAttribute("style", existing === null ? style : `${existing};${style}`);
152
198
  }
153
199
 
154
- const RESOURCE_IMAGE_CONTENT_TYPES: ReadonlySet<string> = new Set([
155
- "image/png",
156
- "image/jpeg",
157
- "image/gif",
158
- "image/webp",
159
- ]);
160
-
161
200
  // Unique valid resource keys in document order.
162
201
  export function collectDocumentResourceKeys(html: string): readonly string[] {
163
202
  return withParsedDocument(html, (root) => {
@@ -171,7 +210,7 @@ export function collectDocumentResourceKeys(html: string): readonly string[] {
171
210
  }
172
211
 
173
212
  export function toResourceDataUri(contentType: string, bytes: Uint8Array): string {
174
- if (!RESOURCE_IMAGE_CONTENT_TYPES.has(contentType)) {
213
+ if (!isResourceImageContentType(contentType)) {
175
214
  throw new Error(`resource content type "${contentType}" cannot be embedded as an image`);
176
215
  }
177
216
  return `data:${contentType};base64,${Buffer.from(bytes).toString("base64")}`;
@@ -188,6 +227,7 @@ function rewriteResourceImage(
188
227
  element: ScanElement,
189
228
  resourceSrc: CompileDocumentBodyOptions["resourceSrc"],
190
229
  maxWidthPx: number | undefined,
230
+ maxHeightPx: number | undefined,
191
231
  ): void {
192
232
  const key = element.getAttribute("data-resource") ?? "";
193
233
  const src = isDocumentResourceKey(key) ? resourceSrc?.(key) : undefined;
@@ -198,8 +238,11 @@ function rewriteResourceImage(
198
238
  }
199
239
  element.setAttribute("src", src);
200
240
  element.removeAttribute("data-resource");
201
- if (maxWidthPx !== undefined)
202
- element.setAttribute("style", `max-width:${maxWidthPx}px;height:auto`);
241
+ const caps = [
242
+ ...(maxWidthPx === undefined ? [] : [`max-width:${maxWidthPx}px`]),
243
+ ...(maxHeightPx === undefined ? [] : [`max-height:${maxHeightPx}px;width:auto`]),
244
+ ];
245
+ if (caps.length > 0) element.setAttribute("style", `${caps.join(";")};height:auto`);
203
246
  }
204
247
 
205
248
  function stripScriptsAndActiveContent(
@@ -208,7 +251,12 @@ function stripScriptsAndActiveContent(
208
251
  ): void {
209
252
  for (const element of Array.from(root.querySelectorAll("script"))) element.remove();
210
253
  for (const element of Array.from(root.querySelectorAll("img[data-resource]"))) {
211
- rewriteResourceImage(element, options.resourceSrc, options.resourceImageMaxWidthPx);
254
+ rewriteResourceImage(
255
+ element,
256
+ options.resourceSrc,
257
+ options.resourceImageMaxWidthPx,
258
+ options.resourceImageMaxHeightPx,
259
+ );
212
260
  }
213
261
  for (const element of Array.from(root.querySelectorAll("*"))) stripActiveContent(element);
214
262
  }
@@ -223,6 +271,13 @@ function applyAlignmentInlineStyles(root: ScanElement): void {
223
271
  }
224
272
  }
225
273
 
274
+ function applyHeadingInlineStyles(root: ScanElement): void {
275
+ for (const heading of Array.from(root.querySelectorAll("h1, h2, h3"))) {
276
+ const style = MAIL_HEADING_STYLES[heading.tagName.toLowerCase()];
277
+ if (style !== undefined) appendStyle(heading, style);
278
+ }
279
+ }
280
+
226
281
  function applyTableInlineStyles(root: ScanElement): void {
227
282
  for (const table of Array.from(root.querySelectorAll("table"))) {
228
283
  appendStyle(table, MAIL_TABLE_STYLE);
@@ -261,7 +316,18 @@ export function compileDocumentBodyToHandlebars(
261
316
  const compiled = withParsedDocument(scrubbed, (root) => {
262
317
  stripScriptsAndActiveContent(root, options);
263
318
  if (options.alignmentAsInlineStyle === true) applyAlignmentInlineStyles(root);
319
+ if (options.headingAsInlineStyle === true) applyHeadingInlineStyles(root);
264
320
  if (options.tableAsInlineStyle === true) applyTableInlineStyles(root);
321
+ for (const element of Array.from(root.querySelectorAll("div[data-steps]"))) {
322
+ rewriteStepsBlock(element, {
323
+ expression: (source) => marker(state, source),
324
+ pathOf: (name, at) => pathAndDef(name, at, state)[0],
325
+ color: options.stepsColor,
326
+ });
327
+ }
328
+ for (const block of Array.from(root.querySelectorAll(VARIABLE_ONLY_BLOCK_SELECTOR))) {
329
+ wrapVariableOnlyBlock(block, state);
330
+ }
265
331
  for (const element of Array.from(root.querySelectorAll("span[data-variable]"))) {
266
332
  rewriteVariable(element, state);
267
333
  }
@@ -0,0 +1,68 @@
1
+ import type { Element as ScanElement } from "happy-dom";
2
+ import { isDocumentReferenceName } from "./document-schema.js";
3
+ import { readDocumentSteps } from "./document-steps.js";
4
+ import { isHexColor } from "./layout-appearance.js";
5
+
6
+ export const DEFAULT_STEPS_COLOR = "#1f5468";
7
+ const TODO_BAR_COLOR = "#ECEDEA";
8
+ const REACHED_LABEL_COLOR = "#0B0D12";
9
+ const TODO_LABEL_COLOR = "#5A6170";
10
+
11
+ export type StepsCompileHooks = {
12
+ /** Registers a Handlebars expression and returns the marker that stands for it. */
13
+ readonly expression: (source: string) => string;
14
+ /** Resolves a variable name to its Handlebars path at the element's position. */
15
+ readonly pathOf: (name: string, element: ScanElement) => string;
16
+ readonly color: string | undefined;
17
+ };
18
+
19
+ // Inline-styled table in every output (mail, PDF, preview): mail clients ignore
20
+ // document CSS, and one rendering keeps the three outputs identical. The labels
21
+ // stay static text; only the reached/current switch is a Handlebars block, and
22
+ // its arguments are a validated path and validated keys.
23
+ export function rewriteStepsBlock(element: ScanElement, hooks: StepsCompileHooks): void {
24
+ const name = element.getAttribute("data-steps") ?? "";
25
+ const steps = readDocumentSteps(element);
26
+ if (!isDocumentReferenceName(name) || steps === null) {
27
+ element.remove();
28
+ // skip: an invalid steps block renders nothing
29
+ return;
30
+ }
31
+ const path = hooks.pathOf(name, element);
32
+ const keys = steps.map((step) => step.key).join(",");
33
+ const color =
34
+ hooks.color !== undefined && isHexColor(hooks.color) ? hooks.color : DEFAULT_STEPS_COLOR;
35
+ const when = (index: number, state: "reached" | "current", yes: string, no: string): string =>
36
+ `${hooks.expression(`{{#stepIs ${path} ${index} "${keys}" "${state}"}}`)}${yes}${hooks.expression("{{else}}")}${no}${hooks.expression("{{/stepIs}}")}`;
37
+
38
+ const document = element.ownerDocument;
39
+ const table = document.createElement("table");
40
+ table.setAttribute("role", "presentation");
41
+ table.setAttribute("width", "100%");
42
+ table.setAttribute("cellpadding", "0");
43
+ table.setAttribute("cellspacing", "0");
44
+ table.setAttribute("style", "table-layout:fixed;border-collapse:collapse;margin:4px 0 24px 0");
45
+ const row = document.createElement("tr");
46
+ steps.forEach((step, index) => {
47
+ const cell = document.createElement("td");
48
+ cell.setAttribute(
49
+ "style",
50
+ `padding:0 ${index === steps.length - 1 ? 0 : 4}px 0 0;vertical-align:top`,
51
+ );
52
+ const bar = document.createElement("div");
53
+ bar.setAttribute(
54
+ "style",
55
+ `height:4px;line-height:4px;font-size:0;border-radius:2px;background-color:${when(index, "reached", color, TODO_BAR_COLOR)}`,
56
+ );
57
+ const label = document.createElement("div");
58
+ label.setAttribute(
59
+ "style",
60
+ `padding-top:8px;font-size:12px;line-height:1.3;color:${when(index, "reached", REACHED_LABEL_COLOR, TODO_LABEL_COLOR)};font-weight:${when(index, "current", "600", "400")}`,
61
+ );
62
+ label.textContent = step.label;
63
+ cell.append(bar, label);
64
+ row.append(cell);
65
+ });
66
+ table.append(row);
67
+ element.replaceWith(table);
68
+ }
@@ -51,7 +51,15 @@ export const TEXT_BLOCK_KIND = "text-block" as const;
51
51
  // Kinds a content collection must not write: a tenant row of these kinds would
52
52
  // shadow the system notification template or layout without the meta and
53
53
  // placeholder gates that upsertTenant applies.
54
- export const COLLECTION_REFUSED_KINDS: readonly string[] = ["mail", "mail-layout", "pdf-layout"];
54
+ export const COLLECTION_REFUSED_KINDS = [
55
+ "mail",
56
+ "mail-layout",
57
+ "pdf-layout",
58
+ ] as const satisfies readonly TemplateKind[];
59
+
60
+ export function isCollectionRefusedKind(kind: string): boolean {
61
+ return COLLECTION_REFUSED_KINDS.some((refused) => refused === kind);
62
+ }
55
63
 
56
64
  export const CONTENT_FORMATS = ["markdown", "mjml", "html", "plain"] as const;
57
65
  export type ContentFormat = (typeof CONTENT_FORMATS)[number];
@@ -61,6 +69,7 @@ export type TemplateScope = (typeof TEMPLATE_SCOPES)[number];
61
69
 
62
70
  export const TEMPLATE_STATUSES = ["draft", "active", "archived"] as const;
63
71
  export type TemplateStatus = (typeof TEMPLATE_STATUSES)[number];
72
+ export const ACTIVE_TEMPLATE_STATUS = "active" satisfies TemplateStatus;
64
73
 
65
74
  export {
66
75
  PDF_DEFAULT_MARGIN_MM,
@@ -76,6 +85,7 @@ export {
76
85
  export const HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
77
86
 
78
87
  export const DEFAULT_MAIL_LAYOUT_SLUG = "default";
88
+ export const DEFAULT_PDF_LAYOUT_SLUG = "default";
79
89
 
80
90
  // The mail transport enforces no size limit of its own; 10 MiB is a common provider ceiling.
81
91
  export const MAIL_PREVIEW_MAX_BYTES = 10 * 1024 * 1024;
@@ -13,6 +13,7 @@ import {
13
13
  isDocumentReferenceName,
14
14
  isDocumentResourceKey,
15
15
  } from "./document-schema.js";
16
+ import { isDocumentStepKey } from "./document-steps.js";
16
17
 
17
18
  export type DocumentViolationCode =
18
19
  | "script_element"
@@ -138,6 +139,8 @@ const REFERENCE_ATTRIBUTES: ReadonlyArray<readonly [string, (value: string) => b
138
139
  ["data-variable", isDocumentReferenceName],
139
140
  ["data-each", isDocumentReferenceName],
140
141
  ["data-resource", isDocumentResourceKey],
142
+ ["data-steps", isDocumentReferenceName],
143
+ ["data-step", isDocumentStepKey],
141
144
  ];
142
145
 
143
146
  function scanReferences(tag: string, element: ScanElement, out: DocumentViolation[]): void {
@@ -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 { createDocumentStepsNode } from "./document-steps.js";
11
12
 
12
13
  const PLACEHOLDER_HREF = /^\{\{(.+)\}\}$/;
13
14
  const REFERENCE_NAME = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
@@ -71,6 +72,7 @@ export const DOCUMENT_NODE_NAMES = [
71
72
  "button",
72
73
  "divider",
73
74
  "pageBreak",
75
+ "steps",
74
76
  ] as const;
75
77
 
76
78
  export const DocumentVariable = Node.create({
@@ -131,7 +133,8 @@ export const DocumentResource = Node.create({
131
133
  export const DocumentButton = Node.create({
132
134
  name: "button",
133
135
  group: "block",
134
- content: "text*",
136
+ // A placeholder in the label ("View {{pageName}}") must survive the round trip.
137
+ content: "(text | variable)*",
135
138
  marks: "",
136
139
  defining: true,
137
140
  addAttributes() {
@@ -179,6 +182,8 @@ export const DocumentPageBreak = Node.create({
179
182
  },
180
183
  });
181
184
 
185
+ export const DocumentSteps = createDocumentStepsNode(isDocumentReferenceName);
186
+
182
187
  // A repeat row is a table row carrying `each`: prosemirror-tables requires
183
188
  // the tableRow node type, so a separate node type is not an option.
184
189
  export const DocumentTableRow = TableRow.extend({
@@ -340,6 +345,7 @@ export const documentExtensions: Extensions = [
340
345
  DocumentButton,
341
346
  DocumentDivider,
342
347
  DocumentPageBreak,
348
+ DocumentSteps,
343
349
  DocumentTextAlign,
344
350
  DocumentTextSize,
345
351
  ];
@@ -0,0 +1,114 @@
1
+ // @runtime client
2
+ // A progress row: fixed stages stored in the template, the reached stage read
3
+ // from a variable at render time. Universal like document-schema.ts.
4
+ import { Node } from "@tiptap/core";
5
+
6
+ // Keys end up as Handlebars string literals in the compiled body, so they are
7
+ // restricted to characters that cannot close or escape a literal.
8
+ const STEP_KEY = /^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/;
9
+ export const DOCUMENT_STEPS_MAX = 8;
10
+ const STEP_LABEL_MAX_LENGTH = 40;
11
+
12
+ export type DocumentStep = { readonly key: string; readonly label: string };
13
+
14
+ export function isDocumentStepKey(value: string): boolean {
15
+ return STEP_KEY.test(value);
16
+ }
17
+
18
+ export function documentStepKeyOf(label: string): string {
19
+ return label
20
+ .normalize("NFKD")
21
+ .replace(/\p{M}/gu, "")
22
+ .toLowerCase()
23
+ .replace(/[^a-z0-9]+/g, "-")
24
+ .replace(/^-+|-+$/g, "")
25
+ .slice(0, 40);
26
+ }
27
+
28
+ // Editor input: "value=Label" entries separated by commas; a bare label derives its value.
29
+ export function parseDocumentStepsInput(text: string): readonly DocumentStep[] | null {
30
+ const steps = text
31
+ .split(",")
32
+ .map((entry) => entry.trim())
33
+ .filter((entry) => entry !== "")
34
+ .map((entry): DocumentStep => {
35
+ const separator = entry.indexOf("=");
36
+ const label = (separator < 0 ? entry : entry.slice(separator + 1)).trim();
37
+ const key = separator < 0 ? documentStepKeyOf(entry) : entry.slice(0, separator).trim();
38
+ return { key, label: label.slice(0, STEP_LABEL_MAX_LENGTH) };
39
+ });
40
+ return areValidSteps(steps) ? steps : null;
41
+ }
42
+
43
+ function areValidSteps(steps: readonly DocumentStep[]): boolean {
44
+ return (
45
+ steps.length > 0 &&
46
+ steps.length <= DOCUMENT_STEPS_MAX &&
47
+ new Set(steps.map((step) => step.key)).size === steps.length &&
48
+ steps.every((step) => isDocumentStepKey(step.key) && step.label !== "")
49
+ );
50
+ }
51
+
52
+ // Duplicate or invalid keys would make the reached stage ambiguous, so the whole
53
+ // list is refused rather than repaired.
54
+ export function readDocumentSteps(element: {
55
+ querySelectorAll(selector: string): ArrayLike<{
56
+ getAttribute(name: string): string | null;
57
+ textContent: string | null;
58
+ }>;
59
+ }): readonly DocumentStep[] | null {
60
+ const steps = Array.from(element.querySelectorAll("span[data-step]")).map((span) => ({
61
+ key: span.getAttribute("data-step") ?? "",
62
+ label: (span.textContent ?? "").trim().slice(0, STEP_LABEL_MAX_LENGTH),
63
+ }));
64
+ return areValidSteps(steps) ? steps : null;
65
+ }
66
+
67
+ function isDocumentStep(step: unknown): step is DocumentStep {
68
+ return (
69
+ typeof step === "object" &&
70
+ step !== null &&
71
+ "key" in step &&
72
+ "label" in step &&
73
+ typeof step.key === "string" &&
74
+ typeof step.label === "string"
75
+ );
76
+ }
77
+
78
+ // Node attrs are untyped JSON; anything that is not a step entry is dropped.
79
+ export function readDocumentStepsAttribute(value: unknown): readonly DocumentStep[] {
80
+ return Array.isArray(value) ? value.filter(isDocumentStep) : [];
81
+ }
82
+
83
+ export function createDocumentStepsNode(isReferenceName: (value: string) => boolean) {
84
+ return Node.create({
85
+ name: "steps",
86
+ group: "block",
87
+ atom: true,
88
+ addAttributes() {
89
+ return { variable: { default: null }, steps: { default: [] } };
90
+ },
91
+ parseHTML() {
92
+ return [
93
+ {
94
+ tag: "div[data-steps]",
95
+ getAttrs: (element) => {
96
+ const variable = element.getAttribute("data-steps");
97
+ const steps = readDocumentSteps(element);
98
+ if (variable === null || !isReferenceName(variable) || steps === null) return false;
99
+ return { variable, steps };
100
+ },
101
+ },
102
+ ];
103
+ },
104
+ renderHTML({ node }) {
105
+ return [
106
+ "div",
107
+ { "data-steps": node.attrs["variable"] },
108
+ ...readDocumentStepsAttribute(node.attrs["steps"]).map(
109
+ (step) => ["span", { "data-step": step.key }, step.label] as const,
110
+ ),
111
+ ];
112
+ },
113
+ });
114
+ }
@@ -3,6 +3,7 @@ import {
3
3
  createSystemConfig,
4
4
  defineFeature,
5
5
  } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { isCollectionRefusedKind } from "./constants.js";
6
7
  import { bySlugQuery } from "./handlers/by-slug.query.js";
7
8
  import { byTenantQuery } from "./handlers/by-tenant.query.js";
8
9
  import { makeCollectionItemQuery } from "./handlers/collection-item.query.js";
@@ -60,8 +61,19 @@ export type TemplateResolverOptions = {
60
61
  readonly collections?: readonly ContentCollectionDefinition[];
61
62
  };
62
63
 
64
+ function assertCollectionKindsAllowed(collections: readonly ContentCollectionDefinition[]): void {
65
+ for (const collection of collections) {
66
+ if (isCollectionRefusedKind(collection.kind)) {
67
+ throw new Error(
68
+ `template-resolver: collection "${collection.id}" uses kind "${collection.kind}", which collections may not write`,
69
+ );
70
+ }
71
+ }
72
+ }
73
+
63
74
  export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}) {
64
75
  const collections = opts.collections ?? [];
76
+ assertCollectionKindsAllowed(collections);
65
77
  const hasUserOwned = collections.some((c) => c.ownership === "user");
66
78
  return defineFeature("template-resolver", (r) => {
67
79
  r.describe(