@michaelthielemann/kestrel 3.0.1 → 4.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 +35 -110
- package/kestrel.config.ts +1 -1
- package/layers/access/server/middleware/00.ip-allowlist.ts +3 -18
- package/layers/access/server/middleware/access-guard.ts +25 -23
- package/layers/access/server/types.d.ts +1 -2
- package/layers/admin/app/components/AdminAccount.vue +1 -1
- package/layers/admin/app/components/BlockFields.vue +1 -2
- package/layers/admin/app/components/BlockPreview.vue +17 -5
- package/layers/admin/app/components/BlocksBody.vue +1 -1
- package/layers/admin/app/components/CollectionEditor.vue +35 -16
- package/layers/admin/app/components/CollectionList.vue +34 -3
- package/layers/admin/app/components/CollectionListBulkBar.vue +17 -3
- package/layers/admin/app/components/CollectionListFilterPanel.vue +1 -1
- package/layers/admin/app/components/CollectionListPager.vue +1 -1
- package/layers/admin/app/components/CollectionListTable.vue +41 -2
- package/layers/admin/app/components/EditorStatus.vue +8 -7
- package/layers/admin/app/components/LocaleBar.vue +3 -1
- package/layers/admin/app/components/PageFields.vue +2 -4
- package/layers/admin/app/components/SeoFields.vue +2 -4
- package/layers/admin/app/components/SingletonEditor.vue +3 -2
- package/layers/admin/app/composables/useAuth.ts +3 -3
- package/layers/admin/app/composables/useBlockTree.ts +1 -1
- package/layers/admin/app/composables/useBlocks.ts +1 -1
- package/layers/admin/app/composables/useCollectionOps.ts +45 -8
- package/layers/admin/app/composables/useCollections.ts +1 -2
- package/layers/admin/app/composables/useEditForm.ts +35 -17
- package/layers/admin/app/composables/useLinkResolver.ts +1 -1
- package/layers/admin/app/composables/useListBatchActions.ts +20 -3
- package/layers/admin/app/composables/useListColumns.ts +1 -1
- package/layers/admin/app/composables/useListFilterDraft.ts +3 -2
- package/layers/admin/app/composables/useListRows.ts +7 -2
- package/layers/admin/app/composables/useListUrlState.ts +1 -1
- package/layers/admin/app/composables/usePublishStatus.ts +2 -2
- package/layers/admin/app/layouts/admin.vue +0 -1
- package/layers/admin/app/pages/admin/[collection]/[id].vue +5 -4
- package/layers/admin/app/pages/admin/index.vue +1 -1
- package/layers/admin/app/pages/admin/references.vue +2 -2
- package/layers/admin/app/utils/block-tree.ts +1 -1
- package/layers/admin/app/utils/collection-ops.ts +28 -3
- package/layers/admin/app/utils/edit-form.ts +34 -2
- package/layers/admin/app/utils/editor-expose.ts +2 -0
- package/layers/admin/app/utils/editor-form-context.ts +1 -2
- package/layers/admin/app/utils/field-keys.ts +1 -1
- package/layers/admin/app/utils/list-columns.ts +3 -2
- package/layers/admin/app/utils/list-query.ts +2 -2
- package/layers/admin/app/utils/populate-blocks.ts +2 -2
- package/layers/admin/app/utils/reauth.ts +5 -6
- package/layers/auth/server/plugins/01.register-auth-pipelines.ts +7 -0
- package/layers/collections/server/plugins/01.register.ts +9 -12
- package/layers/collections/server/plugins/02.register-relation-populate.ts +5 -7
- package/layers/{fields → core}/app/utils/field-factories.ts +1 -1
- package/layers/core/modules/auto-discovery/extract-block.ts +3 -3
- package/layers/core/modules/auto-discovery/index.ts +68 -11
- package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
- package/layers/core/modules/auto-discovery/scan.ts +77 -0
- package/layers/core/modules/auto-discovery/virtual.d.ts +5 -0
- package/layers/core/modules/component-namespace/index.ts +24 -0
- package/layers/core/modules/kestrel/index.ts +6 -1
- package/layers/core/modules/plugin-order/index.ts +69 -0
- package/layers/core/modules/plugin-order/plugin-order.ts +273 -0
- package/layers/core/nuxt.config.ts +1 -0
- package/layers/core/server/api/[...path].ts +98 -0
- package/layers/core/server/plugins/00.config.ts +12 -0
- package/layers/core/server/plugins/00.migrate.ts +1 -0
- package/layers/core/server/plugins/01.register-introspection-pipeline.ts +5 -0
- package/layers/core/server/plugins/01.register-openapi-pipeline.ts +5 -0
- package/layers/core/server/plugins/01.register-tooling-pipelines.ts +5 -0
- package/layers/core/server/plugins/02.schema-sync.ts +2 -7
- package/layers/core/server/plugins/04.outbox-worker.ts +31 -0
- package/layers/core/server/plugins/05.reindex-refs.ts +5 -0
- package/layers/core/server/routes/__kestrel/dashboard.get.ts +29 -0
- package/layers/core/server/tasks/db/migrate-module.ts +83 -0
- package/layers/core/server/tasks/db/migrate-revisions.ts +20 -0
- package/layers/core/server/tasks/db/migrate.ts +2 -6
- package/layers/core/server/utils/server-config.ts +66 -3
- package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +7 -0
- package/layers/fields/server/plugins/01.register-field-populate.ts +2 -2
- package/layers/media/app/components/KestrelImg.vue +3 -3
- package/layers/media/app/components/MediaLibrary.vue +2 -2
- package/layers/media/app/components/field/Media.vue +1 -1
- package/layers/media/app/composables/useMediaResolver.ts +1 -1
- package/layers/media/app/composables/useMediaUpload.ts +1 -1
- package/layers/media/app/composables/useMediaVariant.ts +2 -2
- package/layers/media/app/utils/ai-disclosure.ts +1 -1
- package/layers/media/app/utils/library.ts +1 -1
- package/layers/media/app/utils/ops.ts +1 -3
- package/layers/media/app/utils/picture-model.ts +2 -2
- package/layers/media/app/utils/variant-name.ts +1 -1
- package/layers/media/server/middleware/ondemand-variants.ts +6 -5
- package/layers/media/server/plugins/01.register-media-pipelines.ts +7 -0
- package/layers/media/server/plugins/02.register-media.ts +10 -7
- package/layers/media/server/plugins/04.variant-capture.ts +12 -8
- package/layers/media/server/plugins/05.media-cleanup.ts +5 -0
- package/layers/media/server/tasks/media/backfill.ts +3 -4
- package/layers/public/app/components/KestrelLink.vue +1 -1
- package/layers/public/app/pages/[...slug].vue +1 -1
- package/layers/public/app/pages/__kestrel/preview.vue +2 -2
- package/layers/public/app/utils/json-ld.ts +1 -1
- package/layers/public/app/utils/link-href.ts +1 -1
- package/layers/public/app/utils/page-head.ts +1 -1
- package/layers/public/modules/deploy-output/index.ts +4 -6
- package/layers/public/modules/prerender-routes/index.ts +4 -4
- package/layers/public/modules/prerender-routes/routes.ts +1 -1
- package/layers/public/modules/prune-media/index.ts +2 -2
- package/layers/public/server/delivery-live/serve.ts +111 -0
- package/layers/public/server/middleware/delivery-live-catchall.ts +7 -0
- package/layers/public/server/pipelines/index.ts +23 -0
- package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +14 -0
- package/layers/public/server/plugins/01.register-public-pipelines.ts +7 -0
- package/layers/public/server/plugins/02.register-links.ts +2 -3
- package/layers/public/server/plugins/03.redirects.ts +41 -23
- package/layers/public/server/plugins/05.plan-publish.ts +5 -0
- package/layers/public/server/plugins/zz.publish.ts +79 -24
- package/layers/public/server/routes/llms-full.txt.get.ts +5 -5
- package/layers/public/server/routes/llms.txt.get.ts +4 -2
- package/layers/public/server/routes/redirects.json.get.ts +2 -2
- package/layers/public/server/routes/robots.txt.get.ts +2 -0
- package/layers/public/server/routes/sitemap.xml.get.ts +16 -6
- package/layers/public/server/tasks/publish/run.ts +9 -4
- package/layers/public/server/utils/publish/render-live.ts +26 -0
- package/layers/ui/app/components/field/Choice.vue +1 -1
- package/layers/ui/app/components/field/Datetime.vue +1 -1
- package/layers/ui/app/components/field/Json.vue +1 -2
- package/layers/ui/app/components/field/Layout.vue +2 -4
- package/layers/ui/app/components/field/Link.vue +1 -1
- package/layers/ui/app/components/field/LinkInternalPicker.vue +3 -3
- package/layers/ui/app/components/field/Number.vue +2 -2
- package/layers/ui/app/components/field/Relation.vue +1 -1
- package/layers/ui/app/components/field/Repeater.vue +1 -3
- package/layers/ui/app/components/field/Slug.vue +2 -3
- package/layers/ui/app/components/field/Text.vue +1 -1
- package/layers/ui/app/components/ui/Richtext.vue +1 -1
- package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
- package/layers/ui/app/composables/useLinkField.ts +1 -1
- package/layers/ui/app/composables/useRepeater.ts +1 -1
- package/layers/ui/app/i18n/de.ts +7 -7
- package/layers/ui/app/i18n/en.ts +7 -39
- package/layers/ui/app/utils/field-component.ts +1 -1
- package/layers/ui/app/utils/field-registry.ts +1 -1
- package/layers/ui/app/utils/icons.ts +2 -6
- package/layers/ui/app/utils/localized.ts +1 -1
- package/nuxt.config.ts +1 -1
- package/package.json +53 -19
- package/scripts/consumer-template-ci.mjs +157 -0
- package/scripts/coverage-all.mjs +163 -0
- package/scripts/dashboard.mjs +89 -0
- package/scripts/hash-password.mjs +1 -1
- package/scripts/lib/coverage-model.mjs +51 -0
- package/scripts/port-graph.mjs +350 -0
- package/scripts/typecheck.mjs +11 -0
- package/templates/starter/app/blocks/Prose.vue +1 -1
- package/layers/access/server/utils/csrf.ts +0 -39
- package/layers/access/server/utils/grant-registry.ts +0 -36
- package/layers/access/server/utils/guard.ts +0 -44
- package/layers/access/server/utils/ip-allowlist.ts +0 -79
- package/layers/access/server/utils/policy.ts +0 -98
- package/layers/access/server/utils/public-resources.ts +0 -11
- package/layers/access/server/utils/render-context.ts +0 -41
- package/layers/access/server/utils/require-admin.ts +0 -15
- package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
- package/layers/auth/server/api/auth/login.post.ts +0 -38
- package/layers/auth/server/api/auth/logout.post.ts +0 -9
- package/layers/auth/server/api/auth/session.get.ts +0 -1
- package/layers/auth/server/utils/admin-credential.ts +0 -42
- package/layers/auth/server/utils/client-ip.ts +0 -88
- package/layers/auth/server/utils/login-throttle.ts +0 -126
- package/layers/auth/server/utils/password.ts +0 -52
- package/layers/auth/server/utils/session-cookie.ts +0 -44
- package/layers/auth/server/utils/session-epoch.ts +0 -96
- package/layers/auth/server/utils/session.ts +0 -88
- package/layers/collections/server/collections/pages.ts +0 -19
- package/layers/collections/server/utils/populate-relations.ts +0 -79
- package/layers/core/app/utils/bulk-actions.ts +0 -9
- package/layers/core/app/utils/filter-ops.ts +0 -98
- package/layers/core/app/utils/list-limits.ts +0 -27
- package/layers/core/app/utils/locale-path.ts +0 -12
- package/layers/core/app/utils/slugify.ts +0 -16
- package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
- package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
- package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
- package/layers/core/server/api/[collection]/[id].get.ts +0 -11
- package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
- package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
- package/layers/core/server/api/[collection]/index.get.ts +0 -24
- package/layers/core/server/api/[collection]/index.post.ts +0 -8
- package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
- package/layers/core/server/api/[collection]/index.put.ts +0 -24
- package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
- package/layers/core/server/api/[collection]/options.get.ts +0 -18
- package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
- package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
- package/layers/core/server/api/[collection]/translations.get.ts +0 -25
- package/layers/core/server/api/collections/[name].get.ts +0 -4
- package/layers/core/server/api/collections/index.get.ts +0 -3
- package/layers/core/server/api/references/broken.get.ts +0 -9
- package/layers/core/server/api/references/referrers.get.ts +0 -24
- package/layers/core/server/database/record-refs.ts +0 -20
- package/layers/core/server/plugins/03.record-refs.ts +0 -12
- package/layers/core/server/schema/bootstrap.ts +0 -41
- package/layers/core/server/schema/desired.ts +0 -99
- package/layers/core/server/schema/dev-sync.ts +0 -23
- package/layers/core/server/schema/dialect.ts +0 -61
- package/layers/core/server/schema/diff.ts +0 -110
- package/layers/core/server/schema/introspect.ts +0 -68
- package/layers/core/server/schema/model.ts +0 -61
- package/layers/core/server/schema/render-sqlite.ts +0 -89
- package/layers/core/server/schema/sync.ts +0 -201
- package/layers/core/server/utils/blocks.ts +0 -6
- package/layers/core/server/utils/collection-types.ts +0 -28
- package/layers/core/server/utils/crud.ts +0 -578
- package/layers/core/server/utils/db.ts +0 -26
- package/layers/core/server/utils/defineCollection.ts +0 -213
- package/layers/core/server/utils/duplicate.ts +0 -162
- package/layers/core/server/utils/field-layout.ts +0 -125
- package/layers/core/server/utils/filter-predicate.ts +0 -101
- package/layers/core/server/utils/http.ts +0 -50
- package/layers/core/server/utils/if-unmodified.ts +0 -13
- package/layers/core/server/utils/kestrel-config.ts +0 -451
- package/layers/core/server/utils/key-lock.ts +0 -33
- package/layers/core/server/utils/locale.ts +0 -53
- package/layers/core/server/utils/page-route.ts +0 -58
- package/layers/core/server/utils/page-slug.ts +0 -127
- package/layers/core/server/utils/picker.ts +0 -82
- package/layers/core/server/utils/populate.ts +0 -50
- package/layers/core/server/utils/prerender-memo.ts +0 -23
- package/layers/core/server/utils/read-capture.ts +0 -37
- package/layers/core/server/utils/record-ref-index.ts +0 -200
- package/layers/core/server/utils/registry.ts +0 -24
- package/layers/core/server/utils/resolve-scope.ts +0 -79
- package/layers/core/server/utils/seo.ts +0 -42
- package/layers/core/server/utils/serialize-collection.ts +0 -147
- package/layers/core/server/utils/sql.ts +0 -2
- package/layers/core/server/utils/storage.local.ts +0 -117
- package/layers/core/server/utils/storage.s3.ts +0 -197
- package/layers/core/server/utils/storage.ts +0 -90
- package/layers/core/server/utils/write-effects.ts +0 -40
- package/layers/core/server/utils/write-events.ts +0 -31
- package/layers/fields/app/utils/condition.ts +0 -71
- package/layers/fields/app/utils/define-block.ts +0 -13
- package/layers/fields/app/utils/field-constraints.ts +0 -17
- package/layers/fields/app/utils/field-meta.ts +0 -33
- package/layers/fields/app/utils/field-validate.ts +0 -75
- package/layers/fields/app/utils/field-value.ts +0 -7
- package/layers/fields/app/utils/richtext-links.ts +0 -48
- package/layers/fields/server/api/blocks/index.get.ts +0 -9
- package/layers/fields/server/field-registry/index.ts +0 -249
- package/layers/fields/server/field-registry/naming.ts +0 -10
- package/layers/fields/server/field-registry/sanitize.ts +0 -43
- package/layers/fields/server/field-registry/types.ts +0 -19
- package/layers/fields/server/utils/block-ids.ts +0 -43
- package/layers/fields/server/utils/block-populate.ts +0 -41
- package/layers/fields/server/utils/buildCollection.ts +0 -90
- package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
- package/layers/fields/server/utils/buildTable.ts +0 -113
- package/layers/fields/server/utils/conditional-required.ts +0 -25
- package/layers/fields/server/utils/defineBlock.ts +0 -86
- package/layers/fields/server/utils/defineFieldType.ts +0 -33
- package/layers/fields/server/utils/extract-refs.ts +0 -152
- package/layers/fields/server/utils/field-populate.ts +0 -68
- package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
- package/layers/media/server/api/media/[id].delete.ts +0 -18
- package/layers/media/server/api/media/[id].get.ts +0 -10
- package/layers/media/server/api/media/[id].patch.test.ts +0 -66
- package/layers/media/server/api/media/[id].patch.ts +0 -65
- package/layers/media/server/api/media/backfill.post.ts +0 -18
- package/layers/media/server/api/media/copy.post.ts +0 -7
- package/layers/media/server/api/media/delete.post.ts +0 -13
- package/layers/media/server/api/media/folders.post.ts +0 -18
- package/layers/media/server/api/media/index.get.ts +0 -15
- package/layers/media/server/api/media/index.post.ts +0 -173
- package/layers/media/server/api/media/library.get.ts +0 -17
- package/layers/media/server/api/media/move.post.ts +0 -7
- package/layers/media/server/api/media/rename.post.ts +0 -7
- package/layers/media/server/api/media/resolve.get.ts +0 -13
- package/layers/media/server/collections/media-settings.ts +0 -25
- package/layers/media/server/collections/media.ts +0 -43
- package/layers/media/server/database/folders.ts +0 -10
- package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
- package/layers/media/server/schema-tables/folders.ts +0 -4
- package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
- package/layers/media/server/utils/ai-signal-detect.ts +0 -155
- package/layers/media/server/utils/backfill.ts +0 -144
- package/layers/media/server/utils/derive.ts +0 -59
- package/layers/media/server/utils/folder-paths.ts +0 -42
- package/layers/media/server/utils/folders.ts +0 -12
- package/layers/media/server/utils/library.ts +0 -91
- package/layers/media/server/utils/media-cleanup.ts +0 -27
- package/layers/media/server/utils/media-enabled.ts +0 -23
- package/layers/media/server/utils/media-ops.ts +0 -118
- package/layers/media/server/utils/media-write.ts +0 -13
- package/layers/media/server/utils/naming.ts +0 -52
- package/layers/media/server/utils/ondemand.ts +0 -69
- package/layers/media/server/utils/persist-upload.ts +0 -62
- package/layers/media/server/utils/populate.ts +0 -47
- package/layers/media/server/utils/record.ts +0 -71
- package/layers/media/server/utils/relocate-ops.ts +0 -341
- package/layers/media/server/utils/relocate-plan.ts +0 -63
- package/layers/media/server/utils/resolve.ts +0 -98
- package/layers/media/server/utils/sanitize-svg.ts +0 -12
- package/layers/media/server/utils/sniff.ts +0 -68
- package/layers/media/server/utils/storage-relocate.ts +0 -139
- package/layers/media/server/utils/translations.ts +0 -19
- package/layers/media/server/utils/usages.ts +0 -66
- package/layers/media/server/utils/variant-capture.ts +0 -70
- package/layers/media/server/utils/variants.ts +0 -123
- package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
- package/layers/public/server/api/links/resolve.get.ts +0 -19
- package/layers/public/server/api/preview.get.ts +0 -28
- package/layers/public/server/api/preview.post.ts +0 -93
- package/layers/public/server/api/publish-status.get.ts +0 -58
- package/layers/public/server/api/publish.post.ts +0 -84
- package/layers/public/server/api/route.get.ts +0 -54
- package/layers/public/server/collections/redirects.ts +0 -75
- package/layers/public/server/collections/site.ts +0 -44
- package/layers/public/server/database/publish-deps.ts +0 -12
- package/layers/public/server/database/publish-status.ts +0 -19
- package/layers/public/server/schema-tables/publish-deps.ts +0 -4
- package/layers/public/server/schema-tables/publish-status.ts +0 -4
- package/layers/public/server/utils/link-resolve.ts +0 -38
- package/layers/public/server/utils/llms-full.ts +0 -125
- package/layers/public/server/utils/llms.ts +0 -52
- package/layers/public/server/utils/page-resolve.ts +0 -208
- package/layers/public/server/utils/populate-links.ts +0 -66
- package/layers/public/server/utils/preview-token.ts +0 -109
- package/layers/public/server/utils/publish/coalesce.ts +0 -22
- package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
- package/layers/public/server/utils/publish/deps.ts +0 -87
- package/layers/public/server/utils/publish/invalidation.ts +0 -225
- package/layers/public/server/utils/publish/pending.ts +0 -74
- package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
- package/layers/public/server/utils/publish/publish-status.ts +0 -72
- package/layers/public/server/utils/publish/publisher.ts +0 -362
- package/layers/public/server/utils/publish/queue.ts +0 -67
- package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
- package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
- package/layers/public/server/utils/publish/route-for-record.ts +0 -16
- package/layers/public/server/utils/publish/route-keys.ts +0 -13
- package/layers/public/server/utils/richtext-markdown.ts +0 -260
- package/layers/public/server/utils/site-url.ts +0 -40
- package/layers/public/server/utils/sitemap.ts +0 -99
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import type { Condition, ConditionOperator, ConditionRule } from '../../../core/server/utils/defineCollection'
|
|
2
|
-
|
|
3
|
-
/** Empty = absent or a "no value" (null/undefined/''/[]). Shared by the evaluator (the `empty`
|
|
4
|
-
* operator + bare-rule presence) and the server's required-when-visible check. 0 and false are values. */
|
|
5
|
-
export function isEmptyValue(value: unknown): boolean {
|
|
6
|
-
return value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0)
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
// v1 path scope is SIBLING-ONLY. A bare name or `./name` resolves a sibling in the current scope;
|
|
10
|
-
// cross-scope forms (`/abs`, `../parent`, dotted `a.b`) are out of v1 and resolve to undefined — so the
|
|
11
|
-
// rule is simply false (the field hides), keeping the evaluator total: it never throws on a bad path.
|
|
12
|
-
function resolvePath(field: string, scope: Record<string, unknown>): unknown {
|
|
13
|
-
const key = field.startsWith('./') ? field.slice(2) : field
|
|
14
|
-
if (key === '' || key.startsWith('/') || key.startsWith('..') || key.includes('/') || key.includes('.')) {
|
|
15
|
-
return undefined
|
|
16
|
-
}
|
|
17
|
-
return scope[key]
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function compare(dep: unknown, target: number | string, op: 'gt' | 'gte' | 'lt' | 'lte'): boolean {
|
|
21
|
-
if (typeof dep !== typeof target || (typeof dep !== 'number' && typeof dep !== 'string')) return false
|
|
22
|
-
if (op === 'gt') return dep > target
|
|
23
|
-
if (op === 'gte') return dep >= target
|
|
24
|
-
if (op === 'lt') return dep < target
|
|
25
|
-
return dep <= target
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function matchOperator(dep: unknown, op: ConditionOperator): boolean {
|
|
29
|
-
for (const [key, target] of Object.entries(op)) {
|
|
30
|
-
if (target === undefined) continue
|
|
31
|
-
let ok: boolean
|
|
32
|
-
switch (key) {
|
|
33
|
-
case 'eq': ok = dep === target; break
|
|
34
|
-
case 'ne': ok = dep !== target; break
|
|
35
|
-
case 'gt': case 'gte': case 'lt': case 'lte':
|
|
36
|
-
ok = compare(dep, target as number | string, key); break
|
|
37
|
-
case 'in': ok = Array.isArray(target) && (target as unknown[]).includes(dep); break
|
|
38
|
-
case 'notIn': ok = Array.isArray(target) && !(target as unknown[]).includes(dep); break
|
|
39
|
-
case 'regexp': {
|
|
40
|
-
if (typeof dep !== 'string' || typeof target !== 'string') { ok = false; break }
|
|
41
|
-
try { ok = new RegExp(target).test(dep) } catch { ok = false }
|
|
42
|
-
break
|
|
43
|
-
}
|
|
44
|
-
case 'empty': ok = target ? isEmptyValue(dep) : !isEmptyValue(dep); break
|
|
45
|
-
default: ok = false
|
|
46
|
-
}
|
|
47
|
-
if (!ok) return false
|
|
48
|
-
}
|
|
49
|
-
return true
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function evaluateRule(rule: ConditionRule, scope: Record<string, unknown>): boolean {
|
|
53
|
-
const dep = resolvePath(rule.field, scope)
|
|
54
|
-
if (rule.is !== undefined) return dep === rule.is
|
|
55
|
-
if (rule.op) return matchOperator(dep, rule.op)
|
|
56
|
-
return !isEmptyValue(dep)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Pure, total (never throws) condition evaluator shared by the editor (hide widgets) and the server
|
|
60
|
-
* (required-when-visible). `scope` is the sibling-value map the condition resolves against. */
|
|
61
|
-
export function evaluateCondition(condition: Condition, scope: Record<string, unknown>): boolean {
|
|
62
|
-
if ('and' in condition) return condition.and.every((c) => evaluateCondition(c, scope))
|
|
63
|
-
if ('or' in condition) return condition.or.some((c) => evaluateCondition(c, scope))
|
|
64
|
-
if ('not' in condition) return !evaluateCondition(condition.not, scope)
|
|
65
|
-
return evaluateRule(condition, scope)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Whether a field is shown given the sibling-value `scope`. No condition => always visible. */
|
|
69
|
-
export function isFieldVisible(field: { condition?: Condition } | null | undefined, scope: Record<string, unknown>): boolean {
|
|
70
|
-
return !field?.condition || evaluateCondition(field.condition, scope)
|
|
71
|
-
}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Runtime no-op for a block SFC's metadata call — `defineBlock({ label, slots, icon })`. The metadata is
|
|
3
|
-
* read at BUILD time by the SFC extractor (from the same call, statically); at runtime this only exists so
|
|
4
|
-
* the SFC's `<script setup>` doesn't reference an undefined `defineBlock`. Auto-imported in the app so block
|
|
5
|
-
* authors call it without an import (the field factories are auto-imported the same way).
|
|
6
|
-
*/
|
|
7
|
-
export function defineBlock(_meta?: {
|
|
8
|
-
label?: string | Record<string, string>
|
|
9
|
-
slots?: string[]
|
|
10
|
-
icon?: string
|
|
11
|
-
}): void {
|
|
12
|
-
// intentionally empty — see above.
|
|
13
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import type { FieldDef } from '../../../core/server/utils/defineCollection'
|
|
2
|
-
|
|
3
|
-
type NumberOptions = Extract<FieldDef, { type: 'number' }>['options']
|
|
4
|
-
type ChoiceOptions = Extract<FieldDef, { type: 'choice' }>['options']
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Whether a number field is integer-valued — the default — i.e. neither
|
|
8
|
-
* `integer: false` nor a `decimals` precision opts it into reals.
|
|
9
|
-
*/
|
|
10
|
-
export function numberIsInteger(options: NumberOptions): boolean {
|
|
11
|
-
return options?.integer !== false && options?.decimals === undefined
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** The allowed values of a choice field, as a non-empty tuple (for `z.enum`). */
|
|
15
|
-
export function choiceValues(options: ChoiceOptions): [string, ...string[]] {
|
|
16
|
-
return options.choices.map((c) => c.value) as [string, ...string[]]
|
|
17
|
-
}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import type { FieldDef, FieldOf } from '../../../core/server/utils/defineCollection'
|
|
2
|
-
import { numberIsInteger } from './field-constraints'
|
|
3
|
-
|
|
4
|
-
export interface FieldConstraints {
|
|
5
|
-
required: boolean
|
|
6
|
-
minlength?: number
|
|
7
|
-
maxlength?: number
|
|
8
|
-
min?: number
|
|
9
|
-
max?: number
|
|
10
|
-
step?: number | 'any'
|
|
11
|
-
multiline?: boolean
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** Input-relevant attributes derived from a FieldDef, for the field widgets. */
|
|
15
|
-
export function fieldConstraints(field: FieldDef): FieldConstraints {
|
|
16
|
-
const c: FieldConstraints = { required: !!field.required }
|
|
17
|
-
// `field as FieldOf<'x'>`: the open consumer arm makes `type` a non-discriminant, so `field.type === 'x'`
|
|
18
|
-
// doesn't narrow `field` — the cast applies the arm the guard just proved. (App-side: type-only, no
|
|
19
|
-
// runtime import of the server-side `fieldIs` guard into the client bundle.)
|
|
20
|
-
if (field.type === 'text') {
|
|
21
|
-
const f = field as FieldOf<'text'>
|
|
22
|
-
if (f.options?.minLength !== undefined) c.minlength = f.options.minLength
|
|
23
|
-
if (f.options?.maxLength !== undefined) c.maxlength = f.options.maxLength
|
|
24
|
-
if (f.options?.multiline) c.multiline = true
|
|
25
|
-
} else if (field.type === 'number') {
|
|
26
|
-
const f = field as FieldOf<'number'>
|
|
27
|
-
if (f.options?.min !== undefined) c.min = f.options.min
|
|
28
|
-
if (f.options?.max !== undefined) c.max = f.options.max
|
|
29
|
-
if (f.options?.decimals !== undefined) c.step = 1 / 10 ** f.options.decimals
|
|
30
|
-
else c.step = numberIsInteger(f.options) ? 1 : 'any'
|
|
31
|
-
}
|
|
32
|
-
return c
|
|
33
|
-
}
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import type { FieldDef, FieldOf } from '../../../core/server/utils/defineCollection'
|
|
2
|
-
import { choiceValues, numberIsInteger } from './field-constraints'
|
|
3
|
-
|
|
4
|
-
function isEmpty(value: unknown): boolean {
|
|
5
|
-
return value === undefined || value === null || value === ''
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Advisory client-side validation for instant feedback. A lightweight mirror of a
|
|
10
|
-
* subset of the server rules — the server Zod schema stays the sole authority.
|
|
11
|
-
*/
|
|
12
|
-
export function validateField(field: FieldDef, value: unknown): string | null {
|
|
13
|
-
if (isEmpty(value)) return field.required ? 'This field is required.' : null
|
|
14
|
-
|
|
15
|
-
switch (field.type) {
|
|
16
|
-
case 'text': {
|
|
17
|
-
if (typeof value !== 'string') return null
|
|
18
|
-
const { minLength, maxLength } = (field as FieldOf<'text'>).options ?? {}
|
|
19
|
-
if (minLength !== undefined && value.length < minLength) return `Must be at least ${minLength} characters.`
|
|
20
|
-
if (maxLength !== undefined && value.length > maxLength) return `Must be at most ${maxLength} characters.`
|
|
21
|
-
return null
|
|
22
|
-
}
|
|
23
|
-
case 'number': {
|
|
24
|
-
if (typeof value !== 'number' || Number.isNaN(value)) return 'Must be a number.'
|
|
25
|
-
const opts = (field as FieldOf<'number'>).options
|
|
26
|
-
const { min, max } = opts ?? {}
|
|
27
|
-
if (numberIsInteger(opts) && !Number.isInteger(value)) return 'Must be a whole number.'
|
|
28
|
-
if (min !== undefined && value < min) return `Must be at least ${min}.`
|
|
29
|
-
if (max !== undefined && value > max) return `Must be at most ${max}.`
|
|
30
|
-
return null
|
|
31
|
-
}
|
|
32
|
-
// The widget model is always already-parsed (Json.vue stores `JSON.parse(raw)`, never raw text), so a
|
|
33
|
-
// json field's value here is never source to re-parse — even when it happens to be a plain string
|
|
34
|
-
// (e.g. `default: 'dark'`). The widget owns raw-text parse errors itself via its own `localError`.
|
|
35
|
-
case 'json':
|
|
36
|
-
return null
|
|
37
|
-
case 'datetime': {
|
|
38
|
-
const dt = (field as FieldOf<'datetime'>).options
|
|
39
|
-
const p = dt?.precision ?? 'datetime'
|
|
40
|
-
const re = p === 'date'
|
|
41
|
-
? /^\d{4}-\d{2}-\d{2}$/
|
|
42
|
-
: p === 'time'
|
|
43
|
-
? /^\d{2}:\d{2}(:\d{2})?$/
|
|
44
|
-
: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/
|
|
45
|
-
if (dt?.range) {
|
|
46
|
-
const v = value && typeof value === 'object' ? (value as { start?: unknown; end?: unknown }) : {}
|
|
47
|
-
const start = typeof v.start === 'string' ? v.start : ''
|
|
48
|
-
const end = typeof v.end === 'string' ? v.end : ''
|
|
49
|
-
// A picker mid-selection (one endpoint chosen, the other still blank) is invalid regardless of
|
|
50
|
-
// `required` — the server only ever accepts a fully-filled range or a fully-empty one.
|
|
51
|
-
if (!start && !end) return field.required ? 'This field is required.' : null
|
|
52
|
-
if (!start || !end) return 'Both start and end are required.'
|
|
53
|
-
if (!re.test(start)) return 'Invalid start.'
|
|
54
|
-
if (!re.test(end)) return 'Invalid end.'
|
|
55
|
-
if (start > end) return 'Start must be before or equal to end.'
|
|
56
|
-
return null
|
|
57
|
-
}
|
|
58
|
-
return typeof value === 'string' && value && !re.test(value) ? 'Invalid date or time.' : null
|
|
59
|
-
}
|
|
60
|
-
case 'choice': {
|
|
61
|
-
const opts = (field as FieldOf<'choice'>).options
|
|
62
|
-
const allowed = new Set(choiceValues(opts))
|
|
63
|
-
if (opts.multiple) {
|
|
64
|
-
const arr = Array.isArray(value) ? value : []
|
|
65
|
-
if (field.required && arr.length === 0) return 'Select at least one option.'
|
|
66
|
-
if (arr.some((v) => !allowed.has(v as string))) return 'Invalid selection.'
|
|
67
|
-
return null
|
|
68
|
-
}
|
|
69
|
-
if (typeof value === 'string' && !allowed.has(value)) return 'Invalid selection.'
|
|
70
|
-
return null
|
|
71
|
-
}
|
|
72
|
-
default:
|
|
73
|
-
return null
|
|
74
|
-
}
|
|
75
|
-
}
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
// Internal links inside richtext are stored as a marker href `kestrel:<collection>:<id>` (the editor
|
|
2
|
-
// sets it via the normal Link mark; sanitize allows the `kestrel:` scheme). At read time the marker is
|
|
3
|
-
// rewritten to the target's real localized path — the richtext counterpart of the `link` field's
|
|
4
|
-
// server populator. Pure string helpers, shared by the server populate pass and the client preview.
|
|
5
|
-
|
|
6
|
-
/** URL scheme that flags an internal richtext link (`kestrel:<collection>:<id>`); shared with sanitize. */
|
|
7
|
-
export const RICHTEXT_LINK_SCHEME = 'kestrel'
|
|
8
|
-
|
|
9
|
-
const MARKER_HREF = new RegExp(`href="${RICHTEXT_LINK_SCHEME}:([a-zA-Z0-9_-]+):(\\d+)"`, 'g')
|
|
10
|
-
const MARKER_VALUE = new RegExp(`^${RICHTEXT_LINK_SCHEME}:([a-zA-Z0-9_-]+):(\\d+)$`)
|
|
11
|
-
|
|
12
|
-
/** The marker href the editor stores for an internal richtext link. */
|
|
13
|
-
export function richtextLinkHref(collection: string, id: number): string {
|
|
14
|
-
return `${RICHTEXT_LINK_SCHEME}:${collection}:${id}`
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Parse a bare `kestrel:<collection>:<id>` marker href back to its `{collection, id}` (the inverse of
|
|
19
|
-
* `richtextLinkHref`); returns `null` for an ordinary/external anchor, a malformed marker, or non-string
|
|
20
|
-
* input. Used by the toolbar to prefill the internal-link picker when re-editing an existing link.
|
|
21
|
-
*/
|
|
22
|
-
export function parseRichtextLinkHref(href: string | null | undefined): { collection: string; id: number } | null {
|
|
23
|
-
if (typeof href !== 'string') return null
|
|
24
|
-
const m = href.match(MARKER_VALUE)
|
|
25
|
-
return m ? { collection: m[1]!, id: Number(m[2]) } : null
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Every internal-link `{collection, id}` referenced by marker hrefs in the HTML (for batch resolve). */
|
|
29
|
-
export function collectRichtextRefs(html: string | null | undefined): { collection: string; id: number }[] {
|
|
30
|
-
if (!html) return []
|
|
31
|
-
return [...html.matchAll(MARKER_HREF)].map((m) => ({ collection: m[1]!, id: Number(m[2]) }))
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const escapeAttr = (s: string): string => s.replace(/&/g, '&').replace(/"/g, '"')
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Rewrite every `kestrel:<collection>:<id>` marker href to whatever the injected resolver returns; a
|
|
38
|
-
* target the resolver declines becomes `#`. The production resolver is status-gated, so that covers a
|
|
39
|
-
* missing target AND a draft one. Ordinary anchors are left untouched. Non-string input → `''`.
|
|
40
|
-
*/
|
|
41
|
-
export function resolveRichtextLinks(
|
|
42
|
-
html: string | null | undefined,
|
|
43
|
-
resolveHref: (collection: string, id: number) => string | null,
|
|
44
|
-
): string {
|
|
45
|
-
if (typeof html !== 'string' || !html) return ''
|
|
46
|
-
return html.replace(MARKER_HREF, (_m, collection: string, idStr: string) =>
|
|
47
|
-
`href="${escapeAttr(resolveHref(collection, Number(idStr)) ?? '#')}"`)
|
|
48
|
-
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
// Admin-only (default-deny applies to /api/**): the per-block field schemas the editor needs to
|
|
2
|
-
// render block inputs. `?allowed=a,b` restricts to a collection's allowed block types.
|
|
3
|
-
export default defineEventHandler((event) => {
|
|
4
|
-
const q = getQuery(event)
|
|
5
|
-
const allowed = typeof q.allowed === 'string' && q.allowed.length
|
|
6
|
-
? q.allowed.split(',').map((s) => s.trim()).filter(Boolean)
|
|
7
|
-
: undefined
|
|
8
|
-
return { data: allBlocks(allowed).map(serializeBlock) }
|
|
9
|
-
})
|
|
@@ -1,249 +0,0 @@
|
|
|
1
|
-
import { integer, real, text } from 'drizzle-orm/sqlite-core'
|
|
2
|
-
import type { SQLiteColumnBuilder } from 'drizzle-orm/sqlite-core'
|
|
3
|
-
import { sql } from 'drizzle-orm'
|
|
4
|
-
import { z } from 'zod'
|
|
5
|
-
import type { FieldDef, FieldType, LinkValue } from '../../../core/server/utils/defineCollection'
|
|
6
|
-
import { fieldIs } from '../../../core/server/utils/defineCollection'
|
|
7
|
-
import type { FieldTypeDescriptor } from './types'
|
|
8
|
-
import { sanitizeRichtext } from './sanitize'
|
|
9
|
-
import { choiceValues, numberIsInteger } from '../../app/utils/field-constraints'
|
|
10
|
-
import { slugify } from '../../../core/app/utils/slugify'
|
|
11
|
-
import { refineConditionalRequired } from '../utils/conditional-required'
|
|
12
|
-
|
|
13
|
-
// A conditional field is only `required` when its condition is met — which the per-column / per-field
|
|
14
|
-
// schema can't see (no sibling access). So it is never enforced HERE: the column stays nullable and the
|
|
15
|
-
// validator stays optional; the whole-record `applyConditions` hook re-enforces required-when-visible.
|
|
16
|
-
export function isHardRequired(field: FieldDef): boolean {
|
|
17
|
-
return !!field.required && !field.condition
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function constrain<T extends SQLiteColumnBuilder>(col: T, field: FieldDef): T {
|
|
21
|
-
let c = col
|
|
22
|
-
if (isHardRequired(field)) c = c.notNull() as T
|
|
23
|
-
if (field.unique) c = c.unique() as T
|
|
24
|
-
if (field.default !== undefined) c = c.default(field.default as never) as T
|
|
25
|
-
return c
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export const opt = (s: z.ZodType, field: FieldDef): z.ZodType => (isHardRequired(field) ? s : s.nullish())
|
|
29
|
-
export const optArr = (s: z.ZodType, field?: FieldDef): z.ZodType =>
|
|
30
|
-
field && isHardRequired(field)
|
|
31
|
-
? s.refine((v) => Array.isArray(v) && v.length > 0, { message: 'At least one value is required' })
|
|
32
|
-
// Non-required: an EXPLICIT null is a clear → the empty array [] (so a PATCH `field: null` actually
|
|
33
|
-
// resets it, consistent with the .nullish() scalar fields, rather than mapping to undefined which
|
|
34
|
-
// drizzle's .set() silently skips). An absent (undefined) value stays undefined so the column default
|
|
35
|
-
// ([]) applies on insert.
|
|
36
|
-
: z.preprocess((v) => (v === null ? [] : v), s.optional())
|
|
37
|
-
|
|
38
|
-
// Array-backed column (multiple choice/media, many-relation, repeater). Honours a declared `default` (a
|
|
39
|
-
// default multi-selection / seed rows), serialized as a JSON literal by the schema renderer; else '[]'.
|
|
40
|
-
// (`unique` is intentionally not applied — a UNIQUE index over a JSON blob is not meaningful.)
|
|
41
|
-
const jsonArray = (dbName: string, field?: FieldDef) =>
|
|
42
|
-
text(dbName, { mode: 'json' }).$type<unknown[]>().notNull()
|
|
43
|
-
.default(field?.default !== undefined ? (field.default as never) : sql`'[]'`)
|
|
44
|
-
|
|
45
|
-
/** Type a built-in descriptor against its SPECIFIC arm (`FieldOf<T>`) so its body narrows correctly, then
|
|
46
|
-
* widen to the registry's general `FieldTypeDescriptor` for storage. */
|
|
47
|
-
const fieldType = <T extends FieldType>(d: FieldTypeDescriptor<T>): FieldTypeDescriptor =>
|
|
48
|
-
d as unknown as FieldTypeDescriptor
|
|
49
|
-
|
|
50
|
-
/** Whether `field.unique` actually reaches a DB constraint for THIS field (options-dependent, not just
|
|
51
|
-
* type-dependent — a `choice`/`media` field is only json-backed when `multiple`, a `relation` only when
|
|
52
|
-
* `many`). Mirrors the `column()` arms above exactly: the json/array-backed arms (`jsonArray`, the plain
|
|
53
|
-
* `json` type) never call `constrain()`, so `.unique()` is never invoked — `unique: true` there is a silent
|
|
54
|
-
* no-op, not a soft hint. `buildTable` uses this to fail loud at collection-build time instead. */
|
|
55
|
-
export function fieldCanEnforceUnique(field: FieldDef): boolean {
|
|
56
|
-
// `fieldIs` not `switch`: the open consumer arm makes `type` a non-discriminant (no switch narrowing).
|
|
57
|
-
if (fieldIs(field, 'choice')) return !field.options.multiple
|
|
58
|
-
if (fieldIs(field, 'media')) return !field.options?.multiple
|
|
59
|
-
if (fieldIs(field, 'relation')) return !field.relation.many
|
|
60
|
-
if (fieldIs(field, 'repeater')) return false
|
|
61
|
-
if (fieldIs(field, 'json')) return false
|
|
62
|
-
return true
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export const fieldTypes: Record<string, FieldTypeDescriptor> = {
|
|
66
|
-
text: fieldType<'text'>({
|
|
67
|
-
column: (n, f) => constrain(text(n), f),
|
|
68
|
-
// Plain text is stored verbatim (trimmed): the column is not HTML and every consumer
|
|
69
|
-
// escapes on output, so tag-stripping here would only corrupt legitimate input (e.g. "a < b").
|
|
70
|
-
validator: (f) => {
|
|
71
|
-
let s = z.string().trim()
|
|
72
|
-
if (f.type === 'text') {
|
|
73
|
-
if (f.options?.minLength !== undefined) s = s.min(f.options.minLength)
|
|
74
|
-
if (f.options?.maxLength !== undefined) s = s.max(f.options.maxLength)
|
|
75
|
-
}
|
|
76
|
-
// A hard-required string must be non-empty — '' / whitespace is "missing" to the client validator
|
|
77
|
-
// and the conditional-required check, so the server (sole authority) must agree. Only add the
|
|
78
|
-
// implicit min(1) when no explicit minLength already enforces a lower bound.
|
|
79
|
-
if (isHardRequired(f) && (f.type !== 'text' || f.options?.minLength === undefined)) s = s.min(1)
|
|
80
|
-
return opt(s, f)
|
|
81
|
-
},
|
|
82
|
-
}),
|
|
83
|
-
// A url-safe slug, stored as text. Optionality-aware like text, but typically left non-required so it can
|
|
84
|
-
// be omitted and auto-generated from another field. `transform` derives it on write: an explicit value is
|
|
85
|
-
// slugified (normalised); a blank value falls back to slugify(record[options.from]) (e.g. the title). The
|
|
86
|
-
// `options.from`/`options.prefix` are read by the client widget too (prefix is display-only). A `unique`
|
|
87
|
-
// slug collision is a HARD, field-scoped error (`assertUniqueSlugs` in crud — no silent dedup; the editor
|
|
88
|
-
// must pick a different slug), needing the DB the pure `transform` hook can't touch, so it lives in crud.
|
|
89
|
-
slug: fieldType<'slug'>({
|
|
90
|
-
column: (n, f) => constrain(text(n), f),
|
|
91
|
-
validator: (f) => opt(isHardRequired(f) ? z.string().trim().min(1) : z.string().trim(), f),
|
|
92
|
-
transform: (value, record, field) => {
|
|
93
|
-
const explicit = typeof value === 'string' ? value.trim() : ''
|
|
94
|
-
if (explicit) return slugify(explicit)
|
|
95
|
-
const from = field.options?.from
|
|
96
|
-
const source = from ? record[from] : undefined
|
|
97
|
-
return typeof source === 'string' ? (slugify(source) || value) : value
|
|
98
|
-
},
|
|
99
|
-
}),
|
|
100
|
-
richtext: {
|
|
101
|
-
column: (n, f) => constrain(text(n), f),
|
|
102
|
-
// Required richtext must carry content: sanitize first, then reject a sanitized-empty result
|
|
103
|
-
// (e.g. '' or markup that strips to nothing), mirroring the non-empty rule for plain text.
|
|
104
|
-
validator: (f) => {
|
|
105
|
-
const base = z.string().transform(sanitizeRichtext)
|
|
106
|
-
if (!isHardRequired(f)) return base.nullish()
|
|
107
|
-
return base.refine((v) => v.trim().length > 0, { message: 'This field is required' })
|
|
108
|
-
},
|
|
109
|
-
},
|
|
110
|
-
number: fieldType<'number'>({
|
|
111
|
-
column: (n, f) =>
|
|
112
|
-
constrain(!numberIsInteger(f.options) ? real(n) : integer(n), f),
|
|
113
|
-
validator: (f) => {
|
|
114
|
-
let n = z.number()
|
|
115
|
-
if (numberIsInteger(f.options)) n = n.int()
|
|
116
|
-
if (f.options?.min !== undefined) n = n.min(f.options.min)
|
|
117
|
-
if (f.options?.max !== undefined) n = n.max(f.options.max)
|
|
118
|
-
return opt(n, f)
|
|
119
|
-
},
|
|
120
|
-
}),
|
|
121
|
-
boolean: {
|
|
122
|
-
column: (n, f) => constrain(integer(n, { mode: 'boolean' }), f),
|
|
123
|
-
validator: (f) => opt(z.boolean(), f),
|
|
124
|
-
},
|
|
125
|
-
datetime: fieldType<'datetime'>({
|
|
126
|
-
column: (n, f) =>
|
|
127
|
-
f.options?.range
|
|
128
|
-
? constrain(text(n, { mode: 'json' }).$type<{ start: string; end: string }>(), f)
|
|
129
|
-
: constrain(text(n), f),
|
|
130
|
-
validator: (f) => {
|
|
131
|
-
const p = f.options?.precision ?? 'datetime'
|
|
132
|
-
const base = p === 'date' ? z.iso.date() : p === 'time' ? z.iso.time() : z.iso.datetime({ local: true })
|
|
133
|
-
if (f.options?.range) {
|
|
134
|
-
const r = z.object({ start: base, end: base }).refine((v) => v.start <= v.end, { message: 'start must be before or equal to end' })
|
|
135
|
-
return isHardRequired(f) ? r : r.nullish()
|
|
136
|
-
}
|
|
137
|
-
return opt(base, f)
|
|
138
|
-
},
|
|
139
|
-
}),
|
|
140
|
-
choice: fieldType<'choice'>({
|
|
141
|
-
column: (n, f) => (f.options.multiple ? jsonArray(n, f) : constrain(text(n), f)),
|
|
142
|
-
validator: (f) => {
|
|
143
|
-
const values = choiceValues(f.options)
|
|
144
|
-
const e = z.enum(values)
|
|
145
|
-
return f.options.multiple ? optArr(z.array(e), f) : opt(e, f)
|
|
146
|
-
},
|
|
147
|
-
}),
|
|
148
|
-
link: {
|
|
149
|
-
column: (n, f) => constrain(text(n, { mode: 'json' }).$type<LinkValue>(), f),
|
|
150
|
-
// The validator is type-agnostic; options.types/collections only constrain the editor widget.
|
|
151
|
-
validator: (f) => {
|
|
152
|
-
const label = z.string().trim().optional()
|
|
153
|
-
// Optional URL fragment for internal links (e.g. link to a page's section). Stored WITHOUT the
|
|
154
|
-
// leading '#' (a lenient leading '#' is stripped); restricted to an id-safe charset since it ends
|
|
155
|
-
// up verbatim in a static <a href>. `linkToHref` appends it as `<path>#<hash>` at render.
|
|
156
|
-
const hash = z.string().trim().transform((s) => s.replace(/^#+/, '')).pipe(z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/, 'Invalid anchor')).optional()
|
|
157
|
-
return opt(
|
|
158
|
-
z.discriminatedUnion('type', [
|
|
159
|
-
z.object({ type: z.literal('internal'), collection: z.string().min(1), id: z.number().int(), hash, label }),
|
|
160
|
-
z.object({
|
|
161
|
-
type: z.literal('external'),
|
|
162
|
-
// http(s) only, no control chars, no embedded credentials — the value ends up in a static <a href>.
|
|
163
|
-
url: z.string().trim().pipe(z.url({ protocol: /^https?$/ })).refine((v) => {
|
|
164
|
-
// eslint-disable-next-line no-control-regex -- deliberately rejects control characters embedded in a URL destined for a static <a href>
|
|
165
|
-
if (/[\u0000-\u001f]/.test(v)) return false
|
|
166
|
-
const u = new URL(v)
|
|
167
|
-
return !u.username && !u.password
|
|
168
|
-
}, 'URL must not contain control characters or embedded credentials'),
|
|
169
|
-
label,
|
|
170
|
-
}),
|
|
171
|
-
z.object({ type: z.literal('email'), email: z.string().trim().pipe(z.email()), label }),
|
|
172
|
-
z.object({ type: z.literal('tel'), tel: z.string().trim().min(1).regex(/^[+0-9 ()\-./]+$/).refine((v) => /[0-9]/.test(v), 'Tel must contain at least one digit'), label }),
|
|
173
|
-
]),
|
|
174
|
-
f,
|
|
175
|
-
)
|
|
176
|
-
},
|
|
177
|
-
},
|
|
178
|
-
media: fieldType<'media'>({
|
|
179
|
-
column: (n, f) => (f.options?.multiple ? jsonArray(n, f) : constrain(integer(n), f)),
|
|
180
|
-
validator: (f) =>
|
|
181
|
-
f.options?.multiple ? optArr(z.array(z.number().int()), f) : opt(z.number().int(), f),
|
|
182
|
-
}),
|
|
183
|
-
relation: fieldType<'relation'>({
|
|
184
|
-
column: (n, f) => (f.relation.many ? jsonArray(n, f) : constrain(integer(n), f)),
|
|
185
|
-
validator: (f) =>
|
|
186
|
-
f.relation.many ? optArr(z.array(z.number().int()), f) : opt(z.number().int(), f),
|
|
187
|
-
}),
|
|
188
|
-
repeater: fieldType<'repeater'>({
|
|
189
|
-
column: (n, f) => jsonArray(n, f),
|
|
190
|
-
validator: (f) => {
|
|
191
|
-
const shape = Object.fromEntries(
|
|
192
|
-
Object.entries(f.options.fields).map(([k, sub]) => [k, getFieldType(sub.type).validator(sub)]),
|
|
193
|
-
)
|
|
194
|
-
// Re-enforce conditional-required sub-fields per entry (the entry object is where siblings are visible).
|
|
195
|
-
return optArr(z.array(refineConditionalRequired(z.object(shape), f.options.fields)), f)
|
|
196
|
-
},
|
|
197
|
-
}),
|
|
198
|
-
json: {
|
|
199
|
-
column: (n, f) => text(n, { mode: 'json' }).$type<unknown>().notNull().default(f?.default !== undefined ? (f.default as never) : sql`'{}'`),
|
|
200
|
-
// Optionality-aware like every other arm. The column is NOT NULL with a '{}' default, so a non-required
|
|
201
|
-
// null/undefined must become `undefined` (→ default applies) rather than reaching the column as null (an
|
|
202
|
-
// unmapped NOT NULL 500); a required json must be present (a clean 400, not a 500).
|
|
203
|
-
validator: (f) =>
|
|
204
|
-
isHardRequired(f)
|
|
205
|
-
? z.unknown().refine((v) => v !== null && v !== undefined, { message: 'This field is required' })
|
|
206
|
-
// Explicit null clears to {} (a PATCH reset); undefined stays absent so the column default ({}) applies.
|
|
207
|
-
: z.preprocess((v) => (v === null ? {} : v), z.unknown().optional()),
|
|
208
|
-
},
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/** The names Kestrel ships built-in (captured before any consumer registration). */
|
|
212
|
-
const BUILTIN_FIELD_TYPES = new Set(Object.keys(fieldTypes))
|
|
213
|
-
|
|
214
|
-
/** Register a consumer-defined field type (via `defineFieldType`). Later registration wins, but never
|
|
215
|
-
* silently: a bad descriptor throws a clear error at registration (not a cryptic TypeError when a table is
|
|
216
|
-
* later built), and ANY re-registration of an existing name warns — overriding a built-in, or one extension
|
|
217
|
-
* clobbering another's same-named type. */
|
|
218
|
-
export function registerFieldType(name: string, descriptor: FieldTypeDescriptor): void {
|
|
219
|
-
if (typeof name !== 'string' || !name.trim()) {
|
|
220
|
-
throw new Error('[kestrel] registerFieldType: name must be a non-empty string')
|
|
221
|
-
}
|
|
222
|
-
if (typeof descriptor?.column !== 'function') {
|
|
223
|
-
throw new Error(`[kestrel] registerFieldType("${name}"): descriptor.column must be a function`)
|
|
224
|
-
}
|
|
225
|
-
if (typeof descriptor?.validator !== 'function') {
|
|
226
|
-
throw new Error(`[kestrel] registerFieldType("${name}"): descriptor.validator must be a function`)
|
|
227
|
-
}
|
|
228
|
-
if (descriptor.transform !== undefined && typeof descriptor.transform !== 'function') {
|
|
229
|
-
throw new Error(`[kestrel] registerFieldType("${name}"): descriptor.transform must be a function if provided`)
|
|
230
|
-
}
|
|
231
|
-
// Object.hasOwn, not `in`: a field type named 'constructor'/'toString'/… would otherwise match an
|
|
232
|
-
// inherited Object.prototype member (a spurious "re-registered" warning, and below a truthy lookup).
|
|
233
|
-
if (Object.hasOwn(fieldTypes, name)) {
|
|
234
|
-
console.warn(BUILTIN_FIELD_TYPES.has(name)
|
|
235
|
-
? `[kestrel] field type "${name}" overrides a built-in`
|
|
236
|
-
: `[kestrel] field type "${name}" re-registered — the previous definition was overwritten`)
|
|
237
|
-
}
|
|
238
|
-
fieldTypes[name] = descriptor
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/** Look up a field-type descriptor, with a clear error for an unknown / unregistered type. */
|
|
242
|
-
export function getFieldType(type: string): FieldTypeDescriptor {
|
|
243
|
-
// Object.hasOwn guards the prototype chain: getFieldType('constructor') must throw the clear error, not
|
|
244
|
-
// return Object.prototype.constructor and then crash with `.column is not a function` at table build.
|
|
245
|
-
if (!Object.hasOwn(fieldTypes, type)) {
|
|
246
|
-
throw new Error(`[kestrel] unknown field type "${type}" — register it with defineFieldType in server/field-types/`)
|
|
247
|
-
}
|
|
248
|
-
return fieldTypes[type]
|
|
249
|
-
}
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { isSingleRefColumn, type FieldDef } from '../../../core/server/utils/defineCollection'
|
|
2
|
-
|
|
3
|
-
export function toSnakeCase(key: string): string {
|
|
4
|
-
return key.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`)
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export function resolveColumnName(key: string, field: FieldDef): { jsKey: string; dbName: string } {
|
|
8
|
-
if (isSingleRefColumn(field)) return { jsKey: `${key}Id`, dbName: `${toSnakeCase(key)}_id` }
|
|
9
|
-
return { jsKey: key, dbName: toSnakeCase(key) }
|
|
10
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import sanitizeHtml from 'sanitize-html'
|
|
2
|
-
import { RICHTEXT_LINK_SCHEME } from '../../app/utils/richtext-links'
|
|
3
|
-
|
|
4
|
-
// Kept deliberately in step with the editor's schema (`ui/Richtext.vue`): a tag allowed here that no
|
|
5
|
-
// extension can parse is not "supported", it is a delayed deletion — the editor drops it on load and the
|
|
6
|
-
// next save persists the loss. `richtext.dom.test.ts` asserts the two lists agree, so widening this one
|
|
7
|
-
// means teaching the editor the tag in the same change. Images belong in a media field or an image
|
|
8
|
-
// block, not in flow text; tables await an editor that can hold them.
|
|
9
|
-
export const RICHTEXT_ALLOWLIST: sanitizeHtml.IOptions = {
|
|
10
|
-
allowedTags: [
|
|
11
|
-
'p', 'br', 'span', 'strong', 'b', 'em', 'i', 'u', 's', 'sub', 'sup', 'mark',
|
|
12
|
-
'blockquote', 'pre', 'code', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
13
|
-
'ul', 'ol', 'li', 'a', 'hr',
|
|
14
|
-
],
|
|
15
|
-
allowedAttributes: {
|
|
16
|
-
a: ['href', 'title', 'target', 'rel'],
|
|
17
|
-
'*': ['class', 'style'],
|
|
18
|
-
},
|
|
19
|
-
allowedStyles: {
|
|
20
|
-
'*': { 'text-align': [/^(left|right|center|justify)$/] },
|
|
21
|
-
},
|
|
22
|
-
// `kestrel:` is the internal-link marker (`kestrel:<collection>:<id>`), resolved to a real localized
|
|
23
|
-
// path at read time; it must survive sanitisation so the resolver can rewrite it.
|
|
24
|
-
allowedSchemes: ['http', 'https', 'mailto', 'tel', RICHTEXT_LINK_SCHEME],
|
|
25
|
-
allowedSchemesAppliedToAttributes: ['href', 'src'],
|
|
26
|
-
disallowedTagsMode: 'discard',
|
|
27
|
-
transformTags: {
|
|
28
|
-
// Only absolute http(s) (incl. protocol-relative) links are "external" → open in a new isolated
|
|
29
|
-
// tab + nofollow. Internal/relative/mailto/tel/kestrel: links keep no target/rel (a same-site link
|
|
30
|
-
// must not open a new tab or be nofollowed); any author-supplied target/rel on them is dropped.
|
|
31
|
-
a: (tagName, attribs) => {
|
|
32
|
-
if (/^(https?:)?\/\//i.test(attribs.href ?? '')) {
|
|
33
|
-
return { tagName, attribs: { ...attribs, target: '_blank', rel: 'noopener noreferrer nofollow' } }
|
|
34
|
-
}
|
|
35
|
-
const { target: _t, rel: _r, ...rest } = attribs
|
|
36
|
-
return { tagName, attribs: rest }
|
|
37
|
-
},
|
|
38
|
-
},
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function sanitizeRichtext(html: string): string {
|
|
42
|
-
return sanitizeHtml(html, RICHTEXT_ALLOWLIST)
|
|
43
|
-
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import type { SQLiteColumnBuilderBase } from 'drizzle-orm/sqlite-core'
|
|
2
|
-
import type { ZodType } from 'zod'
|
|
3
|
-
import type { FieldOf, FieldType } from '../../../core/server/utils/defineCollection'
|
|
4
|
-
|
|
5
|
-
/** Descriptor for a field `type`. Generic over the type name so a built-in descriptor's body receives its
|
|
6
|
-
* SPECIFIC `FieldDef` arm (`FieldOf<'text'>` etc.) — the registry can't discriminate the union itself
|
|
7
|
-
* (the open consumer arm makes `type` a non-discriminant). The default `T = FieldType` widens `field` back
|
|
8
|
-
* to the full union for the registry's storage type + consumer-registered descriptors. */
|
|
9
|
-
export interface FieldTypeDescriptor<T extends FieldType = FieldType> {
|
|
10
|
-
/** The ONLY place a Drizzle column type is decided. Pure; no Nitro/DB. */
|
|
11
|
-
column(dbName: string, field: FieldOf<T>): SQLiteColumnBuilderBase
|
|
12
|
-
/** Complete, optionality-aware Zod for the field value (drop-in replacement schema). */
|
|
13
|
-
validator(field: FieldOf<T>): ZodType
|
|
14
|
-
/** Optional write-time derivation: given this field's (validated) value, the whole record, and the field
|
|
15
|
-
* def, return the value to persist — e.g. a slug auto-generated from another field when left blank. Runs
|
|
16
|
-
* on create/update AFTER validation, BEFORE insert. Pure (no DB/Nitro); cross-field reads come from
|
|
17
|
-
* `record`. */
|
|
18
|
-
transform?(value: unknown, record: Record<string, unknown>, field: FieldOf<T>): unknown
|
|
19
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto'
|
|
2
|
-
|
|
3
|
-
// Server-side twin of the admin's `cloneBlockTree` (layers/admin/app/utils/block-tree.ts): re-mint every
|
|
4
|
-
// block id at every slot depth, preserving type / props / structure. This is a DELIBERATE crossing-free
|
|
5
|
-
// duplication, NOT a reshape: `cloneBlockTree` lives in the ADMIN (client) layer and is unreachable from a
|
|
6
|
-
// server layer, so the record-duplicate operation gets its own pure, node-tested helper here in `fields`
|
|
7
|
-
// (the block-content-shape home) rather than inverting the layer dependency. No field-def dependency — a
|
|
8
|
-
// plain traversal of the `{ id, type, props?, slots? }` node shape — so it unit-tests hard.
|
|
9
|
-
|
|
10
|
-
type GenId = () => string
|
|
11
|
-
|
|
12
|
-
interface BlockNode {
|
|
13
|
-
id: string
|
|
14
|
-
type: string
|
|
15
|
-
props?: Record<string, unknown>
|
|
16
|
-
slots?: Record<string, unknown>
|
|
17
|
-
[key: string]: unknown
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Return a copy of a block `content` array with a FRESH id on every node at every slot depth, so a
|
|
22
|
-
* duplicated record never shares block identity with its source (the id-addressed tree, error addressing
|
|
23
|
-
* and per-record edit state all rely on ids being unique per record). Props and slot structure are deep-
|
|
24
|
-
* cloned and preserved verbatim. A non-array `content` (or a non-object node) passes through untouched.
|
|
25
|
-
* `genId` is injectable for deterministic tests; it defaults to `crypto.randomUUID`.
|
|
26
|
-
*/
|
|
27
|
-
export function regenerateBlockIds(content: unknown, genId: GenId = randomUUID): unknown {
|
|
28
|
-
if (!Array.isArray(content)) return content
|
|
29
|
-
return content.map((node) => cloneNode(node, genId))
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function cloneNode(node: unknown, genId: GenId): unknown {
|
|
33
|
-
if (!node || typeof node !== 'object' || Array.isArray(node)) return node
|
|
34
|
-
const n = node as BlockNode
|
|
35
|
-
const copy: BlockNode = { ...n, id: genId() }
|
|
36
|
-
if (n.props && typeof n.props === 'object') copy.props = structuredClone(n.props)
|
|
37
|
-
if (n.slots && typeof n.slots === 'object' && !Array.isArray(n.slots)) {
|
|
38
|
-
copy.slots = Object.fromEntries(
|
|
39
|
-
Object.entries(n.slots).map(([name, arr]) => [name, Array.isArray(arr) ? arr.map((c) => cloneNode(c, genId)) : arr]),
|
|
40
|
-
)
|
|
41
|
-
}
|
|
42
|
-
return copy
|
|
43
|
-
}
|