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