@michaelthielemann/kestrel 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (395) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +12 -0
  3. package/README.md +234 -0
  4. package/kestrel.config.ts +28 -0
  5. package/layers/access/nuxt.config.ts +5 -0
  6. package/layers/access/server/middleware/00.ip-allowlist.ts +67 -0
  7. package/layers/access/server/middleware/access-guard.ts +30 -0
  8. package/layers/access/server/types.d.ts +8 -0
  9. package/layers/access/server/utils/csrf.ts +39 -0
  10. package/layers/access/server/utils/grant-registry.ts +36 -0
  11. package/layers/access/server/utils/guard.ts +44 -0
  12. package/layers/access/server/utils/ip-allowlist.ts +76 -0
  13. package/layers/access/server/utils/policy.ts +98 -0
  14. package/layers/access/server/utils/public-resources.ts +11 -0
  15. package/layers/access/server/utils/render-context.ts +41 -0
  16. package/layers/access/server/utils/require-admin.ts +15 -0
  17. package/layers/admin/app/components/AdminAccount.vue +212 -0
  18. package/layers/admin/app/components/AdminNav.vue +44 -0
  19. package/layers/admin/app/components/BlockFields.vue +68 -0
  20. package/layers/admin/app/components/BlockPreview.vue +401 -0
  21. package/layers/admin/app/components/BlockTree.vue +433 -0
  22. package/layers/admin/app/components/BlocksBody.vue +162 -0
  23. package/layers/admin/app/components/CollectionDeleteDialog.vue +38 -0
  24. package/layers/admin/app/components/CollectionEditor.vue +203 -0
  25. package/layers/admin/app/components/CollectionList.vue +1091 -0
  26. package/layers/admin/app/components/EditorStatus.vue +199 -0
  27. package/layers/admin/app/components/EditorUnsupported.vue +28 -0
  28. package/layers/admin/app/components/FieldsBody.vue +23 -0
  29. package/layers/admin/app/components/LocaleBar.vue +105 -0
  30. package/layers/admin/app/components/PageFields.vue +101 -0
  31. package/layers/admin/app/components/PageFieldsPane.vue +39 -0
  32. package/layers/admin/app/components/SeoFields.vue +169 -0
  33. package/layers/admin/app/components/SingletonEditor.vue +92 -0
  34. package/layers/admin/app/composables/useAuth.ts +68 -0
  35. package/layers/admin/app/composables/useBlockTree.ts +95 -0
  36. package/layers/admin/app/composables/useBlocks.ts +7 -0
  37. package/layers/admin/app/composables/useCachedResource.ts +35 -0
  38. package/layers/admin/app/composables/useCollectionOps.ts +53 -0
  39. package/layers/admin/app/composables/useCollections.ts +7 -0
  40. package/layers/admin/app/composables/useContentLocales.ts +23 -0
  41. package/layers/admin/app/composables/useEditForm.ts +370 -0
  42. package/layers/admin/app/composables/useLinkResolver.ts +40 -0
  43. package/layers/admin/app/composables/useListColumns.ts +53 -0
  44. package/layers/admin/app/composables/useListUrlState.ts +53 -0
  45. package/layers/admin/app/composables/usePendingPublishPoll.ts +15 -0
  46. package/layers/admin/app/composables/usePreviewViewport.ts +30 -0
  47. package/layers/admin/app/composables/usePublishStatus.ts +98 -0
  48. package/layers/admin/app/composables/useRailCollapsed.ts +14 -0
  49. package/layers/admin/app/composables/useTheme.ts +36 -0
  50. package/layers/admin/app/composables/useUnsavedGuard.ts +22 -0
  51. package/layers/admin/app/layouts/admin.vue +267 -0
  52. package/layers/admin/app/middleware/admin-auth.ts +4 -0
  53. package/layers/admin/app/pages/admin/[collection]/[id].vue +202 -0
  54. package/layers/admin/app/pages/admin/[collection]/index.vue +62 -0
  55. package/layers/admin/app/pages/admin/index.vue +118 -0
  56. package/layers/admin/app/pages/admin/login.vue +68 -0
  57. package/layers/admin/app/pages/admin/media.vue +28 -0
  58. package/layers/admin/app/pages/admin/references.vue +146 -0
  59. package/layers/admin/app/plugins/admin-style-guard.client.ts +76 -0
  60. package/layers/admin/app/plugins/reauth.client.ts +20 -0
  61. package/layers/admin/app/utils/admin-style-guard.ts +20 -0
  62. package/layers/admin/app/utils/block-tree.ts +214 -0
  63. package/layers/admin/app/utils/collection-ops.ts +44 -0
  64. package/layers/admin/app/utils/dead-refs.ts +30 -0
  65. package/layers/admin/app/utils/edit-form.ts +223 -0
  66. package/layers/admin/app/utils/editor-expose.ts +26 -0
  67. package/layers/admin/app/utils/editor-form-context.ts +83 -0
  68. package/layers/admin/app/utils/editor-registry.ts +20 -0
  69. package/layers/admin/app/utils/field-keys.ts +11 -0
  70. package/layers/admin/app/utils/list-columns.ts +141 -0
  71. package/layers/admin/app/utils/list-query.ts +96 -0
  72. package/layers/admin/app/utils/nav-active.ts +9 -0
  73. package/layers/admin/app/utils/populate-blocks.ts +288 -0
  74. package/layers/admin/app/utils/preview-channel.ts +62 -0
  75. package/layers/admin/app/utils/preview-viewport.ts +80 -0
  76. package/layers/admin/app/utils/reauth.ts +68 -0
  77. package/layers/admin/app/utils/record-title.ts +15 -0
  78. package/layers/admin/app/utils/register-builtin-editors.ts +9 -0
  79. package/layers/admin/app/utils/safe-redirect.ts +12 -0
  80. package/layers/admin/nuxt.config.ts +17 -0
  81. package/layers/auth/nuxt.config.ts +1 -0
  82. package/layers/auth/server/api/auth/login.post.ts +38 -0
  83. package/layers/auth/server/api/auth/logout.post.ts +9 -0
  84. package/layers/auth/server/api/auth/session.get.ts +1 -0
  85. package/layers/auth/server/utils/admin-credential.ts +42 -0
  86. package/layers/auth/server/utils/client-ip.ts +88 -0
  87. package/layers/auth/server/utils/login-throttle.ts +126 -0
  88. package/layers/auth/server/utils/password.ts +52 -0
  89. package/layers/auth/server/utils/session-cookie.ts +44 -0
  90. package/layers/auth/server/utils/session-epoch.ts +96 -0
  91. package/layers/auth/server/utils/session.ts +85 -0
  92. package/layers/collections/nuxt.config.ts +1 -0
  93. package/layers/collections/server/collections/pages.ts +19 -0
  94. package/layers/collections/server/plugins/01.register.ts +32 -0
  95. package/layers/collections/server/plugins/02.register-relation-populate.ts +17 -0
  96. package/layers/collections/server/utils/populate-relations.ts +66 -0
  97. package/layers/core/app/utils/bulk-actions.ts +9 -0
  98. package/layers/core/app/utils/filter-ops.ts +98 -0
  99. package/layers/core/app/utils/list-limits.ts +27 -0
  100. package/layers/core/app/utils/locale-path.ts +12 -0
  101. package/layers/core/app/utils/slugify.ts +16 -0
  102. package/layers/core/modules/auto-discovery/extract-block.ts +176 -0
  103. package/layers/core/modules/auto-discovery/index.ts +44 -0
  104. package/layers/core/modules/auto-discovery/scan.ts +78 -0
  105. package/layers/core/modules/auto-discovery/virtual.d.ts +12 -0
  106. package/layers/core/modules/kestrel/index.ts +86 -0
  107. package/layers/core/nuxt.config.ts +8 -0
  108. package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +8 -0
  109. package/layers/core/server/api/[collection]/[id]/translations.get.ts +4 -0
  110. package/layers/core/server/api/[collection]/[id].delete.ts +5 -0
  111. package/layers/core/server/api/[collection]/[id].get.ts +8 -0
  112. package/layers/core/server/api/[collection]/[id].patch.ts +8 -0
  113. package/layers/core/server/api/[collection]/bulk.post.ts +37 -0
  114. package/layers/core/server/api/[collection]/index.get.ts +19 -0
  115. package/layers/core/server/api/[collection]/index.post.ts +8 -0
  116. package/layers/core/server/api/[collection]/index.put.ts +7 -0
  117. package/layers/core/server/api/[collection]/options.get.ts +18 -0
  118. package/layers/core/server/api/[collection]/translations.get.ts +25 -0
  119. package/layers/core/server/api/collections/[name].get.ts +4 -0
  120. package/layers/core/server/api/collections/index.get.ts +3 -0
  121. package/layers/core/server/api/references/broken.get.ts +3 -0
  122. package/layers/core/server/api/references/referrers.get.ts +24 -0
  123. package/layers/core/server/database/record-refs.ts +20 -0
  124. package/layers/core/server/plugins/00.migrate.ts +11 -0
  125. package/layers/core/server/plugins/02.schema-sync.ts +63 -0
  126. package/layers/core/server/plugins/03.record-refs.ts +12 -0
  127. package/layers/core/server/schema/bootstrap.ts +41 -0
  128. package/layers/core/server/schema/desired.ts +99 -0
  129. package/layers/core/server/schema/dev-sync.ts +23 -0
  130. package/layers/core/server/schema/dialect.ts +59 -0
  131. package/layers/core/server/schema/diff.ts +110 -0
  132. package/layers/core/server/schema/introspect.ts +67 -0
  133. package/layers/core/server/schema/model.ts +61 -0
  134. package/layers/core/server/schema/render-sqlite.ts +89 -0
  135. package/layers/core/server/schema/sync.ts +201 -0
  136. package/layers/core/server/tasks/db/migrate.ts +54 -0
  137. package/layers/core/server/utils/blocks.ts +6 -0
  138. package/layers/core/server/utils/collection-types.ts +27 -0
  139. package/layers/core/server/utils/crud.ts +577 -0
  140. package/layers/core/server/utils/db.ts +26 -0
  141. package/layers/core/server/utils/defineCollection.ts +207 -0
  142. package/layers/core/server/utils/duplicate.ts +162 -0
  143. package/layers/core/server/utils/field-layout.ts +125 -0
  144. package/layers/core/server/utils/filter-predicate.ts +101 -0
  145. package/layers/core/server/utils/http.ts +50 -0
  146. package/layers/core/server/utils/if-unmodified.ts +13 -0
  147. package/layers/core/server/utils/kestrel-config.ts +410 -0
  148. package/layers/core/server/utils/key-lock.ts +33 -0
  149. package/layers/core/server/utils/locale.ts +53 -0
  150. package/layers/core/server/utils/page-route.ts +58 -0
  151. package/layers/core/server/utils/page-slug.ts +127 -0
  152. package/layers/core/server/utils/picker.ts +82 -0
  153. package/layers/core/server/utils/populate.ts +48 -0
  154. package/layers/core/server/utils/prerender-memo.ts +23 -0
  155. package/layers/core/server/utils/read-capture.ts +37 -0
  156. package/layers/core/server/utils/record-ref-index.ts +200 -0
  157. package/layers/core/server/utils/registry.ts +24 -0
  158. package/layers/core/server/utils/resolve-scope.ts +79 -0
  159. package/layers/core/server/utils/seo.ts +24 -0
  160. package/layers/core/server/utils/serialize-collection.ts +149 -0
  161. package/layers/core/server/utils/server-config.ts +18 -0
  162. package/layers/core/server/utils/sql.ts +2 -0
  163. package/layers/core/server/utils/storage.local.ts +117 -0
  164. package/layers/core/server/utils/storage.s3.ts +197 -0
  165. package/layers/core/server/utils/storage.ts +90 -0
  166. package/layers/core/server/utils/write-events.ts +31 -0
  167. package/layers/fields/app/utils/condition.ts +71 -0
  168. package/layers/fields/app/utils/define-block.ts +13 -0
  169. package/layers/fields/app/utils/field-constraints.ts +17 -0
  170. package/layers/fields/app/utils/field-factories.ts +129 -0
  171. package/layers/fields/app/utils/field-meta.ts +33 -0
  172. package/layers/fields/app/utils/field-validate.ts +75 -0
  173. package/layers/fields/app/utils/field-value.ts +7 -0
  174. package/layers/fields/app/utils/richtext-links.ts +48 -0
  175. package/layers/fields/nuxt.config.ts +1 -0
  176. package/layers/fields/server/api/blocks/index.get.ts +9 -0
  177. package/layers/fields/server/field-registry/index.ts +248 -0
  178. package/layers/fields/server/field-registry/naming.ts +10 -0
  179. package/layers/fields/server/field-registry/sanitize.ts +40 -0
  180. package/layers/fields/server/field-registry/types.ts +19 -0
  181. package/layers/fields/server/plugins/01.register-field-populate.ts +11 -0
  182. package/layers/fields/server/utils/block-ids.ts +43 -0
  183. package/layers/fields/server/utils/block-populate.ts +41 -0
  184. package/layers/fields/server/utils/buildCollection.ts +88 -0
  185. package/layers/fields/server/utils/buildFieldSchema.ts +22 -0
  186. package/layers/fields/server/utils/buildTable.ts +107 -0
  187. package/layers/fields/server/utils/conditional-required.ts +25 -0
  188. package/layers/fields/server/utils/defineBlock.ts +86 -0
  189. package/layers/fields/server/utils/defineFieldType.ts +33 -0
  190. package/layers/fields/server/utils/extract-refs.ts +152 -0
  191. package/layers/fields/server/utils/field-populate.ts +68 -0
  192. package/layers/media/app/components/KestrelImg.vue +65 -0
  193. package/layers/media/app/components/MediaConflictDialog.vue +35 -0
  194. package/layers/media/app/components/MediaDeleteDialog.vue +52 -0
  195. package/layers/media/app/components/MediaGrid.vue +113 -0
  196. package/layers/media/app/components/MediaImage.vue +70 -0
  197. package/layers/media/app/components/MediaLibrary.vue +451 -0
  198. package/layers/media/app/components/MediaNewFolderDialog.vue +32 -0
  199. package/layers/media/app/components/MediaPathBar.vue +163 -0
  200. package/layers/media/app/components/MediaPicker.vue +19 -0
  201. package/layers/media/app/components/MediaRenameDialog.vue +32 -0
  202. package/layers/media/app/components/MediaTable.vue +75 -0
  203. package/layers/media/app/components/MediaThumb.vue +25 -0
  204. package/layers/media/app/components/MediaToolbar.vue +90 -0
  205. package/layers/media/app/components/MediaUploadDialog.vue +87 -0
  206. package/layers/media/app/components/MediaViewer.vue +88 -0
  207. package/layers/media/app/components/field/Media.vue +261 -0
  208. package/layers/media/app/composables/useBlurUp.ts +55 -0
  209. package/layers/media/app/composables/useMediaClipboard.ts +14 -0
  210. package/layers/media/app/composables/useMediaContextMenu.ts +56 -0
  211. package/layers/media/app/composables/useMediaDnd.ts +56 -0
  212. package/layers/media/app/composables/useMediaLibrary.ts +151 -0
  213. package/layers/media/app/composables/useMediaOps.ts +50 -0
  214. package/layers/media/app/composables/useMediaRelocate.ts +39 -0
  215. package/layers/media/app/composables/useMediaResolver.ts +50 -0
  216. package/layers/media/app/composables/useMediaUpload.ts +93 -0
  217. package/layers/media/app/composables/useMediaVariant.ts +34 -0
  218. package/layers/media/app/plugins/field-media.client.ts +6 -0
  219. package/layers/media/app/utils/dnd.ts +69 -0
  220. package/layers/media/app/utils/library.ts +79 -0
  221. package/layers/media/app/utils/ops.ts +62 -0
  222. package/layers/media/app/utils/picture-model.ts +125 -0
  223. package/layers/media/app/utils/thumbhash.ts +19 -0
  224. package/layers/media/app/utils/variant-name.ts +20 -0
  225. package/layers/media/nuxt.config.ts +3 -0
  226. package/layers/media/server/api/media/[id]/usages.get.ts +8 -0
  227. package/layers/media/server/api/media/[id].delete.ts +18 -0
  228. package/layers/media/server/api/media/[id].get.ts +10 -0
  229. package/layers/media/server/api/media/[id].patch.ts +41 -0
  230. package/layers/media/server/api/media/backfill.post.ts +18 -0
  231. package/layers/media/server/api/media/copy.post.ts +7 -0
  232. package/layers/media/server/api/media/delete.post.ts +13 -0
  233. package/layers/media/server/api/media/folders.post.ts +18 -0
  234. package/layers/media/server/api/media/index.get.ts +15 -0
  235. package/layers/media/server/api/media/index.post.ts +157 -0
  236. package/layers/media/server/api/media/library.get.ts +17 -0
  237. package/layers/media/server/api/media/move.post.ts +7 -0
  238. package/layers/media/server/api/media/rename.post.ts +7 -0
  239. package/layers/media/server/api/media/resolve.get.ts +13 -0
  240. package/layers/media/server/collections/media-settings.ts +25 -0
  241. package/layers/media/server/collections/media.ts +29 -0
  242. package/layers/media/server/database/folders.ts +10 -0
  243. package/layers/media/server/middleware/ondemand-variants.ts +37 -0
  244. package/layers/media/server/plugins/02.register-media.ts +19 -0
  245. package/layers/media/server/plugins/03.media-cleanup.ts +20 -0
  246. package/layers/media/server/plugins/04.variant-capture.ts +23 -0
  247. package/layers/media/server/schema-tables/folders.ts +4 -0
  248. package/layers/media/server/tasks/media/backfill.ts +18 -0
  249. package/layers/media/server/utils/backfill.ts +132 -0
  250. package/layers/media/server/utils/derive.ts +59 -0
  251. package/layers/media/server/utils/folder-paths.ts +42 -0
  252. package/layers/media/server/utils/folders.ts +12 -0
  253. package/layers/media/server/utils/library.ts +90 -0
  254. package/layers/media/server/utils/media-cleanup.ts +27 -0
  255. package/layers/media/server/utils/media-enabled.ts +23 -0
  256. package/layers/media/server/utils/media-ops.ts +118 -0
  257. package/layers/media/server/utils/media-write.ts +13 -0
  258. package/layers/media/server/utils/naming.ts +52 -0
  259. package/layers/media/server/utils/ondemand.ts +69 -0
  260. package/layers/media/server/utils/persist-upload.ts +62 -0
  261. package/layers/media/server/utils/populate.ts +47 -0
  262. package/layers/media/server/utils/record.ts +63 -0
  263. package/layers/media/server/utils/relocate-ops.ts +341 -0
  264. package/layers/media/server/utils/relocate-plan.ts +63 -0
  265. package/layers/media/server/utils/resolve.ts +86 -0
  266. package/layers/media/server/utils/sanitize-svg.ts +12 -0
  267. package/layers/media/server/utils/sniff.ts +68 -0
  268. package/layers/media/server/utils/storage-relocate.ts +139 -0
  269. package/layers/media/server/utils/translations.ts +19 -0
  270. package/layers/media/server/utils/usages.ts +66 -0
  271. package/layers/media/server/utils/variant-capture.ts +70 -0
  272. package/layers/media/server/utils/variants.ts +108 -0
  273. package/layers/public/app/app.vue +5 -0
  274. package/layers/public/app/components/BlockRenderer.vue +56 -0
  275. package/layers/public/app/components/KestrelLink.vue +26 -0
  276. package/layers/public/app/components/KestrelPreviewBridge.vue +133 -0
  277. package/layers/public/app/composables/public-page.ts +24 -0
  278. package/layers/public/app/error.vue +12 -0
  279. package/layers/public/app/layouts/default.vue +5 -0
  280. package/layers/public/app/pages/[...slug].vue +157 -0
  281. package/layers/public/app/pages/__kestrel/preview.vue +34 -0
  282. package/layers/public/app/utils/block-edit-context.ts +16 -0
  283. package/layers/public/app/utils/link-href.ts +46 -0
  284. package/layers/public/app/utils/page-head.ts +84 -0
  285. package/layers/public/app/utils/preview-protocol.ts +81 -0
  286. package/layers/public/app/utils/public-route.ts +46 -0
  287. package/layers/public/modules/deploy-output/deploy-output.ts +370 -0
  288. package/layers/public/modules/deploy-output/index.ts +82 -0
  289. package/layers/public/modules/prerender-routes/discover.ts +37 -0
  290. package/layers/public/modules/prerender-routes/index.ts +85 -0
  291. package/layers/public/modules/prerender-routes/routes.ts +16 -0
  292. package/layers/public/modules/prune-media/index.ts +48 -0
  293. package/layers/public/modules/prune-media/prune-media.ts +81 -0
  294. package/layers/public/nuxt.config.ts +11 -0
  295. package/layers/public/server/api/links/resolve.get.ts +19 -0
  296. package/layers/public/server/api/publish-status.get.ts +44 -0
  297. package/layers/public/server/api/route.get.ts +18 -0
  298. package/layers/public/server/database/publish-deps.ts +12 -0
  299. package/layers/public/server/database/publish-status.ts +19 -0
  300. package/layers/public/server/plugins/02.register-links.ts +17 -0
  301. package/layers/public/server/plugins/zz.publish.ts +63 -0
  302. package/layers/public/server/routes/llms.txt.get.ts +72 -0
  303. package/layers/public/server/routes/robots.txt.get.ts +8 -0
  304. package/layers/public/server/routes/sitemap.xml.get.ts +66 -0
  305. package/layers/public/server/schema-tables/publish-deps.ts +4 -0
  306. package/layers/public/server/schema-tables/publish-status.ts +4 -0
  307. package/layers/public/server/tasks/publish/run.ts +26 -0
  308. package/layers/public/server/utils/link-resolve.ts +35 -0
  309. package/layers/public/server/utils/llms.ts +39 -0
  310. package/layers/public/server/utils/page-resolve.ts +86 -0
  311. package/layers/public/server/utils/populate-links.ts +66 -0
  312. package/layers/public/server/utils/publish/coalesce.ts +22 -0
  313. package/layers/public/server/utils/publish/deps-persistence.ts +43 -0
  314. package/layers/public/server/utils/publish/deps.ts +87 -0
  315. package/layers/public/server/utils/publish/invalidation.ts +141 -0
  316. package/layers/public/server/utils/publish/publish-status.ts +54 -0
  317. package/layers/public/server/utils/publish/publisher.ts +291 -0
  318. package/layers/public/server/utils/publish/queue.ts +67 -0
  319. package/layers/public/server/utils/publish/route-for-record.ts +16 -0
  320. package/layers/public/server/utils/publish/route-keys.ts +13 -0
  321. package/layers/public/server/utils/site-url.ts +32 -0
  322. package/layers/public/server/utils/sitemap.ts +97 -0
  323. package/layers/ui/app/assets/scss/_base.scss +16 -0
  324. package/layers/ui/app/assets/scss/_datepicker.scss +82 -0
  325. package/layers/ui/app/assets/scss/_mixins.scss +50 -0
  326. package/layers/ui/app/assets/scss/_reset.scss +65 -0
  327. package/layers/ui/app/assets/scss/_tokens.scss +183 -0
  328. package/layers/ui/app/assets/scss/main.scss +4 -0
  329. package/layers/ui/app/components/field/Boolean.vue +30 -0
  330. package/layers/ui/app/components/field/Choice.vue +68 -0
  331. package/layers/ui/app/components/field/Datetime.vue +96 -0
  332. package/layers/ui/app/components/field/DeadRefNote.vue +28 -0
  333. package/layers/ui/app/components/field/Json.vue +63 -0
  334. package/layers/ui/app/components/field/Layout.vue +140 -0
  335. package/layers/ui/app/components/field/Link.vue +112 -0
  336. package/layers/ui/app/components/field/LinkInternalPicker.vue +95 -0
  337. package/layers/ui/app/components/field/Number.vue +30 -0
  338. package/layers/ui/app/components/field/Relation.vue +49 -0
  339. package/layers/ui/app/components/field/Renderer.vue +30 -0
  340. package/layers/ui/app/components/field/Repeater.vue +460 -0
  341. package/layers/ui/app/components/field/Richtext.vue +26 -0
  342. package/layers/ui/app/components/field/Slug.vue +66 -0
  343. package/layers/ui/app/components/field/Text.vue +35 -0
  344. package/layers/ui/app/components/field/Unsupported.vue +34 -0
  345. package/layers/ui/app/components/ui/Alert.vue +50 -0
  346. package/layers/ui/app/components/ui/Brand.vue +22 -0
  347. package/layers/ui/app/components/ui/Button.vue +116 -0
  348. package/layers/ui/app/components/ui/ButtonGroup.vue +96 -0
  349. package/layers/ui/app/components/ui/Checkbox.vue +52 -0
  350. package/layers/ui/app/components/ui/CheckboxGroup.vue +50 -0
  351. package/layers/ui/app/components/ui/Combobox.vue +343 -0
  352. package/layers/ui/app/components/ui/DatePicker.vue +92 -0
  353. package/layers/ui/app/components/ui/DateRangePicker.vue +100 -0
  354. package/layers/ui/app/components/ui/Dialog.vue +107 -0
  355. package/layers/ui/app/components/ui/EmptyState.vue +46 -0
  356. package/layers/ui/app/components/ui/Field.vue +57 -0
  357. package/layers/ui/app/components/ui/Fieldset.vue +64 -0
  358. package/layers/ui/app/components/ui/Icon.vue +44 -0
  359. package/layers/ui/app/components/ui/Menu.vue +54 -0
  360. package/layers/ui/app/components/ui/NumberInput.vue +173 -0
  361. package/layers/ui/app/components/ui/Richtext.vue +119 -0
  362. package/layers/ui/app/components/ui/RichtextToolbar.vue +253 -0
  363. package/layers/ui/app/components/ui/Select.vue +51 -0
  364. package/layers/ui/app/components/ui/TextInput.vue +110 -0
  365. package/layers/ui/app/components/ui/Textarea.vue +38 -0
  366. package/layers/ui/app/components/ui/TimeInput.vue +17 -0
  367. package/layers/ui/app/components/ui/Toasts.vue +103 -0
  368. package/layers/ui/app/components/ui/Tooltip.vue +45 -0
  369. package/layers/ui/app/composables/useAdminLang.ts +8 -0
  370. package/layers/ui/app/composables/useDragReorder.ts +41 -0
  371. package/layers/ui/app/composables/useEchoGuard.ts +28 -0
  372. package/layers/ui/app/composables/useLinkField.ts +70 -0
  373. package/layers/ui/app/composables/useRecordOptions.ts +84 -0
  374. package/layers/ui/app/composables/useRepeater.ts +99 -0
  375. package/layers/ui/app/composables/useT.ts +32 -0
  376. package/layers/ui/app/composables/useToast.ts +46 -0
  377. package/layers/ui/app/i18n/de.ts +406 -0
  378. package/layers/ui/app/i18n/en.ts +440 -0
  379. package/layers/ui/app/utils/boolean-field.ts +10 -0
  380. package/layers/ui/app/utils/field-component.ts +22 -0
  381. package/layers/ui/app/utils/field-empty.ts +21 -0
  382. package/layers/ui/app/utils/field-registry.ts +36 -0
  383. package/layers/ui/app/utils/humanize.ts +14 -0
  384. package/layers/ui/app/utils/icons.ts +147 -0
  385. package/layers/ui/app/utils/iso-date.ts +18 -0
  386. package/layers/ui/app/utils/localized.ts +13 -0
  387. package/layers/ui/app/utils/record-context.ts +15 -0
  388. package/layers/ui/app/utils/reorder.ts +8 -0
  389. package/layers/ui/app/utils/richtext-preserve-class.ts +31 -0
  390. package/layers/ui/app/utils/useFieldA11y.ts +40 -0
  391. package/layers/ui/nuxt.config.ts +5 -0
  392. package/nuxt.config.ts +105 -0
  393. package/package.json +122 -0
  394. package/scripts/hash-password.mjs +26 -0
  395. package/scripts/typecheck.mjs +51 -0
@@ -0,0 +1,370 @@
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
+ /**
73
+ * `Cache-Control` for a static-output key, or `undefined` for no explicit policy. Content-hashed
74
+ * `_nuxt/` assets get a year + `immutable` (the hash is the cache key — new content ⇒ new URL). HTML,
75
+ * the sitemap and `robots.txt` live at *stable* URLs whose content changes on any deploy, so they get
76
+ * `max-age=0, must-revalidate` (cacheable but always revalidated). Everything else (favicons, fonts,
77
+ * un-hashed media) is left to the host default.
78
+ */
79
+ export function cacheControlFor(key: string): string | undefined {
80
+ // Nuxt's app manifest lives at a STABLE _nuxt URL but its content (the buildId) changes every build, so it
81
+ // must revalidate — else the immutable rule below pins it and clients never detect a new deploy.
82
+ if (key === '_nuxt/builds/latest.json') return REVALIDATE_CACHE
83
+ if (key === '_nuxt' || key.startsWith('_nuxt/')) return IMMUTABLE_CACHE
84
+ const base = key.split('/').pop() ?? key
85
+ if (base.endsWith('.html') || base === 'sitemap.xml' || base === 'robots.txt' || base === 'llms.txt') return REVALIDATE_CACHE
86
+ return undefined
87
+ }
88
+
89
+ /** A boolean env flag is on for any of the common truthy spellings (case-insensitive, trimmed). */
90
+ export function isEnvTrue(value: string | undefined): boolean {
91
+ const t = value?.trim().toLowerCase()
92
+ return t === 'true' || t === '1' || t === 'yes' || t === 'on'
93
+ }
94
+
95
+ /**
96
+ * Decide what the deploy hook should do, kept pure so the trigger guard is unit-testable. Returns
97
+ * `'skip'` when this isn't a static `nuxt generate` — a plain `nuxt build` (or dev) must never deploy,
98
+ * even with `driver: s3` configured — and when `output.auto` hands publishing to the running server
99
+ * (see `autoPublish`). For a real (non-dry-run) static generate it throws when the
100
+ * bucket or `KESTREL_S3_*` credentials are missing, so a misconfigured deploy fails the generate
101
+ * loudly instead of warning and exiting 0 with a stale live bucket. A dry-run needs neither.
102
+ */
103
+ /** Strip leading/trailing slashes so prefixes compare on bare path segments. */
104
+ const normPrefix = (p: string): string => p.replace(/^\/+|\/+$/g, '')
105
+
106
+ /**
107
+ * Do two S3 key prefixes share keyspace on a path-segment boundary — i.e. is one equal to, or nested
108
+ * under, the other? Catches both nesting directions: a prune of `a` reaches everything under `a/b`, and
109
+ * a prune of `a/b` reaches the `a/b/…` slice of media stored under `a`. A pure-string collision like
110
+ * `site` vs `siteother` is *not* an overlap. Empty prefixes never overlap (handled by their own guards).
111
+ */
112
+ function prefixesOverlap(a: string, b: string): boolean {
113
+ const x = normPrefix(a)
114
+ const y = normPrefix(b)
115
+ if (!x || !y) return false
116
+ return x === y || x.startsWith(`${y}/`) || y.startsWith(`${x}/`)
117
+ }
118
+
119
+ export function planS3Deploy(opts: {
120
+ isStaticGenerate: boolean
121
+ dryRun: boolean
122
+ bucket: string
123
+ accessKeyId: string
124
+ secretAccessKey: string
125
+ prefix?: string
126
+ /**
127
+ * `output.auto` — the running server publishes to this bucket/prefix itself. Build-time route seeding is
128
+ * then disabled (a prerendered file would shadow the live route), so `.output/public` holds only what
129
+ * Nitro's crawler reached: shipping it would reconcile the server's live pages, sitemap and robots away.
130
+ */
131
+ autoPublish?: boolean
132
+ /** Bucket the S3 media driver writes live uploads to (empty when media isn't on S3 — then irrelevant). */
133
+ mediaBucket?: string
134
+ /** Key prefix of those live media objects, to keep the reconcile from deleting them. */
135
+ mediaPrefix?: string
136
+ }): 'skip' | 'deploy' {
137
+ if (!opts.isStaticGenerate) return 'skip'
138
+ if (opts.autoPublish) return 'skip'
139
+ if (!opts.dryRun) {
140
+ const missing: string[] = []
141
+ if (!opts.bucket) missing.push('bucket (KESTREL_OUTPUT_S3_BUCKET)')
142
+ if (!opts.accessKeyId) missing.push('KESTREL_OUTPUT_S3_ACCESS_KEY_ID / KESTREL_S3_ACCESS_KEY_ID')
143
+ if (!opts.secretAccessKey) missing.push('KESTREL_OUTPUT_S3_SECRET_ACCESS_KEY / KESTREL_S3_SECRET_ACCESS_KEY')
144
+ if (missing.length) {
145
+ throw new Error(
146
+ `[kestrel] output.driver=s3 but ${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} missing`
147
+ + ' — refusing to finish `nuxt generate` with a broken S3 deploy.',
148
+ )
149
+ }
150
+ // The deploy always reconciles (deletes every remote object under the prefix not in this generate —
151
+ // output ≡ DB). With an empty prefix that's the whole bucket root, which may hold the media bucket's
152
+ // objects too. Refuse rather than risk wiping it: an S3 deploy must target a dedicated prefix.
153
+ if (!opts.prefix) {
154
+ throw new Error(
155
+ '[kestrel] output.s3.prefix is empty — refusing to deploy (the always-on reconcile would delete the'
156
+ + ' whole bucket root, and a shared/media bucket could be wiped). Set KESTREL_OUTPUT_S3_PREFIX to a'
157
+ + ' prefix dedicated to the generated site.',
158
+ )
159
+ }
160
+ // Same bucket as the S3 media driver AND overlapping prefixes → the reconcile would delete live uploads.
161
+ if (opts.mediaBucket && opts.mediaBucket === opts.bucket
162
+ && opts.mediaPrefix && prefixesOverlap(opts.prefix, opts.mediaPrefix)) {
163
+ throw new Error(
164
+ `[kestrel] the output reconcile would delete live media: output prefix "${opts.prefix}" overlaps the`
165
+ + ` S3 media prefix "${opts.mediaPrefix}" in the same bucket "${opts.bucket}". Give the generated`
166
+ + ' site its own bucket or a non-overlapping KESTREL_OUTPUT_S3_PREFIX.',
167
+ )
168
+ }
169
+ }
170
+ return 'deploy'
171
+ }
172
+
173
+ export interface OutputTarget {
174
+ driver: 'local' | 's3'
175
+ /** Non-secret S3 settings; credentials are resolved separately by `resolveOutputCreds`. */
176
+ s3: { bucket: string; region: string; endpoint: string; prefix: string }
177
+ }
178
+
179
+ type OutputConfig = { driver?: string; s3?: { bucket?: string; region?: string; endpoint?: string; prefix?: string } }
180
+ const clean = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
181
+
182
+ /**
183
+ * Resolve where `nuxt generate`'s static output is shipped: a local directory (default) or an S3
184
+ * bucket. Precedence per setting: `KESTREL_OUTPUT_*` env → config → default (the documented Kestrel
185
+ * order, same as the runtime publisher). Pure, so the precedence is unit-testable; secrets are resolved
186
+ * by `resolveOutputCreds`, never here.
187
+ */
188
+ export function resolveOutputTarget(out: OutputConfig = {}, env: Record<string, string | undefined> = {}): OutputTarget {
189
+ const driver = (clean(env.KESTREL_OUTPUT_DRIVER) ?? clean(out.driver))?.toLowerCase() === 's3' ? 's3' : 'local'
190
+ // Same non-secret resolver media uses, under the KESTREL_OUTPUT_S3_* prefix (own bucket, own creds).
191
+ return { driver, s3: resolveS3Settings(out.s3, env, 'KESTREL_OUTPUT_S3') }
192
+ }
193
+
194
+ /**
195
+ * S3 credentials for the output deploy: the output-specific `KESTREL_OUTPUT_S3_*`, falling back to the
196
+ * shared media `KESTREL_S3_*` so a single S3 account "just works" — identical to the runtime publisher's
197
+ * resolution. Required keys default to '' (not undefined) so `planS3Deploy` detects a missing credential.
198
+ */
199
+ export function resolveOutputCreds(env: Record<string, string | undefined> = {}): { accessKeyId: string; secretAccessKey: string; sessionToken?: string } {
200
+ return {
201
+ accessKeyId: clean(env.KESTREL_OUTPUT_S3_ACCESS_KEY_ID) ?? clean(env.KESTREL_S3_ACCESS_KEY_ID) ?? '',
202
+ secretAccessKey: clean(env.KESTREL_OUTPUT_S3_SECRET_ACCESS_KEY) ?? clean(env.KESTREL_S3_SECRET_ACCESS_KEY) ?? '',
203
+ sessionToken: clean(env.KESTREL_OUTPUT_S3_SESSION_TOKEN) ?? clean(env.KESTREL_S3_SESSION_TOKEN),
204
+ }
205
+ }
206
+
207
+ /** Every file under `dir` as `{ abs, key }`, the key being its POSIX-relative path (stable order). */
208
+ function listFiles(dir: string): Array<{ abs: string; key: string }> {
209
+ return readdirSync(dir, { recursive: true, withFileTypes: true })
210
+ .filter((e) => e.isFile())
211
+ .map((e) => {
212
+ const abs = join(e.parentPath, e.name)
213
+ return { abs, key: relative(dir, abs).split(sep).join('/') }
214
+ })
215
+ }
216
+
217
+ /**
218
+ * A `*.gz`/`*.br` file is a pre-compressed sidecar (from `nitro.compressPublicAssets`) when its
219
+ * uncompressed base is also in the output. On a plain static S3 host these are dead weight — the host
220
+ * does no `Accept-Encoding` negotiation, so nobody fetches `index.html.gz`, and storing it *as*
221
+ * `index.html` with `Content-Encoding` would collide with the real file — so the deploy skips them.
222
+ * A standalone `*.gz` archive (no base file) is genuine content and is kept.
223
+ */
224
+ export function isCompressedSidecar(key: string, all: Set<string>): boolean {
225
+ const m = /\.(?:gz|br)$/.exec(key)
226
+ return m ? all.has(key.slice(0, m.index)) : false
227
+ }
228
+
229
+ export interface DeployResult { pruned: number; keys: string[] }
230
+
231
+ /**
232
+ * What build-time route discovery enumerated for this generate, plus WHY that enumeration is known to be
233
+ * short of the site's pages (`incomplete` unset ⇒ it is complete). The runtime publisher's
234
+ * `{ routes, failed }` is the same contract: an enumeration that came up short must never be the
235
+ * authority for what gets deleted.
236
+ */
237
+ export interface RouteDiscovery { routes: string[]; incomplete?: string }
238
+
239
+ interface DiscoveryHost { _kestrelRouteDiscovery?: RouteDiscovery }
240
+
241
+ /** The prerender and deploy modules are loaded separately and share no module scope — but they are handed
242
+ * the same Nuxt instance, so that object is what carries the signal from the one to the other. */
243
+ export function recordRouteDiscovery(nuxt: object, discovery: RouteDiscovery): void {
244
+ (nuxt as DiscoveryHost)._kestrelRouteDiscovery = discovery
245
+ }
246
+
247
+ /** `undefined` ⇒ discovery never reported anything, which is not itself evidence of a degraded build. */
248
+ export function readRouteDiscovery(nuxt: object): RouteDiscovery | undefined {
249
+ return (nuxt as DiscoveryHost)._kestrelRouteDiscovery
250
+ }
251
+
252
+ /** The deploy needs `put` always, and `list`+`delete` for the reconcile; both are optional on the driver. */
253
+ type DeployDriver = Pick<StorageDriver, 'put'> & Partial<Pick<StorageDriver, 'list' | 'delete'>>
254
+
255
+ export interface DeployOptions {
256
+ dryRun?: boolean
257
+ log?: (msg: string) => void
258
+ /** Max operations in flight at once (default 8). Bounded so a large tree never opens thousands of sockets. */
259
+ concurrency?: number
260
+ /** Total attempts per operation before giving up (default 3); retries ride out transient S3 errors. */
261
+ retries?: number
262
+ /** Sleep between retries (exponential backoff); injectable so tests run without real delay. */
263
+ sleep?: (ms: number) => Promise<void>
264
+ /**
265
+ * Why a build step REPORTED that this output tree is short of pages (routes that failed to prerender,
266
+ * route discovery that could not read the DB). Set ⇒ upload but never reconcile: pages missing from the
267
+ * input are indistinguishable from pages removed from the CMS, so the prune would delete live ones.
268
+ * Only a step's own failure report belongs here: the SHAPE of the output cannot tell a degraded build
269
+ * from a small site, so inferring it would strand the pages an editor deleted.
270
+ */
271
+ incomplete?: string
272
+ }
273
+
274
+ /** Run `task` over `items` with at most `concurrency` in flight at once. */
275
+ async function runPool<T>(items: T[], concurrency: number, task: (item: T) => Promise<void>): Promise<void> {
276
+ let next = 0
277
+ const worker = async (): Promise<void> => {
278
+ while (next < items.length) {
279
+ const i = next++
280
+ await task(items[i])
281
+ }
282
+ }
283
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker))
284
+ }
285
+
286
+ /** HTTP statuses the S3 driver surfaces in its error message (e.g. `S3 put failed (403) for x`). */
287
+ function statusFromError(err: unknown): number | undefined {
288
+ const m = /\((\d{3})\)/.exec((err as Error)?.message ?? '')
289
+ return m ? Number(m[1]) : undefined
290
+ }
291
+
292
+ /**
293
+ * A permanent client error — bad/forbidden credentials, missing bucket, denied access (400/401/403/404).
294
+ * These never become successful on a later attempt, so retrying only delays the inevitable loud failure.
295
+ */
296
+ export function isPermanentError(err: unknown): boolean {
297
+ const s = statusFromError(err)
298
+ return s === 400 || s === 401 || s === 403 || s === 404
299
+ }
300
+
301
+ /** Retry `fn` with exponential backoff up to `retries` total attempts, surfacing the last error. */
302
+ async function withRetry(fn: () => Promise<void>, label: string, retries: number, sleep: (ms: number) => Promise<void>, log?: (msg: string) => void): Promise<void> {
303
+ for (let attempt = 1; ; attempt++) {
304
+ try {
305
+ await fn()
306
+ return
307
+ } catch (err) {
308
+ // A permanent 4xx can't be ridden out — re-throw at once so the deploy fails loudly, not slowly.
309
+ if (isPermanentError(err) || attempt >= retries) throw err
310
+ log?.(`retry ${attempt}/${retries - 1} for ${label}: ${(err as Error).message}`)
311
+ await sleep(100 * 2 ** (attempt - 1))
312
+ }
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Upload every file under `dir` to `driver`, keyed by its POSIX-relative path, with a content type
318
+ * inferred from the extension — the S3 equivalent of `aws s3 sync` (overwrites). Then reconciles: deletes
319
+ * remote objects under the prefix that this generate didn't produce (`--delete`), so pages removed from
320
+ * the CMS stop being served (output ≡ DB, no toggle). The reconcile is skipped when the driver can't
321
+ * list+delete and when the caller flagged the tree `incomplete` — a build step reported it is short of
322
+ * pages, so their absence says nothing about the CMS. Uploads still run then: shipping the pages that did
323
+ * render is safe, deleting on their absence is not.
324
+ * Uploads and deletes run with bounded concurrency and each is retried with
325
+ * backoff, so a transient 5xx/throttle/dropped-connection no longer aborts the deploy mid-way and
326
+ * half-updates the bucket; only an operation that fails every attempt rejects (true rollback isn't
327
+ * possible on S3). With `dryRun`, walks/lists and reports without writing.
328
+ */
329
+ export async function deployStaticOutput(
330
+ dir: string,
331
+ driver: DeployDriver,
332
+ opts: DeployOptions = {},
333
+ ): Promise<DeployResult> {
334
+ const concurrency = Math.max(1, opts.concurrency ?? 8)
335
+ const retries = Math.max(1, opts.retries ?? 3)
336
+ const sleep = opts.sleep ?? ((ms: number) => sleepFor(ms))
337
+ const all = listFiles(dir)
338
+ const allKeys = new Set(all.map((f) => f.key))
339
+ const files = all.filter((f) => !isCompressedSidecar(f.key, allKeys))
340
+ const skipped = all.length - files.length
341
+ if (skipped) opts.log?.(`skipping ${skipped} pre-compressed sidecar(s) (.gz/.br)`)
342
+ const keys = files.map((f) => f.key)
343
+
344
+ if (!opts.dryRun) {
345
+ // `files` already excludes pre-compressed sidecars (above), so anything ending `.gz`/`.br` here is a
346
+ // standalone archive shipped with no `Content-Encoding` — keep its own extension in the type lookup.
347
+ await runPool(files, concurrency, (f) =>
348
+ withRetry(() => driver.put(f.key, readFileSync(f.abs), contentTypeFor(f.key, false), { cacheControl: cacheControlFor(f.key) }), f.key, retries, sleep, opts.log))
349
+ }
350
+ opts.log?.(`${opts.dryRun ? 'Would deploy' : 'Deployed'} ${keys.length} file(s)`)
351
+
352
+ // Reconcile (output ≡ DB) — skipped when the driver lacks list/delete (e.g. a put-only target) and when
353
+ // a build step reported that this generate is short of pages.
354
+ let pruned = 0
355
+ if (!driver.list || !driver.delete) {
356
+ opts.log?.('driver cannot list/delete — skipping reconcile')
357
+ } else if (opts.incomplete) {
358
+ opts.log?.(`skipping reconcile — ${opts.incomplete}; the missing pages would read as stale and be deleted`)
359
+ } else {
360
+ const keep = new Set(keys)
361
+ const stale = (await driver.list()).filter((k) => !keep.has(k))
362
+ if (!opts.dryRun) {
363
+ const del = driver.delete
364
+ await runPool(stale, concurrency, (k) => withRetry(() => del(k), `delete ${k}`, retries, sleep, opts.log))
365
+ pruned = stale.length
366
+ }
367
+ opts.log?.(`${opts.dryRun ? 'Would prune' : 'Pruned'} ${stale.length} stale object(s)`)
368
+ }
369
+ return { pruned, keys }
370
+ }
@@ -0,0 +1,82 @@
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
+
8
+ /**
9
+ * Ships `nuxt generate`'s static output (`.output/public`) to an S3 bucket when `kestrel.output.driver`
10
+ * is `s3` — the deploy half of FEATURES "Output target: local directory or S3 bucket". Runs on Nitro's
11
+ * `compiled` hook (which fires once the full output is on disk — prerendered HTML, the `_nuxt` client
12
+ * bundle + static `public/` assets copied by `copyPublicAssets`, and any compressed variants — i.e. after
13
+ * prerendering, which does NOT abort on a route error unless `prerender.failOnError` is set: hence the
14
+ * `incomplete` signal below), and only when `nitro.options.static` is set, i.e. a real `nuxt generate`.
15
+ * A plain `nuxt build`/`dev` never deploys.
16
+ * Nor does it with `output.auto` on — then the running server owns publishing (see the `auto` note below).
17
+ * With `driver: local` (the default) it does nothing — the local `.output/public` tree is the artifact.
18
+ * S3 credentials are env-only (`KESTREL_OUTPUT_S3_*`, falling back to the shared media `KESTREL_S3_*`).
19
+ */
20
+ export default defineNuxtModule({
21
+ meta: { name: 'kestrel-deploy-output' },
22
+ setup(_options, nuxt) {
23
+ // Build-time: resolve from the consumer's config through the one shared resolver, so driver, prefixes
24
+ // and `auto` here are byte-identical to what the runtime publisher and the media driver see — an
25
+ // ad-hoc env probe that disagrees (env-first vs config-first) would silently disarm the guards below.
26
+ const c = resolveKestrel(nuxt.options.kestrel as KestrelConfig, process.env, nuxt.options.rootDir)
27
+ if (c.output.driver !== 's3') return
28
+ const target = c.output.s3
29
+
30
+ // Where the S3 media driver keeps live uploads — used to stop the reconcile from wiping them when the
31
+ // generated site shares its bucket. Undefined (media on the local driver) makes the guard a no-op.
32
+ const mediaS3 = c.media.driver === 's3' ? c.media.s3 : undefined
33
+
34
+ nuxt.hook('nitro:init', (nitro) => {
35
+ // Nitro prerenders BEFORE the build that fires `compiled`, and `prerender.failOnError` is off by
36
+ // default — a route that 5xx'd leaves no file and does not abort the generate. Remember that here:
37
+ // its live page is still there and must not be mistaken for content removed from the CMS.
38
+ let failedRoutes = 0
39
+ nitro.hooks.hook('prerender:done', ({ failedRoutes: failed }) => { failedRoutes = failed?.length ?? 0 })
40
+
41
+ nitro.hooks.hook('compiled', async () => {
42
+ const env = process.env
43
+ const creds = resolveOutputCreds(env)
44
+ const dryRun = isEnvTrue(env.KESTREL_OUTPUT_DRY_RUN)
45
+ // Skip on a plain build (no static generate) or when the runtime publisher owns the bucket;
46
+ // throw on a misconfigured real deploy or unsafe reconcile.
47
+ if (planS3Deploy({
48
+ isStaticGenerate: nitro.options.static === true,
49
+ autoPublish: c.output.auto,
50
+ dryRun,
51
+ bucket: target.bucket,
52
+ accessKeyId: creds.accessKeyId,
53
+ secretAccessKey: creds.secretAccessKey,
54
+ prefix: target.prefix,
55
+ mediaBucket: mediaS3?.bucket,
56
+ mediaPrefix: mediaS3?.prefix,
57
+ }) === 'skip') {
58
+ // A plain build skips silently; someone who actually ran `nuxt generate` deserves to hear why
59
+ // nothing shipped.
60
+ if (nitro.options.static === true && c.output.auto) {
61
+ console.log('[kestrel] output.auto is on — the running server publishes to this bucket; not shipping'
62
+ + ' .output/public. Set output.auto=false (or KESTREL_OUTPUT_AUTO=false) for the build-time model.')
63
+ }
64
+ return
65
+ }
66
+
67
+ const driver = createS3Driver({ ...target, publicBaseUrl: '', ...creds })
68
+ console.log(`[kestrel] ${dryRun ? 'dry-run: ' : ''}shipping static output → s3://${target.bucket || '<bucket>'}/${target.prefix}`)
69
+ // Both signals are REPORTED failures of a step that knows: route discovery could not enumerate the
70
+ // pages, or Nitro could not render some of them. Neither is inferred from how the tree looks —
71
+ // no shape of output distinguishes a degraded build from a small site.
72
+ const discovery = readRouteDiscovery(nuxt)
73
+ const res = await deployStaticOutput(nitro.options.output.publicDir, driver, {
74
+ dryRun,
75
+ incomplete: discovery?.incomplete ?? (failedRoutes ? `${failedRoutes} route(s) failed to prerender` : undefined),
76
+ log: (m) => console.log(`[kestrel] ${m}`),
77
+ })
78
+ console.log(`[kestrel] static output ${dryRun ? 'walk' : 'deploy'} complete — ${res.keys.length} file(s), ${res.pruned} pruned.`)
79
+ })
80
+ })
81
+ },
82
+ })
@@ -0,0 +1,37 @@
1
+ import type Database from 'better-sqlite3'
2
+ import { pagesToRoutes, type PageRouteRow } from './routes'
3
+
4
+ /** Tables the schema engine marked routable, by their partial `path` index (see `collectPageRoutes`).
5
+ * Empty means this file holds no page-like collection at all — a structural fact about the schema, not a
6
+ * statement about how many pages exist: a CMS with one page still has its table and that index. */
7
+ export function pageLikeTables(db: Database.Database): string[] {
8
+ return (db
9
+ .prepare("SELECT DISTINCT tbl_name AS name FROM sqlite_master WHERE type = 'index' AND sql LIKE '%path is not null%'")
10
+ .all() as { name: string }[]).map((t) => t.name)
11
+ }
12
+
13
+ /**
14
+ * Page routes to seed for `nuxt generate`, discovered straight from the build-time DB (no registry is
15
+ * available at build). Every page-like collection carries a **partial unique index on `path`**
16
+ * (`… WHERE path is not null`) — the schema engine's marker for a routable table — so we enumerate those.
17
+ * That is reliable where "has a `path` column" is NOT: the media `folders` registry has a `path` column +
18
+ * a plain unique index, but no such partial clause, so it is correctly excluded. Per pageLike table:
19
+ * published rows (only where a `status` column exists) with a non-null path, keyed by their own `locale`
20
+ * (or the primary locale for a non-translatable collection). Returns the deduped, sorted route list
21
+ * (always including the root).
22
+ */
23
+ export function collectPageRoutes(db: Database.Database, primaryLocale: string, prefixPrimary = false): string[] {
24
+ const rows: PageRouteRow[] = []
25
+ for (const name of pageLikeTables(db)) {
26
+ const q = `"${name.replace(/"/g, '""')}"`
27
+ const cols = (db.prepare(`PRAGMA table_info(${q})`).all() as { name: string }[]).map((c) => c.name)
28
+ const statusClause = cols.includes('status') ? "status = 'published' AND " : ''
29
+ if (cols.includes('locale')) {
30
+ rows.push(...(db.prepare(`SELECT path, locale FROM ${q} WHERE ${statusClause}path IS NOT NULL`).all() as PageRouteRow[]))
31
+ } else {
32
+ const bare = db.prepare(`SELECT path FROM ${q} WHERE ${statusClause}path IS NOT NULL`).all() as { path: string | null }[]
33
+ for (const r of bare) rows.push({ path: r.path, locale: primaryLocale })
34
+ }
35
+ }
36
+ return pagesToRoutes(rows, primaryLocale, prefixPrimary)
37
+ }
@@ -0,0 +1,85 @@
1
+ import { cpus } from 'node:os'
2
+ import { existsSync } from 'node:fs'
3
+ import { defineNuxtModule } from '@nuxt/kit'
4
+ import Database from 'better-sqlite3'
5
+ import { collectPageRoutes, pageLikeTables } from './discover'
6
+ import { localePath } from '../../../core/app/utils/locale-path'
7
+ import { recordRouteDiscovery, type RouteDiscovery } from '../deploy-output/deploy-output'
8
+ import { resolveKestrel, type KestrelConfig } from '../../../core/server/utils/kestrel-config'
9
+
10
+ // Read published paths from EVERY page-like collection straight from the DB at build time so
11
+ // `nuxt generate` prerenders one HTML file per page (see `collectPageRoutes` for how pageLike tables
12
+ // are identified). The server's migrate-on-boot hasn't run yet at this point, so a DB that isn't there
13
+ // yet degrades to the root alone — reported as `incomplete`, because a deploy that reconciles must be
14
+ // able to tell that degrade apart from a CMS that genuinely holds one page.
15
+ // `reconciles` (an S3 deploy follows this build) makes an unreadable-but-present DB fail the build
16
+ // instead: there the degraded tree would also be uploaded over a real live site. Without that step there
17
+ // is nothing destructive downstream, so a degrade stays a warning.
18
+ export function discoverRoutes(path: string, primary: string, prefixPrimary: boolean, reconciles = false): RouteDiscovery {
19
+ const root = localePath('/', primary, primary, prefixPrimary) // `/` or `/<primary>` when the primary is prefixed
20
+ if (path === ':memory:') return { routes: [root], incomplete: 'the build ran against an in-memory database — only the site root was prerendered' }
21
+ if (!existsSync(path)) {
22
+ console.warn(`[kestrel] prerender: no database at ${path} — prerendering the site root only`)
23
+ return { routes: [root], incomplete: `no database at ${path} — only the site root was prerendered` }
24
+ }
25
+ let db: Database.Database | undefined
26
+ try {
27
+ db = new Database(path, { readonly: true, fileMustExist: true })
28
+ // Opening the file is not enumerating it: an unmigrated, zero-byte or simply wrong `KESTREL_DB` file
29
+ // opens as a valid EMPTY database, and the root-only route list it yields is byte-identical to a
30
+ // complete enumeration. Report the missing schema so a reconcile downstream can tell the two apart.
31
+ if (!pageLikeTables(db).length) {
32
+ const why = `the database at ${path} holds no page-like table — only the site root was prerendered`
33
+ console.warn(`[kestrel] prerender: ${why}`)
34
+ return { routes: [root], incomplete: why }
35
+ }
36
+ return { routes: collectPageRoutes(db, primary, prefixPrimary) }
37
+ } catch (error) {
38
+ const why = `cannot read the database at ${path}: ${(error as Error)?.message ?? error}`
39
+ if (reconciles) throw new Error(`[kestrel] prerender: ${why} — refusing to ship a site with no pages to an S3 target`)
40
+ console.warn(`[kestrel] prerender: ${why} — prerendering the site root only`)
41
+ return { routes: [root], incomplete: why }
42
+ } finally {
43
+ db?.close()
44
+ }
45
+ }
46
+
47
+ export default defineNuxtModule({
48
+ meta: { name: 'kestrel-prerender-routes' },
49
+ setup(_options, nuxt) {
50
+ // Build-time: resolve directly from the consumer's config (runtimeConfig/useRuntimeConfig aren't
51
+ // available here), rather than the request-time locale.ts globals.
52
+ const c = resolveKestrel(nuxt.options.kestrel as KestrelConfig, process.env, nuxt.options.rootDir)
53
+ // The editor-preview fallback page must NEVER be prerendered: the classic `nuxt generate` (static
54
+ // preset) auto-seeds every static page path, and this page 404s without an admin session — a
55
+ // guaranteed prerender error on every SSG build. Registered unconditionally (before the output.auto
56
+ // early-return) so both publishing models exclude it.
57
+ nuxt.hook('nitro:config', (nitro) => {
58
+ nitro.prerender ||= {}
59
+ nitro.prerender.ignore = [...(nitro.prerender.ignore ?? []), '/__kestrel/preview']
60
+ })
61
+ // The runtime publisher (output.auto) and build-time prerendering are mutually exclusive: a prerendered
62
+ // `.output/public/<route>/index.html` SHADOWS the live SSR route, so the publisher's localFetch would
63
+ // read STALE build-time HTML instead of rendering fresh. With auto on, the publisher (boot + on-write)
64
+ // owns publishing — so skip prerender seeding entirely. (auto off = the classic `nuxt generate` SSG model.)
65
+ if (c.output.auto) return
66
+ nuxt.hook('nitro:config', (nitro) => {
67
+ // Only an S3 target reconciles (deletes what this build didn't produce); a local build leaves the
68
+ // tree on disk, so an incomplete enumeration destroys nothing there. `nitro.static` is the same flag
69
+ // the deploy itself gates on: this hook also runs for `nuxt dev` and a plain `nuxt build`, and
70
+ // neither ships anything, so there a degrade must stay a warning instead of failing the command.
71
+ const reconciles = c.output.driver === 's3' && nitro.static === true
72
+ const discovery = discoverRoutes(c.dbPath, c.primaryLocale, c.prefixPrimary, reconciles)
73
+ // Hand the deploy module the completeness of THIS enumeration — the only step that knows it.
74
+ recordRouteDiscovery(nuxt, discovery)
75
+ nitro.prerender ||= {}
76
+ nitro.prerender.routes = [...new Set([...(nitro.prerender.routes ?? []), ...discovery.routes, '/sitemap.xml', '/robots.txt', '/llms.txt'])]
77
+ // Render pages in parallel — the dominant lever on `nuxt generate` wall-clock as page count grows.
78
+ // Safe: page reads are synchronous better-sqlite3 (WAL + busy_timeout, can't interleave mid-statement);
79
+ // the one WRITE on this path — the variant-registry capture — is an IMMEDIATE transaction, so
80
+ // concurrent prerender writers (and a co-running server publisher) serialize instead of losing updates.
81
+ // Capped to avoid memory over-subscription; overridable.
82
+ nitro.prerender.concurrency ??= Math.min(Math.max(cpus().length, 2), 8)
83
+ })
84
+ },
85
+ })
@@ -0,0 +1,16 @@
1
+ import { localePath } from '../../../core/app/utils/locale-path'
2
+
3
+ export interface PageRouteRow {
4
+ path: string | null
5
+ locale: string
6
+ }
7
+
8
+ export function pagesToRoutes(rows: PageRouteRow[], primaryLocale: string, prefixPrimary = false): string[] {
9
+ // The site root is the primary-locale home — `/` normally, `/<primary>` when the primary is prefixed.
10
+ const routes = new Set<string>([localePath('/', primaryLocale, primaryLocale, prefixPrimary)])
11
+ for (const { path, locale } of rows) {
12
+ if (!path) continue
13
+ routes.add(localePath(path, locale, primaryLocale, prefixPrimary))
14
+ }
15
+ return [...routes].sort()
16
+ }
@@ -0,0 +1,48 @@
1
+ import { defineNuxtModule } from '@nuxt/kit'
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'
5
+ import { pruneUnreferencedMedia, mediaOwnedKeys } from './prune-media'
6
+
7
+ // The set of storage keys the media library owns (originals + derivatives), read straight from the DB at
8
+ // build time. Returns null when the registry can't be read (no DB, no media table) — the caller then SKIPS
9
+ // the prune entirely rather than risk deleting extension/consumer blobs (e.g. gallery ciphertext).
10
+ function readOwnedKeys(dbPath: string): Set<string> | null {
11
+ if (dbPath === ':memory:') return null
12
+ try {
13
+ const db = new Database(dbPath, { readonly: true, fileMustExist: true })
14
+ try {
15
+ const rows = db.prepare('SELECT storage_key AS storageKey, derivatives FROM media').all() as { storageKey: string; derivatives: string | null }[]
16
+ return mediaOwnedKeys(rows.map((r) => ({ storageKey: r.storageKey, derivatives: r.derivatives ? JSON.parse(r.derivatives) : null })))
17
+ } finally {
18
+ db.close()
19
+ }
20
+ } catch {
21
+ return null
22
+ }
23
+ }
24
+
25
+ // `nuxt generate` bakes the WHOLE upload dir into .output/public/<baseUrl>; without this, images of
26
+ // deleted/unpublished pages ship to prod. At `compiled` (full output on disk, post prerender-abort) delete
27
+ // every baked media file no generated page references — prunes the BAKE only, so a wrong prune costs a
28
+ // re-generate, not data. Local-driver only (only it registers the /uploads publicAssets that gets baked).
29
+ // If deploy-output is ever added to modules[], it must come AFTER this so the prune runs before the ship.
30
+ export default defineNuxtModule({
31
+ meta: { name: 'kestrel-prune-media' },
32
+ setup(_options, nuxt) {
33
+ const c = resolveKestrel(nuxt.options.kestrel as KestrelConfig, process.env, nuxt.options.rootDir)
34
+ if (c.media.driver !== 'local') return
35
+ const dryRun = isEnvTrue(process.env.KESTREL_OUTPUT_DRY_RUN)
36
+ nuxt.hook('nitro:init', (nitro) => {
37
+ nitro.hooks.hook('compiled', () => {
38
+ if (nitro.options.static !== true) return
39
+ const ownedKeys = readOwnedKeys(c.dbPath)
40
+ if (!ownedKeys) {
41
+ console.log('[kestrel] media prune skipped: media registry unavailable (would risk deleting extension/consumer blobs)')
42
+ return
43
+ }
44
+ pruneUnreferencedMedia(nitro.options.output.publicDir, c.media.baseUrl, { dryRun, ownedKeys, log: (m) => console.log(`[kestrel] ${m}`) })
45
+ })
46
+ })
47
+ },
48
+ })