@cosmicdrift/kumiko-samples 0.364.0 → 0.365.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 (143) 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/handlers/forget-subject.write.ts +7 -1
  33. package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
  34. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
  35. package/packages/bundled-features/src/delivery/attachment-ref.ts +18 -1
  36. package/packages/bundled-features/src/delivery/changes.json +12 -0
  37. package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
  38. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  39. package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
  40. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
  41. package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
  42. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
  43. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  44. package/packages/bundled-features/src/jobs/changes.json +12 -0
  45. package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -2
  46. package/packages/bundled-features/src/metrics/changes.json +6 -0
  47. package/packages/bundled-features/src/metrics/filters.ts +28 -12
  48. package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
  49. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
  50. package/packages/bundled-features/src/secrets/changes.json +6 -0
  51. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
  52. package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
  53. package/packages/bundled-features/src/shared/index.ts +8 -0
  54. package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
  55. package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
  56. package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -2
  57. package/packages/bundled-features/src/template-resolver/changes.json +81 -0
  58. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +78 -12
  59. package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
  60. package/packages/bundled-features/src/template-resolver/constants.ts +11 -1
  61. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
  62. package/packages/bundled-features/src/template-resolver/document-schema.ts +7 -1
  63. package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
  64. package/packages/bundled-features/src/template-resolver/feature.ts +12 -0
  65. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
  66. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
  67. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +111 -61
  68. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +1 -0
  69. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
  70. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
  71. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +12 -2
  72. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +48 -9
  73. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
  74. package/packages/bundled-features/src/template-resolver/index.ts +6 -1
  75. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +25 -9
  76. package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
  77. package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
  78. package/packages/bundled-features/src/template-resolver/seeding.ts +2 -0
  79. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
  80. package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
  81. package/packages/bundled-features/src/template-resolver/template-draft.ts +1 -1
  82. package/packages/bundled-features/src/template-resolver/template-meta.ts +8 -0
  83. package/packages/bundled-features/src/template-resolver/template-preview.ts +1 -1
  84. package/packages/bundled-features/src/template-resolver/template-resources.ts +2 -43
  85. package/packages/bundled-features/src/template-resolver/template-validation.ts +15 -9
  86. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +34 -0
  87. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +3 -2
  88. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +56 -11
  89. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +6 -3
  90. package/packages/bundled-features/src/template-resolver/web/i18n.ts +7 -0
  91. package/packages/bundled-features/src/template-resolver/web/index.ts +1 -0
  92. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +14 -5
  93. package/packages/bundled-features/src/tenant/changes.json +6 -0
  94. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
  95. package/packages/bundled-features/src/tenant/seeding.ts +33 -27
  96. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
  97. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  98. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
  99. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -2
  100. package/packages/bundled-features/src/tier-engine/changes.json +12 -0
  101. package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
  102. package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
  103. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
  104. package/packages/bundled-features/src/tier-engine/index.ts +2 -0
  105. package/packages/bundled-features/src/user/constants.ts +1 -0
  106. package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
  107. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  108. package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
  109. package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
  110. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
  111. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
  112. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  113. package/packages/bundled-features/src/waitlist/feature.ts +1 -1
  114. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
  115. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
  116. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
  117. package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
  118. package/packages/bundled-features/src/waitlist/lib.ts +20 -0
  119. package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
  120. package/samples/apps/ui-walkthrough/src/run-config.ts +14 -1
  121. package/samples/apps/use-all-bundled/README.md +3 -13
  122. package/samples/apps/use-all-bundled/feature-manifest.json +3 -3
  123. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
  124. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
  125. package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
  126. package/samples/recipes/auth-mfa/README.md +2 -1
  127. package/samples/recipes/auth-mfa/src/feature.ts +1 -0
  128. package/samples/recipes/custom-handlers/README.md +4 -3
  129. package/samples/recipes/custom-handlers/src/feature.ts +21 -12
  130. package/samples/recipes/dashboard-stats/README.md +38 -0
  131. package/samples/recipes/dashboard-stats/package.json +8 -0
  132. package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
  133. package/samples/recipes/legal-pages/README.md +2 -2
  134. package/samples/recipes/managed-pages/README.md +2 -2
  135. package/samples/recipes/pdf-audit-export/README.md +2 -1
  136. package/samples/recipes/pdf-audit-export/package.json +1 -1
  137. package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
  138. package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
  139. package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
  140. package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
  141. package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
  142. package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
  143. package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
@@ -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(
@@ -1,5 +1,6 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type {
3
+ BeforeTransactionResult,
3
4
  HandlerContext,
4
5
  SessionUser,
5
6
  TenantId,
@@ -16,9 +17,13 @@ import type { LinkedResourceEntry } from "../linked-resources.js";
16
17
  import {
17
18
  attachmentLimitFailure,
18
19
  attachmentNeedsMailTemplate,
20
+ createPreparedBundleResourceEntry,
21
+ type PreparedTemplateFile,
22
+ prepareTemplateAttachmentBeforeTransaction,
23
+ prepareTemplateResourceBeforeTransaction,
24
+ type Release,
19
25
  readTemplateResourceMaxBytes,
20
- storeBundleAttachment,
21
- storeTemplateResource,
26
+ releaseAll,
22
27
  } from "../store-template-resource.js";
23
28
  import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
24
29
  import type { TemplateBundle } from "../template-bundle.js";
@@ -92,64 +97,101 @@ async function collectBundleIssues(
92
97
  return issues;
93
98
  }
94
99
 
95
- async function storeBundleResources(
96
- args: BundleImport,
97
- ): Promise<WriteResult<Record<string, LinkedResourceEntry>>> {
98
- const linked: Record<string, LinkedResourceEntry> = {};
99
- const pdfLinks = Object.entries(args.bundle.pdfAttachments);
100
+ export type PreparedBundleResources = ReadonlyMap<string, PreparedTemplateFile>;
101
+
102
+ function checkBundleAttachments(bundle: TemplateBundle): WriteResult<null> {
100
103
  const attachmentCount =
101
- Object.values(args.bundle.resources).filter((resource) => resource.purpose === "attachment")
102
- .length + pdfLinks.length;
103
- if (attachmentCount > 0 && args.bundle.kind !== "mail") return attachmentNeedsMailTemplate();
104
+ Object.values(bundle.resources).filter((resource) => resource.purpose === "attachment").length +
105
+ Object.keys(bundle.pdfAttachments).length;
106
+ if (attachmentCount > 0 && bundle.kind !== "mail") return attachmentNeedsMailTemplate();
104
107
  if (attachmentCount > MAX_FILE_ATTACHMENTS_PER_TEMPLATE) return attachmentLimitFailure();
105
- // The document template is not looked up: a bundle may carry it later than the mail.
106
- for (const [key, link] of pdfLinks) linked[key] = { kind: "rendered-pdf", ...link };
108
+ return { isSuccess: true, data: null };
109
+ }
110
+
111
+ // beforeTransaction half: validates the bundle and uploads every resource file to the target
112
+ // tenant's storage. A failed or throwing upload releases the ones before it.
113
+ export async function prepareTemplateBundleBeforeTransaction(
114
+ args: BundleImport & { readonly status: BundleImportStatus },
115
+ ): Promise<BeforeTransactionResult<PreparedBundleResources>> {
116
+ const refused = refuseTenantScopeUnderSystemTenant(args.scope, args.tenantId);
117
+ if (refused !== null) return refused;
118
+ const issues = await collectBundleIssues(args, args.status);
119
+ if (issues.length > 0) return writeFailure(new ValidationError({ fields: [...issues] }));
120
+ const attachmentCheck = checkBundleAttachments(args.bundle);
121
+ if (!attachmentCheck.isSuccess) return attachmentCheck;
122
+
107
123
  const maxBytes = await readTemplateResourceMaxBytes(args.ctx.config, args.ctx.registry);
108
- // A bundle is untrusted input like an upload: the same re-encoding decides the stored
109
- // content type, so a declared image/png can't smuggle SVG or HTML into a mail or PDF.
110
- for (const [key, resource] of Object.entries(args.bundle.resources)) {
111
- const bytes = Uint8Array.from(Buffer.from(resource.base64, "base64"));
112
- if (resource.purpose === "attachment") {
113
- const attachment = await storeBundleAttachment({
124
+ const prepared = new Map<string, PreparedTemplateFile>();
125
+ const releases: Release[] = [];
126
+ try {
127
+ // A bundle is untrusted input like an upload: the same re-encoding decides the stored
128
+ // content type, so a declared image/png can't smuggle SVG or HTML into a mail or PDF.
129
+ for (const [key, resource] of Object.entries(args.bundle.resources)) {
130
+ const bytes = Uint8Array.from(Buffer.from(resource.base64, "base64"));
131
+ const common = {
114
132
  ctx: args.ctx,
115
133
  user: args.user,
116
134
  tenantId: args.tenantId,
117
135
  bytes,
118
136
  maxBytes,
119
137
  key,
120
- fileName: resource.fileName,
121
- });
122
- if (!attachment.isSuccess) return attachment;
123
- linked[key] = attachment.data;
124
- continue;
138
+ };
139
+ const result =
140
+ resource.purpose === "attachment"
141
+ ? await prepareTemplateAttachmentBeforeTransaction({
142
+ ...common,
143
+ fileName: resource.fileName,
144
+ })
145
+ : await prepareTemplateResourceBeforeTransaction(common);
146
+ if (!result.isSuccess) {
147
+ await releaseAll(releases);
148
+ return result;
149
+ }
150
+ if (result.release) releases.push(result.release);
151
+ prepared.set(key, result.data);
125
152
  }
126
- const stored = await storeTemplateResource({
127
- ctx: args.ctx,
128
- user: args.user,
129
- tenantId: args.tenantId,
130
- bytes,
131
- maxBytes,
132
- key,
133
- });
134
- if (!stored.isSuccess) return stored;
135
- linked[key] = stored.data.fileRefId;
153
+ } catch (cause) {
154
+ await releaseAll(releases);
155
+ throw cause;
156
+ }
157
+ return { isSuccess: true, data: prepared, release: () => releaseAll(releases) };
158
+ }
159
+
160
+ async function linkPreparedBundleResources(
161
+ args: BundleImport,
162
+ prepared: PreparedBundleResources,
163
+ ): Promise<WriteResult<Record<string, LinkedResourceEntry>>> {
164
+ const linked: Record<string, LinkedResourceEntry> = {};
165
+ // The document template is not looked up: a bundle may carry it later than the mail.
166
+ for (const [key, link] of Object.entries(args.bundle.pdfAttachments)) {
167
+ linked[key] = { kind: "rendered-pdf", ...link };
168
+ }
169
+ for (const [key, file] of prepared) {
170
+ const entry = await createPreparedBundleResourceEntry(
171
+ { ctx: args.ctx, user: args.user, tenantId: args.tenantId },
172
+ file,
173
+ );
174
+ if (!entry.isSuccess) return entry;
175
+ linked[key] = entry.data;
136
176
  }
137
177
  return { isSuccess: true, data: linked };
138
178
  }
139
179
 
140
- // Validates every locale first, stores the resource files, then writes one row
141
- // per locale, so a bad locale never leaves files or half a bundle behind. An image
142
- // that fails re-encoding stops the import before any row is written; files stored
143
- // for the resources before it stay as unlinked FileRefs, like a replaced upload.
180
+ // Validates every locale first, so a bad locale is refused before anything is written.
181
+ // The resource files are uploaded in the beforeTransaction phase; a rolled-back transaction
182
+ // releases them there. A crash between the upload and the commit leaves the binaries unreferenced.
144
183
  export async function importTemplateBundle(
145
- args: BundleImport & { readonly status: BundleImportStatus },
184
+ args: BundleImport & {
185
+ readonly status: BundleImportStatus;
186
+ readonly prepared: PreparedBundleResources;
187
+ },
146
188
  ): Promise<WriteResult<{ ids: Record<string, string> }>> {
147
189
  const refused = refuseTenantScopeUnderSystemTenant(args.scope, args.tenantId);
148
190
  if (refused !== null) return refused;
149
191
  const issues = await collectBundleIssues(args, args.status);
150
192
  if (issues.length > 0) return writeFailure(new ValidationError({ fields: [...issues] }));
151
193
 
152
- const linked = await storeBundleResources(args);
194
+ const linked = await linkPreparedBundleResources(args, args.prepared);
153
195
  if (!linked.isSuccess) return linked;
154
196
 
155
197
  const variableSchema = args.bundle.variableSchema ?? {};
@@ -7,7 +7,7 @@ import {
7
7
  import { ValidationError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
8
8
  import * as z from "zod";
9
9
  import { requireForTenant } from "../../shared/index.js";
10
- import { COLLECTION_REFUSED_KINDS } from "../constants.js";
10
+ import { isCollectionRefusedKind } from "../constants.js";
11
11
  import { documentViolationsToFieldIssues, sanitizeTemplateBody } from "../document-sanitizer.js";
12
12
  import {
13
13
  type CollectionEntryRow,
@@ -43,12 +43,13 @@ export function makeCollectionSetWrite(collection: ContentCollectionDefinition)
43
43
  ? `Creates or overwrites the caller's own "${collection.id}" entry at a slug and locale, live on save with no draft stage; it always writes the acting user's entry, never another user's.`
44
44
  : `Creates or overwrites the tenant-wide "${collection.id}" entry at a slug and locale, live on save with no draft stage; the change is visible to everyone who may reach this shared collection.`,
45
45
  handler: async (event, ctx) => {
46
- if (COLLECTION_REFUSED_KINDS.includes(collection.kind)) {
46
+ // Defense in depth: createTemplateResolverFeature already refuses these kinds at mount.
47
+ if (isCollectionRefusedKind(collection.kind)) {
47
48
  return writeFailure(
48
49
  new ValidationError({
49
50
  fields: [
50
51
  {
51
- path: "kind",
52
+ path: "",
52
53
  code: "kind_not_allowed",
53
54
  i18nKey: "templateResolver.errors.collection.kindNotAllowed",
54
55
  params: { detail: collection.kind },