@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.
Files changed (341) hide show
  1. package/README.md +35 -110
  2. package/kestrel.config.ts +1 -1
  3. package/layers/access/server/middleware/00.ip-allowlist.ts +3 -18
  4. package/layers/access/server/middleware/access-guard.ts +25 -23
  5. package/layers/access/server/types.d.ts +1 -2
  6. package/layers/admin/app/components/AdminAccount.vue +1 -1
  7. package/layers/admin/app/components/BlockFields.vue +1 -2
  8. package/layers/admin/app/components/BlockPreview.vue +17 -5
  9. package/layers/admin/app/components/BlocksBody.vue +1 -1
  10. package/layers/admin/app/components/CollectionEditor.vue +35 -16
  11. package/layers/admin/app/components/CollectionList.vue +34 -3
  12. package/layers/admin/app/components/CollectionListBulkBar.vue +17 -3
  13. package/layers/admin/app/components/CollectionListFilterPanel.vue +1 -1
  14. package/layers/admin/app/components/CollectionListPager.vue +1 -1
  15. package/layers/admin/app/components/CollectionListTable.vue +41 -2
  16. package/layers/admin/app/components/EditorStatus.vue +8 -7
  17. package/layers/admin/app/components/LocaleBar.vue +3 -1
  18. package/layers/admin/app/components/PageFields.vue +2 -4
  19. package/layers/admin/app/components/SeoFields.vue +2 -4
  20. package/layers/admin/app/components/SingletonEditor.vue +3 -2
  21. package/layers/admin/app/composables/useAuth.ts +3 -3
  22. package/layers/admin/app/composables/useBlockTree.ts +1 -1
  23. package/layers/admin/app/composables/useBlocks.ts +1 -1
  24. package/layers/admin/app/composables/useCollectionOps.ts +45 -8
  25. package/layers/admin/app/composables/useCollections.ts +1 -2
  26. package/layers/admin/app/composables/useEditForm.ts +35 -17
  27. package/layers/admin/app/composables/useLinkResolver.ts +1 -1
  28. package/layers/admin/app/composables/useListBatchActions.ts +20 -3
  29. package/layers/admin/app/composables/useListColumns.ts +1 -1
  30. package/layers/admin/app/composables/useListFilterDraft.ts +3 -2
  31. package/layers/admin/app/composables/useListRows.ts +7 -2
  32. package/layers/admin/app/composables/useListUrlState.ts +1 -1
  33. package/layers/admin/app/composables/usePublishStatus.ts +2 -2
  34. package/layers/admin/app/layouts/admin.vue +0 -1
  35. package/layers/admin/app/pages/admin/[collection]/[id].vue +5 -4
  36. package/layers/admin/app/pages/admin/index.vue +1 -1
  37. package/layers/admin/app/pages/admin/references.vue +2 -2
  38. package/layers/admin/app/utils/block-tree.ts +1 -1
  39. package/layers/admin/app/utils/collection-ops.ts +28 -3
  40. package/layers/admin/app/utils/edit-form.ts +34 -2
  41. package/layers/admin/app/utils/editor-expose.ts +2 -0
  42. package/layers/admin/app/utils/editor-form-context.ts +1 -2
  43. package/layers/admin/app/utils/field-keys.ts +1 -1
  44. package/layers/admin/app/utils/list-columns.ts +3 -2
  45. package/layers/admin/app/utils/list-query.ts +2 -2
  46. package/layers/admin/app/utils/populate-blocks.ts +2 -2
  47. package/layers/admin/app/utils/reauth.ts +5 -6
  48. package/layers/auth/server/plugins/01.register-auth-pipelines.ts +7 -0
  49. package/layers/collections/server/plugins/01.register.ts +9 -12
  50. package/layers/collections/server/plugins/02.register-relation-populate.ts +5 -7
  51. package/layers/{fields → core}/app/utils/field-factories.ts +1 -1
  52. package/layers/core/modules/auto-discovery/extract-block.ts +3 -3
  53. package/layers/core/modules/auto-discovery/index.ts +68 -11
  54. package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
  55. package/layers/core/modules/auto-discovery/scan.ts +77 -0
  56. package/layers/core/modules/auto-discovery/virtual.d.ts +5 -0
  57. package/layers/core/modules/component-namespace/index.ts +24 -0
  58. package/layers/core/modules/kestrel/index.ts +6 -1
  59. package/layers/core/modules/plugin-order/index.ts +69 -0
  60. package/layers/core/modules/plugin-order/plugin-order.ts +273 -0
  61. package/layers/core/nuxt.config.ts +1 -0
  62. package/layers/core/server/api/[...path].ts +98 -0
  63. package/layers/core/server/plugins/00.config.ts +12 -0
  64. package/layers/core/server/plugins/00.migrate.ts +1 -0
  65. package/layers/core/server/plugins/01.register-introspection-pipeline.ts +5 -0
  66. package/layers/core/server/plugins/01.register-openapi-pipeline.ts +5 -0
  67. package/layers/core/server/plugins/01.register-tooling-pipelines.ts +5 -0
  68. package/layers/core/server/plugins/02.schema-sync.ts +2 -7
  69. package/layers/core/server/plugins/04.outbox-worker.ts +31 -0
  70. package/layers/core/server/plugins/05.reindex-refs.ts +5 -0
  71. package/layers/core/server/routes/__kestrel/dashboard.get.ts +29 -0
  72. package/layers/core/server/tasks/db/migrate-module.ts +83 -0
  73. package/layers/core/server/tasks/db/migrate-revisions.ts +20 -0
  74. package/layers/core/server/tasks/db/migrate.ts +2 -6
  75. package/layers/core/server/utils/server-config.ts +66 -3
  76. package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +7 -0
  77. package/layers/fields/server/plugins/01.register-field-populate.ts +2 -2
  78. package/layers/media/app/components/KestrelImg.vue +3 -3
  79. package/layers/media/app/components/MediaLibrary.vue +2 -2
  80. package/layers/media/app/components/field/Media.vue +1 -1
  81. package/layers/media/app/composables/useMediaResolver.ts +1 -1
  82. package/layers/media/app/composables/useMediaUpload.ts +1 -1
  83. package/layers/media/app/composables/useMediaVariant.ts +2 -2
  84. package/layers/media/app/utils/ai-disclosure.ts +1 -1
  85. package/layers/media/app/utils/library.ts +1 -1
  86. package/layers/media/app/utils/ops.ts +1 -3
  87. package/layers/media/app/utils/picture-model.ts +2 -2
  88. package/layers/media/app/utils/variant-name.ts +1 -1
  89. package/layers/media/server/middleware/ondemand-variants.ts +6 -5
  90. package/layers/media/server/plugins/01.register-media-pipelines.ts +7 -0
  91. package/layers/media/server/plugins/02.register-media.ts +10 -7
  92. package/layers/media/server/plugins/04.variant-capture.ts +12 -8
  93. package/layers/media/server/plugins/05.media-cleanup.ts +5 -0
  94. package/layers/media/server/tasks/media/backfill.ts +3 -4
  95. package/layers/public/app/components/KestrelLink.vue +1 -1
  96. package/layers/public/app/pages/[...slug].vue +1 -1
  97. package/layers/public/app/pages/__kestrel/preview.vue +2 -2
  98. package/layers/public/app/utils/json-ld.ts +1 -1
  99. package/layers/public/app/utils/link-href.ts +1 -1
  100. package/layers/public/app/utils/page-head.ts +1 -1
  101. package/layers/public/modules/deploy-output/index.ts +4 -6
  102. package/layers/public/modules/prerender-routes/index.ts +4 -4
  103. package/layers/public/modules/prerender-routes/routes.ts +1 -1
  104. package/layers/public/modules/prune-media/index.ts +2 -2
  105. package/layers/public/server/delivery-live/serve.ts +111 -0
  106. package/layers/public/server/middleware/delivery-live-catchall.ts +7 -0
  107. package/layers/public/server/pipelines/index.ts +23 -0
  108. package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +14 -0
  109. package/layers/public/server/plugins/01.register-public-pipelines.ts +7 -0
  110. package/layers/public/server/plugins/02.register-links.ts +2 -3
  111. package/layers/public/server/plugins/03.redirects.ts +41 -23
  112. package/layers/public/server/plugins/05.plan-publish.ts +5 -0
  113. package/layers/public/server/plugins/zz.publish.ts +79 -24
  114. package/layers/public/server/routes/llms-full.txt.get.ts +5 -5
  115. package/layers/public/server/routes/llms.txt.get.ts +4 -2
  116. package/layers/public/server/routes/redirects.json.get.ts +2 -2
  117. package/layers/public/server/routes/robots.txt.get.ts +2 -0
  118. package/layers/public/server/routes/sitemap.xml.get.ts +16 -6
  119. package/layers/public/server/tasks/publish/run.ts +9 -4
  120. package/layers/public/server/utils/publish/render-live.ts +26 -0
  121. package/layers/ui/app/components/field/Choice.vue +1 -1
  122. package/layers/ui/app/components/field/Datetime.vue +1 -1
  123. package/layers/ui/app/components/field/Json.vue +1 -2
  124. package/layers/ui/app/components/field/Layout.vue +2 -4
  125. package/layers/ui/app/components/field/Link.vue +1 -1
  126. package/layers/ui/app/components/field/LinkInternalPicker.vue +3 -3
  127. package/layers/ui/app/components/field/Number.vue +2 -2
  128. package/layers/ui/app/components/field/Relation.vue +1 -1
  129. package/layers/ui/app/components/field/Repeater.vue +1 -3
  130. package/layers/ui/app/components/field/Slug.vue +2 -3
  131. package/layers/ui/app/components/field/Text.vue +1 -1
  132. package/layers/ui/app/components/ui/Richtext.vue +1 -1
  133. package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
  134. package/layers/ui/app/composables/useLinkField.ts +1 -1
  135. package/layers/ui/app/composables/useRepeater.ts +1 -1
  136. package/layers/ui/app/i18n/de.ts +7 -7
  137. package/layers/ui/app/i18n/en.ts +7 -39
  138. package/layers/ui/app/utils/field-component.ts +1 -1
  139. package/layers/ui/app/utils/field-registry.ts +1 -1
  140. package/layers/ui/app/utils/icons.ts +2 -6
  141. package/layers/ui/app/utils/localized.ts +1 -1
  142. package/nuxt.config.ts +1 -1
  143. package/package.json +53 -19
  144. package/scripts/consumer-template-ci.mjs +157 -0
  145. package/scripts/coverage-all.mjs +163 -0
  146. package/scripts/dashboard.mjs +89 -0
  147. package/scripts/hash-password.mjs +1 -1
  148. package/scripts/lib/coverage-model.mjs +51 -0
  149. package/scripts/port-graph.mjs +350 -0
  150. package/scripts/typecheck.mjs +11 -0
  151. package/templates/starter/app/blocks/Prose.vue +1 -1
  152. package/layers/access/server/utils/csrf.ts +0 -39
  153. package/layers/access/server/utils/grant-registry.ts +0 -36
  154. package/layers/access/server/utils/guard.ts +0 -44
  155. package/layers/access/server/utils/ip-allowlist.ts +0 -79
  156. package/layers/access/server/utils/policy.ts +0 -98
  157. package/layers/access/server/utils/public-resources.ts +0 -11
  158. package/layers/access/server/utils/render-context.ts +0 -41
  159. package/layers/access/server/utils/require-admin.ts +0 -15
  160. package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
  161. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
  162. package/layers/auth/server/api/auth/login.post.ts +0 -38
  163. package/layers/auth/server/api/auth/logout.post.ts +0 -9
  164. package/layers/auth/server/api/auth/session.get.ts +0 -1
  165. package/layers/auth/server/utils/admin-credential.ts +0 -42
  166. package/layers/auth/server/utils/client-ip.ts +0 -88
  167. package/layers/auth/server/utils/login-throttle.ts +0 -126
  168. package/layers/auth/server/utils/password.ts +0 -52
  169. package/layers/auth/server/utils/session-cookie.ts +0 -44
  170. package/layers/auth/server/utils/session-epoch.ts +0 -96
  171. package/layers/auth/server/utils/session.ts +0 -88
  172. package/layers/collections/server/collections/pages.ts +0 -19
  173. package/layers/collections/server/utils/populate-relations.ts +0 -79
  174. package/layers/core/app/utils/bulk-actions.ts +0 -9
  175. package/layers/core/app/utils/filter-ops.ts +0 -98
  176. package/layers/core/app/utils/list-limits.ts +0 -27
  177. package/layers/core/app/utils/locale-path.ts +0 -12
  178. package/layers/core/app/utils/slugify.ts +0 -16
  179. package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
  180. package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
  181. package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
  182. package/layers/core/server/api/[collection]/[id].get.ts +0 -11
  183. package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
  184. package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
  185. package/layers/core/server/api/[collection]/index.get.ts +0 -24
  186. package/layers/core/server/api/[collection]/index.post.ts +0 -8
  187. package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
  188. package/layers/core/server/api/[collection]/index.put.ts +0 -24
  189. package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
  190. package/layers/core/server/api/[collection]/options.get.ts +0 -18
  191. package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
  192. package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
  193. package/layers/core/server/api/[collection]/translations.get.ts +0 -25
  194. package/layers/core/server/api/collections/[name].get.ts +0 -4
  195. package/layers/core/server/api/collections/index.get.ts +0 -3
  196. package/layers/core/server/api/references/broken.get.ts +0 -9
  197. package/layers/core/server/api/references/referrers.get.ts +0 -24
  198. package/layers/core/server/database/record-refs.ts +0 -20
  199. package/layers/core/server/plugins/03.record-refs.ts +0 -12
  200. package/layers/core/server/schema/bootstrap.ts +0 -41
  201. package/layers/core/server/schema/desired.ts +0 -99
  202. package/layers/core/server/schema/dev-sync.ts +0 -23
  203. package/layers/core/server/schema/dialect.ts +0 -61
  204. package/layers/core/server/schema/diff.ts +0 -110
  205. package/layers/core/server/schema/introspect.ts +0 -68
  206. package/layers/core/server/schema/model.ts +0 -61
  207. package/layers/core/server/schema/render-sqlite.ts +0 -89
  208. package/layers/core/server/schema/sync.ts +0 -201
  209. package/layers/core/server/utils/blocks.ts +0 -6
  210. package/layers/core/server/utils/collection-types.ts +0 -28
  211. package/layers/core/server/utils/crud.ts +0 -578
  212. package/layers/core/server/utils/db.ts +0 -26
  213. package/layers/core/server/utils/defineCollection.ts +0 -213
  214. package/layers/core/server/utils/duplicate.ts +0 -162
  215. package/layers/core/server/utils/field-layout.ts +0 -125
  216. package/layers/core/server/utils/filter-predicate.ts +0 -101
  217. package/layers/core/server/utils/http.ts +0 -50
  218. package/layers/core/server/utils/if-unmodified.ts +0 -13
  219. package/layers/core/server/utils/kestrel-config.ts +0 -451
  220. package/layers/core/server/utils/key-lock.ts +0 -33
  221. package/layers/core/server/utils/locale.ts +0 -53
  222. package/layers/core/server/utils/page-route.ts +0 -58
  223. package/layers/core/server/utils/page-slug.ts +0 -127
  224. package/layers/core/server/utils/picker.ts +0 -82
  225. package/layers/core/server/utils/populate.ts +0 -50
  226. package/layers/core/server/utils/prerender-memo.ts +0 -23
  227. package/layers/core/server/utils/read-capture.ts +0 -37
  228. package/layers/core/server/utils/record-ref-index.ts +0 -200
  229. package/layers/core/server/utils/registry.ts +0 -24
  230. package/layers/core/server/utils/resolve-scope.ts +0 -79
  231. package/layers/core/server/utils/seo.ts +0 -42
  232. package/layers/core/server/utils/serialize-collection.ts +0 -147
  233. package/layers/core/server/utils/sql.ts +0 -2
  234. package/layers/core/server/utils/storage.local.ts +0 -117
  235. package/layers/core/server/utils/storage.s3.ts +0 -197
  236. package/layers/core/server/utils/storage.ts +0 -90
  237. package/layers/core/server/utils/write-effects.ts +0 -40
  238. package/layers/core/server/utils/write-events.ts +0 -31
  239. package/layers/fields/app/utils/condition.ts +0 -71
  240. package/layers/fields/app/utils/define-block.ts +0 -13
  241. package/layers/fields/app/utils/field-constraints.ts +0 -17
  242. package/layers/fields/app/utils/field-meta.ts +0 -33
  243. package/layers/fields/app/utils/field-validate.ts +0 -75
  244. package/layers/fields/app/utils/field-value.ts +0 -7
  245. package/layers/fields/app/utils/richtext-links.ts +0 -48
  246. package/layers/fields/server/api/blocks/index.get.ts +0 -9
  247. package/layers/fields/server/field-registry/index.ts +0 -249
  248. package/layers/fields/server/field-registry/naming.ts +0 -10
  249. package/layers/fields/server/field-registry/sanitize.ts +0 -43
  250. package/layers/fields/server/field-registry/types.ts +0 -19
  251. package/layers/fields/server/utils/block-ids.ts +0 -43
  252. package/layers/fields/server/utils/block-populate.ts +0 -41
  253. package/layers/fields/server/utils/buildCollection.ts +0 -90
  254. package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
  255. package/layers/fields/server/utils/buildTable.ts +0 -113
  256. package/layers/fields/server/utils/conditional-required.ts +0 -25
  257. package/layers/fields/server/utils/defineBlock.ts +0 -86
  258. package/layers/fields/server/utils/defineFieldType.ts +0 -33
  259. package/layers/fields/server/utils/extract-refs.ts +0 -152
  260. package/layers/fields/server/utils/field-populate.ts +0 -68
  261. package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
  262. package/layers/media/server/api/media/[id].delete.ts +0 -18
  263. package/layers/media/server/api/media/[id].get.ts +0 -10
  264. package/layers/media/server/api/media/[id].patch.test.ts +0 -66
  265. package/layers/media/server/api/media/[id].patch.ts +0 -65
  266. package/layers/media/server/api/media/backfill.post.ts +0 -18
  267. package/layers/media/server/api/media/copy.post.ts +0 -7
  268. package/layers/media/server/api/media/delete.post.ts +0 -13
  269. package/layers/media/server/api/media/folders.post.ts +0 -18
  270. package/layers/media/server/api/media/index.get.ts +0 -15
  271. package/layers/media/server/api/media/index.post.ts +0 -173
  272. package/layers/media/server/api/media/library.get.ts +0 -17
  273. package/layers/media/server/api/media/move.post.ts +0 -7
  274. package/layers/media/server/api/media/rename.post.ts +0 -7
  275. package/layers/media/server/api/media/resolve.get.ts +0 -13
  276. package/layers/media/server/collections/media-settings.ts +0 -25
  277. package/layers/media/server/collections/media.ts +0 -43
  278. package/layers/media/server/database/folders.ts +0 -10
  279. package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
  280. package/layers/media/server/schema-tables/folders.ts +0 -4
  281. package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
  282. package/layers/media/server/utils/ai-signal-detect.ts +0 -155
  283. package/layers/media/server/utils/backfill.ts +0 -144
  284. package/layers/media/server/utils/derive.ts +0 -59
  285. package/layers/media/server/utils/folder-paths.ts +0 -42
  286. package/layers/media/server/utils/folders.ts +0 -12
  287. package/layers/media/server/utils/library.ts +0 -91
  288. package/layers/media/server/utils/media-cleanup.ts +0 -27
  289. package/layers/media/server/utils/media-enabled.ts +0 -23
  290. package/layers/media/server/utils/media-ops.ts +0 -118
  291. package/layers/media/server/utils/media-write.ts +0 -13
  292. package/layers/media/server/utils/naming.ts +0 -52
  293. package/layers/media/server/utils/ondemand.ts +0 -69
  294. package/layers/media/server/utils/persist-upload.ts +0 -62
  295. package/layers/media/server/utils/populate.ts +0 -47
  296. package/layers/media/server/utils/record.ts +0 -71
  297. package/layers/media/server/utils/relocate-ops.ts +0 -341
  298. package/layers/media/server/utils/relocate-plan.ts +0 -63
  299. package/layers/media/server/utils/resolve.ts +0 -98
  300. package/layers/media/server/utils/sanitize-svg.ts +0 -12
  301. package/layers/media/server/utils/sniff.ts +0 -68
  302. package/layers/media/server/utils/storage-relocate.ts +0 -139
  303. package/layers/media/server/utils/translations.ts +0 -19
  304. package/layers/media/server/utils/usages.ts +0 -66
  305. package/layers/media/server/utils/variant-capture.ts +0 -70
  306. package/layers/media/server/utils/variants.ts +0 -123
  307. package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
  308. package/layers/public/server/api/links/resolve.get.ts +0 -19
  309. package/layers/public/server/api/preview.get.ts +0 -28
  310. package/layers/public/server/api/preview.post.ts +0 -93
  311. package/layers/public/server/api/publish-status.get.ts +0 -58
  312. package/layers/public/server/api/publish.post.ts +0 -84
  313. package/layers/public/server/api/route.get.ts +0 -54
  314. package/layers/public/server/collections/redirects.ts +0 -75
  315. package/layers/public/server/collections/site.ts +0 -44
  316. package/layers/public/server/database/publish-deps.ts +0 -12
  317. package/layers/public/server/database/publish-status.ts +0 -19
  318. package/layers/public/server/schema-tables/publish-deps.ts +0 -4
  319. package/layers/public/server/schema-tables/publish-status.ts +0 -4
  320. package/layers/public/server/utils/link-resolve.ts +0 -38
  321. package/layers/public/server/utils/llms-full.ts +0 -125
  322. package/layers/public/server/utils/llms.ts +0 -52
  323. package/layers/public/server/utils/page-resolve.ts +0 -208
  324. package/layers/public/server/utils/populate-links.ts +0 -66
  325. package/layers/public/server/utils/preview-token.ts +0 -109
  326. package/layers/public/server/utils/publish/coalesce.ts +0 -22
  327. package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
  328. package/layers/public/server/utils/publish/deps.ts +0 -87
  329. package/layers/public/server/utils/publish/invalidation.ts +0 -225
  330. package/layers/public/server/utils/publish/pending.ts +0 -74
  331. package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
  332. package/layers/public/server/utils/publish/publish-status.ts +0 -72
  333. package/layers/public/server/utils/publish/publisher.ts +0 -362
  334. package/layers/public/server/utils/publish/queue.ts +0 -67
  335. package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
  336. package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
  337. package/layers/public/server/utils/publish/route-for-record.ts +0 -16
  338. package/layers/public/server/utils/publish/route-keys.ts +0 -13
  339. package/layers/public/server/utils/richtext-markdown.ts +0 -260
  340. package/layers/public/server/utils/site-url.ts +0 -40
  341. 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,7 +0,0 @@
1
- export function tryParseJson(text: string): { ok: true; value: unknown } | { ok: false } {
2
- try {
3
- return { ok: true, value: JSON.parse(text) }
4
- } catch {
5
- return { ok: false }
6
- }
7
- }
@@ -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, '&amp;').replace(/"/g, '&quot;')
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
- }