@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,141 @@
|
|
|
1
|
+
import type { SerializedCollection, SerializedField } from '../../../core/server/utils/serialize-collection'
|
|
2
|
+
import { fieldFilterKind, META_FILTER_KIND, type FilterKind } from '../../../core/app/utils/filter-ops'
|
|
3
|
+
import { jsKey } from './field-keys'
|
|
4
|
+
|
|
5
|
+
export type ListColumnType = 'meta' | 'field' | 'translations' | 'deadRefs'
|
|
6
|
+
|
|
7
|
+
export interface ListColumn {
|
|
8
|
+
/** The wire/row key: used to read `row[key]` and as the sort/filter field. The translations column
|
|
9
|
+
* uses the server sidecar key `$translations`, which is never sent as a sort/filter (not a real column). */
|
|
10
|
+
key: string
|
|
11
|
+
type: ListColumnType
|
|
12
|
+
/** Field display name (only for `type:'field'`) — used directly as the column label. */
|
|
13
|
+
name?: string
|
|
14
|
+
/** i18n key for the column label (meta + translations columns). */
|
|
15
|
+
labelKey?: string
|
|
16
|
+
field?: SerializedField
|
|
17
|
+
sortable: boolean
|
|
18
|
+
filterable: boolean
|
|
19
|
+
/** The column's filter kind (present iff `filterable`) — drives the operator set + typed value control. */
|
|
20
|
+
filterKind?: FilterKind
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The sidecar key the server attaches per row for translation status (mirrors `$media`). */
|
|
24
|
+
export const TRANSLATIONS_KEY = '$translations'
|
|
25
|
+
|
|
26
|
+
/** The sidecar key the server attaches per row when any reference it holds points at a dead/unpublished
|
|
27
|
+
* target (the derived stale-reference warning). Like `$translations`, never a real sort/filter column. */
|
|
28
|
+
export const DEAD_REFS_KEY = '$hasDeadRefs'
|
|
29
|
+
|
|
30
|
+
/** Whether a collection holds any field/block that can reference another record — gates the dead-reference
|
|
31
|
+
* column (mirrors the server's `collectionMayReference`, over the serialized schema). */
|
|
32
|
+
export function hasReferenceFields(schema: SerializedCollection): boolean {
|
|
33
|
+
if (schema.blocks.enabled) return true
|
|
34
|
+
if (schema.seo) return true // the `seo` system column carries a media ref (the social image)
|
|
35
|
+
return Object.values(schema.fields).some(fieldMayReference)
|
|
36
|
+
}
|
|
37
|
+
function fieldMayReference(field: SerializedField): boolean {
|
|
38
|
+
switch (field.type) {
|
|
39
|
+
case 'relation':
|
|
40
|
+
case 'media':
|
|
41
|
+
case 'link':
|
|
42
|
+
case 'richtext':
|
|
43
|
+
return true
|
|
44
|
+
case 'repeater':
|
|
45
|
+
return Object.values((field.options?.fields ?? {}) as Record<string, SerializedField>).some(fieldMayReference)
|
|
46
|
+
default:
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const META_LABEL: Record<string, string> = {
|
|
52
|
+
id: 'list.col.id',
|
|
53
|
+
path: 'list.col.slug',
|
|
54
|
+
status: 'list.col.status',
|
|
55
|
+
createdAt: 'list.col.createdAt',
|
|
56
|
+
updatedAt: 'list.col.updatedAt',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Whether a field can be meaningfully SORTED as a single scalar DB column. JSON-backed fields
|
|
60
|
+
* (richtext/json/repeater/link, multi-value choice/relation/media) cannot. (Filterability is a separate,
|
|
61
|
+
* richer question — see `fieldFilterKind`: richtext and the multi-value fields ARE filterable, via
|
|
62
|
+
* substring / JSON-array membership, even though they are not sortable.) */
|
|
63
|
+
function isSortableField(f: SerializedField): boolean {
|
|
64
|
+
switch (f.type) {
|
|
65
|
+
case 'richtext':
|
|
66
|
+
case 'json':
|
|
67
|
+
case 'repeater':
|
|
68
|
+
case 'link':
|
|
69
|
+
return false
|
|
70
|
+
case 'choice':
|
|
71
|
+
return !f.options?.multiple
|
|
72
|
+
case 'relation':
|
|
73
|
+
case 'media':
|
|
74
|
+
return !!f.single
|
|
75
|
+
default:
|
|
76
|
+
return true // text, number, boolean, datetime
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whether a field column is shown by default — only plain scalar display types. Heavier/structured fields
|
|
81
|
+
* (richtext, json, repeater, link, relation, media, multi-choice) are available but off by default. */
|
|
82
|
+
function isDefaultFieldColumn(f: SerializedField): boolean {
|
|
83
|
+
switch (f.type) {
|
|
84
|
+
case 'text':
|
|
85
|
+
case 'number':
|
|
86
|
+
case 'boolean':
|
|
87
|
+
case 'datetime':
|
|
88
|
+
return true
|
|
89
|
+
case 'choice':
|
|
90
|
+
return !f.options?.multiple
|
|
91
|
+
default:
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A meta/system column, filterable iff it has a FilterKind (id/path/status/timestamps all do). */
|
|
97
|
+
function metaColumn(key: string): ListColumn {
|
|
98
|
+
const filterKind = META_FILTER_KIND[key]
|
|
99
|
+
return { key, type: 'meta', labelKey: META_LABEL[key], sortable: true, filterable: !!filterKind, ...(filterKind ? { filterKind } : {}) }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Every column a collection list can offer, in display order: id · fields · slug · status · timestamps ·
|
|
103
|
+
* translations. The user picks which are visible (see `defaultVisibleKeys` / `resolveVisibleColumns`). */
|
|
104
|
+
export function availableColumns(schema: SerializedCollection): ListColumn[] {
|
|
105
|
+
const cols: ListColumn[] = [metaColumn('id')]
|
|
106
|
+
for (const [name, field] of Object.entries(schema.fields)) {
|
|
107
|
+
const filterKind = fieldFilterKind(field)
|
|
108
|
+
cols.push({ key: jsKey(name, field), type: 'field', name, field, sortable: isSortableField(field), filterable: filterKind !== null, ...(filterKind ? { filterKind } : {}) })
|
|
109
|
+
}
|
|
110
|
+
if (schema.pageLike) cols.push(metaColumn('path'))
|
|
111
|
+
if (schema.status) cols.push(metaColumn('status'))
|
|
112
|
+
cols.push(metaColumn('createdAt'))
|
|
113
|
+
cols.push(metaColumn('updatedAt'))
|
|
114
|
+
if (schema.translatable && schema.mode === 'multi') {
|
|
115
|
+
cols.push({ key: TRANSLATIONS_KEY, type: 'translations', labelKey: 'list.translations', sortable: false, filterable: false })
|
|
116
|
+
}
|
|
117
|
+
// A compact warning column (a triangle on rows holding a stale reference) — only where refs are possible.
|
|
118
|
+
if (hasReferenceFields(schema)) {
|
|
119
|
+
cols.push({ key: DEAD_REFS_KEY, type: 'deadRefs', labelKey: 'list.deadRefs', sortable: false, filterable: false })
|
|
120
|
+
}
|
|
121
|
+
return cols
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The default visible set: everything except `id` and the heavier field types — i.e. the scalar fields
|
|
125
|
+
* plus slug, status, timestamps and translations. */
|
|
126
|
+
export function defaultVisibleKeys(schema: SerializedCollection): string[] {
|
|
127
|
+
return availableColumns(schema)
|
|
128
|
+
.filter((c) => {
|
|
129
|
+
if (c.key === 'id') return false
|
|
130
|
+
if (c.type === 'field') return isDefaultFieldColumn(c.field!)
|
|
131
|
+
return true
|
|
132
|
+
})
|
|
133
|
+
.map((c) => c.key)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Project the requested keys onto the available columns: preserves the canonical column order and silently
|
|
137
|
+
* drops keys that no longer exist (e.g. a removed field still listed in a saved preference). */
|
|
138
|
+
export function resolveVisibleColumns(available: ListColumn[], keys: string[]): ListColumn[] {
|
|
139
|
+
const wanted = new Set(keys)
|
|
140
|
+
return available.filter((c) => wanted.has(c.key))
|
|
141
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { FILTER_RE, isFilterOp, opAllowed, type FilterOp } from '../../../core/app/utils/filter-ops'
|
|
2
|
+
import { clampPerPage } from '../../../core/app/utils/list-limits'
|
|
3
|
+
import type { ListColumn } from './list-columns'
|
|
4
|
+
|
|
5
|
+
// The page-size bounds live in ONE shared module (core/app/utils/list-limits) so the client list, the
|
|
6
|
+
// per-page picker and the server crud cap can never drift; `toQuery` clamps through it below.
|
|
7
|
+
|
|
8
|
+
/** One active filter clause in the admin UI: the chosen operator + its (string) value. Kept per-column
|
|
9
|
+
* (`Record<field, FilterCell>`) — the per-column panel shows one clause per column. */
|
|
10
|
+
export interface FilterCell {
|
|
11
|
+
op: FilterOp
|
|
12
|
+
value: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ListState {
|
|
16
|
+
sort: string | null
|
|
17
|
+
page: number
|
|
18
|
+
perPage: number
|
|
19
|
+
filter: Record<string, FilterCell>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** UI state -> the `$fetch` query object the list endpoint expects. An `eq` clause stays the bare
|
|
23
|
+
* `filter[field]` key (so existing URLs round-trip); any other operator serializes as `filter[field][op]`.
|
|
24
|
+
* Empty-valued clauses are dropped. `perPage` is clamped. */
|
|
25
|
+
export function toQuery(state: ListState): Record<string, string | number> {
|
|
26
|
+
const q: Record<string, string | number> = {
|
|
27
|
+
page: Math.max(1, Math.floor(state.page)),
|
|
28
|
+
perPage: clampPerPage(state.perPage),
|
|
29
|
+
}
|
|
30
|
+
if (state.sort) q.sort = state.sort
|
|
31
|
+
for (const [field, cell] of Object.entries(state.filter)) {
|
|
32
|
+
if (cell.value === '' || cell.value == null) continue
|
|
33
|
+
q[cell.op === 'eq' ? `filter[${field}]` : `filter[${field}][${cell.op}]`] = cell.value
|
|
34
|
+
}
|
|
35
|
+
return q
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Pick the first usable scalar from a URL query value. Real `route.query` values are strings (or arrays of
|
|
39
|
+
* strings for a repeated key — we take the first, so `filter[x]=a&filter[x]=b` keeps the FIRST clause); a
|
|
40
|
+
* round-trip through `toQuery` may hand back a number (page/perPage), so coerce that too. Anything else
|
|
41
|
+
* (null / object) yields `undefined`. */
|
|
42
|
+
function firstStr(v: unknown): string | undefined {
|
|
43
|
+
const s = Array.isArray(v) ? v[0] : v
|
|
44
|
+
if (typeof s === 'string') return s
|
|
45
|
+
if (typeof s === 'number') return String(s)
|
|
46
|
+
return undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The pure inverse of `toQuery`: read committed list state back OUT of a URL query. It NEVER throws — every
|
|
50
|
+
* invalid or hostile key is DROPPED so the caller's defaults apply (a junk `page`/`perPage`, an unknown or
|
|
51
|
+
* non-sortable `sort` field, an unknown/unfilterable filter column, or an operator not allowed for that
|
|
52
|
+
* column's kind). A repeated `filter[field]` keeps the FIRST clause (the admin model holds one clause per
|
|
53
|
+
* column). Absent/invalid keys are OMITTED, so `parseListQuery(toQuery(state), columns)` deep-equals the
|
|
54
|
+
* normalized `state`. */
|
|
55
|
+
export function parseListQuery(query: Record<string, unknown>, columns: ListColumn[]): Partial<ListState> {
|
|
56
|
+
const out: Partial<ListState> = {}
|
|
57
|
+
|
|
58
|
+
const p = firstStr(query.page)
|
|
59
|
+
if (p != null) { const n = Number(p); if (Number.isFinite(n) && n >= 1) out.page = Math.floor(n) }
|
|
60
|
+
|
|
61
|
+
const pp = firstStr(query.perPage)
|
|
62
|
+
if (pp) { const n = Number(pp); if (Number.isFinite(n)) out.perPage = clampPerPage(n) } // garbage → omit → cookie
|
|
63
|
+
|
|
64
|
+
const s = firstStr(query.sort)
|
|
65
|
+
if (s) { const base = s.startsWith('-') ? s.slice(1) : s; if (columns.some((c) => c.key === base && c.sortable)) out.sort = s } // unknown/non-sortable → omit → default
|
|
66
|
+
|
|
67
|
+
const filter: Record<string, FilterCell> = {}
|
|
68
|
+
for (const [k, raw] of Object.entries(query)) {
|
|
69
|
+
const m = FILTER_RE.exec(k)
|
|
70
|
+
if (!m) continue
|
|
71
|
+
const field = m[1]!
|
|
72
|
+
if (field in filter) continue // one clause per column: the first key for this field wins
|
|
73
|
+
const op = m[2] ?? 'eq'
|
|
74
|
+
const value = firstStr(raw)
|
|
75
|
+
if (value == null || value === '') continue // empty value → drop
|
|
76
|
+
if (!isFilterOp(op)) continue // unknown operator token → drop
|
|
77
|
+
const col = columns.find((c) => c.key === field && c.filterable)
|
|
78
|
+
if (!col) continue // unknown / unfilterable column → drop
|
|
79
|
+
if (!opAllowed(col.filterKind ?? 'text', op)) continue // op disallowed for the column kind → drop
|
|
80
|
+
filter[field] = { op, value }
|
|
81
|
+
}
|
|
82
|
+
if (Object.keys(filter).length) out.filter = filter
|
|
83
|
+
|
|
84
|
+
return out
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Toggle a column between ascending (`field`) and descending (`-field`); a new field starts ascending. */
|
|
88
|
+
export function toggleSort(current: string | null, field: string): string {
|
|
89
|
+
return current === field ? `-${field}` : field
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function sortDirection(current: string | null, field: string): 'asc' | 'desc' | null {
|
|
93
|
+
if (current === field) return 'asc'
|
|
94
|
+
if (current === `-${field}`) return 'desc'
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a rail nav item linking to `base` (e.g. "/admin/posts") should read as active for the
|
|
3
|
+
* current route `path`. Inclusive of descendant routes so a collection stays lit while editing one
|
|
4
|
+
* of its records (/admin/posts/1) — Vue Router's record-based active match does not, because the
|
|
5
|
+
* list and record pages are flat sibling routes that share no matched record.
|
|
6
|
+
*/
|
|
7
|
+
export function isNavItemActive(path: string, base: string): boolean {
|
|
8
|
+
return path === base || path.startsWith(`${base}/`)
|
|
9
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import type { SerializedBlock, SerializedField } from '../../../core/server/utils/serialize-collection'
|
|
2
|
+
import type { ResolvedMedia } from '../../../media/app/composables/useMediaResolver'
|
|
3
|
+
import { collectRichtextRefs, resolveRichtextLinks } from '../../../fields/app/utils/richtext-links'
|
|
4
|
+
|
|
5
|
+
// Single source of truth for the resolved-media shape lives in the media layer (admin → media).
|
|
6
|
+
export type { ResolvedMedia }
|
|
7
|
+
|
|
8
|
+
interface BlockNode {
|
|
9
|
+
id?: string
|
|
10
|
+
type: string
|
|
11
|
+
props?: Record<string, unknown>
|
|
12
|
+
[k: string]: unknown
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The sub-field schema of a `repeater` SerializedField, or null. Serialized as `options.fields` (a
|
|
16
|
+
* name→SerializedField map, recursively) by `serializeField`. */
|
|
17
|
+
function repeaterFields(field: SerializedField): Record<string, SerializedField> | null {
|
|
18
|
+
if (field.type !== 'repeater') return null
|
|
19
|
+
const sub = (field.options as { fields?: Record<string, SerializedField> } | undefined)?.fields
|
|
20
|
+
return sub && typeof sub === 'object' ? sub : null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A block's `slots` bag from the data (a name→child-array map), or null if it has none. */
|
|
24
|
+
function slotsOf(node: BlockNode): Record<string, unknown> | null {
|
|
25
|
+
const s = (node as { slots?: unknown }).slots
|
|
26
|
+
return s && typeof s === 'object' && !Array.isArray(s) ? (s as Record<string, unknown>) : null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Rebuild a block's `slots` bag, recursing each child array through `recurse`. Identity-preserving: a
|
|
31
|
+
* slot whose populated children are each `===` the originals keeps the original array ref, and when NO
|
|
32
|
+
* slot changed the whole bag is reported unchanged (`null`) — so the live preview never churns block
|
|
33
|
+
* identities on an unaffected subtree. The `b === arr[i]` short-circuit is load-bearing for reactivity.
|
|
34
|
+
*/
|
|
35
|
+
function mapSlots(
|
|
36
|
+
slots: Record<string, unknown> | null,
|
|
37
|
+
recurse: (arr: unknown[]) => BlockNode[],
|
|
38
|
+
): Record<string, unknown> | null {
|
|
39
|
+
if (!slots) return null
|
|
40
|
+
const rebuilt: Record<string, unknown> = {}
|
|
41
|
+
let changed = false
|
|
42
|
+
for (const [name, arr] of Object.entries(slots)) {
|
|
43
|
+
if (!Array.isArray(arr)) {
|
|
44
|
+
rebuilt[name] = arr
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
const populated = recurse(arr)
|
|
48
|
+
const same = populated.length === arr.length && populated.every((b, i) => b === arr[i])
|
|
49
|
+
rebuilt[name] = same ? arr : populated
|
|
50
|
+
if (!same) changed = true
|
|
51
|
+
}
|
|
52
|
+
return changed ? rebuilt : null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface LinkRef { collection: string; id: number }
|
|
56
|
+
|
|
57
|
+
/** The `{collection, id}` of an internal LinkValue, or null for external/email/tel/empty. */
|
|
58
|
+
function internalRefOf(value: unknown): LinkRef | null {
|
|
59
|
+
if (!value || typeof value !== 'object') return null
|
|
60
|
+
const v = value as Record<string, unknown>
|
|
61
|
+
return v.type === 'internal' && typeof v.collection === 'string' && typeof v.id === 'number'
|
|
62
|
+
? { collection: v.collection, id: v.id }
|
|
63
|
+
: null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
// Field-bag recursion — the client mirror of the server field-tree walker
|
|
68
|
+
// (`layers/fields/server/utils/field-populate.ts`). A "bag" is a block's props OR a repeater entry;
|
|
69
|
+
// both key media/link/richtext values BARE (props key-mode). Every collector/populator descends
|
|
70
|
+
// `repeater` sub-fields so a repeater-nested media/link resolves in the preview exactly as it does in
|
|
71
|
+
// the generated page — the two must stay in lock-step.
|
|
72
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/** Media ids referenced by a value-bag (a block's props or a repeater entry), recursing repeater entries. */
|
|
75
|
+
function collectMediaFromBag(fields: Record<string, SerializedField>, bag: Record<string, unknown>, ids: Set<number>): void {
|
|
76
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
77
|
+
if (field.type === 'media') {
|
|
78
|
+
const v = bag[key]
|
|
79
|
+
if (field.options?.multiple) {
|
|
80
|
+
if (Array.isArray(v)) for (const x of v) if (typeof x === 'number') ids.add(x)
|
|
81
|
+
} else if (typeof v === 'number') {
|
|
82
|
+
ids.add(v)
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
const sub = repeaterFields(field)
|
|
86
|
+
const entries = sub ? bag[key] : null
|
|
87
|
+
if (sub && Array.isArray(entries)) {
|
|
88
|
+
for (const e of entries) if (e && typeof e === 'object') collectMediaFromBag(sub, e as Record<string, unknown>, ids)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Every media id referenced by block props (incl. repeater entries), deduped and walked into slot
|
|
95
|
+
* children — for one batched resolve call. */
|
|
96
|
+
export function collectMediaIds(blocks: unknown, byType: Record<string, SerializedBlock>): number[] {
|
|
97
|
+
const ids = new Set<number>()
|
|
98
|
+
const walk = (nodes: unknown): void => {
|
|
99
|
+
if (!Array.isArray(nodes)) return
|
|
100
|
+
for (const node of nodes as BlockNode[]) {
|
|
101
|
+
const def = node?.type ? byType[node.type] : undefined
|
|
102
|
+
if (def && node.props) collectMediaFromBag(def.fields, node.props, ids)
|
|
103
|
+
const slots = node ? slotsOf(node) : null
|
|
104
|
+
if (slots) for (const arr of Object.values(slots)) walk(arr)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
walk(blocks)
|
|
108
|
+
return [...ids]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Populate media in a value-bag: attach a `$media` sibling for its direct media fields and recurse each
|
|
113
|
+
* repeater entry. Identity-preserving — returns the SAME bag ref when nothing resolved and reuses each
|
|
114
|
+
* unchanged repeater array/entry — so the preview doesn't churn identities on every recompute.
|
|
115
|
+
*/
|
|
116
|
+
function populateMediaInBag(
|
|
117
|
+
fields: Record<string, SerializedField>,
|
|
118
|
+
bag: Record<string, unknown>,
|
|
119
|
+
resolve: (id: number) => ResolvedMedia | null,
|
|
120
|
+
): Record<string, unknown> {
|
|
121
|
+
let media: Record<string, unknown> | null = null
|
|
122
|
+
let repeaters: Record<string, unknown> | null = null
|
|
123
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
124
|
+
if (field.type === 'media') {
|
|
125
|
+
const v = bag[key]
|
|
126
|
+
if (field.options?.multiple) {
|
|
127
|
+
if (Array.isArray(v)) {
|
|
128
|
+
const resolved = v.filter((x): x is number => typeof x === 'number').map(resolve).filter((m): m is ResolvedMedia => !!m)
|
|
129
|
+
if (resolved.length) (media ??= {})[key] = resolved
|
|
130
|
+
}
|
|
131
|
+
} else if (typeof v === 'number') {
|
|
132
|
+
const m = resolve(v)
|
|
133
|
+
if (m) (media ??= {})[key] = m
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
const sub = repeaterFields(field)
|
|
137
|
+
const entries = sub ? bag[key] : null
|
|
138
|
+
if (sub && Array.isArray(entries)) {
|
|
139
|
+
const populated = entries.map((e) => (e && typeof e === 'object' ? populateMediaInBag(sub, e as Record<string, unknown>, resolve) : e))
|
|
140
|
+
if (!populated.every((e, i) => e === entries[i])) (repeaters ??= {})[key] = populated
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!media && !repeaters) return bag
|
|
145
|
+
const out = { ...bag }
|
|
146
|
+
if (repeaters) Object.assign(out, repeaters)
|
|
147
|
+
if (media) out.$media = media
|
|
148
|
+
return out
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Client mirror of the server media populator: attach a `$media` bag to each block's props (and each
|
|
153
|
+
* repeater entry) so the public `BlockRenderer` and `Blocks*` display components render images in the
|
|
154
|
+
* live preview. Pure; `resolve` is the (reactive) id→media lookup. Recurses slot children AND repeater
|
|
155
|
+
* entries. A block whose whole subtree needs no media (media-free, unknown type, empty/unchanged slots,
|
|
156
|
+
* or not-yet-resolved ids) is returned **by reference** so unchanged branches keep their refs all the
|
|
157
|
+
* way up.
|
|
158
|
+
*/
|
|
159
|
+
export function populateBlocksMedia(
|
|
160
|
+
blocks: unknown,
|
|
161
|
+
byType: Record<string, SerializedBlock>,
|
|
162
|
+
resolve: (id: number) => ResolvedMedia | null,
|
|
163
|
+
): BlockNode[] {
|
|
164
|
+
if (!Array.isArray(blocks)) return []
|
|
165
|
+
return (blocks as BlockNode[]).map((node) => {
|
|
166
|
+
const def = node?.type ? byType[node.type] : undefined
|
|
167
|
+
const nextProps = def && node.props ? populateMediaInBag(def.fields, node.props, resolve) : node.props
|
|
168
|
+
|
|
169
|
+
// Rebuild slots only where a child subtree actually changed; otherwise reuse the original array
|
|
170
|
+
// ref so an empty or media-free slot doesn't force a new parent object.
|
|
171
|
+
const nextSlots = mapSlots(node ? slotsOf(node) : null, (arr) => populateBlocksMedia(arr, byType, resolve))
|
|
172
|
+
|
|
173
|
+
if (nextProps === node.props && !nextSlots) return node
|
|
174
|
+
const out: BlockNode = { ...node }
|
|
175
|
+
if (nextProps !== node.props) out.props = nextProps
|
|
176
|
+
if (nextSlots) out.slots = nextSlots
|
|
177
|
+
return out
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Internal-link + richtext-marker refs in a value-bag (a block's props or a repeater entry), recursing
|
|
182
|
+
* repeater entries. */
|
|
183
|
+
function collectLinkRefsFromBag(fields: Record<string, SerializedField>, bag: Record<string, unknown>, add: (ref: LinkRef) => void): void {
|
|
184
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
185
|
+
if (field.type === 'link') {
|
|
186
|
+
const ref = internalRefOf(bag[key])
|
|
187
|
+
if (ref) add(ref)
|
|
188
|
+
} else if (field.type === 'richtext') {
|
|
189
|
+
for (const ref of collectRichtextRefs(bag[key] as string | undefined)) add(ref)
|
|
190
|
+
} else {
|
|
191
|
+
const sub = repeaterFields(field)
|
|
192
|
+
const entries = sub ? bag[key] : null
|
|
193
|
+
if (sub && Array.isArray(entries)) {
|
|
194
|
+
for (const e of entries) if (e && typeof e === 'object') collectLinkRefsFromBag(sub, e as Record<string, unknown>, add)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Every internal-link `{collection,id}` referenced by block props (incl. repeater entries + richtext
|
|
201
|
+
* markers), deduped, walked into slots — for one batched resolve call (mirror of `collectMediaIds`). */
|
|
202
|
+
export function collectLinkRefs(blocks: unknown, byType: Record<string, SerializedBlock>): LinkRef[] {
|
|
203
|
+
const seen = new Set<string>()
|
|
204
|
+
const refs: LinkRef[] = []
|
|
205
|
+
const add = (ref: LinkRef): void => {
|
|
206
|
+
const k = `${ref.collection}:${ref.id}`
|
|
207
|
+
if (!seen.has(k)) {
|
|
208
|
+
seen.add(k)
|
|
209
|
+
refs.push(ref)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const walk = (nodes: unknown): void => {
|
|
213
|
+
if (!Array.isArray(nodes)) return
|
|
214
|
+
for (const node of nodes as BlockNode[]) {
|
|
215
|
+
const def = node?.type ? byType[node.type] : undefined
|
|
216
|
+
if (def && node.props) collectLinkRefsFromBag(def.fields, node.props, add)
|
|
217
|
+
const slots = node ? slotsOf(node) : null
|
|
218
|
+
if (slots) for (const arr of Object.values(slots)) walk(arr)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
walk(blocks)
|
|
222
|
+
return refs
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Resolve internal links + richtext markers in a value-bag, recursing repeater entries. Identity-
|
|
227
|
+
* preserving — a link/richtext value that needs no change keeps its ref, an unchanged repeater array/
|
|
228
|
+
* entry keeps its ref, and a bag with nothing to change is returned as-is (lazily cloned only on the
|
|
229
|
+
* first actual change).
|
|
230
|
+
*/
|
|
231
|
+
function populateLinksInBag(
|
|
232
|
+
fields: Record<string, SerializedField>,
|
|
233
|
+
bag: Record<string, unknown>,
|
|
234
|
+
resolveHref: (collection: string, id: number) => string | null,
|
|
235
|
+
): Record<string, unknown> {
|
|
236
|
+
let next: Record<string, unknown> | null = null
|
|
237
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
238
|
+
if (field.type === 'link') {
|
|
239
|
+
const ref = internalRefOf(bag[key])
|
|
240
|
+
if (!ref) continue
|
|
241
|
+
const href = resolveHref(ref.collection, ref.id)
|
|
242
|
+
const current = bag[key] as Record<string, unknown>
|
|
243
|
+
if (href == null || current.href === href) continue
|
|
244
|
+
;(next ??= { ...bag })[key] = { ...current, href }
|
|
245
|
+
} else if (field.type === 'richtext') {
|
|
246
|
+
const current = bag[key]
|
|
247
|
+
if (typeof current !== 'string') continue
|
|
248
|
+
const resolved = resolveRichtextLinks(current, resolveHref)
|
|
249
|
+
if (resolved === current) continue // no markers (or already identical) → keep ref
|
|
250
|
+
;(next ??= { ...bag })[key] = resolved
|
|
251
|
+
} else {
|
|
252
|
+
const sub = repeaterFields(field)
|
|
253
|
+
const entries = sub ? bag[key] : null
|
|
254
|
+
if (sub && Array.isArray(entries)) {
|
|
255
|
+
const populated = entries.map((e) => (e && typeof e === 'object' ? populateLinksInBag(sub, e as Record<string, unknown>, resolveHref) : e))
|
|
256
|
+
if (!populated.every((e, i) => e === entries[i])) (next ??= { ...bag })[key] = populated
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return next ?? bag
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Client mirror of the server link populator: replace each block's internal-link field value with a
|
|
265
|
+
* cloned `{...value, href}` (and rewrite richtext markers) so the live preview's `KestrelLink` renders
|
|
266
|
+
* the real path instead of `'#'`. external/email/tel and unresolved internals are left untouched.
|
|
267
|
+
* Recurses slot children AND repeater entries. Identity-preserving like `populateBlocksMedia` — a block
|
|
268
|
+
* (and its slot/repeater subtree) whose links need no change is returned by reference.
|
|
269
|
+
*/
|
|
270
|
+
export function populateBlocksLinks(
|
|
271
|
+
blocks: unknown,
|
|
272
|
+
byType: Record<string, SerializedBlock>,
|
|
273
|
+
resolveHref: (collection: string, id: number) => string | null,
|
|
274
|
+
): BlockNode[] {
|
|
275
|
+
if (!Array.isArray(blocks)) return []
|
|
276
|
+
return (blocks as BlockNode[]).map((node) => {
|
|
277
|
+
const def = node?.type ? byType[node.type] : undefined
|
|
278
|
+
const nextProps = def && node.props ? populateLinksInBag(def.fields, node.props, resolveHref) : node.props
|
|
279
|
+
|
|
280
|
+
const nextSlots = mapSlots(node ? slotsOf(node) : null, (arr) => populateBlocksLinks(arr, byType, resolveHref))
|
|
281
|
+
|
|
282
|
+
if (nextProps === node.props && !nextSlots) return node
|
|
283
|
+
const out: BlockNode = { ...node }
|
|
284
|
+
if (nextProps !== node.props) out.props = nextProps
|
|
285
|
+
if (nextSlots) out.slots = nextSlots
|
|
286
|
+
return out
|
|
287
|
+
})
|
|
288
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { contentMessage, selectedMessage, type PreviewBlockNode } from '../../../public/app/utils/preview-protocol'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Editor→iframe send half of the live-preview channel (the receive half is the public
|
|
5
|
+
* `KestrelPreviewBridge`). Content updates are coalesced to one post per animation frame — a typing
|
|
6
|
+
* burst produces a single message carrying the LATEST tree (read at fire time), so the preview stays
|
|
7
|
+
* keystroke-live (≤ one frame behind) without flooding the channel. Selection changes are tiny and
|
|
8
|
+
* latency-sensitive → posted immediately.
|
|
9
|
+
*
|
|
10
|
+
* `target` is a getter because the iframe's contentWindow appears on load and is replaced on every
|
|
11
|
+
* navigation/reload; posts silently no-op while it is gone. `origin` is passed as postMessage's
|
|
12
|
+
* targetOrigin so a frame that navigated cross-origin can never receive editor content.
|
|
13
|
+
*/
|
|
14
|
+
export interface PreviewSender {
|
|
15
|
+
sendContent: (blocks: PreviewBlockNode[]) => void
|
|
16
|
+
sendSelected: (selectedId: string | null) => void
|
|
17
|
+
dispose: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createPreviewSender(
|
|
21
|
+
target: () => Window | null | undefined,
|
|
22
|
+
origin: string,
|
|
23
|
+
// Read at FIRE time (like the blocks), never captured at schedule time: an immediate selection post
|
|
24
|
+
// can overtake a pending content frame, and a stale captured id in that frame would then revert the
|
|
25
|
+
// bridge's newer selection (content messages carry the selection authoritatively).
|
|
26
|
+
getSelected: () => string | null,
|
|
27
|
+
): PreviewSender {
|
|
28
|
+
let rafId: number | null = null
|
|
29
|
+
let latest: PreviewBlockNode[] | null = null
|
|
30
|
+
|
|
31
|
+
const post = (msg: unknown) => target()?.postMessage(msg, origin)
|
|
32
|
+
|
|
33
|
+
const fire = () => {
|
|
34
|
+
rafId = null
|
|
35
|
+
if (!latest) return
|
|
36
|
+
// JSON round-trip: detaches Vue reactive proxies (structured clone chokes on some exotic wrappers)
|
|
37
|
+
// and guarantees the payload is the same JSON-safe shape the DB round-trips. Block trees are small;
|
|
38
|
+
// at one clone per frame this is well under a millisecond.
|
|
39
|
+
post(contentMessage(JSON.parse(JSON.stringify(latest)), getSelected()))
|
|
40
|
+
latest = null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
sendContent(blocks) {
|
|
45
|
+
latest = blocks
|
|
46
|
+
rafId ??= requestAnimationFrame(fire)
|
|
47
|
+
},
|
|
48
|
+
sendSelected(selectedId) {
|
|
49
|
+
post(selectedMessage(selectedId))
|
|
50
|
+
},
|
|
51
|
+
dispose() {
|
|
52
|
+
if (rafId !== null) cancelAnimationFrame(rafId)
|
|
53
|
+
rafId = null
|
|
54
|
+
latest = null
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Parent-side trust guard: accept a message only from OUR origin AND our preview iframe's window. */
|
|
60
|
+
export function acceptsFrameEvent(event: MessageEvent, frameWindow: Window | null, origin: string): boolean {
|
|
61
|
+
return frameWindow !== null && event.origin === origin && event.source === frameWindow
|
|
62
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { IconName } from '../../../ui/app/utils/icons'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure geometry for the page-builder preview toolbar (device presets + automatic scale-to-fit + custom
|
|
5
|
+
* W×H). Kept framework-free so it's node-testable — the Vue component (`BlockPreview.vue`) only wires refs
|
|
6
|
+
* to these.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A viewport axis: a fixed CSS px, or `'auto'` (fills the available pane at the current scale). */
|
|
10
|
+
export type Dim = number | 'auto'
|
|
11
|
+
|
|
12
|
+
export interface ViewportPreset {
|
|
13
|
+
key: 'desktop' | 'tablet' | 'mobile'
|
|
14
|
+
/** Toolbar glyph (registry key). */
|
|
15
|
+
icon: IconName
|
|
16
|
+
/** i18n label key (the resolution is shown alongside it in the tooltip). */
|
|
17
|
+
label: string
|
|
18
|
+
/** Reference viewport width/height — the iframe's REAL size (scaled only visually); `'auto'` fills. */
|
|
19
|
+
w: Dim
|
|
20
|
+
h: Dim
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type PresetKey = ViewportPreset['key'] | 'custom'
|
|
24
|
+
|
|
25
|
+
/** Bounds shared by the two numeric W×H inputs. */
|
|
26
|
+
export const DIM_MIN = 320
|
|
27
|
+
export const WIDTH_MAX = 3840
|
|
28
|
+
export const HEIGHT_MAX = 4320
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The three quick-select presets. Desktop keeps the config-driven breakpoint WIDTH (`kestrel.config.ts` →
|
|
32
|
+
* `preview.desktopWidth`, default 1440) but an `'auto'` HEIGHT so the frame fills the pane vertically and
|
|
33
|
+
* only the iframe's own document scrolls. Tablet/mobile are canonical fixed device viewports (iPad
|
|
34
|
+
* portrait / iPhone) so `100vh`/sticky/lazy behave like the real device, scaled down to fit both axes.
|
|
35
|
+
*/
|
|
36
|
+
export function PRESETS(desktopWidth: number): ViewportPreset[] {
|
|
37
|
+
return [
|
|
38
|
+
{ key: 'desktop', icon: 'monitor', label: 'preview.device.desktop', w: desktopWidth, h: 'auto' },
|
|
39
|
+
{ key: 'tablet', icon: 'tablet', label: 'preview.device.tablet', w: 768, h: 1024 },
|
|
40
|
+
{ key: 'mobile', icon: 'smartphone', label: 'preview.device.mobile', w: 390, h: 844 },
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The preset a (w,h) exactly matches, else `'custom'` — drives which quick-select button is highlighted. */
|
|
45
|
+
export function matchPreset(w: Dim, h: Dim, presets: ViewportPreset[]): PresetKey {
|
|
46
|
+
return presets.find((p) => p.w === w && p.h === h)?.key ?? 'custom'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Uniform (no-distortion) scale: fit each FIXED axis into its available space, take the min, and **cap at
|
|
51
|
+
* 1×** (never upscale). An `'auto'` axis doesn't constrain — it fills instead (see `resolveDim`). So
|
|
52
|
+
* desktop (width fixed, height auto) is width-limited; tablet/mobile (both fixed) fit whichever axis is
|
|
53
|
+
* tighter and letterbox the other. Returns 1 before the stage is measured (`avail ≤ 0`) so nothing jumps.
|
|
54
|
+
*/
|
|
55
|
+
export function fitScale(availW: number, availH: number, w: Dim, h: Dim): number {
|
|
56
|
+
const ratios = [1]
|
|
57
|
+
if (typeof w === 'number' && w > 0 && availW > 0) ratios.push(availW / w)
|
|
58
|
+
if (typeof h === 'number' && h > 0 && availH > 0) ratios.push(availH / h)
|
|
59
|
+
return Math.min(...ratios)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve a `Dim` to real px. A fixed value passes straight through; an `'auto'` axis fills the available
|
|
64
|
+
* space at the current scale (`avail / scale`, so the SCALED px exactly equals `avail`). Falls back to
|
|
65
|
+
* `fallback` when unmeasured (SSR/tests: `avail ≤ 0` or `scale ≤ 0`) so the frame still has a size.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveDim(avail: number, dim: Dim, scale: number, fallback: number): number {
|
|
68
|
+
if (dim !== 'auto') return dim
|
|
69
|
+
return avail > 0 && scale > 0 ? Math.round(avail / scale) : fallback
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Commit a numeric-input value: clamp into `[min,max]` and floor to an integer px. Returns `null` for
|
|
74
|
+
* empty/non-finite input so the caller keeps the previous value (the field can hold un-parsable editing
|
|
75
|
+
* text whose model is momentarily null — we must not reset the viewport to a default then).
|
|
76
|
+
*/
|
|
77
|
+
export function clampDim(n: number | null | undefined, min: number, max: number): number | null {
|
|
78
|
+
if (n == null || !Number.isFinite(n)) return null
|
|
79
|
+
return Math.min(max, Math.max(min, Math.floor(n)))
|
|
80
|
+
}
|