@michaelthielemann/kestrel 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (353) hide show
  1. package/README.md +35 -110
  2. package/kestrel.config.ts +1 -1
  3. package/layers/access/server/middleware/00.ip-allowlist.ts +3 -18
  4. package/layers/access/server/middleware/access-guard.ts +25 -23
  5. package/layers/access/server/types.d.ts +1 -2
  6. package/layers/admin/app/components/AdminAccount.vue +1 -1
  7. package/layers/admin/app/components/BlockFields.vue +1 -2
  8. package/layers/admin/app/components/BlockPreview.vue +17 -5
  9. package/layers/admin/app/components/BlocksBody.vue +1 -1
  10. package/layers/admin/app/components/CollectionEditor.vue +35 -16
  11. package/layers/admin/app/components/CollectionList.vue +34 -3
  12. package/layers/admin/app/components/CollectionListBulkBar.vue +17 -3
  13. package/layers/admin/app/components/CollectionListFilterPanel.vue +1 -1
  14. package/layers/admin/app/components/CollectionListPager.vue +1 -1
  15. package/layers/admin/app/components/CollectionListTable.vue +41 -2
  16. package/layers/admin/app/components/EditorStatus.vue +8 -7
  17. package/layers/admin/app/components/LocaleBar.vue +3 -1
  18. package/layers/admin/app/components/PageFields.vue +2 -4
  19. package/layers/admin/app/components/SeoFields.vue +2 -4
  20. package/layers/admin/app/components/SingletonEditor.vue +3 -2
  21. package/layers/admin/app/composables/useAuth.ts +3 -3
  22. package/layers/admin/app/composables/useBlockTree.ts +1 -1
  23. package/layers/admin/app/composables/useBlocks.ts +1 -1
  24. package/layers/admin/app/composables/useCollectionOps.ts +45 -8
  25. package/layers/admin/app/composables/useCollections.ts +1 -2
  26. package/layers/admin/app/composables/useEditForm.ts +35 -17
  27. package/layers/admin/app/composables/useLinkResolver.ts +1 -1
  28. package/layers/admin/app/composables/useListBatchActions.ts +20 -3
  29. package/layers/admin/app/composables/useListColumns.ts +1 -1
  30. package/layers/admin/app/composables/useListFilterDraft.ts +3 -2
  31. package/layers/admin/app/composables/useListRows.ts +7 -2
  32. package/layers/admin/app/composables/useListUrlState.ts +1 -1
  33. package/layers/admin/app/composables/usePublishStatus.ts +2 -2
  34. package/layers/admin/app/layouts/admin.vue +0 -1
  35. package/layers/admin/app/pages/admin/[collection]/[id].vue +5 -4
  36. package/layers/admin/app/pages/admin/index.vue +1 -1
  37. package/layers/admin/app/pages/admin/references.vue +2 -2
  38. package/layers/admin/app/utils/block-tree.ts +1 -1
  39. package/layers/admin/app/utils/collection-ops.ts +28 -3
  40. package/layers/admin/app/utils/edit-form.ts +34 -2
  41. package/layers/admin/app/utils/editor-expose.ts +2 -0
  42. package/layers/admin/app/utils/editor-form-context.ts +1 -2
  43. package/layers/admin/app/utils/field-keys.ts +1 -1
  44. package/layers/admin/app/utils/list-columns.ts +3 -2
  45. package/layers/admin/app/utils/list-query.ts +2 -2
  46. package/layers/admin/app/utils/populate-blocks.ts +2 -2
  47. package/layers/admin/app/utils/reauth.ts +5 -6
  48. package/layers/auth/server/plugins/01.register-auth-pipelines.ts +7 -0
  49. package/layers/collections/server/plugins/01.register.ts +9 -12
  50. package/layers/collections/server/plugins/02.register-relation-populate.ts +5 -7
  51. package/layers/{fields → core}/app/utils/field-factories.ts +1 -1
  52. package/layers/core/modules/auto-discovery/extract-block.ts +3 -3
  53. package/layers/core/modules/auto-discovery/index.ts +68 -11
  54. package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
  55. package/layers/core/modules/auto-discovery/scan.ts +77 -0
  56. package/layers/core/modules/auto-discovery/virtual.d.ts +5 -0
  57. package/layers/core/modules/component-namespace/index.ts +24 -0
  58. package/layers/core/modules/kestrel/index.ts +6 -1
  59. package/layers/core/modules/plugin-order/index.ts +69 -0
  60. package/layers/core/modules/plugin-order/plugin-order.ts +273 -0
  61. package/layers/core/nuxt.config.ts +1 -0
  62. package/layers/core/server/api/[...path].ts +98 -0
  63. package/layers/core/server/plugins/00.config.ts +12 -0
  64. package/layers/core/server/plugins/00.migrate.ts +1 -0
  65. package/layers/core/server/plugins/01.register-introspection-pipeline.ts +5 -0
  66. package/layers/core/server/plugins/01.register-openapi-pipeline.ts +5 -0
  67. package/layers/core/server/plugins/01.register-tooling-pipelines.ts +5 -0
  68. package/layers/core/server/plugins/02.schema-sync.ts +2 -7
  69. package/layers/core/server/plugins/04.outbox-worker.ts +31 -0
  70. package/layers/core/server/plugins/05.reindex-refs.ts +5 -0
  71. package/layers/core/server/routes/__kestrel/dashboard.get.ts +29 -0
  72. package/layers/core/server/tasks/db/migrate-module.ts +83 -0
  73. package/layers/core/server/tasks/db/migrate-revisions.ts +20 -0
  74. package/layers/core/server/tasks/db/migrate.ts +2 -6
  75. package/layers/core/server/utils/server-config.ts +66 -3
  76. package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +7 -0
  77. package/layers/fields/server/plugins/01.register-field-populate.ts +2 -2
  78. package/layers/media/app/components/KestrelImg.vue +3 -3
  79. package/layers/media/app/components/MediaLibrary.vue +2 -2
  80. package/layers/media/app/components/field/Media.vue +1 -1
  81. package/layers/media/app/composables/useMediaResolver.ts +1 -1
  82. package/layers/media/app/composables/useMediaUpload.ts +1 -1
  83. package/layers/media/app/composables/useMediaVariant.ts +2 -2
  84. package/layers/media/app/utils/ai-disclosure.ts +1 -1
  85. package/layers/media/app/utils/library.ts +1 -1
  86. package/layers/media/app/utils/ops.ts +1 -3
  87. package/layers/media/app/utils/picture-model.ts +2 -2
  88. package/layers/media/app/utils/variant-name.ts +1 -1
  89. package/layers/media/server/middleware/ondemand-variants.ts +6 -5
  90. package/layers/media/server/plugins/01.register-media-pipelines.ts +7 -0
  91. package/layers/media/server/plugins/02.register-media.ts +10 -7
  92. package/layers/media/server/plugins/04.variant-capture.ts +12 -8
  93. package/layers/media/server/plugins/05.media-cleanup.ts +5 -0
  94. package/layers/media/server/tasks/media/backfill.ts +3 -4
  95. package/layers/public/app/components/KestrelLink.vue +1 -1
  96. package/layers/public/app/pages/[...slug].vue +1 -1
  97. package/layers/public/app/pages/__kestrel/preview.vue +2 -2
  98. package/layers/public/app/utils/json-ld.ts +1 -1
  99. package/layers/public/app/utils/link-href.ts +1 -1
  100. package/layers/public/app/utils/page-head.ts +1 -1
  101. package/layers/public/modules/deploy-output/index.ts +4 -6
  102. package/layers/public/modules/prerender-routes/index.ts +4 -4
  103. package/layers/public/modules/prerender-routes/routes.ts +1 -1
  104. package/layers/public/modules/prune-media/index.ts +2 -2
  105. package/layers/public/server/delivery-live/serve.ts +111 -0
  106. package/layers/public/server/middleware/delivery-live-catchall.ts +7 -0
  107. package/layers/public/server/pipelines/index.ts +23 -0
  108. package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +14 -0
  109. package/layers/public/server/plugins/01.register-public-pipelines.ts +7 -0
  110. package/layers/public/server/plugins/02.register-links.ts +2 -3
  111. package/layers/public/server/plugins/03.redirects.ts +41 -23
  112. package/layers/public/server/plugins/05.plan-publish.ts +5 -0
  113. package/layers/public/server/plugins/zz.publish.ts +79 -24
  114. package/layers/public/server/routes/llms-full.txt.get.ts +5 -5
  115. package/layers/public/server/routes/llms.txt.get.ts +4 -2
  116. package/layers/public/server/routes/redirects.json.get.ts +2 -2
  117. package/layers/public/server/routes/robots.txt.get.ts +2 -0
  118. package/layers/public/server/routes/sitemap.xml.get.ts +16 -6
  119. package/layers/public/server/tasks/publish/run.ts +9 -4
  120. package/layers/public/server/utils/publish/render-live.ts +26 -0
  121. package/layers/ui/app/components/field/Choice.vue +1 -1
  122. package/layers/ui/app/components/field/Datetime.vue +1 -1
  123. package/layers/ui/app/components/field/Json.vue +1 -2
  124. package/layers/ui/app/components/field/Layout.vue +2 -4
  125. package/layers/ui/app/components/field/Link.vue +1 -1
  126. package/layers/ui/app/components/field/LinkInternalPicker.vue +3 -3
  127. package/layers/ui/app/components/field/Number.vue +2 -2
  128. package/layers/ui/app/components/field/Relation.vue +1 -1
  129. package/layers/ui/app/components/field/Repeater.vue +6 -8
  130. package/layers/ui/app/components/field/Slug.vue +2 -3
  131. package/layers/ui/app/components/field/Text.vue +1 -1
  132. package/layers/ui/app/components/ui/Button.vue +2 -2
  133. package/layers/ui/app/components/ui/ButtonGroup.vue +2 -2
  134. package/layers/ui/app/components/ui/Checkbox.vue +2 -2
  135. package/layers/ui/app/components/ui/Combobox.vue +5 -5
  136. package/layers/ui/app/components/ui/DatePicker.vue +2 -2
  137. package/layers/ui/app/components/ui/DateRangePicker.vue +2 -2
  138. package/layers/ui/app/components/ui/NumberInput.vue +4 -4
  139. package/layers/ui/app/components/ui/Richtext.vue +1 -1
  140. package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
  141. package/layers/ui/app/components/ui/Select.vue +2 -2
  142. package/layers/ui/app/components/ui/TextInput.vue +4 -4
  143. package/layers/ui/app/components/ui/Textarea.vue +2 -2
  144. package/layers/ui/app/components/ui/TimeInput.vue +2 -2
  145. package/layers/ui/app/components/ui/Toasts.vue +3 -3
  146. package/layers/ui/app/composables/useLinkField.ts +1 -1
  147. package/layers/ui/app/composables/useRepeater.ts +1 -1
  148. package/layers/ui/app/i18n/de.ts +7 -7
  149. package/layers/ui/app/i18n/en.ts +7 -39
  150. package/layers/ui/app/utils/field-component.ts +1 -1
  151. package/layers/ui/app/utils/field-registry.ts +1 -1
  152. package/layers/ui/app/utils/icons.ts +2 -6
  153. package/layers/ui/app/utils/localized.ts +1 -1
  154. package/nuxt.config.ts +1 -1
  155. package/package.json +53 -19
  156. package/scripts/consumer-template-ci.mjs +157 -0
  157. package/scripts/coverage-all.mjs +163 -0
  158. package/scripts/dashboard.mjs +89 -0
  159. package/scripts/hash-password.mjs +1 -1
  160. package/scripts/lib/coverage-model.mjs +51 -0
  161. package/scripts/port-graph.mjs +350 -0
  162. package/scripts/typecheck.mjs +11 -0
  163. package/templates/starter/app/blocks/Prose.vue +1 -1
  164. package/layers/access/server/utils/csrf.ts +0 -39
  165. package/layers/access/server/utils/grant-registry.ts +0 -36
  166. package/layers/access/server/utils/guard.ts +0 -44
  167. package/layers/access/server/utils/ip-allowlist.ts +0 -79
  168. package/layers/access/server/utils/policy.ts +0 -98
  169. package/layers/access/server/utils/public-resources.ts +0 -11
  170. package/layers/access/server/utils/render-context.ts +0 -41
  171. package/layers/access/server/utils/require-admin.ts +0 -15
  172. package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
  173. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
  174. package/layers/auth/server/api/auth/login.post.ts +0 -38
  175. package/layers/auth/server/api/auth/logout.post.ts +0 -9
  176. package/layers/auth/server/api/auth/session.get.ts +0 -1
  177. package/layers/auth/server/utils/admin-credential.ts +0 -42
  178. package/layers/auth/server/utils/client-ip.ts +0 -88
  179. package/layers/auth/server/utils/login-throttle.ts +0 -126
  180. package/layers/auth/server/utils/password.ts +0 -52
  181. package/layers/auth/server/utils/session-cookie.ts +0 -44
  182. package/layers/auth/server/utils/session-epoch.ts +0 -96
  183. package/layers/auth/server/utils/session.ts +0 -88
  184. package/layers/collections/server/collections/pages.ts +0 -19
  185. package/layers/collections/server/utils/populate-relations.ts +0 -79
  186. package/layers/core/app/utils/bulk-actions.ts +0 -9
  187. package/layers/core/app/utils/filter-ops.ts +0 -98
  188. package/layers/core/app/utils/list-limits.ts +0 -27
  189. package/layers/core/app/utils/locale-path.ts +0 -12
  190. package/layers/core/app/utils/slugify.ts +0 -16
  191. package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
  192. package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
  193. package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
  194. package/layers/core/server/api/[collection]/[id].get.ts +0 -11
  195. package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
  196. package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
  197. package/layers/core/server/api/[collection]/index.get.ts +0 -24
  198. package/layers/core/server/api/[collection]/index.post.ts +0 -8
  199. package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
  200. package/layers/core/server/api/[collection]/index.put.ts +0 -24
  201. package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
  202. package/layers/core/server/api/[collection]/options.get.ts +0 -18
  203. package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
  204. package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
  205. package/layers/core/server/api/[collection]/translations.get.ts +0 -25
  206. package/layers/core/server/api/collections/[name].get.ts +0 -4
  207. package/layers/core/server/api/collections/index.get.ts +0 -3
  208. package/layers/core/server/api/references/broken.get.ts +0 -9
  209. package/layers/core/server/api/references/referrers.get.ts +0 -24
  210. package/layers/core/server/database/record-refs.ts +0 -20
  211. package/layers/core/server/plugins/03.record-refs.ts +0 -12
  212. package/layers/core/server/schema/bootstrap.ts +0 -41
  213. package/layers/core/server/schema/desired.ts +0 -99
  214. package/layers/core/server/schema/dev-sync.ts +0 -23
  215. package/layers/core/server/schema/dialect.ts +0 -61
  216. package/layers/core/server/schema/diff.ts +0 -110
  217. package/layers/core/server/schema/introspect.ts +0 -68
  218. package/layers/core/server/schema/model.ts +0 -61
  219. package/layers/core/server/schema/render-sqlite.ts +0 -89
  220. package/layers/core/server/schema/sync.ts +0 -201
  221. package/layers/core/server/utils/blocks.ts +0 -6
  222. package/layers/core/server/utils/collection-types.ts +0 -28
  223. package/layers/core/server/utils/crud.ts +0 -578
  224. package/layers/core/server/utils/db.ts +0 -26
  225. package/layers/core/server/utils/defineCollection.ts +0 -213
  226. package/layers/core/server/utils/duplicate.ts +0 -162
  227. package/layers/core/server/utils/field-layout.ts +0 -125
  228. package/layers/core/server/utils/filter-predicate.ts +0 -101
  229. package/layers/core/server/utils/http.ts +0 -50
  230. package/layers/core/server/utils/if-unmodified.ts +0 -13
  231. package/layers/core/server/utils/kestrel-config.ts +0 -451
  232. package/layers/core/server/utils/key-lock.ts +0 -33
  233. package/layers/core/server/utils/locale.ts +0 -53
  234. package/layers/core/server/utils/page-route.ts +0 -58
  235. package/layers/core/server/utils/page-slug.ts +0 -127
  236. package/layers/core/server/utils/picker.ts +0 -82
  237. package/layers/core/server/utils/populate.ts +0 -50
  238. package/layers/core/server/utils/prerender-memo.ts +0 -23
  239. package/layers/core/server/utils/read-capture.ts +0 -37
  240. package/layers/core/server/utils/record-ref-index.ts +0 -200
  241. package/layers/core/server/utils/registry.ts +0 -24
  242. package/layers/core/server/utils/resolve-scope.ts +0 -79
  243. package/layers/core/server/utils/seo.ts +0 -42
  244. package/layers/core/server/utils/serialize-collection.ts +0 -147
  245. package/layers/core/server/utils/sql.ts +0 -2
  246. package/layers/core/server/utils/storage.local.ts +0 -117
  247. package/layers/core/server/utils/storage.s3.ts +0 -197
  248. package/layers/core/server/utils/storage.ts +0 -90
  249. package/layers/core/server/utils/write-effects.ts +0 -40
  250. package/layers/core/server/utils/write-events.ts +0 -31
  251. package/layers/fields/app/utils/condition.ts +0 -71
  252. package/layers/fields/app/utils/define-block.ts +0 -13
  253. package/layers/fields/app/utils/field-constraints.ts +0 -17
  254. package/layers/fields/app/utils/field-meta.ts +0 -33
  255. package/layers/fields/app/utils/field-validate.ts +0 -75
  256. package/layers/fields/app/utils/field-value.ts +0 -7
  257. package/layers/fields/app/utils/richtext-links.ts +0 -48
  258. package/layers/fields/server/api/blocks/index.get.ts +0 -9
  259. package/layers/fields/server/field-registry/index.ts +0 -249
  260. package/layers/fields/server/field-registry/naming.ts +0 -10
  261. package/layers/fields/server/field-registry/sanitize.ts +0 -43
  262. package/layers/fields/server/field-registry/types.ts +0 -19
  263. package/layers/fields/server/utils/block-ids.ts +0 -43
  264. package/layers/fields/server/utils/block-populate.ts +0 -41
  265. package/layers/fields/server/utils/buildCollection.ts +0 -90
  266. package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
  267. package/layers/fields/server/utils/buildTable.ts +0 -113
  268. package/layers/fields/server/utils/conditional-required.ts +0 -25
  269. package/layers/fields/server/utils/defineBlock.ts +0 -86
  270. package/layers/fields/server/utils/defineFieldType.ts +0 -33
  271. package/layers/fields/server/utils/extract-refs.ts +0 -152
  272. package/layers/fields/server/utils/field-populate.ts +0 -68
  273. package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
  274. package/layers/media/server/api/media/[id].delete.ts +0 -18
  275. package/layers/media/server/api/media/[id].get.ts +0 -10
  276. package/layers/media/server/api/media/[id].patch.test.ts +0 -66
  277. package/layers/media/server/api/media/[id].patch.ts +0 -65
  278. package/layers/media/server/api/media/backfill.post.ts +0 -18
  279. package/layers/media/server/api/media/copy.post.ts +0 -7
  280. package/layers/media/server/api/media/delete.post.ts +0 -13
  281. package/layers/media/server/api/media/folders.post.ts +0 -18
  282. package/layers/media/server/api/media/index.get.ts +0 -15
  283. package/layers/media/server/api/media/index.post.ts +0 -173
  284. package/layers/media/server/api/media/library.get.ts +0 -17
  285. package/layers/media/server/api/media/move.post.ts +0 -7
  286. package/layers/media/server/api/media/rename.post.ts +0 -7
  287. package/layers/media/server/api/media/resolve.get.ts +0 -13
  288. package/layers/media/server/collections/media-settings.ts +0 -25
  289. package/layers/media/server/collections/media.ts +0 -43
  290. package/layers/media/server/database/folders.ts +0 -10
  291. package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
  292. package/layers/media/server/schema-tables/folders.ts +0 -4
  293. package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
  294. package/layers/media/server/utils/ai-signal-detect.ts +0 -155
  295. package/layers/media/server/utils/backfill.ts +0 -144
  296. package/layers/media/server/utils/derive.ts +0 -59
  297. package/layers/media/server/utils/folder-paths.ts +0 -42
  298. package/layers/media/server/utils/folders.ts +0 -12
  299. package/layers/media/server/utils/library.ts +0 -91
  300. package/layers/media/server/utils/media-cleanup.ts +0 -27
  301. package/layers/media/server/utils/media-enabled.ts +0 -23
  302. package/layers/media/server/utils/media-ops.ts +0 -118
  303. package/layers/media/server/utils/media-write.ts +0 -13
  304. package/layers/media/server/utils/naming.ts +0 -52
  305. package/layers/media/server/utils/ondemand.ts +0 -69
  306. package/layers/media/server/utils/persist-upload.ts +0 -62
  307. package/layers/media/server/utils/populate.ts +0 -47
  308. package/layers/media/server/utils/record.ts +0 -71
  309. package/layers/media/server/utils/relocate-ops.ts +0 -341
  310. package/layers/media/server/utils/relocate-plan.ts +0 -63
  311. package/layers/media/server/utils/resolve.ts +0 -98
  312. package/layers/media/server/utils/sanitize-svg.ts +0 -12
  313. package/layers/media/server/utils/sniff.ts +0 -68
  314. package/layers/media/server/utils/storage-relocate.ts +0 -139
  315. package/layers/media/server/utils/translations.ts +0 -19
  316. package/layers/media/server/utils/usages.ts +0 -66
  317. package/layers/media/server/utils/variant-capture.ts +0 -70
  318. package/layers/media/server/utils/variants.ts +0 -123
  319. package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
  320. package/layers/public/server/api/links/resolve.get.ts +0 -19
  321. package/layers/public/server/api/preview.get.ts +0 -28
  322. package/layers/public/server/api/preview.post.ts +0 -93
  323. package/layers/public/server/api/publish-status.get.ts +0 -58
  324. package/layers/public/server/api/publish.post.ts +0 -84
  325. package/layers/public/server/api/route.get.ts +0 -54
  326. package/layers/public/server/collections/redirects.ts +0 -75
  327. package/layers/public/server/collections/site.ts +0 -44
  328. package/layers/public/server/database/publish-deps.ts +0 -12
  329. package/layers/public/server/database/publish-status.ts +0 -19
  330. package/layers/public/server/schema-tables/publish-deps.ts +0 -4
  331. package/layers/public/server/schema-tables/publish-status.ts +0 -4
  332. package/layers/public/server/utils/link-resolve.ts +0 -38
  333. package/layers/public/server/utils/llms-full.ts +0 -125
  334. package/layers/public/server/utils/llms.ts +0 -52
  335. package/layers/public/server/utils/page-resolve.ts +0 -208
  336. package/layers/public/server/utils/populate-links.ts +0 -66
  337. package/layers/public/server/utils/preview-token.ts +0 -109
  338. package/layers/public/server/utils/publish/coalesce.ts +0 -22
  339. package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
  340. package/layers/public/server/utils/publish/deps.ts +0 -87
  341. package/layers/public/server/utils/publish/invalidation.ts +0 -225
  342. package/layers/public/server/utils/publish/pending.ts +0 -74
  343. package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
  344. package/layers/public/server/utils/publish/publish-status.ts +0 -72
  345. package/layers/public/server/utils/publish/publisher.ts +0 -362
  346. package/layers/public/server/utils/publish/queue.ts +0 -67
  347. package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
  348. package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
  349. package/layers/public/server/utils/publish/route-for-record.ts +0 -16
  350. package/layers/public/server/utils/publish/route-keys.ts +0 -13
  351. package/layers/public/server/utils/richtext-markdown.ts +0 -260
  352. package/layers/public/server/utils/site-url.ts +0 -40
  353. package/layers/public/server/utils/sitemap.ts +0 -99
@@ -1,4 +1,4 @@
1
- import { localePath } from '../../../core/app/utils/locale-path'
1
+ import { localePath } from '@michaelthielemann/kestrel-core/client'
2
2
 
3
3
  export interface PageHeadImage { src: string; width?: number | null; height?: number | null }
4
4
  export interface PageHeadAlternate { locale: string; path: string }
@@ -1,10 +1,8 @@
1
1
  import { defineNuxtModule } from '@nuxt/kit'
2
- import { createS3Driver } from '../../../core/server/utils/storage.s3'
3
- import { resolveOutputCreds, deployStaticOutput, isEnvTrue, planS3Deploy, readRouteDiscovery } from './deploy-output'
4
- import { resolveKestrel } from '../../../core/server/utils/kestrel-config'
5
- import type { KestrelConfig } from '../../../core/server/utils/kestrel-config'
6
-
7
-
2
+ import { createS3Driver } from '@michaelthielemann/kestrel-core'
3
+ import { resolveOutputCreds, deployStaticOutput, isEnvTrue, planS3Deploy, readRouteDiscovery } from '@michaelthielemann/kestrel-delivery-static'
4
+ import { resolveKestrel } from '@michaelthielemann/kestrel-core'
5
+ import type { KestrelConfig } from '@michaelthielemann/kestrel-core'
8
6
  /**
9
7
  * Ships `nuxt generate`'s static output (`.output/public`) to an S3 bucket when `kestrel.output.driver`
10
8
  * is `s3` — the deploy half of FEATURES "Output target: local directory or S3 bucket". Runs on Nitro's
@@ -3,10 +3,10 @@ import { existsSync } from 'node:fs'
3
3
  import { defineNuxtModule } from '@nuxt/kit'
4
4
  import Database from 'better-sqlite3'
5
5
  import { collectPageRoutes, pageLikeTables } from './discover'
6
- import { localePath } from '../../../core/app/utils/locale-path'
7
- import { recordRouteDiscovery, META_KEYS, type RouteDiscovery } from '../deploy-output/deploy-output'
8
- import { resolveKestrel, type KestrelConfig } from '../../../core/server/utils/kestrel-config'
9
-
6
+ import { localePath, resolveKestrel } from '@michaelthielemann/kestrel-core'
7
+ import type { KestrelConfig } from '@michaelthielemann/kestrel-core'
8
+ import { META_KEYS } from '@michaelthielemann/kestrel-core'
9
+ import { recordRouteDiscovery, type RouteDiscovery } from '@michaelthielemann/kestrel-delivery-static'
10
10
  // Read published paths from EVERY page-like collection straight from the DB at build time so
11
11
  // `nuxt generate` prerenders one HTML file per page (see `collectPageRoutes` for how pageLike tables
12
12
  // are identified). The server's migrate-on-boot hasn't run yet at this point, so a DB that isn't there
@@ -1,4 +1,4 @@
1
- import { localePath } from '../../../core/app/utils/locale-path'
1
+ import { localePath } from '@michaelthielemann/kestrel-core'
2
2
 
3
3
  export interface PageRouteRow {
4
4
  path: string | null
@@ -1,7 +1,7 @@
1
1
  import { defineNuxtModule } from '@nuxt/kit'
2
2
  import Database from 'better-sqlite3'
3
- import { resolveKestrel, type KestrelConfig } from '../../../core/server/utils/kestrel-config'
4
- import { isEnvTrue } from '../deploy-output/deploy-output'
3
+ import { resolveKestrel, type KestrelConfig } from '@michaelthielemann/kestrel-core'
4
+ import { isEnvTrue } from '@michaelthielemann/kestrel-delivery-static'
5
5
  import { pruneUnreferencedMedia, mediaOwnedKeys } from './prune-media'
6
6
 
7
7
  // The set of storage keys the media library owns (originals + derivatives), read straight from the DB at
@@ -0,0 +1,111 @@
1
+ import { getMethod, getRequestHeader, sendRedirect, send, setHeader, setResponseStatus, type H3Event } from 'h3'
2
+ import { isRendererContext } from '@michaelthielemann/kestrel-access'
3
+ import { cacheControlFor, isDeliveryExemptPath, isDeliveryLive } from '@michaelthielemann/kestrel-core'
4
+ import { usePublishingDb, currentSnapshot, htmlKeyForRoute } from '@michaelthielemann/kestrel-publishing'
5
+ import { liveRedirectFor } from '@michaelthielemann/kestrel-delivery-live'
6
+
7
+ // Requests this adapter must never answer, whatever `currentSnapshot` says: the admin app, the pipeline
8
+ // API, Nitro's own internals (tasks — e.g. the manual `publish:run` trigger), built assets, and the
9
+ // editor's preview ticket flow. `META_ROUTES` mirrors `@michaelthielemann/kestrel-core`'s own `META_KEYS`
10
+ // (sitemap/robots/llms/redirects) — those are rendered by their own routes, not snapshots. Every prefix
11
+ // is slash-terminated (`normalizeExemptPrefix`'s own rule): a bare `/admin` would also match `/admin-guide`
12
+ // — a DIFFERENT, publishable route this adapter must still serve from its own snapshot.
13
+ const EXEMPT_PREFIXES = ['/api/', '/admin/', '/_nuxt/', '/_nitro/', '/__kestrel/']
14
+ const META_ROUTES = new Set(['/sitemap.xml', '/robots.txt', '/llms.txt', '/llms-full.txt', '/redirects.json'])
15
+
16
+ // EXEMPT_PREFIXES is slash-terminated the same way (see the const's own comment), so the bare form gets
17
+ // the same exact-path treatment `isDeliveryExemptPath` applies to the configured entries — matching only
18
+ // `startsWith(prefix)` left it unmatched, contrary to `deliveryExempt`'s own documented examples
19
+ // (`/health`, `/feed.xml`).
20
+ function isExempt(path: string): boolean {
21
+ if (META_ROUTES.has(path)) return true
22
+ if (path === '/admin') return true // the bare admin root has no trailing segment to anchor a '/admin/' prefix match
23
+ if (EXEMPT_PREFIXES.some((prefix) => path === prefix.slice(0, -1) || path.startsWith(prefix))) return true
24
+ return isDeliveryExemptPath(path)
25
+ }
26
+
27
+ // A route's own trailing slash is not a distinct route: '/route/' must resolve the same snapshot as
28
+ // '/route'. Only the FINAL slash is stripped (one normalization pass), so a prefix match like '/admin/'
29
+ // against '/admin/foo/' still sees '/admin/foo' and still starts with '/admin/'. The root path is its
30
+ // own fixed point and is never touched.
31
+ function normalizeTrailingSlash(path: string): string {
32
+ return path !== '/' && path.endsWith('/') ? path.slice(0, -1) : path
33
+ }
34
+
35
+ // RFC 7232 §3.2: If-None-Match may carry a comma-separated list of ETags, and a bare `*` matches any
36
+ // current representation (always a match, as long as the resource exists — checked by the caller).
37
+ // This route's ETag is always strong (`"<fingerprint>"`), so a weak validator (`W/"..."`) never matches
38
+ // under strong comparison, per the same section.
39
+ function ifNoneMatchHits(header: string | undefined, etag: string): boolean {
40
+ if (!header) return false
41
+ const trimmed = header.trim()
42
+ if (trimmed === '*') return true
43
+ return trimmed.split(',').some((candidate) => candidate.trim() === etag)
44
+ }
45
+
46
+ /**
47
+ * The delivery-live catch-all: under `delivery: 'live'`, answers public content traffic exclusively from
48
+ * `published_snapshots` — a configured redirect, 200 with the snapshot's own html, or 404. Wired as a
49
+ * Nitro MIDDLEWARE (see `middleware/delivery-live-catchall.ts`) rather than a route, since a middleware
50
+ * runs ahead of Nuxt's page router; middleware return values are ignored by h3, so this writes the
51
+ * response itself and returns whether it did.
52
+ *
53
+ * Returns `false` (unhandled — the caller falls through to ordinary page rendering) for everything the
54
+ * adapter must not touch: `delivery: 'static'` (the default), a response already sent, a non-GET/HEAD
55
+ * request, a non-content path (admin/api/assets/meta/consumer-exempted routes), or the producer's OWN
56
+ * internal render fetch. That last one is `isRendererContext()`/`import.meta.prerender` — the SAME
57
+ * unforgeable AsyncLocalStorage marker `pipelines/route.ts` and `00.ip-allowlist.ts` already trust for
58
+ * exactly this "is this our own in-process render, not an external request" question (`render-live.ts`'s
59
+ * `renderRouteLive` runs its `localFetch` inside `runAsRenderer`, which sets it) — NOT a request header,
60
+ * which any external client could forge. Without this bypass, a publish's own render would loop back
61
+ * into this catch-all and get served its route's CURRENT (about-to-be-superseded) snapshot instead of
62
+ * ever reaching a real page render, so a
63
+ * publish under `'live'` could never record a fresh one.
64
+ */
65
+ export async function serveLiveRoute(event: H3Event): Promise<boolean> {
66
+ if (event.node?.res?.writableEnded) return false
67
+ if (!isDeliveryLive()) return false
68
+ if (import.meta.prerender === true || isRendererContext()) return false
69
+ const method = getMethod(event)
70
+ if (method !== 'GET' && method !== 'HEAD') return false
71
+ const path = normalizeTrailingSlash(event.path.split('?')[0] || '/')
72
+ if (isExempt(path)) return false
73
+
74
+ // Live mode is the app's own edge — there is no separate njs/edge script in front of it the way the
75
+ // static topology has, so a configured redirect has to be honoured here too, before the 404.
76
+ const redirect = liveRedirectFor(path)
77
+ if (redirect) {
78
+ await sendRedirect(event, redirect.target, redirect.status)
79
+ return true
80
+ }
81
+
82
+ const snapshot = currentSnapshot(usePublishingDb().db, path)
83
+ if (!snapshot) {
84
+ setResponseStatus(event, 404)
85
+ await send(event, 'Not Found', 'text/plain')
86
+ return true
87
+ }
88
+
89
+ // The fingerprint is a sha256 of the exact rendered bytes (publisher.ts), so a STRONG ETag is exact:
90
+ // any two responses sharing it are byte-identical, never merely semantically equivalent. Cache-Control
91
+ // mirrors delivery-static's own policy for an html key (`cacheControlFor`/`REVALIDATE_CACHE`) so the
92
+ // two adapters agree on cacheability for the same snapshot. NOTE: this ETag/304 state lives only in
93
+ // `published_snapshots`, read fresh per request — there is no separate in-process cache to invalidate,
94
+ // so this is safe across multiple server processes sharing the DB.
95
+ const etag = `"${snapshot.fingerprint}"`
96
+ const cacheControl = cacheControlFor(htmlKeyForRoute(path))
97
+ setHeader(event, 'etag', etag)
98
+ if (cacheControl) setHeader(event, 'cache-control', cacheControl)
99
+
100
+ if (ifNoneMatchHits(getRequestHeader(event, 'if-none-match'), etag)) {
101
+ setResponseStatus(event, 304)
102
+ await send(event, undefined)
103
+ return true
104
+ }
105
+
106
+ const body = Buffer.from(snapshot.html, 'utf8')
107
+ setHeader(event, 'content-length', body.byteLength)
108
+ setResponseStatus(event, 200)
109
+ await send(event, method === 'HEAD' ? undefined : snapshot.html, 'text/html; charset=utf-8')
110
+ return true
111
+ }
@@ -0,0 +1,7 @@
1
+ import { serveLiveRoute } from '../delivery-live/serve'
2
+
3
+ // Runs on every request; only intercepts when serveLiveRoute's own gate says so (delivery-live/ stays
4
+ // the single owner of when/how this adapter answers — see its TSDoc).
5
+ export default defineEventHandler(async (event) => {
6
+ await serveLiveRoute(event)
7
+ })
@@ -0,0 +1,23 @@
1
+ import type { PipelineDef } from '@michaelthielemann/kestrel-core'
2
+ import {
3
+ buildPublishPipelines,
4
+ buildPublishRunsPipelines,
5
+ buildLinkPipelines,
6
+ buildPreviewPipelines,
7
+ buildRoutePipelines,
8
+ } from '@michaelthielemann/kestrel-publishing'
9
+ import { buildDeliveryLivePipelines } from '@michaelthielemann/kestrel-delivery-live'
10
+
11
+ /** The public layer's own API surface: the render entry, the preview ticket pair, the publish action and
12
+ * its status read, the orchestrator's admin progress read, the editor's link resolver, and the
13
+ * delivery-live read API. All collection-less, so each lives at `/api/<name>`. */
14
+ export function buildPublicPipelines(): PipelineDef[] {
15
+ return [
16
+ ...buildRoutePipelines(),
17
+ ...buildPreviewPipelines(),
18
+ ...buildPublishPipelines(),
19
+ ...buildPublishRunsPipelines(),
20
+ ...buildLinkPipelines(),
21
+ ...buildDeliveryLivePipelines(),
22
+ ]
23
+ }
@@ -0,0 +1,14 @@
1
+ import type Database from 'better-sqlite3'
2
+ import { useDb } from '@michaelthielemann/kestrel-core'
3
+ import { ensureSnapshotTriggers } from '@michaelthielemann/kestrel-publishing'
4
+
5
+ // Layer order puts every core-layer plugin (00.migrate, 02.schema-sync) before this layer's, so
6
+ // `published_snapshots` already exists here for either provisioning path (a committed migration inside
7
+ // Kestrel's own repo, or the schema engine for a consumer layer) — except a boot before EITHER has run,
8
+ // which `ensureSnapshotTriggers` itself tolerates (warns, does not crash). Skipped during prerender, same
9
+ // as `02.schema-sync`: no DDL runs in that phase.
10
+ export default defineNitroPlugin(() => {
11
+ if (import.meta.prerender) return
12
+ const client = (useDb() as unknown as { $client: Database.Database }).$client
13
+ ensureSnapshotTriggers(client)
14
+ })
@@ -0,0 +1,7 @@
1
+ import { registerPipeline } from '@michaelthielemann/kestrel-core'
2
+ import { buildPublicPipelines } from '../pipelines'
3
+
4
+ // Registration only — nothing resolves a pipeline here; the registry is read on the first request.
5
+ export default defineNitroPlugin(() => {
6
+ for (const def of buildPublicPipelines()) registerPipeline(def)
7
+ })
@@ -1,6 +1,5 @@
1
- import { registerFieldPopulator } from '../../../core/server/utils/populate'
2
- import { buildLinkFieldPopulators } from '../utils/populate-links'
3
- import { resolveInternalHref } from '../utils/link-resolve'
1
+ import { registerFieldPopulator } from '@michaelthielemann/kestrel-core'
2
+ import { buildLinkFieldPopulators, resolveInternalHref } from '@michaelthielemann/kestrel-publishing'
4
3
 
5
4
  // Resolve internal links to the target record's localized public path at read time (page-like targets;
6
5
  // external/email/tel pass through). The resolver IS status-gated: a link to a DRAFT resolves to nothing and
@@ -1,37 +1,55 @@
1
- import { registerWriteEffect } from '../../../core/server/utils/write-effects'
2
- import { outputDriver } from '../utils/publish/publisher'
3
- import { REDIRECTS_COLLECTION, REDIRECTS_FIELD, writeRedirectsArtifact } from '../utils/publish/redirects-artifact'
1
+ import { Effect } from 'effect'
2
+ import { collectionOf, registerAfterStep } from '@michaelthielemann/kestrel-core'
3
+ import type { StepDef } from '@michaelthielemann/kestrel-core'
4
+ import { outputDriver, REDIRECTS_COLLECTION, REDIRECTS_FIELD, writeRedirectsArtifact } from '@michaelthielemann/kestrel-publishing'
5
+ import { invalidateLiveRedirects } from '@michaelthielemann/kestrel-delivery-live'
4
6
 
5
7
  /**
6
8
  * Publish `redirects.json` on every save of the redirects singleton — deliberately decoupled from the
7
9
  * publish cycle, so a redirect goes live without a full republish (and without an editor pressing
8
10
  * Publish, which after ADR-0008 they otherwise would have to).
9
11
  *
10
- * A write EFFECT, not a write listener: the bus swallows throws, and a save that reports success while
11
- * the edge still serves the old rules is exactly the failure this feature cannot have. Registered in
12
- * every environment, unlike the publish plugin — that one is dev-gated because a dev publish would write
13
- * Vite-dev HTML with no hashed `_nuxt`, a CONTENT problem this artifact does not have (it is
12
+ * A CRITICAL after-step, not a non-critical one: a save that reports success while the edge still serves
13
+ * the old rules is exactly the failure this feature cannot have its failure becomes the save's response.
14
+ * Registered in every environment, unlike the publish plugin — that one is dev-gated because a dev publish
15
+ * would write Vite-dev HTML with no hashed `_nuxt`, a CONTENT problem this artifact does not have (it is
14
16
  * `JSON.stringify` of DB rows, byte-identical either way).
15
17
  *
16
18
  * Where it lands is `output.dir` / the S3 prefix — the same target the publisher uses. With the classic
17
19
  * `output.auto: false` + `driver: 'local'` build model that is NOT the deployed tree, so there a
18
20
  * redirect goes live with the next `nuxt generate` instead; documented under Redirects in
19
- * `docs/static-output.md`.
21
+ * `docs/guide/redirects.md`.
20
22
  */
21
23
  export default defineNitroPlugin(() => {
22
- registerWriteEffect(async ({ def, row }) => {
23
- if (def.name !== REDIRECTS_COLLECTION) return
24
- try {
25
- await writeRedirectsArtifact(row[REDIRECTS_FIELD], outputDriver())
26
- } catch (error) {
27
- // The row is already committed (CRUD holds no transaction), so the only honest message is
28
- // "saved, but not live". Short and ASCII on purpose: h3 truncates nothing but the reason phrase is
29
- // the wire's, and the cause goes in `data` where nothing can strip it.
30
- throw createError({
31
- statusCode: 500,
32
- statusMessage: 'Redirects saved, but publishing redirects.json failed. Save again to retry.',
33
- data: { cause: (error as Error)?.message ?? String(error) },
34
- })
35
- }
36
- })
24
+ const writeRedirectsStep: StepDef = {
25
+ name: 'writeRedirects',
26
+ when: (ctx) => collectionOf(ctx).def.name === REDIRECTS_COLLECTION,
27
+ whenLabel: `collection is "${REDIRECTS_COLLECTION}"`,
28
+ fn: (ctx) => Effect.gen(function* () {
29
+ const row = ctx.output as Record<string, unknown>
30
+ // The row is already committed regardless of what the static artifact write below does (see its
31
+ // own comment) the live catch-all reads straight from the row, so its cache drops unconditionally,
32
+ // in lockstep with every save.
33
+ invalidateLiveRedirects()
34
+ // A JS try/catch does NOT observe an Effect failure crossing a `yield*` — only Effect's own
35
+ // combinators do so the survivor rewrap has to be Effect.catchAll, not a wrapping try/catch.
36
+ yield* Effect.tryPromise({ try: () => writeRedirectsArtifact(row[REDIRECTS_FIELD], outputDriver()), catch: (error) => error }).pipe(
37
+ Effect.catchAll((error) => Effect.sync(() => {
38
+ // The row is already committed (CRUD holds no transaction), so the only honest message is
39
+ // "saved, but not live". Short and ASCII on purpose: h3 truncates nothing but the reason phrase is
40
+ // the wire's, and the cause goes in `data` where nothing can strip it. `savedUpdatedAt` lets the
41
+ // editor rebaseline on this failure the same way it would on success — it only rebaselines then.
42
+ throw createError({
43
+ statusCode: 500,
44
+ statusMessage: 'Redirects saved, but publishing redirects.json failed. Save again to retry.',
45
+ data: {
46
+ cause: (error as Error)?.message ?? String(error),
47
+ savedUpdatedAt: new Date(row.updatedAt as string | number).getTime(),
48
+ },
49
+ })
50
+ })),
51
+ )
52
+ }),
53
+ }
54
+ registerAfterStep({ step: writeRedirectsStep, critical: true, ops: ['updateOne'] })
37
55
  })
@@ -0,0 +1,5 @@
1
+ import { registerPlanPublish } from '@michaelthielemann/kestrel-publishing'
2
+
3
+ export default defineNitroPlugin(() => {
4
+ registerPlanPublish()
5
+ })
@@ -1,28 +1,57 @@
1
- import { createPublishQueue } from '../utils/publish/queue'
2
- import { setPublishRuntime } from '../utils/publish/publish-runtime'
3
- import { DepsStore } from '../utils/publish/deps'
4
- import { createSqlitePersistence } from '../utils/publish/deps-persistence'
5
- import { outputDriver, publishInvalidation } from '../utils/publish/publisher'
6
- import { classifyWrite, planWrite } from '../utils/publish/invalidation'
7
- import { registerWriteListener } from '../../../core/server/utils/write-events'
1
+ import { renderRouteLive } from '../utils/publish/render-live'
2
+ import {
3
+ createPublishQueue,
4
+ setPublishRuntime,
5
+ DepsStore,
6
+ createSqlitePersistence,
7
+ usePublishingDb,
8
+ outputDriver,
9
+ publishInvalidation,
10
+ resumePublishRuns,
11
+ startPublishRun,
12
+ setRenderRouteLive,
13
+ type PublishDelivery,
14
+ } from '@michaelthielemann/kestrel-publishing'
8
15
 
9
16
  /**
10
- * The in-server publish runtime (the production operation model). Wires three things, gated on
11
- * `output.auto` and skipped in dev (a dev publish would write Vite-dev HTML without hashed `_nuxt`):
17
+ * The in-server publish runtime (the production operation model). Wires the queue + deps index and gates
18
+ * whether they exist at all, on `output.auto`, skipped in dev (a dev publish would write Vite-dev HTML
19
+ * without hashed `_nuxt`):
12
20
  *
13
- * 1. AUTO-TRIGGER every content write classifies into an invalidation and enqueues an incremental
14
- * republish (a debounced/coalesced/single-flight queue; precise routes via the captured deps index).
21
+ * 1. THE RUNTIME ITSELF `setPublishRuntime` publishes the queue + deps index via `usePublishRuntime()`.
22
+ * The `planPublish` outbox handler (`handlers/plan-publish.ts`, registered independently by
23
+ * `05.plan-publish.ts`) is the actual AUTO-TRIGGER: it classifies every content/media write into an
24
+ * invalidation and enqueues it, but only when this plugin has actually set a runtime — a no-op
25
+ * otherwise (dev, or `output.auto` off), so the gate lives here even though the trigger does not.
15
26
  * 2. BOOT PUBLISH — a full publish on startup resyncs this build's `_nuxt` + records every route's deps.
16
27
  * DETACHED (`runNitroPlugins` is synchronous + unawaited; `localFetch` is already wired before plugins
17
- * run + before the server listens), so it never blocks boot.
28
+ * run + before the server listens), so it never blocks boot. A FULL run (boot's own, and the
29
+ * reconciler's below) goes through `startPublishRun`, so its command -> snapshot -> delivery ->
30
+ * done sequence is persisted to `publish_runs` and survives a crash — an incremental (tag) run a
31
+ * content/media write triggers stays a direct `publishInvalidation` call, since it is not part of
32
+ * the owned sequence.
18
33
  * 3. RECONCILER — an optional periodic full publish (`output.reconcileMinutes`) self-heals any missed
19
- * invalidation.
34
+ * invalidation, also tracked as an owned run. The self-heal is real, not just "re-render everything
35
+ * and hope": every full/reconcile publish live-renders each published route and compares its
36
+ * fingerprint against the currently recorded snapshot (`publisher.ts`'s `PublishMode: 'reconcile'`,
37
+ * ADR-0013's amendment) — a mismatch (a missed invalidation, a template/component deploy) records +
38
+ * delivers the fresh render; a match costs nothing beyond the render itself.
39
+ *
40
+ * Crash-resume (ADR-0025): `resumePublishRuns` marks any run a crash left `running` as `failed` — it does
41
+ * NOT redeliver — and runs BEFORE the boot enqueue below, which fires unconditionally regardless of what
42
+ * resume found. The queue remains the ONLY caller that ever invokes a delivery; resume never bypasses its
43
+ * single-flight guard.
20
44
  */
21
45
  export default defineNitroPlugin(() => {
22
46
  if (import.meta.dev) return
23
47
  const output = (useRuntimeConfig().kestrel as { output?: { auto?: boolean; publishOnSave?: boolean; reconcileMinutes?: number; verbose?: boolean } }).output
24
48
  if (!output?.auto) return
25
49
 
50
+ // Wired explicitly here (not a module-load side effect) — see tasks/publish/run.ts's own comment for
51
+ // why: it removes any dependency on import-order/instance-identity between this plugin's own
52
+ // `@michaelthielemann/kestrel-publishing` import and the one `publisher.ts`'s internals resolve. Idempotent.
53
+ setRenderRouteLive(renderRouteLive)
54
+
26
55
  // `output.verbose`: on top of the one-line summary, itemise each incremental republish with a
27
56
  // timestamped per-route line (rendered / pruned) for traceability. Off by default. (A queued FULL run
28
57
  // — boot / reconciler — is a bulk resync and stays a summary; publishInvalidation returns no per-route
@@ -33,9 +62,35 @@ export default defineNitroPlugin(() => {
33
62
  const driver = outputDriver()
34
63
  // Durable deps: rehydrate `route → tags` from SQLite so the boot full-publish below can prune routes
35
64
  // that were unpublished/deleted while the server was down (staleRoutes over a non-empty tracked set).
36
- const deps = new DepsStore(createSqlitePersistence(useDb()))
65
+ const deps = new DepsStore(createSqlitePersistence(usePublishingDb().db))
66
+
67
+ // The delivery port for a FULL run: the real renderer, driven the same way a queued full invalidation
68
+ // always was. `startPublishRun` persists the command -> snapshot -> delivery -> done sequence around
69
+ // this call; this closure is only the "delivery" step itself.
70
+ const fullDelivery: PublishDelivery = {
71
+ deliver: async () => {
72
+ const { rendered, pruned, counts } = await publishInvalidation({ type: 'full' }, driver, deps)
73
+ if (counts.rendered === 0 && counts.pruned === 0) return
74
+ console.info(`[kestrel] published ${counts.rendered} route(s) (pruned ${counts.pruned})`)
75
+ if (verbose) {
76
+ for (const route of rendered) console.info(`[kestrel] ${stamp()} rendered ${route}`)
77
+ for (const route of pruned) console.info(`[kestrel] ${stamp()} pruned ${route}`)
78
+ }
79
+ },
80
+ }
81
+
37
82
  const queue = createPublishQueue({
38
83
  run: async (inv) => {
84
+ if (inv.type === 'full') {
85
+ const result = await startPublishRun(fullDelivery)
86
+ // Rethrow on a recorded failure so the queue's own retry-on-failure fires (its `.catch()` calls
87
+ // `onError` below and re-queues the batch) — `startPublishRun` itself never throws for a delivery
88
+ // failure (it resolves with the outcome as data), so restoring that retry has to happen here.
89
+ // `id: 0` is the synthetic placeholder `startPublishRun`'s missing-table fallback returns (no real
90
+ // row backs it) — label it distinctly rather than a misleading "#0".
91
+ if (result.status === 'failed') throw new Error(`publish run #${result.id === 0 ? 'untracked' : result.id} failed: ${result.error}`)
92
+ return
93
+ }
39
94
  const { rendered, pruned, counts } = await publishInvalidation(inv, driver, deps)
40
95
  if (counts.rendered === 0 && counts.pruned === 0) return
41
96
  // Incremental runs name the routes; a queued full run (the reconciler) prints counts only.
@@ -53,19 +108,19 @@ export default defineNitroPlugin(() => {
53
108
  // a write-driven prune are serialized by one single-flight run rather than racing each other.
54
109
  setPublishRuntime({ queue, deps })
55
110
 
56
- // A save writes the DB, not the site: by default only what a save must still REMOVE from the output (an
57
- // unpublished or deleted record's page) passes through, and everything renderable waits for an explicit
58
- // publish — see ADR-0008. `output.publishOnSave` restores the pre-2.0 model, where every write republished.
59
- const publishOnSave = output.publishOnSave ?? false
60
- registerWriteListener(({ def, before, after }) => {
61
- queue.enqueue(planWrite(classifyWrite(def, before, after, primaryLocale(), prefixPrimaryLocale()), publishOnSave))
62
- })
63
-
64
111
  // Boot publish goes THROUGH the queue (not a direct publishFull) so it shares the single-flight guard:
65
112
  // otherwise a boot full-publish could overlap a write-triggered incremental or the reconciler, and both
66
113
  // runs share the module-level variant accumulator (corrupting the registry) and can interleave
67
- // render/prune (leaving an unpublished page live). The queue serializes them.
68
- queue.enqueue({ type: 'full' })
114
+ // render/prune (leaving an unpublished page live). The queue serializes them, and it is the ONLY
115
+ // AUTOMATIC caller that ever invokes a delivery — `resumePublishRuns` below just updates rows, so there
116
+ // is nothing left to race it. (The `publish:run` Nitro task — `tasks/publish/run.ts` — is a documented
117
+ // exception: an operator-triggered manual run that calls `publishFull` directly, outside this queue.)
118
+ //
119
+ // Resume runs first, awaited: it is a plain DB update (never invokes delivery, so it cannot itself
120
+ // crash the process the way a redelivered render could), which is what guarantees the boot enqueue below
121
+ // always fires — a resume that instead redelivered and crashed again the same way would leave the row
122
+ // `running` forever and never reach this line, an infinite boot loop that never makes progress.
123
+ void resumePublishRuns().finally(() => queue.enqueue({ type: 'full' }))
69
124
 
70
125
  const mins = output.reconcileMinutes ?? 0
71
126
  if (mins > 0) setInterval(() => queue.enqueue({ type: 'full' }), mins * 60_000).unref?.()
@@ -1,9 +1,9 @@
1
1
  import { getTableColumns } from 'drizzle-orm'
2
- import { localePath } from '../../../core/app/utils/locale-path'
3
- import { LLMS_FULL_HEADING_OFFSET } from '../utils/llms-full'
4
- import type { LlmsFullSection, LlmsFullPage } from '../utils/llms-full'
5
- import type { BuiltCollection } from '../../../core/server/utils/collection-types'
6
-
2
+ import { localePath, useDb, primaryLocale, prefixPrimaryLocale, allCollections } from '@michaelthielemann/kestrel-core'
3
+ import type { BuiltCollection } from '@michaelthielemann/kestrel-core'
4
+ import { isPubliclyReadable, publicReadableResources } from '@michaelthielemann/kestrel-access'
5
+ import { LLMS_FULL_HEADING_OFFSET, buildLlmsFullTxt, recordMarkdown, collectionHeading, siteBaseUrl, siteName, siteDescription, llmsFullEnabled } from '@michaelthielemann/kestrel-publishing'
6
+ import type { LlmsFullSection, LlmsFullPage } from '@michaelthielemann/kestrel-publishing'
7
7
  // The long form of llms.txt: every published, indexable page's full Markdown body in one document, so an
8
8
  // answer engine can ground on the site without crawling it. Same registry-driven public set and the same
9
9
  // status/noindex filters as `llms.txt` and the sitemap (single source: the auth policy).
@@ -1,6 +1,8 @@
1
1
  import { getTableColumns } from 'drizzle-orm'
2
- import { localePath } from '../../../core/app/utils/locale-path'
3
- import type { LlmsSection, LlmsEntry } from '../utils/llms'
2
+ import { localePath, useDb, primaryLocale, prefixPrimaryLocale, allCollections } from '@michaelthielemann/kestrel-core'
3
+ import { isPubliclyReadable, publicReadableResources } from '@michaelthielemann/kestrel-access'
4
+ import { collectionHeading, buildLlmsTxt, siteBaseUrl, siteName, siteDescription } from '@michaelthielemann/kestrel-publishing'
5
+ import type { LlmsSection, LlmsEntry } from '@michaelthielemann/kestrel-publishing'
4
6
 
5
7
  // Generates an `llms.txt` (llmstxt.org): the site name + description, then one section per pageLike
6
8
  // collection listing its published, indexable records — the SAME registry-driven public set the sitemap
@@ -1,6 +1,6 @@
1
1
  import { eq } from 'drizzle-orm'
2
- import { compilePublishableRedirects, serializeRedirects } from '../utils/publish/redirect-rules'
3
- import { REDIRECTS_COLLECTION, REDIRECTS_FIELD } from '../utils/publish/redirects-artifact'
2
+ import { getCollection, useDb } from '@michaelthielemann/kestrel-core'
3
+ import { compilePublishableRedirects, serializeRedirects, REDIRECTS_COLLECTION, REDIRECTS_FIELD } from '@michaelthielemann/kestrel-publishing'
4
4
 
5
5
  /**
6
6
  * The redirect artifact the edge polls. A save publishes it directly (see `plugins/03.redirects.ts`);
@@ -1,3 +1,5 @@
1
+ import { siteBaseUrl, buildRobots, llmsFullEnabled } from '@michaelthielemann/kestrel-publishing'
2
+
1
3
  export default defineEventHandler((event) => {
2
4
  const base = siteBaseUrl()
3
5
  setHeader(event, 'content-type', 'text/plain; charset=utf-8')
@@ -1,8 +1,15 @@
1
1
  import { getTableColumns } from 'drizzle-orm'
2
- import { localePath } from '../../../core/app/utils/locale-path'
2
+ import { localePath, useDb, primaryLocale, prefixPrimaryLocale, allCollections, isDeliveryLive } from '@michaelthielemann/kestrel-core'
3
+ import { isPubliclyReadable, publicReadableResources } from '@michaelthielemann/kestrel-access'
4
+ import { currentRoutes, usePublishingDb, buildSitemap, withHreflang, siteBaseUrl } from '@michaelthielemann/kestrel-publishing'
5
+ import type { SitemapCandidate } from '@michaelthielemann/kestrel-publishing'
3
6
 
4
7
  // Lists every published, indexable page-like record (across all pageLike collections) as a
5
- // sitemap. Filters status/noindex itself, so it is safe to serve publicly and to prerender.
8
+ // sitemap. Filters status/noindex itself, so it is safe to serve publicly and to prerender. Under
9
+ // `delivery: 'live'`, a route must ALSO have a current, non-retracted snapshot — the row's own
10
+ // status/noindex fields and the snapshot store are two independent "is this reachable" facts (a direct
11
+ // snapshot retraction leaves the row untouched, see `@michaelthielemann/kestrel-publishing`'s `server/db/snapshots.ts`), and this route is what a crawler
12
+ // actually finds, so both must agree before a route is listed.
6
13
  export default defineEventHandler((event) => {
7
14
  const db = useDb()
8
15
  const base = siteBaseUrl()
@@ -17,10 +24,11 @@ export default defineEventHandler((event) => {
17
24
  const primary = primaryLocale()
18
25
  const prefixPrimary = prefixPrimaryLocale()
19
26
  const candidates: SitemapCandidate[] = []
27
+ // One set lookup for the whole candidate list instead of one currentSnapshot query per candidate.
28
+ const liveRoutes = isDeliveryLive() ? new Set(currentRoutes(usePublishingDb().db)) : null
20
29
 
21
- // A sitemap must only advertise publicly-reachable URLs. The public render path (anonymous access
22
- // policy, prerender seed, catch-all) now renders every pageLike collection, so the sitemap tracks the
23
- // SAME registry-driven public set (single source: the auth policy) rather than diverging from it.
30
+ // A sitemap must only advertise publicly-reachable URLs, so it tracks the SAME registry-driven
31
+ // public set the public render path uses (single source: the auth policy).
24
32
  const pub = publicReadableResources()
25
33
  for (const c of allCollections()) {
26
34
  if (!c.def.pageLike) continue
@@ -52,8 +60,10 @@ export default defineEventHandler((event) => {
52
60
  const seo = (row.seo ?? {}) as { noindex?: boolean }
53
61
  if (seo.noindex) continue
54
62
  const locale = (row.locale as string | undefined) ?? primary
63
+ const route = localePath(path, locale, primary, prefixPrimary)
64
+ if (liveRoutes && !liveRoutes.has(route)) continue
55
65
  candidates.push({
56
- loc: base + localePath(path, locale, primary, prefixPrimary),
66
+ loc: base + route,
57
67
  lastmod: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : undefined,
58
68
  locale,
59
69
  group: (row.translationGroup as string | undefined) ?? null,
@@ -1,6 +1,5 @@
1
- import { publishFull, outputDriver } from '../../utils/publish/publisher'
2
- import { DepsStore } from '../../utils/publish/deps'
3
- import { createSqlitePersistence } from '../../utils/publish/deps-persistence'
1
+ import { renderRouteLive } from '../../utils/publish/render-live'
2
+ import { publishFull, outputDriver, DepsStore, createSqlitePersistence, usePublishingDb, setRenderRouteLive } from '@michaelthielemann/kestrel-publishing'
4
3
 
5
4
  /**
6
5
  * The shared publish engine, exposed as a Nitro task. Renders every published page (+ sitemap/robots)
@@ -17,9 +16,15 @@ import { createSqlitePersistence } from '../../utils/publish/deps-persistence'
17
16
  export default defineTask({
18
17
  meta: { name: 'publish:run', description: 'Render all published pages to the configured static output (HTML + _nuxt + assets)' },
19
18
  async run() {
19
+ // Wired explicitly here (not a module-load side effect): Nitro dev re-evaluates a task's module graph
20
+ // per invocation for hot-reload, which can give this call and `publisher.ts`'s a differently-scoped
21
+ // `@michaelthielemann/kestrel-publishing` instance if the wiring only ran once, earlier, at a boot-time import — calling
22
+ // the setter fresh, synchronously, right before the only call that needs it removes that dependency on
23
+ // import-order/instance-identity entirely. Idempotent: safe to call before every run.
24
+ setRenderRouteLive(renderRouteLive)
20
25
  // The same durable store the boot-publish plugin uses, so a manually-triggered run also prunes
21
26
  // routes that left the published set and records deps for later incremental publishes.
22
- const deps = new DepsStore(createSqlitePersistence(useDb()))
27
+ const deps = new DepsStore(createSqlitePersistence(usePublishingDb().db))
23
28
  const result = await publishFull(outputDriver(), deps)
24
29
  console.info(`[kestrel] publish:run rendered ${result.rendered} route(s), pruned ${result.pruned}`)
25
30
  return { result }
@@ -0,0 +1,26 @@
1
+ import { runAsRenderer } from '@michaelthielemann/kestrel-access'
2
+
3
+ /** Render a public route via the in-process server, returning its HTML buffer (200) or a null body with the
4
+ * HTTP status, so the caller can tell a broken page (5xx — a server-error RESPONSE, not a throw) from an
5
+ * expected non-200 (a draft / unpublish race → 404 / redirect). The PRODUCER's own render primitive — the
6
+ * delivery-static side never calls this; see `@michaelthielemann/kestrel-delivery-static`'s `render-route.ts`. `@michaelthielemann/kestrel-publishing`
7
+ * never imports this directly (a package cannot reach `useNitroApp`) — each real
8
+ * entry point (`zz.publish.ts`, `tasks/publish/run.ts`) instead calls `setRenderRouteLive(renderRouteLive)`
9
+ * itself, right before the call that needs it (see either file's own comment for why NOT a module-load
10
+ * side effect: Nitro dev can re-evaluate a task's module graph per invocation). */
11
+ export async function renderRouteLive(route: string): Promise<{ body: Buffer | null; status: number }> {
12
+ // Run as the renderer principal so the nested $fetch('/api/route') passes the access guard (it is not a
13
+ // public endpoint). The ALS context propagates through localFetch → the page render → the nested fetch.
14
+ // useNitroApp is imported LAZILY (not via the Nitro auto-import) on purpose: a static import gives this
15
+ // module an edge to nitropack's app.mjs, which both defines useNitroApp AND loads the plugin registry
16
+ // that reaches this file (via zz.publish) — a build-time circular dependency. A dynamic import is a chunk
17
+ // boundary, so that cycle disappears from the build graph; it's the same nitroApp singleton either way
18
+ // (Rollup bundles the literal specifier; useNitroApp is only CALLED here at publish time, long after boot).
19
+ const { useNitroApp } = await import('nitropack/runtime')
20
+ // Every consumer that needs to know "is this OUR OWN in-process render" (delivery-live's catch-all,
21
+ // media's variant-capture scan) reads `isRendererContext()`/`import.meta.prerender` instead of a header —
22
+ // both are set by `runAsRenderer` below and an external request can't forge either.
23
+ const res = await runAsRenderer(() => useNitroApp().localFetch(route, { method: 'GET' }))
24
+ if (res.status !== 200) return { body: null, status: res.status }
25
+ return { body: Buffer.from(await res.arrayBuffer()), status: res.status }
26
+ }