@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,41 +0,0 @@
1
- import type { FieldDef } from '../../../core/server/utils/defineCollection'
2
- import { getBlock } from './defineBlock'
3
-
4
- /** A per-block-props mutation: populate/resolve one block's props in place, given its field defs. */
5
- export type ApplyBlockFields = (props: Record<string, unknown>, fields: Record<string, FieldDef>) => void
6
-
7
- /**
8
- * The shared block-content walker for server-side populators (media `$media`, internal-link resolution,
9
- * …). Maps a `content` array, deep-cloning each block node, applying `applyFields` to a clone of its
10
- * `props` (block type looked up via the registry), and recursing into every declared slot. The differing
11
- * per-field work lives in `applyFields`; only this clone-and-recurse traversal is shared. Returns a fresh
12
- * array (server populators hand back new rows); nodes that aren't block objects pass through untouched.
13
- */
14
- export function buildBlockPopulator(applyFields: ApplyBlockFields): (nodes: unknown) => unknown[] {
15
- const walk = (nodes: unknown): unknown[] => {
16
- if (!Array.isArray(nodes)) return []
17
- return nodes.map((node) => {
18
- if (!node || typeof node !== 'object') return node
19
- const original = node as { type?: string; props?: Record<string, unknown>; slots?: Record<string, unknown> }
20
- const cloned: Record<string, unknown> = { ...original }
21
-
22
- const blockDef = original.type ? getBlock(original.type) : undefined
23
- if (blockDef && original.props) {
24
- const props: Record<string, unknown> = { ...original.props }
25
- cloned.props = props
26
- applyFields(props, blockDef.fields)
27
- }
28
-
29
- if (original.slots && typeof original.slots === 'object') {
30
- const slots: Record<string, unknown> = {}
31
- for (const [slotName, slotNodes] of Object.entries(original.slots)) {
32
- slots[slotName] = walk(slotNodes)
33
- }
34
- cloned.slots = slots
35
- }
36
-
37
- return cloned
38
- })
39
- }
40
- return walk
41
- }
@@ -1,90 +0,0 @@
1
- import { createInsertSchema, createUpdateSchema, createSelectSchema } from 'drizzle-zod'
2
- import { z } from 'zod'
3
- import type { ZodType } from 'zod'
4
- import type { CollectionDef } from '../../../core/server/utils/defineCollection'
5
- import type { BuiltCollection, CollectionSchema } from '../../../core/server/utils/collection-types'
6
- import { seoSchema } from '../../../core/server/utils/seo'
7
- import { evaluateCondition, isEmptyValue } from '../../app/utils/condition'
8
- import { buildTable } from './buildTable'
9
- import { buildFieldSchema } from './buildFieldSchema'
10
- import { resolveColumnName } from '../field-registry/naming'
11
- import { allBlocks, buildBlocksSchema } from './defineBlock'
12
-
13
- /** A page path is consumed verbatim at build time to derive prerender routes / output file paths,
14
- * so it must be incapable of traversal: url-path-safe charset only, no `.`/`..` segments. */
15
- export function isSafePagePath(p: string): boolean {
16
- if (!/^[A-Za-z0-9._~%/-]*$/.test(p)) return false
17
- return !p.split('/').some((seg) => seg === '.' || seg === '..')
18
- }
19
-
20
- export function systemRefinements(def: CollectionDef): Record<string, unknown> {
21
- const refine: Record<string, unknown> = {}
22
- if (def.translatable) refine.locale = z.string().optional()
23
- if (def.mode === 'single') refine.singletonKey = z.string().optional()
24
- else if (def.translatable) refine.translationGroup = z.string().optional()
25
- if (def.pageLike) refine.path = z.string().refine(isSafePagePath, 'Invalid path').nullable().optional()
26
- // The status column is a two-state enum; without this the schema accepts any string and a typo
27
- // ('publushed') stores verbatim and silently unpublishes the record (every gate matches only 'published').
28
- if (def.status) refine.status = z.enum(['draft', 'published']).optional()
29
- if (def.blocks?.enabled) refine.content = z.lazy(() => buildBlocksSchema(allBlocks(def.blocks?.allowed))).optional()
30
- if (def.seo) refine.seo = seoSchema.partial().optional()
31
- return refine
32
- }
33
-
34
- // Update is a partial PATCH: every field may be omitted, so the field refinements
35
- // must be optional on update (a required validator would otherwise override
36
- // drizzle-zod's partial column schema and force the field on every update).
37
- function optionalize(refine: Record<string, ZodType>): Record<string, ZodType> {
38
- const out: Record<string, ZodType> = {}
39
- for (const [key, schema] of Object.entries(refine)) out[key] = schema.optional()
40
- return out
41
- }
42
-
43
- /**
44
- * Accept either a plain `CollectionDef` (the common consumer form: `export default defineCollection(…)`)
45
- * or an already-built collection (the advanced form, when a server route needs the drizzle table object
46
- * directly: `const built = buildCollection(…); export const t = built.table; export default built`).
47
- * The discriminator is the presence of `table`, which only a BuiltCollection has.
48
- */
49
- export function ensureBuilt(c: CollectionDef | BuiltCollection): BuiltCollection {
50
- return 'table' in c ? c : buildCollection(c)
51
- }
52
-
53
- /**
54
- * Build the `applyConditions` hook — every pre-write check the per-field schema can't do — or `undefined`
55
- * when the collection needs none (so CRUD skips it). Two sources, both of which need the whole record:
56
- * the per-field schema relaxed every conditional field to optional (it can't see siblings), so `required`
57
- * is re-enforced here for the ones whose condition is met; and the collection's own `def.validate`, for a
58
- * rule spanning fields. Issues are keyed by the field's def name (so they map to the editor's per-field
59
- * errors), resolving each value at its column key (`<name>Id` for single relation/media).
60
- */
61
- function buildApplyConditions(def: CollectionDef): BuiltCollection['applyConditions'] {
62
- const required = Object.entries(def.fields).filter(([, f]) => f.condition && f.required)
63
- if (!required.length && !def.validate) return undefined
64
- if (!required.length) return (record) => ({ issues: def.validate!(record) })
65
- return (record) => {
66
- const scope: Record<string, unknown> = {}
67
- for (const [key, field] of Object.entries(def.fields)) scope[key] = record[resolveColumnName(key, field).jsKey]
68
- const issues = required
69
- .filter(([key, field]) => evaluateCondition(field.condition!, scope) && isEmptyValue(record[resolveColumnName(key, field).jsKey]))
70
- .map(([key]) => ({ path: [key], message: 'This field is required.' }))
71
- return { issues: [...issues, ...(def.validate?.(record) ?? [])] }
72
- }
73
- }
74
-
75
- export function buildCollection(def: CollectionDef): BuiltCollection {
76
- const table = buildTable(def)
77
- const fieldRefine = buildFieldSchema(def.fields)
78
- const system = systemRefinements(def)
79
- return {
80
- name: def.name,
81
- def,
82
- table,
83
- // Cast through the loose `CollectionSchema`: drizzle-zod's overloads otherwise infer mismatched (ZodEnum)
84
- // types that don't unify across the three builders (see CollectionSchema's note).
85
- insert: createInsertSchema(table, { ...fieldRefine, ...system } as never) as unknown as CollectionSchema,
86
- update: createUpdateSchema(table, { ...optionalize(fieldRefine), ...system } as never) as unknown as CollectionSchema,
87
- select: createSelectSchema(table) as unknown as CollectionSchema,
88
- applyConditions: buildApplyConditions(def),
89
- }
90
- }
@@ -1,22 +0,0 @@
1
- import type { ZodType } from 'zod'
2
- import type { FieldDef } from '../../../core/server/utils/defineCollection'
3
- import { getFieldType } from '../field-registry'
4
- import { resolveColumnName } from '../field-registry/naming'
5
-
6
- function buildShape(fields: Record<string, FieldDef>, keyOf: (key: string, field: FieldDef) => string): Record<string, ZodType> {
7
- const out: Record<string, ZodType> = {}
8
- for (const [key, field] of Object.entries(fields)) {
9
- out[keyOf(key, field)] = getFieldType(field.type).validator(field)
10
- }
11
- return out
12
- }
13
-
14
- /** Collection columns: keyed by jsKey (matches buildTable's column keys). */
15
- export function buildFieldSchema(fields: Record<string, FieldDef>): Record<string, ZodType> {
16
- return buildShape(fields, (key, field) => resolveColumnName(key, field).jsKey)
17
- }
18
-
19
- /** Block props: keyed by the raw field name (props are not DB columns). */
20
- export function buildFieldObjectSchema(fields: Record<string, FieldDef>): Record<string, ZodType> {
21
- return buildShape(fields, (key) => key)
22
- }
@@ -1,113 +0,0 @@
1
- import { sqliteTable, integer, text, uniqueIndex, index } from 'drizzle-orm/sqlite-core'
2
- import type { SQLiteColumnBuilderBase, SQLiteTable } from 'drizzle-orm/sqlite-core'
3
- import { sql } from 'drizzle-orm'
4
- import type { Block } from '../../../core/server/utils/blocks'
5
- import type { SeoMeta } from '../../../core/server/utils/seo'
6
- import type { CollectionDef } from '../../../core/server/utils/defineCollection'
7
- import { getFieldType, fieldCanEnforceUnique } from '../field-registry'
8
- import { resolveColumnName } from '../field-registry/naming'
9
-
10
- /** The system columns this def actually emits (jsKey + DB-name forms), gated by the SAME flags that add
11
- * them in `buildTable` — so a field may only use e.g. `content`/`status` on a collection that doesn't
12
- * enable blocks/status. A field resolving to one of these would silently overwrite a system column. */
13
- function reservedColumns(def: CollectionDef): { js: Set<string>; db: Set<string> } {
14
- const js = new Set(['id', 'createdAt', 'updatedAt'])
15
- const db = new Set(['id', 'created_at', 'updated_at'])
16
- const add = (j: string, d: string) => { js.add(j); db.add(d) }
17
- if (def.translatable) add('locale', 'locale')
18
- if (def.mode === 'single') add('singletonKey', 'singleton_key')
19
- else if (def.translatable) add('translationGroup', 'translation_group')
20
- if (def.pageLike) { add('path', 'path'); add('layout', 'layout') }
21
- if (def.status) add('status', 'status')
22
- if (def.seo) add('seo', 'seo')
23
- if (def.blocks?.enabled) add('content', 'content')
24
- return { js, db }
25
- }
26
-
27
- function buildIndexes(def: CollectionDef, t: Record<string, never>) {
28
- const out = []
29
- const names = new Set<string>()
30
- // System indexes are named first, so a colliding field index (below) is the one that fails loud —
31
- // the fixed system naming scheme can't be the thing that has to change.
32
- const system = (name: string) => { names.add(name); return name }
33
- if (def.mode === 'single') {
34
- out.push(def.translatable
35
- ? uniqueIndex(system(`${def.name}_key_locale`)).on(t.singletonKey, t.locale)
36
- : uniqueIndex(system(`${def.name}_key`)).on(t.singletonKey))
37
- } else if (def.translatable) {
38
- out.push(uniqueIndex(system(`${def.name}_group_locale`)).on(t.translationGroup, t.locale))
39
- out.push(index(system(`${def.name}_group`)).on(t.translationGroup))
40
- }
41
- if (def.pageLike) {
42
- out.push(def.translatable
43
- ? uniqueIndex(system(`${def.name}_path_locale`)).on(t.path, t.locale).where(sql`path is not null`)
44
- : uniqueIndex(system(`${def.name}_path`)).on(t.path).where(sql`path is not null`))
45
- }
46
- // Opt-in non-unique field indexes (`index: true`) — for columns that are frequently filtered/sorted but
47
- // not unique (e.g. media.folder). `unique` fields already get an index from the field-type's `.unique()`.
48
- // A name that collides with a system index (e.g. a field named "key" on a singleton) would otherwise mint
49
- // TWO indexes of the same name — drizzle-kit's `syncSchema` then throws at DEPLOY time, not authoring
50
- // time. Fail loud here instead, same style as the reserved-column / duplicate-column guards above.
51
- for (const [key, field] of Object.entries(def.fields)) {
52
- if (!field.index || field.unique) continue
53
- const { jsKey, dbName } = resolveColumnName(key, field)
54
- const name = `${def.name}_${dbName}`
55
- if (names.has(name)) {
56
- throw new Error(`kestrel: collection "${def.name}" field "${key}" produces the index name "${name}", which collides with a system index — rename the field.`)
57
- }
58
- names.add(name)
59
- out.push(index(name).on(t[jsKey as keyof typeof t]))
60
- }
61
- return out
62
- }
63
-
64
- export function buildTable(def: CollectionDef): SQLiteTable {
65
- const cols: Record<string, SQLiteColumnBuilderBase> = {}
66
- cols.id = integer('id').primaryKey({ autoIncrement: true })
67
- if (def.translatable) cols.locale = text('locale').notNull()
68
- if (def.mode === 'single') cols.singletonKey = text('singleton_key').notNull()
69
- else if (def.translatable) cols.translationGroup = text('translation_group').notNull()
70
- if (def.pageLike) {
71
- cols.path = text('path')
72
- // Nullable with no default: an editor's "inherit" must be distinguishable from an explicit `default`,
73
- // and the render decides the fallback (see resolvePageLayout) so a deleted layout file degrades in one
74
- // place instead of being frozen into every row.
75
- cols.layout = text('layout')
76
- }
77
- if (def.status) cols.status = text('status').notNull().default('draft')
78
- if (def.seo) cols.seo = text('seo', { mode: 'json' }).$type<SeoMeta>().notNull().default(sql`'{}'`)
79
- if (def.blocks?.enabled) cols.content = text('content', { mode: 'json' }).$type<Block[]>().notNull().default(sql`'[]'`)
80
-
81
- const reserved = reservedColumns(def)
82
- const seenJsKeys = new Set<string>()
83
- const seenDbNames = new Set<string>()
84
- for (const [key, field] of Object.entries(def.fields)) {
85
- const { jsKey, dbName } = resolveColumnName(key, field)
86
- // Fail loud (matching defineCollection's pageLike guard) rather than silently overwriting a system
87
- // column — e.g. a field named `id`/`status` would clobber the PK / publish-gating column — or
88
- // shadowing a sibling whose resolved key collides (a single-ref `foo` → `fooId` vs a literal `fooId`).
89
- if (reserved.js.has(jsKey) || reserved.db.has(dbName)) {
90
- throw new Error(`kestrel: collection "${def.name}" field "${key}" resolves to the reserved system column "${jsKey}" — rename the field.`)
91
- }
92
- if (seenJsKeys.has(jsKey) || seenDbNames.has(dbName)) {
93
- throw new Error(`kestrel: collection "${def.name}" field "${key}" resolves to column "${jsKey}"/"${dbName}", which collides with another field — rename one of them.`)
94
- }
95
- // `unique` on a json/array-backed column (multi-choice, multi-media, many-relation, repeater, json) is
96
- // a silent no-op — the column type never carries a DB constraint for it. Warn rather than throw: the
97
- // flag is inert, so a definition that boots today must keep booting.
98
- if (field.unique && !fieldCanEnforceUnique(field)) {
99
- console.warn(`kestrel: collection "${def.name}" field "${key}" is "unique", but its storage (${field.type}, multi-valued) can never enforce a DB uniqueness constraint — the flag is ignored.`)
100
- }
101
- seenJsKeys.add(jsKey)
102
- seenDbNames.add(dbName)
103
- cols[jsKey] = getFieldType(field.type).column(dbName, field)
104
- }
105
-
106
- // `timestamp_ms` (not `timestamp`) keeps MILLISECOND precision: the optimistic-concurrency check compares
107
- // `updatedAt` exactly, and second-granularity would let two saves in the same wall-clock second share a
108
- // token — a stale tab could then silently revert a newer save. Same INTEGER column type, so no DDL change.
109
- cols.createdAt = integer('created_at', { mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date())
110
- cols.updatedAt = integer('updated_at', { mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date())
111
-
112
- return sqliteTable(def.name, cols, (t) => buildIndexes(def, t as unknown as Record<string, never>))
113
- }
@@ -1,25 +0,0 @@
1
- import { z } from 'zod'
2
- import type { FieldDef } from '../../../core/server/utils/defineCollection'
3
- import { evaluateCondition, isEmptyValue } from '../../app/utils/condition'
4
-
5
- /**
6
- * Re-enforce `required` for conditional sub-fields whose condition is met, at the object level where their
7
- * sibling values are visible (a block's props, a repeater entry). The per-field validator relaxed a
8
- * `required + condition` field to optional (it can't see siblings), and the collection-level applyConditions
9
- * hook only walks TOP-LEVEL fields — so without this, a conditional-required prop inside a block or repeater
10
- * is a silent server no-op. Adds a superRefine that checks each such field against the parsed object; a no-op
11
- * (returns the schema unchanged) when the shape has no conditional-required fields.
12
- */
13
- export function refineConditionalRequired<T extends z.ZodTypeAny>(schema: T, fields: Record<string, FieldDef>): z.ZodTypeAny {
14
- const conditional = Object.entries(fields).filter(([, f]) => f.condition && f.required)
15
- if (!conditional.length) return schema
16
- return schema.superRefine((val, ctx) => {
17
- if (!val || typeof val !== 'object') return
18
- const scope = val as Record<string, unknown>
19
- for (const [key, field] of conditional) {
20
- if (evaluateCondition(field.condition!, scope) && isEmptyValue(scope[key])) {
21
- ctx.addIssue({ code: z.ZodIssueCode.custom, path: [key], message: 'This field is required.' })
22
- }
23
- }
24
- })
25
- }
@@ -1,86 +0,0 @@
1
- import { z } from 'zod'
2
- import type { ZodObject } from 'zod'
3
- import type { FieldDef, Localized } from '../../../core/server/utils/defineCollection'
4
- import { buildFieldObjectSchema } from './buildFieldSchema'
5
- import { refineConditionalRequired } from './conditional-required'
6
-
7
- export interface BlockDef {
8
- name: string
9
- fields: Record<string, FieldDef>
10
- slots?: string[]
11
- label?: Localized
12
- /** Optional icon name for the admin block picker (e.g. a lucide name). Set via the SFC's `defineBlock`. */
13
- icon?: string
14
- /** Optional preview image (URL/path, e.g. '/block-previews/hero.png') shown above the name in the picker. */
15
- image?: string
16
- }
17
-
18
- const blocks = new Map<string, BlockDef>()
19
-
20
- export function defineBlock(def: BlockDef): BlockDef {
21
- return def
22
- }
23
- export function registerBlock(def: BlockDef): void {
24
- blocks.set(def.name, def)
25
- }
26
- export function getBlock(name: string): BlockDef | undefined {
27
- return blocks.get(name)
28
- }
29
- export function clearBlocks(): void {
30
- blocks.clear()
31
- }
32
- export function allBlocks(allowed?: string[]): BlockDef[] {
33
- const all = [...blocks.values()]
34
- return allowed?.length ? all.filter((b) => allowed.includes(b.name)) : all
35
- }
36
-
37
- const MAX_BLOCK_DEPTH = 60
38
- const MAX_BLOCK_NODES = 10_000
39
-
40
- // Iterative (no recursion) traversal so the guard itself can't stack-overflow.
41
- function withinBlockBounds(value: unknown): boolean {
42
- const stack: Array<{ v: unknown; depth: number }> = [{ v: value, depth: 0 }]
43
- let nodes = 0
44
- while (stack.length) {
45
- const top = stack.pop() as { v: unknown; depth: number }
46
- const { v, depth } = top
47
- if (v === null || typeof v !== 'object') continue
48
- if (depth > MAX_BLOCK_DEPTH) return false
49
- if (++nodes > MAX_BLOCK_NODES) return false
50
- if (Array.isArray(v)) {
51
- for (const item of v) stack.push({ v: item, depth: depth + 1 })
52
- } else {
53
- for (const key of Object.keys(v as Record<string, unknown>)) {
54
- stack.push({ v: (v as Record<string, unknown>)[key], depth: depth + 1 })
55
- }
56
- }
57
- }
58
- return true
59
- }
60
-
61
- export function buildBlocksSchema(blockDefs: BlockDef[]): z.ZodType {
62
- const node: z.ZodType = blockDefs.length
63
- ? z.lazy(() =>
64
- z.discriminatedUnion(
65
- 'type',
66
- blockDefs.map((b) =>
67
- z.object({
68
- id: z.string().min(1),
69
- type: z.literal(b.name),
70
- props: refineConditionalRequired(z.object(buildFieldObjectSchema(b.fields)), b.fields),
71
- slots: b.slots?.length
72
- ? z.object(Object.fromEntries(b.slots.map((s) => [s, z.array(node)]))).partial().optional()
73
- : z.undefined().optional(),
74
- }),
75
- ) as unknown as [ZodObject, ...ZodObject[]],
76
- ),
77
- )
78
- : z.never()
79
- return z.unknown()
80
- .superRefine((val, ctx) => {
81
- if (!withinBlockBounds(val)) {
82
- ctx.addIssue({ code: 'custom', message: 'Block tree exceeds the maximum depth or size' })
83
- }
84
- })
85
- .pipe(z.array(node))
86
- }
@@ -1,33 +0,0 @@
1
- import type { FieldTypeDescriptor } from '../field-registry/types'
2
- import { registerFieldType } from '../field-registry'
3
-
4
- export type { FieldTypeDescriptor }
5
- // Re-export the descriptor helpers so a consumer's field-type file can build the Drizzle column + Zod with
6
- // the SAME optionality/constraint logic the built-ins use — all auto-imported in a `server/field-types/` file.
7
- export { constrain, opt, optArr, isHardRequired, getFieldType, registerFieldType } from '../field-registry'
8
-
9
- /**
10
- * A consumer-defined field type: a `name` plus the server contract — a Drizzle `column` and a Zod
11
- * `validator`. Drop a file in `server/field-types/` that default-exports `defineFieldType({...})`; it is
12
- * auto-discovered and registered BEFORE any table is built. Pair it with a client editor widget via
13
- * `registerFieldComponent('<name>', Component)` in an `app/plugins/*.client.ts`.
14
- *
15
- * @example
16
- * // server/field-types/color.ts
17
- * import { text } from 'drizzle-orm/sqlite-core'
18
- * import { z } from 'zod'
19
- * export default defineFieldType({
20
- * name: 'color',
21
- * column: (n, f) => constrain(text(n), f),
22
- * validator: (f) => opt(z.string().regex(/^#[0-9a-f]{6}$/i), f),
23
- * })
24
- */
25
- export interface FieldTypeDef extends FieldTypeDescriptor {
26
- name: string
27
- }
28
-
29
- export function defineFieldType(def: FieldTypeDef): FieldTypeDescriptor {
30
- const { name, ...descriptor } = def // keep column, validator AND transform — don't drop a consumer's transform
31
- registerFieldType(name, descriptor)
32
- return descriptor
33
- }
@@ -1,152 +0,0 @@
1
- import type { CollectionDef, FieldDef } from '../../../core/server/utils/defineCollection'
2
- import { fieldIs } from '../../../core/server/utils/defineCollection'
3
- import type { KeyMode } from '../../../core/server/utils/populate'
4
- import { seoPopulateFields } from '../../../core/server/utils/seo'
5
- import { getBlock } from './defineBlock'
6
- import { collectRichtextRefs } from '../../app/utils/richtext-links'
7
-
8
- /**
9
- * Static reference extraction over a record's fields — the pure core of the `record_refs` durable index
10
- * and the derived stale-reference warnings. Walks every reference-bearing field type (relation, media,
11
- * internal link, richtext internal-link markers, repeater) plus the block tree, and yields the
12
- * `(collection, id)` targets it points at. PURE (no DB / no Nuxt) so it unit-tests hard.
13
- */
14
- export interface FieldRef {
15
- collection: string
16
- id: number
17
- }
18
-
19
- /** A ref tagged with WHERE it lives, for the editor's per-field / per-block dead-reference notes. */
20
- export interface LocatedRef extends FieldRef {
21
- /** The top-level field key (a repeater attributes all its entries' refs to the repeater key). */
22
- field: string
23
- /** The id of the block node the ref lives in, or undefined at the record root. */
24
- blockId?: string
25
- }
26
-
27
- // `KeyMode` (columns vs props storage asymmetry) is owned by core's populate seam and imported above —
28
- // the same asymmetry the field-tree populator walks, so both share one definition.
29
-
30
- /** The internal-link guard, reimplemented locally to keep this `core`/`fields`-side extractor from
31
- * importing the `public` layer's private `isInternal` (which would invert the layer dependency). */
32
- function internalLinkRef(value: unknown): FieldRef | null {
33
- if (!value || typeof value !== 'object') return null
34
- const v = value as Record<string, unknown>
35
- if (v.type === 'internal' && typeof v.collection === 'string' && typeof v.id === 'number') {
36
- return { collection: v.collection, id: v.id }
37
- }
38
- return null
39
- }
40
-
41
- /** Located refs inside one flat value bag (top-level row | block props | a repeater entry). */
42
- function bagRefs(fields: Record<string, FieldDef>, bag: Record<string, unknown>, keyMode: KeyMode): LocatedRef[] {
43
- const out: LocatedRef[] = []
44
- for (const [key, field] of Object.entries(fields)) {
45
- // `if (fieldIs(...))` not `switch (field.type)`: the open consumer arm makes `type` a non-discriminant,
46
- // so a switch wouldn't narrow `field` to its arm. (text / number / boolean / datetime / choice / json
47
- // carry no typed references, so they're not handled here.)
48
- if (fieldIs(field, 'relation')) {
49
- const coll = field.relation.collection
50
- if (field.relation.many) {
51
- const ids = bag[key]
52
- if (Array.isArray(ids)) for (const id of ids) if (typeof id === 'number') out.push({ field: key, collection: coll, id })
53
- } else {
54
- const value = bag[keyMode === 'columns' ? `${key}Id` : key]
55
- if (typeof value === 'number') out.push({ field: key, collection: coll, id: value })
56
- }
57
- } else if (fieldIs(field, 'media')) {
58
- if (field.options?.multiple) {
59
- const ids = bag[key]
60
- if (Array.isArray(ids)) for (const id of ids) if (typeof id === 'number') out.push({ field: key, collection: 'media', id })
61
- } else {
62
- const value = bag[keyMode === 'columns' ? `${key}Id` : key]
63
- if (typeof value === 'number') out.push({ field: key, collection: 'media', id: value })
64
- }
65
- } else if (field.type === 'link') {
66
- const ref = internalLinkRef(bag[key])
67
- if (ref) out.push({ field: key, collection: ref.collection, id: ref.id })
68
- } else if (field.type === 'richtext') {
69
- const value = bag[key]
70
- if (typeof value === 'string') for (const r of collectRichtextRefs(value)) out.push({ field: key, collection: r.collection, id: r.id })
71
- } else if (fieldIs(field, 'repeater')) {
72
- const entries = bag[key]
73
- if (Array.isArray(entries)) {
74
- for (const entry of entries) {
75
- if (entry && typeof entry === 'object') {
76
- // A repeater entry is keyed bare (props mode); attribute its refs to the repeater field.
77
- for (const r of bagRefs(field.options.fields, entry as Record<string, unknown>, 'props')) {
78
- out.push({ field: key, collection: r.collection, id: r.id })
79
- }
80
- }
81
- }
82
- }
83
- }
84
- }
85
- return out
86
- }
87
-
88
- /** Located refs inside a block `content` array, recursing slots; each ref carries the innermost block id. */
89
- function blockRefs(content: unknown): LocatedRef[] {
90
- const out: LocatedRef[] = []
91
- const walk = (nodes: unknown): void => {
92
- if (!Array.isArray(nodes)) return
93
- for (const node of nodes) {
94
- if (!node || typeof node !== 'object') continue
95
- const n = node as { id?: string; type?: string; props?: Record<string, unknown>; slots?: Record<string, unknown> }
96
- const def = n.type ? getBlock(n.type) : undefined
97
- if (def && n.props && typeof n.props === 'object') {
98
- for (const r of bagRefs(def.fields, n.props, 'props')) out.push({ field: r.field, blockId: n.id, collection: r.collection, id: r.id })
99
- }
100
- if (n.slots && typeof n.slots === 'object') {
101
- for (const slotNodes of Object.values(n.slots)) walk(slotNodes)
102
- }
103
- }
104
- }
105
- walk(content)
106
- return out
107
- }
108
-
109
- function dedupe(refs: FieldRef[]): FieldRef[] {
110
- const seen = new Set<string>()
111
- const out: FieldRef[] = []
112
- for (const r of refs) {
113
- const k = `${r.collection}:${r.id}`
114
- if (!seen.has(k)) { seen.add(k); out.push({ collection: r.collection, id: r.id }) }
115
- }
116
- return out
117
- }
118
-
119
- function dedupeLocated(refs: LocatedRef[]): LocatedRef[] {
120
- const seen = new Set<string>()
121
- const out: LocatedRef[] = []
122
- for (const r of refs) {
123
- const k = `${r.blockId ?? ''} ${r.field} ${r.collection}:${r.id}`
124
- if (!seen.has(k)) { seen.add(k); out.push(r) }
125
- }
126
- return out
127
- }
128
-
129
- /** Deduped `(collection,id)` refs in a single flat value bag (one field set), honouring the key-mode. */
130
- export function extractFieldRefs(fields: Record<string, FieldDef>, bag: Record<string, unknown>, keyMode: KeyMode): FieldRef[] {
131
- return dedupe(bagRefs(fields, bag, keyMode))
132
- }
133
-
134
- /** Every distinct `(collection,id)` a record points at — its top-level columns plus, if blocks are
135
- * enabled, its whole block tree. The forward edges stored in `record_refs`. */
136
- export function extractRecordRefs(def: CollectionDef, row: Record<string, unknown>): FieldRef[] {
137
- return dedupe(extractLocatedRecordRefs(def, row))
138
- }
139
-
140
- /** Like `extractRecordRefs` but each ref carries its field + block-node location (for editor warnings). */
141
- export function extractLocatedRecordRefs(def: CollectionDef, row: Record<string, unknown>): LocatedRef[] {
142
- const refs = bagRefs(def.fields, row, 'columns')
143
- // The `seo` system column carries a media ref (the social image) — track it like any media field so
144
- // dead-link warnings and the published-media GC see a record whose ONLY use of an image is og:image.
145
- if (def.seo && row.seo && typeof row.seo === 'object') {
146
- for (const r of bagRefs(seoPopulateFields, row.seo as Record<string, unknown>, 'props')) {
147
- refs.push({ ...r, field: `seo.${r.field}` })
148
- }
149
- }
150
- if (def.blocks?.enabled) refs.push(...blockRefs(row.content))
151
- return dedupeLocated(refs)
152
- }
@@ -1,68 +0,0 @@
1
- import type { FieldDef } from '../../../core/server/utils/defineCollection'
2
- import { fieldIs } from '../../../core/server/utils/defineCollection'
3
- import { seoPopulateFields } from '../../../core/server/utils/seo'
4
- import type { Populator, PopulateCtx, KeyMode, FieldPopulator } from '../../../core/server/utils/populate'
5
- import { getFieldPopulator } from '../../../core/server/utils/populate'
6
- import { buildBlockPopulator } from './block-populate'
7
-
8
- /** How the walker finds the populator for a field type — the global registry by default, or an explicit
9
- * map (used by tests + the media/link whole-row compatibility builders to run one populator in isolation). */
10
- export type PopulatorLookup = (type: string) => FieldPopulator | undefined
11
-
12
- /**
13
- * Apply the registered per-field-type populators over ONE flat value bag (top-level row | block props | a
14
- * repeater entry). For each field: dispatch to its type's populator (which reads by `keyMode` and mutates
15
- * `bag`), then — for a repeater — clone its entries and recurse in PROPS mode. Cloning the array + entries
16
- * keeps the ORIGINAL row untouched (population is non-destructive). Mutates `bag` in place. This is the
17
- * `bagRefs` recursion (see `extract-refs.ts`), generalised from "collect refs" to "run a visitor".
18
- */
19
- export function applyFieldPopulators(
20
- bag: Record<string, unknown>,
21
- fields: Record<string, FieldDef>,
22
- keyMode: KeyMode,
23
- ctx: PopulateCtx,
24
- lookup: PopulatorLookup,
25
- ): void {
26
- for (const [key, field] of Object.entries(fields)) {
27
- // A per-instance `field.populate` (Pruvious `additional.population`) wins over the type's default.
28
- const fn = field.populate ?? lookup(field.type)
29
- if (fn) fn(bag, key, field, ctx, keyMode)
30
- if (fieldIs(field, 'repeater')) {
31
- const entries = bag[key]
32
- if (Array.isArray(entries)) {
33
- const cloned = entries.map((e) => (e && typeof e === 'object' ? { ...(e as Record<string, unknown>) } : e))
34
- bag[key] = cloned
35
- for (const entry of cloned) {
36
- if (entry && typeof entry === 'object') {
37
- applyFieldPopulators(entry as Record<string, unknown>, field.options.fields, 'props', ctx, lookup)
38
- }
39
- }
40
- }
41
- }
42
- }
43
- }
44
-
45
- /**
46
- * The single global row populator: walks a row's top-level fields (COLUMNS mode) and, when blocks are
47
- * enabled, its block tree (each node's props in PROPS mode + every slot), dispatching each field to its
48
- * registered per-type populator and recursing repeaters. Non-destructive (returns a fresh row; block
49
- * nodes/props are cloned by `buildBlockPopulator`, repeater entries by `applyFieldPopulators`).
50
- */
51
- export function buildFieldTreePopulator(lookup: PopulatorLookup = getFieldPopulator): Populator {
52
- return (row, ctx) => {
53
- const out: Record<string, unknown> = { ...row }
54
- applyFieldPopulators(out, ctx.def.fields, 'columns', ctx, lookup)
55
- // The `seo` system column carries a media reference (the social image) — walk it like a props bag
56
- // so the id resolves under `seo.$media.image` with the same read-capture as any media field.
57
- if (ctx.def.seo && out.seo && typeof out.seo === 'object') {
58
- const seoBag = { ...(out.seo as Record<string, unknown>) }
59
- applyFieldPopulators(seoBag, seoPopulateFields, 'props', ctx, lookup)
60
- out.seo = seoBag
61
- }
62
- if (ctx.def.blocks?.enabled && Array.isArray(out.content)) {
63
- const walk = buildBlockPopulator((props, fields) => applyFieldPopulators(props, fields, 'props', ctx, lookup))
64
- out.content = walk(out.content)
65
- }
66
- return out
67
- }
68
- }
@@ -1,8 +0,0 @@
1
- import { findMediaUsages } from '../../../utils/usages'
2
- import { requireMediaCollection } from '../../../utils/media-enabled'
3
-
4
- export default defineEventHandler((event) => {
5
- requireMediaCollection()
6
- const id = requireId(event)
7
- return { id, usages: findMediaUsages(useDb(), id) }
8
- })