@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,133 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, nextTick, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue'
|
|
3
|
+
import { blockEditKey } from '../utils/block-edit-context'
|
|
4
|
+
import {
|
|
5
|
+
parseEditorMessage, readyMessage, selectMessage,
|
|
6
|
+
type PreviewBlockNode,
|
|
7
|
+
} from '../utils/preview-protocol'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The iframe half of the editor live preview. Wraps the page's BlockRenderer via a scoped slot: it
|
|
11
|
+
* exposes the LIVE block tree (the editor's in-memory content, received over postMessage) — falling
|
|
12
|
+
* back to the server-rendered `blocks` prop until the first message — and provides the block-edit
|
|
13
|
+
* context so the renderer emits clickable, highlightable markers. Mounted only in preview mode
|
|
14
|
+
* (`?kestrel-preview=1` + admin, or the dedicated fallback page), and lazy-loaded there, so none of
|
|
15
|
+
* this ships in the normal public bundle.
|
|
16
|
+
*
|
|
17
|
+
* SECURITY: messages are trusted only when `event.origin` is OUR origin AND `event.source` is the
|
|
18
|
+
* embedding parent window — a third-party page that frames the site (or is framed by it) can never
|
|
19
|
+
* inject preview content. Outbound posts target our own origin only.
|
|
20
|
+
*/
|
|
21
|
+
const props = defineProps<{ blocks?: unknown[] }>()
|
|
22
|
+
|
|
23
|
+
// Live tree: null until the editor's first content message, then always the message payload.
|
|
24
|
+
// shallowRef — each message replaces the whole tree; BlockRenderer keys by block id.
|
|
25
|
+
const live = shallowRef<PreviewBlockNode[] | null>(null)
|
|
26
|
+
const blocks = computed<unknown[]>(() => live.value ?? (Array.isArray(props.blocks) ? props.blocks : []))
|
|
27
|
+
|
|
28
|
+
// Selection: local state, synced both ways (click here → editor tree; tree click → highlight here).
|
|
29
|
+
const selectedId = ref<string | null>(null)
|
|
30
|
+
provide(blockEditKey, {
|
|
31
|
+
selectedId,
|
|
32
|
+
select: (id: string) => {
|
|
33
|
+
selectedId.value = id // highlight immediately; the editor echoes the selection back
|
|
34
|
+
post(selectMessage(id))
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
function post(msg: unknown) {
|
|
39
|
+
// Posting to self when opened directly (parent === window) is harmless — our own listener ignores
|
|
40
|
+
// frame→editor types — and keeping it unconditional keeps the handshake testable.
|
|
41
|
+
window.parent.postMessage(msg, window.location.origin)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function onMessage(e: MessageEvent) {
|
|
45
|
+
if (e.origin !== window.location.origin || e.source !== window.parent) return
|
|
46
|
+
const m = parseEditorMessage(e.data)
|
|
47
|
+
if (!m) return
|
|
48
|
+
if (m.kestrel === 'preview:content') {
|
|
49
|
+
const prev = selectedId.value
|
|
50
|
+
live.value = m.blocks
|
|
51
|
+
selectedId.value = m.selectedId
|
|
52
|
+
// A selection made while the frame was still loading arrives with the first content push (not as a
|
|
53
|
+
// separate selected message) — scroll to it like a live selection would.
|
|
54
|
+
if (m.selectedId && m.selectedId !== prev) void scrollToSelected(m.selectedId)
|
|
55
|
+
} else {
|
|
56
|
+
selectedId.value = m.selectedId
|
|
57
|
+
void scrollToSelected(m.selectedId)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Bring an externally-selected block into view (tree-pane click in the editor).
|
|
62
|
+
async function scrollToSelected(id: string | null) {
|
|
63
|
+
if (!id) return
|
|
64
|
+
await nextTick()
|
|
65
|
+
const el = Array.from(document.querySelectorAll('[data-block-id]')).find((n) => n.getAttribute('data-block-id') === id)
|
|
66
|
+
// The marker is `display: contents` (no box of its own), so scroll its rendered child instead.
|
|
67
|
+
;(el?.firstElementChild ?? el)?.scrollIntoView({ block: 'nearest' })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The preview is a canvas, not a browsing context: swallow every link navigation (consumer layout nav,
|
|
71
|
+
// CTA anchors) AND form submission in the CAPTURE phase, so neither native navigation nor Vue Router
|
|
72
|
+
// (which respects defaultPrevented) can steer the iframe away from the previewed page. Block clicks
|
|
73
|
+
// still select — preventDefault does not stop propagation, so the marker handlers keep working.
|
|
74
|
+
// (Programmatic navigation from consumer script can't be intercepted here; the editor host snaps the
|
|
75
|
+
// frame back on load when its location no longer matches the preview URL.)
|
|
76
|
+
function onClickCapture(e: MouseEvent) {
|
|
77
|
+
const a = (e.target as Element | null)?.closest?.('a[href]')
|
|
78
|
+
if (a) e.preventDefault()
|
|
79
|
+
}
|
|
80
|
+
function onSubmitCapture(e: Event) {
|
|
81
|
+
e.preventDefault()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
onMounted(() => {
|
|
85
|
+
window.addEventListener('message', onMessage)
|
|
86
|
+
document.addEventListener('click', onClickCapture, true)
|
|
87
|
+
document.addEventListener('submit', onSubmitCapture, true)
|
|
88
|
+
// Handshake: tell the editor this document (fresh mount OR reload) is ready for the current state.
|
|
89
|
+
post(readyMessage())
|
|
90
|
+
})
|
|
91
|
+
onUnmounted(() => {
|
|
92
|
+
window.removeEventListener('message', onMessage)
|
|
93
|
+
document.removeEventListener('click', onClickCapture, true)
|
|
94
|
+
document.removeEventListener('submit', onSubmitCapture, true)
|
|
95
|
+
})
|
|
96
|
+
</script>
|
|
97
|
+
|
|
98
|
+
<template>
|
|
99
|
+
<slot :blocks="blocks" />
|
|
100
|
+
</template>
|
|
101
|
+
|
|
102
|
+
<style lang="scss">
|
|
103
|
+
// Selection chrome for the marker wrappers BlockRenderer emits when the edit context is present.
|
|
104
|
+
// This document is the CONSUMER's public page (no admin design tokens), so the colors are concrete
|
|
105
|
+
// values with overridable custom properties — a consumer can theme them from their own CSS.
|
|
106
|
+
//
|
|
107
|
+
// The marker itself is `display: contents`: it generates NO box, so the block root stays the parent's
|
|
108
|
+
// real flex/grid ITEM exactly as on the published site — this restores flex/grid-item props, `gap`, and
|
|
109
|
+
// breakpoints keyed to the block root, which a wrapper box would break by displacing the root. (It does
|
|
110
|
+
// NOT change `>` combinators: the marker element is still in the DOM tree, so `parent > block-root`
|
|
111
|
+
// rules match the same as before — unchanged either way.) The highlight chrome therefore lives on the
|
|
112
|
+
// block root via `> *`. Events are unaffected — the marker div is still in the DOM/event path.
|
|
113
|
+
.block-edit-marker {
|
|
114
|
+
display: contents;
|
|
115
|
+
|
|
116
|
+
> * {
|
|
117
|
+
cursor: pointer;
|
|
118
|
+
outline: 2px solid transparent;
|
|
119
|
+
outline-offset: -2px;
|
|
120
|
+
transition: outline-color 120ms ease;
|
|
121
|
+
}
|
|
122
|
+
&:hover > * {
|
|
123
|
+
outline-color: var(--kestrel-preview-hover, rgba(100, 116, 139, 0.55));
|
|
124
|
+
}
|
|
125
|
+
&--selected > * {
|
|
126
|
+
// Selection ring drawn with `outline` (not box-shadow) so a consumer block that carries its own root
|
|
127
|
+
// box-shadow keeps it while selected — a box-shadow ring here would replace it. Slightly thicker than
|
|
128
|
+
// the hover ring for contrast on dark/indigo consumer sites.
|
|
129
|
+
outline-color: var(--kestrel-preview-accent, #4f46e5);
|
|
130
|
+
outline-width: 3px;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
</style>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ComputedRef } from 'vue'
|
|
2
|
+
|
|
3
|
+
export interface PublicPageState {
|
|
4
|
+
collection: string | null
|
|
5
|
+
page: Record<string, unknown> | null
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** The record rendered by the catch-all page, shared with layouts (language menu & co.); the page and a
|
|
9
|
+
* layout share no other channel, so the catch-all mirrors its resolved `{ collection, page }` here. */
|
|
10
|
+
export const usePublicPageState = () =>
|
|
11
|
+
useState<PublicPageState>('public-page', () => ({ collection: null, page: null }))
|
|
12
|
+
|
|
13
|
+
/** Content locale of the current public route, derived from the URL prefix scheme. Reads the locale set
|
|
14
|
+
* from `runtimeConfig.public` via the public-layer `pickPublicLocales` (NOT admin's `useContentLocales`,
|
|
15
|
+
* which would make the public site depend on `layers/admin`). */
|
|
16
|
+
export function usePublicLocale(): ComputedRef<string> {
|
|
17
|
+
const route = useRoute()
|
|
18
|
+
const { locales, primary, prefixPrimary } = pickPublicLocales(
|
|
19
|
+
useRuntimeConfig().public as { locales?: unknown; primaryLocale?: unknown; prefixPrimary?: unknown },
|
|
20
|
+
)
|
|
21
|
+
return computed(
|
|
22
|
+
() => resolvePublicRoute(route.path.split('/').filter(Boolean), locales, primary, prefixPrimary).locale,
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { NuxtError } from '#app'
|
|
3
|
+
defineProps<{ error: NuxtError }>()
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<template>
|
|
7
|
+
<main>
|
|
8
|
+
<h1>{{ error.statusCode }}</h1>
|
|
9
|
+
<p>{{ error.statusCode === 404 ? 'Page not found.' : 'Something went wrong.' }}</p>
|
|
10
|
+
<NuxtLink to="/">Home</NuxtLink>
|
|
11
|
+
</main>
|
|
12
|
+
</template>
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
interface RenderedPage {
|
|
3
|
+
title?: string
|
|
4
|
+
seo?: {
|
|
5
|
+
title?: string
|
|
6
|
+
description?: string
|
|
7
|
+
noindex?: boolean
|
|
8
|
+
$media?: { image?: { src: string; width: number | null; height: number | null } | null }
|
|
9
|
+
}
|
|
10
|
+
content?: unknown[]
|
|
11
|
+
status?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const route = useRoute()
|
|
15
|
+
// Locale routing is config-driven (KESTREL_LOCALES / KESTREL_PRIMARY_LOCALE → runtimeConfig.public), NOT
|
|
16
|
+
// hardcoded to en/de — otherwise every non-default locale's prerendered pages 404. Mirrors the server's
|
|
17
|
+
// prefix scheme (primary unprefixed, others `/<locale>/…`) via the pure `resolvePublicRoute`.
|
|
18
|
+
const { locales, primary, prefixPrimary } = pickPublicLocales(
|
|
19
|
+
useRuntimeConfig().public as { locales?: unknown; primaryLocale?: unknown; prefixPrimary?: unknown },
|
|
20
|
+
)
|
|
21
|
+
const { locale, path } = resolvePublicRoute(route.path.split('/').filter(Boolean), locales, primary, prefixPrimary)
|
|
22
|
+
|
|
23
|
+
// Resolve across ALL pageLike collections (not just `pages`) via the generic route resolver, so a
|
|
24
|
+
// consumer's own pageLike collection renders to static HTML too. `useRequestFetch` forwards the
|
|
25
|
+
// incoming request's cookies to the SSR sub-fetch, so an authenticated admin's session reaches
|
|
26
|
+
// `/api/route` and a draft renders at its real URL (the live preview); anonymous + the static render
|
|
27
|
+
// stay published-only (the handler enforces it).
|
|
28
|
+
const requestFetch = useRequestFetch()
|
|
29
|
+
const { data: resolved } = await useAsyncData(`page:${locale}:${path}`, () =>
|
|
30
|
+
requestFetch('/api/route', {
|
|
31
|
+
query: { path, locale },
|
|
32
|
+
}).then((r) => r as {
|
|
33
|
+
collection: string | null
|
|
34
|
+
page: (RenderedPage & Record<string, unknown>) | null
|
|
35
|
+
alternates?: Array<{ locale: string; path: string }>
|
|
36
|
+
}),
|
|
37
|
+
)
|
|
38
|
+
const page = computed(() => resolved.value?.page ?? null)
|
|
39
|
+
|
|
40
|
+
// The layout (language menu & co.) needs the resolved record and its collection; pages and layouts share
|
|
41
|
+
// no other channel, so mirror the fetch result into the shared state — reactively, so client-side
|
|
42
|
+
// navigations update it too.
|
|
43
|
+
const pageState = usePublicPageState()
|
|
44
|
+
watchEffect(() => {
|
|
45
|
+
pageState.value = {
|
|
46
|
+
collection: resolved.value?.collection ?? null,
|
|
47
|
+
page: resolved.value?.page ?? null,
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// A draft only ever resolves here for an authenticated admin (anonymous + the static render are
|
|
52
|
+
// published-only), so its presence is an unambiguous "you are previewing an unpublished page" signal.
|
|
53
|
+
const isDraftPreview = computed(() => page.value?.status === 'draft')
|
|
54
|
+
|
|
55
|
+
// Editor live-preview mode: `?kestrel-preview=1` AND an authenticated admin session. The session is
|
|
56
|
+
// checked server-side (cookies forwarded, same seam as the draft fetch above) so SSR and hydration
|
|
57
|
+
// agree on which branch renders; an anonymous visitor with the query param gets the normal page.
|
|
58
|
+
// The bridge component is Lazy — its chunk (message channel + marker chrome) loads only in this mode.
|
|
59
|
+
const previewRequested = route.query[PREVIEW_QUERY] === '1'
|
|
60
|
+
const { data: previewSession } = previewRequested
|
|
61
|
+
? await useAsyncData('kestrel-preview-session', () =>
|
|
62
|
+
requestFetch('/api/auth/session').catch(() => ({ authenticated: false })))
|
|
63
|
+
: { data: ref<{ authenticated: boolean } | null>(null) }
|
|
64
|
+
const previewActive = computed(() => previewRequested && previewSession.value?.authenticated === true)
|
|
65
|
+
|
|
66
|
+
// The site root stays reachable (empty document) before a home page is published;
|
|
67
|
+
// any other unmatched path is a genuine 404.
|
|
68
|
+
if (!page.value && path !== '/') throw createError({ statusCode: 404, statusMessage: 'Page not found' })
|
|
69
|
+
|
|
70
|
+
// Canonical / Open Graph / twitter card / hreflang: pure model (`buildPageHead`) fed from the resolved
|
|
71
|
+
// page + the public runtime config. Absolute-URL emissions (canonical, og:url, hreflang, relative
|
|
72
|
+
// og:image) require a configured siteUrl and degrade away without one.
|
|
73
|
+
const publicRc = useRuntimeConfig().public as { siteUrl?: string; siteName?: string }
|
|
74
|
+
const seo = page.value?.seo ?? {}
|
|
75
|
+
const head = buildPageHead({
|
|
76
|
+
siteUrl: typeof publicRc.siteUrl === 'string' ? publicRc.siteUrl : '',
|
|
77
|
+
siteName: typeof publicRc.siteName === 'string' ? publicRc.siteName : '',
|
|
78
|
+
path,
|
|
79
|
+
locale,
|
|
80
|
+
primary,
|
|
81
|
+
prefixPrimary,
|
|
82
|
+
title: seo.title || page.value?.title,
|
|
83
|
+
description: seo.description || undefined,
|
|
84
|
+
image: seo.$media?.image ?? null,
|
|
85
|
+
alternates: resolved.value?.alternates ?? [],
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// Set the document language from the resolved locale so prerendered /de pages ship <html lang="de">
|
|
89
|
+
// (WCAG 2.2 SC 3.1.1); without this every page would carry the build-default language.
|
|
90
|
+
// Point AI agents at the generated llms.txt (alongside the robots.txt comment + sitemap) on every page.
|
|
91
|
+
useHead({
|
|
92
|
+
htmlAttrs: { lang: locale },
|
|
93
|
+
link: [
|
|
94
|
+
...(head.canonical ? [{ rel: 'canonical', href: head.canonical }] : []),
|
|
95
|
+
...head.links,
|
|
96
|
+
{ rel: 'alternate', type: 'text/markdown', href: '/llms.txt', title: 'llms.txt' },
|
|
97
|
+
],
|
|
98
|
+
})
|
|
99
|
+
useSeoMeta({
|
|
100
|
+
title: seo.title || page.value?.title,
|
|
101
|
+
description: seo.description || undefined,
|
|
102
|
+
robots: seo.noindex ? 'noindex, nofollow' : undefined,
|
|
103
|
+
ogTitle: head.meta.ogTitle,
|
|
104
|
+
ogDescription: head.meta.ogDescription,
|
|
105
|
+
ogUrl: head.meta.ogUrl,
|
|
106
|
+
ogType: head.meta.ogType,
|
|
107
|
+
ogSiteName: head.meta.ogSiteName,
|
|
108
|
+
ogImage: head.meta.ogImage,
|
|
109
|
+
ogImageWidth: head.meta.ogImageWidth,
|
|
110
|
+
ogImageHeight: head.meta.ogImageHeight,
|
|
111
|
+
twitterCard: head.meta.twitterCard,
|
|
112
|
+
})
|
|
113
|
+
</script>
|
|
114
|
+
|
|
115
|
+
<template>
|
|
116
|
+
<article>
|
|
117
|
+
<!-- Only ever shown to an authenticated admin previewing an unpublished page (drafts never resolve
|
|
118
|
+
for anonymous visitors or the static render), so it never ships to the public/static site.
|
|
119
|
+
Suppressed inside the editor preview iframe — the editor's own status ampel covers it. -->
|
|
120
|
+
<div v-if="isDraftPreview && !previewActive" class="kestrel-draft-badge" role="status">
|
|
121
|
+
<span class="kestrel-draft-badge__dot" aria-hidden="true" />
|
|
122
|
+
Draft preview — not published
|
|
123
|
+
</div>
|
|
124
|
+
<!-- Editor preview: the bridge swaps in the editor's live (unsaved) tree over postMessage and makes
|
|
125
|
+
blocks selectable; the saved content renders until the first message. Normal path unchanged. -->
|
|
126
|
+
<LazyKestrelPreviewBridge v-if="previewActive" :blocks="(page?.content as any[]) ?? []" v-slot="{ blocks }">
|
|
127
|
+
<BlockRenderer :blocks="(blocks as any[])" />
|
|
128
|
+
</LazyKestrelPreviewBridge>
|
|
129
|
+
<BlockRenderer v-else :blocks="(page?.content as any[]) ?? []" />
|
|
130
|
+
</article>
|
|
131
|
+
</template>
|
|
132
|
+
|
|
133
|
+
<style scoped>
|
|
134
|
+
/* Self-contained (no token dependency) so it renders identically whatever layout the consumer ships. */
|
|
135
|
+
.kestrel-draft-badge {
|
|
136
|
+
position: fixed;
|
|
137
|
+
inset-block-start: 0.75rem;
|
|
138
|
+
inset-inline-end: 0.75rem;
|
|
139
|
+
z-index: 2147483647;
|
|
140
|
+
display: inline-flex;
|
|
141
|
+
align-items: center;
|
|
142
|
+
gap: 0.4rem;
|
|
143
|
+
padding: 0.35rem 0.7rem;
|
|
144
|
+
border-radius: 999px;
|
|
145
|
+
background: #1e293b;
|
|
146
|
+
color: #fff;
|
|
147
|
+
font: 600 0.78rem/1 ui-sans-serif, system-ui, sans-serif;
|
|
148
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
|
149
|
+
pointer-events: none;
|
|
150
|
+
}
|
|
151
|
+
.kestrel-draft-badge__dot {
|
|
152
|
+
width: 0.5rem;
|
|
153
|
+
height: 0.5rem;
|
|
154
|
+
border-radius: 50%;
|
|
155
|
+
background: #fbbf24;
|
|
156
|
+
}
|
|
157
|
+
</style>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Dedicated editor live-preview page for records WITHOUT a public URL — a new/unsaved record, a blank
|
|
4
|
+
* slug, or a blocks-enabled non-pageLike collection. It renders the real public app (default layout,
|
|
5
|
+
* the consumer's CSS/fonts/breakpoints) around an empty BlockRenderer that the editor fills over the
|
|
6
|
+
* postMessage bridge. Saved pageLike records preview at their REAL URL instead (higher fidelity);
|
|
7
|
+
* this page is the graceful fallback so previews never regress to "save first".
|
|
8
|
+
*
|
|
9
|
+
* Admin-gated server-side: `useRequestFetch` forwards the incoming cookies to `/api/auth/session`
|
|
10
|
+
* (the same seam the catch-all uses for draft rendering), and anonymous requests get a 404 — the
|
|
11
|
+
* page's existence is not advertised. It is never linked, prerendered, or published (the publisher
|
|
12
|
+
* enumerates DB page rows only), and carries noindex as belt-and-braces.
|
|
13
|
+
*/
|
|
14
|
+
const requestFetch = useRequestFetch()
|
|
15
|
+
const { data: session } = await useAsyncData('kestrel-preview-session', () =>
|
|
16
|
+
requestFetch('/api/auth/session').catch(() => ({ authenticated: false })),
|
|
17
|
+
)
|
|
18
|
+
if (!session.value?.authenticated) throw createError({ statusCode: 404, statusMessage: 'Page not found' })
|
|
19
|
+
|
|
20
|
+
// Content locale from the editor (drives <html lang> for faithful per-locale rendering).
|
|
21
|
+
const route = useRoute()
|
|
22
|
+
const lang = typeof route.query.locale === 'string' && route.query.locale ? route.query.locale : undefined
|
|
23
|
+
useHead({
|
|
24
|
+
title: 'Preview',
|
|
25
|
+
meta: [{ name: 'robots', content: 'noindex, nofollow' }],
|
|
26
|
+
...(lang ? { htmlAttrs: { lang } } : {}),
|
|
27
|
+
})
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<KestrelPreviewBridge v-slot="{ blocks }">
|
|
32
|
+
<BlockRenderer :blocks="(blocks as any[])" />
|
|
33
|
+
</KestrelPreviewBridge>
|
|
34
|
+
</template>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { InjectionKey, Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
// Optional editing context for the public BlockRenderer. On the generated public site the renderer is
|
|
4
|
+
// used WITHOUT this context (inject defaults to null) → it renders plain markup, byte-identical to
|
|
5
|
+
// before. The admin preview *provides* it, which makes the renderer wrap each block in a clickable
|
|
6
|
+
// marker carrying its id — so clicking a block in the preview selects it and the selected block is
|
|
7
|
+
// highlighted. This keeps the SSG render path untouched while letting the same component double as a
|
|
8
|
+
// selection-aware editor preview.
|
|
9
|
+
export interface BlockEditContext {
|
|
10
|
+
/** The currently selected block id (or null for the page root). */
|
|
11
|
+
selectedId: Ref<string | null>
|
|
12
|
+
/** Select a block by id (from a preview click). */
|
|
13
|
+
select: (id: string) => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const blockEditKey: InjectionKey<BlockEditContext> = Symbol('kestrel.blockEdit')
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { LinkValue } from '../../../core/server/utils/defineCollection'
|
|
2
|
+
|
|
3
|
+
// The server link populator (depth > 0) resolves an internal link's localized target path and attaches
|
|
4
|
+
// it as `href`. Until that populator runs (or for a not-yet-resolved value), internal links fall back
|
|
5
|
+
// to '#'. external / email / tel are self-contained and need no resolution.
|
|
6
|
+
type WithHref<T> = T & { href?: string }
|
|
7
|
+
|
|
8
|
+
/** Turn any `LinkValue` into an `href`. external → the url; email → `mailto:`; tel → `tel:`; internal →
|
|
9
|
+
* the populated `href` (localized target path), or '#' when unresolved. Empty string for no value. */
|
|
10
|
+
export function linkToHref(value: WithHref<LinkValue> | null | undefined): string {
|
|
11
|
+
if (!value) return ''
|
|
12
|
+
switch (value.type) {
|
|
13
|
+
case 'external':
|
|
14
|
+
return value.url
|
|
15
|
+
case 'email':
|
|
16
|
+
return `mailto:${value.email}`
|
|
17
|
+
case 'tel':
|
|
18
|
+
return `tel:${value.tel}`
|
|
19
|
+
case 'internal': {
|
|
20
|
+
const base = value.href ?? '#'
|
|
21
|
+
// An unresolved internal (draft/missing target) stays '#'; don't fabricate a fragment-only link
|
|
22
|
+
// out of its hash. A resolved path gets `#<hash>` appended when present.
|
|
23
|
+
return base !== '#' && value.hash ? `${base}#${value.hash}` : base
|
|
24
|
+
}
|
|
25
|
+
default:
|
|
26
|
+
return ''
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A human label for a link: its explicit `label`, else a scheme-appropriate default (the target). */
|
|
31
|
+
export function linkLabel(value: WithHref<LinkValue> | null | undefined): string {
|
|
32
|
+
if (!value) return ''
|
|
33
|
+
if (value.label) return value.label
|
|
34
|
+
switch (value.type) {
|
|
35
|
+
case 'external':
|
|
36
|
+
return value.url
|
|
37
|
+
case 'email':
|
|
38
|
+
return value.email
|
|
39
|
+
case 'tel':
|
|
40
|
+
return value.tel
|
|
41
|
+
case 'internal':
|
|
42
|
+
return value.href ?? ''
|
|
43
|
+
default:
|
|
44
|
+
return ''
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { localePath } from '../../../core/app/utils/locale-path'
|
|
2
|
+
|
|
3
|
+
export interface PageHeadImage { src: string; width?: number | null; height?: number | null }
|
|
4
|
+
export interface PageHeadAlternate { locale: string; path: string }
|
|
5
|
+
|
|
6
|
+
export interface PageHeadInput {
|
|
7
|
+
/** Absolute site origin ('' when unconfigured — canonical/hreflang/og:url are omitted then). */
|
|
8
|
+
siteUrl: string
|
|
9
|
+
siteName?: string
|
|
10
|
+
/** The page's unprefixed path + its locale (the emitter locale-prefixes via `localePath`). */
|
|
11
|
+
path: string
|
|
12
|
+
locale: string
|
|
13
|
+
primary: string
|
|
14
|
+
prefixPrimary: boolean
|
|
15
|
+
title?: string
|
|
16
|
+
description?: string
|
|
17
|
+
image?: PageHeadImage | null
|
|
18
|
+
/** Published translation siblings (self included); [] / omitted → no hreflang set. */
|
|
19
|
+
alternates?: PageHeadAlternate[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A type alias, not an interface: unhead's `link` entries require an implicit `data-*` index signature,
|
|
23
|
+
* which TS derives for aliases but never for interfaces. */
|
|
24
|
+
export type PageHeadLink = { rel: 'alternate'; hreflang: string; href: string }
|
|
25
|
+
|
|
26
|
+
export interface PageHead {
|
|
27
|
+
canonical?: string
|
|
28
|
+
links: PageHeadLink[]
|
|
29
|
+
meta: {
|
|
30
|
+
ogTitle?: string
|
|
31
|
+
ogDescription?: string
|
|
32
|
+
ogUrl?: string
|
|
33
|
+
ogType: 'website'
|
|
34
|
+
ogSiteName?: string
|
|
35
|
+
ogImage?: string
|
|
36
|
+
ogImageWidth?: number
|
|
37
|
+
ogImageHeight?: number
|
|
38
|
+
twitterCard: 'summary' | 'summary_large_image'
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const isAbsolute = (src: string) => /^https?:\/\//.test(src)
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The head model for a rendered public page: canonical, Open Graph / twitter card, and the hreflang
|
|
46
|
+
* alternate set — pure, so the emission rules are unit-testable outside a Nuxt context. Everything that
|
|
47
|
+
* requires an absolute URL (canonical, og:url, hreflang, a relative og:image) degrades away when
|
|
48
|
+
* `siteUrl` is unconfigured; `x-default` points at the primary-locale variant when it is present
|
|
49
|
+
* (mirrors the sitemap's rules, so page heads and sitemap never disagree).
|
|
50
|
+
*/
|
|
51
|
+
export function buildPageHead(input: PageHeadInput): PageHead {
|
|
52
|
+
const base = input.siteUrl ? input.siteUrl.replace(/\/+$/, '') : ''
|
|
53
|
+
const abs = (path: string, locale: string) => `${base}${localePath(path, locale, input.primary, input.prefixPrimary)}`
|
|
54
|
+
const canonical = base ? abs(input.path, input.locale) : undefined
|
|
55
|
+
|
|
56
|
+
const links: PageHeadLink[] = []
|
|
57
|
+
if (base && input.alternates?.length) {
|
|
58
|
+
for (const a of input.alternates) links.push({ rel: 'alternate', hreflang: a.locale, href: abs(a.path, a.locale) })
|
|
59
|
+
const primary = input.alternates.find((a) => a.locale === input.primary)
|
|
60
|
+
if (primary) links.push({ rel: 'alternate', hreflang: 'x-default', href: abs(primary.path, primary.locale) })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let ogImage: string | undefined
|
|
64
|
+
if (input.image?.src) {
|
|
65
|
+
if (isAbsolute(input.image.src)) ogImage = input.image.src
|
|
66
|
+
else if (base) ogImage = `${base}${input.image.src}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
canonical,
|
|
71
|
+
links,
|
|
72
|
+
meta: {
|
|
73
|
+
ogTitle: input.title || undefined,
|
|
74
|
+
ogDescription: input.description || undefined,
|
|
75
|
+
ogUrl: canonical,
|
|
76
|
+
ogType: 'website',
|
|
77
|
+
ogSiteName: input.siteName || undefined,
|
|
78
|
+
ogImage,
|
|
79
|
+
ogImageWidth: ogImage && input.image?.width ? input.image.width : undefined,
|
|
80
|
+
ogImageHeight: ogImage && input.image?.height ? input.image.height : undefined,
|
|
81
|
+
twitterCard: ogImage ? 'summary_large_image' : 'summary',
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The postMessage protocol between the admin block editor (parent window) and the live-preview iframe
|
|
3
|
+
* (the real public page in preview mode). Pure — shared by both sides and unit-tested here; the DOM
|
|
4
|
+
* wiring lives in the editor's preview host and the public `KestrelPreviewBridge`.
|
|
5
|
+
*
|
|
6
|
+
* Every message carries a `kestrel: 'preview:*'` discriminant so foreign postMessage traffic (HMR,
|
|
7
|
+
* browser extensions) can never be mistaken for ours. Parsing is direction-specific: the frame only
|
|
8
|
+
* accepts editor→frame types and vice versa — a reflected/echoed message parses to `null`.
|
|
9
|
+
* SECURITY: parsing validates SHAPE only. Both sides must additionally check `event.origin` against
|
|
10
|
+
* their own origin and `event.source` against the expected window before trusting a message —
|
|
11
|
+
* otherwise any page that embeds the site (or is embedded by it) could inject content.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Query flag that switches the public page into preview mode (value `1`). */
|
|
15
|
+
export const PREVIEW_QUERY = 'kestrel-preview'
|
|
16
|
+
/** Dedicated preview page for records without a public URL (new/unsaved, non-pageLike). Admin-gated. */
|
|
17
|
+
export const PREVIEW_FALLBACK_PATH = '/__kestrel/preview'
|
|
18
|
+
|
|
19
|
+
/** The block-node shape the renderer consumes (already media/link-populated by the editor). */
|
|
20
|
+
export interface PreviewBlockNode { id?: string; type: string; props?: Record<string, unknown>; slots?: Record<string, unknown> }
|
|
21
|
+
|
|
22
|
+
/** editor → frame: the live (populated) block tree + the current selection. */
|
|
23
|
+
export interface PreviewContentMessage { kestrel: 'preview:content'; blocks: PreviewBlockNode[]; selectedId: string | null }
|
|
24
|
+
/** editor → frame: selection changed (tree click) — highlight + scroll into view. */
|
|
25
|
+
export interface PreviewSelectedMessage { kestrel: 'preview:selected'; selectedId: string | null }
|
|
26
|
+
/** frame → editor: the bridge mounted (or the iframe reloaded) — (re)send the full state. */
|
|
27
|
+
export interface PreviewReadyMessage { kestrel: 'preview:ready' }
|
|
28
|
+
/** frame → editor: a block was clicked in the preview. */
|
|
29
|
+
export interface PreviewSelectMessage { kestrel: 'preview:select'; id: string }
|
|
30
|
+
|
|
31
|
+
export type EditorToFrameMessage = PreviewContentMessage | PreviewSelectedMessage
|
|
32
|
+
export type FrameToEditorMessage = PreviewReadyMessage | PreviewSelectMessage
|
|
33
|
+
|
|
34
|
+
export const contentMessage = (blocks: PreviewBlockNode[], selectedId: string | null): PreviewContentMessage =>
|
|
35
|
+
({ kestrel: 'preview:content', blocks, selectedId })
|
|
36
|
+
export const selectedMessage = (selectedId: string | null): PreviewSelectedMessage =>
|
|
37
|
+
({ kestrel: 'preview:selected', selectedId })
|
|
38
|
+
export const readyMessage = (): PreviewReadyMessage => ({ kestrel: 'preview:ready' })
|
|
39
|
+
export const selectMessage = (id: string): PreviewSelectMessage => ({ kestrel: 'preview:select', id })
|
|
40
|
+
|
|
41
|
+
function tagOf(data: unknown): string | null {
|
|
42
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) return null
|
|
43
|
+
const tag = (data as { kestrel?: unknown }).kestrel
|
|
44
|
+
return typeof tag === 'string' ? tag : null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Parse a message the FRAME received (editor → frame); `null` for anything else. */
|
|
48
|
+
export function parseEditorMessage(data: unknown): EditorToFrameMessage | null {
|
|
49
|
+
const tag = tagOf(data)
|
|
50
|
+
if (tag === 'preview:content') {
|
|
51
|
+
const m = data as PreviewContentMessage
|
|
52
|
+
return Array.isArray(m.blocks) && (m.selectedId === null || typeof m.selectedId === 'string') ? m : null
|
|
53
|
+
}
|
|
54
|
+
if (tag === 'preview:selected') {
|
|
55
|
+
const m = data as PreviewSelectedMessage
|
|
56
|
+
return m.selectedId === null || typeof m.selectedId === 'string' ? m : null
|
|
57
|
+
}
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Parse a message the EDITOR received (frame → editor); `null` for anything else. */
|
|
62
|
+
export function parseFrameMessage(data: unknown): FrameToEditorMessage | null {
|
|
63
|
+
const tag = tagOf(data)
|
|
64
|
+
if (tag === 'preview:ready') return data as PreviewReadyMessage
|
|
65
|
+
if (tag === 'preview:select') {
|
|
66
|
+
const m = data as PreviewSelectMessage
|
|
67
|
+
return typeof m.id === 'string' ? m : null
|
|
68
|
+
}
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The iframe URL for the editor's preview pane: the record's real public URL when it has one (saved
|
|
74
|
+
* pageLike record — server-populated first paint, drafts included for the admin session), else the
|
|
75
|
+
* dedicated fallback page (new/unsaved records, non-pageLike collections). Both carry the preview flag.
|
|
76
|
+
*/
|
|
77
|
+
export function previewSrc(publicUrl: string | null, locale: string): string {
|
|
78
|
+
if (publicUrl) return `${publicUrl}?${PREVIEW_QUERY}=1`
|
|
79
|
+
const loc = locale ? `&locale=${encodeURIComponent(locale)}` : ''
|
|
80
|
+
return `${PREVIEW_FALLBACK_PATH}?${PREVIEW_QUERY}=1${loc}`
|
|
81
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the request locale + the lookup path from the URL path segments, matching the server routing
|
|
3
|
+
* (`localePath`): every non-primary configured locale is prefixed `/<locale>/…`; the primary locale is
|
|
4
|
+
* unprefixed unless `prefixPrimary` is set (then `/<primary>/…` too). A leading segment counts as a locale
|
|
5
|
+
* prefix when it is a configured locale (the primary only when `prefixPrimary`); anything else is part of
|
|
6
|
+
* the path under the primary locale. Pure (no runtime-config access) so it is unit-testable and not
|
|
7
|
+
* en/de-hardcoded.
|
|
8
|
+
*/
|
|
9
|
+
/** Config shape read off `runtimeConfig.public` (a subset). */
|
|
10
|
+
export interface PublicLocaleConfig {
|
|
11
|
+
locales?: unknown
|
|
12
|
+
primaryLocale?: unknown
|
|
13
|
+
prefixPrimary?: unknown
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the configured content locales from a `runtimeConfig.public`-shaped object. Pure, so the
|
|
18
|
+
* fallback/primary logic is unit-testable without a Nuxt context, and lives in the PUBLIC layer so
|
|
19
|
+
* public code (the catch-all page + `usePublicLocale`) never has to reach into `layers/admin` for it.
|
|
20
|
+
* The `['en']` fallback is a safe last resort for an absent/empty config (nuxt.config always sets
|
|
21
|
+
* `public.locales`); primary-only is a safer guess than assuming a second locale exists.
|
|
22
|
+
*/
|
|
23
|
+
export function pickPublicLocales(pub: PublicLocaleConfig): {
|
|
24
|
+
locales: string[]
|
|
25
|
+
primary: string
|
|
26
|
+
prefixPrimary: boolean
|
|
27
|
+
} {
|
|
28
|
+
const locales = Array.isArray(pub.locales) && pub.locales.length ? pub.locales.map(String) : ['en']
|
|
29
|
+
const primary =
|
|
30
|
+
typeof pub.primaryLocale === 'string' && locales.includes(pub.primaryLocale) ? pub.primaryLocale : locales[0]!
|
|
31
|
+
return { locales, primary, prefixPrimary: pub.prefixPrimary === true }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolvePublicRoute(
|
|
35
|
+
segments: string[],
|
|
36
|
+
locales: string[],
|
|
37
|
+
primaryLocale: string,
|
|
38
|
+
prefixPrimary = false,
|
|
39
|
+
): { locale: string; path: string } {
|
|
40
|
+
const head = segments[0]?.toLowerCase()
|
|
41
|
+
const prefixed = head !== undefined && locales.includes(head) && (head !== primaryLocale || prefixPrimary)
|
|
42
|
+
const locale = prefixed ? head : primaryLocale
|
|
43
|
+
const rest = prefixed ? segments.slice(1) : segments
|
|
44
|
+
const path = ('/' + rest.join('/')).toLowerCase().replace(/\/+$/, '') || '/'
|
|
45
|
+
return { locale, path }
|
|
46
|
+
}
|