@michaelthielemann/kestrel 3.0.0 → 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 (353) 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 +6 -8
  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/Button.vue +2 -2
  133. package/layers/ui/app/components/ui/ButtonGroup.vue +2 -2
  134. package/layers/ui/app/components/ui/Checkbox.vue +2 -2
  135. package/layers/ui/app/components/ui/Combobox.vue +5 -5
  136. package/layers/ui/app/components/ui/DatePicker.vue +2 -2
  137. package/layers/ui/app/components/ui/DateRangePicker.vue +2 -2
  138. package/layers/ui/app/components/ui/NumberInput.vue +4 -4
  139. package/layers/ui/app/components/ui/Richtext.vue +1 -1
  140. package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
  141. package/layers/ui/app/components/ui/Select.vue +2 -2
  142. package/layers/ui/app/components/ui/TextInput.vue +4 -4
  143. package/layers/ui/app/components/ui/Textarea.vue +2 -2
  144. package/layers/ui/app/components/ui/TimeInput.vue +2 -2
  145. package/layers/ui/app/components/ui/Toasts.vue +3 -3
  146. package/layers/ui/app/composables/useLinkField.ts +1 -1
  147. package/layers/ui/app/composables/useRepeater.ts +1 -1
  148. package/layers/ui/app/i18n/de.ts +7 -7
  149. package/layers/ui/app/i18n/en.ts +7 -39
  150. package/layers/ui/app/utils/field-component.ts +1 -1
  151. package/layers/ui/app/utils/field-registry.ts +1 -1
  152. package/layers/ui/app/utils/icons.ts +2 -6
  153. package/layers/ui/app/utils/localized.ts +1 -1
  154. package/nuxt.config.ts +1 -1
  155. package/package.json +53 -19
  156. package/scripts/consumer-template-ci.mjs +157 -0
  157. package/scripts/coverage-all.mjs +163 -0
  158. package/scripts/dashboard.mjs +89 -0
  159. package/scripts/hash-password.mjs +1 -1
  160. package/scripts/lib/coverage-model.mjs +51 -0
  161. package/scripts/port-graph.mjs +350 -0
  162. package/scripts/typecheck.mjs +11 -0
  163. package/templates/starter/app/blocks/Prose.vue +1 -1
  164. package/layers/access/server/utils/csrf.ts +0 -39
  165. package/layers/access/server/utils/grant-registry.ts +0 -36
  166. package/layers/access/server/utils/guard.ts +0 -44
  167. package/layers/access/server/utils/ip-allowlist.ts +0 -79
  168. package/layers/access/server/utils/policy.ts +0 -98
  169. package/layers/access/server/utils/public-resources.ts +0 -11
  170. package/layers/access/server/utils/render-context.ts +0 -41
  171. package/layers/access/server/utils/require-admin.ts +0 -15
  172. package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
  173. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
  174. package/layers/auth/server/api/auth/login.post.ts +0 -38
  175. package/layers/auth/server/api/auth/logout.post.ts +0 -9
  176. package/layers/auth/server/api/auth/session.get.ts +0 -1
  177. package/layers/auth/server/utils/admin-credential.ts +0 -42
  178. package/layers/auth/server/utils/client-ip.ts +0 -88
  179. package/layers/auth/server/utils/login-throttle.ts +0 -126
  180. package/layers/auth/server/utils/password.ts +0 -52
  181. package/layers/auth/server/utils/session-cookie.ts +0 -44
  182. package/layers/auth/server/utils/session-epoch.ts +0 -96
  183. package/layers/auth/server/utils/session.ts +0 -88
  184. package/layers/collections/server/collections/pages.ts +0 -19
  185. package/layers/collections/server/utils/populate-relations.ts +0 -79
  186. package/layers/core/app/utils/bulk-actions.ts +0 -9
  187. package/layers/core/app/utils/filter-ops.ts +0 -98
  188. package/layers/core/app/utils/list-limits.ts +0 -27
  189. package/layers/core/app/utils/locale-path.ts +0 -12
  190. package/layers/core/app/utils/slugify.ts +0 -16
  191. package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
  192. package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
  193. package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
  194. package/layers/core/server/api/[collection]/[id].get.ts +0 -11
  195. package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
  196. package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
  197. package/layers/core/server/api/[collection]/index.get.ts +0 -24
  198. package/layers/core/server/api/[collection]/index.post.ts +0 -8
  199. package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
  200. package/layers/core/server/api/[collection]/index.put.ts +0 -24
  201. package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
  202. package/layers/core/server/api/[collection]/options.get.ts +0 -18
  203. package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
  204. package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
  205. package/layers/core/server/api/[collection]/translations.get.ts +0 -25
  206. package/layers/core/server/api/collections/[name].get.ts +0 -4
  207. package/layers/core/server/api/collections/index.get.ts +0 -3
  208. package/layers/core/server/api/references/broken.get.ts +0 -9
  209. package/layers/core/server/api/references/referrers.get.ts +0 -24
  210. package/layers/core/server/database/record-refs.ts +0 -20
  211. package/layers/core/server/plugins/03.record-refs.ts +0 -12
  212. package/layers/core/server/schema/bootstrap.ts +0 -41
  213. package/layers/core/server/schema/desired.ts +0 -99
  214. package/layers/core/server/schema/dev-sync.ts +0 -23
  215. package/layers/core/server/schema/dialect.ts +0 -61
  216. package/layers/core/server/schema/diff.ts +0 -110
  217. package/layers/core/server/schema/introspect.ts +0 -68
  218. package/layers/core/server/schema/model.ts +0 -61
  219. package/layers/core/server/schema/render-sqlite.ts +0 -89
  220. package/layers/core/server/schema/sync.ts +0 -201
  221. package/layers/core/server/utils/blocks.ts +0 -6
  222. package/layers/core/server/utils/collection-types.ts +0 -28
  223. package/layers/core/server/utils/crud.ts +0 -578
  224. package/layers/core/server/utils/db.ts +0 -26
  225. package/layers/core/server/utils/defineCollection.ts +0 -213
  226. package/layers/core/server/utils/duplicate.ts +0 -162
  227. package/layers/core/server/utils/field-layout.ts +0 -125
  228. package/layers/core/server/utils/filter-predicate.ts +0 -101
  229. package/layers/core/server/utils/http.ts +0 -50
  230. package/layers/core/server/utils/if-unmodified.ts +0 -13
  231. package/layers/core/server/utils/kestrel-config.ts +0 -451
  232. package/layers/core/server/utils/key-lock.ts +0 -33
  233. package/layers/core/server/utils/locale.ts +0 -53
  234. package/layers/core/server/utils/page-route.ts +0 -58
  235. package/layers/core/server/utils/page-slug.ts +0 -127
  236. package/layers/core/server/utils/picker.ts +0 -82
  237. package/layers/core/server/utils/populate.ts +0 -50
  238. package/layers/core/server/utils/prerender-memo.ts +0 -23
  239. package/layers/core/server/utils/read-capture.ts +0 -37
  240. package/layers/core/server/utils/record-ref-index.ts +0 -200
  241. package/layers/core/server/utils/registry.ts +0 -24
  242. package/layers/core/server/utils/resolve-scope.ts +0 -79
  243. package/layers/core/server/utils/seo.ts +0 -42
  244. package/layers/core/server/utils/serialize-collection.ts +0 -147
  245. package/layers/core/server/utils/sql.ts +0 -2
  246. package/layers/core/server/utils/storage.local.ts +0 -117
  247. package/layers/core/server/utils/storage.s3.ts +0 -197
  248. package/layers/core/server/utils/storage.ts +0 -90
  249. package/layers/core/server/utils/write-effects.ts +0 -40
  250. package/layers/core/server/utils/write-events.ts +0 -31
  251. package/layers/fields/app/utils/condition.ts +0 -71
  252. package/layers/fields/app/utils/define-block.ts +0 -13
  253. package/layers/fields/app/utils/field-constraints.ts +0 -17
  254. package/layers/fields/app/utils/field-meta.ts +0 -33
  255. package/layers/fields/app/utils/field-validate.ts +0 -75
  256. package/layers/fields/app/utils/field-value.ts +0 -7
  257. package/layers/fields/app/utils/richtext-links.ts +0 -48
  258. package/layers/fields/server/api/blocks/index.get.ts +0 -9
  259. package/layers/fields/server/field-registry/index.ts +0 -249
  260. package/layers/fields/server/field-registry/naming.ts +0 -10
  261. package/layers/fields/server/field-registry/sanitize.ts +0 -43
  262. package/layers/fields/server/field-registry/types.ts +0 -19
  263. package/layers/fields/server/utils/block-ids.ts +0 -43
  264. package/layers/fields/server/utils/block-populate.ts +0 -41
  265. package/layers/fields/server/utils/buildCollection.ts +0 -90
  266. package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
  267. package/layers/fields/server/utils/buildTable.ts +0 -113
  268. package/layers/fields/server/utils/conditional-required.ts +0 -25
  269. package/layers/fields/server/utils/defineBlock.ts +0 -86
  270. package/layers/fields/server/utils/defineFieldType.ts +0 -33
  271. package/layers/fields/server/utils/extract-refs.ts +0 -152
  272. package/layers/fields/server/utils/field-populate.ts +0 -68
  273. package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
  274. package/layers/media/server/api/media/[id].delete.ts +0 -18
  275. package/layers/media/server/api/media/[id].get.ts +0 -10
  276. package/layers/media/server/api/media/[id].patch.test.ts +0 -66
  277. package/layers/media/server/api/media/[id].patch.ts +0 -65
  278. package/layers/media/server/api/media/backfill.post.ts +0 -18
  279. package/layers/media/server/api/media/copy.post.ts +0 -7
  280. package/layers/media/server/api/media/delete.post.ts +0 -13
  281. package/layers/media/server/api/media/folders.post.ts +0 -18
  282. package/layers/media/server/api/media/index.get.ts +0 -15
  283. package/layers/media/server/api/media/index.post.ts +0 -173
  284. package/layers/media/server/api/media/library.get.ts +0 -17
  285. package/layers/media/server/api/media/move.post.ts +0 -7
  286. package/layers/media/server/api/media/rename.post.ts +0 -7
  287. package/layers/media/server/api/media/resolve.get.ts +0 -13
  288. package/layers/media/server/collections/media-settings.ts +0 -25
  289. package/layers/media/server/collections/media.ts +0 -43
  290. package/layers/media/server/database/folders.ts +0 -10
  291. package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
  292. package/layers/media/server/schema-tables/folders.ts +0 -4
  293. package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
  294. package/layers/media/server/utils/ai-signal-detect.ts +0 -155
  295. package/layers/media/server/utils/backfill.ts +0 -144
  296. package/layers/media/server/utils/derive.ts +0 -59
  297. package/layers/media/server/utils/folder-paths.ts +0 -42
  298. package/layers/media/server/utils/folders.ts +0 -12
  299. package/layers/media/server/utils/library.ts +0 -91
  300. package/layers/media/server/utils/media-cleanup.ts +0 -27
  301. package/layers/media/server/utils/media-enabled.ts +0 -23
  302. package/layers/media/server/utils/media-ops.ts +0 -118
  303. package/layers/media/server/utils/media-write.ts +0 -13
  304. package/layers/media/server/utils/naming.ts +0 -52
  305. package/layers/media/server/utils/ondemand.ts +0 -69
  306. package/layers/media/server/utils/persist-upload.ts +0 -62
  307. package/layers/media/server/utils/populate.ts +0 -47
  308. package/layers/media/server/utils/record.ts +0 -71
  309. package/layers/media/server/utils/relocate-ops.ts +0 -341
  310. package/layers/media/server/utils/relocate-plan.ts +0 -63
  311. package/layers/media/server/utils/resolve.ts +0 -98
  312. package/layers/media/server/utils/sanitize-svg.ts +0 -12
  313. package/layers/media/server/utils/sniff.ts +0 -68
  314. package/layers/media/server/utils/storage-relocate.ts +0 -139
  315. package/layers/media/server/utils/translations.ts +0 -19
  316. package/layers/media/server/utils/usages.ts +0 -66
  317. package/layers/media/server/utils/variant-capture.ts +0 -70
  318. package/layers/media/server/utils/variants.ts +0 -123
  319. package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
  320. package/layers/public/server/api/links/resolve.get.ts +0 -19
  321. package/layers/public/server/api/preview.get.ts +0 -28
  322. package/layers/public/server/api/preview.post.ts +0 -93
  323. package/layers/public/server/api/publish-status.get.ts +0 -58
  324. package/layers/public/server/api/publish.post.ts +0 -84
  325. package/layers/public/server/api/route.get.ts +0 -54
  326. package/layers/public/server/collections/redirects.ts +0 -75
  327. package/layers/public/server/collections/site.ts +0 -44
  328. package/layers/public/server/database/publish-deps.ts +0 -12
  329. package/layers/public/server/database/publish-status.ts +0 -19
  330. package/layers/public/server/schema-tables/publish-deps.ts +0 -4
  331. package/layers/public/server/schema-tables/publish-status.ts +0 -4
  332. package/layers/public/server/utils/link-resolve.ts +0 -38
  333. package/layers/public/server/utils/llms-full.ts +0 -125
  334. package/layers/public/server/utils/llms.ts +0 -52
  335. package/layers/public/server/utils/page-resolve.ts +0 -208
  336. package/layers/public/server/utils/populate-links.ts +0 -66
  337. package/layers/public/server/utils/preview-token.ts +0 -109
  338. package/layers/public/server/utils/publish/coalesce.ts +0 -22
  339. package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
  340. package/layers/public/server/utils/publish/deps.ts +0 -87
  341. package/layers/public/server/utils/publish/invalidation.ts +0 -225
  342. package/layers/public/server/utils/publish/pending.ts +0 -74
  343. package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
  344. package/layers/public/server/utils/publish/publish-status.ts +0 -72
  345. package/layers/public/server/utils/publish/publisher.ts +0 -362
  346. package/layers/public/server/utils/publish/queue.ts +0 -67
  347. package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
  348. package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
  349. package/layers/public/server/utils/publish/route-for-record.ts +0 -16
  350. package/layers/public/server/utils/publish/route-keys.ts +0 -13
  351. package/layers/public/server/utils/richtext-markdown.ts +0 -260
  352. package/layers/public/server/utils/site-url.ts +0 -40
  353. package/layers/public/server/utils/sitemap.ts +0 -99
@@ -1,213 +0,0 @@
1
- import { validateFieldLayoutsDeep } from './field-layout'
2
-
3
- export type FieldType =
4
- | 'text' | 'slug' | 'richtext' | 'number' | 'boolean' | 'datetime'
5
- | 'choice' | 'link' | 'media' | 'relation' | 'repeater' | 'json'
6
- // Consumer-defined types (registered via `defineFieldType`) — keeps built-in autocomplete while
7
- // accepting any registered name.
8
- | (string & {})
9
-
10
- /** A display string that may be localized per admin language: either a plain string (one language /
11
- * language-agnostic) or a `{ <adminLang>: string }` map resolved against the active admin language
12
- * (falling back to `en`, then any present value). Works for built-in AND consumer-defined collections. */
13
- export type Localized = string | Record<string, string>
14
-
15
- interface BaseFieldDef {
16
- required?: boolean
17
- default?: unknown
18
- unique?: boolean
19
- /** Add a non-unique DB index on this field's column — for a field frequently FILTERED or SORTED by but
20
- * not unique (e.g. `media.folder`). `unique` already implies an index, so this is only the non-unique case. */
21
- index?: boolean
22
- /** Editor label, optionally localized per admin language. Defaults to the humanized field key
23
- * (e.g. `siteName` → "Site Name") so the editor label matches the collection-list header. */
24
- label?: Localized
25
- /** Show this field only when the condition matches other fields' values (see `Condition`). A hidden
26
- * field is exempt from `required` and gets a nullable column; its stored value is left as-is (v1). */
27
- condition?: Condition
28
- /** The field's previous key. On a schema sync the column is RENAMED (data preserved) instead of being
29
- * dropped + re-added. Remove once applied everywhere. */
30
- renamedFrom?: string
31
- /** Per-instance read populator override (Pruvious `additional.population`). When set, the field-tree
32
- * walker runs THIS instead of the field type's default populator for this one field — e.g. a relation
33
- * that should project only a couple of columns, or a masked value. Server-only (a function; never
34
- * serialized to the admin). Typed inline (structurally identical to the populate seam's `FieldPopulator`)
35
- * so this foundational, import-free types file needs no dependency on `populate.ts`. */
36
- populate?: (
37
- bag: Record<string, unknown>,
38
- key: string,
39
- field: FieldDef,
40
- ctx: { depth: number; locale: string; def: CollectionDef },
41
- keyMode: 'columns' | 'props',
42
- ) => void
43
- }
44
-
45
- export type ConditionScalar = string | number | boolean | null
46
-
47
- /** A single operator test against the referenced field's value. Multiple keys are ANDed. */
48
- export interface ConditionOperator {
49
- eq?: ConditionScalar
50
- ne?: ConditionScalar
51
- gt?: number | string
52
- gte?: number | string
53
- lt?: number | string
54
- lte?: number | string
55
- in?: ConditionScalar[]
56
- notIn?: ConditionScalar[]
57
- /** Case-sensitive `new RegExp(value).test(dep)`; a non-string dependency never matches. */
58
- regexp?: string
59
- /** `true` matches null/undefined/''/[]; `false` matches a present value. */
60
- empty?: boolean
61
- }
62
-
63
- /** A leaf rule: test another field's value. Use `is` (strict-equality shorthand) OR `op`; a bare rule
64
- * (neither) means "present" (non-empty). `field` is a sibling field's key (v1 scope). */
65
- export interface ConditionRule {
66
- field: string
67
- is?: ConditionScalar
68
- op?: ConditionOperator
69
- }
70
-
71
- /** Field visibility logic. A discriminated tree: leaf `ConditionRule`s combined by explicit
72
- * `and`/`or`/`not` (no overloaded magic keys, so a real field named "and"/"or" never collides). */
73
- export type Condition =
74
- | ConditionRule
75
- | { and: Condition[] }
76
- | { or: Condition[] }
77
- | { not: Condition }
78
-
79
- export type LinkType = 'internal' | 'external' | 'email' | 'tel'
80
-
81
- export type LinkValue =
82
- | { type: 'internal'; collection: string; id: number; hash?: string; label?: string }
83
- | { type: 'external'; url: string; label?: string }
84
- | { type: 'email'; email: string; label?: string }
85
- | { type: 'tel'; tel: string; label?: string }
86
-
87
- export type FieldDef =
88
- | (BaseFieldDef & { type: 'text'; options?: { minLength?: number; maxLength?: number; multiline?: boolean } })
89
- // A url-safe slug (text-backed). `from` = the field key to auto-generate from when left blank; `prefix` is
90
- // display-only in the editor widget (e.g. '/galleries/').
91
- | (BaseFieldDef & { type: 'slug'; options?: { from?: string; prefix?: string } })
92
- | (BaseFieldDef & { type: 'richtext' })
93
- // `unit` is a display-only suffix shown after the value in the editor widget (e.g. 'rem'); the stored
94
- // value stays a bare number. `units` is reserved for a future selectable-unit variant and is not yet
95
- // honoured server-side.
96
- | (BaseFieldDef & { type: 'number'; options?: { min?: number; max?: number; integer?: boolean; decimals?: number; unit?: string; units?: string[] } })
97
- | (BaseFieldDef & { type: 'boolean' })
98
- | (BaseFieldDef & { type: 'datetime'; options?: { precision?: 'date' | 'datetime' | 'time'; range?: boolean } })
99
- | (BaseFieldDef & { type: 'choice'; options: { choices: { label: Localized; value: string }[]; multiple?: boolean; display?: 'select' | 'buttons' | 'checkboxes' } })
100
- | (BaseFieldDef & { type: 'link'; options?: { types?: LinkType[]; collections?: string[] } })
101
- | (BaseFieldDef & { type: 'media'; options?: { multiple?: boolean; accept?: 'image' | 'any' } })
102
- | (BaseFieldDef & { type: 'relation'; relation: { collection: string; many?: boolean; labelField?: string } })
103
- | (BaseFieldDef & { type: 'repeater'; options: { fields: Record<string, FieldDef>; fieldLayout?: FieldLayoutDSL } })
104
- | (BaseFieldDef & { type: 'json' })
105
- // A consumer-defined field type (registered via `defineFieldType`): any other `type` string, with
106
- // free-form `options` forwarded to its server descriptor + editor widget.
107
- | (BaseFieldDef & { type: string & {}; options?: Record<string, unknown> })
108
-
109
- /** The specific `FieldDef` arm for a built-in field `type`; falls back to the full union for consumer
110
- * types (whose arm is the open `type: string & {}`). The `[…] extends [never]` tuple wrap stops the
111
- * conditional from distributing over `never`. */
112
- export type FieldOf<T extends FieldType> = [Extract<FieldDef, { type: T }>] extends [never]
113
- ? FieldDef
114
- : Extract<FieldDef, { type: T }>
115
-
116
- /** Type-guard narrowing a `FieldDef` to a specific built-in arm. Needed because the open consumer arm
117
- * (`type: string & {}`) makes `type` a non-discriminant, so a bare `f.type === 'x'` won't narrow `f`. */
118
- export function fieldIs<T extends FieldType>(field: FieldDef, type: T): field is FieldOf<T> {
119
- return field.type === type
120
- }
121
-
122
- /** Whether a relation/media field is stored as (and addressed by) a single `<name>Id` FK column —
123
- * true unless it is a many-relation or a multiple-media field. The single source of the `Id`-suffix
124
- * rule shared by column naming (`resolveColumnName`), serialization (the `single` flag), and the editor. */
125
- export function isSingleRefColumn(field: FieldDef): boolean {
126
- return (fieldIs(field, 'relation') && !field.relation.many)
127
- || (fieldIs(field, 'media') && !field.options?.multiple)
128
- }
129
-
130
- /**
131
- * Author-facing field-layout DSL (resolved by `resolveFieldLayout`). Each entry is one of:
132
- * - a string — a single full-width row (`'title'`), optionally widthed (`'title|50%'`);
133
- * - a string[] — a side-by-side row, equal columns by default, or per-field `field|2` (flex weight) /
134
- * `field|30%` (a CSS length/percent);
135
- * - a single-key object — a named group of rows: `{ 'SEO': [ 'metaTitle', ['a', 'b'] ] }` (one level deep).
136
- */
137
- export type FieldLayoutEntry = string | string[] | Record<string, FieldLayoutEntry[]>
138
- export type FieldLayoutDSL = FieldLayoutEntry[]
139
-
140
- export interface CollectionDef {
141
- name: string
142
- mode: 'multi' | 'single'
143
- /** Per-locale content. Optional — defaults to false (a single, non-localized record set). */
144
- translatable?: boolean
145
- pageLike?: boolean
146
- seo?: boolean
147
- blocks?: { enabled: true; allowed?: string[] }
148
- status?: boolean
149
- /** Which admin editor renders this collection's body (the presentation axis, kept separate from the
150
- * `blocks` schema flag). Defaults to `'blocks'` when `blocks.enabled`, else `'fields'`. Open string:
151
- * extensions register additional editor types (e.g. `'node-graph'`) via `registerCollectionEditor`. */
152
- editor?: string
153
- fields: Record<string, FieldDef>
154
- /** Admin editor layout: rows of fields. A string is its own full-width row; a nested array puts fields
155
- * side by side (equal columns, or `field|2` / `field|30%` for flex weights / CSS lengths); a single-key
156
- * object is a named group `{ 'SEO': [ … ] }`. Fields omitted from the layout append as full-width rows,
157
- * so adding a field never hides it. Absent → today's one-field-per-row. (Deviates from Pruvious, which
158
- * nests this under a `dashboard` wrapper — Kestrel keeps its flat CollectionDef idiom.) */
159
- fieldLayout?: FieldLayoutDSL
160
- /** Whole-record validation the per-field Zod schema cannot express, because a field validator only ever
161
- * sees its own value — e.g. a rule set whose rows have to compile as a unit. Runs server-side BEFORE
162
- * the write, next to the conditional-required check, and its issues become the same field-scoped 400
163
- * the editor already renders. Server-only: a function, never serialized.
164
- * Note the asymmetry: `record` is keyed by COLUMN name (`authorId` for a single relation/media field —
165
- * see `resolveColumnName`), while an issue's `path[0]` is the FIELD key the editor renders against. */
166
- validate?: (record: Record<string, unknown>) => Array<{ path: (string | number)[]; message: string }>
167
- /** Display labels. `new` is the complete, per-locale "create" phrase (e.g. de `'Neue Seite'` /
168
- * `'Neuer Beitrag'`) — supplying the whole phrase sidesteps German gender agreement, which no
169
- * `'Neu {x}'` template can get right. Falls back to a generic phrase from `singular` when absent. */
170
- label?: { singular?: Localized; plural?: Localized; new?: Localized }
171
- /** Nav/dashboard icon: a UI registry icon name (e.g. `'settings'`) or raw inline SVG markup. Lets
172
- * each collection — including custom ones — be visually distinct in the admin rail. */
173
- icon?: string
174
- /** Marks a Kestrel-shipped built-in (`pages`, `media`). Built-ins register by default but a consumer
175
- * can disable them via `kestrel: { collections: { <name>: false } }` (config → `KESTREL_COLLECTIONS_<NAME>`
176
- * env → default-on). Ignored for user-defined collections. */
177
- builtin?: boolean
178
- /** Show this collection in the admin rail (default true). Set `false` for a system/config store — e.g.
179
- * a settings singleton — that a layer manages internally and surfaces (if at all) through its own UI, so
180
- * it never appears as a flat top-level rail item beside the layer's main page. */
181
- nav?: boolean
182
- }
183
-
184
- export function defineCollection(def: CollectionDef): CollectionDef {
185
- // A pageLike record is governed by the slug engine (required + auto-generated + globally unique route),
186
- // which only runs through the multi create/update path — `putSingleton` writes directly. A routable
187
- // singleton would silently skip that enforcement, so refuse the combo loudly at definition time.
188
- if (def.pageLike && def.mode === 'single') {
189
- throw new Error(`[kestrel] pageLike collection "${def.name}" must be mode: 'multi' (a routable record needs the slug engine)`)
190
- }
191
- // The built-in `blocks` editor body persists to the `content` column, which the write schema only exposes
192
- // when blocks are enabled. editor:'blocks' without blocks:{enabled:true} would mount a working page builder
193
- // whose every edit is silently dropped on save — refuse the combo loudly at definition time.
194
- if (def.editor === 'blocks' && !def.blocks?.enabled) {
195
- throw new Error(`[kestrel] collection "${def.name}" sets editor: 'blocks' but is missing blocks: { enabled: true } — its block edits would never persist`)
196
- }
197
- // Framework tool endpoints share the flat /api/<segment> namespace. A pageLike collection whose name
198
- // matches one enters the anonymous public-read set (publicReadableResources) and makes that endpoint
199
- // anonymously reachable — e.g. a `links` collection exposes /api/links/resolve, leaking draft slugs.
200
- // Refuse the collision loudly. (`media` is served by the generic router, so it is NOT a tool namespace.)
201
- if (RESERVED_API_NAMESPACES.has(def.name)) {
202
- throw new Error(`[kestrel] collection name "${def.name}" is a reserved framework API namespace — it would shadow /api/${def.name} and leak it into the anonymous public-read set; choose another name`)
203
- }
204
- // Fail loud at definition time on a bad field layout (unknown/duplicate field, invalid width, malformed
205
- // group) — top level plus every nested repeater — so it surfaces at import/startup, not on first render.
206
- validateFieldLayoutsDeep(def)
207
- return def
208
- }
209
-
210
- const RESERVED_API_NAMESPACES = new Set([
211
- 'auth', 'blocks', 'collections', 'links', 'publish-status', 'references', 'route',
212
- 'galleries-secure', 'galleries-secure-proofing',
213
- ])
@@ -1,162 +0,0 @@
1
- import { eq, getTableColumns } from 'drizzle-orm'
2
- import { createError } from 'h3'
3
- import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
4
- import type { AnySQLiteColumn, AnySQLiteTable } from 'drizzle-orm/sqlite-core'
5
- import type { BuiltCollection } from './collection-types'
6
- import { create } from './crud'
7
- import { slugSourceKey, dedupeSourcePath } from './page-slug'
8
- import { primaryLocale, prefixPrimaryLocale } from './locale'
9
- import { allCollections } from './registry'
10
- import { fieldIs, type FieldOf } from './defineCollection'
11
- import { slugify } from '../../app/utils/slugify'
12
- import { resolveColumnName } from '../../../fields/server/field-registry/naming'
13
- import { isHardRequired } from '../../../fields/server/field-registry/index'
14
- import { regenerateBlockIds } from '../../../fields/server/utils/block-ids'
15
-
16
- type DB = BetterSQLite3Database
17
- type Row = Record<string, unknown>
18
-
19
- /**
20
- * Append (or increment) a plain `(copy)` suffix on a title-like string:
21
- * `Foo` → `Foo (copy)` → `Foo (copy 2)` → `Foo (copy 3)` → …
22
- * Pure and non-localized; drives BOTH the list label and (for pageLike / slug fields) the derived slug.
23
- * When `maxLength` is given (the slug-source field's `options.maxLength`), the BASE is truncated so the
24
- * whole `base + suffix` result still fits — so a record at (or within a suffix of) its length limit stays
25
- * duplicable instead of tripping create()'s re-validated `.max`. When the maxLength cannot fit even the
26
- * suffix on its own, the source text is returned unchanged (no suffix); for a pageLike copy the `-N` route
27
- * de-dup then still guarantees a unique slug. The increment rule holds under truncation.
28
- */
29
- export function withCopySuffix(text: string, maxLength?: number): string {
30
- const m = /^(.*) \(copy(?: (\d+))?\)$/.exec(text)
31
- const base = m ? m[1]! : text
32
- const n = (m ? (m[2] ? Number.parseInt(m[2], 10) : 1) : 0) + 1
33
- const suffix = n === 1 ? '(copy)' : `(copy ${n})`
34
- const full = base ? `${base} ${suffix}` : suffix
35
- if (maxLength === undefined || full.length <= maxLength) return full
36
- // The suffix alone overflows the budget → drop it (a pageLike copy's `-N` route de-dup still disambiguates).
37
- if (suffix.length > maxLength) return text.slice(0, maxLength)
38
- // Truncate the base so `base + ' ' + suffix` fits exactly within maxLength (−1 for the joining space).
39
- const room = maxLength - suffix.length - 1
40
- const truncated = room > 0 ? base.slice(0, room).trimEnd() : ''
41
- return truncated ? `${truncated} ${suffix}` : suffix
42
- }
43
-
44
- /** The one field whose verbatim copy CANNOT be reconciled: required + unique + not a slug (a slug re-derives,
45
- * the pageLike path re-derives+de-dupes). Returns its def key, or `undefined` when the collection has none. */
46
- function blockingUniqueField(c: BuiltCollection): string | undefined {
47
- for (const [key, f] of Object.entries(c.def.fields)) {
48
- if (f.unique && isHardRequired(f) && f.type !== 'slug') return key
49
- }
50
- return undefined
51
- }
52
-
53
- /**
54
- * Duplicate one record into a NEW draft copy, composing `crud.create()` so validation, the fresh
55
- * translationGroup nanoid, the slug de-dup, field transforms, the unique-slug check, the insert AND
56
- * `emitWrite` (which rebuilds `record_refs` and drives the publish queue) all come for free — there is NO
57
- * parallel insert path. The copy:
58
- * • strips id / timestamps → a fresh PK + timestamps (create() also strips them);
59
- * • drops `translationGroup` → create() mints a new group, so the copy is a LONE row in a brand-new group
60
- * for its single locale (Option A — keeping the group would violate the UNIQUE(group, locale) index);
61
- * • blanks `path` (pageLike) → `resolvePageSlug` re-derives + `-2`/`-3` de-dupes from the suffixed title,
62
- * or — for a pageLike collection with NO slug-source text field — seeds the source's path, de-duped;
63
- * • forces `status: 'draft'` → a copy of a published record is NOT itself published;
64
- * • regenerates every block id → the copy never shares block identity with its source;
65
- * • appends a `(copy)` suffix to the slug-source text field → the label and derived slug read "Foo (copy)";
66
- * • blanks non-required unique fields EXCEPT the slug-source (its `(copy)` suffix already makes it distinct);
67
- * • SHARES referenced media / relations / links verbatim (scalar column values copied, never deep-copied).
68
- * Throws: 405 for a singleton · 404 for an unknown id · 422 (naming the field) when a REQUIRED UNIQUE field
69
- * that is not the slug/path would collide verbatim (surfaced up-front instead of a raw SQLite 409).
70
- */
71
- export function duplicateRecord(db: DB, c: BuiltCollection, id: number): Row {
72
- if (c.def.mode === 'single') {
73
- throw createError({ statusCode: 405, statusMessage: 'Cannot duplicate a singleton' })
74
- }
75
- const blocking = blockingUniqueField(c)
76
- if (blocking) {
77
- throw createError({ statusCode: 422, statusMessage: `Cannot duplicate: the required unique field "${blocking}" would collide` })
78
- }
79
-
80
- const cols = getTableColumns(c.table) as Record<string, AnySQLiteColumn>
81
- // RAW row (NOT getOne — no populate): populate nests media/relations into objects that would not round-
82
- // trip through the insert schema; the duplicate must copy the stored scalar FKs so the copy SHARES them.
83
- const src = db.select().from(c.table as AnySQLiteTable).where(eq(cols.id, id)).get() as Row | undefined
84
- if (!src) throw createError({ statusCode: 404, statusMessage: `${c.name} ${id} not found` })
85
-
86
- const body: Row = { ...src }
87
- delete body.id
88
- delete body.createdAt
89
- delete body.updatedAt
90
- delete body.translationGroup
91
- delete body.path
92
- if (c.def.status) body.status = 'draft'
93
- if (c.def.blocks?.enabled) body.content = regenerateBlockIds(src.content)
94
-
95
- appendCopySuffix(c, body)
96
- blankNonRequiredUniqueFields(db, c, body)
97
- seedCopyPath(db, c, src, body)
98
-
99
- return create(db, c, body)
100
- }
101
-
102
- /** Suffix the slug-source text field (the same field `slugSourceValue`/`resolvePageSlug` derive from),
103
- * budgeting the suffix against the field's `options.maxLength` so a record at its length limit stays
104
- * duplicable (create() re-validates the text `.max`). */
105
- function appendCopySuffix(c: BuiltCollection, body: Row): void {
106
- const key = slugSourceKey(c.def)
107
- if (!key) return
108
- const value = body[key]
109
- if (typeof value !== 'string') return
110
- const field = c.def.fields[key]
111
- const maxLength = field && fieldIs(field, 'text') ? field.options?.maxLength : undefined
112
- body[key] = withCopySuffix(value, maxLength)
113
- }
114
-
115
- /** A unique field can't be copied verbatim (it would collide). A unique `slug` is re-derived from the
116
- * now-suffixed source AND de-duped with a `-N` suffix against existing rows (so duplicating the SAME
117
- * record twice yields `foo-copy`, then `foo-copy-2` — a plain slug field has no pageLike `-N` path dedup
118
- * of its own, so two copies would otherwise collide on create's hard unique-slug check). Any OTHER unique
119
- * field (guaranteed non-hard-required by the pre-flight — its column is nullable) falls to NULL, which
120
- * SQLite permits many of. The slug-SOURCE text field is EXEMPT even when itself `unique`: its `(copy)`
121
- * suffix already distinguishes it, so nulling it would be silent data loss (and, for a pageLike
122
- * collection, leave resolvePageSlug no title → a 400). */
123
- function blankNonRequiredUniqueFields(db: DB, c: BuiltCollection, body: Row): void {
124
- const slugSource = slugSourceKey(c.def)
125
- const cols = getTableColumns(c.table) as Record<string, AnySQLiteColumn>
126
- for (const [key, f] of Object.entries(c.def.fields)) {
127
- if (!f.unique || key === slugSource) continue
128
- const { jsKey } = resolveColumnName(key, f)
129
- if (fieldIs(f, 'slug')) body[jsKey] = dedupedCopySlug(db, c, cols, key, f, body)
130
- else body[jsKey] = null
131
- }
132
- }
133
-
134
- /** The de-duped slug for a copy: slugify the (already `(copy)`-suffixed) `from` source, else the copied
135
- * slug value, then append `-2`/`-3`/… until it is free. Empty base → undefined (let create's transform try). */
136
- function dedupedCopySlug(db: DB, c: BuiltCollection, cols: Record<string, AnySQLiteColumn>, key: string, f: FieldOf<'slug'>, body: Row): string | undefined {
137
- const { jsKey } = resolveColumnName(key, f)
138
- const from = f.options?.from
139
- const source = from ? body[from] : undefined
140
- const base = typeof source === 'string' && source ? slugify(source)
141
- : typeof body[jsKey] === 'string' ? slugify(body[jsKey] as string) : ''
142
- if (!base) return undefined
143
- let candidate = base
144
- for (let n = 2; db.select({ id: cols.id }).from(c.table as AnySQLiteTable).where(eq(cols[jsKey]!, candidate)).get(); n++) {
145
- candidate = `${base}-${n}`
146
- }
147
- return candidate
148
- }
149
-
150
- /** For a pageLike collection with NO slug-source text field, create()'s auto-gen branch has no title to
151
- * derive a slug from (and duplicate blanked `path`), so seed the copy's `path` off the SOURCE row's own
152
- * explicit path — de-duped to a free route — which create()'s explicit branch then accepts. A no-op for
153
- * every other collection (the suffixed title drives the slug) and when the source carries no usable path. */
154
- function seedCopyPath(db: DB, c: BuiltCollection, src: Row, body: Row): void {
155
- if (!c.def.pageLike || slugSourceKey(c.def)) return
156
- const path = dedupeSourcePath(db, c, src, {
157
- collections: allCollections(),
158
- primary: primaryLocale(),
159
- prefixPrimary: prefixPrimaryLocale(),
160
- })
161
- if (path) body.path = path
162
- }
@@ -1,125 +0,0 @@
1
- import type { CollectionDef, FieldDef, FieldLayoutDSL, Localized } from './defineCollection'
2
-
3
- /**
4
- * Field layout: the pure, node-testable resolver that turns the author DSL (`CollectionDef.fieldLayout` /
5
- * a repeater's `options.fieldLayout`) into a normalized, JSON-safe structure the admin renders. It runs at
6
- * definition time (fail-loud) and again in serialize (the wire copy); no runtime deps — only the erased
7
- * type imports above.
8
- */
9
-
10
- /** A neutral column track: a flex WEIGHT (a plain number → the UI renders it as an `fr` unit) or an
11
- * explicit LENGTH (`'30%'`, `'12rem'`, …). The engine stays free of CSS-grid vocabulary — the UI layer
12
- * (Layout.vue) is the single place that turns tracks into a `grid-template-columns` value. */
13
- export type LayoutTrack = number | string
14
-
15
- /** A rendered row: the fields (in source/reading order) and one column track per field. */
16
- export interface LayoutRow {
17
- kind: 'row'
18
- fields: string[]
19
- /** One track per field (positionally aligned); default weight `1`. Never an author-supplied CSS string. */
20
- tracks: LayoutTrack[]
21
- }
22
- /** A single named group (one level deep): a labelled `<fieldset>` wrapping stacked rows. */
23
- export interface LayoutGroup {
24
- kind: 'group'
25
- label: Localized
26
- rows: LayoutRow[]
27
- }
28
- export type LayoutNode = LayoutRow | LayoutGroup
29
-
30
- // Width grammar, strict allow-lists so a field width can NEVER inject arbitrary CSS into the grid template.
31
- const WEIGHT = /^\d+(?:\.\d+)?$/ // bare number -> `<n>fr` (flex weight)
32
- const LENGTH = /^\d+(?:\.\d+)?(?:fr|%|px|rem|em)$/ // an explicit CSS length/percent (allow-listed units)
33
-
34
- interface Cell {
35
- field: string
36
- /** A flex weight (number) or an explicit length (string), or undefined → default weight 1. */
37
- track?: LayoutTrack
38
- }
39
-
40
- /** Parse one `'name'` / `'name|2'` / `'name|30%'` token into its field + optional grid track width. */
41
- function parseToken(tok: unknown, ctx: string): Cell {
42
- if (typeof tok !== 'string') throw new Error(`[kestrel] ${ctx}: a row field must be a string (got ${typeof tok})`)
43
- const parts = tok.split('|')
44
- if (parts.length > 2) throw new Error(`[kestrel] ${ctx}: "${tok}" has more than one "|"`)
45
- const field = parts[0]!.trim()
46
- if (!field) throw new Error(`[kestrel] ${ctx}: empty field name in "${tok}"`)
47
- if (parts.length === 1) return { field }
48
- const w = parts[1]!.trim()
49
- // A zero (or, defensively, negative) track collapses the cell to a sliver via `.ui-field-cell { min-inline-size:0 }`
50
- // — silently hiding it. Only a strictly positive value is a real column, so require it alongside the grammar.
51
- // Strictly positive AND finite: a huge digit run (`'9'.repeat(400)`) parses to Infinity, which passes
52
- // the digit-only grammar but is not a real column and serializes to JSON `null` on the wire — reject it.
53
- const positive = (tok: string) => { const n = parseFloat(tok); return n > 0 && Number.isFinite(n) }
54
- // A bare number or an `fr` length are both FLEX WEIGHTS → emit a plain number (no CSS unit in the wire).
55
- if (WEIGHT.test(w) && positive(w)) return { field, track: parseFloat(w) }
56
- if (LENGTH.test(w) && positive(w)) return { field, track: w.endsWith('fr') ? parseFloat(w) : w }
57
- throw new Error(`[kestrel] ${ctx}: invalid width "${w}" for "${field}" (use a positive number weight like "2", or a length like "30%"/"12rem"/"200px")`)
58
- }
59
-
60
- /**
61
- * Resolve a layout DSL against the field set. A lone string is a full-width row; a string[] is a
62
- * side-by-side row; a single-key object is a named group of rows (one level, never nested). Fields not
63
- * mentioned append as full-width rows at the end (declaration order) so adding a field never hides it.
64
- * Throws on an unknown/duplicate field, a bad width, or a malformed group.
65
- */
66
- export function resolveFieldLayout(dsl: FieldLayoutDSL, fieldKeys: string[], ctx: string): LayoutNode[] {
67
- const known = new Set(fieldKeys)
68
- const seen = new Set<string>()
69
- const nodes: LayoutNode[] = []
70
-
71
- const use = (field: string) => {
72
- if (!known.has(field)) throw new Error(`[kestrel] ${ctx}: unknown field "${field}" in the layout`)
73
- if (seen.has(field)) throw new Error(`[kestrel] ${ctx}: field "${field}" appears more than once in the layout`)
74
- seen.add(field)
75
- }
76
- const toRow = (entry: string | unknown[]): LayoutRow => {
77
- const tokens = Array.isArray(entry) ? entry : [entry]
78
- if (!tokens.length) throw new Error(`[kestrel] ${ctx}: a row must contain at least one field`)
79
- const cells = tokens.map((t) => parseToken(t, ctx))
80
- cells.forEach((c) => use(c.field))
81
- return { kind: 'row', fields: cells.map((c) => c.field), tracks: cells.map((c) => c.track ?? 1) }
82
- }
83
-
84
- for (const entry of dsl) {
85
- if (typeof entry === 'string' || Array.isArray(entry)) {
86
- nodes.push(toRow(entry))
87
- continue
88
- }
89
- // A single-key object → a named group of rows.
90
- const names = Object.keys(entry)
91
- if (names.length !== 1) throw new Error(`[kestrel] ${ctx}: a group must have exactly one name (got ${names.length})`)
92
- const label = names[0]!
93
- const inner = entry[label]!
94
- if (!Array.isArray(inner) || !inner.length) throw new Error(`[kestrel] ${ctx}: group "${label}" needs at least one row`)
95
- const rows = inner.map((r) => {
96
- if (typeof r !== 'string' && !Array.isArray(r)) throw new Error(`[kestrel] ${ctx}: group "${label}" may not nest another group (one level only)`)
97
- return toRow(r)
98
- })
99
- nodes.push({ kind: 'group', label, rows })
100
- }
101
-
102
- // Any field missing from the layout appends as a full-width row (declaration order) — never hidden.
103
- for (const field of fieldKeys) if (!seen.has(field)) nodes.push({ kind: 'row', fields: [field], tracks: [1] })
104
- return nodes
105
- }
106
-
107
- /**
108
- * Validate every field layout a definition carries — the collection's own plus each (nested) repeater's —
109
- * for the throwing side effect. Called from `defineCollection()` so a bad layout fails at import/startup.
110
- */
111
- export function validateFieldLayoutsDeep(def: CollectionDef): void {
112
- if (def.fieldLayout) resolveFieldLayout(def.fieldLayout, Object.keys(def.fields), `collection "${def.name}"`)
113
- walkRepeaterLayouts(def.fields, '')
114
- }
115
-
116
- function walkRepeaterLayouts(fields: Record<string, FieldDef>, prefix: string): void {
117
- for (const [key, field] of Object.entries(fields)) {
118
- if (field.type !== 'repeater') continue
119
- // Structural narrow (avoids a runtime import of `fieldIs` — keeps this file's only runtime edge one-way).
120
- const options = (field as Extract<FieldDef, { type: 'repeater' }>).options
121
- const ctx = `${prefix}repeater "${key}"`
122
- if (options.fieldLayout) resolveFieldLayout(options.fieldLayout, Object.keys(options.fields), ctx)
123
- walkRepeaterLayouts(options.fields, `${ctx} > `)
124
- }
125
- }
@@ -1,101 +0,0 @@
1
- import { eq, ne, lt, lte, gt, gte, sql, or, isNull, type SQL } from 'drizzle-orm'
2
- import { createError } from 'h3'
3
- import type { AnySQLiteColumn } from 'drizzle-orm/sqlite-core'
4
- import { escapeLike } from './sql'
5
- import type { FilterKind, FilterOp } from '../../app/utils/filter-ops'
6
-
7
- /** Coerce a (stringified) filter value to the target COLUMN's storage type before the predicate. Keyed on
8
- * the resolved Drizzle column (not the builtin def type-name) so a CUSTOM field type backed by a
9
- * boolean/number column coerces too. parseFilter stringifies every query param, and drizzle maps any
10
- * non-empty string (incl. 'false') to 1 for a boolean column, so an uncoerced boolean filter matches the
11
- * wrong rows. (Owns this here — its single home — so both crud.list and filterCondition share it without a
12
- * crud↔predicate import cycle.) */
13
- export function coerceFilterValue(col: { getSQLType(): string; mode?: string }, value: unknown): unknown {
14
- if (col.mode === 'boolean') return value === true || value === 'true' || value === '1'
15
- // A timestamp column stores a Date and drizzle maps it via Date.getTime(); a raw string/number reaches
16
- // mapToDriverValue and throws `value.getTime is not a function` → an unhandled 500 (reachable
17
- // unauthenticated on any public collection). Coerce to a Date; an unparseable value is a clean 400.
18
- if (col.mode === 'timestamp' || col.mode === 'timestamp_ms') {
19
- const d = new Date(typeof value === 'number' ? value : String(value))
20
- if (Number.isNaN(d.getTime())) throw createError({ statusCode: 400, statusMessage: `Invalid timestamp filter value: ${String(value)}` })
21
- return d
22
- }
23
- const sqlType = col.getSQLType()
24
- if ((sqlType === 'integer' || sqlType === 'real') && typeof value === 'string') {
25
- const n = Number(value)
26
- return Number.isFinite(n) ? n : value
27
- }
28
- return value
29
- }
30
-
31
- /** JSON-array membership: EXISTS an element of the array column equal to the (typed) value. Fully
32
- * parameterised — only the schema-derived column identifier is interpolated, never user input — matching
33
- * media/usages.ts:25. A `stringSet` (multi choice) matches string members; an `idSet` (many relation/media)
34
- * matches an integer id (a non-numeric filter value is a clean 400, not a mismatch that silently matches
35
- * nothing). */
36
- function member(col: AnySQLiteColumn, kind: 'stringSet' | 'idSet', raw: string): SQL {
37
- let value: string | number = raw
38
- if (kind === 'idSet') {
39
- // Number('') / Number(' ') are 0 (finite) and Number('1e3') is 1000 — none is a valid id token. Require a
40
- // plain (optionally signed) integer so a blank/garbage filter is a clean 400, not a silent empty match.
41
- const t = raw.trim()
42
- if (t === '' || !/^-?\d+$/.test(t)) throw createError({ statusCode: 400, statusMessage: `Invalid id filter value: ${raw}` })
43
- value = Number(t)
44
- }
45
- return sql`EXISTS (SELECT 1 FROM json_each(${sql.identifier(col.name)}) WHERE value = ${value})`
46
- }
47
-
48
- /**
49
- * The exclusive end of the DAY a date-only value names, or null when the rule does not apply. A datetime
50
- * column stores an instant, but `YYYY-MM-DD` (what a date picker and the `filter[c][op]=YYYY-MM-DD` wire
51
- * contract carry) names a whole day: compared against that day's midnight, "on or before" would drop the
52
- * day itself and "after" would return it. The next UTC day works for both storages — a `Date` for a
53
- * timestamp column, and a lexicographically correct string for a TEXT one ('2026-07-25T16:00' < '2026-07-26').
54
- */
55
- function exclusiveDayEnd(kind: FilterKind, raw: string): string | null {
56
- if (kind !== 'datetime' || !/^\d{4}-\d{2}-\d{2}$/.test(raw)) return null
57
- const d = new Date(`${raw}T00:00:00Z`)
58
- // A non-existent calendar date is left to the normal coercion rather than widened: '2026-13-01' is
59
- // unparseable (a clean 400 on a timestamp column) and '2026-02-31' rolls forward three days, so shifting
60
- // it would silently ask a different question than the URL states.
61
- if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== raw) return null
62
- d.setUTCDate(d.getUTCDate() + 1)
63
- return d.toISOString().slice(0, 10)
64
- }
65
-
66
- /**
67
- * Build the WHERE predicate for ONE already-validated filter clause (the caller allow-lists `kind`/`op`).
68
- * Every user value crosses as a drizzle bound param; the only interpolation is `sql.identifier(col.name)`
69
- * (schema-derived, never user input) — injection-safe, matching picker.ts / usages.ts.
70
- *
71
- * The json_each membership and LIKE ESCAPE are SQLite-specific but consistent with the existing query code
72
- * and the SQLite-only engine; a future Postgres query dialect would have to supply @>/ILIKE variants here
73
- * (a loud compile failure, never a silent mis-query). NOTE: `contains` on text/richtext matches the STORED
74
- * string — for richtext that is the HTML source, so 'p' can hit a '<p>' tag (documented caveat; a stripped
75
- * shadow column / FTS index is a later enhancement).
76
- */
77
- export function filterCondition(col: AnySQLiteColumn, kind: FilterKind, op: FilterOp, raw: string): SQL {
78
- switch (op) {
79
- case 'eq': return eq(col, coerceFilterValue(col, raw))
80
- // SQL <> excludes NULL by three-valued logic; "is not X" must still surface an uncategorised (NULL) row.
81
- case 'ne': { const v = coerceFilterValue(col, raw); return col.notNull ? ne(col, v) : or(ne(col, v), isNull(col))! }
82
- case 'lt': return lt(col, coerceFilterValue(col, raw))
83
- // Only the two bounds that would fall on the wrong side of a whole day get the boundary swap; `lt` /
84
- // `gte` already mean "before the day" / "from the day on" when compared against its midnight.
85
- case 'lte': {
86
- const end = exclusiveDayEnd(kind, raw)
87
- return end ? lt(col, coerceFilterValue(col, end)) : lte(col, coerceFilterValue(col, raw))
88
- }
89
- case 'gt': {
90
- const end = exclusiveDayEnd(kind, raw)
91
- return end ? gte(col, coerceFilterValue(col, end)) : gt(col, coerceFilterValue(col, raw))
92
- }
93
- case 'gte': return gte(col, coerceFilterValue(col, raw))
94
- case 'contains':
95
- if (kind === 'stringSet' || kind === 'idSet') return member(col, kind, raw)
96
- // text / richtext substring (SQLite LIKE is ASCII case-insensitive); LIKE metacharacters escaped.
97
- return sql`${col} like ${`%${escapeLike(raw)}%`} escape '\\'`
98
- case 'notContains':
99
- return sql`NOT ${member(col, kind as 'stringSet' | 'idSet', raw)}`
100
- }
101
- }
@@ -1,50 +0,0 @@
1
- import { createError, getRouterParam, type H3Event } from 'h3'
2
- import { getCollection } from './registry'
3
- import type { BuiltCollection } from './collection-types'
4
-
5
- export function getCollectionOr404(name: string): BuiltCollection {
6
- const collection = getCollection(name)
7
- if (!collection) throw createError({ statusCode: 404, statusMessage: `Unknown collection: ${name}` })
8
- return collection
9
- }
10
-
11
- export function requireCollection(event: H3Event): BuiltCollection {
12
- return getCollectionOr404(getRouterParam(event, 'collection') as string)
13
- }
14
-
15
- export function parseId(raw: string): number {
16
- if (!/^[1-9][0-9]*$/.test(raw)) throw createError({ statusCode: 400, statusMessage: `Invalid id: ${raw}` })
17
- return Number(raw)
18
- }
19
-
20
- export function requireId(event: H3Event): number {
21
- return parseId(getRouterParam(event, 'id') as string)
22
- }
23
-
24
- /**
25
- * Parse a list of record ids — from a bulk request body (`number[]`) or a comma-separated query string
26
- * (`?ids=1,2,3`). Dedupes (preserving first-seen order) and rejects, with a clean 400: an empty list, any
27
- * element that is not a positive integer, or more than `cap` ids. The cap is enforced up front on the raw
28
- * input, so an oversized body is rejected without being fully walked and allocated. Shared by the `bulk`
29
- * command endpoint and the batched referrer lookup so both enforce the same id contract.
30
- */
31
- export function parseIdList(raw: unknown, cap: number): number[] {
32
- const parts: unknown[] = Array.isArray(raw)
33
- ? raw
34
- : typeof raw === 'string'
35
- ? raw.split(',').map((s) => s.trim()).filter(Boolean)
36
- : []
37
- // Bound the work before parsing: reject an oversized input up front rather than iterating and building a
38
- // Set of a million ids just to 400. `parts.length` is an upper bound on the deduped result, so this also
39
- // subsumes any post-dedupe cap — a raw list that fits here can never exceed `cap` after deduping.
40
- if (parts.length > cap) throw createError({ statusCode: 400, statusMessage: `Too many ids (max ${cap})` })
41
- const ids: number[] = []
42
- const seen = new Set<number>()
43
- for (const p of parts) {
44
- const n = typeof p === 'number' ? p : typeof p === 'string' ? Number(p) : Number.NaN
45
- if (!Number.isInteger(n) || n <= 0) throw createError({ statusCode: 400, statusMessage: `Invalid id: ${String(p)}` })
46
- if (!seen.has(n)) { seen.add(n); ids.push(n) }
47
- }
48
- if (!ids.length) throw createError({ statusCode: 400, statusMessage: 'ids must be a non-empty list' })
49
- return ids
50
- }