@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,78 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
import { blockNameFromFile } from './extract-block'
|
|
4
|
+
|
|
5
|
+
type Lister = (dir: string) => string[]
|
|
6
|
+
|
|
7
|
+
const isDefinition = (name: string) =>
|
|
8
|
+
name.endsWith('.ts') && !name.endsWith('.test.ts') && !name.endsWith('.d.ts')
|
|
9
|
+
|
|
10
|
+
export function listDefinitionFiles(dir: string, list: Lister = readdirSync): string[] {
|
|
11
|
+
let entries: string[]
|
|
12
|
+
try {
|
|
13
|
+
entries = list(dir)
|
|
14
|
+
} catch (err) {
|
|
15
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []
|
|
16
|
+
throw err
|
|
17
|
+
}
|
|
18
|
+
return entries
|
|
19
|
+
.filter(isDefinition)
|
|
20
|
+
.sort()
|
|
21
|
+
.map((name) => join(dir, name))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Definition files across every layer's `subdir`, deduped by BASENAME with LAYER PRIORITY: `layerRoots` is
|
|
26
|
+
* Nuxt's `_layers` order (consumer/highest-priority first), so a consumer's `collections/pages.ts` (or
|
|
27
|
+
* `field-types/color.ts`) SHADOWS the same-named lower-layer file — a deterministic override, instead of
|
|
28
|
+
* importing both and relying on the registry's last-write-wins under an alphabetical path sort (which made
|
|
29
|
+
* "who wins" a coincidence of the node_modules path). Mirrors `collectBlockSfcs`'s name-first dedup.
|
|
30
|
+
*/
|
|
31
|
+
export function collectDefinitions(layerRoots: string[], subdir: string, list?: Lister): string[] {
|
|
32
|
+
const byBasename = new Map<string, string>()
|
|
33
|
+
for (const root of layerRoots) {
|
|
34
|
+
for (const file of listDefinitionFiles(join(root, subdir), list)) {
|
|
35
|
+
const name = basename(file)
|
|
36
|
+
if (!byBasename.has(name)) byBasename.set(name, file) // first (highest-priority layer) wins
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return [...byBasename.values()].sort()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const isVue = (name: string) => name.endsWith('.vue') && !name.endsWith('.test.vue')
|
|
43
|
+
|
|
44
|
+
/** `.vue` files in `dir`, sorted; `[]` when the dir is absent. Mirrors `listDefinitionFiles` for blocks. */
|
|
45
|
+
export function listVueFiles(dir: string, list: Lister = readdirSync): string[] {
|
|
46
|
+
let entries: string[]
|
|
47
|
+
try {
|
|
48
|
+
entries = list(dir)
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []
|
|
51
|
+
throw err
|
|
52
|
+
}
|
|
53
|
+
return entries.filter(isVue).sort().map((name) => join(dir, name))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Block SFCs across every layer's `app/blocks/` dir, deduped by BLOCK NAME with LAYER PRIORITY: `layerRoots`
|
|
57
|
+
* is Nuxt's `_layers` order (consumer/highest-priority first), so the first file seen for a given block name
|
|
58
|
+
* wins. This matches how the display component is resolved (project-first), so a same-named override takes
|
|
59
|
+
* its schema AND its template from the same layer — a plain alphabetical path sort handed the schema to
|
|
60
|
+
* whichever path sorted last (typically the node_modules layer), silently splitting the two. */
|
|
61
|
+
export function collectBlockSfcs(layerRoots: string[], subdir = 'app/blocks', list?: Lister): string[] {
|
|
62
|
+
const byName = new Map<string, string>()
|
|
63
|
+
for (const root of layerRoots) {
|
|
64
|
+
for (const file of listVueFiles(join(root, subdir), list)) {
|
|
65
|
+
const name = blockNameFromFile(basename(file))
|
|
66
|
+
if (!byName.has(name)) byName.set(name, file) // first (highest-priority layer) wins
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Name-unique now, so the final sort is only for a deterministic, stable import order.
|
|
70
|
+
return [...byName.values()].sort()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function renderRegistry(absPaths: string[]): string {
|
|
74
|
+
if (!absPaths.length) return 'export default []'
|
|
75
|
+
const imports = absPaths.map((p, i) => `import _${i} from ${JSON.stringify(p)}`)
|
|
76
|
+
const arr = absPaths.map((_, i) => `_${i}`).join(', ')
|
|
77
|
+
return `${imports.join('\n')}\nexport default [${arr}]`
|
|
78
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
declare module '#kestrel/collections' {
|
|
2
|
+
const collections: unknown[]
|
|
3
|
+
export default collections
|
|
4
|
+
}
|
|
5
|
+
declare module '#kestrel/blocks' {
|
|
6
|
+
const blocks: unknown[]
|
|
7
|
+
export default blocks
|
|
8
|
+
}
|
|
9
|
+
declare module '#kestrel/schema-tables' {
|
|
10
|
+
const tables: unknown[]
|
|
11
|
+
export default tables
|
|
12
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { defineNuxtModule } from '@nuxt/kit'
|
|
2
|
+
import { resolveKestrel, type KestrelConfig } from '../../server/utils/kestrel-config'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The `kestrel` config namespace (`kestrel: { … }` in nuxt.config, sourced from `kestrel.config.ts`).
|
|
6
|
+
* Lives in `core` so it runs before the `media`/`public` layer modules and populates runtimeConfig
|
|
7
|
+
* first. It bridges the resolved config to what the CLIENT + Nitro need: `runtimeConfig.media.*`,
|
|
8
|
+
* `runtimeConfig.public.{locales,primaryLocale}`, and the `/uploads` static serving. Server runtime
|
|
9
|
+
* utils (db/locale/site-url) read the same resolved values directly via `server/utils/kestrel-config.ts`.
|
|
10
|
+
*/
|
|
11
|
+
export default defineNuxtModule<KestrelConfig>({
|
|
12
|
+
meta: { name: 'kestrel', configKey: 'kestrel' },
|
|
13
|
+
setup(options, nuxt) {
|
|
14
|
+
const c = resolveKestrel(options, process.env, nuxt.options.rootDir)
|
|
15
|
+
|
|
16
|
+
const rc = nuxt.options.runtimeConfig
|
|
17
|
+
rc.media = {
|
|
18
|
+
driver: c.media.driver,
|
|
19
|
+
maxUploadBytes: c.media.maxUploadBytes,
|
|
20
|
+
allowedMimes: c.media.allowedMimes,
|
|
21
|
+
imagePolicy: c.media.imagePolicy,
|
|
22
|
+
local: { dir: c.media.dir, baseUrl: c.media.baseUrl },
|
|
23
|
+
// Non-secret S3 settings come from the resolved config; the credentials are env-only (never
|
|
24
|
+
// committed) and merged in here so the server driver gets everything from runtimeConfig.
|
|
25
|
+
s3: {
|
|
26
|
+
...c.media.s3,
|
|
27
|
+
accessKeyId: process.env.KESTREL_S3_ACCESS_KEY_ID ?? '',
|
|
28
|
+
secretAccessKey: process.env.KESTREL_S3_SECRET_ACCESS_KEY ?? '',
|
|
29
|
+
sessionToken: process.env.KESTREL_S3_SESSION_TOKEN ?? '',
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
const pub = (rc.public ??= {} as never) as Record<string, unknown>
|
|
33
|
+
pub.locales = c.supportedLocales
|
|
34
|
+
pub.primaryLocale = c.primaryLocale
|
|
35
|
+
pub.prefixPrimary = c.prefixPrimary
|
|
36
|
+
// The public head (canonical/og:url/hreflang) needs the absolute origin CLIENT-side too (SPA navs
|
|
37
|
+
// update the head after hydration). Not a secret — it is the site's own public address.
|
|
38
|
+
pub.siteUrl = c.siteUrl ?? ''
|
|
39
|
+
pub.siteName = c.siteName ?? ''
|
|
40
|
+
// Reference desktop width (px) for the admin page-builder preset; the editor's scale-to-fit shrinks it.
|
|
41
|
+
pub.previewDesktopWidth = c.preview.desktopWidth
|
|
42
|
+
|
|
43
|
+
// Server-only resolved settings, so server utils read the CONSUMER's `kestrel: {}` (via this module)
|
|
44
|
+
// rather than importing Kestrel's own `kestrel.config.ts` file. Essential when Kestrel is consumed as
|
|
45
|
+
// a layer: the package's config file is empty, the real config lives in the consumer's nuxt.config.
|
|
46
|
+
const srv = (rc.kestrel ??= {} as never) as Record<string, unknown>
|
|
47
|
+
srv.dbPath = c.dbPath
|
|
48
|
+
srv.siteUrl = c.siteUrl
|
|
49
|
+
srv.siteName = c.siteName
|
|
50
|
+
srv.siteDescription = c.siteDescription
|
|
51
|
+
// Built-in collection toggles (pages/media) — read by the register plugin to gate built-ins.
|
|
52
|
+
srv.collections = c.collections
|
|
53
|
+
// Static-publish target for the runtime publisher (server-only). S3 creds are env-only; prefer the
|
|
54
|
+
// output-specific creds, fall back to the shared media creds so a single S3 account "just works".
|
|
55
|
+
srv.output = {
|
|
56
|
+
driver: c.output.driver,
|
|
57
|
+
dir: c.output.dir,
|
|
58
|
+
publicDir: c.output.publicDir,
|
|
59
|
+
auto: c.output.auto,
|
|
60
|
+
reconcileMinutes: c.output.reconcileMinutes,
|
|
61
|
+
verbose: c.output.verbose,
|
|
62
|
+
s3: {
|
|
63
|
+
...c.output.s3,
|
|
64
|
+
accessKeyId: process.env.KESTREL_OUTPUT_S3_ACCESS_KEY_ID ?? process.env.KESTREL_S3_ACCESS_KEY_ID ?? '',
|
|
65
|
+
secretAccessKey: process.env.KESTREL_OUTPUT_S3_SECRET_ACCESS_KEY ?? process.env.KESTREL_S3_SECRET_ACCESS_KEY ?? '',
|
|
66
|
+
sessionToken: process.env.KESTREL_OUTPUT_S3_SESSION_TOKEN ?? process.env.KESTREL_S3_SESSION_TOKEN ?? '',
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
nuxt.options.nitro ??= {}
|
|
71
|
+
// Only the local driver serves uploads off disk from the app origin (dev + baked into `nuxt
|
|
72
|
+
// generate`). On S3 the bucket/CDN serves them, so we must not register a publicAssets entry for
|
|
73
|
+
// a directory that isn't the store (it would 404 or shadow real keys).
|
|
74
|
+
if (c.media.driver === 'local') {
|
|
75
|
+
nuxt.options.nitro.publicAssets = [
|
|
76
|
+
...(nuxt.options.nitro.publicAssets ?? []),
|
|
77
|
+
{ baseURL: c.media.baseUrl, dir: c.media.dir, maxAge: 0 },
|
|
78
|
+
]
|
|
79
|
+
// Uploads are served from the app origin; never let the browser content-sniff past the declared type.
|
|
80
|
+
nuxt.options.nitro.routeRules = {
|
|
81
|
+
...(nuxt.options.nitro.routeRules ?? {}),
|
|
82
|
+
[`${c.media.baseUrl}/**`]: { headers: { 'X-Content-Type-Options': 'nosniff' } },
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Per-record stale-reference map for the editor: every reference this record holds that now points at a
|
|
2
|
+
// deleted or unpublished target, with its field/block location + reason. Derived on read (auto-clears when
|
|
3
|
+
// the link is fixed or the target restored). Fetched non-blocking alongside the editor load, like the
|
|
4
|
+
// translations map.
|
|
5
|
+
export default defineEventHandler((event) => {
|
|
6
|
+
const collection = requireCollection(event)
|
|
7
|
+
return recordDeadRefs(useDb(), collection, requireId(event))
|
|
8
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export default defineEventHandler((event) => {
|
|
2
|
+
const collection = requireCollection(event)
|
|
3
|
+
const query = getQuery(event)
|
|
4
|
+
const depth = Number(query.depth ?? 0)
|
|
5
|
+
const locale = query.locale as string | undefined
|
|
6
|
+
const publishedOnly = publishedOnlyForScope(event.context.readScope)
|
|
7
|
+
return getOne(useDb(), collection, requireId(event), depth, locale, publishedOnly)
|
|
8
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export default defineEventHandler(async (event) => {
|
|
2
|
+
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
3
|
+
const collection = requireCollection(event)
|
|
4
|
+
const id = requireId(event)
|
|
5
|
+
const body = await readBody(event)
|
|
6
|
+
// Optimistic concurrency: the editor sends the baseline it loaded; a stale save is refused with 409.
|
|
7
|
+
return update(useDb(), collection, id, body, { expectedUpdatedAt: readIfUnmodifiedSince(event) })
|
|
8
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { MAX_BULK_IDS } from '../../../app/utils/list-limits'
|
|
2
|
+
import { BULK_ACTIONS, isBulkAction, type BulkAction } from '../../../app/utils/bulk-actions'
|
|
3
|
+
|
|
4
|
+
// The ONE command endpoint for collection batch operations — the single wire surface behind both the
|
|
5
|
+
// per-row Quick Actions and the selection Bulk bar (a row action IS a bulk action with one id). POST is
|
|
6
|
+
// classified 'write' on the `{collection}` resource by the access guard (resourceForPath('/api/{c}/bulk')
|
|
7
|
+
// === '{c}'), so CSRF + per-collection policy apply exactly as for a single-record write — no bypass.
|
|
8
|
+
// A literal `bulk.post.ts` segment beats the dynamic `[id]` route (no `[id].post.ts` exists), so no clash.
|
|
9
|
+
//
|
|
10
|
+
// body: { action: 'delete' | 'publish' | 'unpublish' | 'duplicate', ids: number[] }
|
|
11
|
+
// 200: { action, count, ids } (for 'duplicate', `ids` are the CREATED ids)
|
|
12
|
+
//
|
|
13
|
+
// delete + publish/unpublish are all-or-nothing (an unknown id 404s before any write); duplicate is a
|
|
14
|
+
// sequential row action. All batching lives in crud.ts — this handler is a thin parse-and-dispatch.
|
|
15
|
+
|
|
16
|
+
function parseBulkBody(body: unknown): { action: BulkAction; ids: number[] } {
|
|
17
|
+
const action = (body as { action?: unknown })?.action
|
|
18
|
+
if (!isBulkAction(action)) {
|
|
19
|
+
throw createError({ statusCode: 400, statusMessage: `Invalid bulk action: ${String(action)} (expected one of ${BULK_ACTIONS.join(', ')})` })
|
|
20
|
+
}
|
|
21
|
+
const ids = parseIdList((body as { ids?: unknown })?.ids, MAX_BULK_IDS)
|
|
22
|
+
return { action, ids }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default defineEventHandler(async (event) => {
|
|
26
|
+
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
27
|
+
const collection = requireCollection(event)
|
|
28
|
+
const { action, ids } = parseBulkBody(await readBody(event))
|
|
29
|
+
const db = useDb()
|
|
30
|
+
|
|
31
|
+
if (action === 'delete') return { action, ...removeMany(db, collection, ids) }
|
|
32
|
+
if (action === 'duplicate') {
|
|
33
|
+
const created = await duplicateMany(db, collection, ids)
|
|
34
|
+
return { action, count: created.length, ids: created.map((r) => r.id as number) }
|
|
35
|
+
}
|
|
36
|
+
return { action, ...setStatusMany(db, collection, ids, action === 'publish' ? 'published' : 'draft') }
|
|
37
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export default defineEventHandler((event) => {
|
|
2
|
+
const collection = requireCollection(event)
|
|
3
|
+
const query = getQuery(event)
|
|
4
|
+
const db = useDb()
|
|
5
|
+
const publishedOnly = publishedOnlyForScope(event.context.readScope)
|
|
6
|
+
|
|
7
|
+
if (collection.def.mode === 'single') {
|
|
8
|
+
return getSingleton(db, collection, query.locale as string | undefined, publishedOnly, query.depth ? Number(query.depth) : 0)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return list(db, collection, {
|
|
12
|
+
locale: query.locale as string | undefined,
|
|
13
|
+
sort: query.sort as string | undefined,
|
|
14
|
+
page: query.page ? Number(query.page) : undefined,
|
|
15
|
+
perPage: query.perPage ? Number(query.perPage) : undefined,
|
|
16
|
+
filter: parseFilter(query as Record<string, unknown>),
|
|
17
|
+
depth: query.depth ? Number(query.depth) : 0,
|
|
18
|
+
}, publishedOnly)
|
|
19
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export default defineEventHandler(async (event) => {
|
|
2
|
+
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
3
|
+
const collection = requireCollection(event)
|
|
4
|
+
const body = await readBody(event)
|
|
5
|
+
const row = create(useDb(), collection, body)
|
|
6
|
+
setResponseStatus(event, 201)
|
|
7
|
+
return row
|
|
8
|
+
})
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export default defineEventHandler(async (event) => {
|
|
2
|
+
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
3
|
+
const collection = requireCollection(event)
|
|
4
|
+
const query = getQuery(event)
|
|
5
|
+
const body = await readBody(event)
|
|
6
|
+
return putSingleton(useDb(), collection, query.locale as string | undefined, body, { expectedUpdatedAt: readIfUnmodifiedSince(event) })
|
|
7
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { MAX_BULK_IDS } from '../../../app/utils/list-limits'
|
|
2
|
+
|
|
3
|
+
export default defineEventHandler((event) => {
|
|
4
|
+
const collection = requireCollection(event)
|
|
5
|
+
const query = getQuery(event)
|
|
6
|
+
const db = useDb()
|
|
7
|
+
|
|
8
|
+
return pickerOptions(db, collection, {
|
|
9
|
+
search: query.search ? String(query.search) : undefined,
|
|
10
|
+
ids: query.ids ? parseIdList(query.ids, MAX_BULK_IDS) : undefined,
|
|
11
|
+
label: query.label ? String(query.label) : undefined,
|
|
12
|
+
page: query.page ? Number(query.page) : undefined,
|
|
13
|
+
perPage: query.perPage ? Number(query.perPage) : undefined,
|
|
14
|
+
locale: query.locale ? String(query.locale) : undefined,
|
|
15
|
+
// Match list()/getOne(): a published-scope read (anonymous / SSG) must not surface draft labels.
|
|
16
|
+
publishedOnly: publishedOnlyForScope(event.context.readScope),
|
|
17
|
+
})
|
|
18
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { eq, getTableColumns } from 'drizzle-orm'
|
|
2
|
+
import type { AnySQLiteColumn } from 'drizzle-orm/sqlite-core'
|
|
3
|
+
|
|
4
|
+
// The sibling map keyed by translation GROUP — the twin of `/:id/translations` for the editor of a record
|
|
5
|
+
// that does not exist yet (`/admin/pages/new?locale=de&group=…`), which knows its group but has no id. It
|
|
6
|
+
// resolves one member of the group and hands off to the per-id map builder, so both entry points can never
|
|
7
|
+
// answer differently. The literal `translations` segment beats the dynamic `[id]` route (as `options.get.ts`
|
|
8
|
+
// already relies on). Admin-only: `resourceForPath` maps it to the `<collection>/translations` resource, the
|
|
9
|
+
// same gate as the per-record route (the map enumerates draft ids).
|
|
10
|
+
export default defineEventHandler((event) => {
|
|
11
|
+
const collection = requireCollection(event)
|
|
12
|
+
// Checked here, not left to resolveTranslations: a collection without translations has no
|
|
13
|
+
// translationGroup column, so the member lookup below would blow up on an undefined column.
|
|
14
|
+
if (collection.def.mode === 'single' || !collection.def.translatable) {
|
|
15
|
+
throw createError({ statusCode: 400, statusMessage: 'Translations are not enabled for this collection' })
|
|
16
|
+
}
|
|
17
|
+
const group = String(getQuery(event).group ?? '').trim()
|
|
18
|
+
if (!group) throw createError({ statusCode: 400, statusMessage: 'A translation group is required' })
|
|
19
|
+
|
|
20
|
+
const db = useDb()
|
|
21
|
+
const cols = getTableColumns(collection.table) as Record<string, AnySQLiteColumn>
|
|
22
|
+
const member = db.select({ id: cols.id }).from(collection.table).where(eq(cols.translationGroup, group)).get() as { id: number } | undefined
|
|
23
|
+
if (!member) throw createError({ statusCode: 404, statusMessage: `Unknown translation group: ${group}` })
|
|
24
|
+
return resolveTranslations(db, collection, member.id)
|
|
25
|
+
})
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// Every reference in the index whose target is currently deleted or unpublished — the global broken-
|
|
2
|
+
// references report. Admin-only (the `references` resource is not in the public set). Derived on read.
|
|
3
|
+
export default defineEventHandler(() => findBrokenRefs(useDb()) ?? [])
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { MAX_BULK_IDS } from '../../../app/utils/list-limits'
|
|
2
|
+
|
|
3
|
+
// The distinct records that reference a given target — the reverse "what links here" lookup. Admin-only
|
|
4
|
+
// (the `references` resource is not in the public set). Powers the editor's pre-delete referrer warning.
|
|
5
|
+
// Two arms:
|
|
6
|
+
// ?collection=X&id=1 -> FieldRef[] (single target; the editor's warning)
|
|
7
|
+
// ?collection=X&ids=1,2,3 -> { counts: Record<id, count>, checked: boolean } (a selection; the bulk-delete warning)
|
|
8
|
+
// `checked` is false when the lookup itself failed (e.g. record_refs not migrated yet) — an empty `counts`
|
|
9
|
+
// then means "could not check", NOT "no referrers", so a caller must not read it as a green light to delete.
|
|
10
|
+
export default defineEventHandler((event) => {
|
|
11
|
+
const q = getQuery(event)
|
|
12
|
+
const collection = String(q.collection ?? '')
|
|
13
|
+
if (q.ids != null) {
|
|
14
|
+
if (!collection) throw createError({ statusCode: 400, statusMessage: 'collection and ids query params are required' })
|
|
15
|
+
const ids = parseIdList(q.ids, MAX_BULK_IDS)
|
|
16
|
+
const counts = findReferrersForMany(useDb(), collection, ids)
|
|
17
|
+
return { counts: counts ?? {}, checked: counts !== null }
|
|
18
|
+
}
|
|
19
|
+
const id = Number(q.id)
|
|
20
|
+
if (!collection || !Number.isInteger(id) || id <= 0) {
|
|
21
|
+
throw createError({ statusCode: 400, statusMessage: 'collection and id query params are required' })
|
|
22
|
+
}
|
|
23
|
+
return findReferrers(useDb(), collection, id) ?? []
|
|
24
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { sqliteTable, integer, text, index } from 'drizzle-orm/sqlite-core'
|
|
2
|
+
|
|
3
|
+
// Durable reference index: one row per `(source record) → (target record)` edge, over ALL reference
|
|
4
|
+
// types (relation / media / internal link / richtext links / link fields). Maintained on every content
|
|
5
|
+
// write (see `maintainRecordRefs`) and queried on read to DERIVE stale-reference warnings — never stores
|
|
6
|
+
// a warning message (a stored "X deleted" goes stale when X is restored). A non-collection system table,
|
|
7
|
+
// so it lives here (not `server/collections`, which would wire it into the CRUD API/admin). Indexed both
|
|
8
|
+
// forward (what a record references) and reverse (what references a record). `createdAt` via `$defaultFn`
|
|
9
|
+
// (no SQL default → it won't perpetually diff as a rebuild).
|
|
10
|
+
export const recordRefs = sqliteTable('record_refs', {
|
|
11
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
12
|
+
sourceColl: text('source_coll').notNull(),
|
|
13
|
+
sourceId: integer('source_id').notNull(),
|
|
14
|
+
targetColl: text('target_coll').notNull(),
|
|
15
|
+
targetId: integer('target_id').notNull(),
|
|
16
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
|
17
|
+
}, (t) => [
|
|
18
|
+
index('record_refs_source').on(t.sourceColl, t.sourceId),
|
|
19
|
+
index('record_refs_target').on(t.targetColl, t.targetId),
|
|
20
|
+
])
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
|
|
4
|
+
|
|
5
|
+
export default defineNitroPlugin(() => {
|
|
6
|
+
// The committed drizzle-kit migrations only exist in Kestrel's own repo. When Kestrel is consumed as
|
|
7
|
+
// a layer, this folder is absent — there the schema-sync engine (02.schema-sync in dev, the db:migrate
|
|
8
|
+
// task in prod) owns the schema instead (ADR-0002), so skip rather than throw.
|
|
9
|
+
const migrationsFolder = resolve(process.cwd(), 'server/database/migrations')
|
|
10
|
+
if (existsSync(migrationsFolder)) migrate(useDb(), { migrationsFolder })
|
|
11
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import collections from '#kestrel/collections'
|
|
2
|
+
import schemaTables from '#kestrel/schema-tables'
|
|
3
|
+
import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
|
|
4
|
+
import type { BuiltCollection } from '../utils/collection-types'
|
|
5
|
+
import type { CollectionDef } from '../utils/defineCollection'
|
|
6
|
+
import { resolveServerKestrel, serverRuntimeConfig } from '../utils/server-config'
|
|
7
|
+
import { desiredFromCollections } from '../schema/bootstrap'
|
|
8
|
+
import { planOps, describeOp, isDestructive, type SyncDb } from '../schema/sync'
|
|
9
|
+
import { sqlite } from '../schema/dialect'
|
|
10
|
+
import { runDevSchemaSync } from '../schema/dev-sync'
|
|
11
|
+
|
|
12
|
+
// Dev-only schema auto-sync (ADR-0002). Runs after 00.migrate, so built-in collections are already
|
|
13
|
+
// migrated and this is a no-op on them; it additively reconciles the live DB to whatever the registered
|
|
14
|
+
// collections (+ the discovered standalone tables) now compile to — so adding a collection or a field
|
|
15
|
+
// shows up without a hand-written migration. NEVER auto-DDLs in production or during prerender (`nuxt
|
|
16
|
+
// generate`), where schema changes go through committed migrations / an explicit migrate step instead —
|
|
17
|
+
// but production DOES a read-only DRIFT CHECK and warns loudly, so an upgraded consumer that forgot to
|
|
18
|
+
// run `db:migrate` sees a clear boot warning instead of scattered runtime errors.
|
|
19
|
+
export default defineNitroPlugin(() => {
|
|
20
|
+
if (import.meta.prerender) return
|
|
21
|
+
|
|
22
|
+
const desired = desiredFromCollections(collections as (CollectionDef | BuiltCollection)[], {
|
|
23
|
+
// Same toggle source as the registry plugin (01.register), so a disabled built-in is dropped from
|
|
24
|
+
// the desired schema exactly as it is dropped from the runtime surface.
|
|
25
|
+
toggles: (serverRuntimeConfig()?.kestrel?.collections ?? resolveServerKestrel().collections) as Record<string, boolean>,
|
|
26
|
+
extraTables: schemaTables as AnySQLiteTable[],
|
|
27
|
+
})
|
|
28
|
+
const client = (useDb() as unknown as { $client: SyncDb }).$client
|
|
29
|
+
|
|
30
|
+
if (process.env.NODE_ENV === 'production') {
|
|
31
|
+
// Read-only: never DDL in prod. Detect + surface drift so the operator runs db:migrate. Split by kind:
|
|
32
|
+
// ADDITIVE gaps (missing table/column/index) can make the app error → a real "run db:migrate" warning.
|
|
33
|
+
// DESTRUCTIVE ops (a drop for a disabled built-in, or an unmanaged extra table) are NOT app-breaking and
|
|
34
|
+
// are withheld by db:migrate anyway → report them informationally so a permanent, unactionable
|
|
35
|
+
// "app may error" warning doesn't fire on every boot after the operator has done what it asked.
|
|
36
|
+
try {
|
|
37
|
+
const pending = planOps(client, desired)
|
|
38
|
+
const additive = pending.filter((op) => !isDestructive(op))
|
|
39
|
+
const destructive = pending.filter(isDestructive)
|
|
40
|
+
if (additive.length) {
|
|
41
|
+
console.warn(
|
|
42
|
+
`[kestrel] SCHEMA DRIFT: the live DB is missing ${additive.length} change(s) the collections expect — ` +
|
|
43
|
+
`the app may error until you run the db:migrate task. Pending:\n - ${additive.map(describeOp).join('\n - ')}`,
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
if (destructive.length) {
|
|
47
|
+
console.info(
|
|
48
|
+
`[kestrel] schema note: ${destructive.length} table(s)/column(s) exist that the current collections no longer ` +
|
|
49
|
+
`define (e.g. a disabled built-in or an unmanaged table). Not app-breaking; db:migrate withholds these unless ` +
|
|
50
|
+
`you opt in ({force} / {drop:[…]}):\n - ${destructive.map(describeOp).join('\n - ')}`,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.warn('[kestrel] could not check schema drift at boot:', (error as Error)?.message ?? error)
|
|
55
|
+
}
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// SQLite is the only backend today; this is the seam where a config-driven `resolveDialect()` would go.
|
|
60
|
+
// runDevSchemaSync isolates failures (logs loudly, keeps the server up) — an infeasible auto-sync must
|
|
61
|
+
// not 500 every route, including the admin UI needed to fix it.
|
|
62
|
+
runDevSchemaSync(client, desired, sqlite)
|
|
63
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { registerWriteListener } from '../utils/write-events'
|
|
2
|
+
import { maintainRecordRefs } from '../utils/record-ref-index'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Keep the durable `record_refs` index current on every content write — in ALL environments (the dead-
|
|
6
|
+
* reference warnings it powers are an editor feature, not gated on publishing, so unlike the publish
|
|
7
|
+
* plugin this is never skipped in dev). Its own listener, so it coexists with the publish listener; the
|
|
8
|
+
* bus try/catch-isolates each, so an index-maintenance failure never breaks the content write.
|
|
9
|
+
*/
|
|
10
|
+
export default defineNitroPlugin(() => {
|
|
11
|
+
registerWriteListener((event) => maintainRecordRefs(useDb(), event))
|
|
12
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
|
|
2
|
+
import type { BuiltCollection } from '../utils/collection-types'
|
|
3
|
+
import type { CollectionDef } from '../utils/defineCollection'
|
|
4
|
+
import { ensureBuilt } from '../../../fields/server/utils/buildCollection'
|
|
5
|
+
import { recordRefs } from '../database/record-refs'
|
|
6
|
+
import { desiredSchema } from './desired'
|
|
7
|
+
import type { SchemaSnapshot } from './model'
|
|
8
|
+
|
|
9
|
+
/** The ONE rule for whether a discovered collection is active: built-ins (`pages`, `media`, …) can be
|
|
10
|
+
* disabled per consumer via `kestrel: { collections: { <name>: false } }`. Shared by the registry
|
|
11
|
+
* plugin AND the schema engine, so the runtime surface and the desired schema can never diverge —
|
|
12
|
+
* a disabled built-in neither registers routes nor keeps (or creates) its table. */
|
|
13
|
+
export function collectionEnabled(def: CollectionDef, toggles?: Record<string, boolean>): boolean {
|
|
14
|
+
return !(def.builtin && toggles?.[def.name] === false)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DesiredOptions {
|
|
18
|
+
/** Built-in collection toggles (see `collectionEnabled`). Omitted → everything discovered is desired. */
|
|
19
|
+
toggles?: Record<string, boolean>
|
|
20
|
+
/** Standalone (non-collection) tables owned by upper layers — discovered via `#kestrel/schema-tables`
|
|
21
|
+
* (each layer's `server/schema-tables/*.ts` default-exports one table) and passed in by the Nitro
|
|
22
|
+
* callers, so core hardcodes no upper-layer schema. `record_refs` is core-owned and always included. */
|
|
23
|
+
extraTables?: AnySQLiteTable[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The desired DB schema for the discovered collections (the `#kestrel/collections` virtual, passed in
|
|
28
|
+
* so this stays pure and unit-testable): every ENABLED collection's table plus core's `record_refs` and
|
|
29
|
+
* the callers' discovered standalone tables. The single definition of "what the live DB should look
|
|
30
|
+
* like", shared by the dev auto-sync plugin (02.schema-sync) and the `db:migrate` task so the two can
|
|
31
|
+
* never disagree on the target schema. Discovered files may default-export a plain def (consumer form)
|
|
32
|
+
* or an already-built collection.
|
|
33
|
+
*/
|
|
34
|
+
export function desiredFromCollections(
|
|
35
|
+
collections: (CollectionDef | BuiltCollection)[],
|
|
36
|
+
opts: DesiredOptions = {},
|
|
37
|
+
): SchemaSnapshot {
|
|
38
|
+
const built = collections.map(ensureBuilt).filter((c) => collectionEnabled(c.def, opts.toggles))
|
|
39
|
+
const tables = [...built.map((c) => c.table), recordRefs, ...(opts.extraTables ?? [])]
|
|
40
|
+
return desiredSchema(tables, new Map(built.map((c) => [c.def.name, c.def])))
|
|
41
|
+
}
|