@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,71 @@
|
|
|
1
|
+
import type { Condition, ConditionOperator, ConditionRule } from '../../../core/server/utils/defineCollection'
|
|
2
|
+
|
|
3
|
+
/** Empty = absent or a "no value" (null/undefined/''/[]). Shared by the evaluator (the `empty`
|
|
4
|
+
* operator + bare-rule presence) and the server's required-when-visible check. 0 and false are values. */
|
|
5
|
+
export function isEmptyValue(value: unknown): boolean {
|
|
6
|
+
return value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// v1 path scope is SIBLING-ONLY. A bare name or `./name` resolves a sibling in the current scope;
|
|
10
|
+
// cross-scope forms (`/abs`, `../parent`, dotted `a.b`) are out of v1 and resolve to undefined — so the
|
|
11
|
+
// rule is simply false (the field hides), keeping the evaluator total: it never throws on a bad path.
|
|
12
|
+
function resolvePath(field: string, scope: Record<string, unknown>): unknown {
|
|
13
|
+
const key = field.startsWith('./') ? field.slice(2) : field
|
|
14
|
+
if (key === '' || key.startsWith('/') || key.startsWith('..') || key.includes('/') || key.includes('.')) {
|
|
15
|
+
return undefined
|
|
16
|
+
}
|
|
17
|
+
return scope[key]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function compare(dep: unknown, target: number | string, op: 'gt' | 'gte' | 'lt' | 'lte'): boolean {
|
|
21
|
+
if (typeof dep !== typeof target || (typeof dep !== 'number' && typeof dep !== 'string')) return false
|
|
22
|
+
if (op === 'gt') return dep > target
|
|
23
|
+
if (op === 'gte') return dep >= target
|
|
24
|
+
if (op === 'lt') return dep < target
|
|
25
|
+
return dep <= target
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function matchOperator(dep: unknown, op: ConditionOperator): boolean {
|
|
29
|
+
for (const [key, target] of Object.entries(op)) {
|
|
30
|
+
if (target === undefined) continue
|
|
31
|
+
let ok: boolean
|
|
32
|
+
switch (key) {
|
|
33
|
+
case 'eq': ok = dep === target; break
|
|
34
|
+
case 'ne': ok = dep !== target; break
|
|
35
|
+
case 'gt': case 'gte': case 'lt': case 'lte':
|
|
36
|
+
ok = compare(dep, target as number | string, key); break
|
|
37
|
+
case 'in': ok = Array.isArray(target) && (target as unknown[]).includes(dep); break
|
|
38
|
+
case 'notIn': ok = Array.isArray(target) && !(target as unknown[]).includes(dep); break
|
|
39
|
+
case 'regexp': {
|
|
40
|
+
if (typeof dep !== 'string' || typeof target !== 'string') { ok = false; break }
|
|
41
|
+
try { ok = new RegExp(target).test(dep) } catch { ok = false }
|
|
42
|
+
break
|
|
43
|
+
}
|
|
44
|
+
case 'empty': ok = target ? isEmptyValue(dep) : !isEmptyValue(dep); break
|
|
45
|
+
default: ok = false
|
|
46
|
+
}
|
|
47
|
+
if (!ok) return false
|
|
48
|
+
}
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function evaluateRule(rule: ConditionRule, scope: Record<string, unknown>): boolean {
|
|
53
|
+
const dep = resolvePath(rule.field, scope)
|
|
54
|
+
if (rule.is !== undefined) return dep === rule.is
|
|
55
|
+
if (rule.op) return matchOperator(dep, rule.op)
|
|
56
|
+
return !isEmptyValue(dep)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Pure, total (never throws) condition evaluator shared by the editor (hide widgets) and the server
|
|
60
|
+
* (required-when-visible). `scope` is the sibling-value map the condition resolves against. */
|
|
61
|
+
export function evaluateCondition(condition: Condition, scope: Record<string, unknown>): boolean {
|
|
62
|
+
if ('and' in condition) return condition.and.every((c) => evaluateCondition(c, scope))
|
|
63
|
+
if ('or' in condition) return condition.or.some((c) => evaluateCondition(c, scope))
|
|
64
|
+
if ('not' in condition) return !evaluateCondition(condition.not, scope)
|
|
65
|
+
return evaluateRule(condition, scope)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Whether a field is shown given the sibling-value `scope`. No condition => always visible. */
|
|
69
|
+
export function isFieldVisible(field: { condition?: Condition } | null | undefined, scope: Record<string, unknown>): boolean {
|
|
70
|
+
return !field?.condition || evaluateCondition(field.condition, scope)
|
|
71
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime no-op for a block SFC's metadata call — `defineBlock({ label, slots, icon })`. The metadata is
|
|
3
|
+
* read at BUILD time by the SFC extractor (from the same call, statically); at runtime this only exists so
|
|
4
|
+
* the SFC's `<script setup>` doesn't reference an undefined `defineBlock`. Auto-imported in the app so block
|
|
5
|
+
* authors call it without an import (the field factories are auto-imported the same way).
|
|
6
|
+
*/
|
|
7
|
+
export function defineBlock(_meta?: {
|
|
8
|
+
label?: string | Record<string, string>
|
|
9
|
+
slots?: string[]
|
|
10
|
+
icon?: string
|
|
11
|
+
}): void {
|
|
12
|
+
// intentionally empty — see above.
|
|
13
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { FieldDef } from '../../../core/server/utils/defineCollection'
|
|
2
|
+
|
|
3
|
+
type NumberOptions = Extract<FieldDef, { type: 'number' }>['options']
|
|
4
|
+
type ChoiceOptions = Extract<FieldDef, { type: 'choice' }>['options']
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether a number field is integer-valued — the default — i.e. neither
|
|
8
|
+
* `integer: false` nor a `decimals` precision opts it into reals.
|
|
9
|
+
*/
|
|
10
|
+
export function numberIsInteger(options: NumberOptions): boolean {
|
|
11
|
+
return options?.integer !== false && options?.decimals === undefined
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The allowed values of a choice field, as a non-empty tuple (for `z.enum`). */
|
|
15
|
+
export function choiceValues(options: ChoiceOptions): [string, ...string[]] {
|
|
16
|
+
return options.choices.map((c) => c.value) as [string, ...string[]]
|
|
17
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { FieldDef } from '../../../core/server/utils/defineCollection'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Field factories for single-file (SFC) block authoring, à la Pruvious. A block's schema is declared as its
|
|
5
|
+
* component's `defineProps({ heading: textField({ required: true }), … })`; each factory returns a DUAL
|
|
6
|
+
* object — a Vue prop descriptor (`type` = a JS-global constructor) that ALSO carries the Kestrel `FieldDef`
|
|
7
|
+
* under `[KESTREL_FIELD]`. Vue reads `type` at runtime; the build-time SFC extractor reads `[KESTREL_FIELD]`.
|
|
8
|
+
*
|
|
9
|
+
* This file imports NO Vue/Nuxt runtime (only JS globals), so it is importable BOTH in the browser (the SFC's
|
|
10
|
+
* props declaration) AND by the Node extractor that injects these factories to evaluate a block's schema.
|
|
11
|
+
* `Symbol.for` (global registry) keeps the key identical even if the file is instantiated twice.
|
|
12
|
+
*/
|
|
13
|
+
export const KESTREL_FIELD = Symbol.for('kestrel.field')
|
|
14
|
+
|
|
15
|
+
export interface FieldFactoryResult<C = unknown> {
|
|
16
|
+
/** A Vue prop constructor (String / Number / Boolean / Object / Array) — the runtime prop shape. Typed as
|
|
17
|
+
* the specific constructor so Vue's `defineProps` accepts the factory result AND infers the prop's value
|
|
18
|
+
* type (`textField()` → `string`, `mediaField()` → `number`, …). */
|
|
19
|
+
type: C
|
|
20
|
+
/** The Kestrel field definition the extractor lifts into the block's schema. */
|
|
21
|
+
[KESTREL_FIELD]: FieldDef
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Keys that belong on the FieldDef itself (not inside `options`); everything else a factory receives is a
|
|
25
|
+
// type-specific option. Mirrors `BaseFieldDef` in defineCollection.ts.
|
|
26
|
+
const BASE_KEYS = new Set(['required', 'translatable', 'unique', 'label', 'default', 'condition', 'renamedFrom', 'populate'])
|
|
27
|
+
|
|
28
|
+
function partition(opts: Record<string, unknown>): { base: Record<string, unknown>; options: Record<string, unknown> } {
|
|
29
|
+
const base: Record<string, unknown> = {}
|
|
30
|
+
const options: Record<string, unknown> = {}
|
|
31
|
+
for (const [k, v] of Object.entries(opts)) (BASE_KEYS.has(k) ? base : options)[k] = v
|
|
32
|
+
return { base, options }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function wrap<C>(def: Record<string, unknown>, vueType: C): FieldFactoryResult<C> {
|
|
36
|
+
// The factory assembles the FieldDef structurally (open discriminated union); cast through `unknown`.
|
|
37
|
+
return { type: vueType, [KESTREL_FIELD]: def as unknown as FieldDef }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A leaf field: base props at the top level, everything else under `options`. */
|
|
41
|
+
function leaf<C>(type: string, vueType: C) {
|
|
42
|
+
return (opts: Record<string, unknown> = {}): FieldFactoryResult<C> => {
|
|
43
|
+
const { base, options } = partition(opts)
|
|
44
|
+
const def: Record<string, unknown> = { type, ...base }
|
|
45
|
+
if (Object.keys(options).length) def.options = options
|
|
46
|
+
return wrap(def, vueType)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Generic escape hatch for a CUSTOM field type (registered via `defineFieldType`) that has no dedicated
|
|
51
|
+
* factory — `field('secureGallery', { … })`. Base props split off the top level, the rest into `options`;
|
|
52
|
+
* the Vue prop is typed `Object` (unknown value shape). */
|
|
53
|
+
export function field(type: string, opts: Record<string, unknown> = {}): FieldFactoryResult<ObjectConstructor> {
|
|
54
|
+
const { base, options } = partition(opts)
|
|
55
|
+
const def: Record<string, unknown> = { type, ...base }
|
|
56
|
+
if (Object.keys(options).length) def.options = options
|
|
57
|
+
return wrap(def, Object)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const textField = leaf('text', String)
|
|
61
|
+
export const slugField = leaf('slug', String)
|
|
62
|
+
export const richtextField = leaf('richtext', String)
|
|
63
|
+
export const numberField = leaf('number', Number)
|
|
64
|
+
export const booleanField = leaf('boolean', Boolean)
|
|
65
|
+
export const jsonField = leaf('json', Object)
|
|
66
|
+
export const linkField = leaf('link', Object)
|
|
67
|
+
|
|
68
|
+
/** Media: `multiple` (stored bare, an id array) → Array prop; single (a `<name>Id` FK) → Number prop. */
|
|
69
|
+
export function mediaField(opts: Record<string, unknown> = {}): FieldFactoryResult<ArrayConstructor | NumberConstructor> {
|
|
70
|
+
const { base, options } = partition(opts)
|
|
71
|
+
const def: Record<string, unknown> = { type: 'media', ...base }
|
|
72
|
+
if (Object.keys(options).length) def.options = options
|
|
73
|
+
return wrap(def, options.multiple ? Array : Number)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Choice: `multiple` → the value is a `string[]` (Array prop), single → a `string` (String prop). */
|
|
77
|
+
export function choiceField(opts: Record<string, unknown> = {}): FieldFactoryResult<ArrayConstructor | StringConstructor> {
|
|
78
|
+
const { base, options } = partition(opts)
|
|
79
|
+
const def: Record<string, unknown> = { type: 'choice', ...base }
|
|
80
|
+
if (Object.keys(options).length) def.options = options
|
|
81
|
+
return wrap(def, options.multiple ? Array : String)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Datetime: `range` → the value is a `{ start, end }` object (Object prop), else a `string` (String prop). */
|
|
85
|
+
export function datetimeField(opts: Record<string, unknown> = {}): FieldFactoryResult<ObjectConstructor | StringConstructor> {
|
|
86
|
+
const { base, options } = partition(opts)
|
|
87
|
+
const def: Record<string, unknown> = { type: 'datetime', ...base }
|
|
88
|
+
if (Object.keys(options).length) def.options = options
|
|
89
|
+
return wrap(def, options.range ? Object : String)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Relation: `collection`/`many`/`labelField` map into `relation`; `many` → Array prop, single → Number. */
|
|
93
|
+
export function relationField(opts: Record<string, unknown> = {}): FieldFactoryResult<ArrayConstructor | NumberConstructor> {
|
|
94
|
+
const { collection, many, labelField, ...rest } = opts as {
|
|
95
|
+
collection?: string
|
|
96
|
+
many?: boolean
|
|
97
|
+
labelField?: string
|
|
98
|
+
} & Record<string, unknown>
|
|
99
|
+
if (typeof collection !== 'string' || !collection) {
|
|
100
|
+
throw new Error('relationField requires a `collection` (the target collection name)')
|
|
101
|
+
}
|
|
102
|
+
const { base, options } = partition(rest)
|
|
103
|
+
const relation: Record<string, unknown> = { collection }
|
|
104
|
+
if (many) relation.many = many
|
|
105
|
+
if (labelField) relation.labelField = labelField
|
|
106
|
+
const def: Record<string, unknown> = { type: 'relation', ...base, relation }
|
|
107
|
+
if (Object.keys(options).length) def.options = options
|
|
108
|
+
return wrap(def, many ? Array : Number)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Repeater: sub-fields are authored as factory calls too — unwrap each to its FieldDef (recursively).
|
|
112
|
+
* `fieldLayout` (the sub-field editor layout) forwards into `options` instead of being dropped. */
|
|
113
|
+
export function repeaterField(opts: Record<string, unknown> = {}): FieldFactoryResult<ArrayConstructor> {
|
|
114
|
+
const { fields, fieldLayout, ...rest } = opts as { fields?: Record<string, unknown>; fieldLayout?: unknown } & Record<string, unknown>
|
|
115
|
+
const { base } = partition(rest)
|
|
116
|
+
const options: Record<string, unknown> = { fields: unwrapFields(fields ?? {}) }
|
|
117
|
+
if (fieldLayout !== undefined) options.fieldLayout = fieldLayout
|
|
118
|
+
return wrap({ type: 'repeater', ...base, options }, Array)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Convert a `{ name: <factory-result | FieldDef> }` map into a `{ name: FieldDef }` map. A nested repeater's
|
|
122
|
+
* result already carries a fully-unwrapped def, so recursion is implicit. */
|
|
123
|
+
function unwrapFields(fields: Record<string, unknown>): Record<string, FieldDef> {
|
|
124
|
+
const out: Record<string, FieldDef> = {}
|
|
125
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
126
|
+
out[k] = v && typeof v === 'object' && KESTREL_FIELD in v ? (v as FieldFactoryResult)[KESTREL_FIELD] : (v as FieldDef)
|
|
127
|
+
}
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { FieldDef, FieldOf } from '../../../core/server/utils/defineCollection'
|
|
2
|
+
import { numberIsInteger } from './field-constraints'
|
|
3
|
+
|
|
4
|
+
export interface FieldConstraints {
|
|
5
|
+
required: boolean
|
|
6
|
+
minlength?: number
|
|
7
|
+
maxlength?: number
|
|
8
|
+
min?: number
|
|
9
|
+
max?: number
|
|
10
|
+
step?: number | 'any'
|
|
11
|
+
multiline?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Input-relevant attributes derived from a FieldDef, for the field widgets. */
|
|
15
|
+
export function fieldConstraints(field: FieldDef): FieldConstraints {
|
|
16
|
+
const c: FieldConstraints = { required: !!field.required }
|
|
17
|
+
// `field as FieldOf<'x'>`: the open consumer arm makes `type` a non-discriminant, so `field.type === 'x'`
|
|
18
|
+
// doesn't narrow `field` — the cast applies the arm the guard just proved. (App-side: type-only, no
|
|
19
|
+
// runtime import of the server-side `fieldIs` guard into the client bundle.)
|
|
20
|
+
if (field.type === 'text') {
|
|
21
|
+
const f = field as FieldOf<'text'>
|
|
22
|
+
if (f.options?.minLength !== undefined) c.minlength = f.options.minLength
|
|
23
|
+
if (f.options?.maxLength !== undefined) c.maxlength = f.options.maxLength
|
|
24
|
+
if (f.options?.multiline) c.multiline = true
|
|
25
|
+
} else if (field.type === 'number') {
|
|
26
|
+
const f = field as FieldOf<'number'>
|
|
27
|
+
if (f.options?.min !== undefined) c.min = f.options.min
|
|
28
|
+
if (f.options?.max !== undefined) c.max = f.options.max
|
|
29
|
+
if (f.options?.decimals !== undefined) c.step = 1 / 10 ** f.options.decimals
|
|
30
|
+
else c.step = numberIsInteger(f.options) ? 1 : 'any'
|
|
31
|
+
}
|
|
32
|
+
return c
|
|
33
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { FieldDef, FieldOf } from '../../../core/server/utils/defineCollection'
|
|
2
|
+
import { choiceValues, numberIsInteger } from './field-constraints'
|
|
3
|
+
|
|
4
|
+
function isEmpty(value: unknown): boolean {
|
|
5
|
+
return value === undefined || value === null || value === ''
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Advisory client-side validation for instant feedback. A lightweight mirror of a
|
|
10
|
+
* subset of the server rules — the server Zod schema stays the sole authority.
|
|
11
|
+
*/
|
|
12
|
+
export function validateField(field: FieldDef, value: unknown): string | null {
|
|
13
|
+
if (isEmpty(value)) return field.required ? 'This field is required.' : null
|
|
14
|
+
|
|
15
|
+
switch (field.type) {
|
|
16
|
+
case 'text': {
|
|
17
|
+
if (typeof value !== 'string') return null
|
|
18
|
+
const { minLength, maxLength } = (field as FieldOf<'text'>).options ?? {}
|
|
19
|
+
if (minLength !== undefined && value.length < minLength) return `Must be at least ${minLength} characters.`
|
|
20
|
+
if (maxLength !== undefined && value.length > maxLength) return `Must be at most ${maxLength} characters.`
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
case 'number': {
|
|
24
|
+
if (typeof value !== 'number' || Number.isNaN(value)) return 'Must be a number.'
|
|
25
|
+
const opts = (field as FieldOf<'number'>).options
|
|
26
|
+
const { min, max } = opts ?? {}
|
|
27
|
+
if (numberIsInteger(opts) && !Number.isInteger(value)) return 'Must be a whole number.'
|
|
28
|
+
if (min !== undefined && value < min) return `Must be at least ${min}.`
|
|
29
|
+
if (max !== undefined && value > max) return `Must be at most ${max}.`
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
// The widget model is always already-parsed (Json.vue stores `JSON.parse(raw)`, never raw text), so a
|
|
33
|
+
// json field's value here is never source to re-parse — even when it happens to be a plain string
|
|
34
|
+
// (e.g. `default: 'dark'`). The widget owns raw-text parse errors itself via its own `localError`.
|
|
35
|
+
case 'json':
|
|
36
|
+
return null
|
|
37
|
+
case 'datetime': {
|
|
38
|
+
const dt = (field as FieldOf<'datetime'>).options
|
|
39
|
+
const p = dt?.precision ?? 'datetime'
|
|
40
|
+
const re = p === 'date'
|
|
41
|
+
? /^\d{4}-\d{2}-\d{2}$/
|
|
42
|
+
: p === 'time'
|
|
43
|
+
? /^\d{2}:\d{2}(:\d{2})?$/
|
|
44
|
+
: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/
|
|
45
|
+
if (dt?.range) {
|
|
46
|
+
const v = value && typeof value === 'object' ? (value as { start?: unknown; end?: unknown }) : {}
|
|
47
|
+
const start = typeof v.start === 'string' ? v.start : ''
|
|
48
|
+
const end = typeof v.end === 'string' ? v.end : ''
|
|
49
|
+
// A picker mid-selection (one endpoint chosen, the other still blank) is invalid regardless of
|
|
50
|
+
// `required` — the server only ever accepts a fully-filled range or a fully-empty one.
|
|
51
|
+
if (!start && !end) return field.required ? 'This field is required.' : null
|
|
52
|
+
if (!start || !end) return 'Both start and end are required.'
|
|
53
|
+
if (!re.test(start)) return 'Invalid start.'
|
|
54
|
+
if (!re.test(end)) return 'Invalid end.'
|
|
55
|
+
if (start > end) return 'Start must be before or equal to end.'
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
return typeof value === 'string' && value && !re.test(value) ? 'Invalid date or time.' : null
|
|
59
|
+
}
|
|
60
|
+
case 'choice': {
|
|
61
|
+
const opts = (field as FieldOf<'choice'>).options
|
|
62
|
+
const allowed = new Set(choiceValues(opts))
|
|
63
|
+
if (opts.multiple) {
|
|
64
|
+
const arr = Array.isArray(value) ? value : []
|
|
65
|
+
if (field.required && arr.length === 0) return 'Select at least one option.'
|
|
66
|
+
if (arr.some((v) => !allowed.has(v as string))) return 'Invalid selection.'
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
if (typeof value === 'string' && !allowed.has(value)) return 'Invalid selection.'
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
default:
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Internal links inside richtext are stored as a marker href `kestrel:<collection>:<id>` (the editor
|
|
2
|
+
// sets it via the normal Link mark; sanitize allows the `kestrel:` scheme). At read time the marker is
|
|
3
|
+
// rewritten to the target's real localized path — the richtext counterpart of the `link` field's
|
|
4
|
+
// server populator. Pure string helpers, shared by the server populate pass and the client preview.
|
|
5
|
+
|
|
6
|
+
/** URL scheme that flags an internal richtext link (`kestrel:<collection>:<id>`); shared with sanitize. */
|
|
7
|
+
export const RICHTEXT_LINK_SCHEME = 'kestrel'
|
|
8
|
+
|
|
9
|
+
const MARKER_HREF = new RegExp(`href="${RICHTEXT_LINK_SCHEME}:([a-zA-Z0-9_-]+):(\\d+)"`, 'g')
|
|
10
|
+
const MARKER_VALUE = new RegExp(`^${RICHTEXT_LINK_SCHEME}:([a-zA-Z0-9_-]+):(\\d+)$`)
|
|
11
|
+
|
|
12
|
+
/** The marker href the editor stores for an internal richtext link. */
|
|
13
|
+
export function richtextLinkHref(collection: string, id: number): string {
|
|
14
|
+
return `${RICHTEXT_LINK_SCHEME}:${collection}:${id}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse a bare `kestrel:<collection>:<id>` marker href back to its `{collection, id}` (the inverse of
|
|
19
|
+
* `richtextLinkHref`); returns `null` for an ordinary/external anchor, a malformed marker, or non-string
|
|
20
|
+
* input. Used by the toolbar to prefill the internal-link picker when re-editing an existing link.
|
|
21
|
+
*/
|
|
22
|
+
export function parseRichtextLinkHref(href: string | null | undefined): { collection: string; id: number } | null {
|
|
23
|
+
if (typeof href !== 'string') return null
|
|
24
|
+
const m = href.match(MARKER_VALUE)
|
|
25
|
+
return m ? { collection: m[1]!, id: Number(m[2]) } : null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Every internal-link `{collection, id}` referenced by marker hrefs in the HTML (for batch resolve). */
|
|
29
|
+
export function collectRichtextRefs(html: string | null | undefined): { collection: string; id: number }[] {
|
|
30
|
+
if (!html) return []
|
|
31
|
+
return [...html.matchAll(MARKER_HREF)].map((m) => ({ collection: m[1]!, id: Number(m[2]) }))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const escapeAttr = (s: string): string => s.replace(/&/g, '&').replace(/"/g, '"')
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Rewrite every `kestrel:<collection>:<id>` marker href to whatever the injected resolver returns; a
|
|
38
|
+
* target the resolver declines becomes `#`. The production resolver is status-gated, so that covers a
|
|
39
|
+
* missing target AND a draft one. Ordinary anchors are left untouched. Non-string input → `''`.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveRichtextLinks(
|
|
42
|
+
html: string | null | undefined,
|
|
43
|
+
resolveHref: (collection: string, id: number) => string | null,
|
|
44
|
+
): string {
|
|
45
|
+
if (typeof html !== 'string' || !html) return ''
|
|
46
|
+
return html.replace(MARKER_HREF, (_m, collection: string, idStr: string) =>
|
|
47
|
+
`href="${escapeAttr(resolveHref(collection, Number(idStr)) ?? '#')}"`)
|
|
48
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default defineNuxtConfig({})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Admin-only (default-deny applies to /api/**): the per-block field schemas the editor needs to
|
|
2
|
+
// render block inputs. `?allowed=a,b` restricts to a collection's allowed block types.
|
|
3
|
+
export default defineEventHandler((event) => {
|
|
4
|
+
const q = getQuery(event)
|
|
5
|
+
const allowed = typeof q.allowed === 'string' && q.allowed.length
|
|
6
|
+
? q.allowed.split(',').map((s) => s.trim()).filter(Boolean)
|
|
7
|
+
: undefined
|
|
8
|
+
return { data: allBlocks(allowed).map(serializeBlock) }
|
|
9
|
+
})
|