@michaelthielemann/kestrel 1.2.1
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/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +234 -0
- package/kestrel.config.ts +28 -0
- package/layers/access/nuxt.config.ts +5 -0
- package/layers/access/server/middleware/00.ip-allowlist.ts +67 -0
- package/layers/access/server/middleware/access-guard.ts +30 -0
- package/layers/access/server/types.d.ts +8 -0
- package/layers/access/server/utils/csrf.ts +39 -0
- package/layers/access/server/utils/grant-registry.ts +36 -0
- package/layers/access/server/utils/guard.ts +44 -0
- package/layers/access/server/utils/ip-allowlist.ts +76 -0
- package/layers/access/server/utils/policy.ts +98 -0
- package/layers/access/server/utils/public-resources.ts +11 -0
- package/layers/access/server/utils/render-context.ts +41 -0
- package/layers/access/server/utils/require-admin.ts +15 -0
- package/layers/admin/app/components/AdminAccount.vue +212 -0
- package/layers/admin/app/components/AdminNav.vue +44 -0
- package/layers/admin/app/components/BlockFields.vue +68 -0
- package/layers/admin/app/components/BlockPreview.vue +401 -0
- package/layers/admin/app/components/BlockTree.vue +433 -0
- package/layers/admin/app/components/BlocksBody.vue +162 -0
- package/layers/admin/app/components/CollectionDeleteDialog.vue +38 -0
- package/layers/admin/app/components/CollectionEditor.vue +203 -0
- package/layers/admin/app/components/CollectionList.vue +1091 -0
- package/layers/admin/app/components/EditorStatus.vue +199 -0
- package/layers/admin/app/components/EditorUnsupported.vue +28 -0
- package/layers/admin/app/components/FieldsBody.vue +23 -0
- package/layers/admin/app/components/LocaleBar.vue +105 -0
- package/layers/admin/app/components/PageFields.vue +101 -0
- package/layers/admin/app/components/PageFieldsPane.vue +39 -0
- package/layers/admin/app/components/SeoFields.vue +169 -0
- package/layers/admin/app/components/SingletonEditor.vue +92 -0
- package/layers/admin/app/composables/useAuth.ts +68 -0
- package/layers/admin/app/composables/useBlockTree.ts +95 -0
- package/layers/admin/app/composables/useBlocks.ts +7 -0
- package/layers/admin/app/composables/useCachedResource.ts +35 -0
- package/layers/admin/app/composables/useCollectionOps.ts +53 -0
- package/layers/admin/app/composables/useCollections.ts +7 -0
- package/layers/admin/app/composables/useContentLocales.ts +23 -0
- package/layers/admin/app/composables/useEditForm.ts +370 -0
- package/layers/admin/app/composables/useLinkResolver.ts +40 -0
- package/layers/admin/app/composables/useListColumns.ts +53 -0
- package/layers/admin/app/composables/useListUrlState.ts +53 -0
- package/layers/admin/app/composables/usePendingPublishPoll.ts +15 -0
- package/layers/admin/app/composables/usePreviewViewport.ts +30 -0
- package/layers/admin/app/composables/usePublishStatus.ts +98 -0
- package/layers/admin/app/composables/useRailCollapsed.ts +14 -0
- package/layers/admin/app/composables/useTheme.ts +36 -0
- package/layers/admin/app/composables/useUnsavedGuard.ts +22 -0
- package/layers/admin/app/layouts/admin.vue +267 -0
- package/layers/admin/app/middleware/admin-auth.ts +4 -0
- package/layers/admin/app/pages/admin/[collection]/[id].vue +202 -0
- package/layers/admin/app/pages/admin/[collection]/index.vue +62 -0
- package/layers/admin/app/pages/admin/index.vue +118 -0
- package/layers/admin/app/pages/admin/login.vue +68 -0
- package/layers/admin/app/pages/admin/media.vue +28 -0
- package/layers/admin/app/pages/admin/references.vue +146 -0
- package/layers/admin/app/plugins/admin-style-guard.client.ts +76 -0
- package/layers/admin/app/plugins/reauth.client.ts +20 -0
- package/layers/admin/app/utils/admin-style-guard.ts +20 -0
- package/layers/admin/app/utils/block-tree.ts +214 -0
- package/layers/admin/app/utils/collection-ops.ts +44 -0
- package/layers/admin/app/utils/dead-refs.ts +30 -0
- package/layers/admin/app/utils/edit-form.ts +223 -0
- package/layers/admin/app/utils/editor-expose.ts +26 -0
- package/layers/admin/app/utils/editor-form-context.ts +83 -0
- package/layers/admin/app/utils/editor-registry.ts +20 -0
- package/layers/admin/app/utils/field-keys.ts +11 -0
- package/layers/admin/app/utils/list-columns.ts +141 -0
- package/layers/admin/app/utils/list-query.ts +96 -0
- package/layers/admin/app/utils/nav-active.ts +9 -0
- package/layers/admin/app/utils/populate-blocks.ts +288 -0
- package/layers/admin/app/utils/preview-channel.ts +62 -0
- package/layers/admin/app/utils/preview-viewport.ts +80 -0
- package/layers/admin/app/utils/reauth.ts +68 -0
- package/layers/admin/app/utils/record-title.ts +15 -0
- package/layers/admin/app/utils/register-builtin-editors.ts +9 -0
- package/layers/admin/app/utils/safe-redirect.ts +12 -0
- package/layers/admin/nuxt.config.ts +17 -0
- package/layers/auth/nuxt.config.ts +1 -0
- package/layers/auth/server/api/auth/login.post.ts +38 -0
- package/layers/auth/server/api/auth/logout.post.ts +9 -0
- package/layers/auth/server/api/auth/session.get.ts +1 -0
- package/layers/auth/server/utils/admin-credential.ts +42 -0
- package/layers/auth/server/utils/client-ip.ts +88 -0
- package/layers/auth/server/utils/login-throttle.ts +126 -0
- package/layers/auth/server/utils/password.ts +52 -0
- package/layers/auth/server/utils/session-cookie.ts +44 -0
- package/layers/auth/server/utils/session-epoch.ts +96 -0
- package/layers/auth/server/utils/session.ts +85 -0
- package/layers/collections/nuxt.config.ts +1 -0
- package/layers/collections/server/collections/pages.ts +19 -0
- package/layers/collections/server/plugins/01.register.ts +32 -0
- package/layers/collections/server/plugins/02.register-relation-populate.ts +17 -0
- package/layers/collections/server/utils/populate-relations.ts +66 -0
- package/layers/core/app/utils/bulk-actions.ts +9 -0
- package/layers/core/app/utils/filter-ops.ts +98 -0
- package/layers/core/app/utils/list-limits.ts +27 -0
- package/layers/core/app/utils/locale-path.ts +12 -0
- package/layers/core/app/utils/slugify.ts +16 -0
- package/layers/core/modules/auto-discovery/extract-block.ts +176 -0
- package/layers/core/modules/auto-discovery/index.ts +44 -0
- package/layers/core/modules/auto-discovery/scan.ts +78 -0
- package/layers/core/modules/auto-discovery/virtual.d.ts +12 -0
- package/layers/core/modules/kestrel/index.ts +86 -0
- package/layers/core/nuxt.config.ts +8 -0
- package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +8 -0
- package/layers/core/server/api/[collection]/[id]/translations.get.ts +4 -0
- package/layers/core/server/api/[collection]/[id].delete.ts +5 -0
- package/layers/core/server/api/[collection]/[id].get.ts +8 -0
- package/layers/core/server/api/[collection]/[id].patch.ts +8 -0
- package/layers/core/server/api/[collection]/bulk.post.ts +37 -0
- package/layers/core/server/api/[collection]/index.get.ts +19 -0
- package/layers/core/server/api/[collection]/index.post.ts +8 -0
- package/layers/core/server/api/[collection]/index.put.ts +7 -0
- package/layers/core/server/api/[collection]/options.get.ts +18 -0
- package/layers/core/server/api/[collection]/translations.get.ts +25 -0
- package/layers/core/server/api/collections/[name].get.ts +4 -0
- package/layers/core/server/api/collections/index.get.ts +3 -0
- package/layers/core/server/api/references/broken.get.ts +3 -0
- package/layers/core/server/api/references/referrers.get.ts +24 -0
- package/layers/core/server/database/record-refs.ts +20 -0
- package/layers/core/server/plugins/00.migrate.ts +11 -0
- package/layers/core/server/plugins/02.schema-sync.ts +63 -0
- package/layers/core/server/plugins/03.record-refs.ts +12 -0
- package/layers/core/server/schema/bootstrap.ts +41 -0
- package/layers/core/server/schema/desired.ts +99 -0
- package/layers/core/server/schema/dev-sync.ts +23 -0
- package/layers/core/server/schema/dialect.ts +59 -0
- package/layers/core/server/schema/diff.ts +110 -0
- package/layers/core/server/schema/introspect.ts +67 -0
- package/layers/core/server/schema/model.ts +61 -0
- package/layers/core/server/schema/render-sqlite.ts +89 -0
- package/layers/core/server/schema/sync.ts +201 -0
- package/layers/core/server/tasks/db/migrate.ts +54 -0
- package/layers/core/server/utils/blocks.ts +6 -0
- package/layers/core/server/utils/collection-types.ts +27 -0
- package/layers/core/server/utils/crud.ts +577 -0
- package/layers/core/server/utils/db.ts +26 -0
- package/layers/core/server/utils/defineCollection.ts +207 -0
- package/layers/core/server/utils/duplicate.ts +162 -0
- package/layers/core/server/utils/field-layout.ts +125 -0
- package/layers/core/server/utils/filter-predicate.ts +101 -0
- package/layers/core/server/utils/http.ts +50 -0
- package/layers/core/server/utils/if-unmodified.ts +13 -0
- package/layers/core/server/utils/kestrel-config.ts +410 -0
- package/layers/core/server/utils/key-lock.ts +33 -0
- package/layers/core/server/utils/locale.ts +53 -0
- package/layers/core/server/utils/page-route.ts +58 -0
- package/layers/core/server/utils/page-slug.ts +127 -0
- package/layers/core/server/utils/picker.ts +82 -0
- package/layers/core/server/utils/populate.ts +48 -0
- package/layers/core/server/utils/prerender-memo.ts +23 -0
- package/layers/core/server/utils/read-capture.ts +37 -0
- package/layers/core/server/utils/record-ref-index.ts +200 -0
- package/layers/core/server/utils/registry.ts +24 -0
- package/layers/core/server/utils/resolve-scope.ts +79 -0
- package/layers/core/server/utils/seo.ts +24 -0
- package/layers/core/server/utils/serialize-collection.ts +149 -0
- package/layers/core/server/utils/server-config.ts +18 -0
- package/layers/core/server/utils/sql.ts +2 -0
- package/layers/core/server/utils/storage.local.ts +117 -0
- package/layers/core/server/utils/storage.s3.ts +197 -0
- package/layers/core/server/utils/storage.ts +90 -0
- package/layers/core/server/utils/write-events.ts +31 -0
- package/layers/fields/app/utils/condition.ts +71 -0
- package/layers/fields/app/utils/define-block.ts +13 -0
- package/layers/fields/app/utils/field-constraints.ts +17 -0
- package/layers/fields/app/utils/field-factories.ts +129 -0
- package/layers/fields/app/utils/field-meta.ts +33 -0
- package/layers/fields/app/utils/field-validate.ts +75 -0
- package/layers/fields/app/utils/field-value.ts +7 -0
- package/layers/fields/app/utils/richtext-links.ts +48 -0
- package/layers/fields/nuxt.config.ts +1 -0
- package/layers/fields/server/api/blocks/index.get.ts +9 -0
- package/layers/fields/server/field-registry/index.ts +248 -0
- package/layers/fields/server/field-registry/naming.ts +10 -0
- package/layers/fields/server/field-registry/sanitize.ts +40 -0
- package/layers/fields/server/field-registry/types.ts +19 -0
- package/layers/fields/server/plugins/01.register-field-populate.ts +11 -0
- package/layers/fields/server/utils/block-ids.ts +43 -0
- package/layers/fields/server/utils/block-populate.ts +41 -0
- package/layers/fields/server/utils/buildCollection.ts +88 -0
- package/layers/fields/server/utils/buildFieldSchema.ts +22 -0
- package/layers/fields/server/utils/buildTable.ts +107 -0
- package/layers/fields/server/utils/conditional-required.ts +25 -0
- package/layers/fields/server/utils/defineBlock.ts +86 -0
- package/layers/fields/server/utils/defineFieldType.ts +33 -0
- package/layers/fields/server/utils/extract-refs.ts +152 -0
- package/layers/fields/server/utils/field-populate.ts +68 -0
- package/layers/media/app/components/KestrelImg.vue +65 -0
- package/layers/media/app/components/MediaConflictDialog.vue +35 -0
- package/layers/media/app/components/MediaDeleteDialog.vue +52 -0
- package/layers/media/app/components/MediaGrid.vue +113 -0
- package/layers/media/app/components/MediaImage.vue +70 -0
- package/layers/media/app/components/MediaLibrary.vue +451 -0
- package/layers/media/app/components/MediaNewFolderDialog.vue +32 -0
- package/layers/media/app/components/MediaPathBar.vue +163 -0
- package/layers/media/app/components/MediaPicker.vue +19 -0
- package/layers/media/app/components/MediaRenameDialog.vue +32 -0
- package/layers/media/app/components/MediaTable.vue +75 -0
- package/layers/media/app/components/MediaThumb.vue +25 -0
- package/layers/media/app/components/MediaToolbar.vue +90 -0
- package/layers/media/app/components/MediaUploadDialog.vue +87 -0
- package/layers/media/app/components/MediaViewer.vue +88 -0
- package/layers/media/app/components/field/Media.vue +261 -0
- package/layers/media/app/composables/useBlurUp.ts +55 -0
- package/layers/media/app/composables/useMediaClipboard.ts +14 -0
- package/layers/media/app/composables/useMediaContextMenu.ts +56 -0
- package/layers/media/app/composables/useMediaDnd.ts +56 -0
- package/layers/media/app/composables/useMediaLibrary.ts +151 -0
- package/layers/media/app/composables/useMediaOps.ts +50 -0
- package/layers/media/app/composables/useMediaRelocate.ts +39 -0
- package/layers/media/app/composables/useMediaResolver.ts +50 -0
- package/layers/media/app/composables/useMediaUpload.ts +93 -0
- package/layers/media/app/composables/useMediaVariant.ts +34 -0
- package/layers/media/app/plugins/field-media.client.ts +6 -0
- package/layers/media/app/utils/dnd.ts +69 -0
- package/layers/media/app/utils/library.ts +79 -0
- package/layers/media/app/utils/ops.ts +62 -0
- package/layers/media/app/utils/picture-model.ts +125 -0
- package/layers/media/app/utils/thumbhash.ts +19 -0
- package/layers/media/app/utils/variant-name.ts +20 -0
- package/layers/media/nuxt.config.ts +3 -0
- package/layers/media/server/api/media/[id]/usages.get.ts +8 -0
- package/layers/media/server/api/media/[id].delete.ts +18 -0
- package/layers/media/server/api/media/[id].get.ts +10 -0
- package/layers/media/server/api/media/[id].patch.ts +41 -0
- package/layers/media/server/api/media/backfill.post.ts +18 -0
- package/layers/media/server/api/media/copy.post.ts +7 -0
- package/layers/media/server/api/media/delete.post.ts +13 -0
- package/layers/media/server/api/media/folders.post.ts +18 -0
- package/layers/media/server/api/media/index.get.ts +15 -0
- package/layers/media/server/api/media/index.post.ts +157 -0
- package/layers/media/server/api/media/library.get.ts +17 -0
- package/layers/media/server/api/media/move.post.ts +7 -0
- package/layers/media/server/api/media/rename.post.ts +7 -0
- package/layers/media/server/api/media/resolve.get.ts +13 -0
- package/layers/media/server/collections/media-settings.ts +25 -0
- package/layers/media/server/collections/media.ts +29 -0
- package/layers/media/server/database/folders.ts +10 -0
- package/layers/media/server/middleware/ondemand-variants.ts +37 -0
- package/layers/media/server/plugins/02.register-media.ts +19 -0
- package/layers/media/server/plugins/03.media-cleanup.ts +20 -0
- package/layers/media/server/plugins/04.variant-capture.ts +23 -0
- package/layers/media/server/schema-tables/folders.ts +4 -0
- package/layers/media/server/tasks/media/backfill.ts +18 -0
- package/layers/media/server/utils/backfill.ts +132 -0
- package/layers/media/server/utils/derive.ts +59 -0
- package/layers/media/server/utils/folder-paths.ts +42 -0
- package/layers/media/server/utils/folders.ts +12 -0
- package/layers/media/server/utils/library.ts +90 -0
- package/layers/media/server/utils/media-cleanup.ts +27 -0
- package/layers/media/server/utils/media-enabled.ts +23 -0
- package/layers/media/server/utils/media-ops.ts +118 -0
- package/layers/media/server/utils/media-write.ts +13 -0
- package/layers/media/server/utils/naming.ts +52 -0
- package/layers/media/server/utils/ondemand.ts +69 -0
- package/layers/media/server/utils/persist-upload.ts +62 -0
- package/layers/media/server/utils/populate.ts +47 -0
- package/layers/media/server/utils/record.ts +63 -0
- package/layers/media/server/utils/relocate-ops.ts +341 -0
- package/layers/media/server/utils/relocate-plan.ts +63 -0
- package/layers/media/server/utils/resolve.ts +86 -0
- package/layers/media/server/utils/sanitize-svg.ts +12 -0
- package/layers/media/server/utils/sniff.ts +68 -0
- package/layers/media/server/utils/storage-relocate.ts +139 -0
- package/layers/media/server/utils/translations.ts +19 -0
- package/layers/media/server/utils/usages.ts +66 -0
- package/layers/media/server/utils/variant-capture.ts +70 -0
- package/layers/media/server/utils/variants.ts +108 -0
- package/layers/public/app/app.vue +5 -0
- package/layers/public/app/components/BlockRenderer.vue +56 -0
- package/layers/public/app/components/KestrelLink.vue +26 -0
- package/layers/public/app/components/KestrelPreviewBridge.vue +133 -0
- package/layers/public/app/composables/public-page.ts +24 -0
- package/layers/public/app/error.vue +12 -0
- package/layers/public/app/layouts/default.vue +5 -0
- package/layers/public/app/pages/[...slug].vue +157 -0
- package/layers/public/app/pages/__kestrel/preview.vue +34 -0
- package/layers/public/app/utils/block-edit-context.ts +16 -0
- package/layers/public/app/utils/link-href.ts +46 -0
- package/layers/public/app/utils/page-head.ts +84 -0
- package/layers/public/app/utils/preview-protocol.ts +81 -0
- package/layers/public/app/utils/public-route.ts +46 -0
- package/layers/public/modules/deploy-output/deploy-output.ts +370 -0
- package/layers/public/modules/deploy-output/index.ts +82 -0
- package/layers/public/modules/prerender-routes/discover.ts +37 -0
- package/layers/public/modules/prerender-routes/index.ts +85 -0
- package/layers/public/modules/prerender-routes/routes.ts +16 -0
- package/layers/public/modules/prune-media/index.ts +48 -0
- package/layers/public/modules/prune-media/prune-media.ts +81 -0
- package/layers/public/nuxt.config.ts +11 -0
- package/layers/public/server/api/links/resolve.get.ts +19 -0
- package/layers/public/server/api/publish-status.get.ts +44 -0
- package/layers/public/server/api/route.get.ts +18 -0
- package/layers/public/server/database/publish-deps.ts +12 -0
- package/layers/public/server/database/publish-status.ts +19 -0
- package/layers/public/server/plugins/02.register-links.ts +17 -0
- package/layers/public/server/plugins/zz.publish.ts +63 -0
- package/layers/public/server/routes/llms.txt.get.ts +72 -0
- package/layers/public/server/routes/robots.txt.get.ts +8 -0
- package/layers/public/server/routes/sitemap.xml.get.ts +66 -0
- package/layers/public/server/schema-tables/publish-deps.ts +4 -0
- package/layers/public/server/schema-tables/publish-status.ts +4 -0
- package/layers/public/server/tasks/publish/run.ts +26 -0
- package/layers/public/server/utils/link-resolve.ts +35 -0
- package/layers/public/server/utils/llms.ts +39 -0
- package/layers/public/server/utils/page-resolve.ts +86 -0
- package/layers/public/server/utils/populate-links.ts +66 -0
- package/layers/public/server/utils/publish/coalesce.ts +22 -0
- package/layers/public/server/utils/publish/deps-persistence.ts +43 -0
- package/layers/public/server/utils/publish/deps.ts +87 -0
- package/layers/public/server/utils/publish/invalidation.ts +141 -0
- package/layers/public/server/utils/publish/publish-status.ts +54 -0
- package/layers/public/server/utils/publish/publisher.ts +291 -0
- package/layers/public/server/utils/publish/queue.ts +67 -0
- package/layers/public/server/utils/publish/route-for-record.ts +16 -0
- package/layers/public/server/utils/publish/route-keys.ts +13 -0
- package/layers/public/server/utils/site-url.ts +32 -0
- package/layers/public/server/utils/sitemap.ts +97 -0
- package/layers/ui/app/assets/scss/_base.scss +16 -0
- package/layers/ui/app/assets/scss/_datepicker.scss +82 -0
- package/layers/ui/app/assets/scss/_mixins.scss +50 -0
- package/layers/ui/app/assets/scss/_reset.scss +65 -0
- package/layers/ui/app/assets/scss/_tokens.scss +183 -0
- package/layers/ui/app/assets/scss/main.scss +4 -0
- package/layers/ui/app/components/field/Boolean.vue +30 -0
- package/layers/ui/app/components/field/Choice.vue +68 -0
- package/layers/ui/app/components/field/Datetime.vue +96 -0
- package/layers/ui/app/components/field/DeadRefNote.vue +28 -0
- package/layers/ui/app/components/field/Json.vue +63 -0
- package/layers/ui/app/components/field/Layout.vue +140 -0
- package/layers/ui/app/components/field/Link.vue +112 -0
- package/layers/ui/app/components/field/LinkInternalPicker.vue +95 -0
- package/layers/ui/app/components/field/Number.vue +30 -0
- package/layers/ui/app/components/field/Relation.vue +49 -0
- package/layers/ui/app/components/field/Renderer.vue +30 -0
- package/layers/ui/app/components/field/Repeater.vue +460 -0
- package/layers/ui/app/components/field/Richtext.vue +26 -0
- package/layers/ui/app/components/field/Slug.vue +66 -0
- package/layers/ui/app/components/field/Text.vue +35 -0
- package/layers/ui/app/components/field/Unsupported.vue +34 -0
- package/layers/ui/app/components/ui/Alert.vue +50 -0
- package/layers/ui/app/components/ui/Brand.vue +22 -0
- package/layers/ui/app/components/ui/Button.vue +116 -0
- package/layers/ui/app/components/ui/ButtonGroup.vue +96 -0
- package/layers/ui/app/components/ui/Checkbox.vue +52 -0
- package/layers/ui/app/components/ui/CheckboxGroup.vue +50 -0
- package/layers/ui/app/components/ui/Combobox.vue +343 -0
- package/layers/ui/app/components/ui/DatePicker.vue +92 -0
- package/layers/ui/app/components/ui/DateRangePicker.vue +100 -0
- package/layers/ui/app/components/ui/Dialog.vue +107 -0
- package/layers/ui/app/components/ui/EmptyState.vue +46 -0
- package/layers/ui/app/components/ui/Field.vue +57 -0
- package/layers/ui/app/components/ui/Fieldset.vue +64 -0
- package/layers/ui/app/components/ui/Icon.vue +44 -0
- package/layers/ui/app/components/ui/Menu.vue +54 -0
- package/layers/ui/app/components/ui/NumberInput.vue +173 -0
- package/layers/ui/app/components/ui/Richtext.vue +119 -0
- package/layers/ui/app/components/ui/RichtextToolbar.vue +253 -0
- package/layers/ui/app/components/ui/Select.vue +51 -0
- package/layers/ui/app/components/ui/TextInput.vue +110 -0
- package/layers/ui/app/components/ui/Textarea.vue +38 -0
- package/layers/ui/app/components/ui/TimeInput.vue +17 -0
- package/layers/ui/app/components/ui/Toasts.vue +103 -0
- package/layers/ui/app/components/ui/Tooltip.vue +45 -0
- package/layers/ui/app/composables/useAdminLang.ts +8 -0
- package/layers/ui/app/composables/useDragReorder.ts +41 -0
- package/layers/ui/app/composables/useEchoGuard.ts +28 -0
- package/layers/ui/app/composables/useLinkField.ts +70 -0
- package/layers/ui/app/composables/useRecordOptions.ts +84 -0
- package/layers/ui/app/composables/useRepeater.ts +99 -0
- package/layers/ui/app/composables/useT.ts +32 -0
- package/layers/ui/app/composables/useToast.ts +46 -0
- package/layers/ui/app/i18n/de.ts +406 -0
- package/layers/ui/app/i18n/en.ts +440 -0
- package/layers/ui/app/utils/boolean-field.ts +10 -0
- package/layers/ui/app/utils/field-component.ts +22 -0
- package/layers/ui/app/utils/field-empty.ts +21 -0
- package/layers/ui/app/utils/field-registry.ts +36 -0
- package/layers/ui/app/utils/humanize.ts +14 -0
- package/layers/ui/app/utils/icons.ts +147 -0
- package/layers/ui/app/utils/iso-date.ts +18 -0
- package/layers/ui/app/utils/localized.ts +13 -0
- package/layers/ui/app/utils/record-context.ts +15 -0
- package/layers/ui/app/utils/reorder.ts +8 -0
- package/layers/ui/app/utils/richtext-preserve-class.ts +31 -0
- package/layers/ui/app/utils/useFieldA11y.ts +40 -0
- package/layers/ui/nuxt.config.ts +5 -0
- package/nuxt.config.ts +105 -0
- package/package.json +122 -0
- package/scripts/hash-password.mjs +26 -0
- package/scripts/typecheck.mjs +51 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { currentSessionEpoch } from './session-epoch'
|
|
3
|
+
|
|
4
|
+
export function deriveSigningKey(rawSecret: string, passwordHash?: string, epoch = 0): string {
|
|
5
|
+
// The `epoch` is a server-side revocation counter: bumping it (on logout / force-logout) changes the key
|
|
6
|
+
// so every previously-issued token fails verification immediately — the stateless model's "hard logout".
|
|
7
|
+
return createHmac('sha256', rawSecret).update('kestrel-session-v1').update(passwordHash ?? '').update(`e${epoch}`).digest('base64url')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function signSession(secret: string, expMs: number): string {
|
|
11
|
+
const payload = Buffer.from(JSON.stringify({ exp: expMs })).toString('base64url')
|
|
12
|
+
const sig = createHmac('sha256', secret).update(payload).digest('base64url')
|
|
13
|
+
return `${payload}.${sig}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function verifySession(secret: string, value: string | undefined, nowMs: number): { valid: boolean; exp?: number } {
|
|
17
|
+
if (!value || typeof value !== 'string') return { valid: false }
|
|
18
|
+
const dot = value.indexOf('.')
|
|
19
|
+
if (dot <= 0 || dot === value.length - 1) return { valid: false }
|
|
20
|
+
const payload = value.slice(0, dot)
|
|
21
|
+
const sig = value.slice(dot + 1)
|
|
22
|
+
const expected = createHmac('sha256', secret).update(payload).digest('base64url')
|
|
23
|
+
const sigBuf = Buffer.from(sig)
|
|
24
|
+
const expBuf = Buffer.from(expected)
|
|
25
|
+
if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) return { valid: false }
|
|
26
|
+
let exp: unknown
|
|
27
|
+
try {
|
|
28
|
+
exp = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')).exp
|
|
29
|
+
} catch {
|
|
30
|
+
return { valid: false }
|
|
31
|
+
}
|
|
32
|
+
if (typeof exp !== 'number' || exp <= nowMs) return { valid: false }
|
|
33
|
+
return { valid: true, exp }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Sliding-expiry decision: refresh the session cookie once its remaining lifetime drops below HALF the
|
|
38
|
+
* idle window. Activity within the window keeps the session alive (each refresh resets exp to now+maxAge);
|
|
39
|
+
* inactivity longer than the window lets it expire → the admin must log in again. The halfway threshold
|
|
40
|
+
* bounds Set-Cookie churn to at most once per half-window instead of on every request.
|
|
41
|
+
*/
|
|
42
|
+
export function shouldRefreshSession(exp: number, nowMs: number, maxAgeMs: number): boolean {
|
|
43
|
+
return exp - nowMs < maxAgeMs / 2
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let devSecret: string | undefined
|
|
47
|
+
|
|
48
|
+
export interface SessionSettings {
|
|
49
|
+
secret: string
|
|
50
|
+
maxAge: number
|
|
51
|
+
secureCookies: boolean
|
|
52
|
+
cookieName: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function sessionSettings(): SessionSettings {
|
|
56
|
+
// Treat anything that isn't an EXPLICIT dev signal as production for these safeguards, so a deployment
|
|
57
|
+
// that simply omits NODE_ENV (a common slip when launching `.output/server/index.mjs`) is hardened —
|
|
58
|
+
// not silently downgraded to dev, which would tolerate a missing secret + non-Secure cookies. Vitest
|
|
59
|
+
// sets NODE_ENV=test; KESTREL_DEV=1 is an explicit escape hatch for other dev contexts.
|
|
60
|
+
const explicitDev = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test' || process.env.KESTREL_DEV === '1'
|
|
61
|
+
const isProd = !explicitDev
|
|
62
|
+
const secureCookies = process.env.KESTREL_SECURE_COOKIES !== 'false'
|
|
63
|
+
if (isProd && !secureCookies) {
|
|
64
|
+
throw new Error('KESTREL_SECURE_COOKIES=false is not allowed in production')
|
|
65
|
+
}
|
|
66
|
+
const rawMaxAge = Number(process.env.KESTREL_SESSION_MAX_AGE)
|
|
67
|
+
const maxAge = Number.isFinite(rawMaxAge) && rawMaxAge > 0 ? rawMaxAge : 604800
|
|
68
|
+
let secret = process.env.KESTREL_SESSION_SECRET
|
|
69
|
+
if (!secret) {
|
|
70
|
+
if (isProd) throw new Error('KESTREL_SESSION_SECRET is required in production')
|
|
71
|
+
if (!devSecret) {
|
|
72
|
+
devSecret = randomBytes(32).toString('base64url')
|
|
73
|
+
console.warn('[kestrel auth] KESTREL_SESSION_SECRET not set — using a random per-process dev secret; sessions reset on restart')
|
|
74
|
+
}
|
|
75
|
+
secret = devSecret
|
|
76
|
+
} else if (isProd && Buffer.byteLength(secret) < 32) {
|
|
77
|
+
throw new Error('KESTREL_SESSION_SECRET must be at least 32 bytes in production')
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
secret: deriveSigningKey(secret, process.env.KESTREL_ADMIN_PASSWORD_HASH, currentSessionEpoch()),
|
|
81
|
+
maxAge,
|
|
82
|
+
secureCookies,
|
|
83
|
+
cookieName: secureCookies ? '__Host-kestrel_session' : 'kestrel_session',
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default defineNuxtConfig({})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { buildCollection } from '../../../fields/server/utils/buildCollection'
|
|
2
|
+
import { defineCollection } from '../../../core/server/utils/defineCollection'
|
|
3
|
+
|
|
4
|
+
const built = buildCollection(defineCollection({
|
|
5
|
+
name: 'pages',
|
|
6
|
+
mode: 'multi',
|
|
7
|
+
translatable: true,
|
|
8
|
+
pageLike: true,
|
|
9
|
+
seo: true,
|
|
10
|
+
blocks: { enabled: true },
|
|
11
|
+
status: true,
|
|
12
|
+
builtin: true,
|
|
13
|
+
label: { singular: { en: 'Page', de: 'Seite' }, plural: { en: 'Pages', de: 'Seiten' }, new: { en: 'New Page', de: 'Neue Seite' } },
|
|
14
|
+
icon: 'file-text',
|
|
15
|
+
fields: { title: { type: 'text', required: true } },
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
export const pages = built.table
|
|
19
|
+
export default built
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import collections from '#kestrel/collections'
|
|
2
|
+
import blocks from '#kestrel/blocks'
|
|
3
|
+
import { registerBlock, type BlockDef } from '../../../fields/server/utils/defineBlock'
|
|
4
|
+
import { ensureBuilt } from '../../../fields/server/utils/buildCollection'
|
|
5
|
+
import { resolveServerKestrel, serverRuntimeConfig } from '../../../core/server/utils/server-config'
|
|
6
|
+
import { collectionEnabled } from '../../../core/server/schema/bootstrap'
|
|
7
|
+
import type { CollectionDef } from '../../../core/server/utils/defineCollection'
|
|
8
|
+
import type { BuiltCollection } from '../../../core/server/utils/collection-types'
|
|
9
|
+
|
|
10
|
+
// Auto-discovered collection files default-export either a plain CollectionDef (the common consumer
|
|
11
|
+
// form, `export default defineCollection(…)`) or an already-built collection (advanced). `ensureBuilt`
|
|
12
|
+
// normalizes both so the registry only ever holds BuiltCollections.
|
|
13
|
+
//
|
|
14
|
+
// NOTE on ordering: the `01.` prefix sorts only WITHIN this (collections) layer. Nitro runs plugins by
|
|
15
|
+
// layer-then-filename, so this plugin does NOT run second — in-repo it runs LAST (after core's
|
|
16
|
+
// 00.migrate/02.schema-sync/03.record-refs), and the cross-layer order differs again for an
|
|
17
|
+
// `extends: ['@michaelthielemann/kestrel']` consumer. That is safe ONLY because nothing reads the registry at plugin init:
|
|
18
|
+
// migrate/schema-sync read the `#kestrel/collections` virtual directly, and the ref/cleanup/publish
|
|
19
|
+
// plugins register deferred listeners. Keep that invariant — do not add an init-time allCollections() read.
|
|
20
|
+
export default defineNitroPlugin(() => {
|
|
21
|
+
for (const block of blocks as BlockDef[]) registerBlock(block)
|
|
22
|
+
// Built-in collections (`pages`, `media`) register by default but can be disabled per consumer via
|
|
23
|
+
// `kestrel: { collections: { <name>: false } }`. Prefer the value the kestrel module put on runtimeConfig
|
|
24
|
+
// (the consumer's `kestrel: {}`); fall back to Kestrel's own resolved config for non-Nitro / dev callers.
|
|
25
|
+
const toggles = (serverRuntimeConfig()?.kestrel?.collections ?? resolveServerKestrel().collections) as Record<string, boolean>
|
|
26
|
+
for (const collection of collections as (CollectionDef | BuiltCollection)[]) {
|
|
27
|
+
const built = ensureBuilt(collection)
|
|
28
|
+
// The shared predicate (schema/bootstrap) — the schema engine gates on exactly the same rule.
|
|
29
|
+
if (!collectionEnabled(built.def, toggles)) continue
|
|
30
|
+
registerCollection(built)
|
|
31
|
+
}
|
|
32
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { registerFieldPopulator } from '../../../core/server/utils/populate'
|
|
2
|
+
import { buildRelationFieldPopulator, skipMissing } from '../utils/populate-relations'
|
|
3
|
+
import { getCollection } from '../../../core/server/utils/registry'
|
|
4
|
+
import { getOne } from '../../../core/server/utils/crud'
|
|
5
|
+
|
|
6
|
+
// Expand relation fields to the fully-populated related record(s) under `$<name>` at read time (depth ≥ 1).
|
|
7
|
+
// The related read is published-only (a draft target is skipped, never leaked to a public read), tolerates a
|
|
8
|
+
// stale/deleted id (getOne 404 → null via `skipMissing`, other errors propagate — fail-loud), and runs at
|
|
9
|
+
// `depth - 1`, so `populateRow` bails at 0 and cycles terminate. Registered per-type; the shared field-tree
|
|
10
|
+
// walker (fields layer) dispatches it.
|
|
11
|
+
export default defineNitroPlugin(() => {
|
|
12
|
+
registerFieldPopulator('relation', buildRelationFieldPopulator((collection, id, depth, locale) => {
|
|
13
|
+
const built = getCollection(collection)
|
|
14
|
+
if (!built) return null
|
|
15
|
+
return skipMissing(() => getOne(useDb(), built, id, depth, locale, true) as Record<string, unknown>)
|
|
16
|
+
}))
|
|
17
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { fieldIs } from '../../../core/server/utils/defineCollection'
|
|
2
|
+
import type { FieldPopulator } from '../../../core/server/utils/populate'
|
|
3
|
+
import { memoDuringPrerender } from '../../../core/server/utils/prerender-memo'
|
|
4
|
+
import { memoResolver } from '../../../core/server/utils/resolve-scope'
|
|
5
|
+
|
|
6
|
+
/** Fetches a related record FULLY populated (its own media / links / nested relations resolved), or null
|
|
7
|
+
* when the id is stale / deleted / draft. Injected so the populator stays pure + unit-testable; the plugin
|
|
8
|
+
* wires the real `getOne`. `depth` is the ALREADY-decremented depth for the related read (the cycle guard). */
|
|
9
|
+
export type ResolveRecord = (
|
|
10
|
+
collection: string,
|
|
11
|
+
id: number,
|
|
12
|
+
depth: number,
|
|
13
|
+
locale: string,
|
|
14
|
+
) => Record<string, unknown> | null
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Run a related-record fetch, mapping `getOne`'s 404 (a stale / deleted / draft target — the intended
|
|
18
|
+
* "skip this reference" signal) to `null`, but letting ANY OTHER error propagate. Swallowing every error
|
|
19
|
+
* here would drop a relation on a real fault (a DB error, a downstream populator throwing on malformed
|
|
20
|
+
* stored data) while the page still renders + records `success` — violating fail-loud. Pure + testable.
|
|
21
|
+
*/
|
|
22
|
+
export function skipMissing(fetch: () => Record<string, unknown>): Record<string, unknown> | null {
|
|
23
|
+
try {
|
|
24
|
+
return fetch()
|
|
25
|
+
} catch (e) {
|
|
26
|
+
if ((e as { statusCode?: number })?.statusCode === 404) return null
|
|
27
|
+
throw e
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The `relation` field populator: expands a relation id (single, `${name}Id` in columns / bare in props)
|
|
33
|
+
* or id array (many, always bare) into the fully-populated related record(s) under a `$<name>` sibling,
|
|
34
|
+
* leaving the raw id column intact — so write round-trips and the admin relation-picker keep working.
|
|
35
|
+
* Stale / deleted / draft ids are skipped (single → null, many → filtered out) so one bad reference never
|
|
36
|
+
* fails the whole read. The related read passes `ctx.depth - 1`; `populateRow` bails at depth 0, so a
|
|
37
|
+
* relation cycle terminates. Registered per-type via `registerFieldPopulator('relation', …)`; the shared
|
|
38
|
+
* field-tree walker drives it over top-level fields, block props, slots, and repeater entries.
|
|
39
|
+
*/
|
|
40
|
+
export function buildRelationFieldPopulator(resolveRecord: ResolveRecord): FieldPopulator {
|
|
41
|
+
// The same target (collection+id+depth+locale) is resolved once — build-wide during a generate run
|
|
42
|
+
// (memoDuringPrerender), request-/publish-run-wide via the resolve scope (which also budgets the
|
|
43
|
+
// distinct fan-out of one live request and replays read-tags on hits, so publish deps stay complete).
|
|
44
|
+
// memoResolver OUTERMOST: the per-scope budget verdict must stay scope-local. If memoDuringPrerender
|
|
45
|
+
// wrapped memoResolver, a build-wide memoize would cache a budget-skip `null` and poison every later
|
|
46
|
+
// page of a `nuxt generate`. With this order the build-wide memo only ever caches REAL resolver results.
|
|
47
|
+
const key = (collection: string, id: number, depth: number, locale: string) => `rel:${collection}:${id}:${depth}:${locale}`
|
|
48
|
+
const resolve = memoResolver(memoDuringPrerender(resolveRecord, key), key)
|
|
49
|
+
return (bag, key, field, ctx, keyMode) => {
|
|
50
|
+
if (!fieldIs(field, 'relation')) return
|
|
51
|
+
const collection = field.relation.collection
|
|
52
|
+
const depth = ctx.depth - 1
|
|
53
|
+
if (field.relation.many) {
|
|
54
|
+
const ids = bag[key]
|
|
55
|
+
if (Array.isArray(ids)) {
|
|
56
|
+
bag['$' + key] = ids
|
|
57
|
+
.filter((n): n is number => typeof n === 'number')
|
|
58
|
+
.map((id) => resolve(collection, id, depth, ctx.locale))
|
|
59
|
+
.filter((r): r is Record<string, unknown> => r != null)
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
const id = bag[keyMode === 'columns' ? `${key}Id` : key]
|
|
63
|
+
if (typeof id === 'number') bag['$' + key] = resolve(collection, id, depth, ctx.locale)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// The batch operations the bulk endpoint accepts — the single source of truth shared by the server
|
|
2
|
+
// handler (`bulk.post.ts`) and any client that builds a bulk request. Pure, so the allow-list is
|
|
3
|
+
// unit-testable without a Nitro context.
|
|
4
|
+
export const BULK_ACTIONS = ['delete', 'publish', 'unpublish', 'duplicate'] as const
|
|
5
|
+
export type BulkAction = typeof BULK_ACTIONS[number]
|
|
6
|
+
|
|
7
|
+
export function isBulkAction(value: unknown): value is BulkAction {
|
|
8
|
+
return typeof value === 'string' && (BULK_ACTIONS as readonly string[]).includes(value)
|
|
9
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { SerializedField } from '../../server/utils/serialize-collection' // type-only, erased client-side
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The single shared source of truth for list filtering: which normalized "kind" a field/meta column maps
|
|
5
|
+
* to, and which operators that kind allows. Both the server (validation + predicate building) and the admin
|
|
6
|
+
* UI (operator select + typed value control) import this — so the allowed-operators-per-field-type table is
|
|
7
|
+
* defined exactly once. Kept in `core/app/utils` (the server-and-client shared home) and free of any DB or
|
|
8
|
+
* column-naming detail: each side maps its own column keys.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type FilterOp = 'eq' | 'ne' | 'lt' | 'lte' | 'gt' | 'gte' | 'contains' | 'notContains'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A field's storage/semantics bucket for filtering. `text`/`richtext` do substring LIKE; `stringSet`/`idSet`
|
|
15
|
+
* are JSON-array membership (multi choice / many relation+media); `ref` is a single FK id (eq/ne only);
|
|
16
|
+
* `enum`/`boolean` are equality-only; `number`/`datetime` support the full comparison set.
|
|
17
|
+
*/
|
|
18
|
+
export type FilterKind = 'number' | 'text' | 'datetime' | 'boolean' | 'enum' | 'ref' | 'richtext' | 'stringSet' | 'idSet'
|
|
19
|
+
|
|
20
|
+
const CMP = ['eq', 'ne', 'lt', 'lte', 'gt', 'gte'] as const
|
|
21
|
+
|
|
22
|
+
export const OPS_BY_KIND: Record<FilterKind, readonly FilterOp[]> = {
|
|
23
|
+
number: CMP,
|
|
24
|
+
datetime: CMP,
|
|
25
|
+
text: ['eq', 'ne', 'contains'],
|
|
26
|
+
richtext: ['contains'], // `contains` matches the stored HTML SOURCE (searching 'p' can hit a '<p>' tag) —
|
|
27
|
+
// a stripped shadow column / FTS index is a later enhancement (documented caveat).
|
|
28
|
+
boolean: ['eq', 'ne'],
|
|
29
|
+
enum: ['eq', 'ne'],
|
|
30
|
+
ref: ['eq', 'ne'], // a single relation/media FK id — lt/gt on an id is meaningless.
|
|
31
|
+
stringSet: ['contains', 'notContains'],
|
|
32
|
+
idSet: ['contains', 'notContains'],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The operator a column's filter row defaults to in the admin UI. `datetime` defaults to `gte` ("on or
|
|
36
|
+
* after") — `eq` on a millisecond timestamp practically never matches, so it made the filter useless. The
|
|
37
|
+
* admin always emits the operator explicitly, so this does NOT change the wire parser's separate
|
|
38
|
+
* absent-operator default (a bare `filter[field]` still parses as `eq`, URLs round-trip unchanged). */
|
|
39
|
+
export const DEFAULT_OP: Record<FilterKind, FilterOp> = {
|
|
40
|
+
number: 'eq', datetime: 'gte', text: 'eq', boolean: 'eq', enum: 'eq', ref: 'eq',
|
|
41
|
+
richtext: 'contains', stringSet: 'contains', idSet: 'contains',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ALL_OPS = new Set<string>(['eq', 'ne', 'lt', 'lte', 'gt', 'gte', 'contains', 'notContains'])
|
|
45
|
+
|
|
46
|
+
/** Whether a raw wire token is a known operator at all (parse-time guard → a clean 400 for garbage). */
|
|
47
|
+
export const isFilterOp = (s: string): s is FilterOp => ALL_OPS.has(s)
|
|
48
|
+
|
|
49
|
+
/** Whether `op` is allowed for a column of this kind (list-time guard → a clean 400, never a 500). */
|
|
50
|
+
export const opAllowed = (kind: FilterKind, op: FilterOp): boolean => OPS_BY_KIND[kind].includes(op)
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The wire key for a filter clause: `filter[field]` or `filter[field][op]`. `[^\]]+` keeps the field/op
|
|
54
|
+
* tokens bracket-free; the optional second group is the operator (absent → `eq`, so a bare
|
|
55
|
+
* `filter[status]=x` stays backward compatible). Defined ONCE here (the shared client+server home) so the
|
|
56
|
+
* admin URL parser (`parseListQuery`) and the server wire parser (`parseFilter`) use exactly one regex.
|
|
57
|
+
*/
|
|
58
|
+
export const FILTER_RE = /^filter\[([^\]]+)\](?:\[([^\]]+)\])?$/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The FilterKind for a field, or `null` when it is not filterable (link / json / repeater / range-datetime).
|
|
62
|
+
* Unknown consumer types fall back to the conservative `text` (equality + substring on their scalar column).
|
|
63
|
+
*/
|
|
64
|
+
export function fieldFilterKind(f: SerializedField): FilterKind | null {
|
|
65
|
+
switch (f.type) {
|
|
66
|
+
case 'text':
|
|
67
|
+
case 'slug':
|
|
68
|
+
return 'text'
|
|
69
|
+
case 'richtext':
|
|
70
|
+
return 'richtext'
|
|
71
|
+
case 'number':
|
|
72
|
+
return 'number'
|
|
73
|
+
case 'boolean':
|
|
74
|
+
return 'boolean'
|
|
75
|
+
case 'datetime':
|
|
76
|
+
return f.options?.range ? null : 'datetime'
|
|
77
|
+
case 'choice':
|
|
78
|
+
return f.options?.multiple ? 'stringSet' : 'enum'
|
|
79
|
+
case 'relation':
|
|
80
|
+
case 'media':
|
|
81
|
+
return f.single ? 'ref' : 'idSet'
|
|
82
|
+
case 'link':
|
|
83
|
+
case 'json':
|
|
84
|
+
case 'repeater':
|
|
85
|
+
return null
|
|
86
|
+
default:
|
|
87
|
+
return 'text'
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The FilterKind of each filterable meta/system column (keyed by its wire key). */
|
|
92
|
+
export const META_FILTER_KIND: Record<string, FilterKind> = {
|
|
93
|
+
id: 'number',
|
|
94
|
+
path: 'text',
|
|
95
|
+
status: 'enum',
|
|
96
|
+
createdAt: 'datetime',
|
|
97
|
+
updatedAt: 'datetime',
|
|
98
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The single source of truth for collection-list page sizes and, derived from them, the bulk-operation id
|
|
2
|
+
// cap. Lives in `core/app/utils` (the established server+client shared home — same as `filter-ops.ts`) so
|
|
3
|
+
// the admin list UI, the `list()` query engine, and the `bulk` endpoint all agree on ONE set of limits
|
|
4
|
+
// instead of each hardcoding its own.
|
|
5
|
+
|
|
6
|
+
/** The page sizes offered in the list's per-page selector. */
|
|
7
|
+
export const PER_PAGE_OPTIONS = [25, 50, 100, 250, 500] as const
|
|
8
|
+
|
|
9
|
+
/** The page size a list falls back to when none (or a garbage value) is supplied. */
|
|
10
|
+
export const DEFAULT_PER_PAGE = 25
|
|
11
|
+
|
|
12
|
+
/** The largest page a single list request may return — also the ceiling `clampPerPage` clamps to. */
|
|
13
|
+
export const MAX_PER_PAGE = 500
|
|
14
|
+
|
|
15
|
+
/** Coerce an untrusted page size to a sane integer: a non-finite input (undefined / NaN / Infinity / a
|
|
16
|
+
* non-numeric string) falls back to `DEFAULT_PER_PAGE`; anything else is floored and clamped to
|
|
17
|
+
* `[1, MAX_PER_PAGE]`. Guards against `LIMIT NaN` (which SQLite binds as "no limit") — an unauthenticated
|
|
18
|
+
* cap-bypass / DoS lever. */
|
|
19
|
+
export function clampPerPage(n: unknown): number {
|
|
20
|
+
const v = Number(n)
|
|
21
|
+
if (!Number.isFinite(v)) return DEFAULT_PER_PAGE
|
|
22
|
+
return Math.min(MAX_PER_PAGE, Math.max(1, Math.floor(v)))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A bulk op can never carry more ids than one page can show (you select what you can see), so the id cap
|
|
26
|
+
* is DERIVED from the max page size, not guessed. */
|
|
27
|
+
export const MAX_BULK_IDS = MAX_PER_PAGE
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public route path for a page row: other locales are prefixed with `/<locale>`; the primary locale
|
|
3
|
+
* is unprefixed unless `prefixPrimary` is set (then it is prefixed too, root `/` → `/<primary>`). Single
|
|
4
|
+
* source of truth for the URL scheme shared by the prerender routes, the sitemap, `populate-links`, the
|
|
5
|
+
* `[...slug].vue` resolution, the slug-uniqueness check, and the admin SEO preview. Pure, so it is
|
|
6
|
+
* unit-testable and safe to import from both server and app code.
|
|
7
|
+
*/
|
|
8
|
+
export function localePath(path: string, locale: string, primaryLocale: string, prefixPrimary = false): string {
|
|
9
|
+
const p = path.startsWith('/') ? path : `/${path}`
|
|
10
|
+
if (locale === primaryLocale && !prefixPrimary) return p
|
|
11
|
+
return p === '/' ? `/${locale}` : `/${locale}${p}`
|
|
12
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn arbitrary text (a record title) into a url-safe slug: lowercase, fold diacritics to ASCII
|
|
3
|
+
* (`Über` → `uber`, `Café` → `cafe`) via NFKD + combining-mark removal, then collapse every run of
|
|
4
|
+
* non-`[a-z0-9]` into a single hyphen and trim the ends. Letters with no ASCII fold (ß, CJK, …) act as
|
|
5
|
+
* separators — Kestrel does NO language-specific transliteration, so the result is predictable. Returns
|
|
6
|
+
* `''` for blank / symbol-only input (the caller decides: fall back to the manual slug, or reject). Pure.
|
|
7
|
+
*/
|
|
8
|
+
export function slugify(input: string): string {
|
|
9
|
+
if (typeof input !== 'string') return ''
|
|
10
|
+
return input
|
|
11
|
+
.normalize('NFKD')
|
|
12
|
+
.replace(/\p{M}/gu, '') // strip combining diacritical marks left by NFKD (é → e, ü → u)
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
15
|
+
.replace(/^-+|-+$/g, '')
|
|
16
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { basename } from 'node:path'
|
|
3
|
+
import { parse } from '@babel/parser'
|
|
4
|
+
import type { FieldDef } from '../../server/utils/defineCollection'
|
|
5
|
+
import * as factoryModule from '../../../fields/app/utils/field-factories'
|
|
6
|
+
import { KESTREL_FIELD } from '../../../fields/app/utils/field-factories'
|
|
7
|
+
|
|
8
|
+
/** The extracted block definition (shape mirrors `BlockDef`, kept structural so this build-time module
|
|
9
|
+
* needs no `layers/fields` runtime import). */
|
|
10
|
+
export interface ExtractedBlock {
|
|
11
|
+
name: string
|
|
12
|
+
fields: Record<string, FieldDef>
|
|
13
|
+
slots?: string[]
|
|
14
|
+
label?: unknown
|
|
15
|
+
icon?: string
|
|
16
|
+
image?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Only the callable factories are injected into the schema-eval scope (exclude the KESTREL_FIELD symbol).
|
|
20
|
+
const FACTORIES: Record<string, unknown> = Object.fromEntries(
|
|
21
|
+
Object.entries(factoryModule).filter(([, v]) => typeof v === 'function'),
|
|
22
|
+
)
|
|
23
|
+
// The factory functions themselves, to catch an uncalled factory (`heading: textField` vs `textField()`).
|
|
24
|
+
const FACTORY_SET = new Set(Object.values(FACTORIES))
|
|
25
|
+
|
|
26
|
+
/** `Hero.vue` → `hero`, `BoxedContainer.vue` → `boxedContainer` (camel-case; `BlockRenderer` pascal-cases it
|
|
27
|
+
* back to the `Blocks<Name>` component). */
|
|
28
|
+
export function blockNameFromFile(fileBase: string): string {
|
|
29
|
+
const name = fileBase.replace(/\.vue$/, '')
|
|
30
|
+
return name.charAt(0).toLowerCase() + name.slice(1)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The raw `<script setup>` source. `setup` is matched in ANY attribute position (`<script setup lang="ts">`
|
|
34
|
+
* and `<script lang="ts" setup>` are both valid), so a plain `<script>` block (no `setup` word) is skipped.
|
|
35
|
+
* (A real Vite build could use `@vue/compiler-sfc parse` for full robustness — e.g. a `</script>` inside a
|
|
36
|
+
* string literal; a scoped match is adequate + dependency-light here.) */
|
|
37
|
+
function scriptSetup(sfc: string, where: string): string {
|
|
38
|
+
const m = sfc.match(/<script\b[^>]*\bsetup\b[^>]*>([\s\S]*?)<\/script>/)
|
|
39
|
+
if (!m) throw new Error(`${where}: a Kestrel block SFC must have a <script setup> block`)
|
|
40
|
+
return m[1]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface Node { type: string; start: number; end: number; [k: string]: unknown }
|
|
44
|
+
|
|
45
|
+
/** Peel the wrappers a macro call is commonly written behind so the CallExpression underneath is reached:
|
|
46
|
+
* a TS as-cast / satisfies (`defineProps({…}) as T`), a non-null assertion (`defineProps({…})!`), and
|
|
47
|
+
* parentheses. */
|
|
48
|
+
function unwrapExpr(node: unknown): Record<string, unknown> | undefined {
|
|
49
|
+
let n = node as Record<string, unknown> | undefined
|
|
50
|
+
while (n && (n.type === 'TSAsExpression' || n.type === 'TSSatisfiesExpression' || n.type === 'TSNonNullExpression' || n.type === 'ParenthesizedExpression')) {
|
|
51
|
+
n = n.expression as Record<string, unknown> | undefined
|
|
52
|
+
}
|
|
53
|
+
return n
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** If `call` resolves to `<macro>(…)`, its first arg node + whether it carried a type parameter
|
|
57
|
+
* (`defineProps<T>()`, unsupported — no runtime schema). Also looks INSIDE `withDefaults(defineProps(…), …)`
|
|
58
|
+
* — the most common props idiom — so that form is not silently extracted as an empty schema. */
|
|
59
|
+
function macroInCall(call: unknown, macro: string): { arg?: Node; hasTypeParam: boolean } | undefined {
|
|
60
|
+
const n = unwrapExpr(call)
|
|
61
|
+
if (n?.type !== 'CallExpression') return undefined
|
|
62
|
+
const callee = n.callee as Record<string, unknown> | undefined
|
|
63
|
+
if (callee?.type === 'Identifier' && callee.name === macro) {
|
|
64
|
+
return { arg: (n.arguments as Node[])[0], hasTypeParam: !!(n.typeParameters || n.typeArguments) }
|
|
65
|
+
}
|
|
66
|
+
// withDefaults(defineProps(…), { … }) — recurse into its arguments to find the wrapped macro call.
|
|
67
|
+
if (callee?.type === 'Identifier' && callee.name === 'withDefaults') {
|
|
68
|
+
for (const a of (n.arguments as unknown[]) ?? []) {
|
|
69
|
+
const hit = macroInCall(a, macro)
|
|
70
|
+
if (hit) return hit
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The FIRST TOP-LEVEL `<macro>(…)` call — a bare `defineBlock({…})` (ExpressionStatement), a
|
|
77
|
+
* `const props = defineProps({…})` (VariableDeclaration init, incl. `export const` and wrapper forms).
|
|
78
|
+
* Vue's macros are top-level by contract, so scanning only `program.body` (not a deep DFS) means a call
|
|
79
|
+
* nested in a helper function can't shadow it. */
|
|
80
|
+
function macroCall(ast: unknown, macro: string): { arg?: Node; hasTypeParam: boolean } | undefined {
|
|
81
|
+
const body = (ast as { program?: { body?: unknown[] } }).program?.body
|
|
82
|
+
if (!Array.isArray(body)) return undefined
|
|
83
|
+
for (const raw of body as Array<Record<string, unknown>>) {
|
|
84
|
+
// Unwrap `export const props = …` → the VariableDeclaration it wraps.
|
|
85
|
+
const stmt = raw.type === 'ExportNamedDeclaration' && raw.declaration ? (raw.declaration as Record<string, unknown>) : raw
|
|
86
|
+
if (stmt.type === 'ExpressionStatement') {
|
|
87
|
+
const hit = macroInCall(stmt.expression, macro)
|
|
88
|
+
if (hit) return hit
|
|
89
|
+
} else if (stmt.type === 'VariableDeclaration') {
|
|
90
|
+
for (const d of (stmt.declarations as Array<Record<string, unknown>>) ?? []) {
|
|
91
|
+
const hit = macroInCall(d.init, macro)
|
|
92
|
+
if (hit) return hit
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function evalObjectExpr(content: string, node: Node, scope: Record<string, unknown>, where: string): Record<string, unknown> {
|
|
100
|
+
const src = content.slice(node.start, node.end)
|
|
101
|
+
const names = Object.keys(scope)
|
|
102
|
+
try {
|
|
103
|
+
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
|
104
|
+
const fn = new Function(...names, `return (${src})`)
|
|
105
|
+
const out = fn(...names.map((k) => scope[k]))
|
|
106
|
+
if (!out || typeof out !== 'object') throw new Error('expected an object literal')
|
|
107
|
+
return out as Record<string, unknown>
|
|
108
|
+
} catch (e) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`${where}: could not evaluate the block declaration. Field/block args must be self-contained literals + ` +
|
|
111
|
+
`field-factory calls (no imported constants, computed values, or TS type-args). Cause: ${(e as Error).message}`,
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Extract a `BlockDef` from a block SFC's source: `defineProps({ … field factories … })` → `fields`,
|
|
118
|
+
* `defineBlock({ label, slots, icon })` → metadata, `name` ← filename. Static (no component execution): the
|
|
119
|
+
* `defineProps`/`defineBlock` argument object-literals are sliced from source and evaluated with the field
|
|
120
|
+
* factories in scope; each field's `[KESTREL_FIELD]` is lifted into the schema.
|
|
121
|
+
*/
|
|
122
|
+
export function extractBlockDef(sfcSource: string, fileBase: string): ExtractedBlock {
|
|
123
|
+
const name = blockNameFromFile(fileBase)
|
|
124
|
+
const content = scriptSetup(sfcSource, fileBase)
|
|
125
|
+
let ast: ReturnType<typeof parse>
|
|
126
|
+
try {
|
|
127
|
+
ast = parse(content, { sourceType: 'module', plugins: ['typescript'] })
|
|
128
|
+
} catch (e) {
|
|
129
|
+
throw new Error(`${fileBase}: could not parse <script setup> — ${(e as Error).message}`)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const props = macroCall(ast, 'defineProps')
|
|
133
|
+
if (props?.hasTypeParam && !props.arg) {
|
|
134
|
+
throw new Error(`${fileBase}: use the runtime form defineProps({ … }) with field factories, not defineProps<T>() — the type-only form carries no schema`)
|
|
135
|
+
}
|
|
136
|
+
const propsObj = props?.arg ? evalObjectExpr(content, props.arg, FACTORIES, fileBase) : {}
|
|
137
|
+
|
|
138
|
+
const block = macroCall(ast, 'defineBlock')
|
|
139
|
+
const meta = block?.arg ? evalObjectExpr(content, block.arg, {}, fileBase) : {}
|
|
140
|
+
|
|
141
|
+
const fields: Record<string, FieldDef> = {}
|
|
142
|
+
for (const [key, value] of Object.entries(propsObj)) {
|
|
143
|
+
const carried = value && typeof value === 'object' ? (value as Record<symbol, unknown>)[KESTREL_FIELD] : undefined
|
|
144
|
+
if (carried === undefined) {
|
|
145
|
+
// An UNCALLED field factory (`heading: textField` instead of `textField()`) evaluates to the factory
|
|
146
|
+
// function itself — catch that common slip loudly rather than silently dropping the field.
|
|
147
|
+
if (typeof value === 'function' && FACTORY_SET.has(value)) {
|
|
148
|
+
throw new Error(`${fileBase}: prop "${key}" is a field factory that was not called — write ${(value as { name?: string }).name ?? 'xField'}({ … })`)
|
|
149
|
+
}
|
|
150
|
+
// Otherwise it's a plain (display-only) Vue prop (e.g. `media: Object`, the server-resolved $media bag
|
|
151
|
+
// BlockRenderer passes) — not part of the block schema. A misspelled factory throws at eval time above.
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
const def = carried as FieldDef & { default?: unknown }
|
|
155
|
+
if (typeof def.default === 'function') {
|
|
156
|
+
// The registry is inlined as JSON (renderBlockRegistry) — a function default would be silently dropped.
|
|
157
|
+
throw new Error(`${fileBase}: prop "${key}" has a function \`default\` — block field defaults must be JSON-serializable literals`)
|
|
158
|
+
}
|
|
159
|
+
fields[key] = carried as FieldDef
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const out: ExtractedBlock = { name, fields }
|
|
163
|
+
if (meta.label !== undefined) out.label = meta.label
|
|
164
|
+
if (Array.isArray(meta.slots) && meta.slots.length) out.slots = meta.slots as string[]
|
|
165
|
+
if (typeof meta.icon === 'string') out.icon = meta.icon
|
|
166
|
+
if (typeof meta.image === 'string') out.image = meta.image
|
|
167
|
+
return out
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The `#kestrel/blocks` virtual body: extract each block SFC and inline the definitions as JSON data
|
|
171
|
+
* (the block registry is plain, JSON-safe data — no imports needed beyond the field-type side effect the
|
|
172
|
+
* caller prepends). */
|
|
173
|
+
export function renderBlockRegistry(vuePaths: string[]): string {
|
|
174
|
+
const defs = vuePaths.map((p) => extractBlockDef(readFileSync(p, 'utf8'), basename(p)))
|
|
175
|
+
return `export default ${JSON.stringify(defs)}`
|
|
176
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { addComponentsDir, addTypeTemplate, createResolver, defineNuxtModule } from '@nuxt/kit'
|
|
4
|
+
import { collectBlockSfcs, collectDefinitions, renderRegistry } from './scan'
|
|
5
|
+
import { renderBlockRegistry } from './extract-block'
|
|
6
|
+
|
|
7
|
+
export default defineNuxtModule({
|
|
8
|
+
meta: { name: 'kestrel-auto-discovery' },
|
|
9
|
+
setup(_options, nuxt) {
|
|
10
|
+
const roots = nuxt.options._layers.map((layer) => layer.cwd)
|
|
11
|
+
// The `#kestrel/*` registries are Nitro VIRTUAL modules (runtime only) — tsc can't see their shape, so
|
|
12
|
+
// server code importing them would be `Cannot find module`. Register the ambient declarations into BOTH
|
|
13
|
+
// the Nitro and Nuxt tsconfigs so the typecheck resolves them.
|
|
14
|
+
const { resolve } = createResolver(import.meta.url)
|
|
15
|
+
addTypeTemplate({ filename: 'types/kestrel-virtual.d.ts', src: resolve('./virtual.d.ts') }, { nitro: true, nuxt: true })
|
|
16
|
+
|
|
17
|
+
// Blocks are authored as single `.vue` files under each layer's `app/blocks/` — the SFC is BOTH the
|
|
18
|
+
// schema source (extracted at build into `#kestrel/blocks`) AND the display component. Register the dir
|
|
19
|
+
// so `app/blocks/Hero.vue` resolves as the global `BlocksHero` that the public BlockRenderer renders.
|
|
20
|
+
for (const root of roots) {
|
|
21
|
+
const dir = join(root, 'app/blocks')
|
|
22
|
+
if (existsSync(dir)) addComponentsDir({ path: dir, prefix: 'Blocks', global: true, pathPrefix: false })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
nuxt.hook('nitro:config', (nitro) => {
|
|
26
|
+
nitro.virtual ||= {}
|
|
27
|
+
// Consumer field types register as a side effect on import, and the schema engine builds a table the
|
|
28
|
+
// moment a collection/block module loads. So the collections + blocks registries import the field-type
|
|
29
|
+
// registry FIRST — guaranteeing custom types are registered before any column / validator is built.
|
|
30
|
+
nitro.virtual['#kestrel/field-types'] = () =>
|
|
31
|
+
renderRegistry(collectDefinitions(roots, 'server/field-types'))
|
|
32
|
+
nitro.virtual['#kestrel/collections'] = () =>
|
|
33
|
+
`import '#kestrel/field-types'\n` + renderRegistry(collectDefinitions(roots, 'server/collections'))
|
|
34
|
+
// Extract each `app/blocks/*.vue` into a plain BlockDef inlined as JSON (no per-file imports needed).
|
|
35
|
+
nitro.virtual['#kestrel/blocks'] = () =>
|
|
36
|
+
`import '#kestrel/field-types'\n` + renderBlockRegistry(collectBlockSfcs(roots))
|
|
37
|
+
// Standalone (non-collection) tables: each layer's `server/schema-tables/*.ts` default-exports one
|
|
38
|
+
// drizzle table (media → folders, public → publish_deps/publish_status; consumers can add their own).
|
|
39
|
+
// The schema engine consumes this instead of core hardcoding upper-layer tables.
|
|
40
|
+
nitro.virtual['#kestrel/schema-tables'] = () =>
|
|
41
|
+
renderRegistry(collectDefinitions(roots, 'server/schema-tables'))
|
|
42
|
+
})
|
|
43
|
+
},
|
|
44
|
+
})
|