@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,127 @@
|
|
|
1
|
+
import { createError } from 'h3'
|
|
2
|
+
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
3
|
+
import type { CollectionDef } from './defineCollection'
|
|
4
|
+
import type { BuiltCollection } from './collection-types'
|
|
5
|
+
import { slugify } from '../../app/utils/slugify'
|
|
6
|
+
import { routeOf, findRouteConflict } from './page-route'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The KEY of the text field Kestrel derives an auto-slug from: the `title` field if present, else the
|
|
10
|
+
* first text field (or `undefined` when the collection has no text field). Text fields keep their key as
|
|
11
|
+
* their column key (no `Id` suffix), so both the record value and a write target read directly by this key
|
|
12
|
+
* — which keeps this in `core` without importing the `fields` naming layer. Exported so the duplicate
|
|
13
|
+
* operation appends its `(copy)` suffix to the SAME field the slug derives from.
|
|
14
|
+
*/
|
|
15
|
+
export function slugSourceKey(def: CollectionDef): string | undefined {
|
|
16
|
+
const fields = Object.entries(def.fields)
|
|
17
|
+
const pick = fields.find(([k, f]) => k === 'title' && f.type === 'text') ?? fields.find(([, f]) => f.type === 'text')
|
|
18
|
+
return pick?.[0]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The trimmed string value of that slug-source field on a record (empty when absent / non-string). */
|
|
22
|
+
export function slugSourceValue(def: CollectionDef, record: Record<string, unknown>): string {
|
|
23
|
+
const key = slugSourceKey(def)
|
|
24
|
+
if (!key) return ''
|
|
25
|
+
const v = record[key]
|
|
26
|
+
return typeof v === 'string' ? v.trim() : ''
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Canonicalise to exactly what the render-side resolver (resolvePublicRoute + the [...slug] segment
|
|
30
|
+
// filter) looks up: lowercase, drop empty segments (leading/trailing/duplicate slashes). Otherwise a slug
|
|
31
|
+
// typed with a trailing slash stores as `/blog/` but resolves as `/blog` → the page 404s and the sitemap
|
|
32
|
+
// advertises a dead URL.
|
|
33
|
+
const normalizePath = (p: string): string => `/${p.trim().toLowerCase().split('/').filter(Boolean).join('/')}`
|
|
34
|
+
|
|
35
|
+
export interface SlugCtx {
|
|
36
|
+
/** The id of the record being saved (null on create), excluded from the uniqueness scan. */
|
|
37
|
+
id: number | null
|
|
38
|
+
/** The existing row (on update) — supplies the title for auto-gen + the locale when the body omits it. */
|
|
39
|
+
existing?: Record<string, unknown> | null
|
|
40
|
+
/** Every collection to scan for a route collision (the caller passes the registry). */
|
|
41
|
+
collections: BuiltCollection[]
|
|
42
|
+
primary: string
|
|
43
|
+
/** Whether the primary locale is prefixed in routes too — folded into the resolved route. */
|
|
44
|
+
prefixPrimary: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve and validate a pageLike record's slug, mutating `values.path` in place (no-op for non-pageLike):
|
|
49
|
+
* - blank slug → auto-generate from the title field (slugify), de-duped (`-2`, `-3`, …) against the
|
|
50
|
+
* GLOBAL resolved-route set; reject 400 only when there's no title to derive one from.
|
|
51
|
+
* - explicit slug → normalize (single leading slash + lowercase) and reject 409 when its resolved route
|
|
52
|
+
* is already taken by another pageLike record.
|
|
53
|
+
* Uniqueness is on the resolved route (`localePath`), GLOBAL across all pageLike collections — one route =
|
|
54
|
+
* one output file — so `/de/x` ≠ `/en/x` (different locale) but a bare `/x` in `pages` and `posts` collide.
|
|
55
|
+
*/
|
|
56
|
+
export function resolvePageSlug(db: BetterSQLite3Database, c: BuiltCollection, values: Record<string, unknown>, ctx: SlugCtx): void {
|
|
57
|
+
if (!c.def.pageLike) return
|
|
58
|
+
const raw = values.path
|
|
59
|
+
const explicit = typeof raw === 'string' && raw.trim() !== ''
|
|
60
|
+
const translatable = !!c.def.translatable
|
|
61
|
+
const locale = translatable
|
|
62
|
+
? (typeof values.locale === 'string' && values.locale ? values.locale : (ctx.existing?.locale as string) ?? ctx.primary)
|
|
63
|
+
: ctx.primary
|
|
64
|
+
const exclude = { collection: c.name, id: ctx.id }
|
|
65
|
+
const routeFor = (p: string) => routeOf({ path: p, locale }, translatable, ctx.primary, ctx.prefixPrimary)!
|
|
66
|
+
|
|
67
|
+
if (explicit) {
|
|
68
|
+
const path = normalizePath(raw as string)
|
|
69
|
+
const conflict = findRouteConflict(db, routeFor(path), ctx.primary, ctx.collections, exclude, ctx.prefixPrimary)
|
|
70
|
+
if (conflict) throw createError({ statusCode: 409, statusMessage: 'Path already in use', data: { path, conflict } })
|
|
71
|
+
values.path = path
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const slug = slugify(slugSourceValue(c.def, { ...(ctx.existing ?? {}), ...values }))
|
|
76
|
+
if (!slug) {
|
|
77
|
+
throw createError({
|
|
78
|
+
statusCode: 400,
|
|
79
|
+
statusMessage: 'Validation failed',
|
|
80
|
+
data: [{ path: ['path'], message: 'A slug is required (no title to derive one from).' }],
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
values.path = dedupeRoute(db, `/${slug}`, routeFor, ctx.primary, ctx.collections, exclude, ctx.prefixPrimary)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The ONE `-N` de-dup loop: return `base` when its resolved route is free, else `base-2`, `base-3`, … the
|
|
88
|
+
* first candidate not already claimed by another pageLike record. Extracted so BOTH the auto-generated-slug
|
|
89
|
+
* branch above AND the duplicate op (`dedupeSourcePath`) share a single route-uniqueness implementation.
|
|
90
|
+
*/
|
|
91
|
+
function dedupeRoute(
|
|
92
|
+
db: BetterSQLite3Database,
|
|
93
|
+
base: string,
|
|
94
|
+
routeFor: (path: string) => string,
|
|
95
|
+
primary: string,
|
|
96
|
+
collections: BuiltCollection[],
|
|
97
|
+
exclude: { collection: string; id: number | null },
|
|
98
|
+
prefixPrimary: boolean,
|
|
99
|
+
): string {
|
|
100
|
+
let candidate = base
|
|
101
|
+
for (let n = 2; n < 1000 && findRouteConflict(db, routeFor(candidate), primary, collections, exclude, prefixPrimary); n++) {
|
|
102
|
+
candidate = `${base}-${n}`
|
|
103
|
+
}
|
|
104
|
+
return candidate
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The free route a pageLike COPY should claim when its collection has NO slug-source text field for the
|
|
109
|
+
* auto-gen branch to derive from: the SOURCE row's own explicit `path`, de-duped (`-2`, `-3`, …) to the
|
|
110
|
+
* first free global route via the same `dedupeRoute` loop. Seeding this (instead of leaving `path` blank →
|
|
111
|
+
* a 400, or re-using the source's colliding path → a 409) lets `create()`'s explicit branch accept it.
|
|
112
|
+
* Mirrors resolvePageSlug's own locale/route derivation so a copy resolves under the source's locale.
|
|
113
|
+
* Returns null when the source has no usable path (the caller lets create()'s own 400 surface).
|
|
114
|
+
*/
|
|
115
|
+
export function dedupeSourcePath(
|
|
116
|
+
db: BetterSQLite3Database,
|
|
117
|
+
c: BuiltCollection,
|
|
118
|
+
sourceRow: Record<string, unknown>,
|
|
119
|
+
ctx: Pick<SlugCtx, 'collections' | 'primary' | 'prefixPrimary'>,
|
|
120
|
+
): string | null {
|
|
121
|
+
const raw = sourceRow.path
|
|
122
|
+
if (typeof raw !== 'string' || raw.trim() === '') return null
|
|
123
|
+
const translatable = !!c.def.translatable
|
|
124
|
+
const locale = translatable && typeof sourceRow.locale === 'string' && sourceRow.locale ? sourceRow.locale : ctx.primary
|
|
125
|
+
const routeFor = (p: string) => routeOf({ path: p, locale }, translatable, ctx.primary, ctx.prefixPrimary)!
|
|
126
|
+
return dedupeRoute(db, normalizePath(raw), routeFor, ctx.primary, ctx.collections, { collection: c.name, id: null }, ctx.prefixPrimary)
|
|
127
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { and, count, eq, getTableColumns, inArray, sql } from 'drizzle-orm'
|
|
2
|
+
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
3
|
+
import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
|
|
4
|
+
import { MAX_BULK_IDS } from '../../app/utils/list-limits'
|
|
5
|
+
import type { BuiltCollection } from './collection-types'
|
|
6
|
+
import { resolveLocale } from './locale'
|
|
7
|
+
import { escapeLike } from './sql'
|
|
8
|
+
|
|
9
|
+
type DB = BetterSQLite3Database
|
|
10
|
+
|
|
11
|
+
export interface PickerQuery {
|
|
12
|
+
search?: string
|
|
13
|
+
ids?: number[]
|
|
14
|
+
label?: string
|
|
15
|
+
page?: number
|
|
16
|
+
perPage?: number
|
|
17
|
+
locale?: string
|
|
18
|
+
/** Published-scope read (anonymous / SSG): hide draft rows + labels, like list()/getOne(). */
|
|
19
|
+
publishedOnly?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PickerResult {
|
|
23
|
+
data: { id: number; label: string }[]
|
|
24
|
+
total: number
|
|
25
|
+
page: number
|
|
26
|
+
perPage: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The field key used as the human label: an explicit text field, else the first text field, else 'id'. */
|
|
30
|
+
function resolveLabelKey(c: BuiltCollection, requested?: string): string {
|
|
31
|
+
const fields = c.def.fields
|
|
32
|
+
if (requested && fields[requested]?.type === 'text') return requested
|
|
33
|
+
for (const [key, field] of Object.entries(fields)) if (field.type === 'text') return key
|
|
34
|
+
return 'id'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function pickerOptions(db: DB, c: BuiltCollection, q: PickerQuery): PickerResult {
|
|
38
|
+
const cols = getTableColumns(c.table) as Record<string, never>
|
|
39
|
+
const t = c.table as AnySQLiteTable
|
|
40
|
+
const labelKey = resolveLabelKey(c, q.label)
|
|
41
|
+
const labelCol = cols[labelKey] ?? cols.id
|
|
42
|
+
|
|
43
|
+
// Validate the locale (consistent 400) up front; only apply it as a filter in search/list mode.
|
|
44
|
+
const localeCond = c.def.translatable ? eq(cols.locale, resolveLocale(q.locale)) : undefined
|
|
45
|
+
|
|
46
|
+
// A blank label must fall back too (not just nullish): the editor's combobox mirrors this label into
|
|
47
|
+
// its input text, and an empty label would leave a validly selected record looking unselected.
|
|
48
|
+
const toOption = (r: Record<string, unknown>) => {
|
|
49
|
+
const label = String(r[labelKey] ?? '').trim()
|
|
50
|
+
return { id: Number(r.id), label: label || `#${r.id}` }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Published-scope read hides drafts (like list()/getOne()), so a picker on a public surface never leaks
|
|
54
|
+
// draft titles. Only applies when the collection has a status column.
|
|
55
|
+
const statusCond = q.publishedOnly && Object.hasOwn(cols, 'status') ? eq(cols.status, 'published') : undefined
|
|
56
|
+
|
|
57
|
+
// ids mode: resolve the picker's current value(s) to labels. A present-but-empty ids list
|
|
58
|
+
// (e.g. all non-numeric) is an explicit empty result, not a full list. The id is a globally
|
|
59
|
+
// unique PK (each locale is its own row), so the locale predicate is intentionally NOT applied
|
|
60
|
+
// here — it could only drop an already-valid selection across locales.
|
|
61
|
+
if (q.ids) {
|
|
62
|
+
if (!q.ids.length) return { data: [], total: 0, page: 1, perPage: 0 }
|
|
63
|
+
const rows = db.select().from(t).where(and(inArray(cols.id, q.ids.slice(0, MAX_BULK_IDS)), statusCond)).limit(MAX_BULK_IDS).all() as Record<string, unknown>[]
|
|
64
|
+
return { data: rows.map(toOption), total: rows.length, page: 1, perPage: rows.length }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// search / list mode.
|
|
68
|
+
const conds = []
|
|
69
|
+
if (localeCond) conds.push(localeCond)
|
|
70
|
+
if (statusCond) conds.push(statusCond)
|
|
71
|
+
if (q.search && labelKey !== 'id') conds.push(sql`${labelCol} like ${`%${escapeLike(q.search)}%`} escape '\\'`)
|
|
72
|
+
const where = conds.length ? and(...conds) : undefined
|
|
73
|
+
|
|
74
|
+
const num = (v: number | undefined, fallback: number) => (Number.isFinite(v) ? (v as number) : fallback)
|
|
75
|
+
const page = Math.max(1, Math.floor(num(q.page, 1)))
|
|
76
|
+
const perPage = Math.min(100, Math.max(1, Math.floor(num(q.perPage, 25))))
|
|
77
|
+
|
|
78
|
+
const rows = db.select().from(t).where(where)
|
|
79
|
+
.orderBy(labelCol).limit(perPage).offset((page - 1) * perPage).all() as Record<string, unknown>[]
|
|
80
|
+
const totalRow = db.select({ value: count() }).from(t).where(where).get() as { value: number } | undefined
|
|
81
|
+
return { data: rows.map(toOption), total: Number(totalRow?.value ?? 0), page, perPage }
|
|
82
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { CollectionDef, FieldDef } from './defineCollection'
|
|
2
|
+
|
|
3
|
+
export interface PopulateCtx { depth: number; locale: string; def: CollectionDef }
|
|
4
|
+
export type Populator = (row: Record<string, unknown>, ctx: PopulateCtx) => Record<string, unknown>
|
|
5
|
+
|
|
6
|
+
// A composed list: each registered populator runs in turn over the row (e.g. media attaches `$media`,
|
|
7
|
+
// links resolve internal hrefs). They are order-independent — each clones the slice it touches.
|
|
8
|
+
const registered: Populator[] = []
|
|
9
|
+
|
|
10
|
+
export function registerPopulator(fn: Populator): void { registered.push(fn) }
|
|
11
|
+
export function clearPopulator(): void { registered.length = 0 }
|
|
12
|
+
|
|
13
|
+
export function populateRow(row: Record<string, unknown>, ctx: PopulateCtx): Record<string, unknown> {
|
|
14
|
+
if (ctx.depth <= 0) return row
|
|
15
|
+
return registered.reduce((acc, fn) => fn(acc, ctx), row)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Key-mode captures the storage asymmetry a field populator must read around: a SINGLE relation/media is
|
|
20
|
+
* keyed `${name}Id` in TOP-LEVEL row COLUMNS but BARE `name` in block props / repeater entries (PROPS).
|
|
21
|
+
* Everything else (links, richtext, many-relation, multiple-media) is bare-keyed in both. Mirrors the
|
|
22
|
+
* `KeyMode` in `extract-refs.ts`.
|
|
23
|
+
*/
|
|
24
|
+
export type KeyMode = 'columns' | 'props'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A per-field-type read populator (the Pruvious-style seam). Receives the containing — already cloned —
|
|
28
|
+
* value bag, the field's key, its def, the populate ctx, and the key-mode; MUTATES `bag` in place (attach
|
|
29
|
+
* `$media` / a `$<name>` relation sibling, resolve a link value, …). Runs only while `ctx.depth > 0`.
|
|
30
|
+
* SYNCHRONOUS — SQLite reads are synchronous, so the whole populate path stays sync.
|
|
31
|
+
*/
|
|
32
|
+
export type FieldPopulator = (
|
|
33
|
+
bag: Record<string, unknown>,
|
|
34
|
+
key: string,
|
|
35
|
+
field: FieldDef,
|
|
36
|
+
ctx: PopulateCtx,
|
|
37
|
+
keyMode: KeyMode,
|
|
38
|
+
) => void
|
|
39
|
+
|
|
40
|
+
// One populator per field-type name (last-wins, like the field-type registry). The shared field-tree
|
|
41
|
+
// walker (`layers/fields`) dispatches into this; the owning layer registers its populator (media →
|
|
42
|
+
// `media`, public → `link`/`richtext`, collections → `relation`) so no layer bakes another's read logic
|
|
43
|
+
// into a lower-layer field-type descriptor.
|
|
44
|
+
const fieldPopulators = new Map<string, FieldPopulator>()
|
|
45
|
+
|
|
46
|
+
export function registerFieldPopulator(type: string, fn: FieldPopulator): void { fieldPopulators.set(type, fn) }
|
|
47
|
+
export function getFieldPopulator(type: string): FieldPopulator | undefined { return fieldPopulators.get(type) }
|
|
48
|
+
export function clearFieldPopulators(): void { fieldPopulators.clear() }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Memoize a resolver by a string key. The cache lives for the returned function's lifetime, so only
|
|
2
|
+
* use it where the underlying data is immutable for that lifetime. Caches misses (null/undefined) too. */
|
|
3
|
+
export function memoize<A extends unknown[], R>(fn: (...args: A) => R, key: (...args: A) => string): (...args: A) => R {
|
|
4
|
+
const cache = new Map<string, R>()
|
|
5
|
+
return (...args: A): R => {
|
|
6
|
+
const k = key(...args)
|
|
7
|
+
if (cache.has(k)) return cache.get(k) as R
|
|
8
|
+
const value = fn(...args)
|
|
9
|
+
cache.set(k, value)
|
|
10
|
+
return value
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Like `memoize`, but active ONLY during a `nuxt generate` prerender run (`import.meta.prerender`), where
|
|
16
|
+
* the DB is read-only for the whole build and a referenced media asset / link target is identical across
|
|
17
|
+
* every page that embeds it — so a shared header image reused on 100 pages is resolved once, not 100×.
|
|
18
|
+
* Outside prerender (dev SSR, tests) it returns the resolver untouched, so a live edit is never served
|
|
19
|
+
* stale and there is no cross-request cache to invalidate.
|
|
20
|
+
*/
|
|
21
|
+
export function memoDuringPrerender<A extends unknown[], R>(fn: (...args: A) => R, key: (...args: A) => string): (...args: A) => R {
|
|
22
|
+
return (import.meta as { prerender?: boolean }).prerender ? memoize(fn, key) : fn
|
|
23
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
|
|
3
|
+
// Records which collections/records a render reads, so the publisher can map a content write back to
|
|
4
|
+
// exactly the routes that embed it (precise incremental invalidation). The CRUD read seams call
|
|
5
|
+
// `captureRead`; the publisher wraps each route render in `withReadCapture`. Outside a capture run
|
|
6
|
+
// `captureRead` is a cheap no-op, so normal request/CRUD traffic is unaffected.
|
|
7
|
+
const storage = new AsyncLocalStorage<{ tags: Set<string> }>()
|
|
8
|
+
|
|
9
|
+
/** Tag that the current render read collection `coll` (a `list`/singleton) or record `coll:id` (a `getOne`). */
|
|
10
|
+
export function captureRead(coll: string, id?: number | null): void {
|
|
11
|
+
const store = storage.getStore()
|
|
12
|
+
if (!store) return
|
|
13
|
+
store.tags.add(typeof id === 'number' ? `${coll}:${id}` : coll)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Run `fn` while collecting the data tags its reads touch; returns the result + the captured tags.
|
|
17
|
+
* Async-safe: AsyncLocalStorage propagates through the awaits of an SSR render. */
|
|
18
|
+
export async function withReadCapture<T>(fn: () => Promise<T> | T): Promise<{ result: T; tags: string[] }> {
|
|
19
|
+
const store = { tags: new Set<string>() }
|
|
20
|
+
const result = await storage.run(store, fn)
|
|
21
|
+
return { result, tags: [...store.tags] }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Sync variant for the synchronous populate path: collects `fn`'s tags into a FRESH set (the enclosing
|
|
25
|
+
* capture does NOT see them — the caller re-emits, e.g. the resolve-scope memo replaying tags on hits). */
|
|
26
|
+
export function withReadCaptureSync<T>(fn: () => T): { result: T; tags: string[] } {
|
|
27
|
+
const store = { tags: new Set<string>() }
|
|
28
|
+
const result = storage.run(store, fn)
|
|
29
|
+
return { result, tags: [...store.tags] }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Re-emit previously captured tags into the CURRENT capture context (no-op outside one). */
|
|
33
|
+
export function replayReadTags(tags: readonly string[]): void {
|
|
34
|
+
const store = storage.getStore()
|
|
35
|
+
if (!store) return
|
|
36
|
+
for (const t of tags) store.tags.add(t)
|
|
37
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { and, eq, getTableColumns, inArray } from 'drizzle-orm'
|
|
2
|
+
import type { AnySQLiteColumn } from 'drizzle-orm/sqlite-core'
|
|
3
|
+
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
4
|
+
import { recordRefs } from '../database/record-refs'
|
|
5
|
+
import { extractRecordRefs, extractLocatedRecordRefs, type FieldRef, type LocatedRef } from '../../../fields/server/utils/extract-refs'
|
|
6
|
+
import type { CollectionDef, FieldDef } from './defineCollection'
|
|
7
|
+
import { fieldIs } from './defineCollection'
|
|
8
|
+
import type { BuiltCollection } from './collection-types'
|
|
9
|
+
import { getCollection } from './registry'
|
|
10
|
+
import type { WriteEvent } from './write-events'
|
|
11
|
+
|
|
12
|
+
type DB = BetterSQLite3Database
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Maintain the `record_refs` edges for one content write (the write-events listener calls this).
|
|
16
|
+
* Replace-on-write: a create / update / singleton PUT clears the source's existing edges and inserts the
|
|
17
|
+
* freshly extracted set; a delete just clears them. Touches only the written source row's edges, so other
|
|
18
|
+
* records' edges are untouched. Best-effort — the bus isolates a throwing listener, so a failure to keep
|
|
19
|
+
* the index current never breaks the content write itself. The `before`/`after` rows are read, never mutated.
|
|
20
|
+
*/
|
|
21
|
+
export function maintainRecordRefs(db: DB, event: WriteEvent): void {
|
|
22
|
+
const { def, before, after } = event
|
|
23
|
+
const source = after ?? before
|
|
24
|
+
const sourceId = source?.id
|
|
25
|
+
if (typeof sourceId !== 'number') return
|
|
26
|
+
|
|
27
|
+
// Clear the source's edges first — both the delete path and the create/update replace path need this.
|
|
28
|
+
db.delete(recordRefs).where(and(eq(recordRefs.sourceColl, def.name), eq(recordRefs.sourceId, sourceId))).run()
|
|
29
|
+
if (!after) return // delete: edges removed, nothing to re-insert.
|
|
30
|
+
|
|
31
|
+
const refs = extractRecordRefs(def, after)
|
|
32
|
+
if (!refs.length) return
|
|
33
|
+
db.insert(recordRefs)
|
|
34
|
+
.values(refs.map((r) => ({ sourceColl: def.name, sourceId, targetColl: r.collection, targetId: r.id })))
|
|
35
|
+
.run()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Why a reference is stale: its target row is gone, or present but not published. */
|
|
39
|
+
export type DeadReason = 'missing' | 'unpublished'
|
|
40
|
+
|
|
41
|
+
/** A reference plus its dead-ness reason, with field/block location for the editor's per-field warnings. */
|
|
42
|
+
export interface LocatedDeadRef extends LocatedRef {
|
|
43
|
+
reason: DeadReason
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Whether a collection has any field/block that can hold a reference — used to skip the derivation (and
|
|
47
|
+
* to decide whether to offer the dead-reference column) for collections that can never have stale refs. */
|
|
48
|
+
export function collectionMayReference(def: CollectionDef): boolean {
|
|
49
|
+
if (def.blocks?.enabled) return true
|
|
50
|
+
if (def.seo) return true // the `seo` system column carries a media ref (the social image), tracked like any
|
|
51
|
+
return Object.values(def.fields).some(fieldMayReference)
|
|
52
|
+
}
|
|
53
|
+
function fieldMayReference(field: FieldDef): boolean {
|
|
54
|
+
// `fieldIs` not `switch`: the open consumer arm makes `type` a non-discriminant (no switch narrowing).
|
|
55
|
+
if (fieldIs(field, 'repeater')) return Object.values(field.options.fields).some(fieldMayReference)
|
|
56
|
+
return field.type === 'relation' || field.type === 'media' || field.type === 'link' || field.type === 'richtext'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Classify a batch of reference targets as dead, in ONE query per target collection (no N+1). A target is
|
|
61
|
+
* dead when its row is GONE, or — if its collection's table has a `status` column — present but not
|
|
62
|
+
* `'published'` (so a consumer-added status on any collection is honoured automatically). An
|
|
63
|
+
* unknown/unregistered target collection counts as missing. A not-yet-migrated target table (e.g. a bare
|
|
64
|
+
* prerender DB) is treated as indeterminate (not dead), mirroring the link resolver's tolerance. Returns a
|
|
65
|
+
* map keyed `"collection:id"` → reason, holding ONLY the dead targets.
|
|
66
|
+
*/
|
|
67
|
+
export function deadTargets(db: DB, targets: FieldRef[]): Map<string, DeadReason> {
|
|
68
|
+
const dead = new Map<string, DeadReason>()
|
|
69
|
+
const byCollection = new Map<string, Set<number>>()
|
|
70
|
+
for (const t of targets) {
|
|
71
|
+
let ids = byCollection.get(t.collection)
|
|
72
|
+
if (!ids) byCollection.set(t.collection, (ids = new Set()))
|
|
73
|
+
ids.add(t.id)
|
|
74
|
+
}
|
|
75
|
+
for (const [collection, ids] of byCollection) {
|
|
76
|
+
const c = getCollection(collection)
|
|
77
|
+
if (!c) {
|
|
78
|
+
for (const id of ids) dead.set(`${collection}:${id}`, 'missing')
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
const cols = getTableColumns(c.table) as Record<string, AnySQLiteColumn>
|
|
82
|
+
const hasStatus = Object.hasOwn(cols, 'status')
|
|
83
|
+
let rows: { id: number; status?: unknown }[]
|
|
84
|
+
try {
|
|
85
|
+
rows = db.select({ id: cols.id, ...(hasStatus ? { status: cols.status } : {}) })
|
|
86
|
+
.from(c.table as never).where(inArray(cols.id, [...ids])).all() as { id: number; status?: unknown }[]
|
|
87
|
+
} catch {
|
|
88
|
+
continue // table not migrated yet — don't false-flag.
|
|
89
|
+
}
|
|
90
|
+
const byId = new Map(rows.map((r) => [r.id, r]))
|
|
91
|
+
for (const id of ids) {
|
|
92
|
+
const row = byId.get(id)
|
|
93
|
+
if (!row) dead.set(`${collection}:${id}`, 'missing')
|
|
94
|
+
else if (hasStatus && row.status !== 'published') dead.set(`${collection}:${id}`, 'unpublished')
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return dead
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Every stale reference a record currently holds, with its field/block location and reason — derived
|
|
101
|
+
* freshly from the record's value (so it auto-clears when the link is removed or the target restored). */
|
|
102
|
+
export function deriveLocatedDeadRefs(db: DB, def: CollectionDef, row: Record<string, unknown>): LocatedDeadRef[] {
|
|
103
|
+
const located = extractLocatedRecordRefs(def, row)
|
|
104
|
+
if (!located.length) return []
|
|
105
|
+
const dead = deadTargets(db, located)
|
|
106
|
+
if (!dead.size) return []
|
|
107
|
+
const out: LocatedDeadRef[] = []
|
|
108
|
+
for (const ref of located) {
|
|
109
|
+
const reason = dead.get(`${ref.collection}:${ref.id}`)
|
|
110
|
+
if (reason) out.push({ ...ref, reason })
|
|
111
|
+
}
|
|
112
|
+
return out
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Load a record by id and derive its stale references (for the editor's `/dead-refs` endpoint). */
|
|
116
|
+
export function recordDeadRefs(db: DB, c: BuiltCollection, id: number): LocatedDeadRef[] {
|
|
117
|
+
const cols = getTableColumns(c.table) as Record<string, never>
|
|
118
|
+
let row: Record<string, unknown> | undefined
|
|
119
|
+
try {
|
|
120
|
+
row = db.select().from(c.table as never).where(eq(cols.id, id)).get() as Record<string, unknown> | undefined
|
|
121
|
+
} catch {
|
|
122
|
+
return []
|
|
123
|
+
}
|
|
124
|
+
return row ? deriveLocatedDeadRefs(db, c.def, row) : []
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The distinct records that reference a given target — the reverse "what links here" lookup (powers the
|
|
128
|
+
* pre-delete/unpublish warning). Read-only; tolerant of a not-yet-migrated index — returns `null` rather
|
|
129
|
+
* than `[]` on failure, so a caller can tell "checked, no referrers" from "could not check" and must not
|
|
130
|
+
* treat the latter as a green light to delete unwarned. */
|
|
131
|
+
export function findReferrers(db: DB, targetColl: string, targetId: number): FieldRef[] | null {
|
|
132
|
+
try {
|
|
133
|
+
return db.selectDistinct({ sourceColl: recordRefs.sourceColl, sourceId: recordRefs.sourceId })
|
|
134
|
+
.from(recordRefs)
|
|
135
|
+
.where(and(eq(recordRefs.targetColl, targetColl), eq(recordRefs.targetId, targetId)))
|
|
136
|
+
.all()
|
|
137
|
+
.map((r) => ({ collection: r.sourceColl, id: r.sourceId }))
|
|
138
|
+
} catch {
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Referrer COUNTS for a whole selection of targets in ONE query — the batched reverse lookup powering the
|
|
145
|
+
* bulk-delete "what links here" warning. Returns `targetId -> number of distinct referrers`; a target with
|
|
146
|
+
* no referrers is simply omitted. A referrer that is ITSELF in the selection is excluded, so deleting a
|
|
147
|
+
* linked pair together raises no false warning. Read-only; tolerant of a not-yet-migrated index — returns
|
|
148
|
+
* `null` (not `{}`) on failure, mirroring `findReferrers`'s indeterminate-vs-empty distinction.
|
|
149
|
+
*/
|
|
150
|
+
export function findReferrersForMany(db: DB, targetColl: string, ids: number[]): Record<number, number> | null {
|
|
151
|
+
if (!ids.length) return {}
|
|
152
|
+
try {
|
|
153
|
+
const rows = db.selectDistinct({ sourceColl: recordRefs.sourceColl, sourceId: recordRefs.sourceId, targetId: recordRefs.targetId })
|
|
154
|
+
.from(recordRefs)
|
|
155
|
+
.where(and(eq(recordRefs.targetColl, targetColl), inArray(recordRefs.targetId, ids)))
|
|
156
|
+
.all()
|
|
157
|
+
const selected = new Set(ids)
|
|
158
|
+
const counts: Record<number, number> = {}
|
|
159
|
+
for (const r of rows) {
|
|
160
|
+
if (r.sourceColl === targetColl && selected.has(r.sourceId)) continue // in-selection referrer → not counted
|
|
161
|
+
counts[r.targetId] = (counts[r.targetId] ?? 0) + 1
|
|
162
|
+
}
|
|
163
|
+
return counts
|
|
164
|
+
} catch {
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A referrer → dead-target edge, for the global broken-references report. */
|
|
170
|
+
export interface BrokenRef {
|
|
171
|
+
source: FieldRef
|
|
172
|
+
target: FieldRef
|
|
173
|
+
reason: DeadReason
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Every edge in the index whose target is currently dead — the global broken-references report. Scans the
|
|
177
|
+
* whole index (an admin maintenance view, not a hot path) and classifies all targets in one batch. Returns
|
|
178
|
+
* `null` (not `[]`) when the index itself could not be read, mirroring `findReferrers`. */
|
|
179
|
+
export function findBrokenRefs(db: DB): BrokenRef[] | null {
|
|
180
|
+
let edges: { sourceColl: string; sourceId: number; targetColl: string; targetId: number }[]
|
|
181
|
+
try {
|
|
182
|
+
edges = db.select({
|
|
183
|
+
sourceColl: recordRefs.sourceColl,
|
|
184
|
+
sourceId: recordRefs.sourceId,
|
|
185
|
+
targetColl: recordRefs.targetColl,
|
|
186
|
+
targetId: recordRefs.targetId,
|
|
187
|
+
}).from(recordRefs).all()
|
|
188
|
+
} catch {
|
|
189
|
+
return null
|
|
190
|
+
}
|
|
191
|
+
if (!edges.length) return []
|
|
192
|
+
const dead = deadTargets(db, edges.map((e) => ({ collection: e.targetColl, id: e.targetId })))
|
|
193
|
+
if (!dead.size) return []
|
|
194
|
+
const out: BrokenRef[] = []
|
|
195
|
+
for (const e of edges) {
|
|
196
|
+
const reason = dead.get(`${e.targetColl}:${e.targetId}`)
|
|
197
|
+
if (reason) out.push({ source: { collection: e.sourceColl, id: e.sourceId }, target: { collection: e.targetColl, id: e.targetId }, reason })
|
|
198
|
+
}
|
|
199
|
+
return out
|
|
200
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { BuiltCollection } from './collection-types'
|
|
2
|
+
|
|
3
|
+
const registry = new Map<string, BuiltCollection>()
|
|
4
|
+
|
|
5
|
+
export function registerCollection(collection: BuiltCollection): void {
|
|
6
|
+
// A consumer collection sharing a name with a built-in (or another) silently overwrites it in the Map.
|
|
7
|
+
// Warn so an accidental clash is visible, while still allowing a deliberate override (last one wins).
|
|
8
|
+
if (registry.has(collection.name)) {
|
|
9
|
+
console.warn(`[kestrel] collection "${collection.name}" is registered more than once — the later definition wins`)
|
|
10
|
+
}
|
|
11
|
+
registry.set(collection.name, collection)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getCollection(name: string): BuiltCollection | undefined {
|
|
15
|
+
return registry.get(name)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function allCollections(): BuiltCollection[] {
|
|
19
|
+
return [...registry.values()]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function clearRegistry(): void {
|
|
23
|
+
registry.clear()
|
|
24
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { withReadCaptureSync, replayReadTags } from './read-capture'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-request/per-run resolver memoization + fan-out budget. `memoDuringPrerender` only dedupes during a
|
|
6
|
+
* `nuxt generate` build; a LIVE request re-resolved every repeated reference, so an anonymous
|
|
7
|
+
* `?depth=10&perPage=500` read could multiply into a huge number of synchronous DB reads (each blocking
|
|
8
|
+
* the single event-loop thread). A scope gives the populate resolvers (relation/media/link):
|
|
9
|
+
* - a shared cache: each distinct target is resolved ONCE per scope (repeated refs are free), and
|
|
10
|
+
* - a budget on DISTINCT resolves: past it, further NEW targets resolve to null — the same degrade
|
|
11
|
+
* path as a stale reference — bounding the worst-case work of one request.
|
|
12
|
+
* Nested scopes reuse the outermost one (a relation's recursive getOne inherits the request's scope; the
|
|
13
|
+
* runtime publisher wraps a whole run in an UNBUDGETED scope, giving it the per-run dedup a generate
|
|
14
|
+
* build gets from `memoDuringPrerender`). Outside any scope a resolver runs raw — no cross-request cache.
|
|
15
|
+
*/
|
|
16
|
+
interface CacheEntry {
|
|
17
|
+
value: unknown
|
|
18
|
+
/** The read-tags the resolver captured while producing `value` — replayed on every hit, so a page
|
|
19
|
+
* served from the memo still records the SAME publish deps as the page that resolved it. */
|
|
20
|
+
tags: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ResolveScope {
|
|
24
|
+
cache: Map<string, CacheEntry>
|
|
25
|
+
max: number
|
|
26
|
+
used: number
|
|
27
|
+
tripped: boolean
|
|
28
|
+
label: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const storage = new AsyncLocalStorage<ResolveScope>()
|
|
32
|
+
|
|
33
|
+
/** Per-request distinct-resolve ceiling. Sized so a LEGITIMATE full page never trips it — the dominant
|
|
34
|
+
* protection is the per-scope dedup (distinct resolves are bounded by the referenced record set, not the
|
|
35
|
+
* breadth^depth fan-out), so this is only a backstop against a pathologically dense reference graph.
|
|
36
|
+
* `resolveBudgetFor(perPage)` scales it with the page size so a `perPage=500, depth≥1` read of richly-
|
|
37
|
+
* referenced rows still populates every row. */
|
|
38
|
+
export const REQUEST_RESOLVE_BUDGET = 20_000
|
|
39
|
+
export function resolveBudgetFor(perPage?: number): number {
|
|
40
|
+
const scaled = (perPage && perPage > 0 ? perPage : 1) * 200 // ~200 distinct refs/row headroom
|
|
41
|
+
return Math.max(REQUEST_RESOLVE_BUDGET, scaled)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function withResolveScope<T>(fn: () => T, max = Number.POSITIVE_INFINITY, label = 'read'): T {
|
|
45
|
+
if (storage.getStore()) return fn() // nested — the outermost scope owns cache + budget
|
|
46
|
+
return storage.run({ cache: new Map(), max, used: 0, tripped: false, label }, fn)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Wrap a resolver: inside a scope it memoizes by `key` and enforces the scope's budget (over-budget new
|
|
50
|
+
* targets → null); outside a scope it calls through untouched. */
|
|
51
|
+
export function memoResolver<A extends unknown[], R>(
|
|
52
|
+
fn: (...args: A) => R,
|
|
53
|
+
key: (...args: A) => string,
|
|
54
|
+
): (...args: A) => R | null {
|
|
55
|
+
return (...args: A): R | null => {
|
|
56
|
+
const scope = storage.getStore()
|
|
57
|
+
if (!scope) return fn(...args)
|
|
58
|
+
const k = key(...args)
|
|
59
|
+
const hit = scope.cache.get(k)
|
|
60
|
+
if (hit) {
|
|
61
|
+
replayReadTags(hit.tags)
|
|
62
|
+
return hit.value as R
|
|
63
|
+
}
|
|
64
|
+
if (scope.used >= scope.max) {
|
|
65
|
+
// Over budget: skip (like a stale ref) but make it OBSERVABLE — a legitimate large read silently
|
|
66
|
+
// dropping `$media`/`$relation` would otherwise be invisible. Warn ONCE per scope.
|
|
67
|
+
if (!scope.tripped) {
|
|
68
|
+
scope.tripped = true
|
|
69
|
+
console.warn(`[kestrel] resolve budget (${scope.max}) exceeded during "${scope.label}" — some references were left unpopulated. Reduce perPage/depth or raise the budget.`)
|
|
70
|
+
}
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
scope.used++
|
|
74
|
+
const { result, tags } = withReadCaptureSync(() => fn(...args))
|
|
75
|
+
replayReadTags(tags) // the miss's own render must record them too (the sync capture is isolating)
|
|
76
|
+
scope.cache.set(k, { value: result, tags })
|
|
77
|
+
return result
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { FieldDef } from './defineCollection'
|
|
3
|
+
|
|
4
|
+
export interface SeoMeta {
|
|
5
|
+
title?: string
|
|
6
|
+
description?: string
|
|
7
|
+
noindex?: boolean
|
|
8
|
+
/** Media id of the social-share image (og:image / twitter card); resolved under `seo.$media.image`. */
|
|
9
|
+
image?: number | null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const seoSchema = z.object({
|
|
13
|
+
title: z.string().optional(),
|
|
14
|
+
description: z.string().optional(),
|
|
15
|
+
noindex: z.boolean().optional(),
|
|
16
|
+
image: z.number().int().positive().nullish(),
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
// The synthetic field set the row populator walks over the `seo` system column (PROPS key-mode), so the
|
|
20
|
+
// social image resolves through the registered media populator — same `$media` bag, same read-capture
|
|
21
|
+
// (a media change re-publishes every page whose og:image embeds it) as any real media field.
|
|
22
|
+
export const seoPopulateFields: Record<string, FieldDef> = {
|
|
23
|
+
image: { type: 'media', options: { accept: 'image' } } as FieldDef,
|
|
24
|
+
}
|