@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,46 +0,0 @@
|
|
|
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 — or, when opened in a separate tab with `?kestrel-preview-token=…`, around the
|
|
7
|
-
* ticket's unsaved content (ADR-0008), since a tab with no parent window has no bridge to listen to.
|
|
8
|
-
* Saved pageLike records preview at their REAL URL instead (higher fidelity); this page is the graceful
|
|
9
|
-
* fallback so previews never regress to "save first".
|
|
10
|
-
*
|
|
11
|
-
* Admin-gated server-side: `useRequestFetch` forwards the incoming cookies to `/api/session`
|
|
12
|
-
* (the same seam the catch-all uses for draft rendering), and anonymous requests get a 404 — the
|
|
13
|
-
* page's existence is not advertised. It is never linked, prerendered, or published (the publisher
|
|
14
|
-
* enumerates DB page rows only), and carries noindex as belt-and-braces.
|
|
15
|
-
*/
|
|
16
|
-
const requestFetch = useRequestFetch()
|
|
17
|
-
const { data: session } = await useAsyncData('kestrel-preview-session', () =>
|
|
18
|
-
requestFetch<{ authenticated: boolean }>('/api/session').catch(() => ({ authenticated: false })),
|
|
19
|
-
)
|
|
20
|
-
if (!session.value?.authenticated) throw createError({ statusCode: 404, statusMessage: 'Page not found' })
|
|
21
|
-
|
|
22
|
-
// Content locale from the editor (drives <html lang> for faithful per-locale rendering).
|
|
23
|
-
const route = useRoute()
|
|
24
|
-
const lang = typeof route.query.locale === 'string' && route.query.locale ? route.query.locale : undefined
|
|
25
|
-
|
|
26
|
-
// Ticket content for the external-tab case; null (→ the bridge's own empty tree) without one.
|
|
27
|
-
const token = route.query[PREVIEW_TOKEN_QUERY]
|
|
28
|
-
const { data: ticket } = typeof token === 'string' && token
|
|
29
|
-
? await useAsyncData(`kestrel-preview-ticket:${token}`, () =>
|
|
30
|
-
requestFetch('/api/preview', { query: { token } })
|
|
31
|
-
.then((r) => r as { payload?: { values?: Record<string, unknown> } } | null)
|
|
32
|
-
.catch(() => null))
|
|
33
|
-
: { data: ref<{ payload?: { values?: Record<string, unknown> } } | null>(null) }
|
|
34
|
-
const ticketBlocks = computed(() => (ticket.value?.payload?.values?.content as unknown[] | undefined) ?? [])
|
|
35
|
-
useHead({
|
|
36
|
-
title: 'Preview',
|
|
37
|
-
meta: [{ name: 'robots', content: 'noindex, nofollow' }],
|
|
38
|
-
...(lang ? { htmlAttrs: { lang } } : {}),
|
|
39
|
-
})
|
|
40
|
-
</script>
|
|
41
|
-
|
|
42
|
-
<template>
|
|
43
|
-
<KestrelPreviewBridge :blocks="ticketBlocks" v-slot="{ blocks }">
|
|
44
|
-
<KestrelBlockRenderer :blocks="(blocks as any[])" />
|
|
45
|
-
</KestrelPreviewBridge>
|
|
46
|
-
</template>
|
|
@@ -1,16 +0,0 @@
|
|
|
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')
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
import { localePath } from '@michaelthielemann/kestrel-core/client'
|
|
2
|
-
|
|
3
|
-
/** One published ancestor of the rendered page, outermost first — the breadcrumb trail. */
|
|
4
|
-
export interface JsonLdAncestor {
|
|
5
|
-
/** The ancestor's own path, unprefixed (the emitter locale-prefixes it, as `buildPageHead` does). */
|
|
6
|
-
path: string
|
|
7
|
-
title?: string
|
|
8
|
-
/** The ancestor's OWN locale. Absent for a record in a non-translatable collection, whose single
|
|
9
|
-
* published URL is the primary-locale one — the same `row.locale ?? primary` rule the sitemap uses. */
|
|
10
|
-
locale?: string
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/** The opt-in article metadata (`kestrel.seo.articleMeta`). Absent ⇒ the page stays a plain `WebPage`
|
|
14
|
-
* and no authorship or date is published — the default, and the only behaviour a consumer who never
|
|
15
|
-
* turns the flag on can get. */
|
|
16
|
-
export interface JsonLdArticle {
|
|
17
|
-
author?: string
|
|
18
|
-
/** ISO date (`YYYY-MM-DD`) or ISO datetime; anything else is dropped rather than emitted invalid. */
|
|
19
|
-
publishedDate?: string
|
|
20
|
-
/** Free-form comma-separated list — schema.org accepts that spelling for `keywords` verbatim. */
|
|
21
|
-
keywords?: string
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface JsonLdInput {
|
|
25
|
-
/** Absolute site origin ('' when unconfigured). */
|
|
26
|
-
siteUrl: string
|
|
27
|
-
siteName?: string
|
|
28
|
-
/** The page's absolute canonical URL (from `buildPageHead`); absent ⇒ nothing is emitted. */
|
|
29
|
-
canonical?: string
|
|
30
|
-
locale: string
|
|
31
|
-
primary: string
|
|
32
|
-
prefixPrimary: boolean
|
|
33
|
-
title?: string
|
|
34
|
-
description?: string
|
|
35
|
-
/** The already-absolute og:image URL, so the image-resolution rules live in one place. */
|
|
36
|
-
imageUrl?: string
|
|
37
|
-
/** Excluded from the graph entirely — see `buildJsonLd`. */
|
|
38
|
-
noindex?: boolean
|
|
39
|
-
ancestors?: JsonLdAncestor[]
|
|
40
|
-
article?: JsonLdArticle | null
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** A type alias, not an interface, for the same reason as `PageHeadLink`: unhead's `script` entry types
|
|
44
|
-
* `textContent` as `string | Record<string, unknown>`, and TS derives the implicit index signature that
|
|
45
|
-
* needs for an alias but never for an interface. */
|
|
46
|
-
export type JsonLd = {
|
|
47
|
-
'@context': 'https://schema.org'
|
|
48
|
-
'@graph': Record<string, unknown>[]
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const trimmed = (v: unknown): string | undefined => {
|
|
52
|
-
const s = typeof v === 'string' ? v.trim() : ''
|
|
53
|
-
return s || undefined
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// schema.org dates are ISO 8601; an editor free-text value that is not one would be published as a
|
|
57
|
-
// broken `datePublished`, which is worse for a consuming engine than no date at all.
|
|
58
|
-
const ISO_DATE = /^\d{4}-\d{2}-\d{2}([T ]|$)/
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* The JSON-LD graph for a rendered public page: a site-wide `WebSite`, the page itself as a `WebPage`
|
|
62
|
-
* (or an `Article`, when the consumer opted into article metadata AND the record carries some), and a
|
|
63
|
-
* `BreadcrumbList` built from the page's real published ancestors. Pure, like `buildPageHead`, and fed
|
|
64
|
-
* the same already-resolved values — the precedence chain stays outside (ADR-0007).
|
|
65
|
-
*
|
|
66
|
-
* Two whole-graph veto rules, both because the alternative is a false signal rather than a missing one:
|
|
67
|
-
* without an absolute canonical every `@id`/`url` would be a relative path that resolves against
|
|
68
|
-
* whatever host fetched it, and a `noindex` page asking to be excluded from search has no business
|
|
69
|
-
* shipping structured data that exists to be indexed.
|
|
70
|
-
*
|
|
71
|
-
* Breadcrumb items are REAL pages only (the resolver hands over the published ancestors it found), so a
|
|
72
|
-
* trail never advertises an intermediate URL that 404s; a trail of one is dropped, since "you are here"
|
|
73
|
-
* carries no information.
|
|
74
|
-
*/
|
|
75
|
-
export function buildJsonLd(input: JsonLdInput): JsonLd | null {
|
|
76
|
-
const canonical = trimmed(input.canonical)
|
|
77
|
-
if (!canonical || input.noindex) return null
|
|
78
|
-
const base = input.siteUrl.replace(/\/+$/, '')
|
|
79
|
-
const abs = (path: string, locale: string) => `${base}${localePath(path, locale, input.primary, input.prefixPrimary)}`
|
|
80
|
-
|
|
81
|
-
const graph: Record<string, unknown>[] = []
|
|
82
|
-
const siteName = trimmed(input.siteName)
|
|
83
|
-
// A nameless WebSite node is an empty assertion — omit it, and with it the isPartOf edge that would
|
|
84
|
-
// otherwise dangle at an @id nothing defines.
|
|
85
|
-
const websiteId = siteName ? `${base}/#website` : undefined
|
|
86
|
-
if (websiteId) graph.push({ '@type': 'WebSite', '@id': websiteId, url: `${base}/`, name: siteName })
|
|
87
|
-
|
|
88
|
-
const article = usableArticle(input.article)
|
|
89
|
-
const page: Record<string, unknown> = {
|
|
90
|
-
'@type': article ? 'Article' : 'WebPage',
|
|
91
|
-
'@id': `${canonical}#webpage`,
|
|
92
|
-
url: canonical,
|
|
93
|
-
}
|
|
94
|
-
const title = trimmed(input.title)
|
|
95
|
-
// `headline` is the Article spelling of the same value; keeping them apart avoids a node that claims
|
|
96
|
-
// both and matches what validators expect per type.
|
|
97
|
-
if (title) page[article ? 'headline' : 'name'] = title
|
|
98
|
-
const description = trimmed(input.description)
|
|
99
|
-
if (description) page.description = description
|
|
100
|
-
page.inLanguage = input.locale
|
|
101
|
-
if (websiteId) page.isPartOf = { '@id': websiteId }
|
|
102
|
-
const imageUrl = trimmed(input.imageUrl)
|
|
103
|
-
if (imageUrl) page.image = imageUrl
|
|
104
|
-
if (article) {
|
|
105
|
-
if (article.author) page.author = { '@type': 'Person', name: article.author }
|
|
106
|
-
if (article.publishedDate) page.datePublished = article.publishedDate
|
|
107
|
-
if (article.keywords) page.keywords = article.keywords
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const crumbs = [
|
|
111
|
-
...(input.ancestors ?? []).map((a) => ({ name: trimmed(a.title) ?? a.path, item: abs(a.path, a.locale ?? input.primary) })),
|
|
112
|
-
{ name: title ?? canonical, item: canonical },
|
|
113
|
-
]
|
|
114
|
-
if (crumbs.length >= 2) {
|
|
115
|
-
const breadcrumbId = `${canonical}#breadcrumb`
|
|
116
|
-
page.breadcrumb = { '@id': breadcrumbId }
|
|
117
|
-
graph.push(page, {
|
|
118
|
-
'@type': 'BreadcrumbList',
|
|
119
|
-
'@id': breadcrumbId,
|
|
120
|
-
itemListElement: crumbs.map((c, i) => ({ '@type': 'ListItem', position: i + 1, name: c.name, item: c.item })),
|
|
121
|
-
})
|
|
122
|
-
} else {
|
|
123
|
-
graph.push(page)
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
return { '@context': 'https://schema.org', '@graph': graph }
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** The article bag reduced to the values worth publishing, or null — which keeps the node a `WebPage`.
|
|
130
|
-
* A bag of blanks (an editor who opened the fields and typed nothing) must not upgrade the type. */
|
|
131
|
-
function usableArticle(article: JsonLdArticle | null | undefined): Required<JsonLdArticle> | null {
|
|
132
|
-
if (!article) return null
|
|
133
|
-
const author = trimmed(article.author) ?? ''
|
|
134
|
-
const rawDate = trimmed(article.publishedDate) ?? ''
|
|
135
|
-
const publishedDate = ISO_DATE.test(rawDate) ? rawDate : ''
|
|
136
|
-
const keywords = trimmed(article.keywords) ?? ''
|
|
137
|
-
// An unparseable date alone must not upgrade the type either — nothing would be emitted from it.
|
|
138
|
-
return author || publishedDate || keywords ? { author, publishedDate, keywords } : null
|
|
139
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import type { LinkValue } from '@michaelthielemann/kestrel-core'
|
|
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
|
-
}
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { localePath } from '@michaelthielemann/kestrel-core/client'
|
|
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
|
-
}
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
/** The layout every page falls back to. Nuxt's own name for the unnamed layout, and the one the public
|
|
2
|
-
* layer ships (`layers/public/app/layouts/default.vue`), so it is always present. */
|
|
3
|
-
export const DEFAULT_LAYOUT = 'default'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The layout name to render a page in, from its stored `layout` column.
|
|
7
|
-
*
|
|
8
|
-
* Must never return an empty value. The catch-all declares `definePageMeta({ layout: false })` so the page
|
|
9
|
-
* owns its own `<NuxtLayout>`; that makes `route.meta.layout` the literal `false`, and NuxtLayout resolves
|
|
10
|
-
* `unref(props.name) ?? route.meta.layout ?? …` — `??` keeps `false`, which fails its `hasLayout` check and
|
|
11
|
-
* renders the page with NO layout wrapper. The `fallback` prop does not cover this: it only applies to a
|
|
12
|
-
* truthy name absent from the layout map. Coalescing here is what keeps an unset column rendering the
|
|
13
|
-
* normal site frame.
|
|
14
|
-
*/
|
|
15
|
-
export function resolvePageLayout(stored: string | null | undefined): string {
|
|
16
|
-
const name = typeof stored === 'string' ? stored.trim() : ''
|
|
17
|
-
return name || DEFAULT_LAYOUT
|
|
18
|
-
}
|
|
@@ -1,117 +0,0 @@
|
|
|
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
|
-
/** Query carrying a preview TICKET — the editor's unsaved state, rendered in a normal tab (ADR-0008). */
|
|
17
|
-
export const PREVIEW_TOKEN_QUERY = 'kestrel-preview-token'
|
|
18
|
-
/** Dedicated preview page for records without a public URL (new/unsaved, non-pageLike). Admin-gated. */
|
|
19
|
-
export const PREVIEW_FALLBACK_PATH = '/__kestrel/preview'
|
|
20
|
-
|
|
21
|
-
/** The block-node shape the renderer consumes (already media/link-populated by the editor). */
|
|
22
|
-
export interface PreviewBlockNode { id?: string; type: string; props?: Record<string, unknown>; slots?: Record<string, unknown> }
|
|
23
|
-
|
|
24
|
-
/** editor → frame: the live (populated) block tree + the current selection. */
|
|
25
|
-
export interface PreviewContentMessage { kestrel: 'preview:content'; blocks: PreviewBlockNode[]; selectedId: string | null }
|
|
26
|
-
/** editor → frame: selection changed (tree click) — highlight + scroll into view. */
|
|
27
|
-
export interface PreviewSelectedMessage { kestrel: 'preview:selected'; selectedId: string | null }
|
|
28
|
-
/** frame → editor: the bridge mounted (or the iframe reloaded) — (re)send the full state. */
|
|
29
|
-
export interface PreviewReadyMessage { kestrel: 'preview:ready' }
|
|
30
|
-
/** frame → editor: a block was clicked in the preview. */
|
|
31
|
-
export interface PreviewSelectMessage { kestrel: 'preview:select'; id: string }
|
|
32
|
-
|
|
33
|
-
export type EditorToFrameMessage = PreviewContentMessage | PreviewSelectedMessage
|
|
34
|
-
export type FrameToEditorMessage = PreviewReadyMessage | PreviewSelectMessage
|
|
35
|
-
|
|
36
|
-
export const contentMessage = (blocks: PreviewBlockNode[], selectedId: string | null): PreviewContentMessage =>
|
|
37
|
-
({ kestrel: 'preview:content', blocks, selectedId })
|
|
38
|
-
export const selectedMessage = (selectedId: string | null): PreviewSelectedMessage =>
|
|
39
|
-
({ kestrel: 'preview:selected', selectedId })
|
|
40
|
-
export const readyMessage = (): PreviewReadyMessage => ({ kestrel: 'preview:ready' })
|
|
41
|
-
export const selectMessage = (id: string): PreviewSelectMessage => ({ kestrel: 'preview:select', id })
|
|
42
|
-
|
|
43
|
-
function tagOf(data: unknown): string | null {
|
|
44
|
-
if (typeof data !== 'object' || data === null || Array.isArray(data)) return null
|
|
45
|
-
const tag = (data as { kestrel?: unknown }).kestrel
|
|
46
|
-
return typeof tag === 'string' ? tag : null
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Parse a message the FRAME received (editor → frame); `null` for anything else. */
|
|
50
|
-
export function parseEditorMessage(data: unknown): EditorToFrameMessage | null {
|
|
51
|
-
const tag = tagOf(data)
|
|
52
|
-
if (tag === 'preview:content') {
|
|
53
|
-
const m = data as PreviewContentMessage
|
|
54
|
-
return Array.isArray(m.blocks) && (m.selectedId === null || typeof m.selectedId === 'string') ? m : null
|
|
55
|
-
}
|
|
56
|
-
if (tag === 'preview:selected') {
|
|
57
|
-
const m = data as PreviewSelectedMessage
|
|
58
|
-
return m.selectedId === null || typeof m.selectedId === 'string' ? m : null
|
|
59
|
-
}
|
|
60
|
-
return null
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Parse a message the EDITOR received (frame → editor); `null` for anything else. */
|
|
64
|
-
export function parseFrameMessage(data: unknown): FrameToEditorMessage | null {
|
|
65
|
-
const tag = tagOf(data)
|
|
66
|
-
if (tag === 'preview:ready') return data as PreviewReadyMessage
|
|
67
|
-
if (tag === 'preview:select') {
|
|
68
|
-
const m = data as PreviewSelectMessage
|
|
69
|
-
return typeof m.id === 'string' ? m : null
|
|
70
|
-
}
|
|
71
|
-
return null
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* The iframe URL for the editor's preview pane: the record's real public URL when it has one (saved
|
|
76
|
-
* pageLike record — server-populated first paint, drafts included for the admin session), else the
|
|
77
|
-
* dedicated fallback page (new/unsaved records, non-pageLike collections). Both carry the preview flag.
|
|
78
|
-
*/
|
|
79
|
-
/**
|
|
80
|
-
* The record a ticket preview renders: the saved row with the editor's unsaved values laid over it. Both
|
|
81
|
-
* halves are column-keyed (the editor sends what a save would send), so this is a shallow override — a
|
|
82
|
-
* field the editor did not touch keeps the stored value, and a page that does not exist yet (an unsaved
|
|
83
|
-
* slug) renders from the payload alone. Pure.
|
|
84
|
-
*
|
|
85
|
-
* One override is not shallow: a single-valued relation/media field is column-keyed `<name>Id` (the
|
|
86
|
-
* `resolveColumnName`/`isSingleRefColumn` convention — many-relations and multi-media stay bare-keyed, so
|
|
87
|
-
* they're untouched by this), while its populated sidecar sits ALONGSIDE the id, not under it — `$<name>`
|
|
88
|
-
* for a relation (`buildRelationFieldPopulator`), `$media.<name>` for media (`populate.ts`'s `attach`).
|
|
89
|
-
* `values` here is raw unsaved editor state, never itself populated, so clearing such a field to null
|
|
90
|
-
* leaves `saved`'s old sidecar with nothing to overwrite it — a plain spread would let a removed
|
|
91
|
-
* author/cover keep rendering. Drop it explicitly for every `<name>Id` key the editor cleared.
|
|
92
|
-
*/
|
|
93
|
-
export function previewPage(
|
|
94
|
-
saved: Record<string, unknown> | null,
|
|
95
|
-
values: Record<string, unknown> | null | undefined,
|
|
96
|
-
): Record<string, unknown> | null {
|
|
97
|
-
if (!values) return saved
|
|
98
|
-
const merged: Record<string, unknown> = { ...(saved ?? {}), ...values }
|
|
99
|
-
for (const key of Object.keys(values)) {
|
|
100
|
-
if (values[key] !== null || !key.endsWith('Id')) continue
|
|
101
|
-
const name = key.slice(0, -2)
|
|
102
|
-
if (!name) continue
|
|
103
|
-
if (Object.hasOwn(merged, `$${name}`)) delete merged[`$${name}`]
|
|
104
|
-
const media = merged.$media as Record<string, unknown> | undefined
|
|
105
|
-
if (media && Object.hasOwn(media, name)) {
|
|
106
|
-
const { [name]: _dropped, ...rest } = media
|
|
107
|
-
merged.$media = rest
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
return merged
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function previewSrc(publicUrl: string | null, locale: string): string {
|
|
114
|
-
if (publicUrl) return `${publicUrl}?${PREVIEW_QUERY}=1`
|
|
115
|
-
const loc = locale ? `&locale=${encodeURIComponent(locale)}` : ''
|
|
116
|
-
return `${PREVIEW_FALLBACK_PATH}?${PREVIEW_QUERY}=1${loc}`
|
|
117
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
export interface SiteHead {
|
|
2
|
-
baseTitle?: string | null
|
|
3
|
-
titleSeparator?: string | null
|
|
4
|
-
titlePosition?: 'before' | 'after' | null
|
|
5
|
-
description?: string | null
|
|
6
|
-
$media?: { image?: { src: string, width: number | null, height: number | null } | null } | null
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
const DEFAULT_SEPARATOR = '|'
|
|
10
|
-
|
|
11
|
-
const trimmed = (v: unknown): string | undefined => {
|
|
12
|
-
const s = typeof v === 'string' ? v.trim() : ''
|
|
13
|
-
return s || undefined
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* The `<title>` for a page. Only the document title is composed — `og:title` keeps the bare page title,
|
|
18
|
-
* because `og:site_name` already carries the site.
|
|
19
|
-
*/
|
|
20
|
-
export function composeTitle(pageTitle: string | undefined | null, site: SiteHead | null | undefined): string | undefined {
|
|
21
|
-
const page = trimmed(pageTitle)
|
|
22
|
-
const base = trimmed(site?.baseTitle)
|
|
23
|
-
if (!base) return page
|
|
24
|
-
if (!page) return base
|
|
25
|
-
// Migrated content often carries the site name in the page title already; appending it again reads as a
|
|
26
|
-
// bug to every visitor who looks at the tab.
|
|
27
|
-
if (page === base || page.endsWith(base)) return page
|
|
28
|
-
// The separator is stored as a bare token and padded here: a `text` field trims on write, so a stored
|
|
29
|
-
// " | " would come back as "|" and glue the two titles together.
|
|
30
|
-
const separator = trimmed(site?.titleSeparator) ?? DEFAULT_SEPARATOR
|
|
31
|
-
return site?.titlePosition === 'before' ? `${base} ${separator} ${page}` : `${page} ${separator} ${base}`
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** The page wins, the site stands in, and both degrade to absent so the tags disappear entirely. */
|
|
35
|
-
export function siteHeadFallbacks(
|
|
36
|
-
seo: { description?: string | null, $media?: { image?: { src: string, width: number | null, height: number | null } | null } | null } | null | undefined,
|
|
37
|
-
site: SiteHead | null | undefined,
|
|
38
|
-
): { description?: string, image: { src: string, width: number | null, height: number | null } | null } {
|
|
39
|
-
return {
|
|
40
|
-
description: trimmed(seo?.description) ?? trimmed(site?.description),
|
|
41
|
-
image: seo?.$media?.image ?? site?.$media?.image ?? null,
|
|
42
|
-
}
|
|
43
|
-
}
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { defineNuxtModule } from '@nuxt/kit'
|
|
2
|
-
import { createS3Driver } from '@michaelthielemann/kestrel-core'
|
|
3
|
-
import { resolveOutputCreds, deployStaticOutput, isEnvTrue, planS3Deploy, readRouteDiscovery } from '@michaelthielemann/kestrel-delivery-static'
|
|
4
|
-
import { resolveKestrel } from '@michaelthielemann/kestrel-core'
|
|
5
|
-
import type { KestrelConfig } from '@michaelthielemann/kestrel-core'
|
|
6
|
-
/**
|
|
7
|
-
* Ships `nuxt generate`'s static output (`.output/public`) to an S3 bucket when `kestrel.output.driver`
|
|
8
|
-
* is `s3` — the deploy half of FEATURES "Output target: local directory or S3 bucket". Runs on Nitro's
|
|
9
|
-
* `compiled` hook (which fires once the full output is on disk — prerendered HTML, the `_nuxt` client
|
|
10
|
-
* bundle + static `public/` assets copied by `copyPublicAssets`, and any compressed variants — i.e. after
|
|
11
|
-
* prerendering, which does NOT abort on a route error unless `prerender.failOnError` is set: hence the
|
|
12
|
-
* `incomplete` signal below), and only when `nitro.options.static` is set, i.e. a real `nuxt generate`.
|
|
13
|
-
* A plain `nuxt build`/`dev` never deploys.
|
|
14
|
-
* Nor does it with `output.auto` on — then the running server owns publishing (see the `auto` note below).
|
|
15
|
-
* With `driver: local` (the default) it does nothing — the local `.output/public` tree is the artifact.
|
|
16
|
-
* S3 credentials are env-only (`KESTREL_OUTPUT_S3_*`, falling back to the shared media `KESTREL_S3_*`).
|
|
17
|
-
*/
|
|
18
|
-
export default defineNuxtModule({
|
|
19
|
-
meta: { name: 'kestrel-deploy-output' },
|
|
20
|
-
setup(_options, nuxt) {
|
|
21
|
-
// Build-time: resolve from the consumer's config through the one shared resolver, so driver, prefixes
|
|
22
|
-
// and `auto` here are byte-identical to what the runtime publisher and the media driver see — an
|
|
23
|
-
// ad-hoc env probe that disagrees (env-first vs config-first) would silently disarm the guards below.
|
|
24
|
-
const c = resolveKestrel(nuxt.options.kestrel as KestrelConfig, process.env, nuxt.options.rootDir)
|
|
25
|
-
if (c.output.driver !== 's3') return
|
|
26
|
-
const target = c.output.s3
|
|
27
|
-
|
|
28
|
-
// Where the S3 media driver keeps live uploads — used to stop the reconcile from wiping them when the
|
|
29
|
-
// generated site shares its bucket. Undefined (media on the local driver) makes the guard a no-op.
|
|
30
|
-
const mediaS3 = c.media.driver === 's3' ? c.media.s3 : undefined
|
|
31
|
-
|
|
32
|
-
nuxt.hook('nitro:init', (nitro) => {
|
|
33
|
-
// Nitro prerenders BEFORE the build that fires `compiled`, and `prerender.failOnError` is off by
|
|
34
|
-
// default — a route that 5xx'd leaves no file and does not abort the generate. Remember that here:
|
|
35
|
-
// its live page is still there and must not be mistaken for content removed from the CMS.
|
|
36
|
-
let failedRoutes = 0
|
|
37
|
-
nitro.hooks.hook('prerender:done', ({ failedRoutes: failed }) => { failedRoutes = failed?.length ?? 0 })
|
|
38
|
-
|
|
39
|
-
nitro.hooks.hook('compiled', async () => {
|
|
40
|
-
const env = process.env
|
|
41
|
-
const creds = resolveOutputCreds(env)
|
|
42
|
-
const dryRun = isEnvTrue(env.KESTREL_OUTPUT_DRY_RUN)
|
|
43
|
-
// Skip on a plain build (no static generate) or when the runtime publisher owns the bucket;
|
|
44
|
-
// throw on a misconfigured real deploy or unsafe reconcile.
|
|
45
|
-
if (planS3Deploy({
|
|
46
|
-
isStaticGenerate: nitro.options.static === true,
|
|
47
|
-
autoPublish: c.output.auto,
|
|
48
|
-
dryRun,
|
|
49
|
-
bucket: target.bucket,
|
|
50
|
-
accessKeyId: creds.accessKeyId,
|
|
51
|
-
secretAccessKey: creds.secretAccessKey,
|
|
52
|
-
prefix: target.prefix,
|
|
53
|
-
mediaBucket: mediaS3?.bucket,
|
|
54
|
-
mediaPrefix: mediaS3?.prefix,
|
|
55
|
-
}) === 'skip') {
|
|
56
|
-
// A plain build skips silently; someone who actually ran `nuxt generate` deserves to hear why
|
|
57
|
-
// nothing shipped.
|
|
58
|
-
if (nitro.options.static === true && c.output.auto) {
|
|
59
|
-
console.log('[kestrel] output.auto is on — the running server publishes to this bucket; not shipping'
|
|
60
|
-
+ ' .output/public. Set output.auto=false (or KESTREL_OUTPUT_AUTO=false) for the build-time model.')
|
|
61
|
-
}
|
|
62
|
-
return
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const driver = createS3Driver({ ...target, publicBaseUrl: '', ...creds })
|
|
66
|
-
console.log(`[kestrel] ${dryRun ? 'dry-run: ' : ''}shipping static output → s3://${target.bucket || '<bucket>'}/${target.prefix}`)
|
|
67
|
-
// Both signals are REPORTED failures of a step that knows: route discovery could not enumerate the
|
|
68
|
-
// pages, or Nitro could not render some of them. Neither is inferred from how the tree looks —
|
|
69
|
-
// no shape of output distinguishes a degraded build from a small site.
|
|
70
|
-
const discovery = readRouteDiscovery(nuxt)
|
|
71
|
-
const res = await deployStaticOutput(nitro.options.output.publicDir, driver, {
|
|
72
|
-
dryRun,
|
|
73
|
-
incomplete: discovery?.incomplete ?? (failedRoutes ? `${failedRoutes} route(s) failed to prerender` : undefined),
|
|
74
|
-
log: (m) => console.log(`[kestrel] ${m}`),
|
|
75
|
-
})
|
|
76
|
-
console.log(`[kestrel] static output ${dryRun ? 'walk' : 'deploy'} complete — ${res.keys.length} file(s), ${res.pruned} pruned.`)
|
|
77
|
-
})
|
|
78
|
-
})
|
|
79
|
-
},
|
|
80
|
-
})
|