@michaelthielemann/kestrel 4.1.2 → 5.0.0
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 +274 -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 +157 -0
- package/dist/defineConfig.js +41 -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/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 +58 -0
- package/dist/triggers/http.js +292 -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,267 +0,0 @@
|
|
|
1
|
-
import { toRaw } from 'vue'
|
|
2
|
-
import type { FieldDef, SerializedField } from '@michaelthielemann/kestrel-core'
|
|
3
|
-
import { resolveFieldEmpty } from '../../../ui/app/utils/field-empty'
|
|
4
|
-
|
|
5
|
-
export interface ServerIssue {
|
|
6
|
-
path: (string | number)[]
|
|
7
|
-
message: string
|
|
8
|
-
code?: string
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export interface MappedErrors {
|
|
12
|
-
fields: Record<string, string>
|
|
13
|
-
form?: string
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface FetchErrorInfo {
|
|
17
|
-
statusCode?: number
|
|
18
|
-
statusMessage?: string
|
|
19
|
-
issues: ServerIssue[]
|
|
20
|
-
/** The error body when it is NOT a Zod-issue array — e.g. `{ savedUpdatedAt }` from a failed write effect. */
|
|
21
|
-
data?: Record<string, unknown>
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// The wire `SerializedField` is structurally what every Field* widget and `validateField` read
|
|
25
|
-
// (flat type + options record + relation); only its discriminated-union typing differs. Narrow here.
|
|
26
|
-
export function asFieldDef(field: SerializedField): FieldDef {
|
|
27
|
-
return field as unknown as FieldDef
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// Read an ofetch/H3 error envelope: top-level statusCode/statusMessage, Zod issues nested at err.data.data.
|
|
31
|
-
// The BODY's statusMessage wins over the response's: ofetch maps `statusMessage` from `response.statusText`,
|
|
32
|
-
// and HTTP/2 has no reason phrase — behind any h2 proxy that arrives as '' and the real message is only in
|
|
33
|
-
// the JSON. Anything else under `data` (not an issue array) is handed back for the caller to act on.
|
|
34
|
-
export function readFetchError(e: unknown): FetchErrorInfo {
|
|
35
|
-
const err = e as { statusCode?: number; statusMessage?: string; data?: { data?: ServerIssue[] } & Record<string, unknown> }
|
|
36
|
-
const body = Array.isArray(err.data) ? undefined : err.data
|
|
37
|
-
const raw = body?.data ?? err.data
|
|
38
|
-
return {
|
|
39
|
-
statusCode: err.statusCode,
|
|
40
|
-
statusMessage: (body?.statusMessage as string | undefined) || err.statusMessage,
|
|
41
|
-
issues: Array.isArray(raw) ? (raw as ServerIssue[]) : [],
|
|
42
|
-
...(body && !Array.isArray(body.data) ? { data: body } : {}),
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// `field.default` on a block-picker's field comes from `useBlocks()`'s `useState`-backed list — a Vue
|
|
47
|
-
// reactive Proxy for any object/array value. `structuredClone` cannot clone a Proxy (`DataCloneError`,
|
|
48
|
-
// deterministic, not a race) — `toRaw` unwraps it first, same fix `cloneBlockTree` reaches for via a JSON
|
|
49
|
-
// round-trip; `toRaw` + `structuredClone` keeps real Date/Map values a JSON round-trip would mangle.
|
|
50
|
-
function cloneDefault(value: unknown): unknown {
|
|
51
|
-
return value !== null && typeof value === 'object' ? structuredClone(toRaw(value)) : value
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function emptyForField(field: SerializedField): unknown {
|
|
55
|
-
switch (field.type) {
|
|
56
|
-
case 'text':
|
|
57
|
-
return ''
|
|
58
|
-
case 'boolean':
|
|
59
|
-
return false
|
|
60
|
-
case 'repeater':
|
|
61
|
-
return []
|
|
62
|
-
case 'choice':
|
|
63
|
-
return field.options?.multiple ? [] : null
|
|
64
|
-
case 'relation':
|
|
65
|
-
return field.relation?.many ? [] : null
|
|
66
|
-
case 'media':
|
|
67
|
-
return field.options?.multiple ? [] : null
|
|
68
|
-
default:
|
|
69
|
-
// A consumer-defined type may register its blank shape (array/object-backed → [] / {}); else null.
|
|
70
|
-
return resolveFieldEmpty(field.type)?.() ?? null
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Build a blank form state: a present (JSON-safe) default wins, else a type-appropriate empty. */
|
|
75
|
-
export function initialValues(fields: Record<string, SerializedField>): Record<string, unknown> {
|
|
76
|
-
const out: Record<string, unknown> = {}
|
|
77
|
-
for (const [name, field] of Object.entries(fields)) {
|
|
78
|
-
out[name] = 'default' in field ? cloneDefault(field.default) : emptyForField(field)
|
|
79
|
-
}
|
|
80
|
-
return out
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Map a 400 body's Zod issues to per-field errors (by path[0]) and a single form-level error (empty path). */
|
|
84
|
-
export function mapServerErrors(issues: ServerIssue[] | null | undefined): MappedErrors {
|
|
85
|
-
const fields: Record<string, string> = {}
|
|
86
|
-
let form: string | undefined
|
|
87
|
-
for (const issue of issues ?? []) {
|
|
88
|
-
const head = issue.path?.[0]
|
|
89
|
-
if (head === undefined || head === null || head === '') {
|
|
90
|
-
if (form === undefined) form = issue.message
|
|
91
|
-
} else {
|
|
92
|
-
const name = String(head)
|
|
93
|
-
if (!(name in fields)) fields[name] = issue.message
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return form === undefined ? { fields } : { fields, form }
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Per-block (keyed by the stable `block.id`), per-field error map for inline block-content errors. */
|
|
100
|
-
export type BlockErrorMap = Record<string, Record<string, string>>
|
|
101
|
-
|
|
102
|
-
interface BlockLike {
|
|
103
|
-
id?: unknown
|
|
104
|
-
props?: Record<string, unknown>
|
|
105
|
-
slots?: Record<string, unknown>
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function blockId(block: unknown): string | null {
|
|
109
|
-
const id = (block as BlockLike | undefined)?.id
|
|
110
|
-
return typeof id === 'string' && id !== '' ? id : null
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Parse nested block-content issues into a per-block, per-field error map keyed by the block's
|
|
115
|
-
* stable `id`. The server reports a *position* path — `[key, i, 'props', field, …]` for a top-level
|
|
116
|
-
* block, `[key, i, 'slots', name, j, 'props', field, …]` for one nested in a slot (any depth). We
|
|
117
|
-
* walk that path through the submitted `content` to find the target block and pin the error to its
|
|
118
|
-
* id, so it survives a later reorder at any level. First wins per field; an unresolvable path is
|
|
119
|
-
* skipped.
|
|
120
|
-
*/
|
|
121
|
-
export function parseBlockErrors(
|
|
122
|
-
issues: ServerIssue[] | null | undefined,
|
|
123
|
-
content: readonly unknown[] | null | undefined,
|
|
124
|
-
key = 'content',
|
|
125
|
-
): BlockErrorMap {
|
|
126
|
-
const out: BlockErrorMap = {}
|
|
127
|
-
for (const issue of issues ?? []) {
|
|
128
|
-
const path = issue.path ?? []
|
|
129
|
-
if (path[0] !== key) continue
|
|
130
|
-
const hit = resolveBlockField(path.slice(1), content)
|
|
131
|
-
if (!hit) continue
|
|
132
|
-
out[hit.id] ??= {}
|
|
133
|
-
if (!(hit.field in out[hit.id]!)) out[hit.id]![hit.field] = issue.message
|
|
134
|
-
}
|
|
135
|
-
return out
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Walk a position path (after the `content` head) through the block tree to the block whose
|
|
140
|
-
* `props.<field>` the issue targets. Steps over `<index>, 'slots', <name>` segments into nested
|
|
141
|
-
* slot arrays. Returns the target block's stable id + the field name, or null if the path is not a
|
|
142
|
-
* `…props.<field>` leaf or any segment doesn't resolve to an identified block.
|
|
143
|
-
*/
|
|
144
|
-
function resolveBlockField(
|
|
145
|
-
path: readonly (string | number)[],
|
|
146
|
-
blocks: readonly unknown[] | null | undefined,
|
|
147
|
-
): { id: string; field: string } | null {
|
|
148
|
-
let arr: readonly unknown[] | null | undefined = blocks
|
|
149
|
-
for (let i = 0; i < path.length; ) {
|
|
150
|
-
const index = path[i]
|
|
151
|
-
if (typeof index !== 'number' || !Array.isArray(arr)) return null
|
|
152
|
-
const block = arr[index] as BlockLike | undefined
|
|
153
|
-
const next = path[i + 1]
|
|
154
|
-
if (next === 'props') {
|
|
155
|
-
const field = path[i + 2]
|
|
156
|
-
const id = blockId(block)
|
|
157
|
-
return typeof field === 'string' && id !== null ? { id, field } : null
|
|
158
|
-
}
|
|
159
|
-
if (next === 'slots') {
|
|
160
|
-
const name = path[i + 2]
|
|
161
|
-
if (typeof name !== 'string') return null
|
|
162
|
-
const slot = block?.slots?.[name]
|
|
163
|
-
arr = Array.isArray(slot) ? slot : null
|
|
164
|
-
i += 3
|
|
165
|
-
continue
|
|
166
|
-
}
|
|
167
|
-
return null
|
|
168
|
-
}
|
|
169
|
-
return null
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Carry block errors across a `content` change. A block (identified by its stable `id`, top-level or nested in
|
|
174
|
-
* a slot at any depth) keeps its errors while it merely moves — a pure reorder, whether of its slot or of an
|
|
175
|
-
* ancestor — and is dropped when removed; added blocks never gain an error. Reconciliation is PER-FIELD: an
|
|
176
|
-
* edit clears only the edited field's stale message, so a still-invalid sibling field of the same block keeps
|
|
177
|
-
* its error instead of all of them flickering away until the next save.
|
|
178
|
-
*/
|
|
179
|
-
export function reconcileBlockErrors(
|
|
180
|
-
errors: BlockErrorMap,
|
|
181
|
-
prev: readonly unknown[] | null | undefined,
|
|
182
|
-
next: readonly unknown[] | null | undefined,
|
|
183
|
-
): BlockErrorMap {
|
|
184
|
-
const prevById = new Map<string, BlockLike>()
|
|
185
|
-
const nextById = new Map<string, BlockLike>()
|
|
186
|
-
flattenBlocksById(prev, prevById)
|
|
187
|
-
flattenBlocksById(next, nextById)
|
|
188
|
-
const out: BlockErrorMap = {}
|
|
189
|
-
for (const [id, errs] of Object.entries(errors)) {
|
|
190
|
-
const before = prevById.get(id)
|
|
191
|
-
const after = nextById.get(id)
|
|
192
|
-
if (!before || !after) continue // removed → drop
|
|
193
|
-
const beforeProps = (before.props ?? {}) as Record<string, unknown>
|
|
194
|
-
const afterProps = (after.props ?? {}) as Record<string, unknown>
|
|
195
|
-
const kept: Record<string, string> = {}
|
|
196
|
-
for (const [field, message] of Object.entries(errs)) {
|
|
197
|
-
// Keep a field's error only while THAT field's value is unchanged; editing it clears its own stale message.
|
|
198
|
-
if (valuesEqual(beforeProps[field], afterProps[field])) kept[field] = message
|
|
199
|
-
}
|
|
200
|
-
if (Object.keys(kept).length) out[id] = kept
|
|
201
|
-
}
|
|
202
|
-
return out
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/** Index every block in the tree (top-level and nested in slots) by its stable id. */
|
|
206
|
-
function flattenBlocksById(blocks: readonly unknown[] | null | undefined, into: Map<string, BlockLike>): void {
|
|
207
|
-
for (const block of blocks ?? []) {
|
|
208
|
-
const id = blockId(block)
|
|
209
|
-
if (id !== null) into.set(id, block as BlockLike)
|
|
210
|
-
const slots = (block as BlockLike | undefined)?.slots
|
|
211
|
-
if (slots) for (const arr of Object.values(slots)) if (Array.isArray(arr)) flattenBlocksById(arr, into)
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** One stale reference a record holds. Wire shape of `DeadRef` (kept structural here so this module stays
|
|
216
|
-
* Vue-free and doesn't import the admin-only `dead-refs.ts` type file). */
|
|
217
|
-
interface DeadRefLike {
|
|
218
|
-
field: string
|
|
219
|
-
blockId?: string
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Carry dead-reference warnings across a `content` change, mirroring `reconcileBlockErrors`: a warning at
|
|
224
|
-
* a block (by stable id) is dropped once THAT field's value changes (the user swapped the broken
|
|
225
|
-
* reference) or the block is removed; it survives a pure reorder. Root-level (non-block) dead refs are
|
|
226
|
-
* untouched here — `setField` drops those directly when their own field is edited.
|
|
227
|
-
*/
|
|
228
|
-
export function reconcileDeadRefs<T extends DeadRefLike>(
|
|
229
|
-
refs: readonly T[],
|
|
230
|
-
prev: readonly unknown[] | null | undefined,
|
|
231
|
-
next: readonly unknown[] | null | undefined,
|
|
232
|
-
): T[] {
|
|
233
|
-
const prevById = new Map<string, BlockLike>()
|
|
234
|
-
const nextById = new Map<string, BlockLike>()
|
|
235
|
-
flattenBlocksById(prev, prevById)
|
|
236
|
-
flattenBlocksById(next, nextById)
|
|
237
|
-
return refs.filter((r) => {
|
|
238
|
-
if (!r.blockId) return true
|
|
239
|
-
const before = prevById.get(r.blockId)
|
|
240
|
-
const after = nextById.get(r.blockId)
|
|
241
|
-
if (!before || !after) return false
|
|
242
|
-
const beforeVal = (before.props ?? {})[r.field]
|
|
243
|
-
const afterVal = (after.props ?? {})[r.field]
|
|
244
|
-
return valuesEqual(beforeVal, afterVal)
|
|
245
|
-
})
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/** Structural deep-equality for the value shapes the editor tracks (primitives, arrays, plain objects). */
|
|
249
|
-
export function valuesEqual(a: unknown, b: unknown): boolean {
|
|
250
|
-
if (a === b) return true
|
|
251
|
-
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false
|
|
252
|
-
|
|
253
|
-
const aArr = Array.isArray(a)
|
|
254
|
-
const bArr = Array.isArray(b)
|
|
255
|
-
if (aArr !== bArr) return false
|
|
256
|
-
if (aArr && bArr) {
|
|
257
|
-
if (a.length !== b.length) return false
|
|
258
|
-
return a.every((item, i) => valuesEqual(item, b[i]))
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
const aRec = a as Record<string, unknown>
|
|
262
|
-
const bRec = b as Record<string, unknown>
|
|
263
|
-
const aKeys = Object.keys(aRec)
|
|
264
|
-
const bKeys = Object.keys(bRec)
|
|
265
|
-
if (aKeys.length !== bKeys.length) return false
|
|
266
|
-
return aKeys.every((k) => Object.prototype.hasOwnProperty.call(bRec, k) && valuesEqual(aRec[k], bRec[k]))
|
|
267
|
-
}
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import type { PublishStatusData } from '../composables/usePublishStatus'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* The handle `CollectionEditor` exposes via `defineExpose` — the header hosts (`[collection]/[id].vue`
|
|
5
|
-
* and `SingletonEditor.vue`) drive Save / undo / preview and read the status ampel through it. ONE typed
|
|
6
|
-
* contract instead of three drifting inline copies: Vue does not type-check a template `ref="editorRef"`
|
|
7
|
-
* against `defineExpose`, so without a shared interface a renamed/removed key (e.g. `previewUrl`) compiles
|
|
8
|
-
* in both hosts and silently becomes `undefined` at runtime. Type both host refs as `EditorExpose | null`.
|
|
9
|
-
*/
|
|
10
|
-
export interface EditorExpose {
|
|
11
|
-
dirty: boolean
|
|
12
|
-
saving: boolean
|
|
13
|
-
canUndo: boolean
|
|
14
|
-
canRedo: boolean
|
|
15
|
-
undo: () => void
|
|
16
|
-
redo: () => void
|
|
17
|
-
hasStatus: boolean
|
|
18
|
-
status: string
|
|
19
|
-
/** The last SAVED publish status (baseline), for the right lamp's draft/published decision. */
|
|
20
|
-
savedStatus: string
|
|
21
|
-
previewUrl: string | null
|
|
22
|
-
pageLike: boolean
|
|
23
|
-
live: PublishStatusData | null
|
|
24
|
-
/** The record's own title (see `recordTitle`), or `''` — the header then shows "Edit {collection} #{id}". */
|
|
25
|
-
recordTitle: string
|
|
26
|
-
/** Save, then write the static output (ADR-0008) — a draft is promoted to published on the way. */
|
|
27
|
-
publish: () => Promise<void>
|
|
28
|
-
publishing: boolean
|
|
29
|
-
/** False when `output.publishOnSave` is on — a save republishes, so the hosts hide the Publish button. */
|
|
30
|
-
canPublish: boolean
|
|
31
|
-
/** Open the record in a new tab: the saved URL, or the unsaved state carried by a preview ticket. */
|
|
32
|
-
openPreview: () => Promise<void>
|
|
33
|
-
previewOpening: boolean
|
|
34
|
-
/** The loaded row is the quarantine shape — read-only, hosts must hide Save/Publish. */
|
|
35
|
-
quarantined: boolean
|
|
36
|
-
}
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import type { ComputedRef, InjectionKey, Ref } from 'vue'
|
|
2
|
-
import type { FieldDef, LayoutNode } from '@michaelthielemann/kestrel-core'
|
|
3
|
-
import type { BlockErrorMap } from './edit-form'
|
|
4
|
-
import type { DeadRef } from './dead-refs'
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* The form context `CollectionEditor` (the generic shell) provides to whichever editor **body** the
|
|
8
|
-
* registry resolves (fields · blocks · an extension's, e.g. node-graph). It carries the load-bearing
|
|
9
|
-
* `useEditForm` surface a body needs to read and mutate the record, so a body swaps in without a giant
|
|
10
|
-
* prop list — the same provide/inject seam as `recordEditContextKey`, one level up.
|
|
11
|
-
*
|
|
12
|
-
* Provide it BEFORE the shell's top-level `await` (after an await the setup instance is gone and `provide`
|
|
13
|
-
* silently no-ops), exactly like the record-edit context.
|
|
14
|
-
*/
|
|
15
|
-
/** Props for the shared page-fields pane. Defined once here so the shell's pre-built bindings and
|
|
16
|
-
* `PageFieldsPane`'s own `defineProps` stay a single type (a widened `Record` would break the v-bind). */
|
|
17
|
-
export interface PageFieldsBindings {
|
|
18
|
-
translatable?: boolean
|
|
19
|
-
collection: string
|
|
20
|
-
id: string
|
|
21
|
-
mode: 'single' | 'multi'
|
|
22
|
-
locale: string
|
|
23
|
-
translations: Record<string, number | null>
|
|
24
|
-
group?: string
|
|
25
|
-
fields: Record<string, FieldDef>
|
|
26
|
-
/** Normalized admin editor layout for the page fields (undefined → one field per row). */
|
|
27
|
-
fieldLayout?: LayoutNode[]
|
|
28
|
-
values: Record<string, unknown>
|
|
29
|
-
errors: Record<string, string>
|
|
30
|
-
deadFields?: Set<string>
|
|
31
|
-
pageLike?: boolean
|
|
32
|
-
seo?: boolean
|
|
33
|
-
status?: boolean
|
|
34
|
-
disabled?: boolean
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface EditorFormContext {
|
|
38
|
-
/** Reactive form values / per-field errors (from `useEditForm`; reactive objects, not refs). */
|
|
39
|
-
values: Record<string, unknown>
|
|
40
|
-
errors: Record<string, string>
|
|
41
|
-
formError: Ref<string>
|
|
42
|
-
blockErrors: Ref<BlockErrorMap>
|
|
43
|
-
/** Route every mutation through this (→ block-error reconciliation, dirty tracking, history). The
|
|
44
|
-
* optional `coalesceAs` lets a caller (e.g. a block-tree structural op) tag its own undo-history key
|
|
45
|
-
* instead of coalescing under the bare field name. */
|
|
46
|
-
setField: (name: string, value: unknown, coalesceAs?: string) => void
|
|
47
|
-
locale: Ref<string>
|
|
48
|
-
translations: Ref<Record<string, number | null>>
|
|
49
|
-
translationGroup: Ref<string | undefined>
|
|
50
|
-
deadRefs: Ref<DeadRef[]>
|
|
51
|
-
saving: Ref<boolean>
|
|
52
|
-
mode: Ref<'multi' | 'single'>
|
|
53
|
-
translatable: Ref<boolean>
|
|
54
|
-
pageLike: Ref<boolean>
|
|
55
|
-
hasSeo: Ref<boolean>
|
|
56
|
-
hasStatus: Ref<boolean>
|
|
57
|
-
blocksAllowed: Ref<string[] | undefined>
|
|
58
|
-
/** Collection (page-root) fields as `FieldDef`s, ready for the field Renderer. */
|
|
59
|
-
renderable: ComputedRef<Record<string, FieldDef>>
|
|
60
|
-
/** The record's localized public URL (saved pageLike record with a path), or null (new/unsaved,
|
|
61
|
-
* non-pageLike). The shell computes it once for the header's open-in-new-tab AND the live preview. */
|
|
62
|
-
previewUrl: ComputedRef<string | null>
|
|
63
|
-
undo: () => void
|
|
64
|
-
redo: () => void
|
|
65
|
-
applyFrom: (row: Record<string, unknown>) => void
|
|
66
|
-
/** Pre-bound props/handlers for the shared page-fields pane, rendered by both the fields and blocks
|
|
67
|
-
* bodies (identical binding, differing only in layout chrome). */
|
|
68
|
-
pageFieldsBindings: ComputedRef<PageFieldsBindings>
|
|
69
|
-
pageFieldsHandlers: Record<string, (...args: never[]) => void>
|
|
70
|
-
/** A body registers how to reveal a validation failure (e.g. the blocks body focuses the offending
|
|
71
|
-
* block). The shell calls it on a failed save, staying agnostic of the body's internals. */
|
|
72
|
-
registerRevealError: (fn: () => void) => void
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export const editorFormContextKey = Symbol('kestrel.editorForm') as InjectionKey<EditorFormContext>
|
|
76
|
-
|
|
77
|
-
/** Inject the editor form context inside a body component; throws if used outside `CollectionEditor`. */
|
|
78
|
-
export function useEditorFormContext(): EditorFormContext {
|
|
79
|
-
const ctx = inject(editorFormContextKey)
|
|
80
|
-
if (!ctx) throw new Error('[kestrel] editor body used outside CollectionEditor (no editor form context)')
|
|
81
|
-
return ctx
|
|
82
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import type { Component } from 'vue'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Registry of admin editor *bodies* keyed by editor type (the `editor` field on a serialized collection).
|
|
5
|
-
* Mirrors the field-widget registry (`layers/ui/app/utils/field-registry.ts`), one level up: instead of
|
|
6
|
-
* picking a widget per field type, it picks the whole editor body per collection.
|
|
7
|
-
*
|
|
8
|
-
* The built-in `fields` / `blocks` bodies register themselves when the admin editor loads (see
|
|
9
|
-
* `register-editors`); extensions add their own via a `*.client.ts` plugin (auto-discovered through
|
|
10
|
-
* `extends`), exactly like `registerFieldComponent`. The frame — the admin rail (layout) and the record
|
|
11
|
-
* header/toolbar + form state (`CollectionEditor` / the host pages) — stays generic; only this body swaps.
|
|
12
|
-
*/
|
|
13
|
-
export const editorComponents: Record<string, Component> = {}
|
|
14
|
-
|
|
15
|
-
export const resolveCollectionEditor = (type: string): Component | undefined => editorComponents[type]
|
|
16
|
-
|
|
17
|
-
/** Register an editor body for a type. Mutates the singleton; a later call overrides the same type. */
|
|
18
|
-
export function registerCollectionEditor(type: string, component: Component): void {
|
|
19
|
-
editorComponents[type] = component
|
|
20
|
-
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import type { SerializedField } from '@michaelthielemann/kestrel-core'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* The jsKey (wire key) for a field — NOT a DB column name. The server marks single relation/media fields
|
|
5
|
-
* with `single` (stored as a `<name>Id` FK column, e.g. `author` -> `authorId`); everything else keeps the
|
|
6
|
-
* field name. The editor binds widgets by field name but must read/write rows, request bodies and list
|
|
7
|
-
* queries by this key — reading the server flag instead of re-deriving the rule (which silently drifts).
|
|
8
|
-
*/
|
|
9
|
-
export function jsKey(name: string, field: SerializedField): string {
|
|
10
|
-
return field.single ? `${name}Id` : name
|
|
11
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
// Cell rendering for the collection list: how a raw row value becomes the string in a table cell.
|
|
2
|
-
import type { ListColumn } from './list-columns'
|
|
3
|
-
import { humanizeFieldName } from '../../../ui/app/utils/humanize'
|
|
4
|
-
|
|
5
|
-
const dateFmt = new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: '2-digit' })
|
|
6
|
-
// A date-only value ('YYYY-MM-DD', from a datetime field with precision 'date') parses as UTC midnight;
|
|
7
|
-
// formatting it in the local zone would shift the calendar day, so pin it to UTC. Full ISO timestamps
|
|
8
|
-
// (createdAt/updatedAt carry a 'Z') and local datetimes keep the local-zone formatter.
|
|
9
|
-
const dateOnlyFmt = new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: '2-digit', timeZone: 'UTC' })
|
|
10
|
-
|
|
11
|
-
export function isDateColumn(col: ListColumn): boolean {
|
|
12
|
-
return col.key === 'createdAt' || col.key === 'updatedAt' || col.field?.type === 'datetime'
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function cellText(value: unknown): string {
|
|
16
|
-
if (value == null) return ''
|
|
17
|
-
if (typeof value === 'string') return value.replace(/<[^>]*>/g, '').slice(0, 80)
|
|
18
|
-
if (typeof value === 'object') return JSON.stringify(value).slice(0, 80)
|
|
19
|
-
return String(value)
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function cellDisplay(col: ListColumn, row: Record<string, unknown>): string {
|
|
23
|
-
const value = row[col.key]
|
|
24
|
-
if (isDateColumn(col) && value != null) {
|
|
25
|
-
const dateOnly = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)
|
|
26
|
-
const d = new Date(value as string)
|
|
27
|
-
if (!Number.isNaN(d.getTime())) return (dateOnly ? dateOnlyFmt : dateFmt).format(d)
|
|
28
|
-
}
|
|
29
|
-
return cellText(value)
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** A column header's text: a field column humanizes its own name, chrome columns translate a key. */
|
|
33
|
-
export function columnLabel(col: ListColumn, t: (key: string) => string): string {
|
|
34
|
-
return col.type === 'field' ? humanizeFieldName(col.name ?? col.key) : t(col.labelKey ?? col.key)
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** A row's human label: the first visible non-sidecar cell that has a value, else the bare id — used for
|
|
38
|
-
* the per-row checkbox / action aria-labels so each control names its row. */
|
|
39
|
-
export function rowLabel(columns: ListColumn[], row: Record<string, unknown>): string {
|
|
40
|
-
for (const c of columns) {
|
|
41
|
-
if (c.type === 'translations' || c.type === 'deadRefs') continue
|
|
42
|
-
const d = cellDisplay(c, row)
|
|
43
|
-
if (d) return d
|
|
44
|
-
}
|
|
45
|
-
return `#${row.id}`
|
|
46
|
-
}
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
import { META_FILTER_KIND, fieldFilterKind } from '@michaelthielemann/kestrel-core/client'
|
|
2
|
-
import type { FilterKind } from '@michaelthielemann/kestrel-core/client'
|
|
3
|
-
import type { SerializedCollection, SerializedField } from '@michaelthielemann/kestrel-core'
|
|
4
|
-
import { jsKey } from './field-keys'
|
|
5
|
-
|
|
6
|
-
export type ListColumnType = 'meta' | 'field' | 'translations' | 'deadRefs'
|
|
7
|
-
|
|
8
|
-
export interface ListColumn {
|
|
9
|
-
/** The wire/row key: used to read `row[key]` and as the sort/filter field. The translations column
|
|
10
|
-
* uses the server sidecar key `$translations`, which is never sent as a sort/filter (not a real column). */
|
|
11
|
-
key: string
|
|
12
|
-
type: ListColumnType
|
|
13
|
-
/** Field display name (only for `type:'field'`) — used directly as the column label. */
|
|
14
|
-
name?: string
|
|
15
|
-
/** i18n key for the column label (meta + translations columns). */
|
|
16
|
-
labelKey?: string
|
|
17
|
-
field?: SerializedField
|
|
18
|
-
sortable: boolean
|
|
19
|
-
filterable: boolean
|
|
20
|
-
/** The column's filter kind (present iff `filterable`) — drives the operator set + typed value control. */
|
|
21
|
-
filterKind?: FilterKind
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** The sidecar key the server attaches per row for translation status (mirrors `$media`). */
|
|
25
|
-
export const TRANSLATIONS_KEY = '$translations'
|
|
26
|
-
|
|
27
|
-
/** The sidecar key the server attaches per row when any reference it holds points at a dead/unpublished
|
|
28
|
-
* target (the derived stale-reference warning). Like `$translations`, never a real sort/filter column. */
|
|
29
|
-
export const DEAD_REFS_KEY = '$hasDeadRefs'
|
|
30
|
-
|
|
31
|
-
/** Whether a collection holds any field/block that can reference another record — gates the dead-reference
|
|
32
|
-
* column (mirrors the server's `collectionMayReference`, over the serialized schema). */
|
|
33
|
-
export function hasReferenceFields(schema: SerializedCollection): boolean {
|
|
34
|
-
if (schema.blocks.enabled) return true
|
|
35
|
-
if (schema.seo) return true // the `seo` system column carries a media ref (the social image)
|
|
36
|
-
return Object.values(schema.fields).some(fieldMayReference)
|
|
37
|
-
}
|
|
38
|
-
function fieldMayReference(field: SerializedField): boolean {
|
|
39
|
-
switch (field.type) {
|
|
40
|
-
case 'relation':
|
|
41
|
-
case 'media':
|
|
42
|
-
case 'link':
|
|
43
|
-
case 'richtext':
|
|
44
|
-
return true
|
|
45
|
-
case 'repeater':
|
|
46
|
-
return Object.values((field.options?.fields ?? {}) as Record<string, SerializedField>).some(fieldMayReference)
|
|
47
|
-
default:
|
|
48
|
-
return false
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const META_LABEL: Record<string, string> = {
|
|
53
|
-
id: 'list.col.id',
|
|
54
|
-
path: 'list.col.slug',
|
|
55
|
-
status: 'list.col.status',
|
|
56
|
-
createdAt: 'list.col.createdAt',
|
|
57
|
-
updatedAt: 'list.col.updatedAt',
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Whether a field can be meaningfully SORTED as a single scalar DB column. JSON-backed fields
|
|
61
|
-
* (richtext/json/repeater/link, multi-value choice/relation/media) cannot. (Filterability is a separate,
|
|
62
|
-
* richer question — see `fieldFilterKind`: richtext and the multi-value fields ARE filterable, via
|
|
63
|
-
* substring / JSON-array membership, even though they are not sortable.) */
|
|
64
|
-
function isSortableField(f: SerializedField): boolean {
|
|
65
|
-
switch (f.type) {
|
|
66
|
-
case 'richtext':
|
|
67
|
-
case 'json':
|
|
68
|
-
case 'repeater':
|
|
69
|
-
case 'link':
|
|
70
|
-
return false
|
|
71
|
-
case 'choice':
|
|
72
|
-
return !f.options?.multiple
|
|
73
|
-
case 'relation':
|
|
74
|
-
case 'media':
|
|
75
|
-
return !!f.single
|
|
76
|
-
default:
|
|
77
|
-
return true // text, number, boolean, datetime
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Whether a field column is shown by default — only plain scalar display types. Heavier/structured fields
|
|
82
|
-
* (richtext, json, repeater, link, relation, media, multi-choice) are available but off by default. */
|
|
83
|
-
function isDefaultFieldColumn(f: SerializedField): boolean {
|
|
84
|
-
switch (f.type) {
|
|
85
|
-
case 'text':
|
|
86
|
-
case 'number':
|
|
87
|
-
case 'boolean':
|
|
88
|
-
case 'datetime':
|
|
89
|
-
return true
|
|
90
|
-
case 'choice':
|
|
91
|
-
return !f.options?.multiple
|
|
92
|
-
default:
|
|
93
|
-
return false
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** A meta/system column, filterable iff it has a FilterKind (id/path/status/timestamps all do). */
|
|
98
|
-
function metaColumn(key: string): ListColumn {
|
|
99
|
-
const filterKind = META_FILTER_KIND[key]
|
|
100
|
-
return { key, type: 'meta', labelKey: META_LABEL[key], sortable: true, filterable: !!filterKind, ...(filterKind ? { filterKind } : {}) }
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Every column a collection list can offer, in display order: id · fields · slug · status · timestamps ·
|
|
104
|
-
* translations. The user picks which are visible (see `defaultVisibleKeys` / `resolveVisibleColumns`). */
|
|
105
|
-
export function availableColumns(schema: SerializedCollection): ListColumn[] {
|
|
106
|
-
const cols: ListColumn[] = [metaColumn('id')]
|
|
107
|
-
for (const [name, field] of Object.entries(schema.fields)) {
|
|
108
|
-
const filterKind = fieldFilterKind(field)
|
|
109
|
-
cols.push({ key: jsKey(name, field), type: 'field', name, field, sortable: isSortableField(field), filterable: filterKind !== null, ...(filterKind ? { filterKind } : {}) })
|
|
110
|
-
}
|
|
111
|
-
if (schema.pageLike) cols.push(metaColumn('path'))
|
|
112
|
-
if (schema.status) cols.push(metaColumn('status'))
|
|
113
|
-
cols.push(metaColumn('createdAt'))
|
|
114
|
-
cols.push(metaColumn('updatedAt'))
|
|
115
|
-
if (schema.translatable && schema.mode === 'multi') {
|
|
116
|
-
cols.push({ key: TRANSLATIONS_KEY, type: 'translations', labelKey: 'list.translations', sortable: false, filterable: false })
|
|
117
|
-
}
|
|
118
|
-
// A compact warning column (a triangle on rows holding a stale reference) — only where refs are possible.
|
|
119
|
-
if (hasReferenceFields(schema)) {
|
|
120
|
-
cols.push({ key: DEAD_REFS_KEY, type: 'deadRefs', labelKey: 'list.deadRefs', sortable: false, filterable: false })
|
|
121
|
-
}
|
|
122
|
-
return cols
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** The default visible set: everything except `id` and the heavier field types — i.e. the scalar fields
|
|
126
|
-
* plus slug, status, timestamps and translations. */
|
|
127
|
-
export function defaultVisibleKeys(schema: SerializedCollection): string[] {
|
|
128
|
-
return availableColumns(schema)
|
|
129
|
-
.filter((c) => {
|
|
130
|
-
if (c.key === 'id') return false
|
|
131
|
-
if (c.type === 'field') return isDefaultFieldColumn(c.field!)
|
|
132
|
-
return true
|
|
133
|
-
})
|
|
134
|
-
.map((c) => c.key)
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Project the requested keys onto the available columns: preserves the canonical column order and silently
|
|
138
|
-
* drops keys that no longer exist (e.g. a removed field still listed in a saved preference). */
|
|
139
|
-
export function resolveVisibleColumns(available: ListColumn[], keys: string[]): ListColumn[] {
|
|
140
|
-
const wanted = new Set(keys)
|
|
141
|
-
return available.filter((c) => wanted.has(c.key))
|
|
142
|
-
}
|