@michaelthielemann/kestrel 3.0.1 → 4.0.1

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 +69 -11
  54. package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
  55. package/layers/core/modules/auto-discovery/scan.ts +92 -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 +160 -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
@@ -20,12 +20,15 @@ interface ListResponse {
20
20
  total: number
21
21
  page: number
22
22
  perPage: number
23
+ /** Set by `validateOut` — count of rows in `data` replaced with the quarantine shape. */
24
+ quarantinedCount?: number
23
25
  }
24
26
 
25
27
  export function useListRows(opts: ListRowsOptions) {
26
28
  const { t } = useT()
27
29
  const rows = ref<Record<string, unknown>[]>([])
28
30
  const total = ref(0)
31
+ const quarantinedCount = ref(0)
29
32
  // Surfaced when a (re)fetch fails — an inline error + Retry, never a silent stale list or a thrown
30
33
  // error boundary. Distinct from the empty state (which only shows when the fetch SUCCEEDED with no rows).
31
34
  const error = ref<string | null>(null)
@@ -37,12 +40,13 @@ export function useListRows(opts: ListRowsOptions) {
37
40
  const mine = ++seq
38
41
  const locale = opts.locale()
39
42
  try {
40
- const res = await $fetch<ListResponse>(`/api/${opts.collection.value}`, {
43
+ const res = await $fetch<ListResponse>(`/api/${opts.collection.value}/readMany`, {
41
44
  query: { ...opts.effectiveQuery.value, ...(locale ? { locale } : {}) },
42
45
  })
43
46
  if (mine !== seq) return
44
47
  rows.value = res.data
45
48
  total.value = res.total
49
+ quarantinedCount.value = res.quarantinedCount ?? 0
46
50
  error.value = null
47
51
  // A page that emptied out from under us — e.g. bulk-deleting the last rows of the trailing page —
48
52
  // would otherwise strand the user on an out-of-range page showing the "create your first" empty
@@ -60,9 +64,10 @@ export function useListRows(opts: ListRowsOptions) {
60
64
  }
61
65
  }
62
66
 
63
- return { rows, total, error, totalPages, fetchRows } as {
67
+ return { rows, total, quarantinedCount, error, totalPages, fetchRows } as {
64
68
  rows: Ref<Record<string, unknown>[]>
65
69
  total: Ref<number>
70
+ quarantinedCount: Ref<number>
66
71
  error: Ref<string | null>
67
72
  totalPages: ComputedRef<number>
68
73
  fetchRows: () => Promise<void>
@@ -1,7 +1,7 @@
1
1
  import type { Ref } from 'vue'
2
2
  import type { ListColumn } from '../utils/list-columns'
3
3
  import { toQuery, toggleSort, parseListQuery, type FilterCell, type ListState } from '../utils/list-query'
4
- import { clampPerPage, DEFAULT_PER_PAGE } from '../../../core/app/utils/list-limits'
4
+ import { clampPerPage, DEFAULT_PER_PAGE } from '@michaelthielemann/kestrel-core/client'
5
5
 
6
6
  /**
7
7
  * The URL is the SINGLE SOURCE OF TRUTH for a collection list's committed state (sort · page · perPage ·
@@ -1,7 +1,7 @@
1
1
  import { ref, toValue } from 'vue'
2
2
  import type { MaybeRefOrGetter } from 'vue'
3
3
 
4
- /** The live publish state of a record's static page, as returned by `GET /api/publish-status`. */
4
+ /** The live publish state of a record's static page, as returned by `GET /api/publishStatus`. */
5
5
  export interface PublishStatusData {
6
6
  route: string | null
7
7
  status: 'success' | 'error' | null
@@ -59,7 +59,7 @@ export function usePublishStatus(args: {
59
59
  return
60
60
  }
61
61
  try {
62
- const res = await $fetch<PublishStatusData>('/api/publish-status', {
62
+ const res = await $fetch<PublishStatusData>('/api/publishStatus', {
63
63
  query: { collection: toValue(args.collection), id, locale: toValue(args.locale) },
64
64
  })
65
65
  if (token === pollToken) data.value = res
@@ -124,7 +124,6 @@ useHead(() => ({
124
124
  min-height: 0;
125
125
  }
126
126
 
127
- // Collapsed rail: narrow to icon-only width.
128
127
  &--rail-collapsed &__rail {
129
128
  flex-basis: var(--rail-width-collapsed);
130
129
  width: var(--rail-width-collapsed);
@@ -43,8 +43,8 @@ const heading = computed(() =>
43
43
  : t('editor.editRecord', { collection: singular.value, id }),
44
44
  )
45
45
 
46
- // The external-tab button always works now: with unsaved edits it opens a preview TICKET (no save, no
47
- // publish), otherwise the record's own URL — so the tooltip has to say which of the two you'd get.
46
+ // The external-tab button opens a preview ticket when there are unsaved edits (no save, no publish),
47
+ // otherwise the record's own URL — so the tooltip has to say which of the two you'd get.
48
48
  const previewTitle = computed(() => t(editorRef.value?.dirty ? 'editor.previewUnsaved' : 'editor.openInNewTab'))
49
49
 
50
50
  const skipGuard = ref(false)
@@ -121,9 +121,10 @@ async function confirmDelete() {
121
121
  <KestrelUiButton type="button" variant="ghost" size="sm" icon="external-link" :title="previewTitle" :aria-label="previewTitle" :loading="editorRef?.previewOpening" @click="editorRef?.openPreview()" />
122
122
  <KestrelUiButton type="button" variant="secondary" size="sm" icon="x" :disabled="saving" @click="toList">{{ t('common.cancel') }}</KestrelUiButton>
123
123
  <KestrelUiButton v-if="id !== 'new'" variant="danger" size="sm" icon="trash" :loading="deleting" @click="onDelete">{{ t('common.delete') }}</KestrelUiButton>
124
- <KestrelUiButton type="submit" :form="EDITOR_FORM_ID" variant="primary" size="sm" icon="check" :loading="saving">{{ t('common.save') }}</KestrelUiButton>
124
+ <!-- A quarantined record is read-only: no Save, no Publish — there is nothing valid to write. -->
125
+ <KestrelUiButton v-if="!editorRef?.quarantined" type="submit" :form="EDITOR_FORM_ID" variant="primary" size="sm" icon="check" :loading="saving">{{ t('common.save') }}</KestrelUiButton>
125
126
  <!-- Publishing is its own decision: Save persists, Publish writes the static page (ADR-0008). -->
126
- <KestrelUiButton v-if="editorRef?.canPublish !== false" type="button" variant="secondary" size="sm" icon="upload" :loading="editorRef?.publishing" :disabled="saving" @click="editorRef?.publish()">{{ t('common.publish') }}</KestrelUiButton>
127
+ <KestrelUiButton v-if="editorRef?.canPublish !== false && !editorRef?.quarantined" type="button" variant="secondary" size="sm" icon="upload" :loading="editorRef?.publishing" :disabled="saving" @click="editorRef?.publish()">{{ t('common.publish') }}</KestrelUiButton>
127
128
  <KestrelEditorStatus class="record__ampel" :dirty="editorRef?.dirty ?? false" :saving="saving" :has-status="editorRef?.hasStatus ?? false" :status="editorRef?.status" :saved-status="editorRef?.savedStatus" :page-like="editorRef?.pageLike ?? false" :live="editorRef?.live" />
128
129
  </div>
129
130
  </div>
@@ -17,7 +17,7 @@ try {
17
17
  // Broken-reference count for the alert banner — fire-and-forget so it never delays the dashboard, and a
18
18
  // failure (or a not-yet-built index) simply shows no banner.
19
19
  const brokenCount = ref(0)
20
- $fetch<unknown[]>('/api/references/broken')
20
+ $fetch<unknown[]>('/api/brokenRefs')
21
21
  .then((r) => { brokenCount.value = r.length })
22
22
  .catch(() => { brokenCount.value = 0 })
23
23
  </script>
@@ -4,7 +4,7 @@ definePageMeta({ layout: 'admin', middleware: 'admin-auth' })
4
4
  // The global broken-references report: every record that links to a deleted or unpublished target. The
5
5
  // referrer keeps its last published output (the stale link stays live) until it is re-edited — this is
6
6
  // the editor's queue for fixing those. Derived on read, so an entry clears once the link is fixed or the
7
- // target restored. Admin-only (the `references` API resource is outside the public set).
7
+ // target restored. Admin-only (the `brokenRefs` pipeline is outside the public set).
8
8
  interface BrokenRow {
9
9
  source: { collection: string; id: number }
10
10
  target: { collection: string; id: number }
@@ -17,7 +17,7 @@ const broken = ref<BrokenRow[]>([])
17
17
  // the green all-clear (that would imply a verified-clean site when nothing was actually verified).
18
18
  const loadError = ref(false)
19
19
  try {
20
- broken.value = await $fetch<BrokenRow[]>('/api/references/broken')
20
+ broken.value = await $fetch<BrokenRow[]>('/api/brokenRefs')
21
21
  } catch {
22
22
  loadError.value = true
23
23
  }
@@ -1,4 +1,4 @@
1
- import type { SerializedBlock } from '../../../core/server/utils/serialize-collection'
1
+ import type { SerializedBlock } from '@michaelthielemann/kestrel-core'
2
2
  import { reorder } from '../../../ui/app/utils/reorder'
3
3
  import { initialValues } from './edit-form'
4
4
 
@@ -1,17 +1,42 @@
1
1
  // Wire shapes for the collection batch endpoint, shared by the ops composable and the delete dialog.
2
2
  // Mirrors media/app/utils/ops.ts: one definition, no drift between the caller and the confirm UI.
3
+ import type { SerializedAction } from '@michaelthielemann/kestrel-core'
3
4
 
4
5
  /** The four batch actions the command endpoint accepts (a row action is a bulk op with one id). */
5
6
  export type BatchAction = 'delete' | 'publish' | 'unpublish' | 'duplicate'
6
7
 
7
- /** POST /api/{collection}/bulk response. For `duplicate`, `ids` are the CREATED rows. */
8
+ /** The built-in actions the admin renders with its own dedicated presentation (Delete/Duplicate buttons,
9
+ * the Publish/Unpublish pair) — always present on the wire, but never re-rendered generically. */
10
+ const BUILTIN_ACTION_NAMES = new Set(['deleteMany', 'duplicate'])
11
+
12
+ /** The schema-driven actions beyond the built-ins — what a consumer's `definePipeline` adds. */
13
+ export function customActions(actions: SerializedAction[]): SerializedAction[] {
14
+ return actions.filter((a) => !BUILTIN_ACTION_NAMES.has(a.name))
15
+ }
16
+
17
+ /** Custom actions invokable from the bulk-selection bar. */
18
+ export function bulkCustomActions(actions: SerializedAction[]): SerializedAction[] {
19
+ return customActions(actions).filter((a) => a.kind === 'bulk' || a.kind === 'both')
20
+ }
21
+
22
+ /** Custom actions invokable from a single row. */
23
+ export function recordCustomActions(actions: SerializedAction[]): SerializedAction[] {
24
+ return customActions(actions).filter((a) => a.kind === 'record' || a.kind === 'both')
25
+ }
26
+
27
+ /** What a batch pipeline answers with: `deleteMany`/`updateMany` a count plus the ids they touched,
28
+ * `duplicate` the created rows. */
29
+ export type BatchPipelineResult = { count: number, ids: number[] } | { id: number }[]
30
+
31
+ /** The batch outcome the list UI reports. For `duplicate`, `ids` are the CREATED rows. A custom action's
32
+ * `action` is its pipeline name — not one of the four built-ins. */
8
33
  export interface BulkResult {
9
- action: BatchAction
34
+ action: BatchAction | (string & {})
10
35
  count: number
11
36
  ids: number[]
12
37
  }
13
38
 
14
- /** GET /api/references/referrers?ids=1,2,3 response: id -> how many other records reference it.
39
+ /** GET /api/{collection}/referrers?ids=1,2,3 response: id -> how many other records reference it.
15
40
  * `checked` is false when the index lookup itself failed, in which case `counts` is empty because nothing
16
41
  * could be read — NOT because nothing links to the selection. */
17
42
  export interface ReferrerCounts {
@@ -1,5 +1,4 @@
1
- import type { FieldDef } from '../../../core/server/utils/defineCollection'
2
- import type { SerializedField } from '../../../core/server/utils/serialize-collection'
1
+ import type { FieldDef, SerializedField } from '@michaelthielemann/kestrel-core'
3
2
  import { resolveFieldEmpty } from '../../../ui/app/utils/field-empty'
4
3
 
5
4
  export interface ServerIssue {
@@ -208,6 +207,39 @@ function flattenBlocksById(blocks: readonly unknown[] | null | undefined, into:
208
207
  }
209
208
  }
210
209
 
210
+ /** One stale reference a record holds. Wire shape of `DeadRef` (kept structural here so this module stays
211
+ * Vue-free and doesn't import the admin-only `dead-refs.ts` type file). */
212
+ interface DeadRefLike {
213
+ field: string
214
+ blockId?: string
215
+ }
216
+
217
+ /**
218
+ * Carry dead-reference warnings across a `content` change, mirroring `reconcileBlockErrors`: a warning at
219
+ * a block (by stable id) is dropped once THAT field's value changes (the user swapped the broken
220
+ * reference) or the block is removed; it survives a pure reorder. Root-level (non-block) dead refs are
221
+ * untouched here — `setField` drops those directly when their own field is edited.
222
+ */
223
+ export function reconcileDeadRefs<T extends DeadRefLike>(
224
+ refs: readonly T[],
225
+ prev: readonly unknown[] | null | undefined,
226
+ next: readonly unknown[] | null | undefined,
227
+ ): T[] {
228
+ const prevById = new Map<string, BlockLike>()
229
+ const nextById = new Map<string, BlockLike>()
230
+ flattenBlocksById(prev, prevById)
231
+ flattenBlocksById(next, nextById)
232
+ return refs.filter((r) => {
233
+ if (!r.blockId) return true
234
+ const before = prevById.get(r.blockId)
235
+ const after = nextById.get(r.blockId)
236
+ if (!before || !after) return false
237
+ const beforeVal = (before.props ?? {})[r.field]
238
+ const afterVal = (after.props ?? {})[r.field]
239
+ return valuesEqual(beforeVal, afterVal)
240
+ })
241
+ }
242
+
211
243
  /** Structural deep-equality for the value shapes the editor tracks (primitives, arrays, plain objects). */
212
244
  export function valuesEqual(a: unknown, b: unknown): boolean {
213
245
  if (a === b) return true
@@ -31,4 +31,6 @@ export interface EditorExpose {
31
31
  /** Open the record in a new tab: the saved URL, or the unsaved state carried by a preview ticket. */
32
32
  openPreview: () => Promise<void>
33
33
  previewOpening: boolean
34
+ /** The loaded row is the quarantine shape — read-only, hosts must hide Save/Publish. */
35
+ quarantined: boolean
34
36
  }
@@ -1,6 +1,5 @@
1
1
  import type { ComputedRef, InjectionKey, Ref } from 'vue'
2
- import type { FieldDef } from '../../../core/server/utils/defineCollection'
3
- import type { LayoutNode } from '../../../core/server/utils/field-layout'
2
+ import type { FieldDef, LayoutNode } from '@michaelthielemann/kestrel-core'
4
3
  import type { BlockErrorMap } from './edit-form'
5
4
  import type { DeadRef } from './dead-refs'
6
5
 
@@ -1,4 +1,4 @@
1
- import type { SerializedField } from '../../../core/server/utils/serialize-collection'
1
+ import type { SerializedField } from '@michaelthielemann/kestrel-core'
2
2
 
3
3
  /**
4
4
  * The jsKey (wire key) for a field — NOT a DB column name. The server marks single relation/media fields
@@ -1,5 +1,6 @@
1
- import type { SerializedCollection, SerializedField } from '../../../core/server/utils/serialize-collection'
2
- import { fieldFilterKind, META_FILTER_KIND, type FilterKind } from '../../../core/app/utils/filter-ops'
1
+ import { META_FILTER_KIND, fieldFilterKind } from '@michaelthielemann/kestrel-core/client'
2
+ import type { FilterKind } from '@michaelthielemann/kestrel-core/client'
3
+ import type { SerializedCollection, SerializedField } from '@michaelthielemann/kestrel-core'
3
4
  import { jsKey } from './field-keys'
4
5
 
5
6
  export type ListColumnType = 'meta' | 'field' | 'translations' | 'deadRefs'
@@ -1,5 +1,5 @@
1
- import { FILTER_RE, isFilterOp, opAllowed, type FilterOp } from '../../../core/app/utils/filter-ops'
2
- import { clampPerPage } from '../../../core/app/utils/list-limits'
1
+ import { FILTER_RE, clampPerPage, isFilterOp, opAllowed } from '@michaelthielemann/kestrel-core/client'
2
+ import type { FilterOp } from '@michaelthielemann/kestrel-core/client'
3
3
  import type { ListColumn } from './list-columns'
4
4
 
5
5
  // The page-size bounds live in ONE shared module (core/app/utils/list-limits) so the client list, the
@@ -1,6 +1,6 @@
1
- import type { SerializedBlock, SerializedField } from '../../../core/server/utils/serialize-collection'
1
+ import type { SerializedBlock, SerializedField } from '@michaelthielemann/kestrel-core'
2
2
  import type { ResolvedMedia } from '../../../media/app/composables/useMediaResolver'
3
- import { collectRichtextRefs, resolveRichtextLinks } from '../../../fields/app/utils/richtext-links'
3
+ import { collectRichtextRefs, resolveRichtextLinks } from '@michaelthielemann/kestrel-core/client'
4
4
 
5
5
  // Single source of truth for the resolved-media shape lives in the media layer (admin → media).
6
6
  export type { ResolvedMedia }
@@ -1,10 +1,9 @@
1
1
  import { safeRedirect } from './safe-redirect'
2
2
 
3
- // The auth bootstrap endpoints the server always permits (login/session/logout). A 401 from these is
4
- // not a stale session — a wrong-password login 401s — so it must not trigger a re-auth redirect. Matched
5
- // exactly (not by `/api/auth/` prefix) so a guarded endpoint under a consumer collection named `auth`
6
- // (e.g. `/api/auth/5`) still re-authenticates.
7
- const BOOTSTRAP_PATHS = new Set(['/api/auth/login', '/api/auth/session', '/api/auth/logout'])
3
+ // The auth endpoints (login/session/logout). A 401 from these is not a stale session — a wrong-password
4
+ // login 401s — so it must not trigger a re-auth redirect. Matched exactly, so a guarded endpoint under a
5
+ // consumer collection named `auth` (e.g. `/api/auth/readOne/5`) still re-authenticates.
6
+ const BOOTSTRAP_PATHS = new Set(['/api/login', '/api/session', '/api/logout'])
8
7
 
9
8
  /**
10
9
  * Decide whether a failed `$fetch` should bounce the user to the login screen, and to which URL.
@@ -12,7 +11,7 @@ const BOOTSTRAP_PATHS = new Set(['/api/auth/login', '/api/auth/session', '/api/a
12
11
  * Returns the login redirect target for a 401 raised by a guarded `/api/*` call made from inside the
13
12
  * admin SPA — but stays null for:
14
13
  * - non-401 statuses and non-`/api` requests (nothing to re-authenticate);
15
- * - the auth-bootstrap endpoints (`/api/auth/*`): a wrong-password login 401s and must stay put;
14
+ * - the auth endpoints: a wrong-password login 401s and must stay put;
16
15
  * - any location that isn't a safe admin redirect target — which also rules out the login page itself
17
16
  * (no redirect loop) and public/SSG pages (the static site never logs in).
18
17
  */
@@ -0,0 +1,7 @@
1
+ import { registerPipeline } from '@michaelthielemann/kestrel-core'
2
+ import { buildAuthPipelines } from '@michaelthielemann/kestrel-auth'
3
+
4
+ // Registration only — nothing resolves a pipeline here; the registry is read on the first request.
5
+ export default defineNitroPlugin(() => {
6
+ for (const def of buildAuthPipelines()) registerPipeline(def)
7
+ })
@@ -1,22 +1,19 @@
1
1
  import collections from '#kestrel/collections'
2
2
  import blocks from '#kestrel/blocks'
3
- import { registerBlock, type BlockDef } from '../../../fields/server/utils/defineBlock'
4
- import { ensureBuilt } from '../../../fields/server/utils/buildCollection'
3
+ import { registerBlock, type BlockDef } from '@michaelthielemann/kestrel-fields'
4
+ import { ensureBuilt, collectionEnabled, registerCollection } from '@michaelthielemann/kestrel-core'
5
5
  import { resolveServerKestrel, serverRuntimeConfig } from '../../../core/server/utils/server-config'
6
- import { collectionEnabled } from '../../../core/server/schema/bootstrap'
7
- import type { CollectionDef } from '../../../core/server/utils/defineCollection'
8
- import type { BuiltCollection } from '../../../core/server/utils/collection-types'
9
-
6
+ import type { BuiltCollection, CollectionDef } from '@michaelthielemann/kestrel-core'
10
7
  // Auto-discovered collection files default-export either a plain CollectionDef (the common consumer
11
8
  // form, `export default defineCollection(…)`) or an already-built collection (advanced). `ensureBuilt`
12
9
  // normalizes both so the registry only ever holds BuiltCollections.
13
10
  //
14
- // NOTE on ordering: the `01.` prefix sorts only WITHIN this (collections) layer. Nitro runs plugins by
15
- // layer-then-filename, so this plugin does NOT run secondin-repo it runs LAST (after core's
16
- // 00.migrate/02.schema-sync/03.record-refs), and the cross-layer order differs again for an
17
- // `extends: ['@michaelthielemann/kestrel']` consumer. That is safe ONLY because nothing reads the registry at plugin init:
18
- // migrate/schema-sync read the `#kestrel/collections` virtual directly, and the ref/cleanup/publish
19
- // plugins register deferred listeners. Keep that invariant — do not add an init-time allCollections() read.
11
+ // NOTE on ordering: this plugin's real execution position is declared data now
12
+ // (layers/core/modules/plugin-order/plugin-order.ts), not an accident of filename/layer scan order — it
13
+ // runs after every core plugin (00.migrate/02.schema-sync included), identically in both build contexts.
14
+ // It is order-free regardless: nothing reads the registry at plugin init — migrate/schema-sync read the
15
+ // `#kestrel/collections` virtual directly, and the ref/cleanup/publish plugins register deferred listeners.
16
+ // Keep that invariant — do not add an init-time allCollections() read.
20
17
  export default defineNitroPlugin(() => {
21
18
  for (const block of blocks as BlockDef[]) registerBlock(block)
22
19
  // Built-in collections (`pages`, `media`) register by default but can be disabled per consumer via
@@ -1,15 +1,13 @@
1
- import { registerFieldPopulator } from '../../../core/server/utils/populate'
2
- import { buildRelationFieldPopulator, skipMissing } from '../utils/populate-relations'
3
- import { getCollection } from '../../../core/server/utils/registry'
4
- import { getOne } from '../../../core/server/utils/crud'
5
-
1
+ import { getCollection, getOne, registerFieldPopulator, useDb } from '@michaelthielemann/kestrel-core'
2
+ import { isPubliclyReadable, publicReadableResources } from '@michaelthielemann/kestrel-access'
3
+ import { buildRelationFieldPopulator, skipMissing } from '@michaelthielemann/kestrel-collections'
6
4
  // Expand relation fields to the fully-populated related record(s) under `$<name>` at read time (depth ≥ 1).
7
5
  // The related read is published-only (a draft target is skipped, never leaked to a public read), tolerates a
8
6
  // stale/deleted id (getOne 404 → null via `skipMissing`, other errors propagate — fail-loud), and runs at
9
7
  // `depth - 1`, so `populateRow` bails at 0 and cycles terminate. Registered per-type; the shared field-tree
10
8
  // walker (fields layer) dispatches it.
11
- // The reachability predicate is the registry-driven public set, NOT the guard's full decision — the guard
12
- // also folds in `registeredGrants()`, which this omits. So a collection opened to anonymous by a registered
9
+ // The reachability predicate is the declared public set, NOT the guard's full decision — the guard also
10
+ // folds in `registeredGrants()`, which this omits. So a collection opened to anonymous by a registered
13
11
  // grant is served on its own route but stays unexpanded here: fail-closed drift, never a widening.
14
12
  export default defineNitroPlugin(() => {
15
13
  registerFieldPopulator('relation', buildRelationFieldPopulator((collection, id, depth, locale, publicOnly) => {
@@ -1,4 +1,4 @@
1
- import type { FieldDef } from '../../../core/server/utils/defineCollection'
1
+ import type { FieldDef } from '@michaelthielemann/kestrel-core'
2
2
 
3
3
  /**
4
4
  * Field factories for single-file (SFC) block authoring, à la Pruvious. A block's schema is declared as its
@@ -1,9 +1,9 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
  import { basename } from 'node:path'
3
3
  import { parse } from '@babel/parser'
4
- import type { FieldDef } from '../../server/utils/defineCollection'
5
- import * as factoryModule from '../../../fields/app/utils/field-factories'
6
- import { KESTREL_FIELD } from '../../../fields/app/utils/field-factories'
4
+ import type { FieldDef } from '@michaelthielemann/kestrel-core'
5
+ import * as factoryModule from '../../app/utils/field-factories'
6
+ import { KESTREL_FIELD } from '../../app/utils/field-factories'
7
7
 
8
8
  /** The extracted block definition (shape mirrors `BlockDef`, kept structural so this build-time module
9
9
  * needs no `layers/fields` runtime import). */
@@ -1,9 +1,10 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { addComponentsDir, addTemplate, addTypeTemplate, createResolver, defineNuxtModule } from '@nuxt/kit'
4
- import { collectBlockSfcs, collectDefinitions, renderRegistry } from './scan'
4
+ import { collectBlockSfcs, collectDefinitions, collectManifestFiles, renderPackageConcatRegistry, renderPackageMergedRegistry, renderRegistry, resolvePackageEntry } from './scan'
5
5
  import { renderBlockRegistry } from './extract-block'
6
6
  import { offerableLayouts, renderLayoutRegistry } from '../../app/utils/layouts'
7
+ import { PACKAGE_COLLECTIONS, PACKAGE_MANIFESTS, PACKAGE_SCHEMA_TABLES } from './package-registry'
7
8
 
8
9
  export default defineNuxtModule({
9
10
  meta: { name: 'kestrel-auto-discovery' },
@@ -18,6 +19,7 @@ export default defineNuxtModule({
18
19
  // Blocks are authored as single `.vue` files under each layer's `app/blocks/` — the SFC is BOTH the
19
20
  // schema source (extracted at build into `#kestrel/blocks`) AND the display component. Register the dir
20
21
  // so `app/blocks/Hero.vue` resolves as the global `BlocksHero` that the public BlockRenderer renders.
22
+ // No Kestrel package ships a block today, so this stays a pure consumer-dir scan (nothing to bridge).
21
23
  for (const root of roots) {
22
24
  const dir = join(root, 'app/blocks')
23
25
  if (existsSync(dir)) addComponentsDir({ path: dir, prefix: 'Blocks', global: true, pathPrefix: false })
@@ -34,21 +36,77 @@ export default defineNuxtModule({
34
36
 
35
37
  nuxt.hook('nitro:config', (nitro) => {
36
38
  nitro.virtual ||= {}
37
- // Consumer field types register as a side effect on import, and the schema engine builds a table the
38
- // moment a collection/block module loads. So the collections + blocks registries import the field-type
39
- // registry FIRST guaranteeing custom types are registered before any column / validator is built.
39
+ // `@michaelthielemann/kestrel-fields`'s built-in descriptors (text/richtext/number/…) seed `core`'s field-type registry
40
+ // as a side effect of importing the package itself core cannot import fields, so this is the one
41
+ // place that import happens. Resolved to a real file path (`resolvePackageEntry`), not left as the
42
+ // bare specifier: a Nitro virtual module has no real file of its own for a bundler to resolve a bare
43
+ // import FROM, so it falls back to the project root — where, under a real consumer's pnpm install,
44
+ // this package isn't visible (only the engine directly depends on it). Prepended so it always runs, even with zero consumer
45
+ // field types (an empty `collectDefinitions` result would otherwise generate `export default []`,
46
+ // importing nothing). Consumer field types register as a side effect on import too, and the schema
47
+ // engine builds a table the moment a collection/block module loads — so the collections/blocks/
48
+ // schema-tables/module-manifests virtuals ALL import the field-type seed FIRST (every one of them can
49
+ // now reach a package's `buildCollection()` call transitively via `kestrelDiscovery`, per ADR-0029 —
50
+ // an ESM barrel is an eager, whole-module-graph load — so all four need the same guard, not only the
51
+ // two that directly build collections themselves).
52
+ const importFieldTypesSeed = `import { fieldTypes as __kestrelSeed } from ${JSON.stringify(resolvePackageEntry('@michaelthielemann/kestrel-fields'))}\n`
53
+ + `if (!__kestrelSeed || typeof __kestrelSeed !== 'object') throw new Error('[kestrel] built-in field types failed to seed')\n`
54
+ // A real runtime check on the imported binding, not a bare `import "path"` or a discarded `void x`
55
+ // — Nitro's dev build tree-shakes an import whose binding has no provable effect, silently dropping
56
+ // the seed and breaking every collection. A `throw` Rollup can't prove unreachable survives.
57
+ // MUST be a static `import` (not `import()`): a dynamic import runs as an ordinary statement AFTER
58
+ // every static import of the same module has already evaluated (ESM linking order), which would run
59
+ // it too late — after a collection's own static import has already tried to build its table. A static
60
+ // import evaluates before any sibling static import declared after it, which is what orders this
61
+ // correctly. Every virtual below imports `@michaelthielemann/kestrel-fields` directly for this reason (on top of, not
62
+ // instead of, `#kestrel/field-types` for collections/blocks — chaining virtual-imports-virtual was
63
+ // unreliable in Nitro dev, so the real package import stays even where the virtual ALSO gets imported).
64
+ const importFieldTypesVirtual = `${importFieldTypesSeed}import { default as __kestrelFieldTypes } from '#kestrel/field-types'\n`
65
+ + `if (!__kestrelFieldTypes) throw new Error('[kestrel] #kestrel/field-types failed to load')\n`
66
+
40
67
  nitro.virtual['#kestrel/field-types'] = () =>
41
- renderRegistry(collectDefinitions(roots, 'server/field-types'))
68
+ `${importFieldTypesSeed}${renderRegistry(collectDefinitions(roots, 'server/field-types'))}`
69
+
42
70
  nitro.virtual['#kestrel/collections'] = () =>
43
- `import '#kestrel/field-types'\n` + renderRegistry(collectDefinitions(roots, 'server/collections'))
71
+ renderPackageMergedRegistry({
72
+ packages: PACKAGE_COLLECTIONS.map(resolvePackageEntry),
73
+ property: 'collections',
74
+ consumerFiles: collectDefinitions(roots, 'server/collections'),
75
+ nameOfExpr: '(x) => x.name',
76
+ preamble: importFieldTypesVirtual,
77
+ })
78
+
44
79
  // Extract each `app/blocks/*.vue` into a plain BlockDef inlined as JSON (no per-file imports needed).
80
+ // No package ships a block today (see above), so this stays the plain consumer-dir scan, prefixed
81
+ // with the same seed a block's field-typed props may need.
45
82
  nitro.virtual['#kestrel/blocks'] = () =>
46
- `import '#kestrel/field-types'\n` + renderBlockRegistry(collectBlockSfcs(roots))
47
- // Standalone (non-collection) tables: each layer's `server/schema-tables/*.ts` default-exports one
48
- // drizzle table (media folders, public publish_deps/publish_status; consumers can add their own).
49
- // The schema engine consumes this instead of core hardcoding upper-layer tables.
83
+ `${importFieldTypesVirtual}${renderBlockRegistry(collectBlockSfcs(roots))}`
84
+
85
+ // Standalone (non-collection) tables: each package's `kestrelDiscovery.schemaTables`, merged with
86
+ // each layer's `server/schema-tables/*.ts` default export (media folders, public publish_deps/
87
+ // publish_status/…; consumers can add their own). The schema engine consumes this instead of core
88
+ // hardcoding upper-layer tables.
50
89
  nitro.virtual['#kestrel/schema-tables'] = () =>
51
- renderRegistry(collectDefinitions(roots, 'server/schema-tables'))
90
+ renderPackageMergedRegistry({
91
+ packages: PACKAGE_SCHEMA_TABLES.map(resolvePackageEntry),
92
+ property: 'schemaTables',
93
+ consumerFiles: collectDefinitions(roots, 'server/schema-tables'),
94
+ nameOfExpr: '(x) => __kestrelTableName(x)',
95
+ extraImports: `import { getTableName as __kestrelTableName } from 'drizzle-orm'`,
96
+ preamble: importFieldTypesVirtual,
97
+ })
98
+
99
+ // Per-module ownership manifests (ADR-0012): each package's `kestrelDiscovery.manifest` plus each
100
+ // layer's `server/db/manifest.ts` default export, for the `db:migrate-module` task to enumerate
101
+ // without core importing an upper layer (or a package) directly. No dedup — every contribution is a
102
+ // distinct module (see `collectManifestFiles`'s own TSDoc).
103
+ nitro.virtual['#kestrel/module-manifests'] = () =>
104
+ renderPackageConcatRegistry({
105
+ packages: PACKAGE_MANIFESTS.map(resolvePackageEntry),
106
+ property: 'manifest',
107
+ consumerFiles: collectManifestFiles(roots),
108
+ preamble: importFieldTypesVirtual,
109
+ })
52
110
  })
53
111
  },
54
112
  })
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Kestrel's own packages that ship discoverable content, by category — a package declares WHAT it
3
+ * contributes via its own `kestrelDiscovery` named export (see `@michaelthielemann/kestrel-core`'s `KestrelPackageDiscovery`
4
+ * TSDoc); this module still needs an explicit list of WHICH packages to check, since a Nitro virtual's
5
+ * generated source is plain text written before anything runs — there is no filesystem/`node_modules` scan
6
+ * to discover the list itself. Deliberately per-category, not one flat package list: a virtual only ever
7
+ * imports a package here if that package actually contributes to ITS category, so (for example) building
8
+ * the schema-tables virtual never eagerly touches `@michaelthielemann/kestrel-collections`, which has none.
9
+ *
10
+ * A package with content in a new category is added to the matching list below when that lands — an
11
+ * explicit, reviewable registration, not a guess. `test/architecture/kestrel-discovery.test.ts`'s
12
+ * "bidirectional consistency" suite computes, against every real workspace package's actual
13
+ * `kestrelDiscovery` export, that these three lists are exactly right (nothing missing, nothing stale) —
14
+ * so a package that starts or stops contributing a category and forgets to update the matching list here
15
+ * fails a fast unit test, not a runtime 404 discovered later.
16
+ */
17
+ export const PACKAGE_COLLECTIONS = ['@michaelthielemann/kestrel-media', '@michaelthielemann/kestrel-collections', '@michaelthielemann/kestrel-publishing']
18
+ export const PACKAGE_SCHEMA_TABLES = ['@michaelthielemann/kestrel-media', '@michaelthielemann/kestrel-publishing']
19
+ export const PACKAGE_MANIFESTS = ['@michaelthielemann/kestrel-media', '@michaelthielemann/kestrel-publishing']