@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,13 +1,19 @@
1
1
  // Public API of the inbound-provider-inmemory bundled-feature.
2
2
 
3
3
  export {
4
+ contentFetchesOf,
4
5
  createInboundProviderInMemoryFeature,
5
6
  emitWatchError,
6
7
  failNextFetchWith,
7
8
  failNextVerifyWith,
9
+ hangFetchesOf,
8
10
  INMEMORY_PROVIDER_KEY,
9
11
  isWatching,
12
+ lastFetchSignalOf,
10
13
  requireCredentialSecretOnFetch,
11
14
  resetInboundInMemory,
12
15
  seedInboundMessage,
16
+ seedRejectedInboundMessage,
17
+ setInboundFullContent,
18
+ watchedTrustedAuthservIdOf,
13
19
  } from "./feature.ts";
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.394.0",
4
+ "type": "fix",
5
+ "title": "catchUp job consumers ignore foreign event types before upcasting, and catchUp with maxPerTenant skip fails at boot",
6
+ "detail": "A catch-up job consumer now filters on the trigger's event types before the stored event is upcast, so a foreign event with a failing upcaster no longer stalls the job cursor. Boot rejects `trigger.catchUp` together with `maxPerTenant` onLimit \"skip\" (including the number shorthand): a replay must not discard history. Use onLimit \"defer\"."
7
+ },
8
+ {
9
+ "version": "0.394.0",
10
+ "type": "improvement",
11
+ "title": "r.job event triggers can catch up on stored events",
12
+ "detail": "trigger { on, catchUp: true } gives the job its own event consumer job:<jobQn> that starts at the beginning of the event store, so events from before the job existed run it too, once per event (job id derived from job QN and event id). Every \"on\" entry must be an r.defineEvent event, otherwise boot fails. The job is no longer fired by the shared job-trigger consumer. JobRunner gains handleCatchUpEvent."
13
+ },
14
+ {
15
+ "version": "0.394.0",
16
+ "type": "fix",
17
+ "title": "triggerName is documented as the firing event's qualified name",
18
+ "detail": "The JSDoc of `triggerName` said it names the handler that emitted the event; the job runner sets the event's qualified name, which multi-trigger jobs compare against the event QN constants."
19
+ },
2
20
  {
3
21
  "version": "0.390.0",
4
22
  "type": "fix",
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.395.0",
4
+ "type": "breaking",
5
+ "title": "A managed page body is Markdown or an editor document, edited in the document editor",
6
+ "detail": "The page entity has a new field bodyFormat (\"markdown\" or \"document\", default markdown). Pages and events from before the field read as markdown and render unchanged. A document body is HTML from the template-resolver document editor; page create, update and managed-pages:write:set check it with sanitizeDocumentHtml and store the rebuilt HTML and answer a rejected body with field errors on body (i18n key managed-pages.errors.document.rejected, new in all locale packages). The public render sanitizes a document body again and serves an empty body, with a logged warning, when a stored one fails. managed-pages:write:set takes an optional bodyFormat; omitting it sets markdown, it does not preserve the stored format. The page-edit screen no longer has a body textarea: its body section is the extension section managed-pages:page-body (PAGE_BODY_EXTENSION_NAME), which opens a Markdown body converted to HTML, shows a warning for what the conversion drops (images, block quotes, code, headings below level 3, relative links) and writes body and bodyFormat \"document\" only after the first edit. The ETag of the public page includes bodyFormat. seedPage takes an optional bodyFormat and throws on a rejected document body.",
7
+ "migration": "Run `kumiko-schema generate` and apply the migration; read_pages gets a bodyFormat column with default 'markdown', no rebuild is needed. Mount `managedPagesClient()` from `@cosmicdrift/kumiko-bundled-features/managed-pages/web` in createKumikoApp({ clientFeatures }); without it the body section of page-edit shows the missing-component placeholder. Apps that replaced page-edit with their own screen keep the textarea and send no bodyFormat, so their pages stay markdown."
8
+ },
2
9
  {
3
10
  "version": "0.389.0",
4
11
  "type": "improvement",
@@ -1 +1,8 @@
1
1
  export const MANAGED_PAGES_FEATURE = "managed-pages" as const;
2
+
3
+ export {
4
+ BODY_FORMATS,
5
+ type BodyFormat,
6
+ DEFAULT_BODY_FORMAT,
7
+ effectiveBodyFormat,
8
+ } from "./web/constants.ts";
@@ -5,17 +5,17 @@ import {
5
5
  type BrandingTokens,
6
6
  cachedSecurePageResponse,
7
7
  EMPTY_BRANDING,
8
- renderSafeMarkdown,
9
8
  wrapInLayout,
10
9
  } from "../page-render/index.ts";
11
10
  import { rateLimitedTextResponse, type SystemQueryFn } from "../shared/index.ts";
12
11
  import { BRANDING_KEYS, BRANDING_QUERY_QN, CUSTOM_CSS_KEY, coerceBranding } from "./branding.ts";
13
- import { MANAGED_PAGES_FEATURE } from "./constants.ts";
12
+ import { type BodyFormat, MANAGED_PAGES_FEATURE } from "./constants.ts";
14
13
  import { createBrandingQuery } from "./handlers/branding.query.ts";
15
14
  import { bySlugQuery } from "./handlers/by-slug.query.ts";
16
15
  import { byTenantPublishedQuery } from "./handlers/by-tenant-published.query.ts";
17
16
  import { pageCrudQueries, pageCrudWrites } from "./handlers/page-crud.ts";
18
17
  import { setWrite } from "./handlers/set.write.ts";
18
+ import { renderPageBody } from "./render-page-body.ts";
19
19
  import { createBrandingSettingsScreen } from "./screens/branding-screen.ts";
20
20
  import { pageEditScreen, pageListScreen } from "./screens/page-screens.ts";
21
21
  import { pageEntity } from "./table.ts";
@@ -34,6 +34,7 @@ const BY_SLUG_QN = "managed-pages:query:by-slug";
34
34
  type BySlugQueryResult = {
35
35
  title: string;
36
36
  body: string;
37
+ bodyFormat: BodyFormat;
37
38
  lang: string;
38
39
  description: string | null;
39
40
  ogImage: string | null;
@@ -157,7 +158,7 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
157
158
  return defineFeature(MANAGED_PAGES_FEATURE, (r) => {
158
159
  r.adoptsBundledLists();
159
160
  r.describe(
160
- "Tenant-editable, server-rendered public pages with per-tenant branding. Stores one Markdown `page` per `(tenantId, slug, lang)` in the `read_pages` entity table with a `published` gate plus `description`/`ogImage` SEO meta. Registers an anonymous `GET {basePath}/:slug` route that resolves the tenant from the request Host via the app-supplied `resolveApexTenant`, serves only published pages (drafts → 404), renders Markdown through the hardened `page-render` core, and isolates per-tenant content with `Vary: Host`. Ships TenantAdmin/SystemAdmin admin screens (`entityList` + `entityEdit`) backed by convention CRUD handlers (`managed-pages:write:page:{create,update,delete}`, `managed-pages:query:page:{list,detail}`); the app wires nav/workspace onto `managed-pages:screen:page-list`. Also exposes `managed-pages:query:by-tenant-published` (anonymous, SQL-filtered on `published`) for sitemap/discovery consumers such as the `seo` feature. Branding (via `config`, scope tenant): `branding-{title,description,site-url,accent-color,logo-url,layout-preset,custom-css}` keys with write-time validation (hex color, https URLs), a `configEdit` self-service screen (`managed-pages:screen:branding-settings`), and a `managed-pages:query:branding` read that the render path applies as scoped `:root` CSS vars + a logo/title header. Also exposes `managed-pages:write:set` (idempotent slug-keyed upsert, SystemAdmin cross-tenant via `tenantIdOverride`) as a provisioning API. Requires `config` + `anonymousAccess` wired at app bootstrap.",
161
+ "Tenant-editable, server-rendered public pages with per-tenant branding. Stores one `page` (Markdown or editor-document HTML body, per `bodyFormat`) per `(tenantId, slug, lang)` in the `read_pages` entity table with a `published` gate plus `description`/`ogImage` SEO meta. Registers an anonymous `GET {basePath}/:slug` route that resolves the tenant from the request Host via the app-supplied `resolveApexTenant`, serves only published pages (drafts → 404), renders Markdown through the hardened `page-render` core and document bodies through the template-resolver document sanitizer, and isolates per-tenant content with `Vary: Host`. Ships TenantAdmin/SystemAdmin admin screens (`entityList` + `entityEdit`) backed by convention CRUD handlers (`managed-pages:write:page:{create,update,delete}`, `managed-pages:query:page:{list,detail}`); the app wires nav/workspace onto `managed-pages:screen:page-list`. Also exposes `managed-pages:query:by-tenant-published` (anonymous, SQL-filtered on `published`) for sitemap/discovery consumers such as the `seo` feature. Branding (via `config`, scope tenant): `branding-{title,description,site-url,accent-color,logo-url,layout-preset,custom-css}` keys with write-time validation (hex color, https URLs), a `configEdit` self-service screen (`managed-pages:screen:branding-settings`), and a `managed-pages:query:branding` read that the render path applies as scoped `:root` CSS vars + a logo/title header. Also exposes `managed-pages:write:set` (idempotent slug-keyed upsert, SystemAdmin cross-tenant via `tenantIdOverride`) as a provisioning API. Requires `config` + `anonymousAccess` wired at app bootstrap.",
161
162
  );
162
163
  r.uiHints({
163
164
  displayLabel: "Managed Pages · Public CMS",
@@ -241,6 +242,7 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
241
242
  slug,
242
243
  lang,
243
244
  String(data.version),
245
+ data.bodyFormat,
244
246
  data.updatedAt,
245
247
  brandingRevisionSeed(branding),
246
248
  ]);
@@ -261,7 +263,7 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
261
263
 
262
264
  const html = wrapLayout({
263
265
  title: data.title,
264
- bodyHtml: renderSafeMarkdown(data.body),
266
+ bodyHtml: renderPageBody({ slug, body: data.body, bodyFormat: data.bodyFormat }),
265
267
  lang: data.lang,
266
268
  slug,
267
269
  description: data.description,
@@ -1,6 +1,7 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import * as z from "zod";
4
+ import { effectiveBodyFormat } from "../constants.ts";
4
5
  import { type PageRow, pagesTable } from "../table.ts";
5
6
 
6
7
  // Public read by (tenantId, slug, lang), including anonymous access for landing and
@@ -30,6 +31,7 @@ export const bySlugQuery = defineQueryHandler({
30
31
  lang: row.lang,
31
32
  title: row.title,
32
33
  body: row.body,
34
+ bodyFormat: effectiveBodyFormat(row.bodyFormat),
33
35
  description: row.description,
34
36
  ogImage: row.ogImage,
35
37
  version: row.version,
@@ -0,0 +1,61 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
3
+ import {
4
+ type FieldIssue,
5
+ ValidationError,
6
+ writeFailure,
7
+ } from "@cosmicdrift/kumiko-framework/errors";
8
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
9
+ import { type PageRow, pagesTable } from "../table.ts";
10
+ import { sanitizePageBody } from "./page-body-issues.ts";
11
+
12
+ type PageWriteHandler = WriteHandlerDef["handler"];
13
+ type PageWriteContext = Parameters<PageWriteHandler>[1];
14
+
15
+ type Guarded =
16
+ | { readonly ok: true; readonly payload: unknown }
17
+ | { readonly ok: false; readonly issues: FieldIssue[] };
18
+
19
+ function stringOrNull(value: unknown): string | null {
20
+ return typeof value === "string" ? value : null;
21
+ }
22
+
23
+ // preSave hooks cannot report field errors (the executor maps every throw to
24
+ // presave_hook_failed), so the convention handlers are checked before they run.
25
+ // An update may touch only body or only bodyFormat, so the pair is completed
26
+ // from the stored row. A document body is handed on as the rebuilt html.
27
+ async function guardUpdate(payload: unknown, ctx: PageWriteContext): Promise<Guarded> {
28
+ if (!isPlainObject(payload) || !isPlainObject(payload["changes"])) return { ok: true, payload };
29
+ const changes = payload["changes"];
30
+ if (!("body" in changes) && !("bodyFormat" in changes)) return { ok: true, payload };
31
+ const id = payload["id"];
32
+ const stored =
33
+ typeof id === "string" ? await fetchOne<PageRow>(ctx.db, pagesTable, { id }) : null;
34
+ const body = "body" in changes ? stringOrNull(changes["body"]) : (stored?.body ?? null);
35
+ const format = "bodyFormat" in changes ? changes["bodyFormat"] : stored?.bodyFormat;
36
+ const result = sanitizePageBody(body, format);
37
+ if (!result.ok) return result;
38
+ return { ok: true, payload: { ...payload, changes: { ...changes, body: result.body } } };
39
+ }
40
+
41
+ function guardCreate(payload: unknown): Guarded {
42
+ if (!isPlainObject(payload)) return { ok: true, payload };
43
+ const result = sanitizePageBody(stringOrNull(payload["body"]), payload["bodyFormat"]);
44
+ if (!result.ok) return result;
45
+ return { ok: true, payload: { ...payload, body: result.body } };
46
+ }
47
+
48
+ export function withBodyValidation(
49
+ def: WriteHandlerDef,
50
+ verb: "create" | "update",
51
+ ): WriteHandlerDef {
52
+ return {
53
+ ...def,
54
+ handler: async (event, ctx) => {
55
+ const guarded =
56
+ verb === "create" ? guardCreate(event.payload) : await guardUpdate(event.payload, ctx);
57
+ if (!guarded.ok) return writeFailure(new ValidationError({ fields: guarded.issues }));
58
+ return def.handler({ ...event, payload: guarded.payload }, ctx);
59
+ },
60
+ };
61
+ }
@@ -0,0 +1,26 @@
1
+ import type { FieldIssue } from "@cosmicdrift/kumiko-framework/errors";
2
+ import { sanitizeDocumentHtml } from "../../template-resolver/index.ts";
3
+ import { effectiveBodyFormat } from "../constants.ts";
4
+
5
+ export const BODY_REJECTED_I18N_KEY = "managed-pages.errors.document.rejected";
6
+
7
+ export type PageBodyResult =
8
+ | { readonly ok: true; readonly body: string | null }
9
+ | { readonly ok: false; readonly issues: FieldIssue[] };
10
+
11
+ // The single write-time gate for document bodies (create, update, set, seed): a
12
+ // document body is stored as the sanitizer's rebuilt html, never as the raw input.
13
+ export function sanitizePageBody(body: string | null | undefined, format: unknown): PageBodyResult {
14
+ if (effectiveBodyFormat(format) !== "document" || !body) return { ok: true, body: body ?? null };
15
+ const result = sanitizeDocumentHtml(body);
16
+ if (result.ok) return { ok: true, body: result.html };
17
+ return {
18
+ ok: false,
19
+ issues: result.violations.map((violation) => ({
20
+ path: "body",
21
+ code: violation.code,
22
+ i18nKey: BODY_REJECTED_I18N_KEY,
23
+ params: { detail: violation.detail },
24
+ })),
25
+ };
26
+ }
@@ -6,6 +6,7 @@ import {
6
6
  defineEntityUpdateHandler,
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { pageEntity } from "../table.ts";
9
+ import { withBodyValidation } from "./page-body-guard.ts";
9
10
 
10
11
  // Admin authoring runs as TenantAdmin (self-service) or SystemAdmin (app-wide
11
12
  // pages). Mirrors set.write's ACL — an app with its own role alias
@@ -17,16 +18,22 @@ const ADMIN_ACCESS = { roles: ["TenantAdmin", "SystemAdmin"] } as const;
17
18
  // `managed-pages:query:page:{list,detail}` by convention. The `set` write
18
19
  // handler in feature.ts is NOT used by them and stays the provisioning API.
19
20
  export const pageCrudWrites = [
20
- defineEntityCreateHandler("page", pageEntity, {
21
- access: ADMIN_ACCESS,
22
- description:
23
- "Creates a managed page from the admin form's field values, failing if that slug and language already exist; use it from the page catalog screen, whereas managed-pages:write:set upserts a page addressed by slug and language.",
24
- }),
25
- defineEntityUpdateHandler("page", pageEntity, {
26
- access: ADMIN_ACCESS,
27
- description:
28
- "Updates one managed page addressed by row id from the admin form's `{ id, version, changes }` envelope; use it from the page edit screen, whereas managed-pages:write:set addresses a page by slug and language instead.",
29
- }),
21
+ withBodyValidation(
22
+ defineEntityCreateHandler("page", pageEntity, {
23
+ access: ADMIN_ACCESS,
24
+ description:
25
+ "Creates a managed page from the admin form's field values, failing if that slug and language already exist; use it from the page catalog screen, whereas managed-pages:write:set upserts a page addressed by slug and language.",
26
+ }),
27
+ "create",
28
+ ),
29
+ withBodyValidation(
30
+ defineEntityUpdateHandler("page", pageEntity, {
31
+ access: ADMIN_ACCESS,
32
+ description:
33
+ "Updates one managed page addressed by row id from the admin form's `{ id, version, changes }` envelope; use it from the page edit screen, whereas managed-pages:write:set addresses a page by slug and language instead.",
34
+ }),
35
+ "update",
36
+ ),
30
37
  defineEntityDeleteHandler("page", pageEntity, {
31
38
  access: ADMIN_ACCESS,
32
39
  description:
@@ -1,8 +1,11 @@
1
1
  import { createEventStoreExecutor, fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { ValidationError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
4
  import * as z from "zod";
4
5
  import { requireForTenant } from "../../shared/index.ts";
6
+ import { BODY_FORMATS, DEFAULT_BODY_FORMAT } from "../constants.ts";
5
7
  import { type PageRow, pageEntity, pagesTable } from "../table.ts";
8
+ import { sanitizePageBody } from "./page-body-issues.ts";
6
9
 
7
10
  const slugSchema = z
8
11
  .string()
@@ -21,7 +24,8 @@ const executor = createEventStoreExecutor(pagesTable, pageEntity, { entityName:
21
24
  // Upserts one page per (tenantId, slug, lang). The tenant scope defaults to event.user;
22
25
  // SystemAdmin can write to another tenant via `tenantIdOverride` (typically SYSTEM_TENANT_ID
23
26
  // for app-wide pages). On update, omitted published/description/ogImage values are preserved
24
- // so a publish toggle does not overwrite the body, and vice versa.
27
+ // so a publish toggle does not overwrite the body, and vice versa. bodyFormat is the
28
+ // exception: omitted means markdown, so provisioning callers that only know markdown stay valid.
25
29
  export const setWrite = defineWriteHandler({
26
30
  name: "set",
27
31
  schema: z.object({
@@ -29,6 +33,7 @@ export const setWrite = defineWriteHandler({
29
33
  lang: langSchema,
30
34
  title: z.string().min(1).max(200),
31
35
  body: z.string().max(100_000).nullable(),
36
+ bodyFormat: z.enum(BODY_FORMATS).optional(),
32
37
  description: z.string().max(500).nullable().optional(),
33
38
  ogImage: z.string().max(2000).nullable().optional(),
34
39
  published: z.boolean().optional(),
@@ -37,10 +42,14 @@ export const setWrite = defineWriteHandler({
37
42
  }),
38
43
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
39
44
  description:
40
- "Creates or overwrites one managed page addressed by slug and language, keeping the existing published flag, description and OG image when the payload omits them; use it for content edits and publish toggles, and as SystemAdmin to write another tenant's pages.",
45
+ "Creates or overwrites one managed page addressed by slug and language, keeping the existing published flag, description and OG image when the payload omits them and resetting bodyFormat to markdown (the default) when it is omitted; use it for content edits and publish toggles, and as SystemAdmin to write another tenant's pages.",
41
46
  handler: async (event, ctx) => {
42
47
  const override = event.payload.tenantIdOverride;
43
48
  const tenantId = (override ?? event.user.tenantId) as TenantId; // @cast-boundary engine-bridge
49
+ const bodyFormat = event.payload.bodyFormat ?? DEFAULT_BODY_FORMAT;
50
+ const sanitized = sanitizePageBody(event.payload.body, bodyFormat);
51
+ if (!sanitized.ok) return writeFailure(new ValidationError({ fields: sanitized.issues }));
52
+ const { body } = sanitized;
44
53
  // ctx.db is scoped to the executing user's tenant, wrong for a cross-tenant override
45
54
  // on both the existing-check and the executor's stream reads; re-scope to the target.
46
55
  const { db: scopedDb, streamTenantId } = requireForTenant(ctx, tenantId);
@@ -57,7 +66,8 @@ export const setWrite = defineWriteHandler({
57
66
  version: existing.version,
58
67
  changes: {
59
68
  title: event.payload.title,
60
- body: event.payload.body,
69
+ body,
70
+ bodyFormat,
61
71
  description:
62
72
  event.payload.description !== undefined
63
73
  ? event.payload.description
@@ -83,7 +93,8 @@ export const setWrite = defineWriteHandler({
83
93
  slug: event.payload.slug,
84
94
  lang: event.payload.lang,
85
95
  title: event.payload.title,
86
- body: event.payload.body,
96
+ body,
97
+ bodyFormat,
87
98
  description: event.payload.description ?? null,
88
99
  ogImage: event.payload.ogImage ?? null,
89
100
  published: event.payload.published ?? false,
@@ -0,0 +1,28 @@
1
+ import { createFallbackLogger, type Logger } from "@cosmicdrift/kumiko-framework/logging";
2
+ import { renderSafeMarkdown } from "../page-render/index.ts";
3
+ import { sanitizeDocumentHtml } from "../template-resolver/index.ts";
4
+ import type { BodyFormat } from "./constants.ts";
5
+
6
+ type WarnLogger = Pick<Logger, "warn">;
7
+
8
+ const defaultLog = createFallbackLogger("managed-pages");
9
+
10
+ export type RenderablePageBody = {
11
+ readonly slug: string;
12
+ readonly body: string;
13
+ readonly bodyFormat: BodyFormat;
14
+ };
15
+
16
+ // A document body is re-sanitized at render time even though writes validate it:
17
+ // rows can predate the validation or arrive through other writers. A rejected
18
+ // document renders as an empty body (never the raw HTML) and is logged.
19
+ export function renderPageBody(page: RenderablePageBody, log: WarnLogger = defaultLog): string {
20
+ if (page.bodyFormat === "markdown") return renderSafeMarkdown(page.body);
21
+ const result = sanitizeDocumentHtml(page.body);
22
+ if (result.ok) return result.html;
23
+ log.warn("managed page document body rejected at render, serving an empty body", {
24
+ slug: page.slug,
25
+ violations: result.violations.map((violation) => violation.code),
26
+ });
27
+ return "";
28
+ }
@@ -2,6 +2,7 @@ import type {
2
2
  EntityEditScreenDefinition,
3
3
  EntityListScreenDefinition,
4
4
  } from "@cosmicdrift/kumiko-framework/engine";
5
+ import { PAGE_BODY_EXTENSION_NAME } from "../web/constants.ts";
5
6
 
6
7
  // Admin authoring screens for the `page` entity. These are JSON-safe data only —
7
8
  // the generic DataTable/form renderer mounts them, so no React component is
@@ -78,10 +79,13 @@ export const pageEditScreen: EntityEditScreenDefinition = {
78
79
  { field: "published", span: 1 },
79
80
  ],
80
81
  },
82
+ // Not a plain field: the body is edited as a document and a Markdown
83
+ // body is converted on open, so the section also patches `bodyFormat`.
81
84
  {
85
+ kind: "extension",
82
86
  title: "managed-pages:section.body",
83
- columns: 1,
84
- fields: [{ field: "body", span: 1 }],
87
+ component: { react: { __component: PAGE_BODY_EXTENSION_NAME } },
88
+ entityName: "page",
85
89
  },
86
90
  ],
87
91
  },
@@ -15,6 +15,8 @@ import {
15
15
  } from "@cosmicdrift/kumiko-framework/engine";
16
16
  import { runEventStoreSeed, type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
17
17
 
18
+ import { type BodyFormat, DEFAULT_BODY_FORMAT } from "./constants.ts";
19
+ import { sanitizePageBody } from "./handlers/page-body-issues.ts";
18
20
  import { type PageRow, pageEntity, pagesTable } from "./table.ts";
19
21
 
20
22
  const executor = createEventStoreExecutor(pagesTable, pageEntity, { entityName: "page" });
@@ -25,6 +27,7 @@ export type SeedPageOptions = {
25
27
  readonly lang: string;
26
28
  readonly title: string;
27
29
  readonly body?: string | null;
30
+ readonly bodyFormat?: BodyFormat;
28
31
  readonly description?: string | null;
29
32
  readonly ogImage?: string | null;
30
33
  readonly published?: boolean;
@@ -53,6 +56,12 @@ export async function seedPage(db: DbConnection, opts: SeedPageOptions): Promise
53
56
  const description = opts.description ?? null;
54
57
  const ogImage = opts.ogImage ?? null;
55
58
  const published = opts.published ?? false;
59
+ const bodyFormat = opts.bodyFormat ?? DEFAULT_BODY_FORMAT;
60
+ const sanitized = sanitizePageBody(opts.body, bodyFormat);
61
+ if (!sanitized.ok) {
62
+ throw new Error(`seedPage rejected the body: ${JSON.stringify(sanitized.issues)}`);
63
+ }
64
+ const { body } = sanitized;
56
65
 
57
66
  return runEventStoreSeed({
58
67
  existing,
@@ -63,7 +72,8 @@ export async function seedPage(db: DbConnection, opts: SeedPageOptions): Promise
63
72
  slug: opts.slug,
64
73
  lang: opts.lang,
65
74
  title: opts.title,
66
- body: opts.body ?? null,
75
+ body,
76
+ bodyFormat,
67
77
  description,
68
78
  ogImage,
69
79
  published,
@@ -90,7 +100,8 @@ export async function seedPage(db: DbConnection, opts: SeedPageOptions): Promise
90
100
  version: row.version,
91
101
  changes: {
92
102
  title: opts.title,
93
- body: opts.body ?? null,
103
+ body,
104
+ bodyFormat,
94
105
  description,
95
106
  ogImage,
96
107
  published,
@@ -2,12 +2,14 @@ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
2
2
  import {
3
3
  createBooleanField,
4
4
  createEntity,
5
+ createSelectField,
5
6
  createTextField,
6
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { BODY_FORMATS, type BodyFormat, DEFAULT_BODY_FORMAT } from "./constants.ts";
7
9
 
8
10
  // Page — a tenant-editable, server-rendered public page (landing,
9
11
  // about, custom). Exactly one row per (tenantId, slug, lang). The body is
10
- // Markdown, rendered securely on the server via page-render. `published`
12
+ // Markdown or an editor document (bodyFormat), rendered securely on the server. `published`
11
13
  // gates delivery to anonymous visitors: drafts return 404. description and
12
14
  // ogImage support SEO and social previews. SYSTEM_TENANT_ID is used for
13
15
  // app-wide pages; otherwise the page belongs to the tenant resolved from the
@@ -15,7 +17,7 @@ import {
15
17
  export const pageEntity = createEntity({
16
18
  table: "read_pages",
17
19
  description:
18
- "One tenant-editable public web page, unique per slug and language, holding a markdown body plus the title, SEO description and OG image, with a published flag that decides whether anonymous visitors are served it or get a 404.",
20
+ "One tenant-editable public web page, unique per slug and language, holding a markdown or document (HTML) body plus the title, SEO description and OG image, with a published flag that decides whether anonymous visitors are served it or get a 404.",
19
21
  fields: {
20
22
  slug: createTextField({
21
23
  required: true,
@@ -51,6 +53,7 @@ export const pageEntity = createEntity({
51
53
  personal: false,
52
54
  reason: "is_business_data",
53
55
  }),
56
+ bodyFormat: createSelectField({ options: BODY_FORMATS, default: DEFAULT_BODY_FORMAT }),
54
57
  description: createTextField({ maxLength: 500, personal: false, reason: "is_business_data" }),
55
58
  ogImage: createTextField({ maxLength: 2000, personal: false, reason: "is_business_data" }),
56
59
  published: createBooleanField({ default: false }),
@@ -71,6 +74,7 @@ export type PageRow = {
71
74
  readonly lang: string;
72
75
  readonly title: string;
73
76
  readonly body: string | null;
77
+ readonly bodyFormat: BodyFormat | null;
74
78
  readonly description: string | null;
75
79
  readonly ogImage: string | null;
76
80
  readonly published: boolean;
@@ -1,12 +1,13 @@
1
- // Client feature factory for managed-pages. It provides ONLY translations —
2
- // the screens (page-list/page-edit/branding-settings) are schema-driven
3
- // (entityList/entityEdit/configEdit), so no custom React components are
4
- // registered. Apps mount it in createKumikoApp({ clientFeatures:
1
+ // Client feature factory for managed-pages: translations plus the page-body
2
+ // extension section (document editor) of the page-edit screen. The screens
3
+ // themselves are schema-driven. Apps mount it in createKumikoApp({ clientFeatures:
5
4
  // [managedPagesClient()] }) so the admin labels render translated.
6
5
 
7
6
  import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
8
7
  import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
8
+ import { PAGE_BODY_EXTENSION_NAME } from "./constants.ts";
9
9
  import { defaultTranslations } from "./default-translations.ts";
10
+ import { PageBodySection } from "./page-body-section.tsx";
10
11
 
11
12
  export type ManagedPagesClientOptions = {
12
13
  /** Key-wise overrides of the default bundles (de/en). */
@@ -17,5 +18,6 @@ export function managedPagesClient(options?: ManagedPagesClientOptions): ClientF
17
18
  return {
18
19
  name: "managed-pages",
19
20
  translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
21
+ extensionSectionComponents: { [PAGE_BODY_EXTENSION_NAME]: PageBodySection },
20
22
  };
21
23
  }
@@ -0,0 +1,14 @@
1
+ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
2
+
3
+ // Pure data shared by the server (screen, handlers, render) and the client plugin.
4
+ export const PAGE_BODY_EXTENSION_NAME = "managed-pages:page-body";
5
+
6
+ export const BODY_FORMATS = ["markdown", "document"] as const;
7
+ export type BodyFormat = (typeof BODY_FORMATS)[number];
8
+
9
+ // Rows and events written before bodyFormat existed carry no value.
10
+ export const DEFAULT_BODY_FORMAT: BodyFormat = "markdown";
11
+
12
+ export function effectiveBodyFormat(value: unknown): BodyFormat {
13
+ return isOneOf(BODY_FORMATS, value) ? value : DEFAULT_BODY_FORMAT;
14
+ }
@@ -16,6 +16,7 @@ export const MANAGED_PAGES_I18N: Readonly<Record<string, LocalizedString>> = {
16
16
  // Screen titles (global/unprefixed, screenTitleKey = screen:<id>.title)
17
17
  "screen:page-list.title": { en: "Pages" },
18
18
  "screen:page-edit.title": { en: "Edit page" },
19
+ "screen:page-edit.createTitle": { en: "New page" },
19
20
  "screen:branding-settings.title": { en: "Branding" },
20
21
 
21
22
  // Page-Entity Feld-Labels (managed-pages:entity:page:field:*)
@@ -49,6 +50,19 @@ export const MANAGED_PAGES_I18N: Readonly<Record<string, LocalizedString>> = {
49
50
  "managed-pages:section.body": { en: "Content" },
50
51
  "managed-pages:branding.section.identity": { en: "Identity" },
51
52
 
53
+ // Document body: write-time rejection and the Markdown conversion warning
54
+ "managed-pages.errors.document.rejected": {
55
+ en: "The content contains elements that are not allowed: {detail}",
56
+ },
57
+ "managed-pages:body.lossy.warning": {
58
+ en: "This page was written in Markdown. Converting it to the editor drops: {features}. Check the result before saving.",
59
+ },
60
+ "managed-pages:body.lossy.image": { en: "images" },
61
+ "managed-pages:body.lossy.blockquote": { en: "block quotes" },
62
+ "managed-pages:body.lossy.code": { en: "code" },
63
+ "managed-pages:body.lossy.deepHeading": { en: "headings below level 3" },
64
+ "managed-pages:body.lossy.relativeLink": { en: "relative links" },
65
+
52
66
  // Row-Actions + Confirm (pageListScreen)
53
67
  "managed-pages:actions.edit": { en: "Edit" },
54
68
  "managed-pages:actions.delete": { en: "Delete" },
@@ -0,0 +1,71 @@
1
+ import { isAllowedDocumentLinkHref } from "@cosmicdrift/kumiko-bundled-features/template-resolver/document-schema";
2
+ import { Lexer, type Token } from "marked";
3
+
4
+ export const LOSSY_MARKDOWN_FEATURES = [
5
+ "image",
6
+ "blockquote",
7
+ "code",
8
+ "deepHeading",
9
+ "relativeLink",
10
+ ] as const;
11
+ export type LossyMarkdownFeature = (typeof LOSSY_MARKDOWN_FEATURES)[number];
12
+
13
+ // The document editor has no image, quote or code nodes, only three heading
14
+ // levels, and links must pass the document link policy (absolute http(s),
15
+ // mailto, tel, placeholders).
16
+ const MAX_DOCUMENT_HEADING_DEPTH = 3;
17
+
18
+ function tokenList(value: unknown): readonly Token[] {
19
+ return Array.isArray(value) ? value : [];
20
+ }
21
+
22
+ function childTokens(token: Token): readonly Token[] {
23
+ const nested = [...tokenList("tokens" in token ? token.tokens : undefined)];
24
+ if (token.type === "list") nested.push(...tokenList("items" in token ? token.items : undefined));
25
+ return nested;
26
+ }
27
+
28
+ function isDeepHeading(token: Token): boolean {
29
+ return (
30
+ "depth" in token && typeof token.depth === "number" && token.depth > MAX_DOCUMENT_HEADING_DEPTH
31
+ );
32
+ }
33
+
34
+ function isRelativeLink(token: Token): boolean {
35
+ return (
36
+ "href" in token && typeof token.href === "string" && !isAllowedDocumentLinkHref(token.href)
37
+ );
38
+ }
39
+
40
+ function featuresOf(token: Token): readonly LossyMarkdownFeature[] {
41
+ switch (token.type) {
42
+ case "image":
43
+ return ["image"];
44
+ case "blockquote":
45
+ return ["blockquote"];
46
+ case "code":
47
+ case "codespan":
48
+ return ["code"];
49
+ case "heading":
50
+ return isDeepHeading(token) ? ["deepHeading"] : [];
51
+ case "link":
52
+ return isRelativeLink(token) ? ["relativeLink"] : [];
53
+ default:
54
+ return [];
55
+ }
56
+ }
57
+
58
+ function collect(tokens: readonly Token[], found: Set<LossyMarkdownFeature>): void {
59
+ for (const token of tokens) {
60
+ for (const feature of featuresOf(token)) found.add(feature);
61
+ collect(childTokens(token), found);
62
+ }
63
+ }
64
+
65
+ // Lexer options match the renderer used for conversion (gfm off), so the
66
+ // warning lists exactly what the converted HTML loses.
67
+ export function detectLossyMarkdown(markdown: string): readonly LossyMarkdownFeature[] {
68
+ const found = new Set<LossyMarkdownFeature>();
69
+ collect(new Lexer({ gfm: false, breaks: false }).lex(markdown), found);
70
+ return LOSSY_MARKDOWN_FEATURES.filter((feature) => found.has(feature));
71
+ }