@michaelthielemann/kestrel 4.1.3 → 5.0.2
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/README.md +26 -201
- package/dist/boot.d.ts +45 -0
- package/dist/boot.js +284 -0
- package/dist/cast.d.ts +3 -0
- package/dist/cast.js +6 -0
- package/dist/catalogue.d.ts +11 -0
- package/dist/catalogue.js +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +55 -0
- package/dist/context.d.ts +62 -0
- package/dist/context.js +61 -0
- package/dist/dataflow.d.ts +3 -0
- package/dist/dataflow.js +65 -0
- package/dist/defineConfig.d.ts +162 -0
- package/dist/defineConfig.js +42 -0
- package/dist/defineContract.d.ts +12 -0
- package/dist/defineContract.js +16 -0
- package/dist/defineModule.d.ts +75 -0
- package/dist/defineModule.js +9 -0
- package/dist/definePipeline.d.ts +7 -0
- package/dist/definePipeline.js +11 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.js +48 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +14 -0
- package/dist/load.d.ts +7 -0
- package/dist/load.js +79 -0
- package/dist/logger.d.ts +18 -0
- package/dist/logger.js +36 -0
- package/dist/registry.d.ts +14 -0
- package/dist/registry.js +87 -0
- package/dist/result.d.ts +19 -0
- package/dist/result.js +19 -0
- package/dist/runner.d.ts +29 -0
- package/dist/runner.js +133 -0
- package/dist/schema.d.ts +7 -0
- package/dist/schema.js +142 -0
- package/dist/sort.d.ts +2 -0
- package/dist/sort.js +50 -0
- package/dist/testing/runPipeline.d.ts +9 -0
- package/dist/testing/runPipeline.js +22 -0
- package/dist/triggers/allowlist.d.ts +9 -0
- package/dist/triggers/allowlist.js +49 -0
- package/dist/triggers/cron.d.ts +10 -0
- package/dist/triggers/cron.js +44 -0
- package/dist/triggers/cronMatch.d.ts +10 -0
- package/dist/triggers/cronMatch.js +68 -0
- package/dist/triggers/http.d.ts +59 -0
- package/dist/triggers/http.js +300 -0
- package/package.json +25 -151
- package/NOTICE +0 -12
- package/kestrel.config.ts +0 -28
- package/layers/access/nuxt.config.ts +0 -5
- package/layers/access/server/middleware/00.ip-allowlist.ts +0 -52
- package/layers/access/server/middleware/access-guard.ts +0 -32
- package/layers/access/server/types.d.ts +0 -7
- package/layers/admin/app/components/AdminAccount.vue +0 -212
- package/layers/admin/app/components/AdminNav.vue +0 -44
- package/layers/admin/app/components/BlockFields.vue +0 -67
- package/layers/admin/app/components/BlockPreview.vue +0 -413
- package/layers/admin/app/components/BlockTree.vue +0 -433
- package/layers/admin/app/components/BlocksBody.vue +0 -163
- package/layers/admin/app/components/CollectionDeleteDialog.vue +0 -38
- package/layers/admin/app/components/CollectionEditor.vue +0 -324
- package/layers/admin/app/components/CollectionList.vue +0 -429
- package/layers/admin/app/components/CollectionListBulkBar.vue +0 -62
- package/layers/admin/app/components/CollectionListColumnsPanel.vue +0 -37
- package/layers/admin/app/components/CollectionListFilterPanel.vue +0 -145
- package/layers/admin/app/components/CollectionListPager.vue +0 -70
- package/layers/admin/app/components/CollectionListTable.vue +0 -347
- package/layers/admin/app/components/EditorStatus.vue +0 -211
- package/layers/admin/app/components/EditorUnsupported.vue +0 -28
- package/layers/admin/app/components/FieldsBody.vue +0 -23
- package/layers/admin/app/components/LocaleBar.vue +0 -107
- package/layers/admin/app/components/PageFields.vue +0 -129
- package/layers/admin/app/components/PageFieldsPane.vue +0 -39
- package/layers/admin/app/components/SeoFields.vue +0 -210
- package/layers/admin/app/components/SingletonEditor.vue +0 -93
- package/layers/admin/app/composables/useAuth.ts +0 -68
- package/layers/admin/app/composables/useBlockTree.ts +0 -95
- package/layers/admin/app/composables/useBlocks.ts +0 -7
- package/layers/admin/app/composables/useCachedResource.ts +0 -35
- package/layers/admin/app/composables/useCollectionOps.ts +0 -102
- package/layers/admin/app/composables/useCollections.ts +0 -6
- package/layers/admin/app/composables/useContentLocales.ts +0 -23
- package/layers/admin/app/composables/useEditForm.ts +0 -402
- package/layers/admin/app/composables/useLinkResolver.ts +0 -40
- package/layers/admin/app/composables/useListBatchActions.ts +0 -78
- package/layers/admin/app/composables/useListColumns.ts +0 -53
- package/layers/admin/app/composables/useListFilterDraft.ts +0 -129
- package/layers/admin/app/composables/useListRows.ts +0 -75
- package/layers/admin/app/composables/useListSelection.ts +0 -28
- package/layers/admin/app/composables/useListUrlState.ts +0 -53
- package/layers/admin/app/composables/usePendingPublishPoll.ts +0 -15
- package/layers/admin/app/composables/usePreviewViewport.ts +0 -30
- package/layers/admin/app/composables/usePublishStatus.ts +0 -107
- package/layers/admin/app/composables/useRailCollapsed.ts +0 -14
- package/layers/admin/app/composables/useTheme.ts +0 -36
- package/layers/admin/app/composables/useToolbarPanel.ts +0 -38
- package/layers/admin/app/composables/useUnsavedGuard.ts +0 -22
- package/layers/admin/app/layouts/admin.vue +0 -266
- package/layers/admin/app/middleware/admin-auth.ts +0 -4
- package/layers/admin/app/pages/admin/[collection]/[id].vue +0 -203
- package/layers/admin/app/pages/admin/[collection]/index.vue +0 -62
- package/layers/admin/app/pages/admin/index.vue +0 -118
- package/layers/admin/app/pages/admin/login.vue +0 -68
- package/layers/admin/app/pages/admin/media.vue +0 -28
- package/layers/admin/app/pages/admin/references.vue +0 -146
- package/layers/admin/app/plugins/admin-style-guard.client.ts +0 -76
- package/layers/admin/app/plugins/reauth.client.ts +0 -20
- package/layers/admin/app/utils/admin-style-guard.ts +0 -20
- package/layers/admin/app/utils/block-tree.ts +0 -214
- package/layers/admin/app/utils/collection-ops.ts +0 -69
- package/layers/admin/app/utils/dead-refs.ts +0 -30
- package/layers/admin/app/utils/edit-form.ts +0 -267
- package/layers/admin/app/utils/editor-expose.ts +0 -36
- package/layers/admin/app/utils/editor-form-context.ts +0 -82
- package/layers/admin/app/utils/editor-registry.ts +0 -20
- package/layers/admin/app/utils/field-keys.ts +0 -11
- package/layers/admin/app/utils/list-cell.ts +0 -46
- package/layers/admin/app/utils/list-columns.ts +0 -142
- package/layers/admin/app/utils/list-query.ts +0 -96
- package/layers/admin/app/utils/nav-active.ts +0 -9
- package/layers/admin/app/utils/populate-blocks.ts +0 -288
- package/layers/admin/app/utils/preview-channel.ts +0 -62
- package/layers/admin/app/utils/preview-viewport.ts +0 -80
- package/layers/admin/app/utils/reauth.ts +0 -67
- package/layers/admin/app/utils/record-title.ts +0 -15
- package/layers/admin/app/utils/register-builtin-editors.ts +0 -9
- package/layers/admin/app/utils/safe-redirect.ts +0 -12
- package/layers/admin/nuxt.config.ts +0 -20
- package/layers/auth/nuxt.config.ts +0 -1
- package/layers/auth/server/plugins/01.register-auth-pipelines.ts +0 -7
- package/layers/collections/nuxt.config.ts +0 -1
- package/layers/collections/server/plugins/01.register.ts +0 -29
- package/layers/collections/server/plugins/02.register-relation-populate.ts +0 -18
- package/layers/core/app/composables/layouts.ts +0 -5
- package/layers/core/app/utils/field-factories.ts +0 -129
- package/layers/core/app/utils/layouts.ts +0 -38
- package/layers/core/modules/auto-discovery/extract-block.ts +0 -179
- package/layers/core/modules/auto-discovery/index.ts +0 -112
- package/layers/core/modules/auto-discovery/package-registry.ts +0 -19
- package/layers/core/modules/auto-discovery/scan.ts +0 -170
- package/layers/core/modules/auto-discovery/virtual.d.ts +0 -20
- package/layers/core/modules/component-namespace/index.ts +0 -44
- package/layers/core/modules/component-namespace/shared.ts +0 -24
- package/layers/core/modules/kestrel/app-shell.ts +0 -55
- package/layers/core/modules/kestrel/auto-imports.ts +0 -44
- package/layers/core/modules/kestrel/index.ts +0 -144
- package/layers/core/modules/plugin-order/index.ts +0 -69
- package/layers/core/modules/plugin-order/plugin-order.ts +0 -273
- package/layers/core/nuxt.config.ts +0 -10
- package/layers/core/server/api/[...path].ts +0 -98
- package/layers/core/server/plugins/00.config.ts +0 -12
- package/layers/core/server/plugins/00.migrate.ts +0 -12
- package/layers/core/server/plugins/01.register-introspection-pipeline.ts +0 -5
- package/layers/core/server/plugins/01.register-openapi-pipeline.ts +0 -5
- package/layers/core/server/plugins/01.register-tooling-pipelines.ts +0 -5
- package/layers/core/server/plugins/02.schema-sync.ts +0 -58
- package/layers/core/server/plugins/04.outbox-worker.ts +0 -31
- package/layers/core/server/plugins/05.reindex-refs.ts +0 -5
- package/layers/core/server/routes/__kestrel/dashboard.get.ts +0 -29
- package/layers/core/server/tasks/db/migrate-module.ts +0 -83
- package/layers/core/server/tasks/db/migrate-revisions.ts +0 -20
- package/layers/core/server/tasks/db/migrate.ts +0 -50
- package/layers/core/server/utils/server-config.ts +0 -81
- package/layers/fields/nuxt.config.ts +0 -1
- package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +0 -7
- package/layers/fields/server/plugins/01.register-field-populate.ts +0 -11
- package/layers/media/app/components/KestrelImg.vue +0 -94
- package/layers/media/app/components/MediaConflictDialog.vue +0 -35
- package/layers/media/app/components/MediaDeleteDialog.vue +0 -52
- package/layers/media/app/components/MediaGrid.vue +0 -113
- package/layers/media/app/components/MediaImage.vue +0 -70
- package/layers/media/app/components/MediaLibrary.vue +0 -456
- package/layers/media/app/components/MediaNewFolderDialog.vue +0 -32
- package/layers/media/app/components/MediaPathBar.vue +0 -163
- package/layers/media/app/components/MediaPicker.vue +0 -19
- package/layers/media/app/components/MediaRenameDialog.vue +0 -32
- package/layers/media/app/components/MediaTable.vue +0 -75
- package/layers/media/app/components/MediaThumb.vue +0 -25
- package/layers/media/app/components/MediaToolbar.vue +0 -90
- package/layers/media/app/components/MediaUploadDialog.vue +0 -87
- package/layers/media/app/components/MediaViewer.vue +0 -137
- package/layers/media/app/components/field/Media.vue +0 -263
- package/layers/media/app/composables/useBlurUp.ts +0 -55
- package/layers/media/app/composables/useMediaClipboard.ts +0 -14
- package/layers/media/app/composables/useMediaContextMenu.ts +0 -56
- package/layers/media/app/composables/useMediaDnd.ts +0 -56
- package/layers/media/app/composables/useMediaLibrary.ts +0 -152
- package/layers/media/app/composables/useMediaOps.ts +0 -50
- package/layers/media/app/composables/useMediaRelocate.ts +0 -39
- package/layers/media/app/composables/useMediaResolver.ts +0 -50
- package/layers/media/app/composables/useMediaUpload.ts +0 -93
- package/layers/media/app/composables/useMediaVariant.ts +0 -34
- package/layers/media/app/plugins/field-media.client.ts +0 -6
- package/layers/media/app/utils/ai-disclosure.ts +0 -19
- package/layers/media/app/utils/dnd.ts +0 -69
- package/layers/media/app/utils/library.ts +0 -81
- package/layers/media/app/utils/ops.ts +0 -60
- package/layers/media/app/utils/picture-model.ts +0 -125
- package/layers/media/app/utils/thumbhash.ts +0 -19
- package/layers/media/app/utils/variant-name.ts +0 -20
- package/layers/media/nuxt.config.ts +0 -7
- package/layers/media/server/middleware/ondemand-variants.ts +0 -38
- package/layers/media/server/plugins/01.register-media-pipelines.ts +0 -7
- package/layers/media/server/plugins/02.register-media.ts +0 -22
- package/layers/media/server/plugins/04.variant-capture.ts +0 -27
- package/layers/media/server/plugins/05.media-cleanup.ts +0 -5
- package/layers/media/server/tasks/media/backfill.ts +0 -17
- package/layers/public/app/app.vue +0 -5
- package/layers/public/app/components/BlockRenderer.vue +0 -56
- package/layers/public/app/components/KestrelLink.vue +0 -26
- package/layers/public/app/components/KestrelPreviewBridge.vue +0 -133
- package/layers/public/app/composables/public-page.ts +0 -24
- package/layers/public/app/error.vue +0 -12
- package/layers/public/app/layouts/default.vue +0 -5
- package/layers/public/app/pages/[...slug].vue +0 -239
- package/layers/public/app/pages/__kestrel/preview.vue +0 -46
- package/layers/public/app/utils/block-edit-context.ts +0 -16
- package/layers/public/app/utils/json-ld.ts +0 -139
- package/layers/public/app/utils/link-href.ts +0 -46
- package/layers/public/app/utils/page-head.ts +0 -84
- package/layers/public/app/utils/page-layout.ts +0 -18
- package/layers/public/app/utils/preview-protocol.ts +0 -117
- package/layers/public/app/utils/public-route.ts +0 -46
- package/layers/public/app/utils/site-head.ts +0 -43
- package/layers/public/modules/deploy-output/index.ts +0 -80
- package/layers/public/modules/prerender-routes/discover.ts +0 -37
- package/layers/public/modules/prerender-routes/index.ts +0 -88
- package/layers/public/modules/prerender-routes/routes.ts +0 -16
- package/layers/public/modules/prune-media/index.ts +0 -48
- package/layers/public/modules/prune-media/prune-media.ts +0 -81
- package/layers/public/nuxt.config.ts +0 -14
- package/layers/public/server/delivery-live/serve.ts +0 -111
- package/layers/public/server/middleware/delivery-live-catchall.ts +0 -7
- package/layers/public/server/pipelines/index.ts +0 -23
- package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +0 -14
- package/layers/public/server/plugins/01.register-public-pipelines.ts +0 -7
- package/layers/public/server/plugins/02.register-links.ts +0 -16
- package/layers/public/server/plugins/03.redirects.ts +0 -55
- package/layers/public/server/plugins/05.plan-publish.ts +0 -5
- package/layers/public/server/plugins/zz.publish.ts +0 -127
- package/layers/public/server/routes/llms-full.txt.get.ts +0 -99
- package/layers/public/server/routes/llms.txt.get.ts +0 -66
- package/layers/public/server/routes/redirects.json.get.ts +0 -58
- package/layers/public/server/routes/robots.txt.get.ts +0 -11
- package/layers/public/server/routes/sitemap.xml.get.ts +0 -79
- package/layers/public/server/tasks/publish/run.ts +0 -32
- package/layers/public/server/utils/publish/render-live.ts +0 -26
- package/layers/ui/app/assets/scss/_base.scss +0 -16
- package/layers/ui/app/assets/scss/_datepicker.scss +0 -82
- package/layers/ui/app/assets/scss/_mixins.scss +0 -50
- package/layers/ui/app/assets/scss/_reset.scss +0 -65
- package/layers/ui/app/assets/scss/_tokens.scss +0 -183
- package/layers/ui/app/assets/scss/main.scss +0 -4
- package/layers/ui/app/components/field/Boolean.vue +0 -30
- package/layers/ui/app/components/field/Choice.vue +0 -73
- package/layers/ui/app/components/field/Datetime.vue +0 -98
- package/layers/ui/app/components/field/DeadRefNote.vue +0 -28
- package/layers/ui/app/components/field/Json.vue +0 -62
- package/layers/ui/app/components/field/Layout.vue +0 -138
- package/layers/ui/app/components/field/Link.vue +0 -112
- package/layers/ui/app/components/field/LinkInternalPicker.vue +0 -95
- package/layers/ui/app/components/field/Number.vue +0 -30
- package/layers/ui/app/components/field/Relation.vue +0 -49
- package/layers/ui/app/components/field/Renderer.vue +0 -30
- package/layers/ui/app/components/field/Repeater.vue +0 -460
- package/layers/ui/app/components/field/Richtext.vue +0 -26
- package/layers/ui/app/components/field/Slug.vue +0 -65
- package/layers/ui/app/components/field/Text.vue +0 -35
- package/layers/ui/app/components/field/Unsupported.vue +0 -34
- package/layers/ui/app/components/ui/Alert.vue +0 -50
- package/layers/ui/app/components/ui/Brand.vue +0 -22
- package/layers/ui/app/components/ui/Button.vue +0 -116
- package/layers/ui/app/components/ui/ButtonGroup.vue +0 -96
- package/layers/ui/app/components/ui/Checkbox.vue +0 -53
- package/layers/ui/app/components/ui/CheckboxGroup.vue +0 -51
- package/layers/ui/app/components/ui/Combobox.vue +0 -345
- package/layers/ui/app/components/ui/DatePicker.vue +0 -92
- package/layers/ui/app/components/ui/DateRangePicker.vue +0 -100
- package/layers/ui/app/components/ui/Dialog.vue +0 -107
- package/layers/ui/app/components/ui/EmptyState.vue +0 -46
- package/layers/ui/app/components/ui/Field.vue +0 -58
- package/layers/ui/app/components/ui/Fieldset.vue +0 -65
- package/layers/ui/app/components/ui/Icon.vue +0 -44
- package/layers/ui/app/components/ui/Menu.vue +0 -54
- package/layers/ui/app/components/ui/NumberInput.vue +0 -175
- package/layers/ui/app/components/ui/Richtext.vue +0 -141
- package/layers/ui/app/components/ui/RichtextToolbar.vue +0 -251
- package/layers/ui/app/components/ui/Select.vue +0 -52
- package/layers/ui/app/components/ui/TextInput.vue +0 -111
- package/layers/ui/app/components/ui/Textarea.vue +0 -39
- package/layers/ui/app/components/ui/TimeInput.vue +0 -18
- package/layers/ui/app/components/ui/Toasts.vue +0 -103
- package/layers/ui/app/components/ui/Tooltip.vue +0 -45
- package/layers/ui/app/composables/useAdminLang.ts +0 -8
- package/layers/ui/app/composables/useDragReorder.ts +0 -41
- package/layers/ui/app/composables/useEchoGuard.ts +0 -28
- package/layers/ui/app/composables/useLinkField.ts +0 -70
- package/layers/ui/app/composables/useRecordOptions.ts +0 -84
- package/layers/ui/app/composables/useRepeater.ts +0 -99
- package/layers/ui/app/composables/useT.ts +0 -32
- package/layers/ui/app/composables/useToast.ts +0 -46
- package/layers/ui/app/i18n/de.ts +0 -432
- package/layers/ui/app/i18n/en.ts +0 -434
- package/layers/ui/app/utils/boolean-field.ts +0 -10
- package/layers/ui/app/utils/field-component.ts +0 -22
- package/layers/ui/app/utils/field-empty.ts +0 -21
- package/layers/ui/app/utils/field-registry.ts +0 -36
- package/layers/ui/app/utils/humanize.ts +0 -14
- package/layers/ui/app/utils/icons.ts +0 -143
- package/layers/ui/app/utils/iso-date.ts +0 -18
- package/layers/ui/app/utils/localized.ts +0 -13
- package/layers/ui/app/utils/record-context.ts +0 -15
- package/layers/ui/app/utils/reorder.ts +0 -8
- package/layers/ui/app/utils/richtext-preserve-class.ts +0 -31
- package/layers/ui/app/utils/useFieldA11y.ts +0 -40
- package/layers/ui/nuxt.config.ts +0 -9
- package/nuxt.config.ts +0 -108
- package/scripts/consumer-template-ci.mjs +0 -160
- package/scripts/copy-create-payload.mjs +0 -49
- package/scripts/coverage-all.mjs +0 -163
- package/scripts/dashboard.mjs +0 -89
- package/scripts/hash-password.mjs +0 -29
- package/scripts/kestrel.mjs +0 -240
- package/scripts/lib/cli.mjs +0 -129
- package/scripts/lib/coverage-model.mjs +0 -51
- package/scripts/lib/password.mjs +0 -19
- package/scripts/lib/scaffold.mjs +0 -205
- package/scripts/port-graph.mjs +0 -350
- package/scripts/typecheck.mjs +0 -62
- package/templates/starter/README.md +0 -65
- package/templates/starter/_env.example +0 -17
- package/templates/starter/_gitignore +0 -26
- package/templates/starter/_package.json +0 -22
- package/templates/starter/app/app.vue +0 -7
- package/templates/starter/app/blocks/Prose.vue +0 -13
- package/templates/starter/app/layouts/default.vue +0 -6
- package/templates/starter/nuxt.config.ts +0 -23
- package/templates/starter/pnpm-workspace.yaml +0 -8
- package/templates/starter/tsconfig.json +0 -3
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import type { H3Event } from 'h3'
|
|
2
|
-
import { clientIp } from '@michaelthielemann/kestrel-auth'
|
|
3
|
-
import { allowlistMode, ipAllowed, parseAllowlist } from '@michaelthielemann/kestrel-access'
|
|
4
|
-
import { deriveOnDemand, variantKeyFromPath, useStorageDriver, mediaRuntimeConfig, useMediaDb } from '@michaelthielemann/kestrel-media'
|
|
5
|
-
import { DEFAULT_IMAGE_POLICY } from '@michaelthielemann/kestrel-core'
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Whether the IP allow-list would deny this request, checked directly rather than relied on by ordering:
|
|
9
|
-
* Nitro sorts middleware by layer scan dir first, and media's precedes access's, so the real
|
|
10
|
-
* access/00.ip-allowlist gate has NOT run yet when this middleware short-circuits with a derived response.
|
|
11
|
-
*/
|
|
12
|
-
export function deniedByAllowlist(event: H3Event): boolean {
|
|
13
|
-
const mode = allowlistMode(process.env.KESTREL_IP_ALLOWLIST_MODE, process.env.KESTREL_IP_ALLOWLIST)
|
|
14
|
-
if (mode !== 'enforce') return false
|
|
15
|
-
return !ipAllowed(clientIp(event), parseAllowlist(process.env.KESTREL_IP_ALLOWLIST))
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// Dev only: a GET for a not-yet-generated variant under the media baseUrl derives it from the original and
|
|
19
|
-
// serves it, so a newly-declared size/format shows in the editor preview without a full publish. On a hit
|
|
20
|
-
// this is a no-op — the static /uploads handler serves the cached file.
|
|
21
|
-
export default defineEventHandler(async (event) => {
|
|
22
|
-
if (!import.meta.dev || event.method !== 'GET') return
|
|
23
|
-
if (deniedByAllowlist(event)) return
|
|
24
|
-
const cfg = mediaRuntimeConfig()
|
|
25
|
-
const key = variantKeyFromPath(event.path, cfg.baseUrl)
|
|
26
|
-
if (key == null) return
|
|
27
|
-
const driver = useStorageDriver()
|
|
28
|
-
if (await driver.exists?.(key)) return
|
|
29
|
-
// Dev is the diagnosis environment, so surface a real derive failure (missing original, sharp reject)
|
|
30
|
-
// instead of silently 404ing — a broken preview <img> with no log violates the fail-loud convention.
|
|
31
|
-
const result = await deriveOnDemand(useMediaDb().db, driver, cfg.imagePolicy ?? DEFAULT_IMAGE_POLICY, key).catch((error) => {
|
|
32
|
-
console.warn(`[kestrel] on-demand variant derive failed for ${key}:`, (error as Error)?.message ?? error)
|
|
33
|
-
return null
|
|
34
|
-
})
|
|
35
|
-
if (!result) return
|
|
36
|
-
setResponseHeader(event, 'content-type', result.mime)
|
|
37
|
-
return result.bytes
|
|
38
|
-
})
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import { registerPipeline } from '@michaelthielemann/kestrel-core'
|
|
2
|
-
import { buildMediaPipelines } from '@michaelthielemann/kestrel-media'
|
|
3
|
-
|
|
4
|
-
// Registration only — nothing resolves a pipeline here; the registry is read on the first request.
|
|
5
|
-
export default defineNitroPlugin(() => {
|
|
6
|
-
for (const def of buildMediaPipelines()) registerPipeline(def)
|
|
7
|
-
})
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { eq, getTableColumns } from 'drizzle-orm'
|
|
2
|
-
import { registerFieldPopulator } from '@michaelthielemann/kestrel-core'
|
|
3
|
-
import {
|
|
4
|
-
media as mediaTable,
|
|
5
|
-
buildMediaFieldPopulator,
|
|
6
|
-
resolveMedia,
|
|
7
|
-
useStorageDriver,
|
|
8
|
-
mediaCollectionEnabled,
|
|
9
|
-
useMediaDb,
|
|
10
|
-
} from '@michaelthielemann/kestrel-media'
|
|
11
|
-
|
|
12
|
-
export default defineNitroPlugin(() => {
|
|
13
|
-
// A consumer who turned the built-in off has no `media` table, so a row that still carries a media id
|
|
14
|
-
// must stay unpopulated rather than blow up the read path.
|
|
15
|
-
if (!mediaCollectionEnabled()) return
|
|
16
|
-
const cols = getTableColumns(mediaTable) as Record<string, never>
|
|
17
|
-
registerFieldPopulator('media', buildMediaFieldPopulator((id, locale) => {
|
|
18
|
-
const driver = useStorageDriver()
|
|
19
|
-
const row = useMediaDb().db.select().from(mediaTable).where(eq(cols.id, id)).get()
|
|
20
|
-
return row ? resolveMedia(row as never, locale, (k) => driver.publicUrl(k)) : null
|
|
21
|
-
}))
|
|
22
|
-
})
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { isRendererContext } from '@michaelthielemann/kestrel-access'
|
|
2
|
-
import { recordVariants, saveDiscoveredVariants, useMediaDb } from '@michaelthielemann/kestrel-media'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Auto-discovery capture. `KestrelImg`/`useMediaVariant` stash their concrete specs on the render's
|
|
6
|
-
* `event.context.kestrelVariants` during SSR. Renders count when they are either a PUBLISH render (the
|
|
7
|
-
* incremental publisher runs it under `runAsRenderer`, see `render-live.ts`) OR a build-time PRERENDER
|
|
8
|
-
* render (the classic `nuxt generate` topology) — so live preview / stray runtime renders never pollute the
|
|
9
|
-
* scan. For a publish, `publishFull` reconciles the drained accumulator once at the end; the generate
|
|
10
|
-
* topology has no such orchestrator, so each prerendered route reconciles inline WITHOUT draining and the
|
|
11
|
-
* growing accumulator converges to the full used set by the last route.
|
|
12
|
-
*
|
|
13
|
-
* Gating uses the unforgeable AsyncLocalStorage signal `isRendererContext()` (the same one
|
|
14
|
-
* `delivery-live`'s catch-all uses), not the spoofable `x-kestrel-publish` header.
|
|
15
|
-
*
|
|
16
|
-
* NOTE: relies on `beforeResponse` firing for the publisher's `localFetch` renders / the prerenderer, and on
|
|
17
|
-
* the app→server `event.context` bridge — verified by a real publish/generate, not the headless unit suite.
|
|
18
|
-
*/
|
|
19
|
-
export default defineNitroPlugin((nitroApp) => {
|
|
20
|
-
nitroApp.hooks.hook('beforeResponse', (event) => {
|
|
21
|
-
const isPrerender = import.meta.prerender === true
|
|
22
|
-
if (!isRendererContext() && !isPrerender) return
|
|
23
|
-
const specs = (event.context as { kestrelVariants?: unknown[] }).kestrelVariants
|
|
24
|
-
if (Array.isArray(specs) && specs.length) recordVariants(specs as never)
|
|
25
|
-
if (isPrerender) saveDiscoveredVariants(useMediaDb().db, { clear: false })
|
|
26
|
-
})
|
|
27
|
-
})
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import { runBackfill, useStorageDriver, mediaRuntimeConfig, useMediaDb } from '@michaelthielemann/kestrel-media'
|
|
2
|
-
import { DEFAULT_IMAGE_POLICY } from '@michaelthielemann/kestrel-core'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Reconcile every media row's derivatives to the active variant registry: generate the missing sizes/formats
|
|
6
|
-
* (from the stored original) and prune the deregistered ones. `{check:true}` is a dry-run (report only).
|
|
7
|
-
* Trigger (like `db:migrate` — no `nuxi task run`): dev `GET /_nitro/tasks/media:backfill`; prod `runTask(
|
|
8
|
-
* 'media:backfill', { payload })` from the admin endpoint (`POST /api/media/backfill`) or a cron scheduledTask.
|
|
9
|
-
*/
|
|
10
|
-
export default defineTask({
|
|
11
|
-
meta: { name: 'media:backfill', description: 'Generate missing image variants + prune deregistered ones; {check:true}=dry-run' },
|
|
12
|
-
async run({ payload }) {
|
|
13
|
-
const policy = mediaRuntimeConfig().imagePolicy ?? DEFAULT_IMAGE_POLICY
|
|
14
|
-
const result = await runBackfill(useMediaDb().db, useStorageDriver(), policy, { check: !!(payload as { check?: boolean })?.check })
|
|
15
|
-
return { result }
|
|
16
|
-
},
|
|
17
|
-
})
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import { computed, inject, resolveDynamicComponent } from 'vue'
|
|
3
|
-
import BlockRenderer from './BlockRenderer.vue'
|
|
4
|
-
import { blockEditKey } from '../utils/block-edit-context'
|
|
5
|
-
|
|
6
|
-
interface BlockNode { id?: string; type: string; props?: Record<string, unknown>; slots?: Record<string, BlockNode[]> }
|
|
7
|
-
const props = defineProps<{ blocks: BlockNode[] }>()
|
|
8
|
-
|
|
9
|
-
// Absent on the public site (plain render); present inside the admin preview (selection-aware wrapper).
|
|
10
|
-
const edit = inject(blockEditKey, null)
|
|
11
|
-
|
|
12
|
-
const pascal = (s: string) => s.replace(/(^|[-_])(\w)/g, (_m, _sep, c: string) => c.toUpperCase())
|
|
13
|
-
|
|
14
|
-
const items = computed(() =>
|
|
15
|
-
props.blocks.map((block, i) => {
|
|
16
|
-
// A block type with no display component is a content condition, not a programming error: this
|
|
17
|
-
// resolver hands back the bare name for it silently, leaving the message below as the one diagnostic.
|
|
18
|
-
const resolved = resolveDynamicComponent('Blocks' + pascal(block.type))
|
|
19
|
-
const component = typeof resolved === 'string' ? null : resolved
|
|
20
|
-
if (!component && import.meta.dev) console.warn(`[kestrel] no display component for block type "${block.type}"`)
|
|
21
|
-
const { $media, ...fields } = (block.props ?? {}) as Record<string, unknown>
|
|
22
|
-
return { key: block.id ?? i, id: block.id, component, fields, media: $media as Record<string, unknown> | undefined, slots: block.slots }
|
|
23
|
-
}),
|
|
24
|
-
)
|
|
25
|
-
</script>
|
|
26
|
-
|
|
27
|
-
<template>
|
|
28
|
-
<template v-for="item in items" :key="item.key">
|
|
29
|
-
<!-- Public render path: no edit context → plain markup, unchanged. -->
|
|
30
|
-
<component :is="item.component" v-if="item.component && !edit" v-bind="item.fields" :media="item.media">
|
|
31
|
-
<!-- Each declared slot's child blocks recurse through the same renderer (props + nested $media). -->
|
|
32
|
-
<template v-for="(slotBlocks, slotName) in item.slots" #[slotName]>
|
|
33
|
-
<BlockRenderer :blocks="slotBlocks" />
|
|
34
|
-
</template>
|
|
35
|
-
</component>
|
|
36
|
-
<!-- Editor preview path: same component, wrapped in a clickable, highlightable marker. `.prevent`
|
|
37
|
-
cancels any anchor navigation from a link-bearing block; `.stop` keeps a nested-block click from
|
|
38
|
-
also selecting its ancestors. The marker is `display: contents` (see KestrelPreviewBridge), so it
|
|
39
|
-
generates no box: the block root stays the parent's real flex/grid item, so flex/grid-item props,
|
|
40
|
-
`gap`, and root-keyed breakpoints render as on the published site. (`>` combinators are unaffected
|
|
41
|
-
either way — the marker element stays in the DOM tree.) -->
|
|
42
|
-
<div
|
|
43
|
-
v-else-if="item.component"
|
|
44
|
-
class="block-edit-marker"
|
|
45
|
-
:class="{ 'block-edit-marker--selected': item.id != null && edit!.selectedId.value === item.id }"
|
|
46
|
-
:data-block-id="item.id"
|
|
47
|
-
@click.prevent.stop="item.id != null && edit!.select(item.id)"
|
|
48
|
-
>
|
|
49
|
-
<component :is="item.component" v-bind="item.fields" :media="item.media">
|
|
50
|
-
<template v-for="(slotBlocks, slotName) in item.slots" #[slotName]>
|
|
51
|
-
<BlockRenderer :blocks="slotBlocks" />
|
|
52
|
-
</template>
|
|
53
|
-
</component>
|
|
54
|
-
</div>
|
|
55
|
-
</template>
|
|
56
|
-
</template>
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import { computed, useSlots } from 'vue'
|
|
3
|
-
import type { LinkValue } from '@michaelthielemann/kestrel-core'
|
|
4
|
-
import { linkToHref, linkLabel } from '../utils/link-href'
|
|
5
|
-
|
|
6
|
-
// Generic renderer for the `link` field type, for consumer projects + blocks. Renders an <a> for any
|
|
7
|
-
// LinkValue; external links open in a new, isolated tab. Renders nothing for a null/empty value.
|
|
8
|
-
const props = defineProps<{ value?: LinkValue | null }>()
|
|
9
|
-
const slots = useSlots()
|
|
10
|
-
const href = computed(() => linkToHref(props.value))
|
|
11
|
-
const isExternal = computed(() => props.value?.type === 'external')
|
|
12
|
-
// An unresolved internal link (draft/missing target) falls back to '#' with no target-derived label —
|
|
13
|
-
// only render it if it still has an accessible name from elsewhere, or it's a tabbable link to nowhere.
|
|
14
|
-
const dead = computed(() => props.value?.type === 'internal' && href.value === '#')
|
|
15
|
-
const named = computed(() => !!linkLabel(props.value) || !!slots.default)
|
|
16
|
-
</script>
|
|
17
|
-
|
|
18
|
-
<template>
|
|
19
|
-
<a
|
|
20
|
-
v-if="value && href && (!dead || named)"
|
|
21
|
-
class="kestrel-link"
|
|
22
|
-
:href="href"
|
|
23
|
-
:target="isExternal ? '_blank' : undefined"
|
|
24
|
-
:rel="isExternal ? 'noopener noreferrer' : undefined"
|
|
25
|
-
><slot>{{ linkLabel(value) }}</slot></a>
|
|
26
|
-
</template>
|
|
@@ -1,133 +0,0 @@
|
|
|
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>
|
|
@@ -1,24 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
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>
|
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import type { LayoutKey } from '#app'
|
|
3
|
-
import type { SiteHead } from '../utils/site-head'
|
|
4
|
-
|
|
5
|
-
// The record decides its own layout, so route-meta resolution is opted out of and this page renders the
|
|
6
|
-
// `<NuxtLayout>` itself. Side effect worth knowing: the layout becomes a CHILD of the page, so it can read
|
|
7
|
-
// `usePublicPageState()` during SSR — as its parent it rendered before the page had written it.
|
|
8
|
-
definePageMeta({ layout: false })
|
|
9
|
-
|
|
10
|
-
interface RenderedPage {
|
|
11
|
-
title?: string
|
|
12
|
-
layout?: string | null
|
|
13
|
-
seo?: {
|
|
14
|
-
title?: string
|
|
15
|
-
description?: string
|
|
16
|
-
noindex?: boolean
|
|
17
|
-
$media?: { image?: { src: string; width: number | null; height: number | null } | null }
|
|
18
|
-
author?: string
|
|
19
|
-
publishedDate?: string
|
|
20
|
-
keywords?: string
|
|
21
|
-
}
|
|
22
|
-
content?: unknown[]
|
|
23
|
-
status?: string
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const route = useRoute()
|
|
27
|
-
// Locale routing is config-driven (KESTREL_LOCALES / KESTREL_PRIMARY_LOCALE → runtimeConfig.public), NOT
|
|
28
|
-
// hardcoded to en/de — otherwise every non-default locale's prerendered pages 404. Mirrors the server's
|
|
29
|
-
// prefix scheme (primary unprefixed, others `/<locale>/…`) via the pure `resolvePublicRoute`.
|
|
30
|
-
const { locales, primary, prefixPrimary } = pickPublicLocales(
|
|
31
|
-
useRuntimeConfig().public as { locales?: unknown; primaryLocale?: unknown; prefixPrimary?: unknown },
|
|
32
|
-
)
|
|
33
|
-
const { locale, path } = resolvePublicRoute(route.path.split('/').filter(Boolean), locales, primary, prefixPrimary)
|
|
34
|
-
|
|
35
|
-
// Resolve across ALL pageLike collections (not just `pages`) via the generic route resolver, so a
|
|
36
|
-
// consumer's own pageLike collection renders to static HTML too. `useRequestFetch` forwards the
|
|
37
|
-
// incoming request's cookies to the SSR sub-fetch, so an authenticated admin's session reaches
|
|
38
|
-
// `/api/route` and a draft renders at its real URL (the live preview); anonymous + the static render
|
|
39
|
-
// stay published-only (the handler enforces it).
|
|
40
|
-
const requestFetch = useRequestFetch()
|
|
41
|
-
const { data: resolved, error: resolveError } = await useAsyncData(`page:${locale}:${path}`, () =>
|
|
42
|
-
requestFetch('/api/route', {
|
|
43
|
-
query: { path, locale },
|
|
44
|
-
}).then((r) => r as {
|
|
45
|
-
collection: string | null
|
|
46
|
-
page: (RenderedPage & Record<string, unknown>) | null
|
|
47
|
-
alternates?: Array<{ locale: string; path: string }>
|
|
48
|
-
ancestors?: Array<{ path: string; title?: string; locale?: string }>
|
|
49
|
-
site?: SiteHead | null
|
|
50
|
-
}),
|
|
51
|
-
)
|
|
52
|
-
// Ticket preview (ADR-0008): `?kestrel-preview-token=…` carries the editor's UNSAVED state, so an external
|
|
53
|
-
// tab can show work in progress without a save and without publishing. The ticket is admin-only and
|
|
54
|
-
// session-bound server-side; an expired/foreign/unknown one reads as null and the saved record renders.
|
|
55
|
-
const previewToken = route.query[PREVIEW_TOKEN_QUERY]
|
|
56
|
-
const { data: ticket } = typeof previewToken === 'string' && previewToken
|
|
57
|
-
? await useAsyncData(`kestrel-preview-ticket:${previewToken}`, () =>
|
|
58
|
-
requestFetch('/api/preview', { query: { token: previewToken } })
|
|
59
|
-
.then((r) => r as { payload?: { values?: Record<string, unknown> } } | null)
|
|
60
|
-
.catch(() => null))
|
|
61
|
-
: { data: ref<{ payload?: { values?: Record<string, unknown> } } | null>(null) }
|
|
62
|
-
const previewValues = computed(() => ticket.value?.payload?.values ?? null)
|
|
63
|
-
const previewingTicket = computed(() => previewValues.value !== null)
|
|
64
|
-
|
|
65
|
-
const page = computed(() => previewPage(resolved.value?.page ?? null, previewValues.value) as (RenderedPage & Record<string, unknown>) | null)
|
|
66
|
-
// `fallback` below only rescues a truthy name that is missing from the layout map, so the empty cases have
|
|
67
|
-
// to be coalesced here — see resolvePageLayout. The cast is the one honest bridge in this file: the stored
|
|
68
|
-
// name is arbitrary editor data, while `NuxtLayout` types `name` as the union of layouts that existed at
|
|
69
|
-
// build time. Narrowing to that union is impossible for a value read from the DB, and `fallback` is exactly
|
|
70
|
-
// the runtime guard for a name outside it.
|
|
71
|
-
const pageLayout = computed(() => resolvePageLayout(page.value?.layout) as LayoutKey)
|
|
72
|
-
|
|
73
|
-
// The layout (language menu & co.) needs the resolved record and its collection; pages and layouts share
|
|
74
|
-
// no other channel, so mirror the fetch result into the shared state — reactively, so client-side
|
|
75
|
-
// navigations update it too.
|
|
76
|
-
const pageState = usePublicPageState()
|
|
77
|
-
watchEffect(() => {
|
|
78
|
-
pageState.value = {
|
|
79
|
-
collection: resolved.value?.collection ?? null,
|
|
80
|
-
page: page.value,
|
|
81
|
-
}
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
// A draft only ever resolves here for an authenticated admin (anonymous + the static render are
|
|
85
|
-
// published-only), so its presence is an unambiguous "you are previewing an unpublished page" signal.
|
|
86
|
-
const isDraftPreview = computed(() => page.value?.status === 'draft')
|
|
87
|
-
|
|
88
|
-
// What this tab is actually showing, when it is not the live page. A ticket outranks the draft notice: the
|
|
89
|
-
// content on screen was never saved at all, which is the stronger caveat.
|
|
90
|
-
const previewNotice = computed(() => {
|
|
91
|
-
if (previewingTicket.value) return 'Preview — unsaved changes, not published'
|
|
92
|
-
return isDraftPreview.value ? 'Draft preview — not published' : ''
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
// Editor live-preview mode: `?kestrel-preview=1` AND an authenticated admin session. The session is
|
|
96
|
-
// checked server-side (cookies forwarded, same seam as the draft fetch above) so SSR and hydration
|
|
97
|
-
// agree on which branch renders; an anonymous visitor with the query param gets the normal page.
|
|
98
|
-
// The bridge component is Lazy — its chunk (message channel + marker chrome) loads only in this mode.
|
|
99
|
-
const previewRequested = route.query[PREVIEW_QUERY] === '1'
|
|
100
|
-
const { data: previewSession } = previewRequested
|
|
101
|
-
? await useAsyncData('kestrel-preview-session', () =>
|
|
102
|
-
requestFetch<{ authenticated: boolean }>('/api/session').catch(() => ({ authenticated: false })))
|
|
103
|
-
: { data: ref<{ authenticated: boolean } | null>(null) }
|
|
104
|
-
const previewActive = computed(() => previewRequested && previewSession.value?.authenticated === true)
|
|
105
|
-
|
|
106
|
-
// `useAsyncData` resolves even when the fetch threw, so the resolver's own failure has to be re-raised here
|
|
107
|
-
// or the root's empty document (a 200 WITH a body) reads as a successful render and the publisher bakes it
|
|
108
|
-
// over the live page. Unconditional on purpose, not just for `/`: on any other path the failure would fall
|
|
109
|
-
// through to the 404 below, which asserts "no such page" from a lookup that never completed — a claim
|
|
110
|
-
// crawlers and caches act on, and one the publisher files as a skip instead of the error the editor shows.
|
|
111
|
-
if (resolveError.value) throw resolveError.value
|
|
112
|
-
|
|
113
|
-
// The site root stays reachable (empty document) before a home page is published;
|
|
114
|
-
// any other unmatched path is a genuine 404.
|
|
115
|
-
if (!page.value && path !== '/') throw createError({ statusCode: 404, statusMessage: 'Page not found' })
|
|
116
|
-
|
|
117
|
-
// Canonical / Open Graph / twitter card / hreflang: pure model (`buildPageHead`) fed from the resolved
|
|
118
|
-
// page + the public runtime config. Absolute-URL emissions (canonical, og:url, hreflang, relative
|
|
119
|
-
// og:image) require a configured siteUrl and degrade away without one.
|
|
120
|
-
const publicRc = useRuntimeConfig().public as { siteUrl?: string; siteName?: string; seoArticleMeta?: boolean }
|
|
121
|
-
const seo = page.value?.seo ?? {}
|
|
122
|
-
const siteHead = resolved.value?.site ?? null
|
|
123
|
-
const fallbacks = siteHeadFallbacks(seo, siteHead)
|
|
124
|
-
// og:title stays the bare page title (og:site_name already carries the site); only <title> is composed.
|
|
125
|
-
const pageTitle = seo.title || page.value?.title
|
|
126
|
-
const documentTitle = composeTitle(pageTitle, siteHead)
|
|
127
|
-
const head = buildPageHead({
|
|
128
|
-
siteUrl: typeof publicRc.siteUrl === 'string' ? publicRc.siteUrl : '',
|
|
129
|
-
siteName: typeof publicRc.siteName === 'string' ? publicRc.siteName : '',
|
|
130
|
-
path,
|
|
131
|
-
locale,
|
|
132
|
-
primary,
|
|
133
|
-
prefixPrimary,
|
|
134
|
-
title: pageTitle,
|
|
135
|
-
description: fallbacks.description,
|
|
136
|
-
image: fallbacks.image,
|
|
137
|
-
alternates: resolved.value?.alternates ?? [],
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
// schema.org JSON-LD — the one grounding signal every major answer engine documents. Same inputs as the
|
|
141
|
-
// head above, so the two can never disagree; it degrades away without a siteUrl and is suppressed for a
|
|
142
|
-
// noindex page or an unsaved ticket preview. Article metadata is published ONLY with `seo.articleMeta`
|
|
143
|
-
// on: the fields may hold values (the column always round-trips them) that this installation must not
|
|
144
|
-
// disclose, so the flag gates emission, not storage.
|
|
145
|
-
const jsonLd = buildJsonLd({
|
|
146
|
-
siteUrl: typeof publicRc.siteUrl === 'string' ? publicRc.siteUrl : '',
|
|
147
|
-
siteName: typeof publicRc.siteName === 'string' ? publicRc.siteName : '',
|
|
148
|
-
canonical: head.canonical,
|
|
149
|
-
locale,
|
|
150
|
-
primary,
|
|
151
|
-
prefixPrimary,
|
|
152
|
-
title: pageTitle,
|
|
153
|
-
description: fallbacks.description,
|
|
154
|
-
imageUrl: head.meta.ogImage,
|
|
155
|
-
noindex: previewingTicket.value || seo.noindex,
|
|
156
|
-
ancestors: resolved.value?.ancestors ?? [],
|
|
157
|
-
article: publicRc.seoArticleMeta === true
|
|
158
|
-
? { author: seo.author, publishedDate: seo.publishedDate, keywords: seo.keywords }
|
|
159
|
-
: null,
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
// Set the document language from the resolved locale so prerendered /de pages ship <html lang="de">
|
|
163
|
-
// (WCAG 2.2 SC 3.1.1); without this every page would carry the build-default language.
|
|
164
|
-
// Point AI agents at the generated llms.txt (alongside the robots.txt comment + sitemap) on every page.
|
|
165
|
-
useHead({
|
|
166
|
-
htmlAttrs: { lang: locale },
|
|
167
|
-
// `textContent` (not innerHTML) is unhead's XSS-safe arm for a data script: it takes the object and
|
|
168
|
-
// serializes it itself, so editor-authored strings can never close the <script>.
|
|
169
|
-
script: jsonLd ? [{ type: 'application/ld+json' as const, textContent: jsonLd }] : [],
|
|
170
|
-
link: [
|
|
171
|
-
// `rel` needs the literal type: unhead keys its link union on it, and inside an array literal that
|
|
172
|
-
// reaches `link:` through a spread there is no contextual type to stop TS widening it to `string`.
|
|
173
|
-
...(head.canonical ? [{ rel: 'canonical' as const, href: head.canonical }] : []),
|
|
174
|
-
...head.links,
|
|
175
|
-
{ rel: 'alternate' as const, type: 'text/markdown', href: '/llms.txt', title: 'llms.txt' },
|
|
176
|
-
],
|
|
177
|
-
})
|
|
178
|
-
useSeoMeta({
|
|
179
|
-
title: documentTitle,
|
|
180
|
-
description: fallbacks.description,
|
|
181
|
-
// A ticket preview is unsaved content at a real URL — never indexable, whatever the record's own SEO says.
|
|
182
|
-
robots: previewingTicket.value || seo.noindex ? 'noindex, nofollow' : undefined,
|
|
183
|
-
ogTitle: head.meta.ogTitle,
|
|
184
|
-
ogDescription: head.meta.ogDescription,
|
|
185
|
-
ogUrl: head.meta.ogUrl,
|
|
186
|
-
ogType: head.meta.ogType,
|
|
187
|
-
ogSiteName: head.meta.ogSiteName,
|
|
188
|
-
ogImage: head.meta.ogImage,
|
|
189
|
-
ogImageWidth: head.meta.ogImageWidth,
|
|
190
|
-
ogImageHeight: head.meta.ogImageHeight,
|
|
191
|
-
twitterCard: head.meta.twitterCard,
|
|
192
|
-
})
|
|
193
|
-
</script>
|
|
194
|
-
|
|
195
|
-
<template>
|
|
196
|
-
<NuxtLayout :name="pageLayout" fallback="default">
|
|
197
|
-
<article>
|
|
198
|
-
<!-- Only ever shown to an authenticated admin previewing an unpublished page (drafts never resolve
|
|
199
|
-
for anonymous visitors or the static render), so it never ships to the public/static site.
|
|
200
|
-
Suppressed inside the editor preview iframe — the editor's own status ampel covers it. -->
|
|
201
|
-
<div v-if="previewNotice && !previewActive" class="kestrel-draft-badge" role="status">
|
|
202
|
-
<span class="kestrel-draft-badge__dot" aria-hidden="true" />
|
|
203
|
-
{{ previewNotice }}
|
|
204
|
-
</div>
|
|
205
|
-
<!-- Editor preview: the bridge swaps in the editor's live (unsaved) tree over postMessage and makes
|
|
206
|
-
blocks selectable; the saved content renders until the first message. Normal path unchanged. -->
|
|
207
|
-
<LazyKestrelPreviewBridge v-if="previewActive" :blocks="(page?.content as any[]) ?? []" v-slot="{ blocks }">
|
|
208
|
-
<KestrelBlockRenderer :blocks="(blocks as any[])" />
|
|
209
|
-
</LazyKestrelPreviewBridge>
|
|
210
|
-
<KestrelBlockRenderer v-else :blocks="(page?.content as any[]) ?? []" />
|
|
211
|
-
</article>
|
|
212
|
-
</NuxtLayout>
|
|
213
|
-
</template>
|
|
214
|
-
|
|
215
|
-
<style scoped>
|
|
216
|
-
/* Self-contained (no token dependency) so it renders identically whatever layout the consumer ships. */
|
|
217
|
-
.kestrel-draft-badge {
|
|
218
|
-
position: fixed;
|
|
219
|
-
inset-block-start: 0.75rem;
|
|
220
|
-
inset-inline-end: 0.75rem;
|
|
221
|
-
z-index: 2147483647;
|
|
222
|
-
display: inline-flex;
|
|
223
|
-
align-items: center;
|
|
224
|
-
gap: 0.4rem;
|
|
225
|
-
padding: 0.35rem 0.7rem;
|
|
226
|
-
border-radius: 999px;
|
|
227
|
-
background: #1e293b;
|
|
228
|
-
color: #fff;
|
|
229
|
-
font: 600 0.78rem/1 ui-sans-serif, system-ui, sans-serif;
|
|
230
|
-
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
|
231
|
-
pointer-events: none;
|
|
232
|
-
}
|
|
233
|
-
.kestrel-draft-badge__dot {
|
|
234
|
-
width: 0.5rem;
|
|
235
|
-
height: 0.5rem;
|
|
236
|
-
border-radius: 50%;
|
|
237
|
-
background: #fbbf24;
|
|
238
|
-
}
|
|
239
|
-
</style>
|