@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,207 @@
|
|
|
1
|
+
import { validateFieldLayoutsDeep } from './field-layout'
|
|
2
|
+
|
|
3
|
+
export type FieldType =
|
|
4
|
+
| 'text' | 'slug' | 'richtext' | 'number' | 'boolean' | 'datetime'
|
|
5
|
+
| 'choice' | 'link' | 'media' | 'relation' | 'repeater' | 'json'
|
|
6
|
+
// Consumer-defined types (registered via `defineFieldType`) — keeps built-in autocomplete while
|
|
7
|
+
// accepting any registered name.
|
|
8
|
+
| (string & {})
|
|
9
|
+
|
|
10
|
+
/** A display string that may be localized per admin language: either a plain string (one language /
|
|
11
|
+
* language-agnostic) or a `{ <adminLang>: string }` map resolved against the active admin language
|
|
12
|
+
* (falling back to `en`, then any present value). Works for built-in AND consumer-defined collections. */
|
|
13
|
+
export type Localized = string | Record<string, string>
|
|
14
|
+
|
|
15
|
+
interface BaseFieldDef {
|
|
16
|
+
required?: boolean
|
|
17
|
+
default?: unknown
|
|
18
|
+
translatable?: boolean
|
|
19
|
+
unique?: boolean
|
|
20
|
+
/** Add a non-unique DB index on this field's column — for a field frequently FILTERED or SORTED by but
|
|
21
|
+
* not unique (e.g. `media.folder`). `unique` already implies an index, so this is only the non-unique case. */
|
|
22
|
+
index?: boolean
|
|
23
|
+
/** Editor label, optionally localized per admin language. Defaults to the humanized field key
|
|
24
|
+
* (e.g. `siteName` → "Site Name") so the editor label matches the collection-list header. */
|
|
25
|
+
label?: Localized
|
|
26
|
+
/** Show this field only when the condition matches other fields' values (see `Condition`). A hidden
|
|
27
|
+
* field is exempt from `required` and gets a nullable column; its stored value is left as-is (v1). */
|
|
28
|
+
condition?: Condition
|
|
29
|
+
/** The field's previous key. On a schema sync the column is RENAMED (data preserved) instead of being
|
|
30
|
+
* dropped + re-added. Remove once applied everywhere. */
|
|
31
|
+
renamedFrom?: string
|
|
32
|
+
/** Per-instance read populator override (Pruvious `additional.population`). When set, the field-tree
|
|
33
|
+
* walker runs THIS instead of the field type's default populator for this one field — e.g. a relation
|
|
34
|
+
* that should project only a couple of columns, or a masked value. Server-only (a function; never
|
|
35
|
+
* serialized to the admin). Typed inline (structurally identical to the populate seam's `FieldPopulator`)
|
|
36
|
+
* so this foundational, import-free types file needs no dependency on `populate.ts`. */
|
|
37
|
+
populate?: (
|
|
38
|
+
bag: Record<string, unknown>,
|
|
39
|
+
key: string,
|
|
40
|
+
field: FieldDef,
|
|
41
|
+
ctx: { depth: number; locale: string; def: CollectionDef },
|
|
42
|
+
keyMode: 'columns' | 'props',
|
|
43
|
+
) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ConditionScalar = string | number | boolean | null
|
|
47
|
+
|
|
48
|
+
/** A single operator test against the referenced field's value. Multiple keys are ANDed. */
|
|
49
|
+
export interface ConditionOperator {
|
|
50
|
+
eq?: ConditionScalar
|
|
51
|
+
ne?: ConditionScalar
|
|
52
|
+
gt?: number | string
|
|
53
|
+
gte?: number | string
|
|
54
|
+
lt?: number | string
|
|
55
|
+
lte?: number | string
|
|
56
|
+
in?: ConditionScalar[]
|
|
57
|
+
notIn?: ConditionScalar[]
|
|
58
|
+
/** Case-sensitive `new RegExp(value).test(dep)`; a non-string dependency never matches. */
|
|
59
|
+
regexp?: string
|
|
60
|
+
/** `true` matches null/undefined/''/[]; `false` matches a present value. */
|
|
61
|
+
empty?: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A leaf rule: test another field's value. Use `is` (strict-equality shorthand) OR `op`; a bare rule
|
|
65
|
+
* (neither) means "present" (non-empty). `field` is a sibling field's key (v1 scope). */
|
|
66
|
+
export interface ConditionRule {
|
|
67
|
+
field: string
|
|
68
|
+
is?: ConditionScalar
|
|
69
|
+
op?: ConditionOperator
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Field visibility logic. A discriminated tree: leaf `ConditionRule`s combined by explicit
|
|
73
|
+
* `and`/`or`/`not` (no overloaded magic keys, so a real field named "and"/"or" never collides). */
|
|
74
|
+
export type Condition =
|
|
75
|
+
| ConditionRule
|
|
76
|
+
| { and: Condition[] }
|
|
77
|
+
| { or: Condition[] }
|
|
78
|
+
| { not: Condition }
|
|
79
|
+
|
|
80
|
+
export type LinkType = 'internal' | 'external' | 'email' | 'tel'
|
|
81
|
+
|
|
82
|
+
export type LinkValue =
|
|
83
|
+
| { type: 'internal'; collection: string; id: number; hash?: string; label?: string }
|
|
84
|
+
| { type: 'external'; url: string; label?: string }
|
|
85
|
+
| { type: 'email'; email: string; label?: string }
|
|
86
|
+
| { type: 'tel'; tel: string; label?: string }
|
|
87
|
+
|
|
88
|
+
export type FieldDef =
|
|
89
|
+
| (BaseFieldDef & { type: 'text'; options?: { minLength?: number; maxLength?: number; multiline?: boolean } })
|
|
90
|
+
// A url-safe slug (text-backed). `from` = the field key to auto-generate from when left blank; `prefix` is
|
|
91
|
+
// display-only in the editor widget (e.g. '/galleries/').
|
|
92
|
+
| (BaseFieldDef & { type: 'slug'; options?: { from?: string; prefix?: string } })
|
|
93
|
+
| (BaseFieldDef & { type: 'richtext' })
|
|
94
|
+
// `unit` is a display-only suffix shown after the value in the editor widget (e.g. 'rem'); the stored
|
|
95
|
+
// value stays a bare number. `units` is reserved for a future selectable-unit variant and is not yet
|
|
96
|
+
// honoured server-side.
|
|
97
|
+
| (BaseFieldDef & { type: 'number'; options?: { min?: number; max?: number; integer?: boolean; decimals?: number; unit?: string; units?: string[] } })
|
|
98
|
+
| (BaseFieldDef & { type: 'boolean' })
|
|
99
|
+
| (BaseFieldDef & { type: 'datetime'; options?: { precision?: 'date' | 'datetime' | 'time'; range?: boolean } })
|
|
100
|
+
| (BaseFieldDef & { type: 'choice'; options: { choices: { label: string; value: string }[]; multiple?: boolean; display?: 'select' | 'buttons' | 'checkboxes' } })
|
|
101
|
+
| (BaseFieldDef & { type: 'link'; options?: { types?: LinkType[]; collections?: string[] } })
|
|
102
|
+
| (BaseFieldDef & { type: 'media'; options?: { multiple?: boolean; accept?: 'image' | 'any' } })
|
|
103
|
+
| (BaseFieldDef & { type: 'relation'; relation: { collection: string; many?: boolean; labelField?: string } })
|
|
104
|
+
| (BaseFieldDef & { type: 'repeater'; options: { fields: Record<string, FieldDef>; fieldLayout?: FieldLayoutDSL } })
|
|
105
|
+
| (BaseFieldDef & { type: 'json' })
|
|
106
|
+
// A consumer-defined field type (registered via `defineFieldType`): any other `type` string, with
|
|
107
|
+
// free-form `options` forwarded to its server descriptor + editor widget.
|
|
108
|
+
| (BaseFieldDef & { type: string & {}; options?: Record<string, unknown> })
|
|
109
|
+
|
|
110
|
+
/** The specific `FieldDef` arm for a built-in field `type`; falls back to the full union for consumer
|
|
111
|
+
* types (whose arm is the open `type: string & {}`). The `[…] extends [never]` tuple wrap stops the
|
|
112
|
+
* conditional from distributing over `never`. */
|
|
113
|
+
export type FieldOf<T extends FieldType> = [Extract<FieldDef, { type: T }>] extends [never]
|
|
114
|
+
? FieldDef
|
|
115
|
+
: Extract<FieldDef, { type: T }>
|
|
116
|
+
|
|
117
|
+
/** Type-guard narrowing a `FieldDef` to a specific built-in arm. Needed because the open consumer arm
|
|
118
|
+
* (`type: string & {}`) makes `type` a non-discriminant, so a bare `f.type === 'x'` won't narrow `f`. */
|
|
119
|
+
export function fieldIs<T extends FieldType>(field: FieldDef, type: T): field is FieldOf<T> {
|
|
120
|
+
return field.type === type
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Whether a relation/media field is stored as (and addressed by) a single `<name>Id` FK column —
|
|
124
|
+
* true unless it is a many-relation or a multiple-media field. The single source of the `Id`-suffix
|
|
125
|
+
* rule shared by column naming (`resolveColumnName`), serialization (the `single` flag), and the editor. */
|
|
126
|
+
export function isSingleRefColumn(field: FieldDef): boolean {
|
|
127
|
+
return (fieldIs(field, 'relation') && !field.relation.many)
|
|
128
|
+
|| (fieldIs(field, 'media') && !field.options?.multiple)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Author-facing field-layout DSL (resolved by `resolveFieldLayout`). Each entry is one of:
|
|
133
|
+
* - a string — a single full-width row (`'title'`), optionally widthed (`'title|50%'`);
|
|
134
|
+
* - a string[] — a side-by-side row, equal columns by default, or per-field `field|2` (flex weight) /
|
|
135
|
+
* `field|30%` (a CSS length/percent);
|
|
136
|
+
* - a single-key object — a named group of rows: `{ 'SEO': [ 'metaTitle', ['a', 'b'] ] }` (one level deep).
|
|
137
|
+
*/
|
|
138
|
+
export type FieldLayoutEntry = string | string[] | Record<string, FieldLayoutEntry[]>
|
|
139
|
+
export type FieldLayoutDSL = FieldLayoutEntry[]
|
|
140
|
+
|
|
141
|
+
export interface CollectionDef {
|
|
142
|
+
name: string
|
|
143
|
+
mode: 'multi' | 'single'
|
|
144
|
+
/** Per-locale content. Optional — defaults to false (a single, non-localized record set). */
|
|
145
|
+
translatable?: boolean
|
|
146
|
+
pageLike?: boolean
|
|
147
|
+
seo?: boolean
|
|
148
|
+
blocks?: { enabled: true; allowed?: string[] }
|
|
149
|
+
status?: boolean
|
|
150
|
+
/** Which admin editor renders this collection's body (the presentation axis, kept separate from the
|
|
151
|
+
* `blocks` schema flag). Defaults to `'blocks'` when `blocks.enabled`, else `'fields'`. Open string:
|
|
152
|
+
* extensions register additional editor types (e.g. `'node-graph'`) via `registerCollectionEditor`. */
|
|
153
|
+
editor?: string
|
|
154
|
+
fields: Record<string, FieldDef>
|
|
155
|
+
/** Admin editor layout: rows of fields. A string is its own full-width row; a nested array puts fields
|
|
156
|
+
* side by side (equal columns, or `field|2` / `field|30%` for flex weights / CSS lengths); a single-key
|
|
157
|
+
* object is a named group `{ 'SEO': [ … ] }`. Fields omitted from the layout append as full-width rows,
|
|
158
|
+
* so adding a field never hides it. Absent → today's one-field-per-row. (Deviates from Pruvious, which
|
|
159
|
+
* nests this under a `dashboard` wrapper — Kestrel keeps its flat CollectionDef idiom.) */
|
|
160
|
+
fieldLayout?: FieldLayoutDSL
|
|
161
|
+
/** Display labels. `new` is the complete, per-locale "create" phrase (e.g. de `'Neue Seite'` /
|
|
162
|
+
* `'Neuer Beitrag'`) — supplying the whole phrase sidesteps German gender agreement, which no
|
|
163
|
+
* `'Neu {x}'` template can get right. Falls back to a generic phrase from `singular` when absent. */
|
|
164
|
+
label?: { singular?: Localized; plural?: Localized; new?: Localized }
|
|
165
|
+
/** Nav/dashboard icon: a UI registry icon name (e.g. `'settings'`) or raw inline SVG markup. Lets
|
|
166
|
+
* each collection — including custom ones — be visually distinct in the admin rail. */
|
|
167
|
+
icon?: string
|
|
168
|
+
/** Marks a Kestrel-shipped built-in (`pages`, `media`). Built-ins register by default but a consumer
|
|
169
|
+
* can disable them via `kestrel: { collections: { <name>: false } }` (config → `KESTREL_COLLECTIONS_<NAME>`
|
|
170
|
+
* env → default-on). Ignored for user-defined collections. */
|
|
171
|
+
builtin?: boolean
|
|
172
|
+
/** Show this collection in the admin rail (default true). Set `false` for a system/config store — e.g.
|
|
173
|
+
* a settings singleton — that a layer manages internally and surfaces (if at all) through its own UI, so
|
|
174
|
+
* it never appears as a flat top-level rail item beside the layer's main page. */
|
|
175
|
+
nav?: boolean
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function defineCollection(def: CollectionDef): CollectionDef {
|
|
179
|
+
// A pageLike record is governed by the slug engine (required + auto-generated + globally unique route),
|
|
180
|
+
// which only runs through the multi create/update path — `putSingleton` writes directly. A routable
|
|
181
|
+
// singleton would silently skip that enforcement, so refuse the combo loudly at definition time.
|
|
182
|
+
if (def.pageLike && def.mode === 'single') {
|
|
183
|
+
throw new Error(`[kestrel] pageLike collection "${def.name}" must be mode: 'multi' (a routable record needs the slug engine)`)
|
|
184
|
+
}
|
|
185
|
+
// The built-in `blocks` editor body persists to the `content` column, which the write schema only exposes
|
|
186
|
+
// when blocks are enabled. editor:'blocks' without blocks:{enabled:true} would mount a working page builder
|
|
187
|
+
// whose every edit is silently dropped on save — refuse the combo loudly at definition time.
|
|
188
|
+
if (def.editor === 'blocks' && !def.blocks?.enabled) {
|
|
189
|
+
throw new Error(`[kestrel] collection "${def.name}" sets editor: 'blocks' but is missing blocks: { enabled: true } — its block edits would never persist`)
|
|
190
|
+
}
|
|
191
|
+
// Framework tool endpoints share the flat /api/<segment> namespace. A pageLike collection whose name
|
|
192
|
+
// matches one enters the anonymous public-read set (publicReadableResources) and makes that endpoint
|
|
193
|
+
// anonymously reachable — e.g. a `links` collection exposes /api/links/resolve, leaking draft slugs.
|
|
194
|
+
// Refuse the collision loudly. (`media` is served by the generic router, so it is NOT a tool namespace.)
|
|
195
|
+
if (RESERVED_API_NAMESPACES.has(def.name)) {
|
|
196
|
+
throw new Error(`[kestrel] collection name "${def.name}" is a reserved framework API namespace — it would shadow /api/${def.name} and leak it into the anonymous public-read set; choose another name`)
|
|
197
|
+
}
|
|
198
|
+
// Fail loud at definition time on a bad field layout (unknown/duplicate field, invalid width, malformed
|
|
199
|
+
// group) — top level plus every nested repeater — so it surfaces at import/startup, not on first render.
|
|
200
|
+
validateFieldLayoutsDeep(def)
|
|
201
|
+
return def
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const RESERVED_API_NAMESPACES = new Set([
|
|
205
|
+
'auth', 'blocks', 'collections', 'links', 'publish-status', 'references', 'route',
|
|
206
|
+
'galleries-secure', 'galleries-secure-proofing',
|
|
207
|
+
])
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { eq, getTableColumns } from 'drizzle-orm'
|
|
2
|
+
import { createError } from 'h3'
|
|
3
|
+
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
4
|
+
import type { AnySQLiteColumn, AnySQLiteTable } from 'drizzle-orm/sqlite-core'
|
|
5
|
+
import type { BuiltCollection } from './collection-types'
|
|
6
|
+
import { create } from './crud'
|
|
7
|
+
import { slugSourceKey, dedupeSourcePath } from './page-slug'
|
|
8
|
+
import { primaryLocale, prefixPrimaryLocale } from './locale'
|
|
9
|
+
import { allCollections } from './registry'
|
|
10
|
+
import { fieldIs, type FieldOf } from './defineCollection'
|
|
11
|
+
import { slugify } from '../../app/utils/slugify'
|
|
12
|
+
import { resolveColumnName } from '../../../fields/server/field-registry/naming'
|
|
13
|
+
import { isHardRequired } from '../../../fields/server/field-registry/index'
|
|
14
|
+
import { regenerateBlockIds } from '../../../fields/server/utils/block-ids'
|
|
15
|
+
|
|
16
|
+
type DB = BetterSQLite3Database
|
|
17
|
+
type Row = Record<string, unknown>
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Append (or increment) a plain `(copy)` suffix on a title-like string:
|
|
21
|
+
* `Foo` → `Foo (copy)` → `Foo (copy 2)` → `Foo (copy 3)` → …
|
|
22
|
+
* Pure and non-localized; drives BOTH the list label and (for pageLike / slug fields) the derived slug.
|
|
23
|
+
* When `maxLength` is given (the slug-source field's `options.maxLength`), the BASE is truncated so the
|
|
24
|
+
* whole `base + suffix` result still fits — so a record at (or within a suffix of) its length limit stays
|
|
25
|
+
* duplicable instead of tripping create()'s re-validated `.max`. When the maxLength cannot fit even the
|
|
26
|
+
* suffix on its own, the source text is returned unchanged (no suffix); for a pageLike copy the `-N` route
|
|
27
|
+
* de-dup then still guarantees a unique slug. The increment rule holds under truncation.
|
|
28
|
+
*/
|
|
29
|
+
export function withCopySuffix(text: string, maxLength?: number): string {
|
|
30
|
+
const m = /^(.*) \(copy(?: (\d+))?\)$/.exec(text)
|
|
31
|
+
const base = m ? m[1]! : text
|
|
32
|
+
const n = (m ? (m[2] ? Number.parseInt(m[2], 10) : 1) : 0) + 1
|
|
33
|
+
const suffix = n === 1 ? '(copy)' : `(copy ${n})`
|
|
34
|
+
const full = base ? `${base} ${suffix}` : suffix
|
|
35
|
+
if (maxLength === undefined || full.length <= maxLength) return full
|
|
36
|
+
// The suffix alone overflows the budget → drop it (a pageLike copy's `-N` route de-dup still disambiguates).
|
|
37
|
+
if (suffix.length > maxLength) return text.slice(0, maxLength)
|
|
38
|
+
// Truncate the base so `base + ' ' + suffix` fits exactly within maxLength (−1 for the joining space).
|
|
39
|
+
const room = maxLength - suffix.length - 1
|
|
40
|
+
const truncated = room > 0 ? base.slice(0, room).trimEnd() : ''
|
|
41
|
+
return truncated ? `${truncated} ${suffix}` : suffix
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The one field whose verbatim copy CANNOT be reconciled: required + unique + not a slug (a slug re-derives,
|
|
45
|
+
* the pageLike path re-derives+de-dupes). Returns its def key, or `undefined` when the collection has none. */
|
|
46
|
+
function blockingUniqueField(c: BuiltCollection): string | undefined {
|
|
47
|
+
for (const [key, f] of Object.entries(c.def.fields)) {
|
|
48
|
+
if (f.unique && isHardRequired(f) && f.type !== 'slug') return key
|
|
49
|
+
}
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Duplicate one record into a NEW draft copy, composing `crud.create()` so validation, the fresh
|
|
55
|
+
* translationGroup nanoid, the slug de-dup, field transforms, the unique-slug check, the insert AND
|
|
56
|
+
* `emitWrite` (which rebuilds `record_refs` and drives the publish queue) all come for free — there is NO
|
|
57
|
+
* parallel insert path. The copy:
|
|
58
|
+
* • strips id / timestamps → a fresh PK + timestamps (create() also strips them);
|
|
59
|
+
* • drops `translationGroup` → create() mints a new group, so the copy is a LONE row in a brand-new group
|
|
60
|
+
* for its single locale (Option A — keeping the group would violate the UNIQUE(group, locale) index);
|
|
61
|
+
* • blanks `path` (pageLike) → `resolvePageSlug` re-derives + `-2`/`-3` de-dupes from the suffixed title,
|
|
62
|
+
* or — for a pageLike collection with NO slug-source text field — seeds the source's path, de-duped;
|
|
63
|
+
* • forces `status: 'draft'` → a copy of a published record is NOT itself published;
|
|
64
|
+
* • regenerates every block id → the copy never shares block identity with its source;
|
|
65
|
+
* • appends a `(copy)` suffix to the slug-source text field → the label and derived slug read "Foo (copy)";
|
|
66
|
+
* • blanks non-required unique fields EXCEPT the slug-source (its `(copy)` suffix already makes it distinct);
|
|
67
|
+
* • SHARES referenced media / relations / links verbatim (scalar column values copied, never deep-copied).
|
|
68
|
+
* Throws: 405 for a singleton · 404 for an unknown id · 422 (naming the field) when a REQUIRED UNIQUE field
|
|
69
|
+
* that is not the slug/path would collide verbatim (surfaced up-front instead of a raw SQLite 409).
|
|
70
|
+
*/
|
|
71
|
+
export function duplicateRecord(db: DB, c: BuiltCollection, id: number): Row {
|
|
72
|
+
if (c.def.mode === 'single') {
|
|
73
|
+
throw createError({ statusCode: 405, statusMessage: 'Cannot duplicate a singleton' })
|
|
74
|
+
}
|
|
75
|
+
const blocking = blockingUniqueField(c)
|
|
76
|
+
if (blocking) {
|
|
77
|
+
throw createError({ statusCode: 422, statusMessage: `Cannot duplicate: the required unique field "${blocking}" would collide` })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const cols = getTableColumns(c.table) as Record<string, AnySQLiteColumn>
|
|
81
|
+
// RAW row (NOT getOne — no populate): populate nests media/relations into objects that would not round-
|
|
82
|
+
// trip through the insert schema; the duplicate must copy the stored scalar FKs so the copy SHARES them.
|
|
83
|
+
const src = db.select().from(c.table as AnySQLiteTable).where(eq(cols.id, id)).get() as Row | undefined
|
|
84
|
+
if (!src) throw createError({ statusCode: 404, statusMessage: `${c.name} ${id} not found` })
|
|
85
|
+
|
|
86
|
+
const body: Row = { ...src }
|
|
87
|
+
delete body.id
|
|
88
|
+
delete body.createdAt
|
|
89
|
+
delete body.updatedAt
|
|
90
|
+
delete body.translationGroup
|
|
91
|
+
delete body.path
|
|
92
|
+
if (c.def.status) body.status = 'draft'
|
|
93
|
+
if (c.def.blocks?.enabled) body.content = regenerateBlockIds(src.content)
|
|
94
|
+
|
|
95
|
+
appendCopySuffix(c, body)
|
|
96
|
+
blankNonRequiredUniqueFields(db, c, body)
|
|
97
|
+
seedCopyPath(db, c, src, body)
|
|
98
|
+
|
|
99
|
+
return create(db, c, body)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Suffix the slug-source text field (the same field `slugSourceValue`/`resolvePageSlug` derive from),
|
|
103
|
+
* budgeting the suffix against the field's `options.maxLength` so a record at its length limit stays
|
|
104
|
+
* duplicable (create() re-validates the text `.max`). */
|
|
105
|
+
function appendCopySuffix(c: BuiltCollection, body: Row): void {
|
|
106
|
+
const key = slugSourceKey(c.def)
|
|
107
|
+
if (!key) return
|
|
108
|
+
const value = body[key]
|
|
109
|
+
if (typeof value !== 'string') return
|
|
110
|
+
const field = c.def.fields[key]
|
|
111
|
+
const maxLength = field && fieldIs(field, 'text') ? field.options?.maxLength : undefined
|
|
112
|
+
body[key] = withCopySuffix(value, maxLength)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A unique field can't be copied verbatim (it would collide). A unique `slug` is re-derived from the
|
|
116
|
+
* now-suffixed source AND de-duped with a `-N` suffix against existing rows (so duplicating the SAME
|
|
117
|
+
* record twice yields `foo-copy`, then `foo-copy-2` — a plain slug field has no pageLike `-N` path dedup
|
|
118
|
+
* of its own, so two copies would otherwise collide on create's hard unique-slug check). Any OTHER unique
|
|
119
|
+
* field (guaranteed non-hard-required by the pre-flight — its column is nullable) falls to NULL, which
|
|
120
|
+
* SQLite permits many of. The slug-SOURCE text field is EXEMPT even when itself `unique`: its `(copy)`
|
|
121
|
+
* suffix already distinguishes it, so nulling it would be silent data loss (and, for a pageLike
|
|
122
|
+
* collection, leave resolvePageSlug no title → a 400). */
|
|
123
|
+
function blankNonRequiredUniqueFields(db: DB, c: BuiltCollection, body: Row): void {
|
|
124
|
+
const slugSource = slugSourceKey(c.def)
|
|
125
|
+
const cols = getTableColumns(c.table) as Record<string, AnySQLiteColumn>
|
|
126
|
+
for (const [key, f] of Object.entries(c.def.fields)) {
|
|
127
|
+
if (!f.unique || key === slugSource) continue
|
|
128
|
+
const { jsKey } = resolveColumnName(key, f)
|
|
129
|
+
if (fieldIs(f, 'slug')) body[jsKey] = dedupedCopySlug(db, c, cols, key, f, body)
|
|
130
|
+
else body[jsKey] = null
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The de-duped slug for a copy: slugify the (already `(copy)`-suffixed) `from` source, else the copied
|
|
135
|
+
* slug value, then append `-2`/`-3`/… until it is free. Empty base → undefined (let create's transform try). */
|
|
136
|
+
function dedupedCopySlug(db: DB, c: BuiltCollection, cols: Record<string, AnySQLiteColumn>, key: string, f: FieldOf<'slug'>, body: Row): string | undefined {
|
|
137
|
+
const { jsKey } = resolveColumnName(key, f)
|
|
138
|
+
const from = f.options?.from
|
|
139
|
+
const source = from ? body[from] : undefined
|
|
140
|
+
const base = typeof source === 'string' && source ? slugify(source)
|
|
141
|
+
: typeof body[jsKey] === 'string' ? slugify(body[jsKey] as string) : ''
|
|
142
|
+
if (!base) return undefined
|
|
143
|
+
let candidate = base
|
|
144
|
+
for (let n = 2; db.select({ id: cols.id }).from(c.table as AnySQLiteTable).where(eq(cols[jsKey]!, candidate)).get(); n++) {
|
|
145
|
+
candidate = `${base}-${n}`
|
|
146
|
+
}
|
|
147
|
+
return candidate
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** For a pageLike collection with NO slug-source text field, create()'s auto-gen branch has no title to
|
|
151
|
+
* derive a slug from (and duplicate blanked `path`), so seed the copy's `path` off the SOURCE row's own
|
|
152
|
+
* explicit path — de-duped to a free route — which create()'s explicit branch then accepts. A no-op for
|
|
153
|
+
* every other collection (the suffixed title drives the slug) and when the source carries no usable path. */
|
|
154
|
+
function seedCopyPath(db: DB, c: BuiltCollection, src: Row, body: Row): void {
|
|
155
|
+
if (!c.def.pageLike || slugSourceKey(c.def)) return
|
|
156
|
+
const path = dedupeSourcePath(db, c, src, {
|
|
157
|
+
collections: allCollections(),
|
|
158
|
+
primary: primaryLocale(),
|
|
159
|
+
prefixPrimary: prefixPrimaryLocale(),
|
|
160
|
+
})
|
|
161
|
+
if (path) body.path = path
|
|
162
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { CollectionDef, FieldDef, FieldLayoutDSL, Localized } from './defineCollection'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Field layout: the pure, node-testable resolver that turns the author DSL (`CollectionDef.fieldLayout` /
|
|
5
|
+
* a repeater's `options.fieldLayout`) into a normalized, JSON-safe structure the admin renders. It runs at
|
|
6
|
+
* definition time (fail-loud) and again in serialize (the wire copy); no runtime deps — only the erased
|
|
7
|
+
* type imports above.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** A neutral column track: a flex WEIGHT (a plain number → the UI renders it as an `fr` unit) or an
|
|
11
|
+
* explicit LENGTH (`'30%'`, `'12rem'`, …). The engine stays free of CSS-grid vocabulary — the UI layer
|
|
12
|
+
* (Layout.vue) is the single place that turns tracks into a `grid-template-columns` value. */
|
|
13
|
+
export type LayoutTrack = number | string
|
|
14
|
+
|
|
15
|
+
/** A rendered row: the fields (in source/reading order) and one column track per field. */
|
|
16
|
+
export interface LayoutRow {
|
|
17
|
+
kind: 'row'
|
|
18
|
+
fields: string[]
|
|
19
|
+
/** One track per field (positionally aligned); default weight `1`. Never an author-supplied CSS string. */
|
|
20
|
+
tracks: LayoutTrack[]
|
|
21
|
+
}
|
|
22
|
+
/** A single named group (one level deep): a labelled `<fieldset>` wrapping stacked rows. */
|
|
23
|
+
export interface LayoutGroup {
|
|
24
|
+
kind: 'group'
|
|
25
|
+
label: Localized
|
|
26
|
+
rows: LayoutRow[]
|
|
27
|
+
}
|
|
28
|
+
export type LayoutNode = LayoutRow | LayoutGroup
|
|
29
|
+
|
|
30
|
+
// Width grammar, strict allow-lists so a field width can NEVER inject arbitrary CSS into the grid template.
|
|
31
|
+
const WEIGHT = /^\d+(?:\.\d+)?$/ // bare number -> `<n>fr` (flex weight)
|
|
32
|
+
const LENGTH = /^\d+(?:\.\d+)?(?:fr|%|px|rem|em)$/ // an explicit CSS length/percent (allow-listed units)
|
|
33
|
+
|
|
34
|
+
interface Cell {
|
|
35
|
+
field: string
|
|
36
|
+
/** A flex weight (number) or an explicit length (string), or undefined → default weight 1. */
|
|
37
|
+
track?: LayoutTrack
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Parse one `'name'` / `'name|2'` / `'name|30%'` token into its field + optional grid track width. */
|
|
41
|
+
function parseToken(tok: unknown, ctx: string): Cell {
|
|
42
|
+
if (typeof tok !== 'string') throw new Error(`[kestrel] ${ctx}: a row field must be a string (got ${typeof tok})`)
|
|
43
|
+
const parts = tok.split('|')
|
|
44
|
+
if (parts.length > 2) throw new Error(`[kestrel] ${ctx}: "${tok}" has more than one "|"`)
|
|
45
|
+
const field = parts[0]!.trim()
|
|
46
|
+
if (!field) throw new Error(`[kestrel] ${ctx}: empty field name in "${tok}"`)
|
|
47
|
+
if (parts.length === 1) return { field }
|
|
48
|
+
const w = parts[1]!.trim()
|
|
49
|
+
// A zero (or, defensively, negative) track collapses the cell to a sliver via `.ui-field-cell { min-inline-size:0 }`
|
|
50
|
+
// — silently hiding it. Only a strictly positive value is a real column, so require it alongside the grammar.
|
|
51
|
+
// Strictly positive AND finite: a huge digit run (`'9'.repeat(400)`) parses to Infinity, which passes
|
|
52
|
+
// the digit-only grammar but is not a real column and serializes to JSON `null` on the wire — reject it.
|
|
53
|
+
const positive = (tok: string) => { const n = parseFloat(tok); return n > 0 && Number.isFinite(n) }
|
|
54
|
+
// A bare number or an `fr` length are both FLEX WEIGHTS → emit a plain number (no CSS unit in the wire).
|
|
55
|
+
if (WEIGHT.test(w) && positive(w)) return { field, track: parseFloat(w) }
|
|
56
|
+
if (LENGTH.test(w) && positive(w)) return { field, track: w.endsWith('fr') ? parseFloat(w) : w }
|
|
57
|
+
throw new Error(`[kestrel] ${ctx}: invalid width "${w}" for "${field}" (use a positive number weight like "2", or a length like "30%"/"12rem"/"200px")`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve a layout DSL against the field set. A lone string is a full-width row; a string[] is a
|
|
62
|
+
* side-by-side row; a single-key object is a named group of rows (one level, never nested). Fields not
|
|
63
|
+
* mentioned append as full-width rows at the end (declaration order) so adding a field never hides it.
|
|
64
|
+
* Throws on an unknown/duplicate field, a bad width, or a malformed group.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveFieldLayout(dsl: FieldLayoutDSL, fieldKeys: string[], ctx: string): LayoutNode[] {
|
|
67
|
+
const known = new Set(fieldKeys)
|
|
68
|
+
const seen = new Set<string>()
|
|
69
|
+
const nodes: LayoutNode[] = []
|
|
70
|
+
|
|
71
|
+
const use = (field: string) => {
|
|
72
|
+
if (!known.has(field)) throw new Error(`[kestrel] ${ctx}: unknown field "${field}" in the layout`)
|
|
73
|
+
if (seen.has(field)) throw new Error(`[kestrel] ${ctx}: field "${field}" appears more than once in the layout`)
|
|
74
|
+
seen.add(field)
|
|
75
|
+
}
|
|
76
|
+
const toRow = (entry: string | unknown[]): LayoutRow => {
|
|
77
|
+
const tokens = Array.isArray(entry) ? entry : [entry]
|
|
78
|
+
if (!tokens.length) throw new Error(`[kestrel] ${ctx}: a row must contain at least one field`)
|
|
79
|
+
const cells = tokens.map((t) => parseToken(t, ctx))
|
|
80
|
+
cells.forEach((c) => use(c.field))
|
|
81
|
+
return { kind: 'row', fields: cells.map((c) => c.field), tracks: cells.map((c) => c.track ?? 1) }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const entry of dsl) {
|
|
85
|
+
if (typeof entry === 'string' || Array.isArray(entry)) {
|
|
86
|
+
nodes.push(toRow(entry))
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
// A single-key object → a named group of rows.
|
|
90
|
+
const names = Object.keys(entry)
|
|
91
|
+
if (names.length !== 1) throw new Error(`[kestrel] ${ctx}: a group must have exactly one name (got ${names.length})`)
|
|
92
|
+
const label = names[0]!
|
|
93
|
+
const inner = entry[label]!
|
|
94
|
+
if (!Array.isArray(inner) || !inner.length) throw new Error(`[kestrel] ${ctx}: group "${label}" needs at least one row`)
|
|
95
|
+
const rows = inner.map((r) => {
|
|
96
|
+
if (typeof r !== 'string' && !Array.isArray(r)) throw new Error(`[kestrel] ${ctx}: group "${label}" may not nest another group (one level only)`)
|
|
97
|
+
return toRow(r)
|
|
98
|
+
})
|
|
99
|
+
nodes.push({ kind: 'group', label, rows })
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Any field missing from the layout appends as a full-width row (declaration order) — never hidden.
|
|
103
|
+
for (const field of fieldKeys) if (!seen.has(field)) nodes.push({ kind: 'row', fields: [field], tracks: [1] })
|
|
104
|
+
return nodes
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Validate every field layout a definition carries — the collection's own plus each (nested) repeater's —
|
|
109
|
+
* for the throwing side effect. Called from `defineCollection()` so a bad layout fails at import/startup.
|
|
110
|
+
*/
|
|
111
|
+
export function validateFieldLayoutsDeep(def: CollectionDef): void {
|
|
112
|
+
if (def.fieldLayout) resolveFieldLayout(def.fieldLayout, Object.keys(def.fields), `collection "${def.name}"`)
|
|
113
|
+
walkRepeaterLayouts(def.fields, '')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function walkRepeaterLayouts(fields: Record<string, FieldDef>, prefix: string): void {
|
|
117
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
118
|
+
if (field.type !== 'repeater') continue
|
|
119
|
+
// Structural narrow (avoids a runtime import of `fieldIs` — keeps this file's only runtime edge one-way).
|
|
120
|
+
const options = (field as Extract<FieldDef, { type: 'repeater' }>).options
|
|
121
|
+
const ctx = `${prefix}repeater "${key}"`
|
|
122
|
+
if (options.fieldLayout) resolveFieldLayout(options.fieldLayout, Object.keys(options.fields), ctx)
|
|
123
|
+
walkRepeaterLayouts(options.fields, `${ctx} > `)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { eq, ne, lt, lte, gt, gte, sql, or, isNull, type SQL } from 'drizzle-orm'
|
|
2
|
+
import { createError } from 'h3'
|
|
3
|
+
import type { AnySQLiteColumn } from 'drizzle-orm/sqlite-core'
|
|
4
|
+
import { escapeLike } from './sql'
|
|
5
|
+
import type { FilterKind, FilterOp } from '../../app/utils/filter-ops'
|
|
6
|
+
|
|
7
|
+
/** Coerce a (stringified) filter value to the target COLUMN's storage type before the predicate. Keyed on
|
|
8
|
+
* the resolved Drizzle column (not the builtin def type-name) so a CUSTOM field type backed by a
|
|
9
|
+
* boolean/number column coerces too. parseFilter stringifies every query param, and drizzle maps any
|
|
10
|
+
* non-empty string (incl. 'false') to 1 for a boolean column, so an uncoerced boolean filter matches the
|
|
11
|
+
* wrong rows. (Owns this here — its single home — so both crud.list and filterCondition share it without a
|
|
12
|
+
* crud↔predicate import cycle.) */
|
|
13
|
+
export function coerceFilterValue(col: { getSQLType(): string; mode?: string }, value: unknown): unknown {
|
|
14
|
+
if (col.mode === 'boolean') return value === true || value === 'true' || value === '1'
|
|
15
|
+
// A timestamp column stores a Date and drizzle maps it via Date.getTime(); a raw string/number reaches
|
|
16
|
+
// mapToDriverValue and throws `value.getTime is not a function` → an unhandled 500 (reachable
|
|
17
|
+
// unauthenticated on any public collection). Coerce to a Date; an unparseable value is a clean 400.
|
|
18
|
+
if (col.mode === 'timestamp' || col.mode === 'timestamp_ms') {
|
|
19
|
+
const d = new Date(typeof value === 'number' ? value : String(value))
|
|
20
|
+
if (Number.isNaN(d.getTime())) throw createError({ statusCode: 400, statusMessage: `Invalid timestamp filter value: ${String(value)}` })
|
|
21
|
+
return d
|
|
22
|
+
}
|
|
23
|
+
const sqlType = col.getSQLType()
|
|
24
|
+
if ((sqlType === 'integer' || sqlType === 'real') && typeof value === 'string') {
|
|
25
|
+
const n = Number(value)
|
|
26
|
+
return Number.isFinite(n) ? n : value
|
|
27
|
+
}
|
|
28
|
+
return value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** JSON-array membership: EXISTS an element of the array column equal to the (typed) value. Fully
|
|
32
|
+
* parameterised — only the schema-derived column identifier is interpolated, never user input — matching
|
|
33
|
+
* media/usages.ts:25. A `stringSet` (multi choice) matches string members; an `idSet` (many relation/media)
|
|
34
|
+
* matches an integer id (a non-numeric filter value is a clean 400, not a mismatch that silently matches
|
|
35
|
+
* nothing). */
|
|
36
|
+
function member(col: AnySQLiteColumn, kind: 'stringSet' | 'idSet', raw: string): SQL {
|
|
37
|
+
let value: string | number = raw
|
|
38
|
+
if (kind === 'idSet') {
|
|
39
|
+
// Number('') / Number(' ') are 0 (finite) and Number('1e3') is 1000 — none is a valid id token. Require a
|
|
40
|
+
// plain (optionally signed) integer so a blank/garbage filter is a clean 400, not a silent empty match.
|
|
41
|
+
const t = raw.trim()
|
|
42
|
+
if (t === '' || !/^-?\d+$/.test(t)) throw createError({ statusCode: 400, statusMessage: `Invalid id filter value: ${raw}` })
|
|
43
|
+
value = Number(t)
|
|
44
|
+
}
|
|
45
|
+
return sql`EXISTS (SELECT 1 FROM json_each(${sql.identifier(col.name)}) WHERE value = ${value})`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The exclusive end of the DAY a date-only value names, or null when the rule does not apply. A datetime
|
|
50
|
+
* column stores an instant, but `YYYY-MM-DD` (what a date picker and the `filter[c][op]=YYYY-MM-DD` wire
|
|
51
|
+
* contract carry) names a whole day: compared against that day's midnight, "on or before" would drop the
|
|
52
|
+
* day itself and "after" would return it. The next UTC day works for both storages — a `Date` for a
|
|
53
|
+
* timestamp column, and a lexicographically correct string for a TEXT one ('2026-07-25T16:00' < '2026-07-26').
|
|
54
|
+
*/
|
|
55
|
+
function exclusiveDayEnd(kind: FilterKind, raw: string): string | null {
|
|
56
|
+
if (kind !== 'datetime' || !/^\d{4}-\d{2}-\d{2}$/.test(raw)) return null
|
|
57
|
+
const d = new Date(`${raw}T00:00:00Z`)
|
|
58
|
+
// A non-existent calendar date is left to the normal coercion rather than widened: '2026-13-01' is
|
|
59
|
+
// unparseable (a clean 400 on a timestamp column) and '2026-02-31' rolls forward three days, so shifting
|
|
60
|
+
// it would silently ask a different question than the URL states.
|
|
61
|
+
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== raw) return null
|
|
62
|
+
d.setUTCDate(d.getUTCDate() + 1)
|
|
63
|
+
return d.toISOString().slice(0, 10)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build the WHERE predicate for ONE already-validated filter clause (the caller allow-lists `kind`/`op`).
|
|
68
|
+
* Every user value crosses as a drizzle bound param; the only interpolation is `sql.identifier(col.name)`
|
|
69
|
+
* (schema-derived, never user input) — injection-safe, matching picker.ts / usages.ts.
|
|
70
|
+
*
|
|
71
|
+
* The json_each membership and LIKE ESCAPE are SQLite-specific but consistent with the existing query code
|
|
72
|
+
* and the SQLite-only engine; a future Postgres query dialect would have to supply @>/ILIKE variants here
|
|
73
|
+
* (a loud compile failure, never a silent mis-query). NOTE: `contains` on text/richtext matches the STORED
|
|
74
|
+
* string — for richtext that is the HTML source, so 'p' can hit a '<p>' tag (documented caveat; a stripped
|
|
75
|
+
* shadow column / FTS index is a later enhancement).
|
|
76
|
+
*/
|
|
77
|
+
export function filterCondition(col: AnySQLiteColumn, kind: FilterKind, op: FilterOp, raw: string): SQL {
|
|
78
|
+
switch (op) {
|
|
79
|
+
case 'eq': return eq(col, coerceFilterValue(col, raw))
|
|
80
|
+
// SQL <> excludes NULL by three-valued logic; "is not X" must still surface an uncategorised (NULL) row.
|
|
81
|
+
case 'ne': { const v = coerceFilterValue(col, raw); return col.notNull ? ne(col, v) : or(ne(col, v), isNull(col))! }
|
|
82
|
+
case 'lt': return lt(col, coerceFilterValue(col, raw))
|
|
83
|
+
// Only the two bounds that would fall on the wrong side of a whole day get the boundary swap; `lt` /
|
|
84
|
+
// `gte` already mean "before the day" / "from the day on" when compared against its midnight.
|
|
85
|
+
case 'lte': {
|
|
86
|
+
const end = exclusiveDayEnd(kind, raw)
|
|
87
|
+
return end ? lt(col, coerceFilterValue(col, end)) : lte(col, coerceFilterValue(col, raw))
|
|
88
|
+
}
|
|
89
|
+
case 'gt': {
|
|
90
|
+
const end = exclusiveDayEnd(kind, raw)
|
|
91
|
+
return end ? gte(col, coerceFilterValue(col, end)) : gt(col, coerceFilterValue(col, raw))
|
|
92
|
+
}
|
|
93
|
+
case 'gte': return gte(col, coerceFilterValue(col, raw))
|
|
94
|
+
case 'contains':
|
|
95
|
+
if (kind === 'stringSet' || kind === 'idSet') return member(col, kind, raw)
|
|
96
|
+
// text / richtext substring (SQLite LIKE is ASCII case-insensitive); LIKE metacharacters escaped.
|
|
97
|
+
return sql`${col} like ${`%${escapeLike(raw)}%`} escape '\\'`
|
|
98
|
+
case 'notContains':
|
|
99
|
+
return sql`NOT ${member(col, kind as 'stringSet' | 'idSet', raw)}`
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createError, getRouterParam, type H3Event } from 'h3'
|
|
2
|
+
import { getCollection } from './registry'
|
|
3
|
+
import type { BuiltCollection } from './collection-types'
|
|
4
|
+
|
|
5
|
+
export function getCollectionOr404(name: string): BuiltCollection {
|
|
6
|
+
const collection = getCollection(name)
|
|
7
|
+
if (!collection) throw createError({ statusCode: 404, statusMessage: `Unknown collection: ${name}` })
|
|
8
|
+
return collection
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function requireCollection(event: H3Event): BuiltCollection {
|
|
12
|
+
return getCollectionOr404(getRouterParam(event, 'collection') as string)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parseId(raw: string): number {
|
|
16
|
+
if (!/^[1-9][0-9]*$/.test(raw)) throw createError({ statusCode: 400, statusMessage: `Invalid id: ${raw}` })
|
|
17
|
+
return Number(raw)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function requireId(event: H3Event): number {
|
|
21
|
+
return parseId(getRouterParam(event, 'id') as string)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse a list of record ids — from a bulk request body (`number[]`) or a comma-separated query string
|
|
26
|
+
* (`?ids=1,2,3`). Dedupes (preserving first-seen order) and rejects, with a clean 400: an empty list, any
|
|
27
|
+
* element that is not a positive integer, or more than `cap` ids. The cap is enforced up front on the raw
|
|
28
|
+
* input, so an oversized body is rejected without being fully walked and allocated. Shared by the `bulk`
|
|
29
|
+
* command endpoint and the batched referrer lookup so both enforce the same id contract.
|
|
30
|
+
*/
|
|
31
|
+
export function parseIdList(raw: unknown, cap: number): number[] {
|
|
32
|
+
const parts: unknown[] = Array.isArray(raw)
|
|
33
|
+
? raw
|
|
34
|
+
: typeof raw === 'string'
|
|
35
|
+
? raw.split(',').map((s) => s.trim()).filter(Boolean)
|
|
36
|
+
: []
|
|
37
|
+
// Bound the work before parsing: reject an oversized input up front rather than iterating and building a
|
|
38
|
+
// Set of a million ids just to 400. `parts.length` is an upper bound on the deduped result, so this also
|
|
39
|
+
// subsumes any post-dedupe cap — a raw list that fits here can never exceed `cap` after deduping.
|
|
40
|
+
if (parts.length > cap) throw createError({ statusCode: 400, statusMessage: `Too many ids (max ${cap})` })
|
|
41
|
+
const ids: number[] = []
|
|
42
|
+
const seen = new Set<number>()
|
|
43
|
+
for (const p of parts) {
|
|
44
|
+
const n = typeof p === 'number' ? p : typeof p === 'string' ? Number(p) : Number.NaN
|
|
45
|
+
if (!Number.isInteger(n) || n <= 0) throw createError({ statusCode: 400, statusMessage: `Invalid id: ${String(p)}` })
|
|
46
|
+
if (!seen.has(n)) { seen.add(n); ids.push(n) }
|
|
47
|
+
}
|
|
48
|
+
if (!ids.length) throw createError({ statusCode: 400, statusMessage: 'ids must be a non-empty list' })
|
|
49
|
+
return ids
|
|
50
|
+
}
|