@cosmicdrift/kumiko-samples 0.363.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 (237) hide show
  1. package/LICENSE +1 -1
  2. package/package.json +1 -1
  3. package/packages/bundled-features/package.json +7 -1
  4. package/packages/bundled-features/src/audit/changes.json +18 -0
  5. package/packages/bundled-features/src/audit/feature.ts +1 -1
  6. package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
  7. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
  8. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +30 -11
  9. package/packages/bundled-features/src/auth-email-password/changes.json +55 -0
  10. package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
  11. package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
  12. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
  13. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
  14. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
  15. package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
  16. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
  17. package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
  18. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
  19. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
  20. package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
  21. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
  22. package/packages/bundled-features/src/billing-foundation/changes.json +48 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
  28. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
  29. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +18 -2
  30. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
  31. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
  32. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
  33. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
  34. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
  35. package/packages/bundled-features/src/billing-foundation/i18n.ts +9 -0
  36. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +27 -7
  37. package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
  38. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
  39. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
  40. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
  41. package/packages/bundled-features/src/cap-counter/changes.json +19 -0
  42. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
  43. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
  44. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  45. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
  46. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  47. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
  48. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
  49. package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
  50. package/packages/bundled-features/src/channel-email/changes.json +18 -0
  51. package/packages/bundled-features/src/channel-email/email-channel.ts +47 -24
  52. package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
  53. package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
  54. package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
  55. package/packages/bundled-features/src/config/changes.json +6 -0
  56. package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
  57. package/packages/bundled-features/src/config/resolver.ts +15 -12
  58. package/packages/bundled-features/src/config/write-helpers.ts +23 -0
  59. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  60. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +54 -27
  61. package/packages/bundled-features/src/custom-fields/changes.json +6 -0
  62. package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
  63. package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
  64. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
  65. package/packages/bundled-features/src/delivery/attachment-ref.ts +39 -1
  66. package/packages/bundled-features/src/delivery/changes.json +30 -0
  67. package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
  68. package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
  69. package/packages/bundled-features/src/delivery/index.ts +2 -0
  70. package/packages/bundled-features/src/delivery/jobs.ts +51 -14
  71. package/packages/bundled-features/src/delivery/types.ts +7 -1
  72. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  73. package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
  74. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
  75. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
  76. package/packages/bundled-features/src/file-foundation/changes.json +6 -0
  77. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
  78. package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
  79. package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
  80. package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
  81. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
  82. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  83. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  84. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
  85. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
  86. package/packages/bundled-features/src/jobs/changes.json +31 -0
  87. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
  88. package/packages/bundled-features/src/jobs/feature.ts +7 -4
  89. package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
  90. package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
  91. package/packages/bundled-features/src/jobs/job-run-logger.ts +56 -18
  92. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
  93. package/packages/bundled-features/src/ledger/changes.json +7 -0
  94. package/packages/bundled-features/src/ledger/entity.ts +3 -1
  95. package/packages/bundled-features/src/metrics/changes.json +6 -0
  96. package/packages/bundled-features/src/metrics/filters.ts +28 -12
  97. package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
  98. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
  99. package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
  100. package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
  101. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
  102. package/packages/bundled-features/src/secrets/changes.json +12 -0
  103. package/packages/bundled-features/src/secrets/feature.ts +4 -3
  104. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
  105. package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
  106. package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
  107. package/packages/bundled-features/src/shared/changes.json +6 -0
  108. package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
  109. package/packages/bundled-features/src/shared/index.ts +8 -0
  110. package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
  111. package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
  112. package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
  113. package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -2
  114. package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
  115. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
  116. package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
  117. package/packages/bundled-features/src/template-resolver/README.md +4 -2
  118. package/packages/bundled-features/src/template-resolver/api.ts +3 -2
  119. package/packages/bundled-features/src/template-resolver/changes.json +149 -0
  120. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +148 -18
  121. package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
  122. package/packages/bundled-features/src/template-resolver/constants.ts +23 -3
  123. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
  124. package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -5
  125. package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
  126. package/packages/bundled-features/src/template-resolver/feature.ts +16 -1
  127. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
  128. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
  129. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +121 -53
  130. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +6 -44
  131. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
  132. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
  133. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
  134. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
  135. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
  136. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
  137. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
  138. package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
  139. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +102 -19
  140. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +49 -10
  141. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
  142. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
  143. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
  144. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
  145. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
  146. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
  147. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
  148. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
  149. package/packages/bundled-features/src/template-resolver/index.ts +18 -2
  150. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +56 -0
  151. package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
  152. package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
  153. package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
  154. package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
  155. package/packages/bundled-features/src/template-resolver/seeding.ts +27 -4
  156. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
  157. package/packages/bundled-features/src/template-resolver/table.ts +13 -1
  158. package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
  159. package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
  160. package/packages/bundled-features/src/template-resolver/template-meta.ts +95 -24
  161. package/packages/bundled-features/src/template-resolver/template-preview.ts +160 -57
  162. package/packages/bundled-features/src/template-resolver/template-resources.ts +3 -4
  163. package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
  164. package/packages/bundled-features/src/template-resolver/template-validation.ts +16 -9
  165. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
  166. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +97 -10
  167. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +15 -5
  168. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +405 -145
  169. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +17 -3
  170. package/packages/bundled-features/src/template-resolver/web/i18n.ts +32 -4
  171. package/packages/bundled-features/src/template-resolver/web/index.ts +8 -0
  172. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +23 -7
  173. package/packages/bundled-features/src/tenant/changes.json +6 -0
  174. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
  175. package/packages/bundled-features/src/tenant/seeding.ts +33 -27
  176. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
  177. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  178. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
  179. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -2
  180. package/packages/bundled-features/src/tier-engine/changes.json +18 -0
  181. package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
  182. package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
  183. package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
  184. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
  185. package/packages/bundled-features/src/tier-engine/index.ts +2 -0
  186. package/packages/bundled-features/src/user/changes.json +6 -0
  187. package/packages/bundled-features/src/user/constants.ts +1 -0
  188. package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
  189. package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
  190. package/packages/bundled-features/src/user/seeding.ts +19 -5
  191. package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
  192. package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
  193. package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
  194. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
  195. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
  196. package/packages/bundled-features/src/user-profile/changes.json +1 -1
  197. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  198. package/packages/bundled-features/src/waitlist/feature.ts +1 -1
  199. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
  200. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
  201. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
  202. package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
  203. package/packages/bundled-features/src/waitlist/lib.ts +20 -0
  204. package/packages/bundled-features/src/waitlist/options.ts +1 -1
  205. package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
  206. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
  207. package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
  208. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
  209. package/samples/apps/ui-walkthrough/package.json +9 -0
  210. package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
  211. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
  212. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
  213. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
  214. package/samples/apps/ui-walkthrough/src/run-config.ts +18 -1
  215. package/samples/apps/use-all-bundled/README.md +3 -13
  216. package/samples/apps/use-all-bundled/feature-manifest.json +7 -5
  217. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
  218. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
  219. package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
  220. package/samples/recipes/auth-mfa/README.md +2 -1
  221. package/samples/recipes/auth-mfa/src/feature.ts +1 -0
  222. package/samples/recipes/custom-handlers/README.md +4 -3
  223. package/samples/recipes/custom-handlers/src/feature.ts +21 -12
  224. package/samples/recipes/dashboard-stats/README.md +38 -0
  225. package/samples/recipes/dashboard-stats/package.json +8 -0
  226. package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
  227. package/samples/recipes/legal-pages/README.md +2 -2
  228. package/samples/recipes/managed-pages/README.md +2 -2
  229. package/samples/recipes/pdf-audit-export/README.md +2 -1
  230. package/samples/recipes/pdf-audit-export/package.json +1 -1
  231. package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
  232. package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
  233. package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
  234. package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
  235. package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
  236. package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
  237. package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
@@ -1,4 +1,8 @@
1
- import { parseQn, type Registry } from "@cosmicdrift/kumiko-framework/engine";
1
+ import {
2
+ type NotificationDefinition,
3
+ parseQn,
4
+ type Registry,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
2
6
  import { DEFAULT_MAIL_LAYOUT_SLUG, type TemplateKind } from "./constants.js";
3
7
 
4
8
  type UsageRole = "template" | "layout";
@@ -11,27 +15,43 @@ export type TemplateUsageEntry = {
11
15
  readonly role: UsageRole;
12
16
  };
13
17
 
18
+ /** Layout slug of the effective stored mail template, by notification qualified
19
+ * name and locale. The renderer reads the layout from the stored row's meta,
20
+ * so it wins over the code-registered notification meta. */
21
+ export type EffectiveMailLayoutSlugs = ReadonlyMap<string, ReadonlyMap<string, string>>;
22
+
23
+ function layoutSlugsOf(
24
+ qualifiedName: string,
25
+ notification: NotificationDefinition,
26
+ effectiveLayouts: EffectiveMailLayoutSlugs,
27
+ ): string[] {
28
+ const stored = effectiveLayouts.get(qualifiedName);
29
+ const locales = new Set([
30
+ ...Object.keys(notification.template ?? {}),
31
+ ...Object.keys(notification.meta ?? {}),
32
+ ...(stored?.keys() ?? []),
33
+ ]);
34
+ return [...locales].map(
35
+ (locale) =>
36
+ stored?.get(locale) ?? notification.meta?.[locale]?.layoutSlug ?? DEFAULT_MAIL_LAYOUT_SLUG,
37
+ );
38
+ }
39
+
14
40
  export function collectTemplateUsage(
15
41
  registry: Registry,
16
42
  slug: string,
17
43
  kind: TemplateKind,
44
+ effectiveLayouts: EffectiveMailLayoutSlugs = new Map(),
18
45
  ): TemplateUsageEntry[] {
19
46
  const usedBy: TemplateUsageEntry[] = [];
20
47
  if (kind === "mail" || kind === "mail-layout") {
21
48
  for (const [qualifiedName, notification] of registry.getAllNotifications()) {
22
49
  const role: UsageRole = kind === "mail" ? "template" : "layout";
50
+ // Notifications without a code template are not rendered through the resolver.
23
51
  const isUsed =
24
52
  kind === "mail"
25
- ? qualifiedName === slug
26
- : [
27
- ...new Set([
28
- ...Object.keys(notification.template ?? {}),
29
- ...Object.keys(notification.meta ?? {}),
30
- ]),
31
- ].some(
32
- (locale) =>
33
- (notification.meta?.[locale]?.layoutSlug ?? DEFAULT_MAIL_LAYOUT_SLUG) === slug,
34
- );
53
+ ? qualifiedName === slug && notification.template !== undefined
54
+ : layoutSlugsOf(qualifiedName, notification, effectiveLayouts).includes(slug);
35
55
  if (!isUsed) continue;
36
56
  const parsed = parseQn(qualifiedName);
37
57
  usedBy.push({
@@ -47,6 +47,7 @@ const textLikeExample = z.string().optional();
47
47
  const scalarVariableSchema = z.discriminatedUnion("type", [
48
48
  z.object({ ...variableCommon, type: z.literal("text"), example: textLikeExample }),
49
49
  z.object({ ...variableCommon, type: z.literal("link"), example: textLikeExample }),
50
+ z.object({ ...variableCommon, type: z.literal("richText"), example: textLikeExample }),
50
51
  z.object({
51
52
  ...variableCommon,
52
53
  type: z.literal("money"),
@@ -127,12 +128,10 @@ function isKnownName(
127
128
  ): Resolution {
128
129
  const { variables } = scope;
129
130
  const root = rootName(name);
130
- if (Object.hasOwn(itemFields, name) || Object.hasOwn(itemFields, root)) {
131
- return { known: true, used: "" };
132
- }
131
+ // Declared variables and item fields are scalars: a dotted path under one never renders a value.
132
+ if (Object.hasOwn(itemFields, name)) return { known: true, used: "" };
133
133
  if (scope.systemPlaceholders.has(root)) return { known: true, used: "" };
134
134
  if (Object.hasOwn(variables, name)) return { known: true, used: name };
135
- if (Object.hasOwn(variables, root)) return { known: true, used: root };
136
135
  return { known: false, used: "" };
137
136
  }
138
137
 
@@ -150,6 +149,11 @@ function enclosingEachName(element: ScanElement): string | null {
150
149
  return element.closest("tr[data-each]")?.getAttribute("data-each") ?? null;
151
150
  }
152
151
 
152
+ const NAME_ATTRIBUTE_BY_TAG: Readonly<Record<string, string>> = {
153
+ span: "data-variable",
154
+ div: "data-steps",
155
+ };
156
+
153
157
  function scanBodyPlaceholders(body: string, scope: PlaceholderScope): void {
154
158
  withParsedDocument(body, (root) => {
155
159
  for (const element of Array.from(root.querySelectorAll("tr[data-each]"))) {
@@ -159,12 +163,13 @@ function scanBodyPlaceholders(body: string, scope: PlaceholderScope): void {
159
163
  else scope.unknown.add(listName);
160
164
  }
161
165
  for (const element of Array.from(
162
- root.querySelectorAll("span[data-variable], a[href], img[data-resource]"),
166
+ root.querySelectorAll("span[data-variable], div[data-steps], a[href], img[data-resource]"),
163
167
  )) {
164
168
  const itemFields = listItemFields(scope.variables, enclosingEachName(element));
165
169
  const tag = element.tagName.toLowerCase();
166
- if (tag === "span") {
167
- recordName(element.getAttribute("data-variable") ?? "", itemFields, scope);
170
+ const nameAttribute = NAME_ATTRIBUTE_BY_TAG[tag];
171
+ if (nameAttribute !== undefined) {
172
+ recordName(element.getAttribute(nameAttribute) ?? "", itemFields, scope);
168
173
  } else if (tag === "a") {
169
174
  const href = element.getAttribute("href") ?? "";
170
175
  if (isDocumentPlaceholderHref(href)) recordName(href.slice(2, -2), itemFields, scope);
@@ -178,7 +183,7 @@ function scanBodyPlaceholders(body: string, scope: PlaceholderScope): void {
178
183
 
179
184
  const HANDLEBARS_EXPRESSION = /\{\{([^{}]*)\}\}/g;
180
185
 
181
- // Meta text supports only plain `{{name}}` / `{{name.field}}`; every other
186
+ // Meta text supports only plain `{{name}}` references; every other
182
187
  // Handlebars construct is reported as unknown so it can never reach the compiler.
183
188
  function scanMetaPlaceholders(text: string, scope: PlaceholderScope): void {
184
189
  const leftover = text.replace(HANDLEBARS_EXPRESSION, (_match, inner: string) => {
@@ -192,7 +197,9 @@ function scanMetaPlaceholders(text: string, scope: PlaceholderScope): void {
192
197
 
193
198
  function metaTexts(meta: TemplateMeta | null): readonly string[] {
194
199
  if (meta === null || !("subject" in meta)) return [];
195
- return [meta.subject, ...(meta.previewText === undefined ? [] : [meta.previewText])];
200
+ return [meta.subject, meta.previewText, meta.headerChip, meta.tone, meta.footerNote].filter(
201
+ (text): text is string => text !== undefined,
202
+ );
196
203
  }
197
204
 
198
205
  export function validateTemplatePlaceholders(input: {
@@ -40,7 +40,8 @@ import {
40
40
  TemplateResolverHandlers,
41
41
  TemplateResolverQueries,
42
42
  } from "../qualified-names.js";
43
- import { DocumentContentEditor } from "./document-editor.js";
43
+ import { DocumentBlocksContext, DocumentContentEditor } from "./document-editor.js";
44
+ import { documentBlocksForKind } from "./document-toolbar-blocks.js";
44
45
  import { defaultTranslations } from "./i18n.js";
45
46
 
46
47
  // Exported for the unit test — groupBlocksByFolder is a pure function.
@@ -311,6 +312,7 @@ function TextBlockEditor({
311
312
  const contentFormat = findContentFormat(features, collectionId);
312
313
  const variableExamples = findVariableExamples(features, collectionId);
313
314
  const variables = Object.keys(variableExamples);
315
+ const documentBlocks = documentBlocksForKind(findCollection(features, collectionId)?.kind);
314
316
  const ContentEditor = useContentEditor(contentFormat);
315
317
  const canWrite =
316
318
  user?.roles.includes("TenantAdmin") === true || user?.roles.includes("SystemAdmin") === true;
@@ -448,13 +450,15 @@ function TextBlockEditor({
448
450
  contentFormat={contentFormat}
449
451
  />
450
452
  ) : (
451
- <ContentEditor
452
- id={contentEditorId}
453
- value={content}
454
- onChange={setContent}
455
- variables={variables}
456
- readOnly={disabled}
457
- />
453
+ <DocumentBlocksContext.Provider value={documentBlocks}>
454
+ <ContentEditor
455
+ id={contentEditorId}
456
+ value={content}
457
+ onChange={setContent}
458
+ variables={variables}
459
+ readOnly={disabled}
460
+ />
461
+ </DocumentBlocksContext.Provider>
458
462
  )}
459
463
  </Field>
460
464
  {saveError !== null && <Banner variant="error">{saveError}</Banner>}
@@ -2,20 +2,29 @@
2
2
  import type { Extensions, NodeViewRenderer } from "@tiptap/core";
3
3
  import { ListKeymap } from "@tiptap/extension-list";
4
4
  import { Dropcursor, Gapcursor, Placeholder, UndoRedo } from "@tiptap/extensions";
5
+ import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
5
6
  import {
6
7
  DocumentButton,
7
8
  DocumentPageBreak,
8
9
  DocumentResource,
10
+ DocumentSteps,
9
11
  DocumentVariable,
10
12
  documentExtensions,
11
13
  } from "../document-schema.js";
14
+ import { readDocumentStepsAttribute } from "../document-steps.js";
12
15
  import type { DocumentResourcePreview } from "./document-toolbar-blocks.js";
13
16
 
17
+ const DOCUMENT_RESOURCE_SIZE_TOKEN = "{size}";
18
+ export const DOCUMENT_BLOCK_ATTRIBUTE = "data-document-block";
19
+
14
20
  export type DocumentEditorLabels = {
15
21
  readonly placeholder: string;
16
22
  readonly image: string;
17
23
  readonly pageBreak: string;
18
24
  readonly button?: string;
25
+ readonly steps?: string;
26
+ /** Canvas block label of an image, e.g. "Image · inline". */
27
+ readonly imageBlock?: string;
19
28
  readonly variableLabels?: Readonly<Record<string, string>>;
20
29
  readonly systemVariables?: readonly string[];
21
30
  readonly resourcePreviews?: Readonly<Record<string, DocumentResourcePreview>>;
@@ -38,22 +47,36 @@ const CHIP_CLASS = "mx-0.5 inline-flex items-center gap-1 rounded border px-1.5
38
47
  const USER_CHIP_CLASS = `${CHIP_CLASS} border-primary/30 bg-primary/10 text-primary`;
39
48
  const SYSTEM_CHIP_CLASS = `${CHIP_CLASS} border-input bg-muted text-muted-foreground`;
40
49
 
50
+ // A chip inside a repeat row names an item field; its label is registered as `list.field`.
51
+ function repeatRowListAt(doc: ProseMirrorNode, position: number | undefined): string | null {
52
+ if (position === undefined) return null;
53
+ const resolved = doc.resolve(position);
54
+ for (let depth = resolved.depth; depth > 0; depth--) {
55
+ const each: unknown = resolved.node(depth).attrs["each"];
56
+ if (typeof each === "string" && each !== "") return each;
57
+ }
58
+ return null;
59
+ }
60
+
41
61
  // The chip is display only: the stored node carries just the name, so a
42
62
  // label never reaches the document.
43
63
  export function createVariableView(
44
64
  variableLabels: Readonly<Record<string, string>>,
45
65
  systemVariables: readonly string[],
66
+ labelOnly = false,
46
67
  ): NodeViewRenderer {
47
- return ({ node }) => {
68
+ return ({ node, editor, getPos }) => {
48
69
  const name = String(node.attrs["name"]);
49
- const label = Object.hasOwn(variableLabels, name) ? variableLabels[name] : undefined;
70
+ const listName = repeatRowListAt(editor.state.doc, getPos());
71
+ const labelKey = listName === null ? name : `${listName}.${name}`;
72
+ const label = Object.hasOwn(variableLabels, labelKey) ? variableLabels[labelKey] : undefined;
50
73
  const dom = createElementWithClass(
51
74
  "span",
52
75
  systemVariables.includes(name) ? SYSTEM_CHIP_CLASS : USER_CHIP_CLASS,
53
76
  );
54
77
  dom.contentEditable = "false";
55
78
  dom.append(document.createTextNode(label !== undefined && label !== "" ? label : name));
56
- if (label !== undefined && label !== "" && label !== name) {
79
+ if (!labelOnly && label !== undefined && label !== "" && label !== name) {
57
80
  dom.append(createElementWithClass("small", "font-mono text-[10px] opacity-75", name));
58
81
  }
59
82
  return { dom };
@@ -79,18 +102,46 @@ function createResourceView(labels: DocumentEditorLabels): NodeViewRenderer {
79
102
  const key = String(node.attrs["resource"]);
80
103
  const previews = labels.resourcePreviews ?? {};
81
104
  const preview = Object.hasOwn(previews, key) ? previews[key] : undefined;
105
+ const hasImage = preview !== undefined && PREVIEW_SRC.test(preview.src);
106
+ // A rendered image needs no frame until the node is selected (ProseMirror marks it).
82
107
  const dom = createElementWithClass(
83
108
  "div",
84
- "relative my-3 flex items-center gap-3 rounded border border-dashed border-input px-3 py-3 text-sm text-muted-foreground",
109
+ hasImage
110
+ ? "relative my-1 flex items-center gap-2 rounded border border-transparent px-2 py-1 text-sm text-muted-foreground [.ProseMirror-focused_&.ProseMirror-selectednode]:border-dashed [.ProseMirror-focused_&.ProseMirror-selectednode]:border-input"
111
+ : "relative my-3 flex items-center gap-3 rounded border border-dashed border-input px-3 py-3 text-sm text-muted-foreground",
85
112
  );
113
+ dom.setAttribute(DOCUMENT_BLOCK_ATTRIBUTE, "");
86
114
  dom.contentEditable = "false";
87
- if (labels.blockLabels) dom.append(createBlockLabel(labels.image));
88
- if (preview !== undefined && PREVIEW_SRC.test(preview.src)) {
89
- const image = createElementWithClass("img", "max-h-16 max-w-48 rounded");
115
+ if (labels.blockLabels) {
116
+ const label = createBlockLabel(labels.imageBlock ?? labels.image);
117
+ if (hasImage)
118
+ label.classList.add("hidden", "[.ProseMirror-focused_.ProseMirror-selectednode_&]:block");
119
+ dom.append(label);
120
+ }
121
+ if (hasImage) {
122
+ const image = createElementWithClass("img", "max-w-[180px] rounded");
90
123
  image.src = preview.src;
91
124
  image.alt = typeof node.attrs["alt"] === "string" ? node.attrs["alt"] : key;
92
125
  dom.append(image);
93
- dom.append(createElementWithClass("span", "text-xs", preview.caption ?? key));
126
+ // An empty caption means "no caption", not the key.
127
+ const caption = preview.caption ?? key;
128
+ if (caption !== "") {
129
+ const captionElement = createElementWithClass(
130
+ "span",
131
+ "whitespace-nowrap text-[11px]",
132
+ caption.replace(` · ${DOCUMENT_RESOURCE_SIZE_TOKEN}`, ""),
133
+ );
134
+ // The size is only known once the browser decoded the image (possibly already, from cache).
135
+ const showSize = (): void => {
136
+ captionElement.textContent = caption.replace(
137
+ DOCUMENT_RESOURCE_SIZE_TOKEN,
138
+ `${image.naturalWidth} × ${image.naturalHeight} px`,
139
+ );
140
+ };
141
+ image.addEventListener("load", showSize);
142
+ if (image.complete && image.naturalWidth > 0) showSize();
143
+ dom.append(captionElement);
144
+ }
94
145
  } else {
95
146
  dom.append(
96
147
  createElementWithClass("span", "font-medium", labels.image),
@@ -107,6 +158,7 @@ function createButtonView(buttonLabel: string): NodeViewRenderer {
107
158
  "div",
108
159
  "relative my-3 rounded border border-dashed border-input p-3",
109
160
  );
161
+ dom.setAttribute(DOCUMENT_BLOCK_ATTRIBUTE, "");
110
162
  const button = document.createElement("a");
111
163
  button.setAttribute("data-button", "");
112
164
  dom.append(createBlockLabel(buttonLabel), button);
@@ -118,7 +170,7 @@ function createPageBreakView(pageBreakLabel: string): NodeViewRenderer {
118
170
  return () => {
119
171
  const dom = createElementWithClass(
120
172
  "div",
121
- "my-3 border-t border-dashed border-input pt-1 text-center text-xs text-muted-foreground",
173
+ "my-3 border-t border-dashed border-input pt-1 text-center text-xs uppercase tracking-wide text-muted-foreground",
122
174
  pageBreakLabel,
123
175
  );
124
176
  dom.contentEditable = "false";
@@ -126,6 +178,35 @@ function createPageBreakView(pageBreakLabel: string): NodeViewRenderer {
126
178
  };
127
179
  }
128
180
 
181
+ // A static sketch of the row: which stage is reached is only known at render time.
182
+ function createStepsView(labels: DocumentEditorLabels): NodeViewRenderer {
183
+ return ({ node }) => {
184
+ const dom = createElementWithClass(
185
+ "div",
186
+ "relative my-3 rounded border border-dashed border-input px-3 pb-2 pt-3",
187
+ );
188
+ dom.setAttribute(DOCUMENT_BLOCK_ATTRIBUTE, "");
189
+ dom.contentEditable = "false";
190
+ const variable = String(node.attrs["variable"]);
191
+ const variableLabels = labels.variableLabels ?? {};
192
+ const variableLabel = Object.hasOwn(variableLabels, variable)
193
+ ? variableLabels[variable]
194
+ : undefined;
195
+ dom.append(createBlockLabel(`${labels.steps ?? "Steps"} · ${variableLabel ?? variable}`));
196
+ const row = createElementWithClass("div", "flex gap-1.5");
197
+ for (const step of readDocumentStepsAttribute(node.attrs["steps"])) {
198
+ const cell = createElementWithClass("div", "min-w-0 flex-1");
199
+ cell.append(
200
+ createElementWithClass("div", "h-1 rounded-full bg-primary/40"),
201
+ createElementWithClass("div", "truncate pt-1.5 text-xs text-muted-foreground", step.label),
202
+ );
203
+ row.append(cell);
204
+ }
205
+ dom.append(row);
206
+ return { dom };
207
+ };
208
+ }
209
+
129
210
  // Replaced by name, not appended: the schema must keep exactly one node per
130
211
  // name, and only the editor view differs from the shared document schema.
131
212
  export function createDocumentEditorExtensions(labels: DocumentEditorLabels): Extensions {
@@ -134,7 +215,11 @@ export function createDocumentEditorExtensions(labels: DocumentEditorLabels): Ex
134
215
  case DocumentVariable.name:
135
216
  return DocumentVariable.extend({
136
217
  addNodeView: () =>
137
- createVariableView(labels.variableLabels ?? {}, labels.systemVariables ?? []),
218
+ createVariableView(
219
+ labels.variableLabels ?? {},
220
+ labels.systemVariables ?? [],
221
+ labels.blockLabels === true,
222
+ ),
138
223
  });
139
224
  case DocumentResource.name:
140
225
  return DocumentResource.extend({ addNodeView: () => createResourceView(labels) });
@@ -142,6 +227,8 @@ export function createDocumentEditorExtensions(labels: DocumentEditorLabels): Ex
142
227
  return labels.blockLabels
143
228
  ? DocumentButton.extend({ addNodeView: () => createButtonView(labels.button ?? "") })
144
229
  : extension;
230
+ case DocumentSteps.name:
231
+ return DocumentSteps.extend({ addNodeView: () => createStepsView(labels) });
145
232
  case DocumentPageBreak.name:
146
233
  return DocumentPageBreak.extend({
147
234
  addNodeView: () => createPageBreakView(labels.pageBreak),
@@ -4,25 +4,35 @@
4
4
  // Never import document-tiptap-editor or document-editor-extensions statically here.
5
5
 
6
6
  import { TextareaContentEditor } from "@cosmicdrift/kumiko-renderer";
7
- import { lazy, type ReactNode, Suspense } from "react";
8
- import type { DocumentEditorProps } from "./document-toolbar-blocks.js";
7
+ import { createContext, lazy, type ReactNode, Suspense, useContext } from "react";
8
+ import type { DocumentEditorProps, DocumentToolbarBlock } from "./document-toolbar-blocks.js";
9
9
 
10
10
  const LazyDocumentTiptapEditor = lazy(() => import("./document-tiptap-editor.js"));
11
11
 
12
+ // The editor registry only hands ContentEditorProps to an editor, so the
13
+ // collection's block set (mail vs. PDF) reaches it through this context. An
14
+ // explicit `blocks` prop still wins.
15
+ export const DocumentBlocksContext = createContext<readonly DocumentToolbarBlock[] | undefined>(
16
+ undefined,
17
+ );
18
+
12
19
  export function DocumentContentEditor(props: DocumentEditorProps): ReactNode {
20
+ const contextBlocks = useContext(DocumentBlocksContext);
21
+ const editorProps = props.blocks === undefined ? { ...props, blocks: contextBlocks } : props;
13
22
  return (
14
23
  <Suspense
24
+ // Read-only without chips: a textarea would insert {{name}} text and plaintext into a document body.
15
25
  fallback={
16
26
  <TextareaContentEditor
17
27
  id={props.id}
18
28
  value={props.value}
19
29
  onChange={props.onChange}
20
- variables={props.variables}
21
- readOnly={props.readOnly}
30
+ variables={[]}
31
+ readOnly
22
32
  />
23
33
  }
24
34
  >
25
- <LazyDocumentTiptapEditor {...props} />
35
+ <LazyDocumentTiptapEditor {...editorProps} />
26
36
  </Suspense>
27
37
  );
28
38
  }