@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,384 +0,0 @@
1
- import { readdirSync, readFileSync } from 'node:fs'
2
- import { join, relative, sep, extname } from 'node:path'
3
- import { setTimeout as sleepFor } from 'node:timers/promises'
4
- import { resolveS3Settings } from '../../../core/server/utils/kestrel-config'
5
- import type { StorageDriver } from '../../../core/server/utils/storage'
6
-
7
- const MIME: Record<string, string> = {
8
- // markup / scripts / data
9
- '.html': 'text/html; charset=utf-8',
10
- '.css': 'text/css; charset=utf-8',
11
- '.js': 'text/javascript; charset=utf-8',
12
- '.mjs': 'text/javascript; charset=utf-8',
13
- '.json': 'application/json; charset=utf-8',
14
- '.map': 'application/json; charset=utf-8',
15
- '.xml': 'application/xml; charset=utf-8',
16
- '.txt': 'text/plain; charset=utf-8',
17
- '.csv': 'text/csv; charset=utf-8',
18
- '.md': 'text/markdown; charset=utf-8',
19
- '.wasm': 'application/wasm',
20
- '.pdf': 'application/pdf',
21
- '.webmanifest': 'application/manifest+json',
22
- // images
23
- '.svg': 'image/svg+xml',
24
- '.ico': 'image/x-icon',
25
- '.png': 'image/png',
26
- '.jpg': 'image/jpeg',
27
- '.jpeg': 'image/jpeg',
28
- '.gif': 'image/gif',
29
- '.webp': 'image/webp',
30
- '.avif': 'image/avif',
31
- '.apng': 'image/apng',
32
- '.bmp': 'image/bmp',
33
- // fonts
34
- '.woff': 'font/woff',
35
- '.woff2': 'font/woff2',
36
- '.ttf': 'font/ttf',
37
- '.otf': 'font/otf',
38
- '.eot': 'application/vnd.ms-fontobject',
39
- // audio / video
40
- '.mp3': 'audio/mpeg',
41
- '.ogg': 'audio/ogg',
42
- '.wav': 'audio/wav',
43
- '.m4a': 'audio/mp4',
44
- '.mp4': 'video/mp4',
45
- '.webm': 'video/webm',
46
- '.mov': 'video/quicktime',
47
- }
48
-
49
- /** MIME type for a static output file, inferred from its extension; octet-stream when unknown. A
50
- * pre-compressed sibling (`app.js.br`) carries the encoding in `Content-Encoding`, so its type is the
51
- * underlying asset's — strip a trailing `.br`/`.gz` before the lookup. Only strip it when a
52
- * `Content-Encoding` will actually be sent (`hasEncoding`, default true for existing callers): a
53
- * standalone archive with no such header must keep its own extension, or it ships mislabelled as the
54
- * underlying (uncompressed) type — e.g. a browser trying to parse raw gzip bytes as JSON. */
55
- export function contentTypeFor(filename: string, hasEncoding = true): string {
56
- const base = hasEncoding ? filename.replace(/\.(?:br|gz)$/i, '') : filename
57
- return MIME[extname(base).toLowerCase()] ?? 'application/octet-stream'
58
- }
59
-
60
- /** The `Content-Encoding` for a precompressed sidecar (`app.js.br` beside `app.js`), or undefined for a
61
- * standalone archive with no uncompressed base — tagging that would make a browser transparently decode
62
- * genuine content and corrupt it. `siblingNames` are the file names in the same directory. */
63
- export function precompressedEncoding(filename: string, siblingNames: string[]): 'br' | 'gzip' | undefined {
64
- const m = /\.(br|gz)$/.exec(filename)
65
- if (!m || !siblingNames.includes(filename.slice(0, m.index))) return undefined
66
- return m[1] === 'br' ? 'br' : 'gzip'
67
- }
68
-
69
- const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'
70
- const REVALIDATE_CACHE = 'public, max-age=0, must-revalidate'
71
-
72
- /** Artifacts served at a LITERAL key (not `<path>/index.html`) and rendered from the live DB rather than
73
- * copied from the build. One list, because each of them has to be handled the same way in four places:
74
- * re-rendered on every publish, excluded from the build-asset mirror (a stale copy must never overwrite
75
- * a fresh one), seeded into the prerender routes, and cached as revalidate-always below.
76
- * `llms-full.txt` is opt-in (`kestrel.seo.llmsFull`) and simply renders to a 404 when off — it stays in
77
- * this list either way, so the rules that are about the FILENAME hold regardless of the flag; only the
78
- * prerender seeding, which asks for the ROUTE, has to consult it. */
79
- export const META_KEYS = ['sitemap.xml', 'robots.txt', 'llms.txt', 'llms-full.txt', 'redirects.json'] as const
80
-
81
- export function isMetaKey(key: string): boolean {
82
- return (META_KEYS as readonly string[]).includes(key)
83
- }
84
-
85
- /**
86
- * `Cache-Control` for a static-output key, or `undefined` for no explicit policy. Content-hashed
87
- * `_nuxt/` assets get a year + `immutable` (the hash is the cache key — new content ⇒ new URL). HTML and
88
- * the `META_KEYS` artifacts live at *stable* URLs whose content changes on any deploy, so they get
89
- * `max-age=0, must-revalidate` (cacheable but always revalidated) — `redirects.json` especially, since a
90
- * cached copy keeps serving withdrawn redirects. Everything else (favicons, fonts, un-hashed media) is
91
- * left to the host default.
92
- */
93
- export function cacheControlFor(key: string): string | undefined {
94
- // Nuxt's app manifest lives at a STABLE _nuxt URL but its content (the buildId) changes every build, so it
95
- // must revalidate — else the immutable rule below pins it and clients never detect a new deploy.
96
- if (key === '_nuxt/builds/latest.json') return REVALIDATE_CACHE
97
- if (key === '_nuxt' || key.startsWith('_nuxt/')) return IMMUTABLE_CACHE
98
- const base = key.split('/').pop() ?? key
99
- if (base.endsWith('.html') || isMetaKey(base)) return REVALIDATE_CACHE
100
- return undefined
101
- }
102
-
103
- /** A boolean env flag is on for any of the common truthy spellings (case-insensitive, trimmed). */
104
- export function isEnvTrue(value: string | undefined): boolean {
105
- const t = value?.trim().toLowerCase()
106
- return t === 'true' || t === '1' || t === 'yes' || t === 'on'
107
- }
108
-
109
- /**
110
- * Decide what the deploy hook should do, kept pure so the trigger guard is unit-testable. Returns
111
- * `'skip'` when this isn't a static `nuxt generate` — a plain `nuxt build` (or dev) must never deploy,
112
- * even with `driver: s3` configured — and when `output.auto` hands publishing to the running server
113
- * (see `autoPublish`). For a real (non-dry-run) static generate it throws when the
114
- * bucket or `KESTREL_S3_*` credentials are missing, so a misconfigured deploy fails the generate
115
- * loudly instead of warning and exiting 0 with a stale live bucket. A dry-run needs neither.
116
- */
117
- /** Strip leading/trailing slashes so prefixes compare on bare path segments. */
118
- const normPrefix = (p: string): string => p.replace(/^\/+|\/+$/g, '')
119
-
120
- /**
121
- * Do two S3 key prefixes share keyspace on a path-segment boundary — i.e. is one equal to, or nested
122
- * under, the other? Catches both nesting directions: a prune of `a` reaches everything under `a/b`, and
123
- * a prune of `a/b` reaches the `a/b/…` slice of media stored under `a`. A pure-string collision like
124
- * `site` vs `siteother` is *not* an overlap. Empty prefixes never overlap (handled by their own guards).
125
- */
126
- function prefixesOverlap(a: string, b: string): boolean {
127
- const x = normPrefix(a)
128
- const y = normPrefix(b)
129
- if (!x || !y) return false
130
- return x === y || x.startsWith(`${y}/`) || y.startsWith(`${x}/`)
131
- }
132
-
133
- export function planS3Deploy(opts: {
134
- isStaticGenerate: boolean
135
- dryRun: boolean
136
- bucket: string
137
- accessKeyId: string
138
- secretAccessKey: string
139
- prefix?: string
140
- /**
141
- * `output.auto` — the running server publishes to this bucket/prefix itself. Build-time route seeding is
142
- * then disabled (a prerendered file would shadow the live route), so `.output/public` holds only what
143
- * Nitro's crawler reached: shipping it would reconcile the server's live pages, sitemap and robots away.
144
- */
145
- autoPublish?: boolean
146
- /** Bucket the S3 media driver writes live uploads to (empty when media isn't on S3 — then irrelevant). */
147
- mediaBucket?: string
148
- /** Key prefix of those live media objects, to keep the reconcile from deleting them. */
149
- mediaPrefix?: string
150
- }): 'skip' | 'deploy' {
151
- if (!opts.isStaticGenerate) return 'skip'
152
- if (opts.autoPublish) return 'skip'
153
- if (!opts.dryRun) {
154
- const missing: string[] = []
155
- if (!opts.bucket) missing.push('bucket (KESTREL_OUTPUT_S3_BUCKET)')
156
- if (!opts.accessKeyId) missing.push('KESTREL_OUTPUT_S3_ACCESS_KEY_ID / KESTREL_S3_ACCESS_KEY_ID')
157
- if (!opts.secretAccessKey) missing.push('KESTREL_OUTPUT_S3_SECRET_ACCESS_KEY / KESTREL_S3_SECRET_ACCESS_KEY')
158
- if (missing.length) {
159
- throw new Error(
160
- `[kestrel] output.driver=s3 but ${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} missing`
161
- + ' — refusing to finish `nuxt generate` with a broken S3 deploy.',
162
- )
163
- }
164
- // The deploy always reconciles (deletes every remote object under the prefix not in this generate —
165
- // output ≡ DB). With an empty prefix that's the whole bucket root, which may hold the media bucket's
166
- // objects too. Refuse rather than risk wiping it: an S3 deploy must target a dedicated prefix.
167
- if (!opts.prefix) {
168
- throw new Error(
169
- '[kestrel] output.s3.prefix is empty — refusing to deploy (the always-on reconcile would delete the'
170
- + ' whole bucket root, and a shared/media bucket could be wiped). Set KESTREL_OUTPUT_S3_PREFIX to a'
171
- + ' prefix dedicated to the generated site.',
172
- )
173
- }
174
- // Same bucket as the S3 media driver AND overlapping prefixes → the reconcile would delete live uploads.
175
- if (opts.mediaBucket && opts.mediaBucket === opts.bucket
176
- && opts.mediaPrefix && prefixesOverlap(opts.prefix, opts.mediaPrefix)) {
177
- throw new Error(
178
- `[kestrel] the output reconcile would delete live media: output prefix "${opts.prefix}" overlaps the`
179
- + ` S3 media prefix "${opts.mediaPrefix}" in the same bucket "${opts.bucket}". Give the generated`
180
- + ' site its own bucket or a non-overlapping KESTREL_OUTPUT_S3_PREFIX.',
181
- )
182
- }
183
- }
184
- return 'deploy'
185
- }
186
-
187
- export interface OutputTarget {
188
- driver: 'local' | 's3'
189
- /** Non-secret S3 settings; credentials are resolved separately by `resolveOutputCreds`. */
190
- s3: { bucket: string; region: string; endpoint: string; prefix: string }
191
- }
192
-
193
- type OutputConfig = { driver?: string; s3?: { bucket?: string; region?: string; endpoint?: string; prefix?: string } }
194
- const clean = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
195
-
196
- /**
197
- * Resolve where `nuxt generate`'s static output is shipped: a local directory (default) or an S3
198
- * bucket. Precedence per setting: `KESTREL_OUTPUT_*` env → config → default (the documented Kestrel
199
- * order, same as the runtime publisher). Pure, so the precedence is unit-testable; secrets are resolved
200
- * by `resolveOutputCreds`, never here.
201
- */
202
- export function resolveOutputTarget(out: OutputConfig = {}, env: Record<string, string | undefined> = {}): OutputTarget {
203
- const driver = (clean(env.KESTREL_OUTPUT_DRIVER) ?? clean(out.driver))?.toLowerCase() === 's3' ? 's3' : 'local'
204
- // Same non-secret resolver media uses, under the KESTREL_OUTPUT_S3_* prefix (own bucket, own creds).
205
- return { driver, s3: resolveS3Settings(out.s3, env, 'KESTREL_OUTPUT_S3') }
206
- }
207
-
208
- /**
209
- * S3 credentials for the output deploy: the output-specific `KESTREL_OUTPUT_S3_*`, falling back to the
210
- * shared media `KESTREL_S3_*` so a single S3 account "just works" — identical to the runtime publisher's
211
- * resolution. Required keys default to '' (not undefined) so `planS3Deploy` detects a missing credential.
212
- */
213
- export function resolveOutputCreds(env: Record<string, string | undefined> = {}): { accessKeyId: string; secretAccessKey: string; sessionToken?: string } {
214
- return {
215
- accessKeyId: clean(env.KESTREL_OUTPUT_S3_ACCESS_KEY_ID) ?? clean(env.KESTREL_S3_ACCESS_KEY_ID) ?? '',
216
- secretAccessKey: clean(env.KESTREL_OUTPUT_S3_SECRET_ACCESS_KEY) ?? clean(env.KESTREL_S3_SECRET_ACCESS_KEY) ?? '',
217
- sessionToken: clean(env.KESTREL_OUTPUT_S3_SESSION_TOKEN) ?? clean(env.KESTREL_S3_SESSION_TOKEN),
218
- }
219
- }
220
-
221
- /** Every file under `dir` as `{ abs, key }`, the key being its POSIX-relative path (stable order). */
222
- function listFiles(dir: string): Array<{ abs: string; key: string }> {
223
- return readdirSync(dir, { recursive: true, withFileTypes: true })
224
- .filter((e) => e.isFile())
225
- .map((e) => {
226
- const abs = join(e.parentPath, e.name)
227
- return { abs, key: relative(dir, abs).split(sep).join('/') }
228
- })
229
- }
230
-
231
- /**
232
- * A `*.gz`/`*.br` file is a pre-compressed sidecar (from `nitro.compressPublicAssets`) when its
233
- * uncompressed base is also in the output. On a plain static S3 host these are dead weight — the host
234
- * does no `Accept-Encoding` negotiation, so nobody fetches `index.html.gz`, and storing it *as*
235
- * `index.html` with `Content-Encoding` would collide with the real file — so the deploy skips them.
236
- * A standalone `*.gz` archive (no base file) is genuine content and is kept.
237
- */
238
- export function isCompressedSidecar(key: string, all: Set<string>): boolean {
239
- const m = /\.(?:gz|br)$/.exec(key)
240
- return m ? all.has(key.slice(0, m.index)) : false
241
- }
242
-
243
- export interface DeployResult { pruned: number; keys: string[] }
244
-
245
- /**
246
- * What build-time route discovery enumerated for this generate, plus WHY that enumeration is known to be
247
- * short of the site's pages (`incomplete` unset ⇒ it is complete). The runtime publisher's
248
- * `{ routes, failed }` is the same contract: an enumeration that came up short must never be the
249
- * authority for what gets deleted.
250
- */
251
- export interface RouteDiscovery { routes: string[]; incomplete?: string }
252
-
253
- interface DiscoveryHost { _kestrelRouteDiscovery?: RouteDiscovery }
254
-
255
- /** The prerender and deploy modules are loaded separately and share no module scope — but they are handed
256
- * the same Nuxt instance, so that object is what carries the signal from the one to the other. */
257
- export function recordRouteDiscovery(nuxt: object, discovery: RouteDiscovery): void {
258
- (nuxt as DiscoveryHost)._kestrelRouteDiscovery = discovery
259
- }
260
-
261
- /** `undefined` ⇒ discovery never reported anything, which is not itself evidence of a degraded build. */
262
- export function readRouteDiscovery(nuxt: object): RouteDiscovery | undefined {
263
- return (nuxt as DiscoveryHost)._kestrelRouteDiscovery
264
- }
265
-
266
- /** The deploy needs `put` always, and `list`+`delete` for the reconcile; both are optional on the driver. */
267
- type DeployDriver = Pick<StorageDriver, 'put'> & Partial<Pick<StorageDriver, 'list' | 'delete'>>
268
-
269
- export interface DeployOptions {
270
- dryRun?: boolean
271
- log?: (msg: string) => void
272
- /** Max operations in flight at once (default 8). Bounded so a large tree never opens thousands of sockets. */
273
- concurrency?: number
274
- /** Total attempts per operation before giving up (default 3); retries ride out transient S3 errors. */
275
- retries?: number
276
- /** Sleep between retries (exponential backoff); injectable so tests run without real delay. */
277
- sleep?: (ms: number) => Promise<void>
278
- /**
279
- * Why a build step REPORTED that this output tree is short of pages (routes that failed to prerender,
280
- * route discovery that could not read the DB). Set ⇒ upload but never reconcile: pages missing from the
281
- * input are indistinguishable from pages removed from the CMS, so the prune would delete live ones.
282
- * Only a step's own failure report belongs here: the SHAPE of the output cannot tell a degraded build
283
- * from a small site, so inferring it would strand the pages an editor deleted.
284
- */
285
- incomplete?: string
286
- }
287
-
288
- /** Run `task` over `items` with at most `concurrency` in flight at once. */
289
- async function runPool<T>(items: T[], concurrency: number, task: (item: T) => Promise<void>): Promise<void> {
290
- let next = 0
291
- const worker = async (): Promise<void> => {
292
- while (next < items.length) {
293
- const i = next++
294
- await task(items[i])
295
- }
296
- }
297
- await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker))
298
- }
299
-
300
- /** HTTP statuses the S3 driver surfaces in its error message (e.g. `S3 put failed (403) for x`). */
301
- function statusFromError(err: unknown): number | undefined {
302
- const m = /\((\d{3})\)/.exec((err as Error)?.message ?? '')
303
- return m ? Number(m[1]) : undefined
304
- }
305
-
306
- /**
307
- * A permanent client error — bad/forbidden credentials, missing bucket, denied access (400/401/403/404).
308
- * These never become successful on a later attempt, so retrying only delays the inevitable loud failure.
309
- */
310
- export function isPermanentError(err: unknown): boolean {
311
- const s = statusFromError(err)
312
- return s === 400 || s === 401 || s === 403 || s === 404
313
- }
314
-
315
- /** Retry `fn` with exponential backoff up to `retries` total attempts, surfacing the last error. */
316
- async function withRetry(fn: () => Promise<void>, label: string, retries: number, sleep: (ms: number) => Promise<void>, log?: (msg: string) => void): Promise<void> {
317
- for (let attempt = 1; ; attempt++) {
318
- try {
319
- await fn()
320
- return
321
- } catch (err) {
322
- // A permanent 4xx can't be ridden out — re-throw at once so the deploy fails loudly, not slowly.
323
- if (isPermanentError(err) || attempt >= retries) throw err
324
- log?.(`retry ${attempt}/${retries - 1} for ${label}: ${(err as Error).message}`)
325
- await sleep(100 * 2 ** (attempt - 1))
326
- }
327
- }
328
- }
329
-
330
- /**
331
- * Upload every file under `dir` to `driver`, keyed by its POSIX-relative path, with a content type
332
- * inferred from the extension — the S3 equivalent of `aws s3 sync` (overwrites). Then reconciles: deletes
333
- * remote objects under the prefix that this generate didn't produce (`--delete`), so pages removed from
334
- * the CMS stop being served (output ≡ DB, no toggle). The reconcile is skipped when the driver can't
335
- * list+delete and when the caller flagged the tree `incomplete` — a build step reported it is short of
336
- * pages, so their absence says nothing about the CMS. Uploads still run then: shipping the pages that did
337
- * render is safe, deleting on their absence is not.
338
- * Uploads and deletes run with bounded concurrency and each is retried with
339
- * backoff, so a transient 5xx/throttle/dropped-connection no longer aborts the deploy mid-way and
340
- * half-updates the bucket; only an operation that fails every attempt rejects (true rollback isn't
341
- * possible on S3). With `dryRun`, walks/lists and reports without writing.
342
- */
343
- export async function deployStaticOutput(
344
- dir: string,
345
- driver: DeployDriver,
346
- opts: DeployOptions = {},
347
- ): Promise<DeployResult> {
348
- const concurrency = Math.max(1, opts.concurrency ?? 8)
349
- const retries = Math.max(1, opts.retries ?? 3)
350
- const sleep = opts.sleep ?? ((ms: number) => sleepFor(ms))
351
- const all = listFiles(dir)
352
- const allKeys = new Set(all.map((f) => f.key))
353
- const files = all.filter((f) => !isCompressedSidecar(f.key, allKeys))
354
- const skipped = all.length - files.length
355
- if (skipped) opts.log?.(`skipping ${skipped} pre-compressed sidecar(s) (.gz/.br)`)
356
- const keys = files.map((f) => f.key)
357
-
358
- if (!opts.dryRun) {
359
- // `files` already excludes pre-compressed sidecars (above), so anything ending `.gz`/`.br` here is a
360
- // standalone archive shipped with no `Content-Encoding` — keep its own extension in the type lookup.
361
- await runPool(files, concurrency, (f) =>
362
- withRetry(() => driver.put(f.key, readFileSync(f.abs), contentTypeFor(f.key, false), { cacheControl: cacheControlFor(f.key) }), f.key, retries, sleep, opts.log))
363
- }
364
- opts.log?.(`${opts.dryRun ? 'Would deploy' : 'Deployed'} ${keys.length} file(s)`)
365
-
366
- // Reconcile (output ≡ DB) — skipped when the driver lacks list/delete (e.g. a put-only target) and when
367
- // a build step reported that this generate is short of pages.
368
- let pruned = 0
369
- if (!driver.list || !driver.delete) {
370
- opts.log?.('driver cannot list/delete — skipping reconcile')
371
- } else if (opts.incomplete) {
372
- opts.log?.(`skipping reconcile — ${opts.incomplete}; the missing pages would read as stale and be deleted`)
373
- } else {
374
- const keep = new Set(keys)
375
- const stale = (await driver.list()).filter((k) => !keep.has(k))
376
- if (!opts.dryRun) {
377
- const del = driver.delete
378
- await runPool(stale, concurrency, (k) => withRetry(() => del(k), `delete ${k}`, retries, sleep, opts.log))
379
- pruned = stale.length
380
- }
381
- opts.log?.(`${opts.dryRun ? 'Would prune' : 'Pruned'} ${stale.length} stale object(s)`)
382
- }
383
- return { pruned, keys }
384
- }
@@ -1,19 +0,0 @@
1
- import { resolveInternalHref } from '../../utils/link-resolve'
2
-
3
- // Batch-resolve internal link refs (`?refs=collection:id,collection:id`) to public hrefs for the admin
4
- // block preview. Mirrors the read-path populator, status gate included; only resolved refs are returned,
5
- // so the client renders '#' for a target that is missing or still a draft — same as the generated site.
6
- export default defineEventHandler((event) => {
7
- const refs = String(getQuery(event).refs ?? '').split(',').map((s) => s.trim()).filter(Boolean)
8
- const data: { collection: string; id: number; href: string }[] = []
9
- for (const ref of refs) {
10
- const sep = ref.lastIndexOf(':')
11
- if (sep <= 0) continue
12
- const collection = ref.slice(0, sep)
13
- const id = Number(ref.slice(sep + 1))
14
- if (!Number.isInteger(id) || id <= 0) continue
15
- const href = resolveInternalHref(collection, id)
16
- if (href != null) data.push({ collection, id, href })
17
- }
18
- return { data }
19
- })
@@ -1,28 +0,0 @@
1
- import { usePreviewStore, previewOwner } from '../utils/preview-token'
2
-
3
- /**
4
- * Read back a preview ticket (ADR-0008) — the public page fetches it during SSR when the URL carries
5
- * `?kestrel-preview-token=…` and renders the editor's unsaved state instead of the stored record. Admin-
6
- * only and session-bound; an expired, foreign or unknown token is `null`, which the page treats as
7
- * "nothing to preview" and falls back to the saved content rather than failing the render.
8
- */
9
- export default defineEventHandler((event) => {
10
- requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
11
- const token = getQuery(event).token
12
- if (typeof token !== 'string' || !token) return null
13
- const payload = usePreviewStore().read(token, previewOwner(event))
14
- if (!payload) return null
15
-
16
- // The editor sends what a SAVE would send — raw ids for media and relations — so the ticket goes through
17
- // the same read population a stored record does. Without it the preview would render a page with the
18
- // images and internal links stripped, which is worse than useless for judging a layout.
19
- const c = getCollection(payload.collection)
20
- if (!c) return { payload }
21
- const locale = payload.locale || primaryLocale()
22
- const values = withResolveScope(
23
- () => populateRow({ ...payload.values }, { depth: 1, locale, def: c.def }),
24
- resolveBudgetFor(1),
25
- `preview ${payload.collection}`,
26
- )
27
- return { payload: { ...payload, values } }
28
- })
@@ -1,93 +0,0 @@
1
- import { usePreviewStore, previewOwner, type PreviewPayload } from '../utils/preview-token'
2
- import type { BuiltCollection } from '../../../core/server/utils/collection-types'
3
- import { fieldIs, type FieldDef } from '../../../core/server/utils/defineCollection'
4
- import { resolveColumnName } from '../../../fields/server/field-registry/naming'
5
- import { sanitizeRichtext } from '../../../fields/server/field-registry/sanitize'
6
- import { getBlock } from '../../../fields/server/utils/defineBlock'
7
-
8
- /** Editor payloads are form state, not uploads — a block tree with populated media is far below this. */
9
- const MAX_PAYLOAD_BYTES = 2_000_000
10
-
11
- /**
12
- * Sanitize every richtext leaf reachable from `scope` (top-level fields + nested repeater entries), in
13
- * place. Mirrors the WALK shape of crud.ts's `applyFieldTransforms`/`transformNested`, but not the
14
- * mechanism: richtext's sanitizer is wired into the Zod validator (`z.string().transform(sanitizeRichtext)`
15
- * in field-registry/index.ts), not the field-type's `transform` hook that walk calls — and running the
16
- * collection's Zod schema here isn't an option anyway, see `sanitizePreviewValues` below. So this calls
17
- * `sanitizeRichtext` directly wherever a `richtext` field is present, and only there; every other field
18
- * passes through untouched.
19
- */
20
- function sanitizeRichtextFields(fields: Record<string, FieldDef>, scope: Record<string, unknown>): void {
21
- for (const [key, fieldDef] of Object.entries(fields)) {
22
- const { jsKey } = resolveColumnName(key, fieldDef)
23
- if (!Object.hasOwn(scope, jsKey)) continue
24
- if (fieldIs(fieldDef, 'richtext')) {
25
- if (typeof scope[jsKey] === 'string') scope[jsKey] = sanitizeRichtext(scope[jsKey] as string)
26
- } else if (fieldIs(fieldDef, 'repeater')) {
27
- const entries = scope[jsKey]
28
- if (Array.isArray(entries)) {
29
- for (const entry of entries) if (entry && typeof entry === 'object') sanitizeRichtextFields(fieldDef.options.fields, entry as Record<string, unknown>)
30
- }
31
- }
32
- }
33
- }
34
-
35
- /** Same walk as `transformBlocks` in crud.ts: each block's props (by its registered field defs) + its
36
- * slots' nested blocks, recursively. An unregistered block type is left as-is (matches crud.ts). */
37
- function sanitizeRichtextInBlocks(blocks: unknown): void {
38
- if (!Array.isArray(blocks)) return
39
- for (const block of blocks) {
40
- if (!block || typeof block !== 'object') continue
41
- const b = block as { type?: string; props?: unknown; slots?: unknown }
42
- const def = typeof b.type === 'string' ? getBlock(b.type) : undefined
43
- if (def && b.props && typeof b.props === 'object') sanitizeRichtextFields(def.fields, b.props as Record<string, unknown>)
44
- if (b.slots && typeof b.slots === 'object') for (const sub of Object.values(b.slots as Record<string, unknown>)) sanitizeRichtextInBlocks(sub)
45
- }
46
- }
47
-
48
- /**
49
- * Apply the richtext sanitizer to a preview ticket's `values` — the fix for the ticket bypassing it
50
- * entirely (it normally runs as a Zod `.transform()` on WRITE, `create`/`update`/`putSingleton` in
51
- * crud.ts; a ticket never reaches those). Deliberately NOT a schema parse: `values` is the editor's
52
- * in-progress, possibly-invalid draft (a missing required field, an out-of-range number, …), and a preview
53
- * exists precisely so a draft can be inspected BEFORE it would pass a save — a strict or even a `.partial()`
54
- * parse would legitimately reject some of what it must accept. Sanitizing is not a validity constraint
55
- * though (it only narrows stored bytes, the same way a save would), so a direct field-tree walk gets the
56
- * safety without the rejection risk. Walks top-level fields, repeater entries, and (when the collection
57
- * uses the block editor) block props + nested slots — everywhere a richtext leaf can live.
58
- */
59
- function sanitizePreviewValues(collection: BuiltCollection, values: Record<string, unknown>): void {
60
- sanitizeRichtextFields(collection.def.fields, values)
61
- if (collection.def.blocks?.enabled && Object.hasOwn(values, 'content')) sanitizeRichtextInBlocks(values.content)
62
- }
63
-
64
- /**
65
- * Mint a preview ticket for the editor's UNSAVED state (ADR-0008). The editor posts what it currently
66
- * holds and opens the page at `?kestrel-preview-token=<token>` in a new tab; nothing is written to the DB,
67
- * so previewing never doubles as an unasked-for save. Admin-only (default-deny API guard + the backstop),
68
- * and the ticket is bound to this session.
69
- *
70
- * body: { collection, id: number | null, locale?, values }
71
- * 200: { token, expiresAt }
72
- */
73
- export default defineEventHandler(async (event) => {
74
- requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
75
- const body = (await readBody(event)) as Partial<PreviewPayload> | null
76
-
77
- const name = typeof body?.collection === 'string' ? body.collection : ''
78
- const collection = getCollection(name) as BuiltCollection | undefined
79
- if (!collection) throw createError({ statusCode: 404, statusMessage: `Unknown collection: ${name}` })
80
-
81
- const values = body?.values
82
- if (!values || typeof values !== 'object' || Array.isArray(values)) {
83
- throw createError({ statusCode: 400, statusMessage: 'preview requires a `values` object' })
84
- }
85
- if (JSON.stringify(values).length > MAX_PAYLOAD_BYTES) {
86
- throw createError({ statusCode: 413, statusMessage: 'Preview payload too large' })
87
- }
88
- sanitizePreviewValues(collection, values as Record<string, unknown>)
89
-
90
- const id = typeof body?.id === 'number' && Number.isInteger(body.id) ? body.id : null
91
- const locale = typeof body?.locale === 'string' ? body.locale : undefined
92
- return usePreviewStore().mint(previewOwner(event), { collection: name, id, locale, values })
93
- })
@@ -1,58 +0,0 @@
1
- import { eq, getTableColumns } from 'drizzle-orm'
2
- import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
3
- import { publishStatus } from '../database/publish-status'
4
- import { routeForRecord } from '../utils/publish/route-for-record'
5
- import { hasPendingChanges } from '../utils/publish/pending'
6
-
7
- /**
8
- * Admin-only read of the LIVE publish state of a record's static page (`?collection=&id=`). Admin-only by
9
- * construction: `/api/*` is default-deny for anonymous and `publish-status` is not a public pageLike
10
- * resource, so the access guard rejects an unauthenticated request before this runs. Resolves the record's
11
- * route, then looks up its `publish_status` row. Returns `{ route, status }` (+ error/updatedAt on a row),
12
- * or `{ route: null, status: null }` when the record has no route (non-pageLike / blank slug / missing),
13
- * or `{ route, status: null }` when there is no status row yet (never published — dev, a draft, or a
14
- * republish still in flight). The editor's live-ampel reads this and refetches after each save.
15
- */
16
- export default defineEventHandler((event) => {
17
- // Environment-level facts the right lamp needs beyond the per-route row: where the NEXT publish would go
18
- // (`driver`) and whether the runtime publisher actually produces files HERE (prod + `output.auto`). In dev
19
- // (or with auto off) nothing is ever generated, so the lamp shows a calm "Not built" instead of a stuck
20
- // "Generating". Read straight from runtimeConfig — no need to pull in the whole publisher module.
21
- const output = (useRuntimeConfig().kestrel as { output?: { driver?: 'local' | 's3'; auto?: boolean; publishOnSave?: boolean } }).output
22
- // `publishOnSave` also tells the editor whether to offer a Publish button at all: with the split turned
23
- // off there is nothing left for it to do.
24
- const publishOnSave = !!output?.publishOnSave
25
- const env = { driver: (output?.driver ?? 'local') as 'local' | 's3', generates: !import.meta.dev && !!output?.auto, publishOnSave }
26
-
27
- const q = getQuery(event)
28
- const name = typeof q.collection === 'string' ? q.collection : ''
29
- const id = Number(typeof q.id === 'string' ? q.id : NaN)
30
- const c = getCollection(name)
31
- if (!c || !c.def.pageLike || !Number.isInteger(id) || id <= 0) return { route: null, status: null, pending: false, neverPublished: false, ...env }
32
-
33
- const db = useDb()
34
- const table = c.table as AnySQLiteTable
35
- const cols = getTableColumns(table) as Record<string, never>
36
- const row = db.select().from(table).where(eq(cols.id, id)).get() as { path?: unknown; locale?: unknown; updatedAt?: unknown } | undefined
37
- const route = routeForRecord(row, true, primaryLocale(), prefixPrimaryLocale())
38
- if (!route) return { route: null, status: null, pending: false, neverPublished: false, ...env }
39
-
40
- // Saved after it was last published: with publishing deferred to an explicit action, that is the normal
41
- // working state of a page being edited — the live file is the previous version until someone publishes.
42
- const savedAt = row?.updatedAt instanceof Date ? row.updatedAt.getTime() : null
43
- try {
44
- const st = db.select().from(publishStatus).where(eq(publishStatus.route, route)).get()
45
- // A routable page with no row was never published. Before the split that was indistinguishable from
46
- // "a publish is running" — a save always enqueued one — but now nothing is in flight and nothing will be
47
- // until someone presses Publish, so the lamp must not claim progress that is not happening.
48
- if (!st) return { route, status: null, pending: false, neverPublished: true, ...env }
49
- // With the split off, a save republishes on its own, so a newer save means a republish is in flight —
50
- // reporting that as "unpublished changes" would ask the user to act on something already happening.
51
- const pending = !publishOnSave && hasPendingChanges(savedAt, st.updatedAt instanceof Date ? st.updatedAt.getTime() : null)
52
- return { route, status: st.status, error: st.error, updatedAt: st.updatedAt, target: st.target, pending, neverPublished: false, ...env }
53
- } catch {
54
- // publish_status not migrated yet → treat as "no status" rather than a 500. Not "never published"
55
- // either: the table is unreadable, so the page's real state is unknown, not known to be absent.
56
- return { route, status: null, pending: false, neverPublished: false, ...env }
57
- }
58
- })