@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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +15 -5
- package/packages/bundled-features/src/account-security/README.md +1 -1
- package/packages/bundled-features/src/account-security/changes.json +6 -0
- package/packages/bundled-features/src/account-security/feature.ts +9 -1
- package/packages/bundled-features/src/audit/event-classification.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
- package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +10 -52
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -4
- package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +3 -12
- package/packages/bundled-features/src/auth-email-password/web/index.ts +2 -0
- package/packages/bundled-features/src/auth-email-password/web/login-failure.ts +54 -0
- package/packages/bundled-features/src/auth-email-password/web/login-url.ts +19 -0
- package/packages/bundled-features/src/auth-email-password/web/static-login.ts +183 -0
- package/packages/bundled-features/src/auth-mfa/web/mfa-client.ts +7 -63
- package/packages/bundled-features/src/billing-foundation/changes.json +30 -0
- package/packages/bundled-features/src/billing-foundation/checkout-core.ts +8 -16
- package/packages/bundled-features/src/billing-foundation/events.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +9 -2
- package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/one-off-checkout-gate.ts +44 -0
- package/packages/bundled-features/src/billing-foundation/one-off-customer.ts +17 -0
- package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/types.ts +26 -0
- package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +1 -0
- package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +8 -2
- package/packages/bundled-features/src/billing-foundation/web/plan-switch-recurring.ts +23 -0
- package/packages/bundled-features/src/billing-foundation/webhook-handler.ts +1 -0
- package/packages/bundled-features/src/cap-overview/changes.json +6 -0
- package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +3 -3
- package/packages/bundled-features/src/cap-overview/types.ts +2 -0
- package/packages/bundled-features/src/compliance-profiles/web/compliance-profile-catalog.tsx +10 -1
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +12 -3
- package/packages/bundled-features/src/files/changes.json +18 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-health.ts +146 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-poll-schedule.ts +74 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-poll.ts +238 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-record.ts +2 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-wake.ts +21 -0
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +143 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-callback-verify.ts +65 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-route-responses.ts +26 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +93 -62
- package/packages/bundled-features/src/inbound-mail-foundation/constants.ts +66 -3
- package/packages/bundled-features/src/inbound-mail-foundation/content-refetch.ts +137 -0
- package/packages/bundled-features/src/inbound-mail-foundation/describe-error.ts +5 -0
- package/packages/bundled-features/src/inbound-mail-foundation/entities.ts +45 -2
- package/packages/bundled-features/src/inbound-mail-foundation/events.ts +69 -2
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +133 -118
- package/packages/bundled-features/src/inbound-mail-foundation/file-storage-failure.ts +23 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/account-address.ts +79 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/acknowledge-quarantine.write.ts +55 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/attach-message-content.write.ts +184 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +137 -84
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/disconnect-account.write.ts +65 -45
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-prepare.ts +177 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-schema.ts +42 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +79 -160
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +93 -30
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts +46 -30
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-quarantine.query.ts +106 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/mark-credential-stored.write.ts +47 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.query.ts +99 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.ts +229 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-detail.query.ts +34 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/reactivate-account.write.ts +129 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/retry-quarantined-message.write.ts +49 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/scope-visibility.ts +48 -9
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/set-trusted-authserv-id.write.ts +66 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +136 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/trusted-authserv-id.ts +4 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +82 -60
- package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +80 -2
- package/packages/bundled-features/src/inbound-mail-foundation/inbound-oauth-flow.ts +48 -0
- package/packages/bundled-features/src/inbound-mail-foundation/index.ts +93 -15
- package/packages/bundled-features/src/inbound-mail-foundation/ingest-failures.ts +137 -0
- package/packages/bundled-features/src/inbound-mail-foundation/ingest-payload.ts +46 -0
- package/packages/bundled-features/src/inbound-mail-foundation/mail-roles.ts +132 -0
- package/packages/bundled-features/src/inbound-mail-foundation/normalize-inbound-message.ts +119 -0
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +26 -8
- package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachment-access.ts +28 -0
- package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachments.ts +111 -0
- package/packages/bundled-features/src/inbound-mail-foundation/outbound-mail.ts +19 -1
- package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +2 -1
- package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +33 -2
- package/packages/bundled-features/src/inbound-mail-foundation/provider-errors.ts +74 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine-entities.ts +56 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine-header-import.ts +63 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine-retry.ts +137 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine-screens.ts +66 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine.ts +72 -0
- package/packages/bundled-features/src/inbound-mail-foundation/retention-job.ts +78 -0
- package/packages/bundled-features/src/inbound-mail-foundation/retention-sweep.ts +43 -8
- package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +238 -89
- package/packages/bundled-features/src/inbound-mail-foundation/send-from-account.ts +24 -5
- package/packages/bundled-features/src/inbound-mail-foundation/sender-auth.ts +215 -0
- package/packages/bundled-features/src/inbound-mail-foundation/supervisor-ingest.ts +311 -0
- package/packages/bundled-features/src/inbound-mail-foundation/sync-account-now.ts +61 -0
- package/packages/bundled-features/src/inbound-mail-foundation/sync-cursor-store.ts +79 -0
- package/packages/bundled-features/src/inbound-mail-foundation/syncable-accounts.ts +123 -0
- package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +5 -1
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +76 -105
- package/packages/bundled-features/src/inbound-mail-foundation/wake-consumer.ts +33 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-catch-up.ts +68 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-lease.ts +124 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor-types.ts +87 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +143 -433
- package/packages/bundled-features/src/inbound-mail-foundation/web/client-plugin.ts +73 -0
- package/packages/bundled-features/src/inbound-mail-foundation/web/constants.ts +7 -0
- package/packages/bundled-features/src/inbound-mail-foundation/web/index.ts +5 -0
- package/packages/bundled-features/src/inbound-provider-imap/changes.json +36 -0
- package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +124 -41
- package/packages/bundled-features/src/inbound-provider-imap/connect-my-mailbox.ts +45 -0
- package/packages/bundled-features/src/inbound-provider-imap/constants.ts +2 -1
- package/packages/bundled-features/src/inbound-provider-imap/credential-document.ts +2 -1
- package/packages/bundled-features/src/inbound-provider-imap/download-batch.ts +38 -0
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +61 -136
- package/packages/bundled-features/src/inbound-provider-imap/fetch-content.ts +84 -0
- package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +9 -0
- package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +35 -84
- package/packages/bundled-features/src/inbound-provider-imap/index.ts +1 -2
- package/packages/bundled-features/src/inbound-provider-imap/mime-content.ts +85 -0
- package/packages/bundled-features/src/inbound-provider-imap/parse-message.ts +149 -0
- package/packages/bundled-features/src/inbound-provider-imap/reconnect-account.ts +4 -6
- package/packages/bundled-features/src/inbound-provider-imap/reconnect-mailbox.ts +75 -45
- package/packages/bundled-features/src/inbound-provider-imap/reconnect-prefill.query.ts +19 -8
- package/packages/bundled-features/src/inbound-provider-imap/screens.ts +203 -109
- package/packages/bundled-features/src/inbound-provider-imap/send-mail.ts +11 -2
- package/packages/bundled-features/src/inbound-provider-imap/sender-auth-headers.ts +36 -0
- package/packages/bundled-features/src/inbound-provider-imap/watch-mailbox.ts +109 -0
- package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +75 -3
- package/packages/bundled-features/src/inbound-provider-inmemory/index.ts +6 -0
- package/packages/bundled-features/src/jobs/changes.json +18 -0
- package/packages/bundled-features/src/managed-pages/changes.json +7 -0
- package/packages/bundled-features/src/managed-pages/constants.ts +7 -0
- package/packages/bundled-features/src/managed-pages/feature.ts +6 -4
- package/packages/bundled-features/src/managed-pages/handlers/by-slug.query.ts +2 -0
- package/packages/bundled-features/src/managed-pages/handlers/page-body-guard.ts +61 -0
- package/packages/bundled-features/src/managed-pages/handlers/page-body-issues.ts +26 -0
- package/packages/bundled-features/src/managed-pages/handlers/page-crud.ts +17 -10
- package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +15 -4
- package/packages/bundled-features/src/managed-pages/render-page-body.ts +28 -0
- package/packages/bundled-features/src/managed-pages/screens/page-screens.ts +6 -2
- package/packages/bundled-features/src/managed-pages/seeding.ts +13 -2
- package/packages/bundled-features/src/managed-pages/table.ts +6 -2
- package/packages/bundled-features/src/managed-pages/web/client-plugin.tsx +6 -4
- package/packages/bundled-features/src/managed-pages/web/constants.ts +14 -0
- package/packages/bundled-features/src/managed-pages/web/i18n.ts +14 -0
- package/packages/bundled-features/src/managed-pages/web/lossy-markdown.ts +71 -0
- package/packages/bundled-features/src/managed-pages/web/markdown-to-document.ts +27 -0
- package/packages/bundled-features/src/managed-pages/web/page-body-section.tsx +73 -0
- package/packages/bundled-features/src/subscription-paddle/changes.json +15 -0
- package/packages/bundled-features/src/subscription-paddle/checkout-config.query.ts +17 -0
- package/packages/bundled-features/src/subscription-paddle/checkout-methods.ts +107 -0
- package/packages/bundled-features/src/subscription-paddle/checkout-page.ts +154 -0
- package/packages/bundled-features/src/subscription-paddle/client.ts +78 -0
- package/packages/bundled-features/src/subscription-paddle/constants.ts +32 -0
- package/packages/bundled-features/src/subscription-paddle/feature.ts +265 -0
- package/packages/bundled-features/src/subscription-paddle/index.ts +17 -0
- package/packages/bundled-features/src/subscription-paddle/plan-switch-methods.ts +135 -0
- package/packages/bundled-features/src/subscription-paddle/price-methods.ts +148 -0
- package/packages/bundled-features/src/subscription-paddle/runtime.ts +203 -0
- package/packages/bundled-features/src/subscription-paddle/subscription-state.ts +73 -0
- package/packages/bundled-features/src/subscription-paddle/tenant-binding.ts +34 -0
- package/packages/bundled-features/src/subscription-paddle/verify-webhook.ts +61 -0
- package/packages/bundled-features/src/subscription-paddle/webhook-events.ts +172 -0
- package/packages/bundled-features/src/template-resolver/README.md +2 -2
- package/packages/bundled-features/src/template-resolver/changes.json +20 -0
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +16 -16
- package/packages/bundled-features/src/template-resolver/feature.ts +14 -1
- package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +2 -2
- package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +6 -2
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +24 -6
- package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +32 -0
- package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +1 -0
- package/packages/bundled-features/src/template-resolver/handlers/shared.ts +9 -3
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +13 -480
- package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +105 -0
- package/packages/bundled-features/src/template-resolver/web/collection-lookup.ts +33 -0
- package/packages/bundled-features/src/template-resolver/web/constants.ts +3 -0
- package/packages/bundled-features/src/template-resolver/web/content-field.tsx +61 -0
- package/packages/bundled-features/src/template-resolver/web/content-tree-provider.ts +256 -0
- package/packages/bundled-features/src/template-resolver/web/document-reference-name.ts +12 -0
- package/packages/bundled-features/src/template-resolver/web/document-schema.ts +3 -11
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +9 -2
- package/packages/bundled-features/src/template-resolver/web/legacy-variable-text.ts +30 -0
- package/packages/bundled-features/src/template-resolver/web/tenant-override.ts +7 -0
- package/packages/bundled-features/src/template-resolver/web/text-block-editor.tsx +92 -0
- package/packages/bundled-features/src/template-resolver/web/use-collection-editor-context.ts +42 -0
- package/packages/bundled-features/src/template-resolver/web/use-collection-entry-create.ts +92 -0
- package/packages/bundled-features/src/template-resolver/web/use-content-write.ts +81 -0
- package/packages/bundled-features/src/template-resolver/web/use-text-block-editor.ts +127 -0
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +9 -1
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +2 -1
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +4 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +11 -5
- package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +2 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +89 -40
- package/samples/apps/use-all-bundled/kumiko/migrations/0056_inbound-mail-quarantine-retry.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/package.json +1 -0
- package/samples/apps/use-all-bundled/playwright.billing-docs.config.ts +15 -0
- package/samples/apps/use-all-bundled/src/app/cap-overview-caps.ts +16 -2
- package/samples/apps/use-all-bundled/src/app/cap-overview-labels-feature.ts +2 -1
- package/samples/apps/use-all-bundled/src/app/client.tsx +8 -1
- package/samples/apps/use-all-bundled/src/app/screens-feature.ts +14 -2
- package/samples/apps/use-all-bundled/src/app/seed.ts +17 -2
- package/samples/apps/use-all-bundled/src/run-config.ts +19 -11
- package/samples/recipes/cross-feature-events/README.md +10 -0
- package/samples/recipes/cross-feature-events/src/feature.ts +14 -0
- package/samples/recipes/dashboard-stats/README.md +8 -0
- package/samples/recipes/dashboard-stats/src/feature.ts +91 -1
- package/samples/recipes/env-composition/README.md +2 -2
- package/samples/recipes/env-composition/src/env.ts +2 -2
- package/samples/recipes/inbound-mail-triage/README.md +8 -0
- package/samples/recipes/pdf-audit-export/src/feature.ts +71 -48
- package/packages/bundled-features/src/subscription-dodo/changes.json +0 -14
- package/packages/bundled-features/src/subscription-dodo/client.ts +0 -45
- package/packages/bundled-features/src/subscription-dodo/constants.ts +0 -24
- package/packages/bundled-features/src/subscription-dodo/feature.ts +0 -207
- package/packages/bundled-features/src/subscription-dodo/index.ts +0 -16
- package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +0 -345
- package/packages/bundled-features/src/subscription-dodo/runtime.ts +0 -126
- 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",
|
|
@@ -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 `
|
|
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:
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
+
}
|