@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,13 @@
|
|
|
1
|
+
import { getHeader, type H3Event } from 'h3'
|
|
2
|
+
|
|
3
|
+
/** The optimistic-concurrency precondition header: the epoch-ms `updatedAt` the client last loaded. A
|
|
4
|
+
* bad/absent value means "no precondition" (unconditional write) rather than an error — so a plain API
|
|
5
|
+
* client that doesn't opt in still works. */
|
|
6
|
+
export const IF_UNMODIFIED_HEADER = 'x-kestrel-if-unmodified-since'
|
|
7
|
+
|
|
8
|
+
export function readIfUnmodifiedSince(event: H3Event): number | undefined {
|
|
9
|
+
const raw = getHeader(event, IF_UNMODIFIED_HEADER)
|
|
10
|
+
if (!raw) return undefined
|
|
11
|
+
const n = Number(raw)
|
|
12
|
+
return Number.isFinite(n) ? n : undefined
|
|
13
|
+
}
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
|
|
3
|
+
// The single source of non-auth configuration. Lives in `kestrel.config.ts` (repo root, plain TS so
|
|
4
|
+
// it's importable by nuxt.config, drizzle.config AND server utils), surfaced as `kestrel: { … }` in
|
|
5
|
+
// nuxt.config. Auth/session settings stay env-only (secrets don't belong in committed config).
|
|
6
|
+
/**
|
|
7
|
+
* Non-secret S3 settings (used when `media.driver === 's3'`). The access-key id and secret are
|
|
8
|
+
* deliberately **absent** here — they are env-only (`KESTREL_S3_ACCESS_KEY_ID` /
|
|
9
|
+
* `KESTREL_S3_SECRET_ACCESS_KEY`), read at driver construction, never in committed config.
|
|
10
|
+
*/
|
|
11
|
+
export interface KestrelS3Config {
|
|
12
|
+
/** Target bucket name. */
|
|
13
|
+
bucket?: string
|
|
14
|
+
/** Signing region (default `us-east-1`; use `auto` for Cloudflare R2). */
|
|
15
|
+
region?: string
|
|
16
|
+
/** Custom endpoint origin for S3-compatible services (R2/MinIO); omit for AWS S3. */
|
|
17
|
+
endpoint?: string
|
|
18
|
+
/** Key prefix prepended to every object key (slashes are normalised away). */
|
|
19
|
+
prefix?: string
|
|
20
|
+
/** Public base URL objects are served from (CDN / bucket origin); `publicUrl` joins this + key. */
|
|
21
|
+
publicBaseUrl?: string
|
|
22
|
+
}
|
|
23
|
+
export interface KestrelMediaConfig {
|
|
24
|
+
/** Directory uploads are written to AND served from (relative → resolved against the project root). */
|
|
25
|
+
uploadDir?: string
|
|
26
|
+
/** Public URL prefix uploads are served under (default `/uploads`). */
|
|
27
|
+
baseUrl?: string
|
|
28
|
+
driver?: 'local' | 's3'
|
|
29
|
+
maxBytes?: number
|
|
30
|
+
allowedMimes?: string
|
|
31
|
+
s3?: KestrelS3Config
|
|
32
|
+
/** Responsive-image derivation policy. `widths` is the legacy proportional ladder (desugars into
|
|
33
|
+
* `variants` as `w<width>` webp presets); `variants` is the usage-driven set of named size × format
|
|
34
|
+
* presets. `variants` is config-only (a structured list doesn't round-trip a scalar/CSV env var); the
|
|
35
|
+
* quality scalars keep their `KESTREL_MEDIA_IMAGE_*_QUALITY` env overrides. */
|
|
36
|
+
image?: { widths?: number[]; webpQuality?: number; jpegQuality?: number; variants?: VariantSpec[] }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** sharp fit modes for a fixed-box (crop) variant. */
|
|
40
|
+
export type VariantFit = 'cover' | 'contain' | 'inside' | 'outside' | 'fill'
|
|
41
|
+
/** Output formats Kestrel emits (WebP + a JPEG fallback). */
|
|
42
|
+
export type VariantFormat = 'webp' | 'jpeg'
|
|
43
|
+
|
|
44
|
+
/** A single image variant to derive on upload — the authoring shape (config or, later, the registry). */
|
|
45
|
+
export interface VariantSpec {
|
|
46
|
+
/** Stable identifier; the manifest key + object-key stem. Sanitised to `[A-Za-z0-9_-]`. */
|
|
47
|
+
name: string
|
|
48
|
+
/** Target width in px. */
|
|
49
|
+
width: number
|
|
50
|
+
/** Fixed box height (crop). Omitted ⇒ proportional width-only resize (never upscaled). */
|
|
51
|
+
height?: number
|
|
52
|
+
/** Fit for a fixed box (default `cover`). */
|
|
53
|
+
fit?: VariantFit
|
|
54
|
+
/** sharp crop position/gravity for `cover`/`contain` (default `centre`). */
|
|
55
|
+
position?: string
|
|
56
|
+
/** Output formats (default `['webp']`). */
|
|
57
|
+
formats?: VariantFormat[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A fully-resolved variant (every field defaulted) threaded to `deriveImage`. */
|
|
61
|
+
export interface ResolvedVariant {
|
|
62
|
+
name: string
|
|
63
|
+
width: number
|
|
64
|
+
/** null ⇒ proportional (no fixed box). */
|
|
65
|
+
height: number | null
|
|
66
|
+
fit: VariantFit
|
|
67
|
+
position: string
|
|
68
|
+
formats: VariantFormat[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Resolved responsive-image derivation policy threaded to `deriveImage` on upload. */
|
|
72
|
+
export interface ResolvedImagePolicy {
|
|
73
|
+
/** Legacy proportional widths (kept until every consumer reads `variants`); each is clamped to the source. */
|
|
74
|
+
widths: number[]
|
|
75
|
+
/** WebP encode quality, 1–100. */
|
|
76
|
+
webpQuality: number
|
|
77
|
+
/** JPEG encode quality, 1–100. */
|
|
78
|
+
jpegQuality: number
|
|
79
|
+
/** The variant set to derive: named config presets unioned with the desugared legacy widths (name-deduped). */
|
|
80
|
+
variants: ResolvedVariant[]
|
|
81
|
+
/** The config-authored named presets only (`image.variants`). Explicit, name-referenced declarations that
|
|
82
|
+
* stay active through usage-driven narrowing — kept separate from the discardable legacy-width ladder. */
|
|
83
|
+
presets: ResolvedVariant[]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const DEFAULT_IMAGE_POLICY: ResolvedImagePolicy = {
|
|
87
|
+
widths: [320, 640, 960, 1280, 1920],
|
|
88
|
+
webpQuality: 78,
|
|
89
|
+
jpegQuality: 80,
|
|
90
|
+
variants: [320, 640, 960, 1280, 1920].map((w): ResolvedVariant => ({
|
|
91
|
+
name: `w${w}`, width: w, height: null, fit: 'cover', position: 'centre', formats: ['webp'],
|
|
92
|
+
})),
|
|
93
|
+
presets: [],
|
|
94
|
+
}
|
|
95
|
+
export interface KestrelConfig {
|
|
96
|
+
/** SQLite path (relative → resolved against root; `:memory:` passes through). */
|
|
97
|
+
db?: string
|
|
98
|
+
/** Absolute site origin for sitemap `<loc>` + robots `Sitemap:`. */
|
|
99
|
+
siteUrl?: string
|
|
100
|
+
/** Human site name + one-line description for the generated `llms.txt` (AI-agent site map). The name
|
|
101
|
+
* falls back to the `siteUrl` host. Env: `KESTREL_SITE_NAME` / `KESTREL_SITE_DESCRIPTION`. */
|
|
102
|
+
siteName?: string
|
|
103
|
+
siteDescription?: string
|
|
104
|
+
/** Website/content locales. */
|
|
105
|
+
locales?: string[]
|
|
106
|
+
primaryLocale?: string
|
|
107
|
+
/** Prefix the PRIMARY locale in URLs too (`/en/about` instead of `/about`); default false. When on, the
|
|
108
|
+
* bare `/` has no page — redirect it to `/<primary>` at the edge. */
|
|
109
|
+
prefixPrimaryLocale?: boolean
|
|
110
|
+
media?: KestrelMediaConfig
|
|
111
|
+
/** Where the published static site is written — both the build-time `nuxt generate` deploy AND the
|
|
112
|
+
* runtime incremental publisher write here: a local dir (default) or an S3 bucket. S3 credentials are
|
|
113
|
+
* env-only (`KESTREL_OUTPUT_S3_*`), never in committed config. Separate from `media` (uploads) so the
|
|
114
|
+
* pages tree and the uploads can target different dirs/buckets. */
|
|
115
|
+
output?: {
|
|
116
|
+
driver?: 'local' | 's3'
|
|
117
|
+
/** Local dir the published static tree (HTML + synced `_nuxt`) is written to (relative → root).
|
|
118
|
+
* Default `.data/published`. Separate from `media.uploadDir` by design. */
|
|
119
|
+
dir?: string
|
|
120
|
+
/** On-disk source of the built client bundle the publisher mirrors into the output (the running
|
|
121
|
+
* build's `_nuxt/**` + public assets, relative → root). Default `.output/public`. */
|
|
122
|
+
publicDir?: string
|
|
123
|
+
/** Auto-publish affected pages on every content write (default true). */
|
|
124
|
+
auto?: boolean
|
|
125
|
+
/** Run a FULL reconcile every N minutes (default 0 = off) — self-heals missed invalidations and
|
|
126
|
+
* picks up time-based `publishDate` publishing that no write event would trigger. */
|
|
127
|
+
reconcileMinutes?: number
|
|
128
|
+
/** Verbose publish logging: emit a timestamped per-route line (rendered / pruned) on each incremental
|
|
129
|
+
* republish, on top of the summary line. Default false (`KESTREL_OUTPUT_VERBOSE`). */
|
|
130
|
+
verbose?: boolean
|
|
131
|
+
s3?: { bucket?: string; region?: string; endpoint?: string; prefix?: string }
|
|
132
|
+
}
|
|
133
|
+
/** Disable Kestrel's built-in collections (default on). Set in `kestrel: {}` (consumer nuxt.config) or
|
|
134
|
+
* `kestrel.config.ts`; per-setting env `KESTREL_COLLECTIONS_PAGES` / `_MEDIA` overrides. */
|
|
135
|
+
collections?: { pages?: boolean; media?: boolean }
|
|
136
|
+
/** Admin page-builder live preview. `desktopWidth` is the reference viewport width (px) the "Desktop"
|
|
137
|
+
* preset renders at before the editor's scale-to-fit shrinks it to the pane; default 1440. Env:
|
|
138
|
+
* `KESTREL_PREVIEW_DESKTOP_WIDTH`. */
|
|
139
|
+
preview?: { desktopWidth?: number }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface ResolvedKestrel {
|
|
143
|
+
dbPath: string
|
|
144
|
+
siteUrl: string
|
|
145
|
+
siteName: string
|
|
146
|
+
siteDescription: string
|
|
147
|
+
supportedLocales: string[]
|
|
148
|
+
primaryLocale: string
|
|
149
|
+
prefixPrimary: boolean
|
|
150
|
+
media: {
|
|
151
|
+
dir: string; baseUrl: string; driver: 'local' | 's3'; maxUploadBytes: number; allowedMimes: string
|
|
152
|
+
s3: { bucket: string; region: string; endpoint: string; prefix: string; publicBaseUrl: string }
|
|
153
|
+
imagePolicy: ResolvedImagePolicy
|
|
154
|
+
}
|
|
155
|
+
/** Resolved static-publish target (runtime publisher + build deploy). */
|
|
156
|
+
output: {
|
|
157
|
+
driver: 'local' | 's3'
|
|
158
|
+
dir: string
|
|
159
|
+
publicDir: string
|
|
160
|
+
auto: boolean
|
|
161
|
+
reconcileMinutes: number
|
|
162
|
+
verbose: boolean
|
|
163
|
+
s3: ResolvedS3Settings
|
|
164
|
+
}
|
|
165
|
+
/** Resolved built-in-collection toggles (default on). The register plugin skips a built-in whose flag is false. */
|
|
166
|
+
collections: { pages: boolean; media: boolean }
|
|
167
|
+
/** Resolved admin-preview settings (surfaced to the client via `runtimeConfig.public`). */
|
|
168
|
+
preview: { desktopWidth: number }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
type Env = Record<string, string | undefined>
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Gate the layer's OWN committed `kestrel.config.ts` so it is contributed to Nuxt only when Kestrel runs
|
|
175
|
+
* in-repo — NOT when it is consumed as a dependency. Why: Nuxt/c12 merges layer config with `defu`, which
|
|
176
|
+
* CONCATENATES array options, so the engine's demo `locales: ['en','de']` would merge back into a consumer
|
|
177
|
+
* that set `locales: ['de']` (→ `['de','en','de']`). Scalars overwrite cleanly (consumer wins), but any
|
|
178
|
+
* array field leaks. Detected via the module URL: a consumed package is loaded from `node_modules`, so it
|
|
179
|
+
* contributes `{}` and the consumer's own `kestrel: {}` (resolved by `resolveKestrel`: env → config →
|
|
180
|
+
* default) is the sole source. In-repo, the file is contributed as-is (the unified DX: nuxt/drizzle/runtime
|
|
181
|
+
* all derive the same values from the one file). Pure + tested so `nuxt.config` stays declarative. */
|
|
182
|
+
export function layerKestrelOption(config: KestrelConfig, moduleUrl: string): KestrelConfig {
|
|
183
|
+
return moduleUrl.includes('/node_modules/') ? {} : config
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Parse a comma-separated locale list (trim + lowercase + dedupe, first-seen order); fall back when blank. */
|
|
187
|
+
export function parseLocaleList(raw: string | undefined, fallback: string[]): string[] {
|
|
188
|
+
const list = (raw ?? '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)
|
|
189
|
+
return list.length ? [...new Set(list)] : fallback
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function clean(value: string | undefined): string | undefined {
|
|
193
|
+
const t = value?.trim()
|
|
194
|
+
return t ? t : undefined
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Normalise + validate the media driver. Case-insensitive (so `S3`/`Local` are accepted), but an unknown
|
|
199
|
+
* value is a fail-loud misconfig — NOT a silent fall-through to local. Without this, the `=== 'local'`
|
|
200
|
+
* guard in the kestrel module (which registers `/uploads`) and the `!== 's3'` branch in the storage
|
|
201
|
+
* resolver (which returns the local driver) disagree, so a typo writes uploads to disk that are never served.
|
|
202
|
+
*/
|
|
203
|
+
function resolveDriver(value: string | undefined): 'local' | 's3' {
|
|
204
|
+
const driver = (value ?? 'local').toLowerCase()
|
|
205
|
+
if (driver !== 'local' && driver !== 's3') {
|
|
206
|
+
throw new Error(`kestrel: unknown media driver "${value}" — expected "local" or "s3"`)
|
|
207
|
+
}
|
|
208
|
+
return driver
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The four non-secret S3 settings, used by `bucket`/`region`/`endpoint`/`prefix`. */
|
|
212
|
+
export interface ResolvedS3Settings { bucket: string; region: string; endpoint: string; prefix: string }
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Resolve the non-secret S3 block (bucket/region/endpoint/prefix), precedence **`<PREFIX>_*` env → config
|
|
216
|
+
* → default**. Shared by media uploads (`KESTREL_S3_*`) and the generate-output deploy
|
|
217
|
+
* (`KESTREL_OUTPUT_S3_*`) so both buckets resolve identically; secrets are never read here.
|
|
218
|
+
*/
|
|
219
|
+
export function resolveS3Settings(
|
|
220
|
+
cfg: { bucket?: string; region?: string; endpoint?: string; prefix?: string } | undefined,
|
|
221
|
+
env: Env,
|
|
222
|
+
envPrefix: string,
|
|
223
|
+
): ResolvedS3Settings {
|
|
224
|
+
const s = cfg ?? {}
|
|
225
|
+
return {
|
|
226
|
+
bucket: clean(env[`${envPrefix}_BUCKET`]) ?? clean(s.bucket) ?? '',
|
|
227
|
+
region: clean(env[`${envPrefix}_REGION`]) ?? clean(s.region) ?? 'us-east-1',
|
|
228
|
+
endpoint: clean(env[`${envPrefix}_ENDPOINT`]) ?? clean(s.endpoint) ?? '',
|
|
229
|
+
prefix: clean(env[`${envPrefix}_PREFIX`]) ?? clean(s.prefix) ?? '',
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function resolveMaybe(rootDir: string, value: string): string {
|
|
234
|
+
return value === ':memory:' ? value : resolve(rootDir, value)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Boolean-ish env var: true/1/yes/on → true, false/0/no/off → false, undefined/garbage → fallback. */
|
|
238
|
+
function envBool(raw: string | undefined, fallback: boolean): boolean {
|
|
239
|
+
const v = raw?.trim().toLowerCase()
|
|
240
|
+
if (!v) return fallback
|
|
241
|
+
if (['true', '1', 'yes', 'on'].includes(v)) return true
|
|
242
|
+
if (['false', '0', 'no', 'off'].includes(v)) return false
|
|
243
|
+
return fallback
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Non-negative integer (e.g. minutes); env wins over config; invalid → fall through (env→config→0). Routes
|
|
247
|
+
* the env value through `clean()` like every sibling resolver: `Number('')`/`Number(' ')` is 0 (finite,
|
|
248
|
+
* >= 0) and would otherwise override a configured value with a blank `KEY=` env-file line. */
|
|
249
|
+
function resolveNonNegInt(cfg: number | undefined, env: string | undefined): number {
|
|
250
|
+
const raw = clean(env)
|
|
251
|
+
const n = raw !== undefined ? Number(raw) : NaN
|
|
252
|
+
if (Number.isFinite(n) && n >= 0) return Math.floor(n)
|
|
253
|
+
if (typeof cfg === 'number' && Number.isFinite(cfg) && cfg >= 0) return Math.floor(cfg)
|
|
254
|
+
return 0
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Positive integer (e.g. a px width); env wins over config; invalid or ≤0 → fall through (env→config→fallback). */
|
|
258
|
+
function resolvePosInt(cfg: number | undefined, env: string | undefined, fallback: number): number {
|
|
259
|
+
const n = env !== undefined ? Number(env) : NaN
|
|
260
|
+
if (Number.isFinite(n) && n > 0) return Math.floor(n)
|
|
261
|
+
if (typeof cfg === 'number' && Number.isFinite(cfg) && cfg > 0) return Math.floor(cfg)
|
|
262
|
+
return fallback
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Floor to ints first, then keep only widths >= 1 (so a sub-1 fraction is dropped, not floored to 0); dedupe, sort. */
|
|
266
|
+
function sanitizeWidths(list: readonly number[]): number[] {
|
|
267
|
+
const clean = list.map((n) => Math.floor(n)).filter((n) => Number.isFinite(n) && n >= 1)
|
|
268
|
+
return [...new Set(clean)].sort((a, b) => a - b)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** widths: KESTREL_MEDIA_IMAGE_WIDTHS (csv) → config array → default; each level used only if non-empty after sanitising. */
|
|
272
|
+
function resolveWidths(cfg: number[] | undefined, env: string | undefined): number[] {
|
|
273
|
+
const fromEnv = sanitizeWidths((env ?? '').split(',').map(Number))
|
|
274
|
+
if (fromEnv.length) return fromEnv
|
|
275
|
+
const fromCfg = sanitizeWidths(cfg ?? [])
|
|
276
|
+
return fromCfg.length ? fromCfg : [...DEFAULT_IMAGE_POLICY.widths]
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** A quality is valid only as an integer in [1, 100]; anything else is ignored at that level. */
|
|
280
|
+
function validQuality(n: number | undefined): number | undefined {
|
|
281
|
+
return n !== undefined && Number.isFinite(n) && n >= 1 && n <= 100 ? Math.round(n) : undefined
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function resolveQuality(cfg: number | undefined, env: string | undefined, fallback: number = DEFAULT_IMAGE_POLICY.webpQuality): number {
|
|
285
|
+
return validQuality(env !== undefined ? Number(env) : undefined) ?? validQuality(cfg) ?? fallback
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const VARIANT_FITS = new Set<VariantFit>(['cover', 'contain', 'inside', 'outside', 'fill'])
|
|
289
|
+
|
|
290
|
+
/** Keep only `[A-Za-z0-9_-]`; collapse runs to a single `-`; trim edge `-`. Empty ⇒ the spec is dropped. */
|
|
291
|
+
function sanitizeVariantName(raw: unknown): string {
|
|
292
|
+
return String(raw ?? '').replace(/[^A-Za-z0-9_-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Keep only known formats, first-seen deduped; an empty result falls back to `['webp']`. */
|
|
296
|
+
function sanitizeFormats(raw: unknown): VariantFormat[] {
|
|
297
|
+
const arr = Array.isArray(raw) ? raw : []
|
|
298
|
+
const out = [...new Set(arr.filter((f): f is VariantFormat => f === 'webp' || f === 'jpeg'))]
|
|
299
|
+
return out.length ? out : ['webp']
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Validate + default each authored spec; drop those with no usable name or a width < 1; dedupe by name. */
|
|
303
|
+
function sanitizeVariants(list: readonly VariantSpec[]): ResolvedVariant[] {
|
|
304
|
+
const out: ResolvedVariant[] = []
|
|
305
|
+
const seen = new Set<string>()
|
|
306
|
+
for (const v of list) {
|
|
307
|
+
const name = sanitizeVariantName(v?.name)
|
|
308
|
+
const width = Math.floor(Number(v?.width))
|
|
309
|
+
if (!name || seen.has(name) || !Number.isFinite(width) || width < 1) continue
|
|
310
|
+
const hn = Math.floor(Number(v?.height))
|
|
311
|
+
const height = v?.height === undefined || v?.height === null || !Number.isFinite(hn) || hn < 1 ? null : hn
|
|
312
|
+
const fit = v?.fit && VARIANT_FITS.has(v.fit) ? v.fit : 'cover'
|
|
313
|
+
const position = typeof v?.position === 'string' && v.position.trim() ? v.position.trim() : 'centre'
|
|
314
|
+
seen.add(name)
|
|
315
|
+
out.push({ name, width, height, fit, position, formats: sanitizeFormats(v?.formats) })
|
|
316
|
+
}
|
|
317
|
+
return out
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Legacy proportional widths → `w<width>` single-webp presets (the back-compat bridge into `variants`). */
|
|
321
|
+
function desugarWidths(widths: number[]): ResolvedVariant[] {
|
|
322
|
+
return widths.map((w): ResolvedVariant => ({ name: `w${w}`, width: w, height: null, fit: 'cover', position: 'centre', formats: ['webp'] }))
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The variant set to derive: named config presets (config-only — no env) unioned with the desugared legacy
|
|
327
|
+
* `widths` (which still honour `KESTREL_MEDIA_IMAGE_WIDTHS`). A named preset WINS a `w<width>` name collision.
|
|
328
|
+
*/
|
|
329
|
+
export function resolveVariants(cfg: VariantSpec[] | undefined, cfgWidths: number[] | undefined, envWidths: string | undefined): ResolvedVariant[] {
|
|
330
|
+
const byName = new Map<string, ResolvedVariant>()
|
|
331
|
+
for (const v of sanitizeVariants(cfg ?? [])) byName.set(v.name, v)
|
|
332
|
+
for (const v of desugarWidths(resolveWidths(cfgWidths, envWidths))) if (!byName.has(v.name)) byName.set(v.name, v)
|
|
333
|
+
return [...byName.values()]
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Resolve every non-auth setting from one place, precedence **`KESTREL_*` (env) → `kestrel.*` (config) →
|
|
338
|
+
* built-in default**. The committed `kestrel.config.ts` is the normal source of truth; an explicit
|
|
339
|
+
* `KESTREL_*` env var OVERRIDES it — the test/deploy escape-hatch (an isolated test DB, a per-env path),
|
|
340
|
+
* so the e2e suite isolates by setting `KESTREL_DB`/`KESTREL_MEDIA_LOCAL_DIR`/`KESTREL_SITE_URL` per run.
|
|
341
|
+
* Normal dev / build / drizzle-kit set no `KESTREL_*`, so config wins and they never drift. (Auth/session
|
|
342
|
+
* settings are env-ONLY and resolved elsewhere; they never appear here.) Pure (only `node:path`), so
|
|
343
|
+
* nuxt.config / the kestrel module / drizzle.config / server utils all derive identical values. Relative
|
|
344
|
+
* paths resolve against `rootDir`; `siteUrl` is returned raw (the caller normalises it).
|
|
345
|
+
*/
|
|
346
|
+
export function resolveKestrel(config: KestrelConfig | undefined, env: Env, rootDir: string): ResolvedKestrel {
|
|
347
|
+
const c = config ?? {}
|
|
348
|
+
|
|
349
|
+
const dbPath = resolveMaybe(rootDir, clean(env.KESTREL_DB) ?? clean(c.db) ?? '.data/db.sqlite')
|
|
350
|
+
|
|
351
|
+
const supportedLocales = parseLocaleList(
|
|
352
|
+
clean(env.KESTREL_LOCALES) ?? (c.locales?.length ? c.locales.join(',') : undefined),
|
|
353
|
+
['en', 'de'],
|
|
354
|
+
)
|
|
355
|
+
const wantedPrimary = clean(env.KESTREL_PRIMARY_LOCALE)?.toLowerCase() ?? clean(c.primaryLocale)?.toLowerCase()
|
|
356
|
+
const primaryLocale = wantedPrimary && supportedLocales.includes(wantedPrimary) ? wantedPrimary : (supportedLocales[0] ?? 'en')
|
|
357
|
+
const prefixPrimary = envBool(env.KESTREL_PREFIX_PRIMARY_LOCALE, c.prefixPrimaryLocale ?? false)
|
|
358
|
+
|
|
359
|
+
const siteUrl = clean(env.KESTREL_SITE_URL) ?? clean(c.siteUrl) ?? ''
|
|
360
|
+
const siteName = clean(env.KESTREL_SITE_NAME) ?? clean(c.siteName) ?? ''
|
|
361
|
+
const siteDescription = clean(env.KESTREL_SITE_DESCRIPTION) ?? clean(c.siteDescription) ?? ''
|
|
362
|
+
|
|
363
|
+
const m = c.media ?? {}
|
|
364
|
+
// maxBytes: a valid env count wins, else a valid config count, else the default (a garbage env falls through).
|
|
365
|
+
const validBytes = (n: number | undefined) => (typeof n === 'number' && Number.isFinite(n) && n > 0 ? n : undefined)
|
|
366
|
+
const maxUploadBytes = validBytes(env.KESTREL_MEDIA_MAX_BYTES !== undefined ? Number(env.KESTREL_MEDIA_MAX_BYTES) : undefined)
|
|
367
|
+
?? validBytes(m.maxBytes) ?? 26214400
|
|
368
|
+
// Non-secret S3 settings only — secrets (access key id / secret) are env-only and read by the driver.
|
|
369
|
+
const s = m.s3 ?? {}
|
|
370
|
+
const s3 = {
|
|
371
|
+
...resolveS3Settings(s, env, 'KESTREL_S3'),
|
|
372
|
+
publicBaseUrl: clean(env.KESTREL_S3_PUBLIC_BASE_URL) ?? clean(s.publicBaseUrl) ?? '',
|
|
373
|
+
}
|
|
374
|
+
const img = m.image ?? {}
|
|
375
|
+
const media = {
|
|
376
|
+
dir: resolveMaybe(rootDir, clean(env.KESTREL_MEDIA_LOCAL_DIR) ?? clean(m.uploadDir) ?? '.data/uploads'),
|
|
377
|
+
baseUrl: clean(env.KESTREL_MEDIA_BASE_URL) ?? clean(m.baseUrl) ?? '/uploads',
|
|
378
|
+
driver: resolveDriver(clean(env.KESTREL_MEDIA_DRIVER) ?? clean(m.driver)),
|
|
379
|
+
maxUploadBytes,
|
|
380
|
+
allowedMimes: clean(env.KESTREL_MEDIA_ALLOWED_MIME) ?? clean(m.allowedMimes) ?? '',
|
|
381
|
+
s3,
|
|
382
|
+
imagePolicy: {
|
|
383
|
+
widths: resolveWidths(img.widths, env.KESTREL_MEDIA_IMAGE_WIDTHS),
|
|
384
|
+
webpQuality: resolveQuality(img.webpQuality, env.KESTREL_MEDIA_IMAGE_WEBP_QUALITY),
|
|
385
|
+
jpegQuality: resolveQuality(img.jpegQuality, env.KESTREL_MEDIA_IMAGE_JPEG_QUALITY, DEFAULT_IMAGE_POLICY.jpegQuality),
|
|
386
|
+
variants: resolveVariants(img.variants, img.widths, env.KESTREL_MEDIA_IMAGE_WIDTHS),
|
|
387
|
+
presets: sanitizeVariants(img.variants ?? []),
|
|
388
|
+
},
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const o = c.output ?? {}
|
|
392
|
+
const output = {
|
|
393
|
+
driver: resolveDriver(clean(env.KESTREL_OUTPUT_DRIVER) ?? clean(o.driver)),
|
|
394
|
+
dir: resolveMaybe(rootDir, clean(env.KESTREL_OUTPUT_DIR) ?? clean(o.dir) ?? '.data/published'),
|
|
395
|
+
publicDir: resolveMaybe(rootDir, clean(env.KESTREL_OUTPUT_PUBLIC_DIR) ?? clean(o.publicDir) ?? '.output/public'),
|
|
396
|
+
auto: envBool(env.KESTREL_OUTPUT_AUTO, o.auto ?? true),
|
|
397
|
+
reconcileMinutes: resolveNonNegInt(o.reconcileMinutes, env.KESTREL_OUTPUT_RECONCILE_MINUTES),
|
|
398
|
+
verbose: envBool(env.KESTREL_OUTPUT_VERBOSE, o.verbose ?? false),
|
|
399
|
+
s3: resolveS3Settings(o.s3, env, 'KESTREL_OUTPUT_S3'),
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const collections = {
|
|
403
|
+
pages: envBool(env.KESTREL_COLLECTIONS_PAGES, c.collections?.pages ?? true),
|
|
404
|
+
media: envBool(env.KESTREL_COLLECTIONS_MEDIA, c.collections?.media ?? true),
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const preview = { desktopWidth: resolvePosInt(c.preview?.desktopWidth, env.KESTREL_PREVIEW_DESKTOP_WIDTH, 1440) }
|
|
408
|
+
|
|
409
|
+
return { dbPath, siteUrl, siteName, siteDescription, supportedLocales, primaryLocale, prefixPrimary, media, output, collections, preview }
|
|
410
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// An in-process async mutex keyed by string: operations sharing a key run STRICTLY one-at-a-time (FIFO),
|
|
2
|
+
// while different keys run concurrently. Kestrel is single-process SSR (the publish queue is single-flight
|
|
3
|
+
// the same way), so this is sufficient to serialize storage+DB mutations that would otherwise race —
|
|
4
|
+
// concurrent uploads to the same key, a folder delete/move interleaving an upload under it, or a backfill
|
|
5
|
+
// re-deriving a row an overwrite is replacing. (A horizontally-scaled deployment would need a distributed
|
|
6
|
+
// lock instead; that is a separate concern flagged where it matters.)
|
|
7
|
+
|
|
8
|
+
const tails = new Map<string, Promise<unknown>>()
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Run `fn` once every previously-enqueued task for `key` has settled. Returns fn's result/rejection
|
|
12
|
+
* unchanged; a failing task never blocks the queue (the next task still runs). The key's chain entry is
|
|
13
|
+
* dropped once it drains, so the map doesn't grow unbounded.
|
|
14
|
+
*/
|
|
15
|
+
export function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
16
|
+
const prev = tails.get(key) ?? Promise.resolve()
|
|
17
|
+
// Chain after prev regardless of how prev settled (don't let one task's rejection skip the next).
|
|
18
|
+
const result = prev.then(fn, fn)
|
|
19
|
+
const tail = result.then(() => {}, () => {})
|
|
20
|
+
tails.set(key, tail)
|
|
21
|
+
void tail.then(() => { if (tails.get(key) === tail) tails.delete(key) })
|
|
22
|
+
return result
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The lock key for a media mutation on a specific object: the exact storage key. Same-key ops serialize
|
|
27
|
+
* (a concurrent upload to the same key, or a backfill re-deriving a row an overwrite is replacing), while
|
|
28
|
+
* different objects stay fully concurrent. (Folder delete/move races are handled separately by the
|
|
29
|
+
* removeDir listPrefix guard, so they need no lock here.)
|
|
30
|
+
*/
|
|
31
|
+
export function mediaLockKey(storageKey: string): string {
|
|
32
|
+
return `media:${storageKey.replace(/^\/+/, '')}`
|
|
33
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createError } from 'h3'
|
|
2
|
+
import { resolveServerKestrel, serverRuntimeConfig } from './server-config'
|
|
3
|
+
|
|
4
|
+
// Website/content locales — the languages published *content* exists in. NOT the admin-UI language
|
|
5
|
+
// (that stays single-language English for now). Resolved lazily: prefer `runtimeConfig.public` (populated
|
|
6
|
+
// by the kestrel module from the consumer's `kestrel: {}`), falling back to Kestrel's own config file +
|
|
7
|
+
// env for non-Nitro callers (node tests, build time). Lazy so the consumer's config wins at request time
|
|
8
|
+
// rather than baking in the package's empty config at module-load.
|
|
9
|
+
function resolved(): { supported: readonly string[]; primary: string; prefixPrimary: boolean } {
|
|
10
|
+
const pub = serverRuntimeConfig()?.public as { locales?: string[]; primaryLocale?: string; prefixPrimary?: boolean } | undefined
|
|
11
|
+
if (pub?.locales?.length && pub.primaryLocale) return { supported: pub.locales, primary: pub.primaryLocale, prefixPrimary: pub.prefixPrimary === true }
|
|
12
|
+
const r = resolveServerKestrel()
|
|
13
|
+
return { supported: r.supportedLocales, primary: r.primaryLocale, prefixPrimary: r.prefixPrimary }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function supportedLocales(): readonly string[] {
|
|
17
|
+
return resolved().supported
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function primaryLocale(): string {
|
|
21
|
+
return resolved().primary
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whether the primary locale is prefixed in URLs too (`/en/about`) — the `localePath` /
|
|
25
|
+
* `resolvePublicRoute` `prefixPrimary` flag, resolved from config. Default false. */
|
|
26
|
+
export function prefixPrimaryLocale(): boolean {
|
|
27
|
+
return resolved().prefixPrimary
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type Locale = string
|
|
31
|
+
|
|
32
|
+
function normalizeLocale(value?: string | string[] | null): string {
|
|
33
|
+
const single = Array.isArray(value) ? value[0] : value
|
|
34
|
+
return (single ?? '').trim().toLowerCase()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isSupportedLocale(value: string): value is Locale {
|
|
38
|
+
return supportedLocales().includes(value)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Normalize a locale from a write body or a read query: trim + lowercase, default to the
|
|
43
|
+
* primary locale when absent, throw 400 when explicitly given but unsupported. Shared by the
|
|
44
|
+
* write and read paths so a WHERE filter and the populate locale can never drift apart.
|
|
45
|
+
*/
|
|
46
|
+
export function resolveLocale(value?: string | string[] | null): Locale {
|
|
47
|
+
const normalized = normalizeLocale(value)
|
|
48
|
+
if (!normalized) return primaryLocale()
|
|
49
|
+
if (!isSupportedLocale(normalized)) {
|
|
50
|
+
throw createError({ statusCode: 400, statusMessage: `Unsupported locale: ${normalized}` })
|
|
51
|
+
}
|
|
52
|
+
return normalized
|
|
53
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { getTableColumns } from 'drizzle-orm'
|
|
2
|
+
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
3
|
+
import type { BuiltCollection } from './collection-types'
|
|
4
|
+
import { localePath } from '../../app/utils/locale-path'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* THE single definition of "stored page-like row → public output route": `localePath(path, the row's
|
|
8
|
+
* OWN locale, …)`, or null when the row is missing or has no path. Every route-deriving site delegates
|
|
9
|
+
* here — slug-uniqueness (`routeOf`/`findRouteConflict`), internal-link targets (`link-resolve`), the
|
|
10
|
+
* publish-status endpoint (`routeForRecord`), and the publisher + its invalidation — so the URL scheme
|
|
11
|
+
* lives in one place. A non-translatable row simply carries no `locale`, so it falls back to `primary`.
|
|
12
|
+
*/
|
|
13
|
+
export function pageRowHref(row: { path?: unknown; locale?: unknown } | null | undefined, primary: string, prefixPrimary = false): string | null {
|
|
14
|
+
if (!row || typeof row.path !== 'string' || !row.path) return null
|
|
15
|
+
const locale = typeof row.locale === 'string' && row.locale ? row.locale : primary
|
|
16
|
+
return localePath(row.path, locale, primary, prefixPrimary)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Route for a pageLike row honoring a collection's `translatable` flag: a translatable collection
|
|
21
|
+
* localizes by the row's `locale`, a non-translatable one is always primary-locale (ignores any stray
|
|
22
|
+
* `locale`). Thin wrapper over `pageRowHref`, used by the slug-uniqueness scan.
|
|
23
|
+
*/
|
|
24
|
+
export function routeOf(row: { path?: unknown; locale?: unknown }, translatable: boolean, primary: string, prefixPrimary = false): string | null {
|
|
25
|
+
return pageRowHref(translatable ? row : { path: row.path }, primary, prefixPrimary)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RouteConflict { collection: string; id: number }
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The first pageLike record — across ALL given collections — whose resolved route equals `targetRoute`,
|
|
32
|
+
* excluding the record being saved. Enforces GLOBAL per-locale slug uniqueness: two pageLike records may
|
|
33
|
+
* never resolve to the same static route (one route = one output file). Scanning resolved routes (not raw
|
|
34
|
+
* `(path, locale)`) is what makes it correct for the non-injective cases — a primary-locale row whose bare
|
|
35
|
+
* path looks prefixed (`/de/x`) aliases a `de`-locale row's `/x`, and a stored path with/without a leading
|
|
36
|
+
* slash normalizes the same. O(pageLike rows): a single-user admin save, the same scope the publisher walks.
|
|
37
|
+
*/
|
|
38
|
+
export function findRouteConflict(
|
|
39
|
+
db: BetterSQLite3Database,
|
|
40
|
+
targetRoute: string,
|
|
41
|
+
primary: string,
|
|
42
|
+
collections: BuiltCollection[],
|
|
43
|
+
exclude: { collection: string; id: number | null },
|
|
44
|
+
prefixPrimary = false,
|
|
45
|
+
): RouteConflict | null {
|
|
46
|
+
for (const c of collections) {
|
|
47
|
+
if (!c.def.pageLike) continue
|
|
48
|
+
const cols = getTableColumns(c.table) as Record<string, never>
|
|
49
|
+
const proj: Record<string, unknown> = { id: cols.id, path: cols.path }
|
|
50
|
+
if (c.def.translatable) proj.locale = cols.locale
|
|
51
|
+
const rows = db.select(proj as never).from(c.table).all() as { id: number; path: unknown; locale?: unknown }[]
|
|
52
|
+
for (const r of rows) {
|
|
53
|
+
if (c.name === exclude.collection && r.id === exclude.id) continue
|
|
54
|
+
if (routeOf(r, !!c.def.translatable, primary, prefixPrimary) === targetRoute) return { collection: c.name, id: r.id }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return null
|
|
58
|
+
}
|