@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,12 @@
|
|
|
1
|
+
import { JSDOM } from 'jsdom'
|
|
2
|
+
import createDOMPurify from 'dompurify'
|
|
3
|
+
|
|
4
|
+
const purify = createDOMPurify(new JSDOM('').window as unknown as Parameters<typeof createDOMPurify>[0])
|
|
5
|
+
|
|
6
|
+
export function sanitizeSvg(svg: string): string {
|
|
7
|
+
return purify.sanitize(svg, {
|
|
8
|
+
USE_PROFILES: { svg: true, svgFilters: true },
|
|
9
|
+
FORBID_TAGS: ['script', 'foreignObject'],
|
|
10
|
+
FORBID_ATTR: ['onload', 'onerror', 'onclick'],
|
|
11
|
+
})
|
|
12
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { fileTypeFromBuffer } from 'file-type'
|
|
2
|
+
|
|
3
|
+
/** Default upload allow-list — sniffable, safe formats. NO executable/active types (html/js/swf →
|
|
4
|
+
* XSS from the media origin) and no plain-text (txt/csv: no magic bytes → unsniffable). Override
|
|
5
|
+
* the whole set via KESTREL_MEDIA_ALLOWED_MIME (see resolveAllowedMimes). Exact file-type@22 MIMEs. */
|
|
6
|
+
export const DEFAULT_ALLOWED_MIME = new Set([
|
|
7
|
+
'image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/avif', 'image/svg+xml',
|
|
8
|
+
'application/pdf',
|
|
9
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
10
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
11
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
12
|
+
'application/vnd.oasis.opendocument.text',
|
|
13
|
+
'application/vnd.oasis.opendocument.spreadsheet',
|
|
14
|
+
'application/vnd.oasis.opendocument.presentation',
|
|
15
|
+
'video/mp4', 'video/webm', 'video/quicktime',
|
|
16
|
+
// audio (file-type emits 'audio/x-m4a' for typical m4a, 'audio/ogg; codecs=opus' for opus)
|
|
17
|
+
'audio/mpeg', 'audio/ogg', 'audio/ogg; codecs=opus', 'audio/wav', 'audio/flac', 'audio/x-m4a', 'audio/mp4',
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
/** Resolve the effective allow-list: a non-empty comma list overrides the default entirely. */
|
|
21
|
+
export function resolveAllowedMimes(configured: string | undefined): Set<string> {
|
|
22
|
+
const list = (configured ?? '').split(',').map((s) => s.trim()).filter(Boolean)
|
|
23
|
+
return list.length ? new Set(list) : DEFAULT_ALLOWED_MIME
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const EXT_BY_MIME: Record<string, string> = {
|
|
27
|
+
'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif',
|
|
28
|
+
'image/avif': 'avif', 'application/pdf': 'pdf', 'image/svg+xml': 'svg',
|
|
29
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
|
30
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
|
|
31
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
|
|
32
|
+
'application/vnd.oasis.opendocument.text': 'odt',
|
|
33
|
+
'application/vnd.oasis.opendocument.spreadsheet': 'ods',
|
|
34
|
+
'application/vnd.oasis.opendocument.presentation': 'odp',
|
|
35
|
+
'video/mp4': 'mp4', 'video/webm': 'webm', 'video/quicktime': 'mov',
|
|
36
|
+
'audio/mpeg': 'mp3', 'audio/ogg': 'ogg', 'audio/ogg; codecs=opus': 'opus',
|
|
37
|
+
'audio/wav': 'wav', 'audio/flac': 'flac', 'audio/x-m4a': 'm4a', 'audio/mp4': 'm4a',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function looksLikeSvg(bytes: Buffer): boolean {
|
|
41
|
+
let head = bytes.subarray(0, 1024).toString('utf8').trimStart()
|
|
42
|
+
// Skip any leading XML declaration, DOCTYPE, and comments (some exporters emit these before <svg>),
|
|
43
|
+
// then require the root element to be <svg>.
|
|
44
|
+
for (let prev = ''; head !== prev; ) {
|
|
45
|
+
prev = head
|
|
46
|
+
head = head
|
|
47
|
+
.replace(/^<\?xml\b[^>]*\?>\s*/i, '')
|
|
48
|
+
.replace(/^<!DOCTYPE\b[^>]*>\s*/i, '')
|
|
49
|
+
.replace(/^<!--[\s\S]*?-->\s*/, '')
|
|
50
|
+
.trimStart()
|
|
51
|
+
}
|
|
52
|
+
return /^<svg[\s>]/i.test(head)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Detect the real MIME from the bytes (magic-byte sniff, then an SVG text heuristic). Pure
|
|
56
|
+
* detection — authorization against the allow-list is the caller's job. */
|
|
57
|
+
export async function sniffMime(bytes: Buffer): Promise<string | null> {
|
|
58
|
+
const ft = await fileTypeFromBuffer(bytes)
|
|
59
|
+
// An SVG that opens with an XML declaration / DOCTYPE is reported by the magic-byte sniff as generic XML;
|
|
60
|
+
// promote it to image/svg+xml when the root element is actually <svg>. A non-XML detection is trusted as-is.
|
|
61
|
+
if (ft && ft.mime !== 'application/xml' && ft.mime !== 'text/xml') return ft.mime
|
|
62
|
+
if (looksLikeSvg(bytes)) return 'image/svg+xml'
|
|
63
|
+
return ft ? ft.mime : null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function extForMime(mime: string): string {
|
|
67
|
+
return EXT_BY_MIME[mime] ?? 'bin'
|
|
68
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { eq, getTableColumns } from 'drizzle-orm'
|
|
2
|
+
import { createError } from 'h3'
|
|
3
|
+
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
4
|
+
import { buildKey } from './naming'
|
|
5
|
+
import { derivativeKey, type DerivativeManifest } from './record'
|
|
6
|
+
import { media } from '../collections/media'
|
|
7
|
+
import { emitMediaWrite } from './media-write'
|
|
8
|
+
import type { StorageDriver } from '../../../core/server/utils/storage'
|
|
9
|
+
import { withLock, mediaLockKey } from '../../../core/server/utils/key-lock'
|
|
10
|
+
|
|
11
|
+
export interface RelocatePlan {
|
|
12
|
+
storageKey: string
|
|
13
|
+
derivatives: DerivativeManifest
|
|
14
|
+
copies: { src: string; dst: string }[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Pure: compute the new storageKey, the rewritten derivative manifest, and the list of object
|
|
18
|
+
* copies (original + each derivative) for moving/renaming a media row to `folder`/`filename`. */
|
|
19
|
+
export function planObjectRelocation(
|
|
20
|
+
row: { storageKey: string; derivatives: DerivativeManifest | null },
|
|
21
|
+
folder: string,
|
|
22
|
+
filename: string,
|
|
23
|
+
): RelocatePlan {
|
|
24
|
+
const storageKey = buildKey(folder, filename)
|
|
25
|
+
const derivatives: DerivativeManifest = {}
|
|
26
|
+
const copies: { src: string; dst: string }[] = [{ src: row.storageKey, dst: storageKey }]
|
|
27
|
+
for (const [manifestKey, entry] of Object.entries(row.derivatives ?? {})) {
|
|
28
|
+
// manifestKey is `<name>.<format>`; variant names are `[A-Za-z0-9_-]` (no dots), so the LAST dot
|
|
29
|
+
// splits name from format cleanly — rebuild the destination key from the name, not the (now-absent
|
|
30
|
+
// from the key scheme) width.
|
|
31
|
+
const dot = manifestKey.lastIndexOf('.')
|
|
32
|
+
const name = dot > 0 ? manifestKey.slice(0, dot) : manifestKey
|
|
33
|
+
const format = dot > 0 ? manifestKey.slice(dot + 1) : (entry.mime.split('/').pop() ?? 'bin')
|
|
34
|
+
const dst = derivativeKey(storageKey, name, format)
|
|
35
|
+
derivatives[manifestKey] = { ...entry, key: dst }
|
|
36
|
+
copies.push({ src: entry.key, dst })
|
|
37
|
+
}
|
|
38
|
+
return { storageKey, derivatives, copies }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface MediaRowLite { id: number; storageKey: string; folder: string | null; filename: string; derivatives: DerivativeManifest | null }
|
|
42
|
+
|
|
43
|
+
function mediaCols() { return getTableColumns(media) as Record<string, never> }
|
|
44
|
+
|
|
45
|
+
/** Reject if any destination object already exists on disk (e.g. another media's derivative, which
|
|
46
|
+
* carries no `storageKey` row, so the DB clash check misses it). Keys the plan itself vacates are
|
|
47
|
+
* excluded so a plain move never collides with its own source. */
|
|
48
|
+
async function assertDestKeysFree(driver: StorageDriver, plan: RelocatePlan): Promise<void> {
|
|
49
|
+
const sources = new Set(plan.copies.map((c) => c.src))
|
|
50
|
+
for (const c of plan.copies) {
|
|
51
|
+
if (sources.has(c.dst)) continue
|
|
52
|
+
if (await driver.exists?.(c.dst)) {
|
|
53
|
+
throw createError({ statusCode: 409, statusMessage: `A file already exists at ${c.dst}` })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Move and/or rename a media object (+ its derivatives). Pre-checks the destination key (409 if
|
|
59
|
+
* taken), copies to the new keys, updates the row, then deletes the old keys; on a DB failure the
|
|
60
|
+
* freshly-copied objects are removed so the source is never lost. No-op if the target equals the
|
|
61
|
+
* current key. */
|
|
62
|
+
export async function relocateMedia(
|
|
63
|
+
db: BetterSQLite3Database, driver: StorageDriver, id: number,
|
|
64
|
+
target: { folder?: string; filename?: string },
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
const cols = mediaCols()
|
|
67
|
+
const row = db.select().from(media).where(eq(cols.id, id)).get() as MediaRowLite | undefined
|
|
68
|
+
if (!row) throw createError({ statusCode: 404, statusMessage: `media ${id} not found` })
|
|
69
|
+
|
|
70
|
+
const folder = target.folder ?? row.folder ?? ''
|
|
71
|
+
const filename = target.filename ?? row.filename
|
|
72
|
+
const plan = planObjectRelocation(row, folder, filename)
|
|
73
|
+
if (plan.storageKey === row.storageKey) return
|
|
74
|
+
|
|
75
|
+
// Same lock the upload path holds across its own exists-check → put → insert (mediaLockKey, keyed on
|
|
76
|
+
// the DESTINATION key only): without it, a concurrent upload or another relocate targeting this same
|
|
77
|
+
// key can observe it free mid-way through this one and land on top of it — or, two relocates racing
|
|
78
|
+
// for the same destination, have the loser's failure-compensation delete the WINNER's just-landed blob.
|
|
79
|
+
await withLock(mediaLockKey(plan.storageKey), async () => {
|
|
80
|
+
const clash = db.select({ id: cols.id }).from(media).where(eq(cols.storageKey, plan.storageKey)).get() as { id: number } | undefined
|
|
81
|
+
if (clash) throw createError({ statusCode: 409, statusMessage: `A file already exists at ${plan.storageKey}` })
|
|
82
|
+
await assertDestKeysFree(driver, plan)
|
|
83
|
+
|
|
84
|
+
for (const c of plan.copies) await driver.copy(c.src, c.dst)
|
|
85
|
+
try {
|
|
86
|
+
db.update(media).set({ storageKey: plan.storageKey, folder: folder || null, filename, derivatives: plan.derivatives, updatedAt: new Date() }).where(eq(cols.id, id)).run()
|
|
87
|
+
} catch (error) {
|
|
88
|
+
for (const c of plan.copies) await driver.delete(c.dst)
|
|
89
|
+
throw error
|
|
90
|
+
}
|
|
91
|
+
for (const c of plan.copies) await driver.delete(c.src)
|
|
92
|
+
})
|
|
93
|
+
// The public URL is built from storageKey — moving/renaming changes it, so embedding pages must re-render.
|
|
94
|
+
emitMediaWrite({ id }, { id })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface MediaRowFull extends MediaRowLite {
|
|
98
|
+
mime: string; ext: string; size: number; width: number | null; height: number | null
|
|
99
|
+
checksum: string | null; thumbhash: string | null
|
|
100
|
+
translations: Record<string, unknown> | null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Duplicate a media object (+ derivatives) to a new key and insert a new row (same bytes →
|
|
104
|
+
* same checksum/dims/translations). Rejects 409 if the target key is taken. */
|
|
105
|
+
export async function duplicateMedia(
|
|
106
|
+
db: BetterSQLite3Database, driver: StorageDriver, id: number,
|
|
107
|
+
target: { folder?: string; filename?: string },
|
|
108
|
+
): Promise<{ id: number }> {
|
|
109
|
+
const cols = mediaCols()
|
|
110
|
+
const row = db.select().from(media).where(eq(cols.id, id)).get() as MediaRowFull | undefined
|
|
111
|
+
if (!row) throw createError({ statusCode: 404, statusMessage: `media ${id} not found` })
|
|
112
|
+
|
|
113
|
+
const folder = target.folder ?? row.folder ?? ''
|
|
114
|
+
const filename = target.filename ?? row.filename
|
|
115
|
+
const plan = planObjectRelocation(row, folder, filename)
|
|
116
|
+
|
|
117
|
+
// See relocateMedia: same destination-key lock the upload path holds, so a concurrent upload/relocate
|
|
118
|
+
// targeting this exact key can't observe it free mid-way through this copy+insert.
|
|
119
|
+
return withLock(mediaLockKey(plan.storageKey), async () => {
|
|
120
|
+
const clash = db.select({ id: cols.id }).from(media).where(eq(cols.storageKey, plan.storageKey)).get() as { id: number } | undefined
|
|
121
|
+
if (clash) throw createError({ statusCode: 409, statusMessage: `A file already exists at ${plan.storageKey}` })
|
|
122
|
+
await assertDestKeysFree(driver, plan)
|
|
123
|
+
|
|
124
|
+
for (const c of plan.copies) await driver.copy(c.src, c.dst)
|
|
125
|
+
try {
|
|
126
|
+
const created = db.insert(media).values({
|
|
127
|
+
storageKey: plan.storageKey, folder: folder || null, filename,
|
|
128
|
+
mime: row.mime, ext: row.ext, size: row.size, width: row.width, height: row.height,
|
|
129
|
+
checksum: row.checksum, thumbhash: row.thumbhash, derivatives: plan.derivatives,
|
|
130
|
+
translations: row.translations ?? {},
|
|
131
|
+
} as never).returning({ id: cols.id }).get() as { id: number }
|
|
132
|
+
emitMediaWrite(null, { id: created.id }) // new media row → re-render listings
|
|
133
|
+
return created
|
|
134
|
+
} catch (error) {
|
|
135
|
+
for (const c of plan.copies) await driver.delete(c.dst)
|
|
136
|
+
throw error
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type LocaleFields = Record<string, unknown>
|
|
2
|
+
export type Translations = Record<string, LocaleFields>
|
|
3
|
+
|
|
4
|
+
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
|
5
|
+
typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
6
|
+
|
|
7
|
+
/** Deep-merge a partial translations patch into the current map: new locales are added, and within a
|
|
8
|
+
* locale the patched fields are merged over the existing ones — so patching `en.alt` keeps `en.title`
|
|
9
|
+
* / `en.description`, and other locales are untouched. Defensive against malformed input: a non-object
|
|
10
|
+
* current/patch or a non-object per-locale value is ignored rather than persisting garbage. */
|
|
11
|
+
export function mergeTranslations(current: Translations | null | undefined, patch: Translations): Translations {
|
|
12
|
+
const out: Translations = isPlainObject(current) ? { ...current } : {}
|
|
13
|
+
if (!isPlainObject(patch)) return out
|
|
14
|
+
for (const [loc, fields] of Object.entries(patch)) {
|
|
15
|
+
if (!isPlainObject(fields)) continue
|
|
16
|
+
out[loc] = { ...(isPlainObject(out[loc]) ? out[loc] : {}), ...fields }
|
|
17
|
+
}
|
|
18
|
+
return out
|
|
19
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { sql, getTableColumns, getTableName, type SQL } from 'drizzle-orm'
|
|
2
|
+
import type { AnySQLiteColumn } from 'drizzle-orm/sqlite-core'
|
|
3
|
+
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
4
|
+
import { allCollections } from '../../../core/server/utils/registry'
|
|
5
|
+
import { fieldIs } from '../../../core/server/utils/defineCollection'
|
|
6
|
+
import { resolveColumnName } from '../../../fields/server/field-registry/naming'
|
|
7
|
+
|
|
8
|
+
export interface MediaUsage { collection: string; recordId: number; field: string }
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Reverse lookup for a whole set of media ids at once: ONE query per (collection, column), never one per
|
|
12
|
+
* id. The JSON columns can only be searched by a full table scan, and better-sqlite3 is synchronous, so a
|
|
13
|
+
* per-id loop over a folder-sized selection would block the event loop for seconds. Every requested id
|
|
14
|
+
* gets an entry (empty when unreferenced). Mirrors `findReferrersForMany` in core's record-ref index.
|
|
15
|
+
*/
|
|
16
|
+
export function findMediaUsagesForMany(db: BetterSQLite3Database, ids: number[]): Record<number, MediaUsage[]> {
|
|
17
|
+
const out: Record<number, MediaUsage[]> = {}
|
|
18
|
+
for (const id of ids) out[id] = []
|
|
19
|
+
if (!ids.length) return out
|
|
20
|
+
// The id set travels as ONE bound parameter (a JSON array), not one placeholder per id: a recursive
|
|
21
|
+
// folder delete can hand us tens of thousands of ids, well past SQLite's 32766-variable ceiling.
|
|
22
|
+
const idSet = sql`(select value from json_each(${JSON.stringify(ids)}))`
|
|
23
|
+
|
|
24
|
+
for (const c of allCollections()) {
|
|
25
|
+
if (c.name === 'media') continue
|
|
26
|
+
const cols = getTableColumns(c.table) as Record<string, AnySQLiteColumn>
|
|
27
|
+
const tbl = sql.identifier(getTableName(c.table))
|
|
28
|
+
const rowId = sql.identifier(cols.id.name)
|
|
29
|
+
const collect = (query: SQL, field: string) => {
|
|
30
|
+
for (const r of db.all(query) as { rid: number; mid: number }[]) {
|
|
31
|
+
out[r.mid]?.push({ collection: c.name, recordId: r.rid, field })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Media ids can nest anywhere inside a repeater/json/content/seo value — json_tree the whole column and
|
|
35
|
+
// match any integer atom (a safe over-approximation for usage warnings).
|
|
36
|
+
const scanJson = (column: string, field: string) => collect(
|
|
37
|
+
sql`select distinct t.${rowId} as rid, jt.atom as mid from ${tbl} t, json_tree(t.${sql.identifier(column)}) jt
|
|
38
|
+
where jt.type = 'integer' and jt.atom in ${idSet}`,
|
|
39
|
+
field,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
for (const [key, field] of Object.entries(c.def.fields)) {
|
|
43
|
+
if (fieldIs(field, 'media') && !field.options?.multiple) {
|
|
44
|
+
const { jsKey } = resolveColumnName(key, field)
|
|
45
|
+
const col = sql.identifier(cols[jsKey].name)
|
|
46
|
+
collect(sql`select t.${rowId} as rid, t.${col} as mid from ${tbl} t where t.${col} in ${idSet}`, key)
|
|
47
|
+
} else if (fieldIs(field, 'media') && field.options?.multiple) {
|
|
48
|
+
const col = sql.identifier(cols[key].name)
|
|
49
|
+
collect(sql`select distinct t.${rowId} as rid, je.value as mid from ${tbl} t, json_each(t.${col}) je
|
|
50
|
+
where je.value in ${idSet}`, key)
|
|
51
|
+
} else if (field.type === 'repeater' || field.type === 'json') {
|
|
52
|
+
const { jsKey } = resolveColumnName(key, field)
|
|
53
|
+
scanJson(cols[jsKey].name, key)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (c.def.blocks?.enabled) scanJson('content', 'content')
|
|
57
|
+
// The `seo` system column carries the social-share image, so a page whose only reference to an asset is
|
|
58
|
+
// its og:image must still be reported — otherwise deleting it silently 404s the published meta tag.
|
|
59
|
+
if (c.def.seo) scanJson('seo', 'seo.image')
|
|
60
|
+
}
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function findMediaUsages(db: BetterSQLite3Database, id: number): MediaUsage[] {
|
|
65
|
+
return findMediaUsagesForMany(db, [id])[id]!
|
|
66
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { eq, getTableColumns } from 'drizzle-orm'
|
|
2
|
+
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
3
|
+
import type { ResolvedVariant } from '../../../core/server/utils/kestrel-config'
|
|
4
|
+
import { mediaSettings } from '../collections/media-settings'
|
|
5
|
+
import { reconcileVariants, type StoredVariant } from './variants'
|
|
6
|
+
|
|
7
|
+
// Auto-discovery accumulator. `KestrelImg`/`useMediaVariant` stash their concrete specs on the render's
|
|
8
|
+
// `event.context.kestrelVariants`; a `beforeResponse` hook (only for publish renders, gated on the
|
|
9
|
+
// `x-kestrel-publish` header) drains them into this module Map, keyed by name and unioning formats across
|
|
10
|
+
// usages. At the end of a FULL publish / generate `saveDiscoveredVariants` reconciles the union into the
|
|
11
|
+
// registry and clears it — so the registry reflects exactly what the last full render actually used.
|
|
12
|
+
const discovered = new Map<string, ResolvedVariant>()
|
|
13
|
+
|
|
14
|
+
/** Merge freshly-declared specs into the accumulator (union formats for same-named variants). */
|
|
15
|
+
export function recordVariants(specs: readonly ResolvedVariant[]): void {
|
|
16
|
+
for (const s of specs) {
|
|
17
|
+
if (!s?.name) continue
|
|
18
|
+
const prev = discovered.get(s.name)
|
|
19
|
+
if (prev) prev.formats = [...new Set([...prev.formats, ...(s.formats ?? [])])]
|
|
20
|
+
else discovered.set(s.name, { ...s, formats: [...(s.formats ?? ['webp'])] })
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function collectVariants(): ResolvedVariant[] {
|
|
25
|
+
return [...discovered.values()]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function clearVariants(): void {
|
|
29
|
+
discovered.clear()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Reconcile the accumulated discovered specs into the `media_settings` registry (scan entries replaced,
|
|
34
|
+
* manual/pinned kept) and clear the accumulator. Called at the end of a full publish / generate.
|
|
35
|
+
*
|
|
36
|
+
* Safety: if NOTHING was discovered this run, leave the registry untouched rather than wiping every scan
|
|
37
|
+
* entry — a zero result almost always means the capture didn't fire (no KestrelImg rendered, or a plumbing
|
|
38
|
+
* gap), not that the site genuinely uses no variants. Narrowing still works whenever ≥1 variant is found.
|
|
39
|
+
*
|
|
40
|
+
* `{ clear: false }` reconciles WITHOUT draining the accumulator — the classic `nuxt generate` topology has
|
|
41
|
+
* no publisher to run this once at the end, so it reconciles after each prerendered route and the growing
|
|
42
|
+
* accumulator converges to the full used set by the last route.
|
|
43
|
+
*/
|
|
44
|
+
export function saveDiscoveredVariants(db: BetterSQLite3Database, opts: { clear?: boolean } = {}): void {
|
|
45
|
+
const found = collectVariants()
|
|
46
|
+
if (!found.length) { if (opts.clear !== false) clearVariants(); return }
|
|
47
|
+
const cols = getTableColumns(mediaSettings) as Record<string, never>
|
|
48
|
+
try {
|
|
49
|
+
// The registry is a read-modify-write, and a `nuxt generate` can run against the SAME DB as a live
|
|
50
|
+
// server's publisher — an unguarded select→reconcile→update loses one side's variants. An IMMEDIATE
|
|
51
|
+
// transaction takes SQLite's write lock up front, so a concurrent writer WAITS (busy_timeout) and then
|
|
52
|
+
// reconciles against the already-updated row instead of clobbering it (cross-process safe).
|
|
53
|
+
db.transaction((tx) => {
|
|
54
|
+
const row = tx.select().from(mediaSettings).where(eq(cols.singletonKey, 'media_settings')).get() as
|
|
55
|
+
| { variants?: StoredVariant[] | null }
|
|
56
|
+
| undefined
|
|
57
|
+
// The json field type's column default is the OBJECT shape '{}' (not '[]') — a generic singleton
|
|
58
|
+
// PUT that omits `variants` leaves exactly that, so nullish-only guarding isn't enough here.
|
|
59
|
+
const next = reconcileVariants(Array.isArray(row?.variants) ? row.variants : [], found)
|
|
60
|
+
if (row) tx.update(mediaSettings).set({ variants: next, updatedAt: new Date() } as never).where(eq(cols.singletonKey, 'media_settings')).run()
|
|
61
|
+
else tx.insert(mediaSettings).values({ singletonKey: 'media_settings', variants: next } as never).run()
|
|
62
|
+
}, { behavior: 'immediate' })
|
|
63
|
+
// Drain the accumulator only AFTER a durable write, so a transient failure keeps it for the next pass.
|
|
64
|
+
if (opts.clear !== false) clearVariants()
|
|
65
|
+
} catch (error) {
|
|
66
|
+
// media_settings not migrated yet — skip persisting the registry (narrowing stays off, config fallback
|
|
67
|
+
// applies) rather than crash the publish. See the schema-drift note; run `drizzle-kit generate`.
|
|
68
|
+
console.warn('[kestrel] media_settings registry not available — variant discovery not persisted:', (error as Error)?.message ?? error)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { eq, getTableColumns } from 'drizzle-orm'
|
|
2
|
+
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
3
|
+
import type { ResolvedVariant, VariantFit } from '../../../core/server/utils/kestrel-config'
|
|
4
|
+
import { mediaSettings } from '../collections/media-settings'
|
|
5
|
+
|
|
6
|
+
const VARIANT_FITS = new Set<VariantFit>(['cover', 'contain', 'inside', 'outside', 'fill'])
|
|
7
|
+
// sharp's crop gravity/position values (resize({fit:'cover'|'contain', position})).
|
|
8
|
+
const SHARP_POSITIONS = new Set([
|
|
9
|
+
'top', 'right top', 'right', 'right bottom', 'bottom', 'left bottom', 'left', 'left top', 'centre', 'center',
|
|
10
|
+
'north', 'northeast', 'east', 'southeast', 'south', 'southwest', 'west', 'northwest', 'entropy', 'attention',
|
|
11
|
+
])
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A variant persisted in the `media_settings` registry: a resolved variant plus its provenance. `source`
|
|
15
|
+
* distinguishes an auto-discovered (`'scan'`) entry from a hand-authored (`'manual'`) one; a `manual`/`pinned`
|
|
16
|
+
* entry survives the scan reconcile and wins a name collision with a scanned entry.
|
|
17
|
+
*/
|
|
18
|
+
export interface StoredVariant extends ResolvedVariant {
|
|
19
|
+
source?: 'manual' | 'scan'
|
|
20
|
+
pinned?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const rank = (v: StoredVariant): number => (v.pinned || v.source === 'manual' ? 1 : 0)
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The effective variant set to derive: the stored registry entries (deduped by name, a manual/pinned entry
|
|
27
|
+
* winning a scan collision, stripped of provenance and re-defaulted defensively), or `fallback` (the resolved
|
|
28
|
+
* config policy variants) when the registry is empty/absent. `presets` are config-authored named variants
|
|
29
|
+
* (`image.variants`): explicit, name-referenced declarations that are never scan-discovered, so they stay
|
|
30
|
+
* active through usage-driven narrowing (unioned into a non-empty registry, winning any name collision).
|
|
31
|
+
* Pure — the load-bearing logic, unit-tested without a DB.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveActiveVariants(
|
|
34
|
+
stored: StoredVariant[] | null | undefined, fallback: ResolvedVariant[], presets: ResolvedVariant[] = [],
|
|
35
|
+
): ResolvedVariant[] {
|
|
36
|
+
// A registry row is hand-authorable via the media_settings JSON PATCH; reject a name outside the
|
|
37
|
+
// derivative-key charset ([A-Za-z0-9_-]) — an out-of-charset char breaks the URL / the prune-media
|
|
38
|
+
// referencedKeys regex (its derivative is pruned though pages reference it) and a `/` nests a pseudo-folder.
|
|
39
|
+
const safeName = (n: unknown): n is string => typeof n === 'string' && /^[A-Za-z0-9_-]+$/.test(n)
|
|
40
|
+
const list = (Array.isArray(stored) ? stored : []).filter(
|
|
41
|
+
(v): v is StoredVariant => !!v && safeName(v.name) && Number.isFinite(v.width) && v.width >= 1,
|
|
42
|
+
)
|
|
43
|
+
// Empty registry ⇒ the fallback already contains the presets (resolveVariants unions them), so return it as-is.
|
|
44
|
+
if (!list.length) return fallback
|
|
45
|
+
const byName = new Map<string, StoredVariant>()
|
|
46
|
+
for (const v of list) {
|
|
47
|
+
const prev = byName.get(v.name)
|
|
48
|
+
if (!prev || rank(v) > rank(prev)) byName.set(v.name, v)
|
|
49
|
+
}
|
|
50
|
+
for (const p of presets) {
|
|
51
|
+
if (p && typeof p.name === 'string' && p.name !== '' && Number.isFinite(p.width) && p.width >= 1) byName.set(p.name, { ...p, source: 'manual' })
|
|
52
|
+
}
|
|
53
|
+
return [...byName.values()].map((v): ResolvedVariant => ({
|
|
54
|
+
name: v.name,
|
|
55
|
+
width: Math.floor(v.width),
|
|
56
|
+
// coerce a garbage/stale height to null so it never reaches sharp's crop resize and 500s the upload
|
|
57
|
+
height: typeof v.height === 'number' && Number.isFinite(v.height) && v.height >= 1 ? Math.floor(v.height) : null,
|
|
58
|
+
// A hand-authored (media_settings PATCH) fit/position outside sharp's enum would otherwise reach
|
|
59
|
+
// sharp.resize() verbatim and throw for EVERY upload that derives this variant — allow-list, don't
|
|
60
|
+
// just default the nullish case.
|
|
61
|
+
fit: v.fit && VARIANT_FITS.has(v.fit) ? v.fit : 'cover',
|
|
62
|
+
position: typeof v.position === 'string' && SHARP_POSITIONS.has(v.position) ? v.position : 'centre',
|
|
63
|
+
formats: v.formats?.length ? v.formats : ['webp'],
|
|
64
|
+
}))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Reconcile a fresh scan into the stored registry: every hand-authored entry (`source:'manual'` or
|
|
69
|
+
* `pinned`) is KEPT, and the discovered specs replace ALL prior `source:'scan'` entries (so a variant whose
|
|
70
|
+
* last `KestrelImg` usage was removed disappears — usage-driven narrowing). A manual/pinned entry wins a
|
|
71
|
+
* name collision with a discovered one. Pure — the load-bearing reconcile, unit-tested without a DB.
|
|
72
|
+
*/
|
|
73
|
+
export function reconcileVariants(existing: StoredVariant[], discovered: ResolvedVariant[]): StoredVariant[] {
|
|
74
|
+
const out: StoredVariant[] = []
|
|
75
|
+
const seen = new Set<string>()
|
|
76
|
+
// Belt-and-braces against the json field type's object-shaped default ('{}', not '[]') reaching here
|
|
77
|
+
// directly — mirrors the Array.isArray guard in resolveActiveVariants.
|
|
78
|
+
for (const e of (Array.isArray(existing) ? existing : [])) {
|
|
79
|
+
if ((e.source === 'manual' || e.pinned) && e.name && !seen.has(e.name)) { out.push(e); seen.add(e.name) }
|
|
80
|
+
}
|
|
81
|
+
for (const d of discovered) {
|
|
82
|
+
if (!d.name || seen.has(d.name)) continue
|
|
83
|
+
out.push({ ...d, source: 'scan' })
|
|
84
|
+
seen.add(d.name)
|
|
85
|
+
}
|
|
86
|
+
return out
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read the persisted variant registry (the `media_settings` singleton) and resolve the active set,
|
|
91
|
+
* falling back to `fallback` (the resolved config policy variants) when nothing is stored yet. `presets`
|
|
92
|
+
* (config-authored named variants) stay active regardless of the stored set. The upload path calls this
|
|
93
|
+
* so it derives exactly the currently-registered set + presets (narrow generation).
|
|
94
|
+
*/
|
|
95
|
+
export function activeVariants(db: BetterSQLite3Database, fallback: ResolvedVariant[], presets: ResolvedVariant[] = []): ResolvedVariant[] {
|
|
96
|
+
const cols = getTableColumns(mediaSettings) as Record<string, never>
|
|
97
|
+
let row: { variants?: StoredVariant[] | null } | undefined
|
|
98
|
+
try {
|
|
99
|
+
row = db.select().from(mediaSettings).where(eq(cols.singletonKey, 'media_settings')).get() as
|
|
100
|
+
| { variants?: StoredVariant[] | null }
|
|
101
|
+
| undefined
|
|
102
|
+
} catch {
|
|
103
|
+
// media_settings not migrated yet (a DB provisioned by committed migrations alone) — degrade to the
|
|
104
|
+
// config fallback rather than 500 the upload. Mirrors attachDeadRefs tolerating a missing record_refs.
|
|
105
|
+
row = undefined
|
|
106
|
+
}
|
|
107
|
+
return resolveActiveVariants(row?.variants ?? null, fallback, presets)
|
|
108
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, inject, resolveDynamicComponent } from 'vue'
|
|
3
|
+
import BlockRenderer from './BlockRenderer.vue'
|
|
4
|
+
import { blockEditKey } from '../utils/block-edit-context'
|
|
5
|
+
|
|
6
|
+
interface BlockNode { id?: string; type: string; props?: Record<string, unknown>; slots?: Record<string, BlockNode[]> }
|
|
7
|
+
const props = defineProps<{ blocks: BlockNode[] }>()
|
|
8
|
+
|
|
9
|
+
// Absent on the public site (plain render); present inside the admin preview (selection-aware wrapper).
|
|
10
|
+
const edit = inject(blockEditKey, null)
|
|
11
|
+
|
|
12
|
+
const pascal = (s: string) => s.replace(/(^|[-_])(\w)/g, (_m, _sep, c: string) => c.toUpperCase())
|
|
13
|
+
|
|
14
|
+
const items = computed(() =>
|
|
15
|
+
props.blocks.map((block, i) => {
|
|
16
|
+
// A block type with no display component is a content condition, not a programming error: this
|
|
17
|
+
// resolver hands back the bare name for it silently, leaving the message below as the one diagnostic.
|
|
18
|
+
const resolved = resolveDynamicComponent('Blocks' + pascal(block.type))
|
|
19
|
+
const component = typeof resolved === 'string' ? null : resolved
|
|
20
|
+
if (!component && import.meta.dev) console.warn(`[kestrel] no display component for block type "${block.type}"`)
|
|
21
|
+
const { $media, ...fields } = (block.props ?? {}) as Record<string, unknown>
|
|
22
|
+
return { key: block.id ?? i, id: block.id, component, fields, media: $media as Record<string, unknown> | undefined, slots: block.slots }
|
|
23
|
+
}),
|
|
24
|
+
)
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<template v-for="item in items" :key="item.key">
|
|
29
|
+
<!-- Public render path: no edit context → plain markup, unchanged. -->
|
|
30
|
+
<component :is="item.component" v-if="item.component && !edit" v-bind="item.fields" :media="item.media">
|
|
31
|
+
<!-- Each declared slot's child blocks recurse through the same renderer (props + nested $media). -->
|
|
32
|
+
<template v-for="(slotBlocks, slotName) in item.slots" #[slotName]>
|
|
33
|
+
<BlockRenderer :blocks="slotBlocks" />
|
|
34
|
+
</template>
|
|
35
|
+
</component>
|
|
36
|
+
<!-- Editor preview path: same component, wrapped in a clickable, highlightable marker. `.prevent`
|
|
37
|
+
cancels any anchor navigation from a link-bearing block; `.stop` keeps a nested-block click from
|
|
38
|
+
also selecting its ancestors. The marker is `display: contents` (see KestrelPreviewBridge), so it
|
|
39
|
+
generates no box: the block root stays the parent's real flex/grid item, so flex/grid-item props,
|
|
40
|
+
`gap`, and root-keyed breakpoints render as on the published site. (`>` combinators are unaffected
|
|
41
|
+
either way — the marker element stays in the DOM tree.) -->
|
|
42
|
+
<div
|
|
43
|
+
v-else-if="item.component"
|
|
44
|
+
class="block-edit-marker"
|
|
45
|
+
:class="{ 'block-edit-marker--selected': item.id != null && edit!.selectedId.value === item.id }"
|
|
46
|
+
:data-block-id="item.id"
|
|
47
|
+
@click.prevent.stop="item.id != null && edit!.select(item.id)"
|
|
48
|
+
>
|
|
49
|
+
<component :is="item.component" v-bind="item.fields" :media="item.media">
|
|
50
|
+
<template v-for="(slotBlocks, slotName) in item.slots" #[slotName]>
|
|
51
|
+
<BlockRenderer :blocks="slotBlocks" />
|
|
52
|
+
</template>
|
|
53
|
+
</component>
|
|
54
|
+
</div>
|
|
55
|
+
</template>
|
|
56
|
+
</template>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, useSlots } from 'vue'
|
|
3
|
+
import type { LinkValue } from '../../../core/server/utils/defineCollection'
|
|
4
|
+
import { linkToHref, linkLabel } from '../utils/link-href'
|
|
5
|
+
|
|
6
|
+
// Generic renderer for the `link` field type, for consumer projects + blocks. Renders an <a> for any
|
|
7
|
+
// LinkValue; external links open in a new, isolated tab. Renders nothing for a null/empty value.
|
|
8
|
+
const props = defineProps<{ value?: LinkValue | null }>()
|
|
9
|
+
const slots = useSlots()
|
|
10
|
+
const href = computed(() => linkToHref(props.value))
|
|
11
|
+
const isExternal = computed(() => props.value?.type === 'external')
|
|
12
|
+
// An unresolved internal link (draft/missing target) falls back to '#' with no target-derived label —
|
|
13
|
+
// only render it if it still has an accessible name from elsewhere, or it's a tabbable link to nowhere.
|
|
14
|
+
const dead = computed(() => props.value?.type === 'internal' && href.value === '#')
|
|
15
|
+
const named = computed(() => !!linkLabel(props.value) || !!slots.default)
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<a
|
|
20
|
+
v-if="value && href && (!dead || named)"
|
|
21
|
+
class="kestrel-link"
|
|
22
|
+
:href="href"
|
|
23
|
+
:target="isExternal ? '_blank' : undefined"
|
|
24
|
+
:rel="isExternal ? 'noopener noreferrer' : undefined"
|
|
25
|
+
><slot>{{ linkLabel(value) }}</slot></a>
|
|
26
|
+
</template>
|