@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
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Kestrel
2
2
 
3
+ **Please be aware that this project is under rapid development and may not be suitable for long-term projects.**
4
+
3
5
  A slim, **static-site-generator CMS**: a generic collection-driven CRUD core with a schema-driven admin
4
6
  editor. You declare collections and fields in TypeScript; Kestrel derives the database, a typed CRUD API,
5
7
  and a full admin UI from those definitions, and renders published content to static HTML via
@@ -25,14 +27,9 @@ static host. It is deliberately **not**:
25
27
  output); everything else stays behind the admin guard. No per-request rate limiting / live API.
26
28
  - **No runtime redirect engine** — redirects are *authored* in the CMS and published as a `redirects.json`
27
29
  artifact, but Kestrel never answers a 30x itself; an edge (NGINX / njs / CloudFront) has to read it.
28
- - **No per-file access control on uploads.** The admin guard protects the media *library* listing,
29
- editing, deleting but not the bytes. With `media.driver: 'local'` the files are served from the app
30
- origin by Nitro's static handler, which runs ahead of every middleware, so anyone who knows a URL can
31
- fetch it; the optional IP allow-list does not cover them either (see
32
- [configuration.md](./docs/configuration.md#ip-allow-list--optional)). That is the intended model — the
33
- editing origin is meant to be non-public, and published media is public by definition. If you deploy
34
- Kestrel as a general-purpose CMS whose uploads must stay private, restrict them at the reverse proxy or
35
- serve media from a private S3 bucket; the app will not do it for you.
30
+ - **No per-file access control on uploads.** The admin guard protects the media *library*, not the
31
+ bytes; the editing origin is meant to be non-public and published media is public by definition. See
32
+ [media.md](./docs/guide/media.md).
36
33
 
37
34
  ## Features
38
35
 
@@ -42,9 +39,10 @@ static host. It is deliberately **not**:
42
39
  - **Collection-driven** — declare collections + fields in TypeScript; Kestrel derives the SQLite tables, a
43
40
  typed CRUD REST API, and the full admin UI. The schema **migrates itself** (additive in dev; explicit
44
41
  `db:migrate` in prod).
45
- - **Field types** — text, textarea, rich-text, number, boolean, choice (select / buttons / checkboxes),
46
- date / time, media, relation (single / many), link (internal / external / email / tel), repeater
47
- (nestable), JSON — plus per-field `condition` visibility rules.
42
+ - **Field types** — twelve built in: text (single- or multi-line), slug, richtext, number, boolean,
43
+ datetime (date / time / range), choice (select / buttons / checkboxes), media, relation (single /
44
+ many), link (internal / external / email / tel), repeater (nestable), JSON — plus per-field `condition`
45
+ visibility rules. Register your own with `defineFieldType`.
48
46
  - **Page-like collections** — give a collection a `path`, SEO meta, and block or flat content; published
49
47
  records render to static HTML and join the sitemap. **Singletons** for settings / navigation.
50
48
  - **Block page-builder** — a 3-pane editor (tree · live preview · fields) with nestable block slots; each
@@ -119,48 +117,34 @@ content at `/admin`. You bring your own **public layout** and **block SFCs**
119
117
  > the file `nuxi init` writes renders `<NuxtWelcome />` instead of your routes — the admin then appears to
120
118
  > be missing rather than blank. Kestrel reports this at build time; `kestrel doctor` catches it earlier.
121
119
 
122
- Full guide: **[consuming-kestrel.md](docs/consuming-kestrel.md)**.
120
+ Full walkthrough: **[getting-started.md](docs/guide/getting-started.md)**.
123
121
 
124
122
  ## Documentation
125
123
 
126
- Start with the **[architecture guide](docs/architecture.md)** — a per-layer map (what each layer owns,
127
- where to start reading, the cross-layer seams, the gotchas). Then the per-topic docs:
128
-
129
- | Doc | Covers |
130
- |-----|--------|
131
- | [architecture.md](docs/architecture.md) | **Start here** — the layer model, boot order, cross-cutting seams, per-layer guide |
132
- | [field-types.md](docs/field-types.md) | Every built-in field type with its options, the column it becomes, what the server enforces vs. what only configures the widget |
133
- | [consuming-kestrel.md](docs/consuming-kestrel.md) | Using Kestrel in your own app: defining collections/fields/blocks, auto-discovery, the schema lifecycle |
134
- | [configuration.md](docs/configuration.md) | The single config source (`kestrel.config.ts`), every `KESTREL_*` env var, the auth/session env split |
135
- | [block-editing.md](docs/block-editing.md) | The block content model + the 3-pane block editor (tree · preview · fields) |
136
- | [media-uploads.md](docs/media-uploads.md) | Ingest security, storage drivers (local/S3), responsive-image derivation, EU AI Act disclosure |
137
- | [multilingual.md](docs/multilingual.md) | Content locales, the editor locale flow, locale-prefixed routing |
138
- | [static-output.md](docs/static-output.md) | `nuxt generate` + the runtime incremental publisher, the live editor preview, `sitemap.xml` / `robots.txt` / `llms.txt` / `llms-full.txt`, the JSON-LD structured data, CMS-managed redirects, the optional S3 deploy |
139
- | [reference-integrity.md](docs/reference-integrity.md) | How writes invalidate the static site precisely, dead-reference warnings (a dead link renders `#`, and the editor is warned), and required/unique page slugs |
140
- | [architecture-decisions.md](docs/architecture-decisions.md) | ADRs (the collection-derived schema engine, reference integrity, the auth/password choice) |
124
+ Two doors, one per audience:
125
+
126
+ - **[Guide](docs/guide/README.md)** — building a site with the package: collections, fields, blocks,
127
+ media, publishing, deployment, configuration, extension points.
128
+ - **[Internals](docs/internals/README.md)** — developing Kestrel itself: architecture, layers and
129
+ packages, the pipeline engine, test rails, releasing, the ADR log.
130
+
131
+ Most-asked pages: [getting started](docs/guide/getting-started.md) ·
132
+ [field types](docs/guide/field-types.md) · [blocks](docs/guide/blocks.md) ·
133
+ [configuration](docs/guide/configuration.md) · [deploying](docs/guide/deploying.md) ·
134
+ [troubleshooting](docs/guide/troubleshooting.md).
141
135
 
142
136
  ## Layout
143
137
 
144
- The CMS is split into Nuxt layers under `layers/`:
145
-
146
- - **`core`** the collection/field model, the generic CRUD engine + REST API, def→JSON serialization,
147
- the runtime schema-migration engine, config resolution, and the populate registry.
148
- - **`fields`** turns a definition into Drizzle tables + Zod schemas (the field-type registry).
149
- - **`ui`** the admin design system: schema-driven field widgets, generic primitives, tokens, i18n.
150
- - **`auth`** — single-user authentication (session cookie, scrypt password, login, CSRF).
151
- - **`access`** — authorization: a default-deny guard over the entire `/api/` surface + policy/grant registry.
152
- - **`collections`** the toggleable built-in `pages` collection + the plugin that registers every
153
- discovered collection/block. (Demo content — `posts`/`settings`, the `hero`/`prose` blocks, and a layout —
154
- lives in the repo root, dev-only, and is **not** shipped in the package.)
155
- - **`media`** — uploads, pluggable storage, image derivation, the media library + asset viewer.
156
- - **`admin`** — the editor SPA: collection list, record editor, the 3-pane block editor.
157
- - **`public`** — the SSG render path: the catch-all page, `KestrelBlockRenderer`, the JSON-LD head, the literal-key
158
- artifacts (sitemap / robots / llms.txt / llms-full.txt / redirects.json), deploy.
159
-
160
- `playground/` is a small consuming example. `templates/starter/` is what the scaffolder writes out;
161
- `scripts/kestrel.mjs` is the engine's CLI and `packages/create-kestrel/` the standalone
162
- `pnpm create kestrel` front end, which copies the same templates in at pack time rather than keeping
163
- its own.
138
+ The domain/server code lives in ten `@michaelthielemann/kestrel-*` packages under `packages/` (`contracts`, `core`,
139
+ `fields`, `auth`, `access`, `collections`, `media`, `publishing`, `delivery-live`, `delivery-static`).
140
+ The nine Nuxt layers under `layers/` are thin wiring shells around those packages, except where a surface
141
+ depends on Nuxt's own component resolution and auto-imports: the `ui` and `admin` layers (design system
142
+ and editor SPA) and the `app/` halves of `media` and `public` stay real code in layers. Layers import
143
+ packages by name; packages never import layers.
144
+
145
+ `playground/` is a small consuming example, `templates/starter/` is what the scaffolder writes,
146
+ `scripts/kestrel.mjs` is the CLI, and `packages/create-kestrel/` the `pnpm create kestrel` front end.
147
+ The full map: [layers-and-packages.md](docs/internals/layers-and-packages.md).
164
148
 
165
149
  ## Development
166
150
 
@@ -185,68 +169,9 @@ node scripts/kestrel.mjs init <dir> # the consumer scaffolder, from a checkout
185
169
  node scripts/kestrel.mjs doctor <dir> # diagnose a consumer project
186
170
  ```
187
171
 
188
- In dev, the schema auto-syncs from the collection definitions (additive changes only); production applies
189
- schema changes explicitly via the `db:migrate` task. See
190
- [architecture-decisions.md](docs/architecture-decisions.md) for the rationale.
191
-
192
- ### Publishing the static site
193
-
194
- Two ways to produce the static output:
195
-
196
- - **One-shot:** `pnpm generate` → `.output/public` (the classic full rebuild).
197
- - **Runtime publisher (default):** a **production** run (`pnpm build && pnpm preview`, or
198
- `node .output/server/index.mjs`) publishes on boot and incrementally re-publishes the affected pages when
199
- you press **Publish** in the editor, into `output.dir` (default `.data/published`). Saving is a DB write
200
- and leaves the live page alone (unpublishing and deleting still take a page down at once) — see
201
- [ADR-0008](docs/architecture-decisions.md), or set `output.publishOnSave: true` for the pre-2.0 behaviour
202
- where every save republished. Serve that dir with any static server, e.g. `npx serve .data/published`.
203
-
204
- The runtime publisher is **intentionally disabled in `pnpm dev`** (a dev render would write un-hashed Vite
205
- HTML), so the static files only appear on a production run. In dev you instead get the **live preview**:
206
- public pages render straight from the running server, and an authenticated admin can open an unpublished
207
- page at its real URL (the "open in new tab" button in the editor) — with unsaved changes that button
208
- carries them along in a preview ticket rather than saving them. See
209
- [static-output.md](docs/static-output.md) for the full picture.
210
-
211
- ### Simulate a production deploy locally
212
-
213
- A real deployment is **two processes**: the private CMS server that *renders* the static files, and a
214
- public static host that *serves* them. Rehearse that split on one machine:
215
-
216
- ```bash
217
- # 1. Auth secrets live in .env (pnpm preview loads it). At minimum:
218
- # KESTREL_SESSION_SECRET=<≥32 bytes, e.g. `openssl rand -hex 32`>
219
- # KESTREL_ADMIN_PASSWORD_HASH=<from `pnpm hash-password <your-password>`>
220
- # Leave KESTREL_SECURE_COOKIES at its default — see the note below.
221
-
222
- pnpm build
223
- pnpm preview # built Nitro server, NODE_ENV=production → admin at http://localhost:3000/admin
224
- # boot-publish writes .data/published; editing in /admin re-publishes affected pages
225
-
226
- # in a SECOND terminal — serve the generated site like a CDN would:
227
- npx serve .data/published # the public static site, exactly as it ships
228
- ```
229
-
230
- `pnpm preview` runs the build under `NODE_ENV=production` and **loads `.env`** (running
231
- `node .output/server/index.mjs` directly does **not** — export the vars yourself if you go that route).
232
- Note the split: the **auth** vars below are read per request, so exporting them works either way, but the
233
- non-auth `KESTREL_*` settings are baked in during `pnpm build` — in front of an already-built server they
234
- are ignored, and you override them with Nuxt's runtime names instead (`NUXT_KESTREL_DB_PATH`,
235
- `NUXT_KESTREL_SITE_URL`, …; see [configuration.md](docs/configuration.md)).
236
- Production mode makes the auth guards strict, so for a working local login:
237
-
238
- - `KESTREL_SESSION_SECRET` is **required and ≥32 bytes** (dev uses a random per-process secret); the
239
- check runs per-request, not at boot, so a deploy that forgets it still starts and binds the port —
240
- every `/api/*` request then answers **500** until the secret is set.
241
- - `KESTREL_ADMIN_PASSWORD_HASH` must be set, or `POST /api/auth/login` answers **503** (“admin login is
242
- not configured”) rather than a wrong-password 401.
243
- - **Keep `KESTREL_SECURE_COOKIES` at its default (`true`).** `=false` is *refused* in production
244
- (`KESTREL_SECURE_COOKIES=false is not allowed in production`); you don't need it — the session cookie is
245
- `Secure`/`__Host-`, and browsers accept those over plain `http://localhost`, so login works without HTTPS.
246
-
247
- Watch the CMS terminal: boot prints `[kestrel] boot publish: N route(s) written, …`, and each save logs
248
- the routes it re-rendered. Env reference → [configuration.md](docs/configuration.md); publishing internals
249
- → [static-output.md](docs/static-output.md).
172
+ Conventions, test rails and the release flow: [internals/testing.md](docs/internals/testing.md) and
173
+ [internals/releasing.md](docs/internals/releasing.md). Running a built site production env, the static
174
+ publisher, rehearsing a deploy locally: [deploying.md](docs/guide/deploying.md).
250
175
 
251
176
  ## Configuration
252
177
 
@@ -254,7 +179,7 @@ Non-auth settings live in `kestrel.config.ts` (`satisfies KestrelConfig`); each
254
179
  `KESTREL_* env → config → default`, resolved at build/dev start and frozen into `runtimeConfig` (a prebuilt
255
180
  server is retuned with the `NUXT_*` runtimeConfig names, not `KESTREL_*`). Auth/session secrets are
256
181
  **env-only** and read per request (`KESTREL_SESSION_SECRET`, `KESTREL_ADMIN_PASSWORD_HASH`, …). Full reference:
257
- [configuration.md](docs/configuration.md).
182
+ [configuration.md](docs/guide/configuration.md).
258
183
 
259
184
  ## Security
260
185
 
package/kestrel.config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { KestrelConfig } from './layers/core/server/utils/kestrel-config'
1
+ import type { KestrelConfig } from '@michaelthielemann/kestrel-core'
2
2
 
3
3
  /**
4
4
  * Central Kestrel configuration (everything except auth/session, which stays env-only because secrets
@@ -5,30 +5,15 @@
5
5
  // when `media.driver: 'local'`) — the `00.` prefix only orders files within this layer, while Nitro's own
6
6
  // static handler is unconditionally unshifted to the front of the whole handler list. `off` when no list
7
7
  // is set → zero cost on PROD/local.
8
- function loadConfig() {
9
- const listRaw = process.env.KESTREL_IP_ALLOWLIST
10
- const mode = allowlistMode(process.env.KESTREL_IP_ALLOWLIST_MODE, listRaw)
11
- // Named per-token, not just a "0 valid CIDRs" count — a mixed list (one good entry, one IPv6 CIDR this
12
- // IPv4-only parser can never match) would otherwise silently under-enforce with no diagnostic at all.
13
- const cidrs = parseAllowlist(listRaw, (token) => {
14
- console.warn(`[ip-allowlist] dropping unparseable/unsupported allow-list entry "${token}" (IPv4/CIDR only)`)
15
- })
16
- if (mode !== 'off' && cidrs.length === 0) {
17
- console.warn('[ip-allowlist] KESTREL_IP_ALLOWLIST is set but parsed to 0 valid CIDRs — enforce mode will block ALL traffic')
18
- }
19
- return { mode, cidrs }
20
- }
21
-
22
- // Env is fixed for the process lifetime → parse the (up to a few hundred) CIDRs once, not per request.
23
- let configured: ReturnType<typeof loadConfig> | undefined
8
+ import { clientIp } from '@michaelthielemann/kestrel-auth'
9
+ import { allowlistConfig, ipAllowed, ipv4ToInt, isRendererContext, isStageGatePassedContext, markStageGatePassed } from '@michaelthielemann/kestrel-access'
24
10
 
25
11
  // A listener that never shows the gate a usable client address blocks everything, which looks like an
26
12
  // outage rather than a misconfiguration unless we say once what to set.
27
13
  let warnedUnresolvable = false
28
14
 
29
15
  export default defineEventHandler((event) => {
30
- configured ??= loadConfig()
31
- const { mode, cidrs } = configured
16
+ const { mode, cidrs } = allowlistConfig()
32
17
  if (mode === 'off') return
33
18
 
34
19
  // The runtime publisher renders the public site via a nested in-process $fetch (renderer principal), and
@@ -1,29 +1,31 @@
1
+ import { Forbidden, Unauthorized } from '@michaelthielemann/kestrel-contracts'
2
+ import { toHttpError } from '@michaelthielemann/kestrel-core'
3
+ import { refreshAuthSession } from '@michaelthielemann/kestrel-auth'
4
+ import { claimedByPipelineRoute, resolveEventPrincipal } from '@michaelthielemann/kestrel-access'
5
+
1
6
  export default defineEventHandler((event) => {
2
7
  if (!event.path.startsWith('/api/')) return // default-deny applies to the API only
3
- const settings = sessionSettings()
4
- const decision = evaluateAccess({
5
- method: getMethod(event),
6
- path: event.path,
7
- csrf: {
8
- secFetchSite: getRequestHeader(event, 'sec-fetch-site'),
9
- origin: getRequestHeader(event, 'origin'),
10
- referer: getRequestHeader(event, 'referer'),
11
- host: getRequestHeader(event, 'host'),
12
- },
13
- cookie: getCookie(event, settings.cookieName),
14
- secret: settings.secret,
15
- nowMs: Date.now(),
16
- // The renderer principal is granted to build-time prerender AND to the runtime publisher's render
17
- // (an in-process ALS context, not a forgeable header) — both render the public site via /api/route.
18
- isPrerender: import.meta.prerender === true || isRendererContext(),
19
- // Registry-contributed grants (opt-in layers, e.g. a public proofing back-channel) consulted on top of
20
- // the hardcoded policy. Empty by default → guard behaviour unchanged.
21
- }, publicReadableResources(), registeredGrants())
22
- if (!decision.allow) {
23
- throw createError({ statusCode: decision.status, statusMessage: decision.message })
8
+
9
+ // Authorization itself belongs to the pipeline the URL names — its `access`/`csrf`/`ipAllowlist`
10
+ // declarations, evaluated by the engine before step 1. Only the principal is resolved here, because the
11
+ // gates need it and because the session refresh below is a property of the request, not of the route.
12
+ event.context.principal = resolveEventPrincipal(event)
13
+
14
+ // Default-deny for everything no pipeline claims — an unknown name, a wrong verb, a stray route a
15
+ // consumer mounted under /api without declaring who may run it. The admin falls through instead, so the
16
+ // router can still answer accurately (404 for an unknown pipeline, 405 for the wrong verb) without that
17
+ // answer confirming to an anonymous prober which pipelines exist. For the same reason an anonymous
18
+ // caller gets the access gate's 401 here, not a 403 — a 401/403 split would itself be the oracle.
19
+ if (!claimedByPipelineRoute(getMethod(event), event.path) && event.context.principal.role !== 'admin') {
20
+ // toHttpError is the same single translation every pipeline-thrown KestrelError goes through
21
+ // (core/server/api/[...path].ts's catch) this middleware runs before any pipeline, so it calls the
22
+ // map directly instead of growing a second one.
23
+ if (event.context.principal.role === 'anonymous') {
24
+ throw toHttpError(new Unauthorized({ reason: 'Authentication required' }))
25
+ }
26
+ throw toHttpError(new Forbidden({ reason: 'Forbidden' }))
24
27
  }
25
- event.context.principal = decision.principal
26
- event.context.readScope = decision.readScope
28
+
27
29
  // Sliding-expiry: any authenticated API activity refreshes the session so it stays alive while in use and
28
30
  // auto-expires after `maxAge` of inactivity (idle logout). No-op for anonymous/public requests.
29
31
  refreshAuthSession(event)
@@ -1,8 +1,7 @@
1
- import type { Principal } from './utils/policy'
1
+ import type { Principal } from '@michaelthielemann/kestrel-access'
2
2
 
3
3
  declare module 'h3' {
4
4
  interface H3EventContext {
5
5
  principal?: Principal
6
- readScope?: 'published' | 'all'
7
6
  }
8
7
  }
@@ -131,7 +131,7 @@ defineExpose({ lang, selectLang, signOut })
131
131
  font-weight: var(--weight-medium);
132
132
  }
133
133
  .rail-account__caret {
134
- margin-left: auto; // push the chevron to the right edge of the full-width trigger
134
+ margin-left: auto;
135
135
  flex-shrink: 0;
136
136
  color: var(--color-text-muted);
137
137
  }
@@ -1,7 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
- import type { SerializedBlock } from '../../../core/server/utils/serialize-collection'
4
- import type { LayoutNode } from '../../../core/server/utils/field-layout'
3
+ import type { LayoutNode, SerializedBlock } from '@michaelthielemann/kestrel-core'
5
4
  import type { BlockRow } from '../utils/block-tree'
6
5
  import { resolveLocalized } from '../../../ui/app/utils/localized'
7
6
 
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, inject, onMounted, onUnmounted, ref, watch } from 'vue'
3
- import type { SerializedBlock } from '../../../core/server/utils/serialize-collection'
3
+ import type { SerializedBlock } from '@michaelthielemann/kestrel-core'
4
4
  import { previewSrc, parseFrameMessage, type PreviewBlockNode } from '../../../public/app/utils/preview-protocol'
5
5
  import { createPreviewSender, acceptsFrameEvent, type PreviewSender } from '../utils/preview-channel'
6
6
  import { editorFormContextKey } from '../utils/editor-form-context'
@@ -22,6 +22,16 @@ const { t } = useT()
22
22
  const ctx = inject(editorFormContextKey, null)
23
23
  const src = computed(() => previewSrc(ctx?.previewUrl.value ?? null, props.locale ?? ''))
24
24
 
25
+ // The layout wraps the frame's own document (`<NuxtLayout>`, a parent the postMessage bridge can't reach
26
+ // from inside its slot), so a layout change can't ride the content push — only a fresh navigation
27
+ // re-resolves it. Forcing one needs an actual URL change (a `src` re-assignment to the same string is a
28
+ // no-op), so a change bumps this counter, which `frameSrc` folds into the URL as a harmless query param.
29
+ const layoutReloadKey = ref(0)
30
+ const frameSrc = computed(() => {
31
+ if (!layoutReloadKey.value) return src.value
32
+ return `${src.value}${src.value.includes('?') ? '&' : '?'}_r=${layoutReloadKey.value}`
33
+ })
34
+
25
35
  // ---- populate (pipeline: resolve media ids + internal links into reactive caches) ----
26
36
  // Block field schemas (non-blocking: byType fills once loaded, then the next send carries the media).
27
37
  const { blocks: blockDefs, load } = useBlocks()
@@ -84,9 +94,9 @@ function onFrameLoad() {
84
94
  if (!el) return
85
95
  try {
86
96
  const loc = el.contentWindow?.location
87
- if (loc && loc.pathname + loc.search !== src.value) el.src = src.value
97
+ if (loc && loc.pathname + loc.search !== frameSrc.value) el.src = frameSrc.value
88
98
  } catch {
89
- el.src = src.value // cross-origin navigation — location unreadable, snap back
99
+ el.src = frameSrc.value // cross-origin navigation — location unreadable, snap back
90
100
  }
91
101
  }
92
102
 
@@ -101,7 +111,7 @@ function refresh() {
101
111
  try {
102
112
  el.contentWindow?.location.reload() // same URL → onFrameLoad's snap-back check passes
103
113
  } catch {
104
- el.src = src.value // cross-origin: location unreadable → renavigate
114
+ el.src = frameSrc.value // cross-origin: location unreadable → renavigate
105
115
  }
106
116
  }
107
117
 
@@ -121,6 +131,8 @@ watch(populated, () => { if (ready.value) pushContent() })
121
131
  watch(() => props.selectedId, (id) => { if (ready.value) sender?.sendSelected(id ?? null) })
122
132
  // A src change reloads the iframe (locale switch, first save assigning a URL) → wait for its new ready.
123
133
  watch(src, () => { ready.value = false })
134
+ // See `layoutReloadKey` above: a layout change forces a fresh navigation, the only way to re-resolve it.
135
+ watch(() => ctx?.values?.layout, () => { layoutReloadKey.value++; ready.value = false })
124
136
 
125
137
  // ---- responsive preview: device presets (quick-fill) + automatic scale-to-fit + custom W×H ----
126
138
  // The iframe renders at the target resolution's REAL px (so the page fires its true breakpoints); a
@@ -281,7 +293,7 @@ onUnmounted(() => { ro?.disconnect(); ro = null })
281
293
 
282
294
  <div ref="stage" class="block-preview__stage">
283
295
  <div class="block-preview__viewport" :style="viewportStyle">
284
- <iframe ref="frame" class="block-preview__frame" :src="src" :style="frameStyle" :title="t('preview.label')" @load="onFrameLoad" />
296
+ <iframe ref="frame" class="block-preview__frame" :src="frameSrc" :style="frameStyle" :title="t('preview.label')" @load="onFrameLoad" />
285
297
  </div>
286
298
  </div>
287
299
  </section>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { SerializedBlock } from '../../../core/server/utils/serialize-collection'
2
+ import type { SerializedBlock } from '@michaelthielemann/kestrel-core'
3
3
  import type { BlockTreeCtx } from '../utils/block-tree'
4
4
 
5
5
  // The page-builder editor body: hierarchy · live preview · contextual fields. All form state comes from
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { localePath } from '../../../core/app/utils/locale-path'
2
+ import { localePath } from '@michaelthielemann/kestrel-core/client'
3
3
  import { PREVIEW_TOKEN_QUERY, PREVIEW_FALLBACK_PATH } from '../../../public/app/utils/preview-protocol'
4
4
  import { resolveCollectionEditor } from '../utils/editor-registry'
5
5
  import { editorFormContextKey } from '../utils/editor-form-context'
@@ -23,7 +23,7 @@ const f = useEditForm({ collection: props.collection, id: props.id, locale: prop
23
23
  // below, where it originally lived) so `defineExpose` can also run before that same await.
24
24
  const {
25
25
  formError, saving, submit, dirty, editorType, pageLike, hasStatus,
26
- savedStatus, undo, redo, canUndo, canRedo,
26
+ savedStatus, undo, redo, canUndo, canRedo, quarantined,
27
27
  } = f
28
28
 
29
29
  // Editor → field-widget context. MUST run BEFORE the top-level `await` below — after an await the setup
@@ -91,21 +91,20 @@ const liveStatus = usePublishStatus({
91
91
  // Carries "poll the live status on mount" across the create→navigate remount (see usePendingPublishPoll).
92
92
  const pendingPoll = usePendingPublishPoll()
93
93
 
94
- // ---- publish + external preview -------------------------------------------------------------------
95
94
  // Saving persists to the DB and leaves the live site alone; publishing writes the static file(s). The two
96
95
  // are separate buttons because they are separate decisions (ADR-0008) — you can save a page a dozen times
97
96
  // while the published version stays exactly as it was.
98
97
  const publishing = ref(false)
99
98
  const previewOpening = ref(false)
100
- // `output.publishOnSave` turns the split off again (a save republishes, as before 2.0) — then a Publish
101
- // button would have nothing to do, so the hosts hide it. Reported by /api/publish-status; unknown (a
99
+ // `output.publishOnSave` turns the split off again (a save republishes) — then a Publish
100
+ // button would have nothing to do, so the hosts hide it. Reported by /api/publishStatus; unknown (a
102
101
  // never-saved record, which does not fetch it) reads as the default, i.e. the button stays.
103
102
  const publishOnSave = computed(() => liveStatus.data.value.publishOnSave === true)
104
103
  const canPublish = computed(() => !publishOnSave.value)
105
104
 
106
105
  /** Save (a publish publishes what you SEE), promote a draft, then write the output. */
107
106
  async function publish() {
108
- if (publishing.value || saving.value) return
107
+ if (publishing.value || saving.value || quarantined.value) return
109
108
  publishing.value = true
110
109
  try {
111
110
  // Pressing Publish IS the publish intent, so a draft is promoted here rather than sending the user to
@@ -159,7 +158,7 @@ async function openPreview() {
159
158
  const tab = window.open('', '_blank')
160
159
  previewOpening.value = true
161
160
  try {
162
- const ticket = await $fetch<{ token: string }>('/api/preview', {
161
+ const ticket = await $fetch<{ token: string }>('/api/createPreview', {
163
162
  method: 'POST',
164
163
  body: {
165
164
  collection: props.collection,
@@ -193,6 +192,7 @@ async function openPreview() {
193
192
  defineExpose({
194
193
  dirty, saving, undo, redo, canUndo, canRedo, hasStatus, status, savedStatus, previewUrl, pageLike,
195
194
  live: liveStatus.data, recordTitle: heading, publish, publishing, canPublish, openPreview, previewOpening,
195
+ quarantined,
196
196
  })
197
197
 
198
198
  await f.ready
@@ -202,6 +202,7 @@ await f.ready
202
202
  const bodyComponent = computed(() => resolveCollectionEditor(editorType.value))
203
203
 
204
204
  async function onSave() {
205
+ if (quarantined.value) return
205
206
  // Read BEFORE the save: a successful submit rebaselines, after which the saved status is the new one.
206
207
  const wasPublished = savedStatus.value === 'published'
207
208
  const r = await submit()
@@ -232,7 +233,7 @@ async function onCopyFrom(loc: string) {
232
233
  const tid = f.translations.value[loc]
233
234
  if (typeof tid !== 'number') return
234
235
  try {
235
- const row = await $fetch<Record<string, unknown>>(`/api/${props.collection}/${tid}`)
236
+ const row = await $fetch<Record<string, unknown>>(`/api/${props.collection}/readOne/${tid}`)
236
237
  f.applyFrom(row)
237
238
  } catch {
238
239
  f.formError.value = t('localeBar.copyFailed', { locale: loc.toUpperCase() })
@@ -268,16 +269,27 @@ onUnmounted(() => {
268
269
  the submit event and block onSave with a browser bubble — wrongly so on proxy inputs whose text
269
270
  doesn't mirror the model (combobox search boxes stay empty although records are selected). -->
270
271
  <form :id="formId" class="editor" novalidate @submit.prevent="onSave">
271
- <p v-if="formError" class="editor__error" role="alert">{{ formError }}</p>
272
+ <!-- Quarantined: the row failed its select schema, so its real fields are unknown — no body,
273
+ no actions, nothing to submit. KestrelUiAlert renders role="alert" for variant="error". -->
274
+ <KestrelUiAlert v-if="quarantined" variant="error" class="editor__quarantine">
275
+ <template #title>
276
+ <KestrelUiIcon name="triangle-alert" :size="16" />
277
+ {{ t('editor.quarantined.title') }}
278
+ </template>
279
+ {{ t('editor.quarantined.desc') }}
280
+ </KestrelUiAlert>
281
+ <template v-else>
282
+ <p v-if="formError" class="editor__error" role="alert">{{ formError }}</p>
272
283
 
273
- <!-- The editor body is chosen by the collection's `editor` type (fields · blocks · an extension). -->
274
- <component :is="bodyComponent" v-if="bodyComponent" />
275
- <KestrelEditorUnsupported v-else :editor="editorType" />
284
+ <!-- The editor body is chosen by the collection's `editor` type (fields · blocks · an extension). -->
285
+ <component :is="bodyComponent" v-if="bodyComponent" />
286
+ <KestrelEditorUnsupported v-else :editor="editorType" />
276
287
 
277
- <div v-if="actions" class="editor__actions">
278
- <KestrelUiButton type="submit" variant="primary" icon="check" :loading="saving">{{ t('common.save') }}</KestrelUiButton>
279
- <KestrelUiButton type="button" variant="secondary" icon="x" :disabled="saving" @click="emit('cancel')">{{ t('common.cancel') }}</KestrelUiButton>
280
- </div>
288
+ <div v-if="actions" class="editor__actions">
289
+ <KestrelUiButton type="submit" variant="primary" icon="check" :loading="saving">{{ t('common.save') }}</KestrelUiButton>
290
+ <KestrelUiButton type="button" variant="secondary" icon="x" :disabled="saving" @click="emit('cancel')">{{ t('common.cancel') }}</KestrelUiButton>
291
+ </div>
292
+ </template>
281
293
  </form>
282
294
  </template>
283
295
 
@@ -297,6 +309,13 @@ onUnmounted(() => {
297
309
  color: var(--color-danger);
298
310
  font-size: var(--text-sm);
299
311
  }
312
+ &__quarantine {
313
+ .ui-alert__title {
314
+ display: inline-flex;
315
+ align-items: center;
316
+ gap: var(--space-2);
317
+ }
318
+ }
300
319
  &__actions {
301
320
  display: flex;
302
321
  gap: var(--space-3);
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
- import type { SerializedCollection } from '../../../core/server/utils/serialize-collection'
2
+ import type { SerializedCollection } from '@michaelthielemann/kestrel-core'
3
3
  import { resolveLocalized } from '../../../ui/app/utils/localized'
4
+ import { bulkCustomActions, recordCustomActions } from '../utils/collection-ops'
4
5
 
5
6
  // `locale` is set only for a translatable collection: it filters the list to that content locale and
6
7
  // shows a locale switcher. New records / row links carry it so they open in the same locale.
@@ -33,7 +34,7 @@ const { sort, page, perPage, filter, effectiveQuery, setSort, setPage, clampPage
33
34
  const filterDraft = useListFilterDraft({ available, filterableColumns, filter, setFilter })
34
35
  const activeFilters = filterDraft.activeFilters
35
36
 
36
- const { rows, total, error, totalPages, fetchRows } = useListRows({
37
+ const { rows, total, quarantinedCount, error, totalPages, fetchRows } = useListRows({
37
38
  collection,
38
39
  effectiveQuery,
39
40
  locale: () => props.locale,
@@ -50,15 +51,23 @@ const { selected, allSelected, headerIndeterminate, toggleRow, toggleAll, clear:
50
51
  useListSelection(rows)
51
52
 
52
53
  // Row action = 1 id, bulk bar = N ids; the server does the batching.
53
- const { busy: opsBusy, error: opsError, deleteOpen, deleteReport, askDelete, confirmDelete, duplicate: onDuplicate, setStatus } =
54
+ const { busy: opsBusy, error: opsError, deleteOpen, deleteReport, askDelete, confirmDelete, duplicate: onDuplicate, setStatus, runAction } =
54
55
  useListBatchActions(collection, fetchRows)
55
56
 
57
+ // Schema-driven actions beyond the built-in delete/duplicate/publish: a consumer's `definePipeline` shows
58
+ // up here without any UI code.
59
+ const bulkActions = computed(() => bulkCustomActions(props.schema.actions ?? []))
60
+ const rowActions = computed(() => recordCustomActions(props.schema.actions ?? []))
61
+
56
62
  // Toolbar popovers (filter / columns), including the outside-click and Escape-restores-focus behaviour.
57
63
  const { container: toolsRef, open: openPanel, toggle: togglePanel } = useToolbarPanel<'filter' | 'columns'>()
58
64
 
59
65
  // Polite status announced after every (re)fetch so filter/sort/page changes report their result count
60
66
  // to screen readers — WCAG 4.1.3 Status Messages.
61
67
  const resultsLabel = computed(() => t(total.value === 1 ? 'list.result' : 'list.results', { total: total.value }))
68
+ // Quarantined rows failed their select schema and would otherwise be silent data loss — a chip in
69
+ // the header surfaces the count whenever it's non-zero.
70
+ const quarantinedLabel = computed(() => t(quarantinedCount.value === 1 ? 'list.quarantinedOne' : 'list.quarantinedCount', { n: quarantinedCount.value }))
62
71
  // The ONE permanent live region. A live region only announces MUTATIONS to a node already in the tree, so
63
72
  // the selection count is folded in here (mutating this region's text) rather than toggling a second
64
73
  // role="status" node into the DOM — inserting an already-populated live node is typically not announced,
@@ -133,6 +142,11 @@ await fetchRows()
133
142
  </div>
134
143
  </div>
135
144
 
145
+ <span v-if="quarantinedCount > 0" class="list__quarantine-chip">
146
+ <KestrelUiIcon name="triangle-alert" :size="14" />
147
+ {{ quarantinedLabel }}
148
+ </span>
149
+
136
150
  <NuxtLink :to="`/admin/${collection}/new${localeQuery}`" class="list__new">{{ newLabel }}</NuxtLink>
137
151
  </div>
138
152
 
@@ -141,8 +155,10 @@ await fetchRows()
141
155
  :count="selected.size"
142
156
  :has-status="!!schema.status"
143
157
  :busy="opsBusy"
158
+ :actions="bulkActions"
144
159
  @set-status="(status) => setStatus([...selected], status)"
145
160
  @delete="askDelete([...selected])"
161
+ @run-action="(action) => runAction(action, [...selected])"
146
162
  @clear="clearSelection"
147
163
  />
148
164
 
@@ -173,11 +189,13 @@ await fetchRows()
173
189
  :selected="selected"
174
190
  :all-selected="allSelected"
175
191
  :header-indeterminate="headerIndeterminate"
192
+ :actions="rowActions"
176
193
  @sort="setSort"
177
194
  @toggle-row="toggleRow"
178
195
  @toggle-all="toggleAll"
179
196
  @duplicate="onDuplicate"
180
197
  @delete="askDelete"
198
+ @run-action="(action, id) => runAction(action, [id])"
181
199
  />
182
200
 
183
201
  <KestrelUiEmptyState
@@ -348,6 +366,19 @@ await fetchRows()
348
366
  cursor: pointer;
349
367
  }
350
368
 
369
+ // Quarantine count chip — icon + text (not color alone, WCAG 1.4.1) so it reads even without color.
370
+ &__quarantine-chip {
371
+ display: inline-flex;
372
+ align-items: center;
373
+ gap: var(--space-1);
374
+ padding: var(--space-1) var(--space-2);
375
+ border-radius: var(--radius-sm);
376
+ background: var(--color-danger-soft, var(--color-surface));
377
+ color: var(--color-danger);
378
+ font-size: var(--text-sm);
379
+ font-weight: var(--weight-medium);
380
+ }
381
+
351
382
  &__new {
352
383
  margin-left: auto;
353
384
  padding: var(--space-2) var(--space-4);