@cosmicdrift/kumiko-samples 0.393.0 → 0.395.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 (226) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +15 -5
  3. package/packages/bundled-features/src/account-security/README.md +1 -1
  4. package/packages/bundled-features/src/account-security/changes.json +6 -0
  5. package/packages/bundled-features/src/account-security/feature.ts +9 -1
  6. package/packages/bundled-features/src/audit/event-classification.ts +1 -0
  7. package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
  8. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +10 -52
  9. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -4
  10. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +3 -12
  11. package/packages/bundled-features/src/auth-email-password/web/index.ts +2 -0
  12. package/packages/bundled-features/src/auth-email-password/web/login-failure.ts +54 -0
  13. package/packages/bundled-features/src/auth-email-password/web/login-url.ts +19 -0
  14. package/packages/bundled-features/src/auth-email-password/web/static-login.ts +183 -0
  15. package/packages/bundled-features/src/auth-mfa/web/mfa-client.ts +7 -63
  16. package/packages/bundled-features/src/billing-foundation/changes.json +30 -0
  17. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +8 -16
  18. package/packages/bundled-features/src/billing-foundation/events.ts +2 -0
  19. package/packages/bundled-features/src/billing-foundation/feature.ts +1 -1
  20. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +9 -2
  21. package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -0
  22. package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
  23. package/packages/bundled-features/src/billing-foundation/one-off-checkout-gate.ts +44 -0
  24. package/packages/bundled-features/src/billing-foundation/one-off-customer.ts +17 -0
  25. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
  26. package/packages/bundled-features/src/billing-foundation/types.ts +26 -0
  27. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +1 -0
  28. package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +8 -2
  29. package/packages/bundled-features/src/billing-foundation/web/plan-switch-recurring.ts +23 -0
  30. package/packages/bundled-features/src/billing-foundation/webhook-handler.ts +1 -0
  31. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  32. package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +3 -3
  33. package/packages/bundled-features/src/cap-overview/types.ts +2 -0
  34. package/packages/bundled-features/src/compliance-profiles/web/compliance-profile-catalog.tsx +10 -1
  35. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +12 -3
  36. package/packages/bundled-features/src/files/changes.json +18 -0
  37. package/packages/bundled-features/src/inbound-mail-foundation/account-health.ts +146 -0
  38. package/packages/bundled-features/src/inbound-mail-foundation/account-poll-schedule.ts +74 -0
  39. package/packages/bundled-features/src/inbound-mail-foundation/account-poll.ts +238 -0
  40. package/packages/bundled-features/src/inbound-mail-foundation/account-record.ts +2 -0
  41. package/packages/bundled-features/src/inbound-mail-foundation/account-wake.ts +21 -0
  42. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +143 -0
  43. package/packages/bundled-features/src/inbound-mail-foundation/connect-callback-verify.ts +65 -0
  44. package/packages/bundled-features/src/inbound-mail-foundation/connect-route-responses.ts +26 -0
  45. package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +93 -62
  46. package/packages/bundled-features/src/inbound-mail-foundation/constants.ts +66 -3
  47. package/packages/bundled-features/src/inbound-mail-foundation/content-refetch.ts +137 -0
  48. package/packages/bundled-features/src/inbound-mail-foundation/describe-error.ts +5 -0
  49. package/packages/bundled-features/src/inbound-mail-foundation/entities.ts +45 -2
  50. package/packages/bundled-features/src/inbound-mail-foundation/events.ts +69 -2
  51. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +133 -118
  52. package/packages/bundled-features/src/inbound-mail-foundation/file-storage-failure.ts +23 -0
  53. package/packages/bundled-features/src/inbound-mail-foundation/handlers/account-address.ts +79 -0
  54. package/packages/bundled-features/src/inbound-mail-foundation/handlers/acknowledge-quarantine.write.ts +55 -0
  55. package/packages/bundled-features/src/inbound-mail-foundation/handlers/attach-message-content.write.ts +184 -0
  56. package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +137 -84
  57. package/packages/bundled-features/src/inbound-mail-foundation/handlers/disconnect-account.write.ts +65 -45
  58. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-prepare.ts +177 -0
  59. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-schema.ts +42 -0
  60. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +79 -160
  61. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +93 -30
  62. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts +46 -30
  63. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-quarantine.query.ts +106 -0
  64. package/packages/bundled-features/src/inbound-mail-foundation/handlers/mark-credential-stored.write.ts +47 -0
  65. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.query.ts +99 -0
  66. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.ts +229 -0
  67. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-detail.query.ts +34 -0
  68. package/packages/bundled-features/src/inbound-mail-foundation/handlers/reactivate-account.write.ts +129 -0
  69. package/packages/bundled-features/src/inbound-mail-foundation/handlers/retry-quarantined-message.write.ts +49 -0
  70. package/packages/bundled-features/src/inbound-mail-foundation/handlers/scope-visibility.ts +48 -9
  71. package/packages/bundled-features/src/inbound-mail-foundation/handlers/set-trusted-authserv-id.write.ts +66 -0
  72. package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +136 -0
  73. package/packages/bundled-features/src/inbound-mail-foundation/handlers/trusted-authserv-id.ts +4 -0
  74. package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +82 -60
  75. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +80 -2
  76. package/packages/bundled-features/src/inbound-mail-foundation/inbound-oauth-flow.ts +48 -0
  77. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +93 -15
  78. package/packages/bundled-features/src/inbound-mail-foundation/ingest-failures.ts +137 -0
  79. package/packages/bundled-features/src/inbound-mail-foundation/ingest-payload.ts +46 -0
  80. package/packages/bundled-features/src/inbound-mail-foundation/mail-roles.ts +132 -0
  81. package/packages/bundled-features/src/inbound-mail-foundation/normalize-inbound-message.ts +119 -0
  82. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +26 -8
  83. package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachment-access.ts +28 -0
  84. package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachments.ts +111 -0
  85. package/packages/bundled-features/src/inbound-mail-foundation/outbound-mail.ts +19 -1
  86. package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +2 -1
  87. package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +33 -2
  88. package/packages/bundled-features/src/inbound-mail-foundation/provider-errors.ts +74 -0
  89. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-entities.ts +56 -0
  90. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-header-import.ts +63 -0
  91. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-retry.ts +137 -0
  92. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-screens.ts +66 -0
  93. package/packages/bundled-features/src/inbound-mail-foundation/quarantine.ts +72 -0
  94. package/packages/bundled-features/src/inbound-mail-foundation/retention-job.ts +78 -0
  95. package/packages/bundled-features/src/inbound-mail-foundation/retention-sweep.ts +43 -8
  96. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +238 -89
  97. package/packages/bundled-features/src/inbound-mail-foundation/send-from-account.ts +24 -5
  98. package/packages/bundled-features/src/inbound-mail-foundation/sender-auth.ts +215 -0
  99. package/packages/bundled-features/src/inbound-mail-foundation/supervisor-ingest.ts +311 -0
  100. package/packages/bundled-features/src/inbound-mail-foundation/sync-account-now.ts +61 -0
  101. package/packages/bundled-features/src/inbound-mail-foundation/sync-cursor-store.ts +79 -0
  102. package/packages/bundled-features/src/inbound-mail-foundation/syncable-accounts.ts +123 -0
  103. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +5 -1
  104. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +76 -105
  105. package/packages/bundled-features/src/inbound-mail-foundation/wake-consumer.ts +33 -0
  106. package/packages/bundled-features/src/inbound-mail-foundation/watch-catch-up.ts +68 -0
  107. package/packages/bundled-features/src/inbound-mail-foundation/watch-lease.ts +124 -0
  108. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor-types.ts +87 -0
  109. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +143 -433
  110. package/packages/bundled-features/src/inbound-mail-foundation/web/client-plugin.ts +73 -0
  111. package/packages/bundled-features/src/inbound-mail-foundation/web/constants.ts +7 -0
  112. package/packages/bundled-features/src/inbound-mail-foundation/web/index.ts +5 -0
  113. package/packages/bundled-features/src/inbound-provider-imap/changes.json +36 -0
  114. package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +124 -41
  115. package/packages/bundled-features/src/inbound-provider-imap/connect-my-mailbox.ts +45 -0
  116. package/packages/bundled-features/src/inbound-provider-imap/constants.ts +2 -1
  117. package/packages/bundled-features/src/inbound-provider-imap/credential-document.ts +2 -1
  118. package/packages/bundled-features/src/inbound-provider-imap/download-batch.ts +38 -0
  119. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +61 -136
  120. package/packages/bundled-features/src/inbound-provider-imap/fetch-content.ts +84 -0
  121. package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +9 -0
  122. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +35 -84
  123. package/packages/bundled-features/src/inbound-provider-imap/index.ts +1 -2
  124. package/packages/bundled-features/src/inbound-provider-imap/mime-content.ts +85 -0
  125. package/packages/bundled-features/src/inbound-provider-imap/parse-message.ts +149 -0
  126. package/packages/bundled-features/src/inbound-provider-imap/reconnect-account.ts +4 -6
  127. package/packages/bundled-features/src/inbound-provider-imap/reconnect-mailbox.ts +75 -45
  128. package/packages/bundled-features/src/inbound-provider-imap/reconnect-prefill.query.ts +19 -8
  129. package/packages/bundled-features/src/inbound-provider-imap/screens.ts +203 -109
  130. package/packages/bundled-features/src/inbound-provider-imap/send-mail.ts +11 -2
  131. package/packages/bundled-features/src/inbound-provider-imap/sender-auth-headers.ts +36 -0
  132. package/packages/bundled-features/src/inbound-provider-imap/watch-mailbox.ts +109 -0
  133. package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +75 -3
  134. package/packages/bundled-features/src/inbound-provider-inmemory/index.ts +6 -0
  135. package/packages/bundled-features/src/jobs/changes.json +18 -0
  136. package/packages/bundled-features/src/managed-pages/changes.json +7 -0
  137. package/packages/bundled-features/src/managed-pages/constants.ts +7 -0
  138. package/packages/bundled-features/src/managed-pages/feature.ts +6 -4
  139. package/packages/bundled-features/src/managed-pages/handlers/by-slug.query.ts +2 -0
  140. package/packages/bundled-features/src/managed-pages/handlers/page-body-guard.ts +61 -0
  141. package/packages/bundled-features/src/managed-pages/handlers/page-body-issues.ts +26 -0
  142. package/packages/bundled-features/src/managed-pages/handlers/page-crud.ts +17 -10
  143. package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +15 -4
  144. package/packages/bundled-features/src/managed-pages/render-page-body.ts +28 -0
  145. package/packages/bundled-features/src/managed-pages/screens/page-screens.ts +6 -2
  146. package/packages/bundled-features/src/managed-pages/seeding.ts +13 -2
  147. package/packages/bundled-features/src/managed-pages/table.ts +6 -2
  148. package/packages/bundled-features/src/managed-pages/web/client-plugin.tsx +6 -4
  149. package/packages/bundled-features/src/managed-pages/web/constants.ts +14 -0
  150. package/packages/bundled-features/src/managed-pages/web/i18n.ts +14 -0
  151. package/packages/bundled-features/src/managed-pages/web/lossy-markdown.ts +71 -0
  152. package/packages/bundled-features/src/managed-pages/web/markdown-to-document.ts +27 -0
  153. package/packages/bundled-features/src/managed-pages/web/page-body-section.tsx +73 -0
  154. package/packages/bundled-features/src/subscription-paddle/changes.json +15 -0
  155. package/packages/bundled-features/src/subscription-paddle/checkout-config.query.ts +17 -0
  156. package/packages/bundled-features/src/subscription-paddle/checkout-methods.ts +107 -0
  157. package/packages/bundled-features/src/subscription-paddle/checkout-page.ts +154 -0
  158. package/packages/bundled-features/src/subscription-paddle/client.ts +78 -0
  159. package/packages/bundled-features/src/subscription-paddle/constants.ts +32 -0
  160. package/packages/bundled-features/src/subscription-paddle/feature.ts +265 -0
  161. package/packages/bundled-features/src/subscription-paddle/index.ts +17 -0
  162. package/packages/bundled-features/src/subscription-paddle/plan-switch-methods.ts +135 -0
  163. package/packages/bundled-features/src/subscription-paddle/price-methods.ts +148 -0
  164. package/packages/bundled-features/src/subscription-paddle/runtime.ts +203 -0
  165. package/packages/bundled-features/src/subscription-paddle/subscription-state.ts +73 -0
  166. package/packages/bundled-features/src/subscription-paddle/tenant-binding.ts +34 -0
  167. package/packages/bundled-features/src/subscription-paddle/verify-webhook.ts +61 -0
  168. package/packages/bundled-features/src/subscription-paddle/webhook-events.ts +172 -0
  169. package/packages/bundled-features/src/template-resolver/README.md +2 -2
  170. package/packages/bundled-features/src/template-resolver/changes.json +20 -0
  171. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +16 -16
  172. package/packages/bundled-features/src/template-resolver/feature.ts +14 -1
  173. package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +2 -2
  174. package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +6 -2
  175. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +24 -6
  176. package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +32 -0
  177. package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +1 -0
  178. package/packages/bundled-features/src/template-resolver/handlers/shared.ts +9 -3
  179. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +13 -480
  180. package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +105 -0
  181. package/packages/bundled-features/src/template-resolver/web/collection-lookup.ts +33 -0
  182. package/packages/bundled-features/src/template-resolver/web/constants.ts +3 -0
  183. package/packages/bundled-features/src/template-resolver/web/content-field.tsx +61 -0
  184. package/packages/bundled-features/src/template-resolver/web/content-tree-provider.ts +256 -0
  185. package/packages/bundled-features/src/template-resolver/web/document-reference-name.ts +12 -0
  186. package/packages/bundled-features/src/template-resolver/web/document-schema.ts +3 -11
  187. package/packages/bundled-features/src/template-resolver/web/i18n.ts +9 -2
  188. package/packages/bundled-features/src/template-resolver/web/legacy-variable-text.ts +30 -0
  189. package/packages/bundled-features/src/template-resolver/web/tenant-override.ts +7 -0
  190. package/packages/bundled-features/src/template-resolver/web/text-block-editor.tsx +92 -0
  191. package/packages/bundled-features/src/template-resolver/web/use-collection-editor-context.ts +42 -0
  192. package/packages/bundled-features/src/template-resolver/web/use-collection-entry-create.ts +92 -0
  193. package/packages/bundled-features/src/template-resolver/web/use-content-write.ts +81 -0
  194. package/packages/bundled-features/src/template-resolver/web/use-text-block-editor.ts +127 -0
  195. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  196. package/packages/bundled-features/src/tier-engine/feature.ts +9 -1
  197. package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +2 -1
  198. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +4 -0
  199. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +11 -5
  200. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +2 -2
  201. package/samples/apps/use-all-bundled/feature-manifest.json +89 -40
  202. package/samples/apps/use-all-bundled/kumiko/migrations/0056_inbound-mail-quarantine-retry.rebuild.json +4 -0
  203. package/samples/apps/use-all-bundled/package.json +1 -0
  204. package/samples/apps/use-all-bundled/playwright.billing-docs.config.ts +15 -0
  205. package/samples/apps/use-all-bundled/src/app/cap-overview-caps.ts +16 -2
  206. package/samples/apps/use-all-bundled/src/app/cap-overview-labels-feature.ts +2 -1
  207. package/samples/apps/use-all-bundled/src/app/client.tsx +8 -1
  208. package/samples/apps/use-all-bundled/src/app/screens-feature.ts +14 -2
  209. package/samples/apps/use-all-bundled/src/app/seed.ts +17 -2
  210. package/samples/apps/use-all-bundled/src/run-config.ts +19 -11
  211. package/samples/recipes/cross-feature-events/README.md +10 -0
  212. package/samples/recipes/cross-feature-events/src/feature.ts +14 -0
  213. package/samples/recipes/dashboard-stats/README.md +8 -0
  214. package/samples/recipes/dashboard-stats/src/feature.ts +91 -1
  215. package/samples/recipes/env-composition/README.md +2 -2
  216. package/samples/recipes/env-composition/src/env.ts +2 -2
  217. package/samples/recipes/inbound-mail-triage/README.md +8 -0
  218. package/samples/recipes/pdf-audit-export/src/feature.ts +71 -48
  219. package/packages/bundled-features/src/subscription-dodo/changes.json +0 -14
  220. package/packages/bundled-features/src/subscription-dodo/client.ts +0 -45
  221. package/packages/bundled-features/src/subscription-dodo/constants.ts +0 -24
  222. package/packages/bundled-features/src/subscription-dodo/feature.ts +0 -207
  223. package/packages/bundled-features/src/subscription-dodo/index.ts +0 -16
  224. package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +0 -345
  225. package/packages/bundled-features/src/subscription-dodo/runtime.ts +0 -126
  226. package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +0 -262
@@ -1,480 +1,19 @@
1
- import { EMPTY_VALUE_PLACEHOLDER } from "@cosmicdrift/kumiko-renderer/headless";
2
- // Client feature factory for the text-block content tree. Apps mount it via
3
- // createKumikoApp({ clientFeatures: [textBlocksClient()] }); it loads the
4
- // blocks from the by-tenant query, groups them by their `folder` field and
5
- // emits them as TreeNode[], plus the editor behind the edit action.
6
- //
7
- // **Folder grouping**: block.folder !== null puts the node under a container
8
- // node labelled with the folder; folder === null keeps it at the root.
9
- //
10
- // **State**: TreeNode.state = "filled" when content is set, otherwise "stub"
11
- // (a hint that the slug exists but is empty).
12
-
13
- import { useShellUser } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
14
- import type {
15
- TargetRef,
16
- TreeChildrenSubscribe,
17
- TreeNode,
18
- } from "@cosmicdrift/kumiko-framework/engine";
19
- import {
20
- ContentPreview,
21
- type FeatureSchema,
22
- useAppFeatures,
23
- useContentEditor,
24
- useDispatcher,
25
- usePrimitives,
26
- useQuery,
27
- useTranslation,
28
- } from "@cosmicdrift/kumiko-renderer";
29
- import { CSRF_HEADER_NAME, readCsrfToken } from "@cosmicdrift/kumiko-renderer/live";
30
- import {
31
- type ClientFeatureDefinition,
32
- PlainContentEditor,
33
- RichContentEditor,
34
- } from "@cosmicdrift/kumiko-renderer/web";
35
- import type { ContentEditorFormat } from "@cosmicdrift/kumiko-types/nav";
36
- import { type FormEvent, type ReactNode, useEffect, useId, useState } from "react";
37
- import { DocumentBlocksContext, DocumentContentEditor } from "./document-editor.tsx";
38
- import { documentBlocksForKind } from "./document-toolbar-blocks.ts";
1
+ import { type ClientFeatureDefinition, PlainContentEditor } from "@cosmicdrift/kumiko-renderer/web";
2
+ import { createCollectionEntryCreateForm } from "./collection-entry-create-form.tsx";
3
+ import { makeTreeProvider } from "./content-tree-provider.ts";
4
+ import { DocumentContentEditor } from "./document-editor.tsx";
39
5
  import { defaultTranslations } from "./i18n.ts";
40
- import {
41
- collectionHandlerName,
42
- collectionQueryName,
43
- TemplateResolverHandlers,
44
- TemplateResolverQueries,
45
- } from "./qualified-names.ts";
46
-
47
- // Exported for the unit test — groupBlocksByFolder is a pure function.
48
- export type BlockSummary = {
49
- readonly slug: string;
50
- readonly locale: string;
51
- readonly title: string | null;
52
- readonly content: string | null;
53
- readonly folder: string | null;
54
- readonly modifiedAt: string | null;
55
- };
56
-
57
- type ByTenantResponse = {
58
- readonly data: { readonly blocks: readonly BlockSummary[] };
59
- };
60
-
61
- // Multi-level folder splitting: `folder` is a `/`-separated path, the tree
62
- // splits per segment and creates nested folder nodes:
63
- // block.folder = null → root leaf
64
- // block.folder = "page" → folder page > leaf
65
- // block.folder = "page/marketing" → folder page > folder marketing > leaf
66
- //
67
- // **Folder/leaf collision**: a block with `folder=null, slug="page"` and
68
- // another with `folder="page"` both live on the same level — the folder with
69
- // children plus a clickable leaf. Visually distinct (chevron + folder icon),
70
- // no crash, no surprise.
71
-
72
- type FolderNode = {
73
- readonly leaves: TreeNode[];
74
- readonly subFolders: Map<string, FolderNode>;
75
- };
76
-
77
- function newFolderNode(): FolderNode {
78
- return { leaves: [], subFolders: new Map() };
79
- }
80
-
81
- function attachBlock(
82
- root: FolderNode,
83
- block: BlockSummary,
84
- tenantIdOverride?: string,
85
- collectionId?: string,
86
- ): void {
87
- const leaf: TreeNode = {
88
- label: block.title || block.slug,
89
- icon: "file",
90
- target: {
91
- featureId: "template-resolver",
92
- action: "edit",
93
- // tenantIdOverride and collectionId travel with the node → the editor
94
- // reads and writes through the same tenant and the same collection the
95
- // tree was loaded from. Omitted → session tenant, public text-blocks.
96
- args: {
97
- slug: block.slug,
98
- locale: block.locale,
99
- ...(tenantIdOverride !== undefined && { tenantIdOverride }),
100
- ...(collectionId !== undefined && { collectionId }),
101
- },
102
- },
103
- state: block.content ? "filled" : "stub",
104
- };
105
- if (block.folder === null) {
106
- root.leaves.push(leaf);
107
- return;
108
- }
109
- let cur = root;
110
- for (const segment of block.folder.split("/")) {
111
- let next = cur.subFolders.get(segment);
112
- if (next === undefined) {
113
- next = newFolderNode();
114
- cur.subFolders.set(segment, next);
115
- }
116
- cur = next;
117
- }
118
- cur.leaves.push(leaf);
119
- }
120
-
121
- function renderFolderNode(node: FolderNode): TreeNode[] {
122
- // Leaves first (root items), then folders alphabetically.
123
- const result: TreeNode[] = [...node.leaves];
124
- for (const folderName of [...node.subFolders.keys()].sort()) {
125
- const subNode = node.subFolders.get(folderName);
126
- if (subNode === undefined) continue;
127
- result.push({
128
- label: folderName,
129
- icon: "folder",
130
- state: "filled",
131
- children: renderFolderNode(subNode),
132
- });
133
- }
134
- return result;
135
- }
136
-
137
- export function groupBlocksByFolder(
138
- blocks: readonly BlockSummary[],
139
- tenantIdOverride?: string,
140
- collectionId?: string,
141
- ): readonly TreeNode[] {
142
- const root = newFolderNode();
143
- for (const block of blocks) {
144
- attachBlock(root, block, tenantIdOverride, collectionId);
145
- }
146
- const rendered = renderFolderNode(root);
147
- if (rendered.length === 0) return [];
148
- return [
149
- {
150
- label: "Content",
151
- icon: "folder",
152
- state: "filled",
153
- children: rendered,
154
- },
155
- ];
156
- }
157
-
158
- // tenantIdOverride (SystemAdmin-only) lets an app point the content tree at a
159
- // tenant other than the session's — publicstatus seeds marketing/legal blocks
160
- // on SYSTEM_TENANT_ID, so its SystemAdmin must read that tenant, not their own.
161
- // Without a collection id: the anonymous-capable by-tenant query, so a public
162
- // page keeps its content sidebar without a session. With one: that
163
- // collection's own list handler, which carries the collection's access rule.
164
- function makeTreeProvider(tenantIdOverride?: string, collectionId?: string): TreeChildrenSubscribe {
165
- const queryType =
166
- collectionId === undefined
167
- ? TemplateResolverQueries.byTenant
168
- : collectionQueryName(collectionId, "list");
169
- return () => (emit, emitError) => {
170
- // CSRF header is mandatory on authenticated requests (auth-middleware
171
- // double-submit pattern). Anonymous/pre-login has no token → header
172
- // omitted → the server takes the anonymous path.
173
- const headers: Record<string, string> = { "content-type": "application/json" };
174
- const csrf = readCsrfToken();
175
- if (csrf !== undefined) headers[CSRF_HEADER_NAME] = csrf;
176
- // kumiko-lint-ignore no-raw-hooks not yet converted to the framework hook set
177
- fetch("/api/query", {
178
- method: "POST",
179
- headers,
180
- body: JSON.stringify({
181
- type: queryType,
182
- payload: tenantIdOverride !== undefined ? { tenantIdOverride } : {},
183
- }),
184
- })
185
- .then(async (r) => {
186
- if (!r.ok) {
187
- // 403 (CSRF/auth) or 5xx — explicit error instead of silent empty.
188
- const text = await r.text().catch(() => r.statusText);
189
- throw new Error(`text-block load failed: ${r.status} ${text}`);
190
- }
191
- return r.json();
192
- })
193
- .then((data: ByTenantResponse) => {
194
- // The app-side r.nav node IS the "Content" container → the provider's
195
- // children are the folders/leaves below it, not the wrapper.
196
- const content = groupBlocksByFolder(data.data.blocks, tenantIdOverride, collectionId)[0];
197
- emit(content !== undefined && Array.isArray(content.children) ? content.children : []);
198
- })
199
- .catch((e) => {
200
- // Explicit error signal via emitError; ProviderBranch shows a banner
201
- // plus retry. Falls back to emit([]) for consumers without emitError.
202
- if (emitError) {
203
- emitError(e instanceof Error ? e : new Error(String(e)));
204
- } else {
205
- emit([]);
206
- }
207
- });
208
- return () => {};
209
- };
210
- }
211
-
212
- type ContentCollectionSchema = NonNullable<FeatureSchema["contentCollections"]>[number];
213
-
214
- // A node without a collectionId (the hand-wired text-block tree) has no
215
- // collection to look up — contentFormat/variableSchema then fall back to
216
- // their own callers' defaults.
217
- function findCollection(
218
- features: readonly FeatureSchema[],
219
- collectionId?: string,
220
- ): ContentCollectionSchema | undefined {
221
- if (collectionId === undefined) return undefined;
222
- for (const feature of features) {
223
- const match = feature.contentCollections?.find((c) => c.id === collectionId);
224
- if (match !== undefined) return match;
225
- }
226
- return undefined;
227
- }
228
-
229
- function findContentFormat(
230
- features: readonly FeatureSchema[],
231
- collectionId?: string,
232
- ): ContentEditorFormat | undefined {
233
- return findCollection(features, collectionId)?.contentFormat;
234
- }
235
-
236
- // The collection's fixed variable names (ContentCollectionDefinition.
237
- // variableSchema) — the chip bar's data source and, keyed, the example
238
- // values the Preview substitutes in for `{{name}}`.
239
- function findVariableExamples(
240
- features: readonly FeatureSchema[],
241
- collectionId?: string,
242
- ): Readonly<Record<string, string>> {
243
- return findCollection(features, collectionId)?.variableSchema ?? {};
244
- }
245
-
246
- // Edit form: loads the current values via by-slug, lets TenantAdmin and
247
- // SystemAdmin edit title + content, dispatches set on submit. Other users see
248
- // the form read-only with a hint banner — write permission stays opinionated
249
- // TenantAdmin-only, apps widen it via dual-role mapping if they want to.
250
-
251
- type TextBlock = {
252
- readonly slug: string;
253
- readonly locale: string;
254
- readonly title: string | null;
255
- readonly content: string | null;
256
- readonly contentFormat: string;
257
- readonly folder: string | null;
258
- readonly modifiedAt: string | null;
259
- };
260
-
261
- // The collection's declared contentFormat ("plain" | "rich" | "markdown" |
262
- // "document", see ContentCollectionDefinition.contentFormat) uses a different vocabulary
263
- // than the stored entry's contentFormat ("plain" | "html" | "markdown" |
264
- // "mjml", see CONTENT_FORMATS) — "rich" and "document" always store HTML. Without this,
265
- // handleSave omitting contentFormat let the write schema's
266
- // contentFormatSchema.default("markdown") silently stamp every save as
267
- // markdown, clobbering e.g. a "plain" entry. Undefined (no collection, or
268
- // the collection didn't declare a format) keeps whatever was already saved
269
- // instead of guessing.
270
- // Exported for the unit test — resolveWriteContentFormat is a pure function.
271
- export function resolveWriteContentFormat(
272
- collectionContentFormat: string | undefined,
273
- loadedContentFormat: string | undefined,
274
- ): string | undefined {
275
- if (collectionContentFormat === "plain") return "plain";
276
- if (collectionContentFormat === "rich" || collectionContentFormat === "document") return "html";
277
- if (collectionContentFormat === "markdown") return "markdown";
278
- return loadedContentFormat;
279
- }
280
-
281
- type SetResponse = { readonly slug: string; readonly locale: string; readonly isNew: boolean };
282
-
283
- function TextBlockEditor({
284
- target,
285
- }: {
286
- readonly target: TargetRef;
287
- // onClose stays part of the resolver contract (the visual panel passes it)
288
- // but is unused: tree selection navigates, there is no close button.
289
- readonly onClose: () => void;
290
- }): ReactNode {
291
- // @cast-boundary visual-tree-args — TargetRef.args is erased to
292
- // Record<string, unknown>; the resolver knows the action shape (see the
293
- // treeActions.edit definition in feature.ts) and de-erases per action, the
294
- // same way event payloads do. Optional chaining absorbs missing fields so a
295
- // hand-edited URL cannot crash the editor.
296
- const args = target.args as
297
- | { slug?: string; locale?: string; collectionId?: string; tenantIdOverride?: string }
298
- | undefined;
299
- const slug = args?.slug ?? "";
300
- const locale = args?.locale ?? "";
301
- const tenantIdOverride = args?.tenantIdOverride;
302
- // Which collection the node came from. Absent → the public text-block pair,
303
- // which is what the hand-wired content tree uses.
304
- const collectionId = args?.collectionId;
305
-
306
- const { Form, Field, Input, Button, Banner } = usePrimitives();
307
- const dispatcher = useDispatcher();
308
- const user = useShellUser();
309
- const t = useTranslation();
310
- const contentEditorId = useId();
311
- const features = useAppFeatures();
312
- const contentFormat = findContentFormat(features, collectionId);
313
- const variableExamples = findVariableExamples(features, collectionId);
314
- const variables = Object.keys(variableExamples);
315
- const documentBlocks = documentBlocksForKind(findCollection(features, collectionId)?.kind);
316
- const ContentEditor = useContentEditor(contentFormat);
317
- const canWrite =
318
- user?.roles.includes("TenantAdmin") === true || user?.roles.includes("SystemAdmin") === true;
319
-
320
- // Same split as the tree provider: a node without a collection reads through
321
- // the public by-slug, a collection node through that collection's handlers.
322
- const readQuery =
323
- collectionId === undefined
324
- ? TemplateResolverQueries.bySlug
325
- : collectionQueryName(collectionId, "item");
326
- const writeHandler =
327
- collectionId === undefined ? TemplateResolverHandlers.set : collectionHandlerName(collectionId);
328
- const {
329
- data: loaded,
330
- loading,
331
- error: loadError,
332
- } = useQuery<TextBlock | null>(
333
- readQuery,
334
- { slug, locale, ...(tenantIdOverride !== undefined && { tenantIdOverride }) },
335
- { enabled: slug !== "" && locale !== "" },
336
- );
337
-
338
- const [title, setTitle] = useState("");
339
- const [content, setContent] = useState("");
340
- const [previewing, setPreviewing] = useState(false);
341
- const [submitting, setSubmitting] = useState(false);
342
- const [saveError, setSaveError] = useState<string | null>(null);
343
- const [savedMsg, setSavedMsg] = useState<string | null>(null);
344
-
345
- // Sync loaded data into the form state. loaded === null means the block does
346
- // not exist yet — empty form for the create flow.
347
- // kumiko-lint-ignore no-raw-hooks not yet converted to the framework hook set
348
- useEffect(() => {
349
- if (loading) return;
350
- setTitle(loaded?.title ?? "");
351
- setContent(loaded?.content ?? "");
352
- setSaveError(null);
353
- setSavedMsg(null);
354
- }, [loading, loaded]);
355
-
356
- const handleSave = async (): Promise<void> => {
357
- setSubmitting(true);
358
- setSaveError(null);
359
- setSavedMsg(null);
360
- try {
361
- const resolvedContentFormat = resolveWriteContentFormat(contentFormat, loaded?.contentFormat);
362
- const result = await dispatcher.write<SetResponse>(writeHandler, {
363
- slug,
364
- locale,
365
- title,
366
- content: content.length > 0 ? content : null,
367
- folder: loaded?.folder ?? null,
368
- ...(resolvedContentFormat !== undefined && { contentFormat: resolvedContentFormat }),
369
- ...(tenantIdOverride !== undefined && { tenantIdOverride }),
370
- });
371
- if (result.isSuccess) {
372
- setSavedMsg(
373
- result.data.isNew
374
- ? t("template-resolver.editor.created")
375
- : t("template-resolver.editor.saved"),
376
- );
377
- return;
378
- }
379
- setSaveError(
380
- result.error.message ?? result.error.code ?? t("template-resolver.editor.saveFailed"),
381
- );
382
- } catch (e) {
383
- // Network blip / dispatcher throw — otherwise submitting stays true and
384
- // the save button is locked forever with no feedback.
385
- setSaveError(e instanceof Error ? e.message : t("template-resolver.editor.networkError"));
386
- } finally {
387
- setSubmitting(false);
388
- }
389
- };
390
-
391
- const onSubmit = (e?: FormEvent): void => {
392
- e?.preventDefault();
393
- void handleSave();
394
- };
395
-
396
- const disabled = submitting || loading || !canWrite;
397
-
398
- return (
399
- <Form
400
- onSubmit={onSubmit}
401
- testId="text-block-editor"
402
- title={title || slug || EMPTY_VALUE_PLACEHOLDER}
403
- subtitle={locale !== "" ? `${slug} · (${locale})` : slug}
404
- actions={
405
- canWrite ? (
406
- <Button type="submit" loading={submitting} disabled={disabled}>
407
- {submitting ? t("template-resolver.editor.saving") : t("template-resolver.editor.save")}
408
- </Button>
409
- ) : undefined
410
- }
411
- >
412
- {loading && <Banner variant="loading">{t("template-resolver.editor.loading")}</Banner>}
413
- {loadError !== null && (
414
- <Banner variant="error">
415
- {t("template-resolver.editor.loadFailed")}: {loadError.code}
416
- </Banner>
417
- )}
418
- {!canWrite && !loading && (
419
- <Banner variant="info">{t("template-resolver.editor.readOnly")}</Banner>
420
- )}
421
- <Field id="text-block-title" label={t("template-resolver.editor.titleLabel")} required>
422
- <Input
423
- kind="text"
424
- id="text-block-title"
425
- name="text-block-title"
426
- value={title}
427
- onChange={setTitle}
428
- disabled={disabled}
429
- required
430
- />
431
- </Field>
432
- <Field
433
- id={contentEditorId}
434
- label={t("template-resolver.editor.contentLabel")}
435
- labelAppendix={
436
- <Button
437
- type="button"
438
- variant="secondary"
439
- size="sm"
440
- onClick={() => setPreviewing((p) => !p)}
441
- >
442
- {previewing ? t("kumiko.contentEditor.editMode") : t("kumiko.contentEditor.preview")}
443
- </Button>
444
- }
445
- >
446
- {previewing ? (
447
- <ContentPreview
448
- content={content}
449
- variables={variableExamples}
450
- contentFormat={contentFormat}
451
- />
452
- ) : (
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>
462
- )}
463
- </Field>
464
- {saveError !== null && <Banner variant="error">{saveError}</Banner>}
465
- {savedMsg !== null && <Banner variant="info">{savedMsg}</Banner>}
466
- </Form>
467
- );
468
- }
6
+ import { TextBlockEditor } from "./text-block-editor.tsx";
469
7
 
470
8
  // `navId` = the QN of the r.nav({ provider: true }) node the app registers for
471
9
  // the content tree (e.g. "publicstatus:nav:content"). The app owns the node's
472
10
  // label/icon/access, the feature only supplies children plus the editor.
473
11
  // Without navId: resolver only, no sidebar node (server-only consumers such as
474
12
  // money-horse leak nothing).
475
- // `tenantId` (SystemAdmin-only): which tenant the tree and editor serve —
476
- // SYSTEM_TENANT_ID for apps that seed marketing/legal there. Omit for the
477
- // session tenant (default, no cross-tenant access).
13
+ // `tenantId`: which tenant a SystemAdmin's trees and editor serve —
14
+ // SYSTEM_TENANT_ID for apps that seed marketing/legal there. Other users and
15
+ // per-user collections always stay on the session tenant. Omit for the
16
+ // session tenant everywhere.
478
17
  //
479
18
  // Beyond that single tree, this client also serves every content collection
480
19
  // the app declares via r.contentCollection() — one provider per collection,
@@ -498,25 +37,19 @@ export function textBlocksClient(opts?: {
498
37
  // this — an app can have both a hand-wired content tree and collections.
499
38
  navProvidersFromCollections: (collections) => ({
500
39
  providers: Object.fromEntries(
501
- collections.map((c) => [c.navQn, makeTreeProvider(tenantId, c.id)]),
40
+ collections.map((c) => [c.navQn, makeTreeProvider(tenantId, c)]),
502
41
  ),
503
42
  entities: Object.fromEntries(collections.map((c) => [c.navQn, ["template-resource"]])),
504
43
  }),
505
44
  resolvers: {
506
45
  "template-resolver:edit": TextBlockEditor,
46
+ "template-resolver:create": createCollectionEntryCreateForm(tenantId),
507
47
  },
508
- // The default for every "plain" collection (ai-prompt, mail without
509
- // an explicit rich override) — an app-registered "plain" editor on
510
- // another clientFeature still wins, last-wins same as columnRenderers.
48
+ // An app-registered editor for a format on another clientFeature still wins,
49
+ // last-wins same as columnRenderers.
511
50
  contentEditors: {
512
51
  plain: PlainContentEditor,
513
- rich: RichContentEditor,
514
52
  document: DocumentContentEditor,
515
- // ponytail: same textarea+chips as "plain" — markdown is stored as
516
- // text either way. Shares PlainContentEditor's fixed-DOM-id ceiling
517
- // (see its own file comment); swap for a live-preview widget or a
518
- // forwarded-ref variant if a collection actually needs one.
519
- markdown: PlainContentEditor,
520
53
  },
521
54
  translations: defaultTranslations,
522
55
  };
@@ -0,0 +1,105 @@
1
+ import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { FieldIssue } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { FormScreenLayout, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
4
+ import type { ReactNode } from "react";
5
+ import { ContentField } from "./content-field.tsx";
6
+ import { useCollectionEntryCreate } from "./use-collection-entry-create.ts";
7
+
8
+ const SLUG_INVALID_ISSUE: FieldIssue = {
9
+ path: "slug",
10
+ code: "slug_invalid",
11
+ i18nKey: "template-resolver.editor.slugInvalid",
12
+ };
13
+
14
+ // A new entry in the app's own locale; the tree then lists it under that locale like any other.
15
+ // `tenantId` is the plugin's SystemAdmin tenant, the one the collection tree lists, so a new entry
16
+ // lands where the tree looks. Same rule as the tree: per-user collections stay on the session tenant.
17
+ export function createCollectionEntryCreateForm(
18
+ tenantId: string | undefined,
19
+ ): (props: { readonly target: TargetRef; readonly onClose: () => void }) => ReactNode {
20
+ return function BoundCollectionEntryCreateForm(props) {
21
+ return <CollectionEntryCreateForm {...props} tenantId={tenantId} />;
22
+ };
23
+ }
24
+
25
+ type CreateState = ReturnType<typeof useCollectionEntryCreate>;
26
+
27
+ function CreateFields({ state }: { readonly state: CreateState }): ReactNode {
28
+ const { Field, Input, Banner } = usePrimitives();
29
+ const t = useTranslation();
30
+ const { canWrite } = state.editorContext;
31
+ return (
32
+ <>
33
+ {!canWrite && <Banner variant="info">{t("template-resolver.editor.readOnly")}</Banner>}
34
+ <Field
35
+ id="create-entry-slug"
36
+ label={t("template-resolver.editor.slugLabel")}
37
+ required
38
+ {...(state.slugInvalid && { issues: [SLUG_INVALID_ISSUE] })}
39
+ >
40
+ <Input
41
+ kind="text"
42
+ id="create-entry-slug"
43
+ name="create-entry-slug"
44
+ value={state.slug}
45
+ onChange={state.setSlug}
46
+ disabled={state.disabled}
47
+ required
48
+ />
49
+ </Field>
50
+ <Field id="create-entry-title" label={t("template-resolver.editor.titleLabel")} required>
51
+ <Input
52
+ kind="text"
53
+ id="create-entry-title"
54
+ name="create-entry-title"
55
+ value={state.title}
56
+ onChange={state.setTitle}
57
+ disabled={state.disabled}
58
+ required
59
+ />
60
+ </Field>
61
+ <ContentField
62
+ editorContext={state.editorContext}
63
+ value={state.content}
64
+ onChange={state.setContent}
65
+ disabled={state.disabled}
66
+ />
67
+ {state.error !== null && <Banner variant="error">{state.error}</Banner>}
68
+ </>
69
+ );
70
+ }
71
+
72
+ export function CollectionEntryCreateForm({
73
+ target,
74
+ tenantId,
75
+ }: {
76
+ readonly target: TargetRef;
77
+ readonly onClose: () => void;
78
+ readonly tenantId?: string | undefined;
79
+ }): ReactNode {
80
+ const { Button, PageHeader, Section } = usePrimitives();
81
+ const t = useTranslation();
82
+ const state = useCollectionEntryCreate(target, tenantId);
83
+ return (
84
+ <>
85
+ {PageHeader !== undefined && <PageHeader title={state.headerTitle} />}
86
+ <FormScreenLayout
87
+ onSubmit={() => {
88
+ void state.create();
89
+ }}
90
+ testId="collection-entry-create"
91
+ actions={
92
+ state.editorContext.canWrite ? (
93
+ <Button type="submit" loading={state.submitting} disabled={state.createDisabled}>
94
+ {t("template-resolver.editor.create")}
95
+ </Button>
96
+ ) : undefined
97
+ }
98
+ >
99
+ <Section>
100
+ <CreateFields state={state} />
101
+ </Section>
102
+ </FormScreenLayout>
103
+ </>
104
+ );
105
+ }
@@ -0,0 +1,33 @@
1
+ import { isUiAccessGranted } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
3
+ import { DEFAULT_COLLECTION_ACCESS } from "./constants.ts";
4
+
5
+ export type ContentCollectionSchema = NonNullable<FeatureSchema["contentCollections"]>[number];
6
+
7
+ // A node without a collectionId (the hand-wired text-block tree) has no
8
+ // collection to look up — contentFormat/variableSchema then fall back to
9
+ // their own callers' defaults.
10
+ export function findCollection(
11
+ features: readonly FeatureSchema[],
12
+ collectionId?: string,
13
+ ): ContentCollectionSchema | undefined {
14
+ if (collectionId === undefined) return undefined;
15
+ for (const feature of features) {
16
+ const match = feature.contentCollections?.find((c) => c.id === collectionId);
17
+ if (match !== undefined) return match;
18
+ }
19
+ return undefined;
20
+ }
21
+
22
+ // Who may save follows the server: a collection's set handler carries the
23
+ // collection's own access rule (one rule for list, item and set), the public
24
+ // text-block pair stays TenantAdmin/SystemAdmin. Other users see the form read-only.
25
+ export function canWriteContent(
26
+ collection: Pick<ContentCollectionSchema, "access"> | undefined,
27
+ roles: readonly string[] | undefined,
28
+ ): boolean {
29
+ if (collection !== undefined) {
30
+ return isUiAccessGranted(collection.access ?? DEFAULT_COLLECTION_ACCESS, roles);
31
+ }
32
+ return roles?.includes("TenantAdmin") === true || roles?.includes("SystemAdmin") === true;
33
+ }
@@ -63,6 +63,9 @@ export function isCollectionRefusedKind(
63
63
  return isOneOf(COLLECTION_REFUSED_KINDS, kind);
64
64
  }
65
65
 
66
+ export const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
67
+ export const SLUG_MAX_LENGTH = 80;
68
+
66
69
  export const CONTENT_FORMATS = ["markdown", "mjml", "html", "plain"] as const;
67
70
  export type ContentFormat = (typeof CONTENT_FORMATS)[number];
68
71