@michaelthielemann/kestrel 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (353) hide show
  1. package/README.md +35 -110
  2. package/kestrel.config.ts +1 -1
  3. package/layers/access/server/middleware/00.ip-allowlist.ts +3 -18
  4. package/layers/access/server/middleware/access-guard.ts +25 -23
  5. package/layers/access/server/types.d.ts +1 -2
  6. package/layers/admin/app/components/AdminAccount.vue +1 -1
  7. package/layers/admin/app/components/BlockFields.vue +1 -2
  8. package/layers/admin/app/components/BlockPreview.vue +17 -5
  9. package/layers/admin/app/components/BlocksBody.vue +1 -1
  10. package/layers/admin/app/components/CollectionEditor.vue +35 -16
  11. package/layers/admin/app/components/CollectionList.vue +34 -3
  12. package/layers/admin/app/components/CollectionListBulkBar.vue +17 -3
  13. package/layers/admin/app/components/CollectionListFilterPanel.vue +1 -1
  14. package/layers/admin/app/components/CollectionListPager.vue +1 -1
  15. package/layers/admin/app/components/CollectionListTable.vue +41 -2
  16. package/layers/admin/app/components/EditorStatus.vue +8 -7
  17. package/layers/admin/app/components/LocaleBar.vue +3 -1
  18. package/layers/admin/app/components/PageFields.vue +2 -4
  19. package/layers/admin/app/components/SeoFields.vue +2 -4
  20. package/layers/admin/app/components/SingletonEditor.vue +3 -2
  21. package/layers/admin/app/composables/useAuth.ts +3 -3
  22. package/layers/admin/app/composables/useBlockTree.ts +1 -1
  23. package/layers/admin/app/composables/useBlocks.ts +1 -1
  24. package/layers/admin/app/composables/useCollectionOps.ts +45 -8
  25. package/layers/admin/app/composables/useCollections.ts +1 -2
  26. package/layers/admin/app/composables/useEditForm.ts +35 -17
  27. package/layers/admin/app/composables/useLinkResolver.ts +1 -1
  28. package/layers/admin/app/composables/useListBatchActions.ts +20 -3
  29. package/layers/admin/app/composables/useListColumns.ts +1 -1
  30. package/layers/admin/app/composables/useListFilterDraft.ts +3 -2
  31. package/layers/admin/app/composables/useListRows.ts +7 -2
  32. package/layers/admin/app/composables/useListUrlState.ts +1 -1
  33. package/layers/admin/app/composables/usePublishStatus.ts +2 -2
  34. package/layers/admin/app/layouts/admin.vue +0 -1
  35. package/layers/admin/app/pages/admin/[collection]/[id].vue +5 -4
  36. package/layers/admin/app/pages/admin/index.vue +1 -1
  37. package/layers/admin/app/pages/admin/references.vue +2 -2
  38. package/layers/admin/app/utils/block-tree.ts +1 -1
  39. package/layers/admin/app/utils/collection-ops.ts +28 -3
  40. package/layers/admin/app/utils/edit-form.ts +34 -2
  41. package/layers/admin/app/utils/editor-expose.ts +2 -0
  42. package/layers/admin/app/utils/editor-form-context.ts +1 -2
  43. package/layers/admin/app/utils/field-keys.ts +1 -1
  44. package/layers/admin/app/utils/list-columns.ts +3 -2
  45. package/layers/admin/app/utils/list-query.ts +2 -2
  46. package/layers/admin/app/utils/populate-blocks.ts +2 -2
  47. package/layers/admin/app/utils/reauth.ts +5 -6
  48. package/layers/auth/server/plugins/01.register-auth-pipelines.ts +7 -0
  49. package/layers/collections/server/plugins/01.register.ts +9 -12
  50. package/layers/collections/server/plugins/02.register-relation-populate.ts +5 -7
  51. package/layers/{fields → core}/app/utils/field-factories.ts +1 -1
  52. package/layers/core/modules/auto-discovery/extract-block.ts +3 -3
  53. package/layers/core/modules/auto-discovery/index.ts +68 -11
  54. package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
  55. package/layers/core/modules/auto-discovery/scan.ts +77 -0
  56. package/layers/core/modules/auto-discovery/virtual.d.ts +5 -0
  57. package/layers/core/modules/component-namespace/index.ts +24 -0
  58. package/layers/core/modules/kestrel/index.ts +6 -1
  59. package/layers/core/modules/plugin-order/index.ts +69 -0
  60. package/layers/core/modules/plugin-order/plugin-order.ts +273 -0
  61. package/layers/core/nuxt.config.ts +1 -0
  62. package/layers/core/server/api/[...path].ts +98 -0
  63. package/layers/core/server/plugins/00.config.ts +12 -0
  64. package/layers/core/server/plugins/00.migrate.ts +1 -0
  65. package/layers/core/server/plugins/01.register-introspection-pipeline.ts +5 -0
  66. package/layers/core/server/plugins/01.register-openapi-pipeline.ts +5 -0
  67. package/layers/core/server/plugins/01.register-tooling-pipelines.ts +5 -0
  68. package/layers/core/server/plugins/02.schema-sync.ts +2 -7
  69. package/layers/core/server/plugins/04.outbox-worker.ts +31 -0
  70. package/layers/core/server/plugins/05.reindex-refs.ts +5 -0
  71. package/layers/core/server/routes/__kestrel/dashboard.get.ts +29 -0
  72. package/layers/core/server/tasks/db/migrate-module.ts +83 -0
  73. package/layers/core/server/tasks/db/migrate-revisions.ts +20 -0
  74. package/layers/core/server/tasks/db/migrate.ts +2 -6
  75. package/layers/core/server/utils/server-config.ts +66 -3
  76. package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +7 -0
  77. package/layers/fields/server/plugins/01.register-field-populate.ts +2 -2
  78. package/layers/media/app/components/KestrelImg.vue +3 -3
  79. package/layers/media/app/components/MediaLibrary.vue +2 -2
  80. package/layers/media/app/components/field/Media.vue +1 -1
  81. package/layers/media/app/composables/useMediaResolver.ts +1 -1
  82. package/layers/media/app/composables/useMediaUpload.ts +1 -1
  83. package/layers/media/app/composables/useMediaVariant.ts +2 -2
  84. package/layers/media/app/utils/ai-disclosure.ts +1 -1
  85. package/layers/media/app/utils/library.ts +1 -1
  86. package/layers/media/app/utils/ops.ts +1 -3
  87. package/layers/media/app/utils/picture-model.ts +2 -2
  88. package/layers/media/app/utils/variant-name.ts +1 -1
  89. package/layers/media/server/middleware/ondemand-variants.ts +6 -5
  90. package/layers/media/server/plugins/01.register-media-pipelines.ts +7 -0
  91. package/layers/media/server/plugins/02.register-media.ts +10 -7
  92. package/layers/media/server/plugins/04.variant-capture.ts +12 -8
  93. package/layers/media/server/plugins/05.media-cleanup.ts +5 -0
  94. package/layers/media/server/tasks/media/backfill.ts +3 -4
  95. package/layers/public/app/components/KestrelLink.vue +1 -1
  96. package/layers/public/app/pages/[...slug].vue +1 -1
  97. package/layers/public/app/pages/__kestrel/preview.vue +2 -2
  98. package/layers/public/app/utils/json-ld.ts +1 -1
  99. package/layers/public/app/utils/link-href.ts +1 -1
  100. package/layers/public/app/utils/page-head.ts +1 -1
  101. package/layers/public/modules/deploy-output/index.ts +4 -6
  102. package/layers/public/modules/prerender-routes/index.ts +4 -4
  103. package/layers/public/modules/prerender-routes/routes.ts +1 -1
  104. package/layers/public/modules/prune-media/index.ts +2 -2
  105. package/layers/public/server/delivery-live/serve.ts +111 -0
  106. package/layers/public/server/middleware/delivery-live-catchall.ts +7 -0
  107. package/layers/public/server/pipelines/index.ts +23 -0
  108. package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +14 -0
  109. package/layers/public/server/plugins/01.register-public-pipelines.ts +7 -0
  110. package/layers/public/server/plugins/02.register-links.ts +2 -3
  111. package/layers/public/server/plugins/03.redirects.ts +41 -23
  112. package/layers/public/server/plugins/05.plan-publish.ts +5 -0
  113. package/layers/public/server/plugins/zz.publish.ts +79 -24
  114. package/layers/public/server/routes/llms-full.txt.get.ts +5 -5
  115. package/layers/public/server/routes/llms.txt.get.ts +4 -2
  116. package/layers/public/server/routes/redirects.json.get.ts +2 -2
  117. package/layers/public/server/routes/robots.txt.get.ts +2 -0
  118. package/layers/public/server/routes/sitemap.xml.get.ts +16 -6
  119. package/layers/public/server/tasks/publish/run.ts +9 -4
  120. package/layers/public/server/utils/publish/render-live.ts +26 -0
  121. package/layers/ui/app/components/field/Choice.vue +1 -1
  122. package/layers/ui/app/components/field/Datetime.vue +1 -1
  123. package/layers/ui/app/components/field/Json.vue +1 -2
  124. package/layers/ui/app/components/field/Layout.vue +2 -4
  125. package/layers/ui/app/components/field/Link.vue +1 -1
  126. package/layers/ui/app/components/field/LinkInternalPicker.vue +3 -3
  127. package/layers/ui/app/components/field/Number.vue +2 -2
  128. package/layers/ui/app/components/field/Relation.vue +1 -1
  129. package/layers/ui/app/components/field/Repeater.vue +6 -8
  130. package/layers/ui/app/components/field/Slug.vue +2 -3
  131. package/layers/ui/app/components/field/Text.vue +1 -1
  132. package/layers/ui/app/components/ui/Button.vue +2 -2
  133. package/layers/ui/app/components/ui/ButtonGroup.vue +2 -2
  134. package/layers/ui/app/components/ui/Checkbox.vue +2 -2
  135. package/layers/ui/app/components/ui/Combobox.vue +5 -5
  136. package/layers/ui/app/components/ui/DatePicker.vue +2 -2
  137. package/layers/ui/app/components/ui/DateRangePicker.vue +2 -2
  138. package/layers/ui/app/components/ui/NumberInput.vue +4 -4
  139. package/layers/ui/app/components/ui/Richtext.vue +1 -1
  140. package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
  141. package/layers/ui/app/components/ui/Select.vue +2 -2
  142. package/layers/ui/app/components/ui/TextInput.vue +4 -4
  143. package/layers/ui/app/components/ui/Textarea.vue +2 -2
  144. package/layers/ui/app/components/ui/TimeInput.vue +2 -2
  145. package/layers/ui/app/components/ui/Toasts.vue +3 -3
  146. package/layers/ui/app/composables/useLinkField.ts +1 -1
  147. package/layers/ui/app/composables/useRepeater.ts +1 -1
  148. package/layers/ui/app/i18n/de.ts +7 -7
  149. package/layers/ui/app/i18n/en.ts +7 -39
  150. package/layers/ui/app/utils/field-component.ts +1 -1
  151. package/layers/ui/app/utils/field-registry.ts +1 -1
  152. package/layers/ui/app/utils/icons.ts +2 -6
  153. package/layers/ui/app/utils/localized.ts +1 -1
  154. package/nuxt.config.ts +1 -1
  155. package/package.json +53 -19
  156. package/scripts/consumer-template-ci.mjs +157 -0
  157. package/scripts/coverage-all.mjs +163 -0
  158. package/scripts/dashboard.mjs +89 -0
  159. package/scripts/hash-password.mjs +1 -1
  160. package/scripts/lib/coverage-model.mjs +51 -0
  161. package/scripts/port-graph.mjs +350 -0
  162. package/scripts/typecheck.mjs +11 -0
  163. package/templates/starter/app/blocks/Prose.vue +1 -1
  164. package/layers/access/server/utils/csrf.ts +0 -39
  165. package/layers/access/server/utils/grant-registry.ts +0 -36
  166. package/layers/access/server/utils/guard.ts +0 -44
  167. package/layers/access/server/utils/ip-allowlist.ts +0 -79
  168. package/layers/access/server/utils/policy.ts +0 -98
  169. package/layers/access/server/utils/public-resources.ts +0 -11
  170. package/layers/access/server/utils/render-context.ts +0 -41
  171. package/layers/access/server/utils/require-admin.ts +0 -15
  172. package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
  173. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
  174. package/layers/auth/server/api/auth/login.post.ts +0 -38
  175. package/layers/auth/server/api/auth/logout.post.ts +0 -9
  176. package/layers/auth/server/api/auth/session.get.ts +0 -1
  177. package/layers/auth/server/utils/admin-credential.ts +0 -42
  178. package/layers/auth/server/utils/client-ip.ts +0 -88
  179. package/layers/auth/server/utils/login-throttle.ts +0 -126
  180. package/layers/auth/server/utils/password.ts +0 -52
  181. package/layers/auth/server/utils/session-cookie.ts +0 -44
  182. package/layers/auth/server/utils/session-epoch.ts +0 -96
  183. package/layers/auth/server/utils/session.ts +0 -88
  184. package/layers/collections/server/collections/pages.ts +0 -19
  185. package/layers/collections/server/utils/populate-relations.ts +0 -79
  186. package/layers/core/app/utils/bulk-actions.ts +0 -9
  187. package/layers/core/app/utils/filter-ops.ts +0 -98
  188. package/layers/core/app/utils/list-limits.ts +0 -27
  189. package/layers/core/app/utils/locale-path.ts +0 -12
  190. package/layers/core/app/utils/slugify.ts +0 -16
  191. package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
  192. package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
  193. package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
  194. package/layers/core/server/api/[collection]/[id].get.ts +0 -11
  195. package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
  196. package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
  197. package/layers/core/server/api/[collection]/index.get.ts +0 -24
  198. package/layers/core/server/api/[collection]/index.post.ts +0 -8
  199. package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
  200. package/layers/core/server/api/[collection]/index.put.ts +0 -24
  201. package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
  202. package/layers/core/server/api/[collection]/options.get.ts +0 -18
  203. package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
  204. package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
  205. package/layers/core/server/api/[collection]/translations.get.ts +0 -25
  206. package/layers/core/server/api/collections/[name].get.ts +0 -4
  207. package/layers/core/server/api/collections/index.get.ts +0 -3
  208. package/layers/core/server/api/references/broken.get.ts +0 -9
  209. package/layers/core/server/api/references/referrers.get.ts +0 -24
  210. package/layers/core/server/database/record-refs.ts +0 -20
  211. package/layers/core/server/plugins/03.record-refs.ts +0 -12
  212. package/layers/core/server/schema/bootstrap.ts +0 -41
  213. package/layers/core/server/schema/desired.ts +0 -99
  214. package/layers/core/server/schema/dev-sync.ts +0 -23
  215. package/layers/core/server/schema/dialect.ts +0 -61
  216. package/layers/core/server/schema/diff.ts +0 -110
  217. package/layers/core/server/schema/introspect.ts +0 -68
  218. package/layers/core/server/schema/model.ts +0 -61
  219. package/layers/core/server/schema/render-sqlite.ts +0 -89
  220. package/layers/core/server/schema/sync.ts +0 -201
  221. package/layers/core/server/utils/blocks.ts +0 -6
  222. package/layers/core/server/utils/collection-types.ts +0 -28
  223. package/layers/core/server/utils/crud.ts +0 -578
  224. package/layers/core/server/utils/db.ts +0 -26
  225. package/layers/core/server/utils/defineCollection.ts +0 -213
  226. package/layers/core/server/utils/duplicate.ts +0 -162
  227. package/layers/core/server/utils/field-layout.ts +0 -125
  228. package/layers/core/server/utils/filter-predicate.ts +0 -101
  229. package/layers/core/server/utils/http.ts +0 -50
  230. package/layers/core/server/utils/if-unmodified.ts +0 -13
  231. package/layers/core/server/utils/kestrel-config.ts +0 -451
  232. package/layers/core/server/utils/key-lock.ts +0 -33
  233. package/layers/core/server/utils/locale.ts +0 -53
  234. package/layers/core/server/utils/page-route.ts +0 -58
  235. package/layers/core/server/utils/page-slug.ts +0 -127
  236. package/layers/core/server/utils/picker.ts +0 -82
  237. package/layers/core/server/utils/populate.ts +0 -50
  238. package/layers/core/server/utils/prerender-memo.ts +0 -23
  239. package/layers/core/server/utils/read-capture.ts +0 -37
  240. package/layers/core/server/utils/record-ref-index.ts +0 -200
  241. package/layers/core/server/utils/registry.ts +0 -24
  242. package/layers/core/server/utils/resolve-scope.ts +0 -79
  243. package/layers/core/server/utils/seo.ts +0 -42
  244. package/layers/core/server/utils/serialize-collection.ts +0 -147
  245. package/layers/core/server/utils/sql.ts +0 -2
  246. package/layers/core/server/utils/storage.local.ts +0 -117
  247. package/layers/core/server/utils/storage.s3.ts +0 -197
  248. package/layers/core/server/utils/storage.ts +0 -90
  249. package/layers/core/server/utils/write-effects.ts +0 -40
  250. package/layers/core/server/utils/write-events.ts +0 -31
  251. package/layers/fields/app/utils/condition.ts +0 -71
  252. package/layers/fields/app/utils/define-block.ts +0 -13
  253. package/layers/fields/app/utils/field-constraints.ts +0 -17
  254. package/layers/fields/app/utils/field-meta.ts +0 -33
  255. package/layers/fields/app/utils/field-validate.ts +0 -75
  256. package/layers/fields/app/utils/field-value.ts +0 -7
  257. package/layers/fields/app/utils/richtext-links.ts +0 -48
  258. package/layers/fields/server/api/blocks/index.get.ts +0 -9
  259. package/layers/fields/server/field-registry/index.ts +0 -249
  260. package/layers/fields/server/field-registry/naming.ts +0 -10
  261. package/layers/fields/server/field-registry/sanitize.ts +0 -43
  262. package/layers/fields/server/field-registry/types.ts +0 -19
  263. package/layers/fields/server/utils/block-ids.ts +0 -43
  264. package/layers/fields/server/utils/block-populate.ts +0 -41
  265. package/layers/fields/server/utils/buildCollection.ts +0 -90
  266. package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
  267. package/layers/fields/server/utils/buildTable.ts +0 -113
  268. package/layers/fields/server/utils/conditional-required.ts +0 -25
  269. package/layers/fields/server/utils/defineBlock.ts +0 -86
  270. package/layers/fields/server/utils/defineFieldType.ts +0 -33
  271. package/layers/fields/server/utils/extract-refs.ts +0 -152
  272. package/layers/fields/server/utils/field-populate.ts +0 -68
  273. package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
  274. package/layers/media/server/api/media/[id].delete.ts +0 -18
  275. package/layers/media/server/api/media/[id].get.ts +0 -10
  276. package/layers/media/server/api/media/[id].patch.test.ts +0 -66
  277. package/layers/media/server/api/media/[id].patch.ts +0 -65
  278. package/layers/media/server/api/media/backfill.post.ts +0 -18
  279. package/layers/media/server/api/media/copy.post.ts +0 -7
  280. package/layers/media/server/api/media/delete.post.ts +0 -13
  281. package/layers/media/server/api/media/folders.post.ts +0 -18
  282. package/layers/media/server/api/media/index.get.ts +0 -15
  283. package/layers/media/server/api/media/index.post.ts +0 -173
  284. package/layers/media/server/api/media/library.get.ts +0 -17
  285. package/layers/media/server/api/media/move.post.ts +0 -7
  286. package/layers/media/server/api/media/rename.post.ts +0 -7
  287. package/layers/media/server/api/media/resolve.get.ts +0 -13
  288. package/layers/media/server/collections/media-settings.ts +0 -25
  289. package/layers/media/server/collections/media.ts +0 -43
  290. package/layers/media/server/database/folders.ts +0 -10
  291. package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
  292. package/layers/media/server/schema-tables/folders.ts +0 -4
  293. package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
  294. package/layers/media/server/utils/ai-signal-detect.ts +0 -155
  295. package/layers/media/server/utils/backfill.ts +0 -144
  296. package/layers/media/server/utils/derive.ts +0 -59
  297. package/layers/media/server/utils/folder-paths.ts +0 -42
  298. package/layers/media/server/utils/folders.ts +0 -12
  299. package/layers/media/server/utils/library.ts +0 -91
  300. package/layers/media/server/utils/media-cleanup.ts +0 -27
  301. package/layers/media/server/utils/media-enabled.ts +0 -23
  302. package/layers/media/server/utils/media-ops.ts +0 -118
  303. package/layers/media/server/utils/media-write.ts +0 -13
  304. package/layers/media/server/utils/naming.ts +0 -52
  305. package/layers/media/server/utils/ondemand.ts +0 -69
  306. package/layers/media/server/utils/persist-upload.ts +0 -62
  307. package/layers/media/server/utils/populate.ts +0 -47
  308. package/layers/media/server/utils/record.ts +0 -71
  309. package/layers/media/server/utils/relocate-ops.ts +0 -341
  310. package/layers/media/server/utils/relocate-plan.ts +0 -63
  311. package/layers/media/server/utils/resolve.ts +0 -98
  312. package/layers/media/server/utils/sanitize-svg.ts +0 -12
  313. package/layers/media/server/utils/sniff.ts +0 -68
  314. package/layers/media/server/utils/storage-relocate.ts +0 -139
  315. package/layers/media/server/utils/translations.ts +0 -19
  316. package/layers/media/server/utils/usages.ts +0 -66
  317. package/layers/media/server/utils/variant-capture.ts +0 -70
  318. package/layers/media/server/utils/variants.ts +0 -123
  319. package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
  320. package/layers/public/server/api/links/resolve.get.ts +0 -19
  321. package/layers/public/server/api/preview.get.ts +0 -28
  322. package/layers/public/server/api/preview.post.ts +0 -93
  323. package/layers/public/server/api/publish-status.get.ts +0 -58
  324. package/layers/public/server/api/publish.post.ts +0 -84
  325. package/layers/public/server/api/route.get.ts +0 -54
  326. package/layers/public/server/collections/redirects.ts +0 -75
  327. package/layers/public/server/collections/site.ts +0 -44
  328. package/layers/public/server/database/publish-deps.ts +0 -12
  329. package/layers/public/server/database/publish-status.ts +0 -19
  330. package/layers/public/server/schema-tables/publish-deps.ts +0 -4
  331. package/layers/public/server/schema-tables/publish-status.ts +0 -4
  332. package/layers/public/server/utils/link-resolve.ts +0 -38
  333. package/layers/public/server/utils/llms-full.ts +0 -125
  334. package/layers/public/server/utils/llms.ts +0 -52
  335. package/layers/public/server/utils/page-resolve.ts +0 -208
  336. package/layers/public/server/utils/populate-links.ts +0 -66
  337. package/layers/public/server/utils/preview-token.ts +0 -109
  338. package/layers/public/server/utils/publish/coalesce.ts +0 -22
  339. package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
  340. package/layers/public/server/utils/publish/deps.ts +0 -87
  341. package/layers/public/server/utils/publish/invalidation.ts +0 -225
  342. package/layers/public/server/utils/publish/pending.ts +0 -74
  343. package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
  344. package/layers/public/server/utils/publish/publish-status.ts +0 -72
  345. package/layers/public/server/utils/publish/publisher.ts +0 -362
  346. package/layers/public/server/utils/publish/queue.ts +0 -67
  347. package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
  348. package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
  349. package/layers/public/server/utils/publish/route-for-record.ts +0 -16
  350. package/layers/public/server/utils/publish/route-keys.ts +0 -13
  351. package/layers/public/server/utils/richtext-markdown.ts +0 -260
  352. package/layers/public/server/utils/site-url.ts +0 -40
  353. package/layers/public/server/utils/sitemap.ts +0 -99
@@ -3,10 +3,14 @@
3
3
  // than overlaying it. Publish/Unpublish are schema-driven (only when the collection has a status).
4
4
  // NOT a live region: the selection count is announced through the list's permanent .list__sr-status
5
5
  // region; a v-if-toggled role="status" here would double up and stay silent on first show.
6
- defineProps<{ count: number; hasStatus: boolean; busy: boolean }>()
7
- const emit = defineEmits<{ setStatus: [status: 'published' | 'draft']; delete: []; clear: [] }>()
6
+ import { resolveLocalized } from '../../../ui/app/utils/localized'
7
+ import type { SerializedAction } from '@michaelthielemann/kestrel-core'
8
8
 
9
- const { t } = useT()
9
+ const props = defineProps<{ count: number; hasStatus: boolean; busy: boolean; actions?: SerializedAction[] }>()
10
+ const emit = defineEmits<{ setStatus: [status: 'published' | 'draft']; delete: []; runAction: [action: SerializedAction]; clear: [] }>()
11
+
12
+ const { t, lang } = useT()
13
+ const actionLabel = (action: SerializedAction) => resolveLocalized(action.label, lang.value) ?? action.name
10
14
  </script>
11
15
 
12
16
  <template>
@@ -17,6 +21,16 @@ const { t } = useT()
17
21
  <KestrelUiButton type="button" size="sm" variant="secondary" :disabled="busy" @click="emit('setStatus', 'published')">{{ t('list.bulkPublish') }}</KestrelUiButton>
18
22
  <KestrelUiButton type="button" size="sm" variant="secondary" :disabled="busy" @click="emit('setStatus', 'draft')">{{ t('list.bulkUnpublish') }}</KestrelUiButton>
19
23
  </template>
24
+ <KestrelUiButton
25
+ v-for="action in props.actions"
26
+ :key="action.name"
27
+ type="button"
28
+ size="sm"
29
+ variant="secondary"
30
+ :disabled="busy"
31
+ :data-action="action.name"
32
+ @click="emit('runAction', action)"
33
+ >{{ actionLabel(action) }}</KestrelUiButton>
20
34
  <KestrelUiButton type="button" size="sm" variant="danger" :disabled="busy" @click="emit('delete')">{{ t('list.bulkDelete') }}</KestrelUiButton>
21
35
  <KestrelUiButton type="button" size="sm" variant="ghost" @click="emit('clear')">{{ t('list.clearSelection') }}</KestrelUiButton>
22
36
  </div>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { FilterOp } from '../../../core/app/utils/filter-ops'
2
+ import type { FilterOp } from '@michaelthielemann/kestrel-core'
3
3
  import type { ListFilterDraft } from '../composables/useListFilterDraft'
4
4
  import { columnLabel } from '../utils/list-cell'
5
5
  import type { ListColumn } from '../utils/list-columns'
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { PER_PAGE_OPTIONS } from '../../../core/app/utils/list-limits'
2
+ import { PER_PAGE_OPTIONS } from '@michaelthielemann/kestrel-core/client'
3
3
 
4
4
  const props = defineProps<{ page: number; totalPages: number; total: number; perPage: number }>()
5
5
  const emit = defineEmits<{ 'update:page': [page: number]; 'update:perPage': [perPage: number] }>()
@@ -2,6 +2,8 @@
2
2
  import { sortDirection } from '../utils/list-query'
3
3
  import { cellDisplay, columnLabel, rowLabel as labelForRow } from '../utils/list-cell'
4
4
  import type { ListColumn } from '../utils/list-columns'
5
+ import { resolveLocalized } from '../../../ui/app/utils/localized'
6
+ import type { SerializedAction } from '@michaelthielemann/kestrel-core'
5
7
 
6
8
  const props = defineProps<{
7
9
  rows: Record<string, unknown>[]
@@ -13,6 +15,8 @@ const props = defineProps<{
13
15
  selected: Set<number>
14
16
  allSelected: boolean
15
17
  headerIndeterminate: boolean
18
+ /** Schema-driven row actions beyond the built-in edit/duplicate/delete. */
19
+ actions?: SerializedAction[]
16
20
  }>()
17
21
  const emit = defineEmits<{
18
22
  sort: [key: string]
@@ -20,13 +24,15 @@ const emit = defineEmits<{
20
24
  toggleAll: [on: boolean]
21
25
  duplicate: [id: number]
22
26
  delete: [ids: number[]]
27
+ runAction: [action: SerializedAction, id: number]
23
28
  }>()
24
29
 
25
- const { t } = useT()
30
+ const { t, lang } = useT()
26
31
  const { locales } = useContentLocales()
27
32
 
28
33
  const colLabel = (col: ListColumn) => columnLabel(col, t)
29
34
  const rowLabel = (row: Record<string, unknown>) => labelForRow(props.columns, row)
35
+ const actionLabel = (action: SerializedAction) => resolveLocalized(action.label, lang.value) ?? action.name
30
36
 
31
37
  function arrow(field: string) {
32
38
  const dir = sortDirection(props.sort, field)
@@ -99,12 +105,32 @@ function rowTranslations(row: Record<string, unknown>) {
99
105
  <button type="button" class="list__action-btn" :disabled="busy" :aria-label="t('list.rowDuplicate', { name: rowLabel(row) })" @click="emit('duplicate', Number(row.id))">
100
106
  <KestrelUiIcon name="copy" :size="15" />
101
107
  </button>
108
+ <button
109
+ v-for="action in actions"
110
+ :key="action.name"
111
+ type="button"
112
+ class="list__action-btn"
113
+ :disabled="busy"
114
+ :data-action="action.name"
115
+ :aria-label="`${actionLabel(action)}: ${rowLabel(row)}`"
116
+ @click="emit('runAction', action, Number(row.id))"
117
+ >
118
+ <KestrelUiIcon :name="action.icon ?? 'zap'" :size="15" />
119
+ </button>
102
120
  <button type="button" class="list__action-btn list__action-btn--danger" :disabled="busy" :aria-label="t('list.rowDelete', { name: rowLabel(row) })" @click="emit('delete', [Number(row.id)])">
103
121
  <KestrelUiIcon name="trash" :size="15" />
104
122
  </button>
105
123
  </div>
106
124
  </td>
107
- <td v-for="c in columns" :key="c.key" :class="{ 'list__narrow-cell': c.type === 'translations' || c.type === 'deadRefs' }">
125
+ <!-- A quarantined row carries none of its normal fields a single spanning cell replaces
126
+ the per-column cells rather than rendering blanks for data that doesn't exist. -->
127
+ <td v-if="row.$quarantined === true" :colspan="columns.length">
128
+ <span class="list__quarantine-badge">
129
+ <KestrelUiIcon name="triangle-alert" :size="14" role="img" :aria-label="t('list.quarantined')" />
130
+ {{ t('list.quarantined') }}
131
+ </span>
132
+ </td>
133
+ <td v-else v-for="c in columns" :key="c.key" :class="{ 'list__narrow-cell': c.type === 'translations' || c.type === 'deadRefs' }">
108
134
  <span v-if="c.type === 'deadRefs'" class="list__deadrefs">
109
135
  <KestrelUiIcon
110
136
  v-if="row.$hasDeadRefs"
@@ -196,6 +222,19 @@ function rowTranslations(row: Record<string, unknown>) {
196
222
  white-space: nowrap;
197
223
  }
198
224
 
225
+ // Quarantine badge — icon + text (not color alone, WCAG 1.4.1), replacing a row's normal data cells.
226
+ &__quarantine-badge {
227
+ display: inline-flex;
228
+ align-items: center;
229
+ gap: var(--space-1);
230
+ padding: 2px var(--space-2);
231
+ border: 1px solid var(--color-danger);
232
+ border-radius: var(--radius-sm);
233
+ color: var(--color-danger);
234
+ font-size: var(--text-xs);
235
+ font-weight: var(--weight-medium);
236
+ }
237
+
199
238
  // Dead-reference warning: an amber triangle, shown only on rows holding a stale reference.
200
239
  &__deadrefs {
201
240
  display: inline-flex;
@@ -5,7 +5,7 @@
5
5
  // blue — saved but a Draft, so it is intentionally NOT on the live/static site
6
6
  // green — saved and (for status collections) published, or just saved (no status field)
7
7
  // RIGHT — the GENERATED state of this record's static page (pageLike collections only), read from
8
- // `publish_status` via `/api/publish-status`:
8
+ // `publish_status` via `/api/publishStatus`:
9
9
  // green — the route's last publish succeeded (the file is live; the tooltip names local vs S3)
10
10
  // amber — published but no success row yet — a republish is in flight ("Generating…")
11
11
  // neutral — this environment never produces the file (dev / static output off) → "Not built"
@@ -24,7 +24,7 @@ const props = defineProps<{
24
24
  savedStatus?: string
25
25
  /** Only pageLike collections produce a static page → only they get the right (live) lamp. */
26
26
  pageLike?: boolean
27
- /** The record's last publish outcome (from `/api/publish-status`); null/absent while unknown. */
27
+ /** The record's last publish outcome (from `/api/publishStatus`); null/absent while unknown. */
28
28
  live?: PublishStatusData | null
29
29
  }>()
30
30
  const { t, lang } = useT()
@@ -62,6 +62,11 @@ const live = computed<{ tone: 'green' | 'red' | 'amber' | 'blue' | 'neutral'; wo
62
62
  const s = props.live?.status ?? null
63
63
  if (s === 'error')
64
64
  return { tone: 'red', word: t('editorStatus.word.error'), detail: t('editorStatus.live.error'), when: updatedAtLabel.value, error: props.live?.error ?? '' }
65
+ // An environment that never generates (dev / static output off) cannot resolve any pending state, so an
66
+ // alarming "Outdated" would point at a problem no action here can fix — calm neutral, even when an old
67
+ // success row (e.g. a prod run against the same DB) exists.
68
+ if (props.live?.generates === false)
69
+ return { tone: 'neutral', word: t('editorStatus.word.notBuilt'), detail: t('editorStatus.live.notBuilt') }
65
70
  if (s === 'success') {
66
71
  // Saved after the last publish: the file IS live, but it is an older version of this record. Saving no
67
72
  // longer republishes (ADR-0008), so this is the normal state of a page being worked on — amber, not red.
@@ -72,11 +77,7 @@ const live = computed<{ tone: 'green' | 'red' | 'amber' | 'blue' | 'neutral'; wo
72
77
  const onS3 = (props.live?.target ?? props.live?.driver) === 's3'
73
78
  return { tone: 'green', word: t('editorStatus.word.live'), detail: t(onS3 ? 'editorStatus.live.liveS3' : 'editorStatus.live.liveLocal'), when: updatedAtLabel.value }
74
79
  }
75
- // No success row yet. Only an explicit generates:false means "this environment produces nothing" (dev /
76
- // static output off) → a calm neutral "Not built". Unknown (undefined) → assume a publish is in flight.
77
- if (props.live?.generates === false)
78
- return { tone: 'neutral', word: t('editorStatus.word.notBuilt'), detail: t('editorStatus.live.notBuilt') }
79
- // Never published, and a save no longer enqueues anything (ADR-0008) — so nothing is in flight and
80
+ // Never published, and a save does not enqueue anything (ADR-0008) so nothing is in flight and
80
81
  // nothing will be until someone presses Publish. Reporting progress here would point the user at a
81
82
  // spinner instead of at the one action that changes it. With `publishOnSave` a save DOES republish, so
82
83
  // there the in-flight reading is the right one.
@@ -41,7 +41,9 @@ const { t } = useT()
41
41
  >{{ up(loc) }}<KestrelUiIcon name="pencil" :size="14" /></NuxtLink>
42
42
 
43
43
  <span v-else-if="existingId(loc) !== null" class="locale-bar__item">
44
- <NuxtLink class="locale-bar__btn" :to="`/admin/${collection}/${existingId(loc)}?locale=${loc}`" :aria-label="t('localeBar.editLocale', { loc: up(loc) })">{{ up(loc) }}<KestrelUiIcon name="pencil" :size="14" /></NuxtLink>
44
+ <!-- No `?locale=` here: the target id already IS a specific locale's row (useEditForm reads the
45
+ active locale off the loaded record, not the query, once an id is known). -->
46
+ <NuxtLink class="locale-bar__btn" :to="`/admin/${collection}/${existingId(loc)}`" :aria-label="t('localeBar.editLocale', { loc: up(loc) })">{{ up(loc) }}<KestrelUiIcon name="pencil" :size="14" /></NuxtLink>
45
47
  <button type="button" class="locale-bar__btn locale-bar__btn--copy" :aria-label="t('localeBar.copyInto', { loc: up(loc), current: up(current) })" @click="emit('copyFrom', loc)"><KestrelUiIcon name="copy" :size="14" /></button>
46
48
  </span>
47
49
 
@@ -1,8 +1,6 @@
1
1
  <script setup lang="ts">
2
- import type { FieldDef } from '../../../core/server/utils/defineCollection'
3
- import type { LayoutNode } from '../../../core/server/utils/field-layout'
4
- import type { SeoMeta } from '../../../core/server/utils/seo'
5
-
2
+ import type { FieldDef, LayoutNode, SeoMeta } from '@michaelthielemann/kestrel-core'
3
+ import { slugify } from '@michaelthielemann/kestrel-core/client'
6
4
  // The collection (page) field list — delegates to the shared <KestrelFieldLayout> renderer (rows / groups /
7
5
  // widths, or one field per row when the collection declares no layout), shared by the flat-collection
8
6
  // form and the 3-pane editor's root-selected pane so the wiring lives in exactly one place. pageLike
@@ -1,9 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
- import type { SeoMeta } from '../../../core/server/utils/seo'
4
- import type { FieldDef } from '../../../core/server/utils/defineCollection'
5
- import { localePath } from '../../../core/app/utils/locale-path'
6
-
3
+ import { localePath } from '@michaelthielemann/kestrel-core/client'
4
+ import type { FieldDef, SeoMeta } from '@michaelthielemann/kestrel-core'
7
5
  // The page-level SEO section: edit the meta title/description/noindex (the `seo` system column) with a
8
6
  // live Google-result preview. Emits the whole merged SeoMeta so the parent routes it through setField.
9
7
  const props = defineProps<{
@@ -34,9 +34,10 @@ useUnsavedGuard(() => editorRef.value?.dirty ?? false, () => t('editor.discardCo
34
34
  <KestrelUiButton type="button" variant="ghost" size="sm" icon="undo" :disabled="saving || !editorRef?.canUndo" :title="t('history.undo')" :aria-label="t('history.undo')" @click="editorRef?.undo()" />
35
35
  <KestrelUiButton type="button" variant="ghost" size="sm" icon="redo" :disabled="saving || !editorRef?.canRedo" :title="t('history.redo')" :aria-label="t('history.redo')" @click="editorRef?.redo()" />
36
36
  <KestrelUiButton type="button" variant="ghost" size="sm" icon="external-link" :title="previewTitle" :aria-label="previewTitle" :loading="editorRef?.previewOpening" @click="editorRef?.openPreview()" />
37
- <KestrelUiButton type="submit" :form="EDITOR_FORM_ID" variant="primary" size="sm" icon="check" :loading="saving">{{ t('common.save') }}</KestrelUiButton>
37
+ <!-- A quarantined record is read-only: no Save, no Publish — there is nothing valid to write. -->
38
+ <KestrelUiButton v-if="!editorRef?.quarantined" type="submit" :form="EDITOR_FORM_ID" variant="primary" size="sm" icon="check" :loading="saving">{{ t('common.save') }}</KestrelUiButton>
38
39
  <!-- Publishing is its own decision: Save persists, Publish writes the static page (ADR-0008). -->
39
- <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>
40
+ <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>
40
41
  <KestrelEditorStatus class="singleton__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" />
41
42
  </div>
42
43
  </div>
@@ -17,7 +17,7 @@ export function useAuth() {
17
17
  // Fail closed: a transient session-endpoint failure (5xx, offline) must read as
18
18
  // "not authenticated" so the route guard redirects to login instead of throwing an error page.
19
19
  try {
20
- const r = await $fetch<{ authenticated: boolean; exp?: number }>('/api/auth/session')
20
+ const r = await $fetch<{ authenticated: boolean; exp?: number }>('/api/session')
21
21
  state.value = { authenticated: r.authenticated, exp: r.exp ?? null, checked: true }
22
22
  } catch {
23
23
  state.value = { authenticated: false, exp: null, checked: true }
@@ -31,7 +31,7 @@ export function useAuth() {
31
31
  }
32
32
 
33
33
  async function login(password: string) {
34
- const r = await $fetch<{ ok: true; exp: number }>('/api/auth/login', {
34
+ const r = await $fetch<{ ok: true; exp: number }>('/api/login', {
35
35
  method: 'POST',
36
36
  body: { password },
37
37
  })
@@ -48,7 +48,7 @@ export function useAuth() {
48
48
  // The desired end state is "logged out" regardless of the response: an expired/rotated
49
49
  // session makes the guarded logout endpoint 401, but the local session is still cleared.
50
50
  try {
51
- await $fetch('/api/auth/logout', { method: 'POST' })
51
+ await $fetch('/api/logout', { method: 'POST' })
52
52
  } catch {
53
53
  // already invalid server-side — nothing to clear there
54
54
  }
@@ -1,7 +1,7 @@
1
1
  import { ref, computed } from 'vue'
2
2
  import type { Ref } from 'vue'
3
3
  import { useEchoGuard } from '../../../ui/app/composables/useEchoGuard'
4
- import type { SerializedBlock } from '../../../core/server/utils/serialize-collection'
4
+ import type { SerializedBlock } from '@michaelthielemann/kestrel-core'
5
5
  import {
6
6
  type BlockRow,
7
7
  findInTree,
@@ -1,4 +1,4 @@
1
- import type { SerializedBlock } from '../../../core/server/utils/serialize-collection'
1
+ import type { SerializedBlock } from '@michaelthielemann/kestrel-core'
2
2
 
3
3
  // Thin wrapper over the shared cached-resource loader; keeps the named `blocks` return.
4
4
  export function useBlocks() {
@@ -1,10 +1,20 @@
1
1
  import { ref, toValue, type MaybeRefOrGetter } from 'vue'
2
- import { buildDeleteReport, type BatchAction, type BatchDeleteReport, type BulkResult, type ReferrerCounts } from '../utils/collection-ops'
2
+ import { buildDeleteReport, type BatchAction, type BatchDeleteReport, type BatchPipelineResult, type BulkResult, type ReferrerCounts } from '../utils/collection-ops'
3
+ import type { SerializedAction } from '@michaelthielemann/kestrel-core'
4
+
5
+ /** Which write pipeline each batch action runs, and the body it takes. A publish/unpublish is an
6
+ * `updateMany` with a status patch — the same pipeline any other field-wide edit would use. */
7
+ const BATCH_PIPELINE: Record<BatchAction, { op: string, patch?: { status: 'published' | 'draft' } }> = {
8
+ delete: { op: 'deleteMany' },
9
+ duplicate: { op: 'duplicate' },
10
+ publish: { op: 'updateMany', patch: { status: 'published' } },
11
+ unpublish: { op: 'updateMany', patch: { status: 'draft' } },
12
+ }
3
13
 
4
14
  /**
5
15
  * Client orchestration for the collection batch operations — ONE module, two entry points: a row action
6
- * passes a single id, the bulk bar passes many. Every mutating call hits the one command endpoint
7
- * (`POST /api/{collection}/bulk`); the server does the batching, the client never loops N requests.
16
+ * passes a single id, the bulk bar passes many. Every mutating call runs a batch pipeline
17
+ * (`POST /api/{collection}/{pipeline}`); the server does the batching, the client never loops N requests.
8
18
  * Mirrors media/useMediaOps (busy/error + an onChanged refetch hook).
9
19
  */
10
20
  export function useCollectionOps(collection: MaybeRefOrGetter<string>, onChanged?: () => void | Promise<void>) {
@@ -17,9 +27,14 @@ export function useCollectionOps(collection: MaybeRefOrGetter<string>, onChanged
17
27
  busy.value = true
18
28
  error.value = null
19
29
  try {
20
- const res = await $fetch<BulkResult>(`/api/${name()}/bulk`, { method: 'POST', body: { action, ids } })
30
+ const { op, patch } = BATCH_PIPELINE[action]
31
+ const res = await $fetch<BatchPipelineResult>(`/api/${name()}/${op}`, { method: 'POST', body: { ids, ...(patch ? { patch } : {}) } })
21
32
  await onChanged?.()
22
- return res
33
+ // `duplicate` answers with the CREATED rows, the two batch ops with a count + the ids they touched;
34
+ // both fold into the one shape the list UI reports.
35
+ return Array.isArray(res)
36
+ ? { action, count: res.length, ids: res.map((row) => Number(row.id)) }
37
+ : { action, count: res.count, ids: res.ids }
23
38
  } catch (e) {
24
39
  error.value = (e as { statusMessage?: string })?.statusMessage ?? t('list.opFailed')
25
40
  throw e
@@ -33,7 +48,7 @@ export function useCollectionOps(collection: MaybeRefOrGetter<string>, onChanged
33
48
  async function previewDelete(ids: number[]): Promise<BatchDeleteReport> {
34
49
  error.value = null
35
50
  try {
36
- const res = await $fetch<ReferrerCounts>('/api/references/referrers', { query: { collection: name(), ids: ids.join(',') } })
51
+ const res = await $fetch<ReferrerCounts>(`/api/${name()}/referrers`, { query: { ids: ids.join(',') } })
37
52
  // The endpoint flags whether the index lookup ran; carry that through rather than inferring safety from
38
53
  // an empty `counts`. A missing body is indeterminate too — it is no evidence that nothing links here.
39
54
  return buildDeleteReport(ids, res?.counts ?? {}, res != null && res.checked !== false)
@@ -43,11 +58,33 @@ export function useCollectionOps(collection: MaybeRefOrGetter<string>, onChanged
43
58
  }
44
59
  }
45
60
 
61
+ /**
62
+ * Run a schema-driven custom action (a consumer's `definePipeline` beyond the built-in bulk set). The
63
+ * response shape is whatever that pipeline answers with — unlike the built-ins, generic rendering has no
64
+ * contract to decode, so the reported result is simply the ids the caller asked to act on.
65
+ */
66
+ async function runCustomAction(action: SerializedAction, ids: number[]): Promise<BulkResult> {
67
+ busy.value = true
68
+ error.value = null
69
+ try {
70
+ const record = action.kind === 'record'
71
+ const url = record ? `/api/${name()}/${action.name}/${ids[0]}` : `/api/${name()}/${action.name}`
72
+ await $fetch(url, { method: 'POST', body: record ? undefined : { ids } })
73
+ await onChanged?.()
74
+ return { action: action.name, count: ids.length, ids }
75
+ } catch (e) {
76
+ error.value = (e as { statusMessage?: string })?.statusMessage ?? t('list.opFailed')
77
+ throw e
78
+ } finally {
79
+ busy.value = false
80
+ }
81
+ }
82
+
46
83
  const confirmDelete = (ids: number[]) => bulk('delete', ids)
47
84
  const duplicate = (ids: number[]) => bulk('duplicate', ids)
48
85
  /**
49
86
  * Persist a new status and, for a publish, write the static output too (ADR-0008). Two calls because
50
- * they are two things: the bulk endpoint owns the DB write (validation, all-or-nothing, write events),
87
+ * they are two things: `updateMany` owns the DB write (validation, all-or-nothing, write events),
51
88
  * `/api/publish` owns the render. Unpublishing needs no second call — the write event prunes the pages
52
89
  * on its own, since taking a page offline must never wait for a separate action.
53
90
  */
@@ -61,5 +98,5 @@ export function useCollectionOps(collection: MaybeRefOrGetter<string>, onChanged
61
98
  return res
62
99
  }
63
100
 
64
- return { busy, error, previewDelete, confirmDelete, duplicate, setStatus }
101
+ return { busy, error, previewDelete, confirmDelete, duplicate, setStatus, runCustomAction }
65
102
  }
@@ -1,6 +1,5 @@
1
- import type { SerializedCollection } from '../../../core/server/utils/serialize-collection'
1
+ import type { SerializedCollection } from '@michaelthielemann/kestrel-core'
2
2
 
3
- // Thin wrapper over the shared cached-resource loader; keeps the named `collections` return.
4
3
  export function useCollections() {
5
4
  const { state: collections, load } = useCachedResource<SerializedCollection>('kestrel-collections', '/api/collections')
6
5
  return { collections, load }
@@ -1,8 +1,9 @@
1
- import type { SerializedField } from '../../../core/server/utils/serialize-collection'
2
- import type { LayoutNode } from '../../../core/server/utils/field-layout'
1
+ import type { LayoutNode, SerializedField } from '@michaelthielemann/kestrel-core'
2
+ import { isFieldVisible } from '@michaelthielemann/kestrel-core/client'
3
+ import { validateField } from '@michaelthielemann/kestrel-fields/client'
3
4
  import type { BlockErrorMap } from '../utils/edit-form'
4
5
  import type { DeadRef } from '../utils/dead-refs'
5
- import { asFieldDef, initialValues, mapServerErrors, parseBlockErrors, reconcileBlockErrors, readFetchError } from '../utils/edit-form'
6
+ import { asFieldDef, initialValues, mapServerErrors, parseBlockErrors, reconcileBlockErrors, reconcileDeadRefs, readFetchError } from '../utils/edit-form'
6
7
 
7
8
  export interface UseEditFormOptions {
8
9
  collection: string
@@ -51,6 +52,11 @@ export function useEditForm(opts: UseEditFormOptions) {
51
52
  // but fall back defensively (blocks → 'blocks', else 'fields') so an older/partial schema still works.
52
53
  const editorType = ref('fields')
53
54
 
55
+ // Set when the loaded row is the quarantine shape (`{ id, $quarantined: true }`) — a stored row
56
+ // that no longer matches its collection's select schema. The form locks read-only; nothing here can
57
+ // widen what a corrupted row exposes.
58
+ const quarantined = ref(false)
59
+
54
60
  const values = reactive<Record<string, unknown>>({})
55
61
  const errors = reactive<Record<string, string>>({})
56
62
  const formError = ref('')
@@ -171,16 +177,18 @@ export function useEditForm(opts: UseEditFormOptions) {
171
177
 
172
178
  let row: Record<string, unknown> | null = null
173
179
  if (mode.value === 'single') {
174
- row = await $fetch<Record<string, unknown> | null>(`/api/${collection}`, translatable.value ? { query: { locale: requestedLocale } } : {})
180
+ row = await $fetch<Record<string, unknown> | null>(`/api/${collection}/readOne`, translatable.value ? { query: { locale: requestedLocale } } : {})
175
181
  activeLocale.value = requestedLocale
176
182
  } else if (id !== 'new') {
177
- row = await $fetch<Record<string, unknown>>(`/api/${collection}/${id}`)
178
- if (translatable.value) {
183
+ row = await $fetch<Record<string, unknown>>(`/api/${collection}/readOne/${id}`)
184
+ // A quarantined row carries none of its normal fields — skip the locale/translations lookups, which
185
+ // assume a real row, and go straight to the read-only lock.
186
+ if (row?.$quarantined !== true && translatable.value) {
179
187
  if (typeof row?.locale === 'string') activeLocale.value = row.locale
180
188
  if (typeof row?.translationGroup === 'string') translationGroup.value = row.translationGroup
181
189
  // Supplementary (drives the LocaleBar) — never block editing if it fails.
182
190
  try {
183
- translations.value = await $fetch<Record<string, number | null>>(`/api/${collection}/${id}/translations`)
191
+ translations.value = await $fetch<Record<string, number | null>>(`/api/${collection}/translations/${id}`)
184
192
  } catch {
185
193
  translations.value = {}
186
194
  }
@@ -199,18 +207,19 @@ export function useEditForm(opts: UseEditFormOptions) {
199
207
  }
200
208
  }
201
209
  }
210
+ quarantined.value = row?.$quarantined === true
202
211
  rebaseline(row)
203
212
 
204
213
  // Supplementary dead-reference map (the editor warnings) — for any saved record, never blocks editing.
205
214
  const recordId = typeof row?.id === 'number' ? row.id : null
206
- if (recordId != null) {
215
+ if (recordId != null && !quarantined.value) {
207
216
  try {
208
- deadRefs.value = await $fetch<DeadRef[]>(`/api/${collection}/${recordId}/dead-refs`)
217
+ deadRefs.value = await $fetch<DeadRef[]>(`/api/${collection}/deadRefs/${recordId}`)
209
218
  } catch {
210
219
  deadRefs.value = []
211
220
  }
212
221
  } else {
213
- deadRefs.value = [] // a new (unsaved) record has no references — keep it explicit, never stale
222
+ deadRefs.value = [] // a new (unsaved) or quarantined record has no references — keep it explicit
214
223
  }
215
224
  }
216
225
 
@@ -221,9 +230,14 @@ export function useEditForm(opts: UseEditFormOptions) {
221
230
  function setField(name: string, value: unknown, coalesceAs: string = name) {
222
231
  // Capture the pre-edit state for undo (coalesced for a same-field typing burst).
223
232
  recordHistory(coalesceAs)
224
- // Reconcile before overwriting: keep block errors through a reorder, drop them on an edit.
233
+ // Reconcile before overwriting: keep block errors/dead-ref warnings through a reorder, drop them once
234
+ // the offending field itself is edited (swapping out a broken reference clears its own warning without
235
+ // a reload — the dead-ref map is otherwise only refetched on load).
225
236
  if (name === 'content') {
226
237
  blockErrors.value = reconcileBlockErrors(blockErrors.value, values.content as unknown[], value as unknown[])
238
+ deadRefs.value = reconcileDeadRefs(deadRefs.value, values.content as unknown[], value as unknown[])
239
+ } else if (deadRefs.value.some((r) => !r.blockId && r.field === name)) {
240
+ deadRefs.value = deadRefs.value.filter((r) => r.blockId || r.field !== name)
227
241
  }
228
242
  values[name] = value
229
243
  formError.value = ''
@@ -279,9 +293,9 @@ export function useEditForm(opts: UseEditFormOptions) {
279
293
 
280
294
  function handleError(e: unknown) {
281
295
  const { statusCode, statusMessage, issues, data } = readFetchError(e)
282
- // The row WAS written and a follow-up step failed (a write effect — see write-effects.ts), so the
283
- // server hands back the new `updatedAt`. Take it as the baseline: the client only rebaselines on
284
- // success, and without this the "save again" those errors ask for would 409 on a stale precondition.
296
+ // The row WAS written and a critical after-step failed (e.g. `writeRedirects`), so the server hands
297
+ // back the new `updatedAt`. Take it as the baseline: the client only rebaselines on success, and
298
+ // without this the "save again" those errors ask for would 409 on a stale precondition.
285
299
  if (typeof data?.savedUpdatedAt === 'number') baseUpdatedAt.value = data.savedUpdatedAt
286
300
  if (statusCode === 400) {
287
301
  const mapped = mapServerErrors(issues)
@@ -305,6 +319,9 @@ export function useEditForm(opts: UseEditFormOptions) {
305
319
  }
306
320
 
307
321
  async function submit(): Promise<SubmitResult> {
322
+ // Sealed at the wire (validateOut) — a quarantined row's fields are unknown, so nothing here may
323
+ // send a write for it.
324
+ if (quarantined.value) return { ok: false }
308
325
  formError.value = ''
309
326
  blockErrors.value = {}
310
327
  // Clear the whole map, not just declared-field errors: `path`/`seo`/`status` are system columns
@@ -326,10 +343,10 @@ export function useEditForm(opts: UseEditFormOptions) {
326
343
  // `method` cast: Nuxt's typed `$fetch` over-constrains the method for this dynamic (template-literal)
327
344
  // admin route to GET; the runtime value is correct, only the static route type can't express it.
328
345
  const row = mode.value === 'single'
329
- ? await $fetch(`/api/${collection}`, { method: 'PUT' as never, body, headers: ifUnmodified, ...(translatable.value ? { query: { locale: activeLocale.value } } : {}) })
346
+ ? await $fetch(`/api/${collection}/updateOne`, { method: 'POST' as never, body, headers: ifUnmodified, ...(translatable.value ? { query: { locale: activeLocale.value } } : {}) })
330
347
  : id === 'new'
331
- ? await $fetch(`/api/${collection}`, { method: 'POST' as never, body })
332
- : await $fetch(`/api/${collection}/${id}`, { method: 'PATCH', body, headers: ifUnmodified })
348
+ ? await $fetch(`/api/${collection}/createOne`, { method: 'POST' as never, body })
349
+ : await $fetch(`/api/${collection}/updateOne/${id}`, { method: 'POST' as never, body, headers: ifUnmodified })
333
350
  rebaseline(row as Record<string, unknown>)
334
351
  // `record` is the saved entity at the editor's public boundary; `row` stays the DB-layer term.
335
352
  return { ok: true, record: row }
@@ -361,6 +378,7 @@ export function useEditForm(opts: UseEditFormOptions) {
361
378
  pageLike,
362
379
  hasSeo,
363
380
  hasStatus,
381
+ quarantined,
364
382
  values,
365
383
  errors,
366
384
  formError,
@@ -21,7 +21,7 @@ export function useLinkResolver() {
21
21
  for (const k of keys) pending.add(k)
22
22
  try {
23
23
  const { data } = await $fetch<{ data: { collection: string; id: number; href: string }[] }>(
24
- '/api/links/resolve', { query: { refs: keys.join(',') } },
24
+ '/api/resolveLinks', { query: { refs: keys.join(',') } },
25
25
  )
26
26
  const got = new Map(data.map((d) => [keyFor(d.collection, d.id), d.href]))
27
27
  for (const k of keys) cache.set(k, got.get(k) ?? '') // cache misses too, as '' → no re-fetch
@@ -3,12 +3,14 @@
3
3
  //
4
4
  // Destructive → confirm dialog. Reversible → run, toast, refetch (the ops composable's onChanged).
5
5
  import { ref, type ComputedRef } from 'vue'
6
+ import { resolveLocalized } from '../../../ui/app/utils/localized'
6
7
  import type { BatchDeleteReport } from '../utils/collection-ops'
8
+ import type { SerializedAction } from '@michaelthielemann/kestrel-core'
7
9
 
8
10
  export function useListBatchActions(collection: ComputedRef<string>, refetch: () => void | Promise<void>) {
9
- const { t } = useT()
11
+ const { t, lang } = useT()
10
12
  const toast = useToast()
11
- const { busy, error, previewDelete, confirmDelete: runDelete, duplicate: runDuplicate, setStatus: runSetStatus } =
13
+ const { busy, error, previewDelete, confirmDelete: runDelete, duplicate: runDuplicate, setStatus: runSetStatus, runCustomAction } =
12
14
  useCollectionOps(collection, () => refetch())
13
15
 
14
16
  const deleteOpen = ref(false)
@@ -57,5 +59,20 @@ export function useListBatchActions(collection: ComputedRef<string>, refetch: ()
57
59
  }
58
60
  }
59
61
 
60
- return { busy, error, deleteOpen, deleteReport, askDelete, confirmDelete, duplicate, setStatus }
62
+ /** A schema-driven custom action: label falls back to the pipeline name, and an `ui.confirm` flag gates
63
+ * on a plain confirm prompt (the built-ins get the richer dialog above; a generic action has no report to
64
+ * preview). */
65
+ async function runAction(action: SerializedAction, ids: number[]) {
66
+ if (!ids.length) return
67
+ const label = resolveLocalized(action.label, lang.value) ?? action.name
68
+ if (action.confirm && !confirm(t('list.actionConfirm', { name: label, n: ids.length }))) return
69
+ try {
70
+ await runCustomAction(action, ids)
71
+ toast.success(t('list.actionDone', { name: label }))
72
+ } catch {
73
+ toast.error(error.value ?? t('list.opFailed'))
74
+ }
75
+ }
76
+
77
+ return { busy, error, deleteOpen, deleteReport, askDelete, confirmDelete, duplicate, setStatus, runAction }
61
78
  }
@@ -1,5 +1,5 @@
1
1
  import type { Ref } from 'vue'
2
- import type { SerializedCollection } from '../../../core/server/utils/serialize-collection'
2
+ import type { SerializedCollection } from '@michaelthielemann/kestrel-core'
3
3
  import { availableColumns, defaultVisibleKeys, resolveVisibleColumns, type ListColumn } from '../utils/list-columns'
4
4
 
5
5
  /**
@@ -3,10 +3,11 @@
3
3
  // `filter` (from the URL) is the committed state that drives the query; `draft` is what the panel shows
4
4
  // while the user is still typing. A value change debounces, an operator change commits at once.
5
5
  import { computed, onBeforeUnmount, reactive, watch, type ComputedRef } from 'vue'
6
- import { DEFAULT_OP, OPS_BY_KIND, type FilterKind, type FilterOp } from '../../../core/app/utils/filter-ops'
6
+ import { DEFAULT_OP, OPS_BY_KIND } from '@michaelthielemann/kestrel-core/client'
7
+ import type { FilterKind, FilterOp } from '@michaelthielemann/kestrel-core/client'
8
+ import type { Localized } from '@michaelthielemann/kestrel-core'
7
9
  import { resolveLocalized } from '../../../ui/app/utils/localized'
8
10
  import { columnLabel } from '../utils/list-cell'
9
- import type { Localized } from '../../../core/server/utils/defineCollection'
10
11
  import type { ListColumn } from '../utils/list-columns'
11
12
  import type { FilterCell } from '../utils/list-query'
12
13