@michaelthielemann/kestrel 3.0.0 → 4.0.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/README.md +35 -110
- package/kestrel.config.ts +1 -1
- package/layers/access/server/middleware/00.ip-allowlist.ts +3 -18
- package/layers/access/server/middleware/access-guard.ts +25 -23
- package/layers/access/server/types.d.ts +1 -2
- package/layers/admin/app/components/AdminAccount.vue +1 -1
- package/layers/admin/app/components/BlockFields.vue +1 -2
- package/layers/admin/app/components/BlockPreview.vue +17 -5
- package/layers/admin/app/components/BlocksBody.vue +1 -1
- package/layers/admin/app/components/CollectionEditor.vue +35 -16
- package/layers/admin/app/components/CollectionList.vue +34 -3
- package/layers/admin/app/components/CollectionListBulkBar.vue +17 -3
- package/layers/admin/app/components/CollectionListFilterPanel.vue +1 -1
- package/layers/admin/app/components/CollectionListPager.vue +1 -1
- package/layers/admin/app/components/CollectionListTable.vue +41 -2
- package/layers/admin/app/components/EditorStatus.vue +8 -7
- package/layers/admin/app/components/LocaleBar.vue +3 -1
- package/layers/admin/app/components/PageFields.vue +2 -4
- package/layers/admin/app/components/SeoFields.vue +2 -4
- package/layers/admin/app/components/SingletonEditor.vue +3 -2
- package/layers/admin/app/composables/useAuth.ts +3 -3
- package/layers/admin/app/composables/useBlockTree.ts +1 -1
- package/layers/admin/app/composables/useBlocks.ts +1 -1
- package/layers/admin/app/composables/useCollectionOps.ts +45 -8
- package/layers/admin/app/composables/useCollections.ts +1 -2
- package/layers/admin/app/composables/useEditForm.ts +35 -17
- package/layers/admin/app/composables/useLinkResolver.ts +1 -1
- package/layers/admin/app/composables/useListBatchActions.ts +20 -3
- package/layers/admin/app/composables/useListColumns.ts +1 -1
- package/layers/admin/app/composables/useListFilterDraft.ts +3 -2
- package/layers/admin/app/composables/useListRows.ts +7 -2
- package/layers/admin/app/composables/useListUrlState.ts +1 -1
- package/layers/admin/app/composables/usePublishStatus.ts +2 -2
- package/layers/admin/app/layouts/admin.vue +0 -1
- package/layers/admin/app/pages/admin/[collection]/[id].vue +5 -4
- package/layers/admin/app/pages/admin/index.vue +1 -1
- package/layers/admin/app/pages/admin/references.vue +2 -2
- package/layers/admin/app/utils/block-tree.ts +1 -1
- package/layers/admin/app/utils/collection-ops.ts +28 -3
- package/layers/admin/app/utils/edit-form.ts +34 -2
- package/layers/admin/app/utils/editor-expose.ts +2 -0
- package/layers/admin/app/utils/editor-form-context.ts +1 -2
- package/layers/admin/app/utils/field-keys.ts +1 -1
- package/layers/admin/app/utils/list-columns.ts +3 -2
- package/layers/admin/app/utils/list-query.ts +2 -2
- package/layers/admin/app/utils/populate-blocks.ts +2 -2
- package/layers/admin/app/utils/reauth.ts +5 -6
- package/layers/auth/server/plugins/01.register-auth-pipelines.ts +7 -0
- package/layers/collections/server/plugins/01.register.ts +9 -12
- package/layers/collections/server/plugins/02.register-relation-populate.ts +5 -7
- package/layers/{fields → core}/app/utils/field-factories.ts +1 -1
- package/layers/core/modules/auto-discovery/extract-block.ts +3 -3
- package/layers/core/modules/auto-discovery/index.ts +68 -11
- package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
- package/layers/core/modules/auto-discovery/scan.ts +77 -0
- package/layers/core/modules/auto-discovery/virtual.d.ts +5 -0
- package/layers/core/modules/component-namespace/index.ts +24 -0
- package/layers/core/modules/kestrel/index.ts +6 -1
- package/layers/core/modules/plugin-order/index.ts +69 -0
- package/layers/core/modules/plugin-order/plugin-order.ts +273 -0
- package/layers/core/nuxt.config.ts +1 -0
- package/layers/core/server/api/[...path].ts +98 -0
- package/layers/core/server/plugins/00.config.ts +12 -0
- package/layers/core/server/plugins/00.migrate.ts +1 -0
- package/layers/core/server/plugins/01.register-introspection-pipeline.ts +5 -0
- package/layers/core/server/plugins/01.register-openapi-pipeline.ts +5 -0
- package/layers/core/server/plugins/01.register-tooling-pipelines.ts +5 -0
- package/layers/core/server/plugins/02.schema-sync.ts +2 -7
- package/layers/core/server/plugins/04.outbox-worker.ts +31 -0
- package/layers/core/server/plugins/05.reindex-refs.ts +5 -0
- package/layers/core/server/routes/__kestrel/dashboard.get.ts +29 -0
- package/layers/core/server/tasks/db/migrate-module.ts +83 -0
- package/layers/core/server/tasks/db/migrate-revisions.ts +20 -0
- package/layers/core/server/tasks/db/migrate.ts +2 -6
- package/layers/core/server/utils/server-config.ts +66 -3
- package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +7 -0
- package/layers/fields/server/plugins/01.register-field-populate.ts +2 -2
- package/layers/media/app/components/KestrelImg.vue +3 -3
- package/layers/media/app/components/MediaLibrary.vue +2 -2
- package/layers/media/app/components/field/Media.vue +1 -1
- package/layers/media/app/composables/useMediaResolver.ts +1 -1
- package/layers/media/app/composables/useMediaUpload.ts +1 -1
- package/layers/media/app/composables/useMediaVariant.ts +2 -2
- package/layers/media/app/utils/ai-disclosure.ts +1 -1
- package/layers/media/app/utils/library.ts +1 -1
- package/layers/media/app/utils/ops.ts +1 -3
- package/layers/media/app/utils/picture-model.ts +2 -2
- package/layers/media/app/utils/variant-name.ts +1 -1
- package/layers/media/server/middleware/ondemand-variants.ts +6 -5
- package/layers/media/server/plugins/01.register-media-pipelines.ts +7 -0
- package/layers/media/server/plugins/02.register-media.ts +10 -7
- package/layers/media/server/plugins/04.variant-capture.ts +12 -8
- package/layers/media/server/plugins/05.media-cleanup.ts +5 -0
- package/layers/media/server/tasks/media/backfill.ts +3 -4
- package/layers/public/app/components/KestrelLink.vue +1 -1
- package/layers/public/app/pages/[...slug].vue +1 -1
- package/layers/public/app/pages/__kestrel/preview.vue +2 -2
- package/layers/public/app/utils/json-ld.ts +1 -1
- package/layers/public/app/utils/link-href.ts +1 -1
- package/layers/public/app/utils/page-head.ts +1 -1
- package/layers/public/modules/deploy-output/index.ts +4 -6
- package/layers/public/modules/prerender-routes/index.ts +4 -4
- package/layers/public/modules/prerender-routes/routes.ts +1 -1
- package/layers/public/modules/prune-media/index.ts +2 -2
- package/layers/public/server/delivery-live/serve.ts +111 -0
- package/layers/public/server/middleware/delivery-live-catchall.ts +7 -0
- package/layers/public/server/pipelines/index.ts +23 -0
- package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +14 -0
- package/layers/public/server/plugins/01.register-public-pipelines.ts +7 -0
- package/layers/public/server/plugins/02.register-links.ts +2 -3
- package/layers/public/server/plugins/03.redirects.ts +41 -23
- package/layers/public/server/plugins/05.plan-publish.ts +5 -0
- package/layers/public/server/plugins/zz.publish.ts +79 -24
- package/layers/public/server/routes/llms-full.txt.get.ts +5 -5
- package/layers/public/server/routes/llms.txt.get.ts +4 -2
- package/layers/public/server/routes/redirects.json.get.ts +2 -2
- package/layers/public/server/routes/robots.txt.get.ts +2 -0
- package/layers/public/server/routes/sitemap.xml.get.ts +16 -6
- package/layers/public/server/tasks/publish/run.ts +9 -4
- package/layers/public/server/utils/publish/render-live.ts +26 -0
- package/layers/ui/app/components/field/Choice.vue +1 -1
- package/layers/ui/app/components/field/Datetime.vue +1 -1
- package/layers/ui/app/components/field/Json.vue +1 -2
- package/layers/ui/app/components/field/Layout.vue +2 -4
- package/layers/ui/app/components/field/Link.vue +1 -1
- package/layers/ui/app/components/field/LinkInternalPicker.vue +3 -3
- package/layers/ui/app/components/field/Number.vue +2 -2
- package/layers/ui/app/components/field/Relation.vue +1 -1
- package/layers/ui/app/components/field/Repeater.vue +6 -8
- package/layers/ui/app/components/field/Slug.vue +2 -3
- package/layers/ui/app/components/field/Text.vue +1 -1
- package/layers/ui/app/components/ui/Button.vue +2 -2
- package/layers/ui/app/components/ui/ButtonGroup.vue +2 -2
- package/layers/ui/app/components/ui/Checkbox.vue +2 -2
- package/layers/ui/app/components/ui/Combobox.vue +5 -5
- package/layers/ui/app/components/ui/DatePicker.vue +2 -2
- package/layers/ui/app/components/ui/DateRangePicker.vue +2 -2
- package/layers/ui/app/components/ui/NumberInput.vue +4 -4
- package/layers/ui/app/components/ui/Richtext.vue +1 -1
- package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
- package/layers/ui/app/components/ui/Select.vue +2 -2
- package/layers/ui/app/components/ui/TextInput.vue +4 -4
- package/layers/ui/app/components/ui/Textarea.vue +2 -2
- package/layers/ui/app/components/ui/TimeInput.vue +2 -2
- package/layers/ui/app/components/ui/Toasts.vue +3 -3
- package/layers/ui/app/composables/useLinkField.ts +1 -1
- package/layers/ui/app/composables/useRepeater.ts +1 -1
- package/layers/ui/app/i18n/de.ts +7 -7
- package/layers/ui/app/i18n/en.ts +7 -39
- package/layers/ui/app/utils/field-component.ts +1 -1
- package/layers/ui/app/utils/field-registry.ts +1 -1
- package/layers/ui/app/utils/icons.ts +2 -6
- package/layers/ui/app/utils/localized.ts +1 -1
- package/nuxt.config.ts +1 -1
- package/package.json +53 -19
- package/scripts/consumer-template-ci.mjs +157 -0
- package/scripts/coverage-all.mjs +163 -0
- package/scripts/dashboard.mjs +89 -0
- package/scripts/hash-password.mjs +1 -1
- package/scripts/lib/coverage-model.mjs +51 -0
- package/scripts/port-graph.mjs +350 -0
- package/scripts/typecheck.mjs +11 -0
- package/templates/starter/app/blocks/Prose.vue +1 -1
- package/layers/access/server/utils/csrf.ts +0 -39
- package/layers/access/server/utils/grant-registry.ts +0 -36
- package/layers/access/server/utils/guard.ts +0 -44
- package/layers/access/server/utils/ip-allowlist.ts +0 -79
- package/layers/access/server/utils/policy.ts +0 -98
- package/layers/access/server/utils/public-resources.ts +0 -11
- package/layers/access/server/utils/render-context.ts +0 -41
- package/layers/access/server/utils/require-admin.ts +0 -15
- package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
- package/layers/auth/server/api/auth/login.post.ts +0 -38
- package/layers/auth/server/api/auth/logout.post.ts +0 -9
- package/layers/auth/server/api/auth/session.get.ts +0 -1
- package/layers/auth/server/utils/admin-credential.ts +0 -42
- package/layers/auth/server/utils/client-ip.ts +0 -88
- package/layers/auth/server/utils/login-throttle.ts +0 -126
- package/layers/auth/server/utils/password.ts +0 -52
- package/layers/auth/server/utils/session-cookie.ts +0 -44
- package/layers/auth/server/utils/session-epoch.ts +0 -96
- package/layers/auth/server/utils/session.ts +0 -88
- package/layers/collections/server/collections/pages.ts +0 -19
- package/layers/collections/server/utils/populate-relations.ts +0 -79
- package/layers/core/app/utils/bulk-actions.ts +0 -9
- package/layers/core/app/utils/filter-ops.ts +0 -98
- package/layers/core/app/utils/list-limits.ts +0 -27
- package/layers/core/app/utils/locale-path.ts +0 -12
- package/layers/core/app/utils/slugify.ts +0 -16
- package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
- package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
- package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
- package/layers/core/server/api/[collection]/[id].get.ts +0 -11
- package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
- package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
- package/layers/core/server/api/[collection]/index.get.ts +0 -24
- package/layers/core/server/api/[collection]/index.post.ts +0 -8
- package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
- package/layers/core/server/api/[collection]/index.put.ts +0 -24
- package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
- package/layers/core/server/api/[collection]/options.get.ts +0 -18
- package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
- package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
- package/layers/core/server/api/[collection]/translations.get.ts +0 -25
- package/layers/core/server/api/collections/[name].get.ts +0 -4
- package/layers/core/server/api/collections/index.get.ts +0 -3
- package/layers/core/server/api/references/broken.get.ts +0 -9
- package/layers/core/server/api/references/referrers.get.ts +0 -24
- package/layers/core/server/database/record-refs.ts +0 -20
- package/layers/core/server/plugins/03.record-refs.ts +0 -12
- package/layers/core/server/schema/bootstrap.ts +0 -41
- package/layers/core/server/schema/desired.ts +0 -99
- package/layers/core/server/schema/dev-sync.ts +0 -23
- package/layers/core/server/schema/dialect.ts +0 -61
- package/layers/core/server/schema/diff.ts +0 -110
- package/layers/core/server/schema/introspect.ts +0 -68
- package/layers/core/server/schema/model.ts +0 -61
- package/layers/core/server/schema/render-sqlite.ts +0 -89
- package/layers/core/server/schema/sync.ts +0 -201
- package/layers/core/server/utils/blocks.ts +0 -6
- package/layers/core/server/utils/collection-types.ts +0 -28
- package/layers/core/server/utils/crud.ts +0 -578
- package/layers/core/server/utils/db.ts +0 -26
- package/layers/core/server/utils/defineCollection.ts +0 -213
- package/layers/core/server/utils/duplicate.ts +0 -162
- package/layers/core/server/utils/field-layout.ts +0 -125
- package/layers/core/server/utils/filter-predicate.ts +0 -101
- package/layers/core/server/utils/http.ts +0 -50
- package/layers/core/server/utils/if-unmodified.ts +0 -13
- package/layers/core/server/utils/kestrel-config.ts +0 -451
- package/layers/core/server/utils/key-lock.ts +0 -33
- package/layers/core/server/utils/locale.ts +0 -53
- package/layers/core/server/utils/page-route.ts +0 -58
- package/layers/core/server/utils/page-slug.ts +0 -127
- package/layers/core/server/utils/picker.ts +0 -82
- package/layers/core/server/utils/populate.ts +0 -50
- package/layers/core/server/utils/prerender-memo.ts +0 -23
- package/layers/core/server/utils/read-capture.ts +0 -37
- package/layers/core/server/utils/record-ref-index.ts +0 -200
- package/layers/core/server/utils/registry.ts +0 -24
- package/layers/core/server/utils/resolve-scope.ts +0 -79
- package/layers/core/server/utils/seo.ts +0 -42
- package/layers/core/server/utils/serialize-collection.ts +0 -147
- package/layers/core/server/utils/sql.ts +0 -2
- package/layers/core/server/utils/storage.local.ts +0 -117
- package/layers/core/server/utils/storage.s3.ts +0 -197
- package/layers/core/server/utils/storage.ts +0 -90
- package/layers/core/server/utils/write-effects.ts +0 -40
- package/layers/core/server/utils/write-events.ts +0 -31
- package/layers/fields/app/utils/condition.ts +0 -71
- package/layers/fields/app/utils/define-block.ts +0 -13
- package/layers/fields/app/utils/field-constraints.ts +0 -17
- package/layers/fields/app/utils/field-meta.ts +0 -33
- package/layers/fields/app/utils/field-validate.ts +0 -75
- package/layers/fields/app/utils/field-value.ts +0 -7
- package/layers/fields/app/utils/richtext-links.ts +0 -48
- package/layers/fields/server/api/blocks/index.get.ts +0 -9
- package/layers/fields/server/field-registry/index.ts +0 -249
- package/layers/fields/server/field-registry/naming.ts +0 -10
- package/layers/fields/server/field-registry/sanitize.ts +0 -43
- package/layers/fields/server/field-registry/types.ts +0 -19
- package/layers/fields/server/utils/block-ids.ts +0 -43
- package/layers/fields/server/utils/block-populate.ts +0 -41
- package/layers/fields/server/utils/buildCollection.ts +0 -90
- package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
- package/layers/fields/server/utils/buildTable.ts +0 -113
- package/layers/fields/server/utils/conditional-required.ts +0 -25
- package/layers/fields/server/utils/defineBlock.ts +0 -86
- package/layers/fields/server/utils/defineFieldType.ts +0 -33
- package/layers/fields/server/utils/extract-refs.ts +0 -152
- package/layers/fields/server/utils/field-populate.ts +0 -68
- package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
- package/layers/media/server/api/media/[id].delete.ts +0 -18
- package/layers/media/server/api/media/[id].get.ts +0 -10
- package/layers/media/server/api/media/[id].patch.test.ts +0 -66
- package/layers/media/server/api/media/[id].patch.ts +0 -65
- package/layers/media/server/api/media/backfill.post.ts +0 -18
- package/layers/media/server/api/media/copy.post.ts +0 -7
- package/layers/media/server/api/media/delete.post.ts +0 -13
- package/layers/media/server/api/media/folders.post.ts +0 -18
- package/layers/media/server/api/media/index.get.ts +0 -15
- package/layers/media/server/api/media/index.post.ts +0 -173
- package/layers/media/server/api/media/library.get.ts +0 -17
- package/layers/media/server/api/media/move.post.ts +0 -7
- package/layers/media/server/api/media/rename.post.ts +0 -7
- package/layers/media/server/api/media/resolve.get.ts +0 -13
- package/layers/media/server/collections/media-settings.ts +0 -25
- package/layers/media/server/collections/media.ts +0 -43
- package/layers/media/server/database/folders.ts +0 -10
- package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
- package/layers/media/server/schema-tables/folders.ts +0 -4
- package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
- package/layers/media/server/utils/ai-signal-detect.ts +0 -155
- package/layers/media/server/utils/backfill.ts +0 -144
- package/layers/media/server/utils/derive.ts +0 -59
- package/layers/media/server/utils/folder-paths.ts +0 -42
- package/layers/media/server/utils/folders.ts +0 -12
- package/layers/media/server/utils/library.ts +0 -91
- package/layers/media/server/utils/media-cleanup.ts +0 -27
- package/layers/media/server/utils/media-enabled.ts +0 -23
- package/layers/media/server/utils/media-ops.ts +0 -118
- package/layers/media/server/utils/media-write.ts +0 -13
- package/layers/media/server/utils/naming.ts +0 -52
- package/layers/media/server/utils/ondemand.ts +0 -69
- package/layers/media/server/utils/persist-upload.ts +0 -62
- package/layers/media/server/utils/populate.ts +0 -47
- package/layers/media/server/utils/record.ts +0 -71
- package/layers/media/server/utils/relocate-ops.ts +0 -341
- package/layers/media/server/utils/relocate-plan.ts +0 -63
- package/layers/media/server/utils/resolve.ts +0 -98
- package/layers/media/server/utils/sanitize-svg.ts +0 -12
- package/layers/media/server/utils/sniff.ts +0 -68
- package/layers/media/server/utils/storage-relocate.ts +0 -139
- package/layers/media/server/utils/translations.ts +0 -19
- package/layers/media/server/utils/usages.ts +0 -66
- package/layers/media/server/utils/variant-capture.ts +0 -70
- package/layers/media/server/utils/variants.ts +0 -123
- package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
- package/layers/public/server/api/links/resolve.get.ts +0 -19
- package/layers/public/server/api/preview.get.ts +0 -28
- package/layers/public/server/api/preview.post.ts +0 -93
- package/layers/public/server/api/publish-status.get.ts +0 -58
- package/layers/public/server/api/publish.post.ts +0 -84
- package/layers/public/server/api/route.get.ts +0 -54
- package/layers/public/server/collections/redirects.ts +0 -75
- package/layers/public/server/collections/site.ts +0 -44
- package/layers/public/server/database/publish-deps.ts +0 -12
- package/layers/public/server/database/publish-status.ts +0 -19
- package/layers/public/server/schema-tables/publish-deps.ts +0 -4
- package/layers/public/server/schema-tables/publish-status.ts +0 -4
- package/layers/public/server/utils/link-resolve.ts +0 -38
- package/layers/public/server/utils/llms-full.ts +0 -125
- package/layers/public/server/utils/llms.ts +0 -52
- package/layers/public/server/utils/page-resolve.ts +0 -208
- package/layers/public/server/utils/populate-links.ts +0 -66
- package/layers/public/server/utils/preview-token.ts +0 -109
- package/layers/public/server/utils/publish/coalesce.ts +0 -22
- package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
- package/layers/public/server/utils/publish/deps.ts +0 -87
- package/layers/public/server/utils/publish/invalidation.ts +0 -225
- package/layers/public/server/utils/publish/pending.ts +0 -74
- package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
- package/layers/public/server/utils/publish/publish-status.ts +0 -72
- package/layers/public/server/utils/publish/publisher.ts +0 -362
- package/layers/public/server/utils/publish/queue.ts +0 -67
- package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
- package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
- package/layers/public/server/utils/publish/route-for-record.ts +0 -16
- package/layers/public/server/utils/publish/route-keys.ts +0 -13
- package/layers/public/server/utils/richtext-markdown.ts +0 -260
- package/layers/public/server/utils/site-url.ts +0 -40
- package/layers/public/server/utils/sitemap.ts +0 -99
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Kestrel
|
|
2
2
|
|
|
3
|
+
**Please be aware that this project is under rapid development and may not be suitable for long-term projects.**
|
|
4
|
+
|
|
3
5
|
A slim, **static-site-generator CMS**: a generic collection-driven CRUD core with a schema-driven admin
|
|
4
6
|
editor. You declare collections and fields in TypeScript; Kestrel derives the database, a typed CRUD API,
|
|
5
7
|
and a full admin UI from those definitions, and renders published content to static HTML via
|
|
@@ -25,14 +27,9 @@ static host. It is deliberately **not**:
|
|
|
25
27
|
output); everything else stays behind the admin guard. No per-request rate limiting / live API.
|
|
26
28
|
- **No runtime redirect engine** — redirects are *authored* in the CMS and published as a `redirects.json`
|
|
27
29
|
artifact, but Kestrel never answers a 30x itself; an edge (NGINX / njs / CloudFront) has to read it.
|
|
28
|
-
- **No per-file access control on uploads.** The admin guard protects the media *library
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
fetch it; the optional IP allow-list does not cover them either (see
|
|
32
|
-
[configuration.md](./docs/configuration.md#ip-allow-list--optional)). That is the intended model — the
|
|
33
|
-
editing origin is meant to be non-public, and published media is public by definition. If you deploy
|
|
34
|
-
Kestrel as a general-purpose CMS whose uploads must stay private, restrict them at the reverse proxy or
|
|
35
|
-
serve media from a private S3 bucket; the app will not do it for you.
|
|
30
|
+
- **No per-file access control on uploads.** The admin guard protects the media *library*, not the
|
|
31
|
+
bytes; the editing origin is meant to be non-public and published media is public by definition. See
|
|
32
|
+
[media.md](./docs/guide/media.md).
|
|
36
33
|
|
|
37
34
|
## Features
|
|
38
35
|
|
|
@@ -42,9 +39,10 @@ static host. It is deliberately **not**:
|
|
|
42
39
|
- **Collection-driven** — declare collections + fields in TypeScript; Kestrel derives the SQLite tables, a
|
|
43
40
|
typed CRUD REST API, and the full admin UI. The schema **migrates itself** (additive in dev; explicit
|
|
44
41
|
`db:migrate` in prod).
|
|
45
|
-
- **Field types** — text,
|
|
46
|
-
date / time
|
|
47
|
-
(nestable), JSON — plus per-field `condition`
|
|
42
|
+
- **Field types** — twelve built in: text (single- or multi-line), slug, richtext, number, boolean,
|
|
43
|
+
datetime (date / time / range), choice (select / buttons / checkboxes), media, relation (single /
|
|
44
|
+
many), link (internal / external / email / tel), repeater (nestable), JSON — plus per-field `condition`
|
|
45
|
+
visibility rules. Register your own with `defineFieldType`.
|
|
48
46
|
- **Page-like collections** — give a collection a `path`, SEO meta, and block or flat content; published
|
|
49
47
|
records render to static HTML and join the sitemap. **Singletons** for settings / navigation.
|
|
50
48
|
- **Block page-builder** — a 3-pane editor (tree · live preview · fields) with nestable block slots; each
|
|
@@ -119,48 +117,34 @@ content at `/admin`. You bring your own **public layout** and **block SFCs**
|
|
|
119
117
|
> the file `nuxi init` writes renders `<NuxtWelcome />` instead of your routes — the admin then appears to
|
|
120
118
|
> be missing rather than blank. Kestrel reports this at build time; `kestrel doctor` catches it earlier.
|
|
121
119
|
|
|
122
|
-
Full
|
|
120
|
+
Full walkthrough: **[getting-started.md](docs/guide/getting-started.md)**.
|
|
123
121
|
|
|
124
122
|
## Documentation
|
|
125
123
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
| [multilingual.md](docs/multilingual.md) | Content locales, the editor locale flow, locale-prefixed routing |
|
|
138
|
-
| [static-output.md](docs/static-output.md) | `nuxt generate` + the runtime incremental publisher, the live editor preview, `sitemap.xml` / `robots.txt` / `llms.txt` / `llms-full.txt`, the JSON-LD structured data, CMS-managed redirects, the optional S3 deploy |
|
|
139
|
-
| [reference-integrity.md](docs/reference-integrity.md) | How writes invalidate the static site precisely, dead-reference warnings (a dead link renders `#`, and the editor is warned), and required/unique page slugs |
|
|
140
|
-
| [architecture-decisions.md](docs/architecture-decisions.md) | ADRs (the collection-derived schema engine, reference integrity, the auth/password choice) |
|
|
124
|
+
Two doors, one per audience:
|
|
125
|
+
|
|
126
|
+
- **[Guide](docs/guide/README.md)** — building a site with the package: collections, fields, blocks,
|
|
127
|
+
media, publishing, deployment, configuration, extension points.
|
|
128
|
+
- **[Internals](docs/internals/README.md)** — developing Kestrel itself: architecture, layers and
|
|
129
|
+
packages, the pipeline engine, test rails, releasing, the ADR log.
|
|
130
|
+
|
|
131
|
+
Most-asked pages: [getting started](docs/guide/getting-started.md) ·
|
|
132
|
+
[field types](docs/guide/field-types.md) · [blocks](docs/guide/blocks.md) ·
|
|
133
|
+
[configuration](docs/guide/configuration.md) · [deploying](docs/guide/deploying.md) ·
|
|
134
|
+
[troubleshooting](docs/guide/troubleshooting.md).
|
|
141
135
|
|
|
142
136
|
## Layout
|
|
143
137
|
|
|
144
|
-
The
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
lives in the repo root, dev-only, and is **not** shipped in the package.)
|
|
155
|
-
- **`media`** — uploads, pluggable storage, image derivation, the media library + asset viewer.
|
|
156
|
-
- **`admin`** — the editor SPA: collection list, record editor, the 3-pane block editor.
|
|
157
|
-
- **`public`** — the SSG render path: the catch-all page, `KestrelBlockRenderer`, the JSON-LD head, the literal-key
|
|
158
|
-
artifacts (sitemap / robots / llms.txt / llms-full.txt / redirects.json), deploy.
|
|
159
|
-
|
|
160
|
-
`playground/` is a small consuming example. `templates/starter/` is what the scaffolder writes out;
|
|
161
|
-
`scripts/kestrel.mjs` is the engine's CLI and `packages/create-kestrel/` the standalone
|
|
162
|
-
`pnpm create kestrel` front end, which copies the same templates in at pack time rather than keeping
|
|
163
|
-
its own.
|
|
138
|
+
The domain/server code lives in ten `@michaelthielemann/kestrel-*` packages under `packages/` (`contracts`, `core`,
|
|
139
|
+
`fields`, `auth`, `access`, `collections`, `media`, `publishing`, `delivery-live`, `delivery-static`).
|
|
140
|
+
The nine Nuxt layers under `layers/` are thin wiring shells around those packages, except where a surface
|
|
141
|
+
depends on Nuxt's own component resolution and auto-imports: the `ui` and `admin` layers (design system
|
|
142
|
+
and editor SPA) and the `app/` halves of `media` and `public` stay real code in layers. Layers import
|
|
143
|
+
packages by name; packages never import layers.
|
|
144
|
+
|
|
145
|
+
`playground/` is a small consuming example, `templates/starter/` is what the scaffolder writes,
|
|
146
|
+
`scripts/kestrel.mjs` is the CLI, and `packages/create-kestrel/` the `pnpm create kestrel` front end.
|
|
147
|
+
The full map: [layers-and-packages.md](docs/internals/layers-and-packages.md).
|
|
164
148
|
|
|
165
149
|
## Development
|
|
166
150
|
|
|
@@ -185,68 +169,9 @@ node scripts/kestrel.mjs init <dir> # the consumer scaffolder, from a checkout
|
|
|
185
169
|
node scripts/kestrel.mjs doctor <dir> # diagnose a consumer project
|
|
186
170
|
```
|
|
187
171
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
[
|
|
191
|
-
|
|
192
|
-
### Publishing the static site
|
|
193
|
-
|
|
194
|
-
Two ways to produce the static output:
|
|
195
|
-
|
|
196
|
-
- **One-shot:** `pnpm generate` → `.output/public` (the classic full rebuild).
|
|
197
|
-
- **Runtime publisher (default):** a **production** run (`pnpm build && pnpm preview`, or
|
|
198
|
-
`node .output/server/index.mjs`) publishes on boot and incrementally re-publishes the affected pages when
|
|
199
|
-
you press **Publish** in the editor, into `output.dir` (default `.data/published`). Saving is a DB write
|
|
200
|
-
and leaves the live page alone (unpublishing and deleting still take a page down at once) — see
|
|
201
|
-
[ADR-0008](docs/architecture-decisions.md), or set `output.publishOnSave: true` for the pre-2.0 behaviour
|
|
202
|
-
where every save republished. Serve that dir with any static server, e.g. `npx serve .data/published`.
|
|
203
|
-
|
|
204
|
-
The runtime publisher is **intentionally disabled in `pnpm dev`** (a dev render would write un-hashed Vite
|
|
205
|
-
HTML), so the static files only appear on a production run. In dev you instead get the **live preview**:
|
|
206
|
-
public pages render straight from the running server, and an authenticated admin can open an unpublished
|
|
207
|
-
page at its real URL (the "open in new tab" button in the editor) — with unsaved changes that button
|
|
208
|
-
carries them along in a preview ticket rather than saving them. See
|
|
209
|
-
[static-output.md](docs/static-output.md) for the full picture.
|
|
210
|
-
|
|
211
|
-
### Simulate a production deploy locally
|
|
212
|
-
|
|
213
|
-
A real deployment is **two processes**: the private CMS server that *renders* the static files, and a
|
|
214
|
-
public static host that *serves* them. Rehearse that split on one machine:
|
|
215
|
-
|
|
216
|
-
```bash
|
|
217
|
-
# 1. Auth secrets live in .env (pnpm preview loads it). At minimum:
|
|
218
|
-
# KESTREL_SESSION_SECRET=<≥32 bytes, e.g. `openssl rand -hex 32`>
|
|
219
|
-
# KESTREL_ADMIN_PASSWORD_HASH=<from `pnpm hash-password <your-password>`>
|
|
220
|
-
# Leave KESTREL_SECURE_COOKIES at its default — see the note below.
|
|
221
|
-
|
|
222
|
-
pnpm build
|
|
223
|
-
pnpm preview # built Nitro server, NODE_ENV=production → admin at http://localhost:3000/admin
|
|
224
|
-
# boot-publish writes .data/published; editing in /admin re-publishes affected pages
|
|
225
|
-
|
|
226
|
-
# in a SECOND terminal — serve the generated site like a CDN would:
|
|
227
|
-
npx serve .data/published # the public static site, exactly as it ships
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
`pnpm preview` runs the build under `NODE_ENV=production` and **loads `.env`** (running
|
|
231
|
-
`node .output/server/index.mjs` directly does **not** — export the vars yourself if you go that route).
|
|
232
|
-
Note the split: the **auth** vars below are read per request, so exporting them works either way, but the
|
|
233
|
-
non-auth `KESTREL_*` settings are baked in during `pnpm build` — in front of an already-built server they
|
|
234
|
-
are ignored, and you override them with Nuxt's runtime names instead (`NUXT_KESTREL_DB_PATH`,
|
|
235
|
-
`NUXT_KESTREL_SITE_URL`, …; see [configuration.md](docs/configuration.md)).
|
|
236
|
-
Production mode makes the auth guards strict, so for a working local login:
|
|
237
|
-
|
|
238
|
-
- `KESTREL_SESSION_SECRET` is **required and ≥32 bytes** (dev uses a random per-process secret); the
|
|
239
|
-
check runs per-request, not at boot, so a deploy that forgets it still starts and binds the port —
|
|
240
|
-
every `/api/*` request then answers **500** until the secret is set.
|
|
241
|
-
- `KESTREL_ADMIN_PASSWORD_HASH` must be set, or `POST /api/auth/login` answers **503** (“admin login is
|
|
242
|
-
not configured”) rather than a wrong-password 401.
|
|
243
|
-
- **Keep `KESTREL_SECURE_COOKIES` at its default (`true`).** `=false` is *refused* in production
|
|
244
|
-
(`KESTREL_SECURE_COOKIES=false is not allowed in production`); you don't need it — the session cookie is
|
|
245
|
-
`Secure`/`__Host-`, and browsers accept those over plain `http://localhost`, so login works without HTTPS.
|
|
246
|
-
|
|
247
|
-
Watch the CMS terminal: boot prints `[kestrel] boot publish: N route(s) written, …`, and each save logs
|
|
248
|
-
the routes it re-rendered. Env reference → [configuration.md](docs/configuration.md); publishing internals
|
|
249
|
-
→ [static-output.md](docs/static-output.md).
|
|
172
|
+
Conventions, test rails and the release flow: [internals/testing.md](docs/internals/testing.md) and
|
|
173
|
+
[internals/releasing.md](docs/internals/releasing.md). Running a built site — production env, the static
|
|
174
|
+
publisher, rehearsing a deploy locally: [deploying.md](docs/guide/deploying.md).
|
|
250
175
|
|
|
251
176
|
## Configuration
|
|
252
177
|
|
|
@@ -254,7 +179,7 @@ Non-auth settings live in `kestrel.config.ts` (`satisfies KestrelConfig`); each
|
|
|
254
179
|
`KESTREL_* env → config → default`, resolved at build/dev start and frozen into `runtimeConfig` (a prebuilt
|
|
255
180
|
server is retuned with the `NUXT_*` runtimeConfig names, not `KESTREL_*`). Auth/session secrets are
|
|
256
181
|
**env-only** and read per request (`KESTREL_SESSION_SECRET`, `KESTREL_ADMIN_PASSWORD_HASH`, …). Full reference:
|
|
257
|
-
[configuration.md](docs/configuration.md).
|
|
182
|
+
[configuration.md](docs/guide/configuration.md).
|
|
258
183
|
|
|
259
184
|
## Security
|
|
260
185
|
|
package/kestrel.config.ts
CHANGED
|
@@ -5,30 +5,15 @@
|
|
|
5
5
|
// when `media.driver: 'local'`) — the `00.` prefix only orders files within this layer, while Nitro's own
|
|
6
6
|
// static handler is unconditionally unshifted to the front of the whole handler list. `off` when no list
|
|
7
7
|
// is set → zero cost on PROD/local.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const mode = allowlistMode(process.env.KESTREL_IP_ALLOWLIST_MODE, listRaw)
|
|
11
|
-
// Named per-token, not just a "0 valid CIDRs" count — a mixed list (one good entry, one IPv6 CIDR this
|
|
12
|
-
// IPv4-only parser can never match) would otherwise silently under-enforce with no diagnostic at all.
|
|
13
|
-
const cidrs = parseAllowlist(listRaw, (token) => {
|
|
14
|
-
console.warn(`[ip-allowlist] dropping unparseable/unsupported allow-list entry "${token}" (IPv4/CIDR only)`)
|
|
15
|
-
})
|
|
16
|
-
if (mode !== 'off' && cidrs.length === 0) {
|
|
17
|
-
console.warn('[ip-allowlist] KESTREL_IP_ALLOWLIST is set but parsed to 0 valid CIDRs — enforce mode will block ALL traffic')
|
|
18
|
-
}
|
|
19
|
-
return { mode, cidrs }
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Env is fixed for the process lifetime → parse the (up to a few hundred) CIDRs once, not per request.
|
|
23
|
-
let configured: ReturnType<typeof loadConfig> | undefined
|
|
8
|
+
import { clientIp } from '@michaelthielemann/kestrel-auth'
|
|
9
|
+
import { allowlistConfig, ipAllowed, ipv4ToInt, isRendererContext, isStageGatePassedContext, markStageGatePassed } from '@michaelthielemann/kestrel-access'
|
|
24
10
|
|
|
25
11
|
// A listener that never shows the gate a usable client address blocks everything, which looks like an
|
|
26
12
|
// outage rather than a misconfiguration unless we say once what to set.
|
|
27
13
|
let warnedUnresolvable = false
|
|
28
14
|
|
|
29
15
|
export default defineEventHandler((event) => {
|
|
30
|
-
|
|
31
|
-
const { mode, cidrs } = configured
|
|
16
|
+
const { mode, cidrs } = allowlistConfig()
|
|
32
17
|
if (mode === 'off') return
|
|
33
18
|
|
|
34
19
|
// The runtime publisher renders the public site via a nested in-process $fetch (renderer principal), and
|
|
@@ -1,29 +1,31 @@
|
|
|
1
|
+
import { Forbidden, Unauthorized } from '@michaelthielemann/kestrel-contracts'
|
|
2
|
+
import { toHttpError } from '@michaelthielemann/kestrel-core'
|
|
3
|
+
import { refreshAuthSession } from '@michaelthielemann/kestrel-auth'
|
|
4
|
+
import { claimedByPipelineRoute, resolveEventPrincipal } from '@michaelthielemann/kestrel-access'
|
|
5
|
+
|
|
1
6
|
export default defineEventHandler((event) => {
|
|
2
7
|
if (!event.path.startsWith('/api/')) return // default-deny applies to the API only
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (!decision.allow) {
|
|
23
|
-
throw createError({ statusCode: decision.status, statusMessage: decision.message })
|
|
8
|
+
|
|
9
|
+
// Authorization itself belongs to the pipeline the URL names — its `access`/`csrf`/`ipAllowlist`
|
|
10
|
+
// declarations, evaluated by the engine before step 1. Only the principal is resolved here, because the
|
|
11
|
+
// gates need it and because the session refresh below is a property of the request, not of the route.
|
|
12
|
+
event.context.principal = resolveEventPrincipal(event)
|
|
13
|
+
|
|
14
|
+
// Default-deny for everything no pipeline claims — an unknown name, a wrong verb, a stray route a
|
|
15
|
+
// consumer mounted under /api without declaring who may run it. The admin falls through instead, so the
|
|
16
|
+
// router can still answer accurately (404 for an unknown pipeline, 405 for the wrong verb) without that
|
|
17
|
+
// answer confirming to an anonymous prober which pipelines exist. For the same reason an anonymous
|
|
18
|
+
// caller gets the access gate's 401 here, not a 403 — a 401/403 split would itself be the oracle.
|
|
19
|
+
if (!claimedByPipelineRoute(getMethod(event), event.path) && event.context.principal.role !== 'admin') {
|
|
20
|
+
// toHttpError is the same single translation every pipeline-thrown KestrelError goes through
|
|
21
|
+
// (core/server/api/[...path].ts's catch) — this middleware runs before any pipeline, so it calls the
|
|
22
|
+
// map directly instead of growing a second one.
|
|
23
|
+
if (event.context.principal.role === 'anonymous') {
|
|
24
|
+
throw toHttpError(new Unauthorized({ reason: 'Authentication required' }))
|
|
25
|
+
}
|
|
26
|
+
throw toHttpError(new Forbidden({ reason: 'Forbidden' }))
|
|
24
27
|
}
|
|
25
|
-
|
|
26
|
-
event.context.readScope = decision.readScope
|
|
28
|
+
|
|
27
29
|
// Sliding-expiry: any authenticated API activity refreshes the session so it stays alive while in use and
|
|
28
30
|
// auto-expires after `maxAge` of inactivity (idle logout). No-op for anonymous/public requests.
|
|
29
31
|
refreshAuthSession(event)
|
|
@@ -131,7 +131,7 @@ defineExpose({ lang, selectLang, signOut })
|
|
|
131
131
|
font-weight: var(--weight-medium);
|
|
132
132
|
}
|
|
133
133
|
.rail-account__caret {
|
|
134
|
-
margin-left: auto;
|
|
134
|
+
margin-left: auto;
|
|
135
135
|
flex-shrink: 0;
|
|
136
136
|
color: var(--color-text-muted);
|
|
137
137
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed } from 'vue'
|
|
3
|
-
import type { SerializedBlock } from '
|
|
4
|
-
import type { LayoutNode } from '../../../core/server/utils/field-layout'
|
|
3
|
+
import type { LayoutNode, SerializedBlock } from '@michaelthielemann/kestrel-core'
|
|
5
4
|
import type { BlockRow } from '../utils/block-tree'
|
|
6
5
|
import { resolveLocalized } from '../../../ui/app/utils/localized'
|
|
7
6
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, inject, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
3
|
-
import type { SerializedBlock } from '
|
|
3
|
+
import type { SerializedBlock } from '@michaelthielemann/kestrel-core'
|
|
4
4
|
import { previewSrc, parseFrameMessage, type PreviewBlockNode } from '../../../public/app/utils/preview-protocol'
|
|
5
5
|
import { createPreviewSender, acceptsFrameEvent, type PreviewSender } from '../utils/preview-channel'
|
|
6
6
|
import { editorFormContextKey } from '../utils/editor-form-context'
|
|
@@ -22,6 +22,16 @@ const { t } = useT()
|
|
|
22
22
|
const ctx = inject(editorFormContextKey, null)
|
|
23
23
|
const src = computed(() => previewSrc(ctx?.previewUrl.value ?? null, props.locale ?? ''))
|
|
24
24
|
|
|
25
|
+
// The layout wraps the frame's own document (`<NuxtLayout>`, a parent the postMessage bridge can't reach
|
|
26
|
+
// from inside its slot), so a layout change can't ride the content push — only a fresh navigation
|
|
27
|
+
// re-resolves it. Forcing one needs an actual URL change (a `src` re-assignment to the same string is a
|
|
28
|
+
// no-op), so a change bumps this counter, which `frameSrc` folds into the URL as a harmless query param.
|
|
29
|
+
const layoutReloadKey = ref(0)
|
|
30
|
+
const frameSrc = computed(() => {
|
|
31
|
+
if (!layoutReloadKey.value) return src.value
|
|
32
|
+
return `${src.value}${src.value.includes('?') ? '&' : '?'}_r=${layoutReloadKey.value}`
|
|
33
|
+
})
|
|
34
|
+
|
|
25
35
|
// ---- populate (pipeline: resolve media ids + internal links into reactive caches) ----
|
|
26
36
|
// Block field schemas (non-blocking: byType fills once loaded, then the next send carries the media).
|
|
27
37
|
const { blocks: blockDefs, load } = useBlocks()
|
|
@@ -84,9 +94,9 @@ function onFrameLoad() {
|
|
|
84
94
|
if (!el) return
|
|
85
95
|
try {
|
|
86
96
|
const loc = el.contentWindow?.location
|
|
87
|
-
if (loc && loc.pathname + loc.search !==
|
|
97
|
+
if (loc && loc.pathname + loc.search !== frameSrc.value) el.src = frameSrc.value
|
|
88
98
|
} catch {
|
|
89
|
-
el.src =
|
|
99
|
+
el.src = frameSrc.value // cross-origin navigation — location unreadable, snap back
|
|
90
100
|
}
|
|
91
101
|
}
|
|
92
102
|
|
|
@@ -101,7 +111,7 @@ function refresh() {
|
|
|
101
111
|
try {
|
|
102
112
|
el.contentWindow?.location.reload() // same URL → onFrameLoad's snap-back check passes
|
|
103
113
|
} catch {
|
|
104
|
-
el.src =
|
|
114
|
+
el.src = frameSrc.value // cross-origin: location unreadable → renavigate
|
|
105
115
|
}
|
|
106
116
|
}
|
|
107
117
|
|
|
@@ -121,6 +131,8 @@ watch(populated, () => { if (ready.value) pushContent() })
|
|
|
121
131
|
watch(() => props.selectedId, (id) => { if (ready.value) sender?.sendSelected(id ?? null) })
|
|
122
132
|
// A src change reloads the iframe (locale switch, first save assigning a URL) → wait for its new ready.
|
|
123
133
|
watch(src, () => { ready.value = false })
|
|
134
|
+
// See `layoutReloadKey` above: a layout change forces a fresh navigation, the only way to re-resolve it.
|
|
135
|
+
watch(() => ctx?.values?.layout, () => { layoutReloadKey.value++; ready.value = false })
|
|
124
136
|
|
|
125
137
|
// ---- responsive preview: device presets (quick-fill) + automatic scale-to-fit + custom W×H ----
|
|
126
138
|
// The iframe renders at the target resolution's REAL px (so the page fires its true breakpoints); a
|
|
@@ -281,7 +293,7 @@ onUnmounted(() => { ro?.disconnect(); ro = null })
|
|
|
281
293
|
|
|
282
294
|
<div ref="stage" class="block-preview__stage">
|
|
283
295
|
<div class="block-preview__viewport" :style="viewportStyle">
|
|
284
|
-
<iframe ref="frame" class="block-preview__frame" :src="
|
|
296
|
+
<iframe ref="frame" class="block-preview__frame" :src="frameSrc" :style="frameStyle" :title="t('preview.label')" @load="onFrameLoad" />
|
|
285
297
|
</div>
|
|
286
298
|
</div>
|
|
287
299
|
</section>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type { SerializedBlock } from '
|
|
2
|
+
import type { SerializedBlock } from '@michaelthielemann/kestrel-core'
|
|
3
3
|
import type { BlockTreeCtx } from '../utils/block-tree'
|
|
4
4
|
|
|
5
5
|
// The page-builder editor body: hierarchy · live preview · contextual fields. All form state comes from
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { localePath } from '
|
|
2
|
+
import { localePath } from '@michaelthielemann/kestrel-core/client'
|
|
3
3
|
import { PREVIEW_TOKEN_QUERY, PREVIEW_FALLBACK_PATH } from '../../../public/app/utils/preview-protocol'
|
|
4
4
|
import { resolveCollectionEditor } from '../utils/editor-registry'
|
|
5
5
|
import { editorFormContextKey } from '../utils/editor-form-context'
|
|
@@ -23,7 +23,7 @@ const f = useEditForm({ collection: props.collection, id: props.id, locale: prop
|
|
|
23
23
|
// below, where it originally lived) so `defineExpose` can also run before that same await.
|
|
24
24
|
const {
|
|
25
25
|
formError, saving, submit, dirty, editorType, pageLike, hasStatus,
|
|
26
|
-
savedStatus, undo, redo, canUndo, canRedo,
|
|
26
|
+
savedStatus, undo, redo, canUndo, canRedo, quarantined,
|
|
27
27
|
} = f
|
|
28
28
|
|
|
29
29
|
// Editor → field-widget context. MUST run BEFORE the top-level `await` below — after an await the setup
|
|
@@ -91,21 +91,20 @@ const liveStatus = usePublishStatus({
|
|
|
91
91
|
// Carries "poll the live status on mount" across the create→navigate remount (see usePendingPublishPoll).
|
|
92
92
|
const pendingPoll = usePendingPublishPoll()
|
|
93
93
|
|
|
94
|
-
// ---- publish + external preview -------------------------------------------------------------------
|
|
95
94
|
// Saving persists to the DB and leaves the live site alone; publishing writes the static file(s). The two
|
|
96
95
|
// are separate buttons because they are separate decisions (ADR-0008) — you can save a page a dozen times
|
|
97
96
|
// while the published version stays exactly as it was.
|
|
98
97
|
const publishing = ref(false)
|
|
99
98
|
const previewOpening = ref(false)
|
|
100
|
-
// `output.publishOnSave` turns the split off again (a save republishes
|
|
101
|
-
// button would have nothing to do, so the hosts hide it. Reported by /api/
|
|
99
|
+
// `output.publishOnSave` turns the split off again (a save republishes) — then a Publish
|
|
100
|
+
// button would have nothing to do, so the hosts hide it. Reported by /api/publishStatus; unknown (a
|
|
102
101
|
// never-saved record, which does not fetch it) reads as the default, i.e. the button stays.
|
|
103
102
|
const publishOnSave = computed(() => liveStatus.data.value.publishOnSave === true)
|
|
104
103
|
const canPublish = computed(() => !publishOnSave.value)
|
|
105
104
|
|
|
106
105
|
/** Save (a publish publishes what you SEE), promote a draft, then write the output. */
|
|
107
106
|
async function publish() {
|
|
108
|
-
if (publishing.value || saving.value) return
|
|
107
|
+
if (publishing.value || saving.value || quarantined.value) return
|
|
109
108
|
publishing.value = true
|
|
110
109
|
try {
|
|
111
110
|
// Pressing Publish IS the publish intent, so a draft is promoted here rather than sending the user to
|
|
@@ -159,7 +158,7 @@ async function openPreview() {
|
|
|
159
158
|
const tab = window.open('', '_blank')
|
|
160
159
|
previewOpening.value = true
|
|
161
160
|
try {
|
|
162
|
-
const ticket = await $fetch<{ token: string }>('/api/
|
|
161
|
+
const ticket = await $fetch<{ token: string }>('/api/createPreview', {
|
|
163
162
|
method: 'POST',
|
|
164
163
|
body: {
|
|
165
164
|
collection: props.collection,
|
|
@@ -193,6 +192,7 @@ async function openPreview() {
|
|
|
193
192
|
defineExpose({
|
|
194
193
|
dirty, saving, undo, redo, canUndo, canRedo, hasStatus, status, savedStatus, previewUrl, pageLike,
|
|
195
194
|
live: liveStatus.data, recordTitle: heading, publish, publishing, canPublish, openPreview, previewOpening,
|
|
195
|
+
quarantined,
|
|
196
196
|
})
|
|
197
197
|
|
|
198
198
|
await f.ready
|
|
@@ -202,6 +202,7 @@ await f.ready
|
|
|
202
202
|
const bodyComponent = computed(() => resolveCollectionEditor(editorType.value))
|
|
203
203
|
|
|
204
204
|
async function onSave() {
|
|
205
|
+
if (quarantined.value) return
|
|
205
206
|
// Read BEFORE the save: a successful submit rebaselines, after which the saved status is the new one.
|
|
206
207
|
const wasPublished = savedStatus.value === 'published'
|
|
207
208
|
const r = await submit()
|
|
@@ -232,7 +233,7 @@ async function onCopyFrom(loc: string) {
|
|
|
232
233
|
const tid = f.translations.value[loc]
|
|
233
234
|
if (typeof tid !== 'number') return
|
|
234
235
|
try {
|
|
235
|
-
const row = await $fetch<Record<string, unknown>>(`/api/${props.collection}/${tid}`)
|
|
236
|
+
const row = await $fetch<Record<string, unknown>>(`/api/${props.collection}/readOne/${tid}`)
|
|
236
237
|
f.applyFrom(row)
|
|
237
238
|
} catch {
|
|
238
239
|
f.formError.value = t('localeBar.copyFailed', { locale: loc.toUpperCase() })
|
|
@@ -268,16 +269,27 @@ onUnmounted(() => {
|
|
|
268
269
|
the submit event and block onSave with a browser bubble — wrongly so on proxy inputs whose text
|
|
269
270
|
doesn't mirror the model (combobox search boxes stay empty although records are selected). -->
|
|
270
271
|
<form :id="formId" class="editor" novalidate @submit.prevent="onSave">
|
|
271
|
-
|
|
272
|
+
<!-- Quarantined: the row failed its select schema, so its real fields are unknown — no body,
|
|
273
|
+
no actions, nothing to submit. KestrelUiAlert renders role="alert" for variant="error". -->
|
|
274
|
+
<KestrelUiAlert v-if="quarantined" variant="error" class="editor__quarantine">
|
|
275
|
+
<template #title>
|
|
276
|
+
<KestrelUiIcon name="triangle-alert" :size="16" />
|
|
277
|
+
{{ t('editor.quarantined.title') }}
|
|
278
|
+
</template>
|
|
279
|
+
{{ t('editor.quarantined.desc') }}
|
|
280
|
+
</KestrelUiAlert>
|
|
281
|
+
<template v-else>
|
|
282
|
+
<p v-if="formError" class="editor__error" role="alert">{{ formError }}</p>
|
|
272
283
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
284
|
+
<!-- The editor body is chosen by the collection's `editor` type (fields · blocks · an extension). -->
|
|
285
|
+
<component :is="bodyComponent" v-if="bodyComponent" />
|
|
286
|
+
<KestrelEditorUnsupported v-else :editor="editorType" />
|
|
276
287
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
288
|
+
<div v-if="actions" class="editor__actions">
|
|
289
|
+
<KestrelUiButton type="submit" variant="primary" icon="check" :loading="saving">{{ t('common.save') }}</KestrelUiButton>
|
|
290
|
+
<KestrelUiButton type="button" variant="secondary" icon="x" :disabled="saving" @click="emit('cancel')">{{ t('common.cancel') }}</KestrelUiButton>
|
|
291
|
+
</div>
|
|
292
|
+
</template>
|
|
281
293
|
</form>
|
|
282
294
|
</template>
|
|
283
295
|
|
|
@@ -297,6 +309,13 @@ onUnmounted(() => {
|
|
|
297
309
|
color: var(--color-danger);
|
|
298
310
|
font-size: var(--text-sm);
|
|
299
311
|
}
|
|
312
|
+
&__quarantine {
|
|
313
|
+
.ui-alert__title {
|
|
314
|
+
display: inline-flex;
|
|
315
|
+
align-items: center;
|
|
316
|
+
gap: var(--space-2);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
300
319
|
&__actions {
|
|
301
320
|
display: flex;
|
|
302
321
|
gap: var(--space-3);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type { SerializedCollection } from '
|
|
2
|
+
import type { SerializedCollection } from '@michaelthielemann/kestrel-core'
|
|
3
3
|
import { resolveLocalized } from '../../../ui/app/utils/localized'
|
|
4
|
+
import { bulkCustomActions, recordCustomActions } from '../utils/collection-ops'
|
|
4
5
|
|
|
5
6
|
// `locale` is set only for a translatable collection: it filters the list to that content locale and
|
|
6
7
|
// shows a locale switcher. New records / row links carry it so they open in the same locale.
|
|
@@ -33,7 +34,7 @@ const { sort, page, perPage, filter, effectiveQuery, setSort, setPage, clampPage
|
|
|
33
34
|
const filterDraft = useListFilterDraft({ available, filterableColumns, filter, setFilter })
|
|
34
35
|
const activeFilters = filterDraft.activeFilters
|
|
35
36
|
|
|
36
|
-
const { rows, total, error, totalPages, fetchRows } = useListRows({
|
|
37
|
+
const { rows, total, quarantinedCount, error, totalPages, fetchRows } = useListRows({
|
|
37
38
|
collection,
|
|
38
39
|
effectiveQuery,
|
|
39
40
|
locale: () => props.locale,
|
|
@@ -50,15 +51,23 @@ const { selected, allSelected, headerIndeterminate, toggleRow, toggleAll, clear:
|
|
|
50
51
|
useListSelection(rows)
|
|
51
52
|
|
|
52
53
|
// Row action = 1 id, bulk bar = N ids; the server does the batching.
|
|
53
|
-
const { busy: opsBusy, error: opsError, deleteOpen, deleteReport, askDelete, confirmDelete, duplicate: onDuplicate, setStatus } =
|
|
54
|
+
const { busy: opsBusy, error: opsError, deleteOpen, deleteReport, askDelete, confirmDelete, duplicate: onDuplicate, setStatus, runAction } =
|
|
54
55
|
useListBatchActions(collection, fetchRows)
|
|
55
56
|
|
|
57
|
+
// Schema-driven actions beyond the built-in delete/duplicate/publish: a consumer's `definePipeline` shows
|
|
58
|
+
// up here without any UI code.
|
|
59
|
+
const bulkActions = computed(() => bulkCustomActions(props.schema.actions ?? []))
|
|
60
|
+
const rowActions = computed(() => recordCustomActions(props.schema.actions ?? []))
|
|
61
|
+
|
|
56
62
|
// Toolbar popovers (filter / columns), including the outside-click and Escape-restores-focus behaviour.
|
|
57
63
|
const { container: toolsRef, open: openPanel, toggle: togglePanel } = useToolbarPanel<'filter' | 'columns'>()
|
|
58
64
|
|
|
59
65
|
// Polite status announced after every (re)fetch so filter/sort/page changes report their result count
|
|
60
66
|
// to screen readers — WCAG 4.1.3 Status Messages.
|
|
61
67
|
const resultsLabel = computed(() => t(total.value === 1 ? 'list.result' : 'list.results', { total: total.value }))
|
|
68
|
+
// Quarantined rows failed their select schema and would otherwise be silent data loss — a chip in
|
|
69
|
+
// the header surfaces the count whenever it's non-zero.
|
|
70
|
+
const quarantinedLabel = computed(() => t(quarantinedCount.value === 1 ? 'list.quarantinedOne' : 'list.quarantinedCount', { n: quarantinedCount.value }))
|
|
62
71
|
// The ONE permanent live region. A live region only announces MUTATIONS to a node already in the tree, so
|
|
63
72
|
// the selection count is folded in here (mutating this region's text) rather than toggling a second
|
|
64
73
|
// role="status" node into the DOM — inserting an already-populated live node is typically not announced,
|
|
@@ -133,6 +142,11 @@ await fetchRows()
|
|
|
133
142
|
</div>
|
|
134
143
|
</div>
|
|
135
144
|
|
|
145
|
+
<span v-if="quarantinedCount > 0" class="list__quarantine-chip">
|
|
146
|
+
<KestrelUiIcon name="triangle-alert" :size="14" />
|
|
147
|
+
{{ quarantinedLabel }}
|
|
148
|
+
</span>
|
|
149
|
+
|
|
136
150
|
<NuxtLink :to="`/admin/${collection}/new${localeQuery}`" class="list__new">{{ newLabel }}</NuxtLink>
|
|
137
151
|
</div>
|
|
138
152
|
|
|
@@ -141,8 +155,10 @@ await fetchRows()
|
|
|
141
155
|
:count="selected.size"
|
|
142
156
|
:has-status="!!schema.status"
|
|
143
157
|
:busy="opsBusy"
|
|
158
|
+
:actions="bulkActions"
|
|
144
159
|
@set-status="(status) => setStatus([...selected], status)"
|
|
145
160
|
@delete="askDelete([...selected])"
|
|
161
|
+
@run-action="(action) => runAction(action, [...selected])"
|
|
146
162
|
@clear="clearSelection"
|
|
147
163
|
/>
|
|
148
164
|
|
|
@@ -173,11 +189,13 @@ await fetchRows()
|
|
|
173
189
|
:selected="selected"
|
|
174
190
|
:all-selected="allSelected"
|
|
175
191
|
:header-indeterminate="headerIndeterminate"
|
|
192
|
+
:actions="rowActions"
|
|
176
193
|
@sort="setSort"
|
|
177
194
|
@toggle-row="toggleRow"
|
|
178
195
|
@toggle-all="toggleAll"
|
|
179
196
|
@duplicate="onDuplicate"
|
|
180
197
|
@delete="askDelete"
|
|
198
|
+
@run-action="(action, id) => runAction(action, [id])"
|
|
181
199
|
/>
|
|
182
200
|
|
|
183
201
|
<KestrelUiEmptyState
|
|
@@ -348,6 +366,19 @@ await fetchRows()
|
|
|
348
366
|
cursor: pointer;
|
|
349
367
|
}
|
|
350
368
|
|
|
369
|
+
// Quarantine count chip — icon + text (not color alone, WCAG 1.4.1) so it reads even without color.
|
|
370
|
+
&__quarantine-chip {
|
|
371
|
+
display: inline-flex;
|
|
372
|
+
align-items: center;
|
|
373
|
+
gap: var(--space-1);
|
|
374
|
+
padding: var(--space-1) var(--space-2);
|
|
375
|
+
border-radius: var(--radius-sm);
|
|
376
|
+
background: var(--color-danger-soft, var(--color-surface));
|
|
377
|
+
color: var(--color-danger);
|
|
378
|
+
font-size: var(--text-sm);
|
|
379
|
+
font-weight: var(--weight-medium);
|
|
380
|
+
}
|
|
381
|
+
|
|
351
382
|
&__new {
|
|
352
383
|
margin-left: auto;
|
|
353
384
|
padding: var(--space-2) var(--space-4);
|