@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,149 @@
1
+ import { fieldIs, isSingleRefColumn, type CollectionDef, type Condition, type FieldDef, type FieldType, type Localized } from './defineCollection'
2
+ import { resolveFieldLayout, type LayoutNode } from './field-layout'
3
+
4
+ export interface SerializedField {
5
+ type: FieldType
6
+ required: boolean
7
+ translatable: boolean
8
+ unique: boolean
9
+ /** Optional (localized) editor label; the admin falls back to the humanized field key when absent. */
10
+ label?: Localized
11
+ /** True when stored as a single `<name>Id` FK column (single relation/media). The editor reads this
12
+ * to derive the wire/column key instead of re-deriving the rule. Omitted (falsy) for normal fields. */
13
+ single?: boolean
14
+ options?: Record<string, unknown>
15
+ relation?: { collection: string; many: boolean; labelField?: string }
16
+ default?: unknown
17
+ /** Visibility logic (see `Condition`). Crosses to the admin so the editor can hide the field; it is
18
+ * JSON-safe by construction, so it passes the wire (and the `asFieldDef` cast) unchanged. */
19
+ condition?: Condition
20
+ }
21
+
22
+ export interface SerializedCollection {
23
+ name: string
24
+ mode: 'multi' | 'single'
25
+ translatable: boolean
26
+ pageLike: boolean
27
+ seo: boolean
28
+ status: boolean
29
+ blocks: { enabled: boolean; allowed?: string[] }
30
+ /** The admin editor body to render — resolved once here so the client just reads it. Defaults from the
31
+ * `blocks` flag (`'blocks'` / `'fields'`); an explicit `def.editor` (incl. extension types) wins. */
32
+ editor: string
33
+ /** Whether the collection appears in the admin rail (default true; `false` = system/config store). */
34
+ nav: boolean
35
+ label?: { singular?: Localized; plural?: Localized; new?: Localized }
36
+ icon?: string
37
+ fields: Record<string, SerializedField>
38
+ /** Normalized admin editor layout (rows / named groups). Resolved once here; absent when the author
39
+ * gave none (the admin then renders one field per row). Plain JSON — crosses the wire untouched. */
40
+ fieldLayout?: LayoutNode[]
41
+ }
42
+
43
+ // A field `default` is typed `unknown`; it crosses the HTTP/JSON boundary verbatim, so anything
44
+ // that is not plain JSON (Date, Map, Set, function, NaN, class instance, …) would silently mismatch
45
+ // or throw on serialize. Keep only values that survive JSON unchanged.
46
+ function isJsonSafe(value: unknown): boolean {
47
+ if (value === null) return true
48
+ switch (typeof value) {
49
+ case 'string':
50
+ case 'boolean':
51
+ return true
52
+ case 'number':
53
+ return Number.isFinite(value)
54
+ case 'object': {
55
+ if (Array.isArray(value)) return value.every(isJsonSafe)
56
+ const proto = Object.getPrototypeOf(value)
57
+ if (proto !== Object.prototype && proto !== null) return false
58
+ return Object.values(value as Record<string, unknown>).every(isJsonSafe)
59
+ }
60
+ default:
61
+ return false
62
+ }
63
+ }
64
+
65
+ export function serializeField(field: FieldDef): SerializedField {
66
+ const out: SerializedField = {
67
+ type: field.type,
68
+ required: field.required ?? false,
69
+ translatable: field.translatable ?? false,
70
+ unique: field.unique ?? false,
71
+ }
72
+ if (isSingleRefColumn(field)) out.single = true
73
+ if (field.label) out.label = field.label
74
+ if ('default' in field && isJsonSafe(field.default)) {
75
+ out.default = field.default
76
+ }
77
+ // Top-level (never inside `options`, which relations don't set and repeaters overwrite). One copy
78
+ // here flows to collection fields, repeater sub-fields (the recursion below), and serializeBlock.
79
+ if (field.condition) out.condition = field.condition
80
+ if (fieldIs(field, 'relation')) {
81
+ out.relation = {
82
+ collection: field.relation.collection,
83
+ many: field.relation.many ?? false,
84
+ ...(field.relation.labelField ? { labelField: field.relation.labelField } : {}),
85
+ }
86
+ } else if (fieldIs(field, 'repeater')) {
87
+ out.options = {
88
+ fields: serializeFields(field.options.fields),
89
+ ...(field.options.fieldLayout
90
+ ? { fieldLayout: resolveFieldLayout(field.options.fieldLayout, Object.keys(field.options.fields), 'repeater') }
91
+ : {}),
92
+ }
93
+ } else if ('options' in field && field.options && isJsonSafe(field.options)) {
94
+ // Gate options through the same JSON-safety guard as `default`: a custom defineFieldType config carrying a
95
+ // Date/function/Map/NaN would otherwise throw (or silently corrupt) the whole /api/collections response.
96
+ out.options = field.options as Record<string, unknown>
97
+ }
98
+ return out
99
+ }
100
+
101
+ export function serializeFields(fields: Record<string, FieldDef>): Record<string, SerializedField> {
102
+ const out: Record<string, SerializedField> = {}
103
+ for (const [key, def] of Object.entries(fields)) out[key] = serializeField(def)
104
+ return out
105
+ }
106
+
107
+ export function serializeCollection(def: CollectionDef): SerializedCollection {
108
+ return {
109
+ name: def.name,
110
+ mode: def.mode,
111
+ translatable: def.translatable ?? false,
112
+ pageLike: def.pageLike ?? false,
113
+ seo: def.seo ?? false,
114
+ status: def.status ?? false,
115
+ blocks: { enabled: def.blocks?.enabled ?? false, ...(def.blocks?.allowed ? { allowed: def.blocks.allowed } : {}) },
116
+ editor: def.editor ?? (def.blocks?.enabled ? 'blocks' : 'fields'),
117
+ nav: def.nav !== false,
118
+ ...(def.label ? { label: def.label } : {}),
119
+ ...(def.icon ? { icon: def.icon } : {}),
120
+ fields: serializeFields(def.fields),
121
+ ...(def.fieldLayout
122
+ ? { fieldLayout: resolveFieldLayout(def.fieldLayout, Object.keys(def.fields), `collection "${def.name}"`) }
123
+ : {}),
124
+ }
125
+ }
126
+
127
+ export interface SerializedBlock {
128
+ name: string
129
+ label?: Localized
130
+ slots?: string[]
131
+ /** Icon name for the admin block picker (from the SFC's `defineBlock({ icon })`). */
132
+ icon?: string
133
+ /** Preview image (URL/path) shown above the name in the admin block picker (from `defineBlock({ image })`). */
134
+ image?: string
135
+ fields: Record<string, SerializedField>
136
+ }
137
+
138
+ // Structurally typed (not importing BlockDef) so `core` stays independent of the `fields` layer.
139
+ // Block fields serialize identically to collection fields, so the admin FieldRenderer reuses them as-is.
140
+ export function serializeBlock(def: { name: string; label?: Localized; slots?: string[]; icon?: string; image?: string; fields: Record<string, FieldDef> }): SerializedBlock {
141
+ return {
142
+ name: def.name,
143
+ ...(def.label ? { label: def.label } : {}),
144
+ ...(def.slots?.length ? { slots: def.slots } : {}),
145
+ ...(def.icon ? { icon: def.icon } : {}),
146
+ ...(def.image ? { image: def.image } : {}),
147
+ fields: serializeFields(def.fields),
148
+ }
149
+ }
@@ -0,0 +1,18 @@
1
+ import kestrelConfig from '../../../../kestrel.config'
2
+ import { resolveKestrel } from './kestrel-config'
3
+
4
+ /**
5
+ * The Nitro runtimeConfig, or `undefined` for non-Nitro callers (node scripts, build) so they don't
6
+ * throw on `useRuntimeConfig()`. Server utils prefer the values the kestrel module put here (from the
7
+ * consumer's `kestrel: {}`), falling back to `resolveServerKestrel()` when it isn't populated.
8
+ */
9
+ export function serverRuntimeConfig(): { kestrel?: Record<string, unknown>; public?: Record<string, unknown> } | undefined {
10
+ return typeof useRuntimeConfig === 'function'
11
+ ? (useRuntimeConfig() as { kestrel?: Record<string, unknown>; public?: Record<string, unknown> })
12
+ : undefined
13
+ }
14
+
15
+ /** Resolve Kestrel's own config file + env — the fallback when runtimeConfig isn't populated. */
16
+ export function resolveServerKestrel(): ReturnType<typeof resolveKestrel> {
17
+ return resolveKestrel(kestrelConfig, process.env, process.cwd())
18
+ }
@@ -0,0 +1,2 @@
1
+ /** Escape LIKE metacharacters so a search for "a_b" / "%" matches them literally (paired with ESCAPE '\'). */
2
+ export const escapeLike = (s: string): string => s.replace(/[\\%_]/g, (ch) => `\\${ch}`)
@@ -0,0 +1,117 @@
1
+ import { mkdir, writeFile, readFile, unlink, access, copyFile, rm, rmdir, readdir, stat, rename } from 'node:fs/promises'
2
+ import { dirname, resolve, sep } from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+ import type { StorageDriver } from './storage'
5
+
6
+ function joinURL(base: string, path: string): string {
7
+ return base.replace(/\/+$/, '') + '/' + path.replace(/^\/+/, '')
8
+ }
9
+
10
+ /** Whether an fs error proves the key is not there (missing, or a path component that is not a directory).
11
+ * Anything else — EACCES, EIO/ESTALE on a network volume — means the driver could not TELL, and callers key
12
+ * destructive decisions off that difference (the gallery index guard treats "absent" as licence to skip its
13
+ * concurrency check and prune unreferenced ciphertext), so it must surface instead of reading as absence. */
14
+ function provesMissing(error: unknown): boolean {
15
+ const code = (error as NodeJS.ErrnoException | null)?.code
16
+ return code === 'ENOENT' || code === 'ENOTDIR'
17
+ }
18
+
19
+ export function createLocalDriver(opts: { dir: string; baseUrl: string }): StorageDriver {
20
+ const root = resolve(opts.dir)
21
+ const abs = (key: string) => {
22
+ const path = resolve(root, key)
23
+ if (path !== root && !path.startsWith(root + sep)) {
24
+ throw new Error(`storage key escapes the uploads root: ${key}`)
25
+ }
26
+ return path
27
+ }
28
+ return {
29
+ async put(key, bytes) {
30
+ const path = abs(key)
31
+ await mkdir(dirname(path), { recursive: true })
32
+ // Write to a temp sibling then atomically rename into place: an overwrite (replace-upload, backfill
33
+ // re-put, republished HTML) never exposes a truncated/partial file to a concurrent reader (nginx / the
34
+ // static host), and a crash mid-write leaves the stale temp file, not a corrupt object at the real key.
35
+ const tmp = `${path}.${randomUUID()}.tmp`
36
+ try {
37
+ await writeFile(tmp, bytes)
38
+ await rename(tmp, path)
39
+ } catch (error) {
40
+ await unlink(tmp).catch(() => {})
41
+ throw error
42
+ }
43
+ },
44
+ async copy(srcKey, dstKey) {
45
+ const dst = abs(dstKey)
46
+ await mkdir(dirname(dst), { recursive: true })
47
+ await copyFile(abs(srcKey), dst)
48
+ },
49
+ async delete(key, opts) {
50
+ const path = abs(key)
51
+ try { await unlink(path) } catch (error) { if (!provesMissing(error)) throw error }
52
+ // Opt-in (publisher) only: remove parent dirs left empty (up to — never including — the root), so
53
+ // unpublishing `/a/b` leaves no orphan empty `a/b/`. OFF by default so the media library keeps a
54
+ // user-created folder after its last file is deleted. Stops climbing at the first non-empty dir.
55
+ if (!opts?.pruneEmptyDirs) return
56
+ let dir = dirname(path)
57
+ while (dir !== root && dir.startsWith(root + sep)) {
58
+ try { await rmdir(dir) } catch { break } // ENOTEMPTY (still holds files) or already gone → stop
59
+ dir = dirname(dir)
60
+ }
61
+ },
62
+ publicUrl(key) { return joinURL(opts.baseUrl, key) },
63
+ async get(key) {
64
+ // abs() reapplies the escape-guard: an attacker-influenced key must never read outside the root.
65
+ return readFile(abs(key))
66
+ },
67
+ async exists(key) {
68
+ try { await access(abs(key)); return true } catch (error) { if (provesMissing(error)) return false; throw error }
69
+ },
70
+ async stat(key) {
71
+ try { return { mtimeMs: (await stat(abs(key))).mtimeMs } } catch (error) { if (provesMissing(error)) return null; throw error }
72
+ },
73
+ async ensureDir(folder) {
74
+ await mkdir(abs(folder), { recursive: true })
75
+ },
76
+ async removeDir(folder) {
77
+ if (!folder) return
78
+ const path = abs(folder)
79
+ if (path === root) return // never remove the uploads root ('.', 'a/..', etc. resolve to it)
80
+ await rm(path, { recursive: true, force: true })
81
+ },
82
+ async list() {
83
+ // Every file under the root as a sorted POSIX-relative key (the put/delete key space), so the
84
+ // publish reconciler can set-difference against the keys it just wrote and prune the rest.
85
+ const out: string[] = []
86
+ async function walk(dir: string, prefix: string): Promise<void> {
87
+ let entries
88
+ try {
89
+ entries = await readdir(dir, { withFileTypes: true })
90
+ } catch (error) {
91
+ // A proven-absent dir (an uploads root not built yet, a subtree deleted concurrently) holds no
92
+ // keys. Anything else means the enumeration could not be performed, and callers that delete on
93
+ // the strength of an empty listing (the media removeDir guards) would read that as "nothing is
94
+ // stored here" and recursively wipe a subtree whose contents merely could not be read.
95
+ if (provesMissing(error)) return
96
+ throw error
97
+ }
98
+ for (const e of entries) {
99
+ const rel = prefix ? `${prefix}/${e.name}` : e.name
100
+ if (e.isDirectory()) await walk(resolve(dir, e.name), rel)
101
+ else if (e.isFile()) out.push(rel)
102
+ }
103
+ }
104
+ await walk(root, '')
105
+ return out.sort()
106
+ },
107
+ async listPrefix(prefix) {
108
+ // A namespace listing: every key under `<prefix>/` (normalised). Filters the full walk — fine for the
109
+ // small per-record namespaces this serves. Empty/`.` prefix → all keys (same as list()).
110
+ const all = await this.list!()
111
+ const p = prefix.replace(/^\/+|\/+$/g, '')
112
+ if (!p) return all
113
+ const under = `${p}/`
114
+ return all.filter((k) => k === p || k.startsWith(under))
115
+ },
116
+ }
117
+ }
@@ -0,0 +1,197 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { AwsClient } from 'aws4fetch'
3
+ import type { StorageDriver } from './storage'
4
+
5
+ /**
6
+ * Non-secret S3 settings plus the env-only credentials. Built by `useStorageDriver` from
7
+ * `runtimeConfig.media.s3`; the factory is otherwise free of Nitro/Nuxt globals so it stays
8
+ * unit-testable with an injected `fetch`.
9
+ */
10
+ export interface S3DriverOptions {
11
+ bucket: string
12
+ /** Signing region (default `us-east-1`). */
13
+ region?: string
14
+ /** Custom endpoint origin for S3-compatible services (R2/MinIO); omit for AWS S3. */
15
+ endpoint?: string
16
+ /** Key prefix prepended to every object key (slashes normalised away). */
17
+ prefix?: string
18
+ /** Public base URL objects are served from; `publicUrl` joins this + the full key. */
19
+ publicBaseUrl: string
20
+ accessKeyId: string
21
+ secretAccessKey: string
22
+ sessionToken?: string
23
+ }
24
+
25
+ const trimSlashes = (s: string) => s.replace(/^\/+|\/+$/g, '')
26
+ const stripTrailing = (s: string) => s.replace(/\/+$/, '')
27
+ const xmlEscape = (s: string) =>
28
+ s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;')
29
+ const xmlUnescape = (s: string) =>
30
+ s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&')
31
+
32
+ /**
33
+ * S3-compatible storage driver over SigV4-signed `fetch` (AWS S3, Cloudflare R2, MinIO). Path-style
34
+ * addressing throughout for maximum compatibility. Credentials are SigV4-signed by `aws4fetch`; the
35
+ * injected `fetchImpl` keeps it fully testable.
36
+ */
37
+ export function createS3Driver(opts: S3DriverOptions, fetchImpl: typeof fetch = globalThis.fetch): StorageDriver {
38
+ const { bucket, publicBaseUrl } = opts
39
+ const region = opts.region || 'us-east-1'
40
+ const prefix = trimSlashes(opts.prefix || '')
41
+ const origin = opts.endpoint ? stripTrailing(opts.endpoint) : `https://s3.${region}.amazonaws.com`
42
+ const bucketUrl = `${origin}/${bucket}`
43
+ const client = new AwsClient({
44
+ accessKeyId: opts.accessKeyId,
45
+ secretAccessKey: opts.secretAccessKey,
46
+ sessionToken: opts.sessionToken,
47
+ service: 's3',
48
+ region,
49
+ })
50
+
51
+ const fullKey = (key: string) => (prefix ? `${prefix}/${key}` : key)
52
+ const encodePath = (k: string) => k.split('/').map(encodeURIComponent).join('/')
53
+ const objectUrl = (key: string) => `${bucketUrl}/${encodePath(fullKey(key))}`
54
+
55
+ async function send(method: string, url: string, init: { body?: Buffer | Uint8Array | string; headers?: Record<string, string> } = {}): Promise<Response> {
56
+ // Buffer/Uint8Array/string are all valid request bodies at runtime; the cast bridges the DOM `BodyInit`
57
+ // type (which omits Node's Buffer) at the single aws4fetch boundary.
58
+ const signed = await client.sign(url, { method, body: init.body as BodyInit | undefined, headers: init.headers })
59
+ return fetchImpl(signed)
60
+ }
61
+
62
+ /** One ListObjectsV2 page: the keys it lists plus the continuation token when more remain. */
63
+ async function listPage(listPrefix: string, token?: string): Promise<{ keys: string[]; next?: string }> {
64
+ const u = new URL(bucketUrl)
65
+ u.searchParams.set('list-type', '2')
66
+ u.searchParams.set('prefix', listPrefix)
67
+ if (token) u.searchParams.set('continuation-token', token)
68
+ const res = await send('GET', u.toString())
69
+ if (!res.ok) throw new Error(`S3 list failed (${res.status}) for prefix ${listPrefix}`)
70
+ const xml = await res.text()
71
+ const keys = [...xml.matchAll(/<Key>([^<]*)<\/Key>/g)].map((m) => xmlUnescape(m[1]))
72
+ const truncated = /<IsTruncated>\s*true\s*<\/IsTruncated>/i.test(xml)
73
+ const next = xml.match(/<NextContinuationToken>([^<]*)<\/NextContinuationToken>/)
74
+ return { keys, next: truncated && next ? xmlUnescape(next[1]) : undefined }
75
+ }
76
+
77
+ /** Multi-object delete (≤1000 keys, one page). Content-MD5 is required by AWS for this request. */
78
+ async function batchDelete(keys: string[]): Promise<void> {
79
+ if (!keys.length) return
80
+ const body = `<?xml version="1.0" encoding="UTF-8"?><Delete>`
81
+ + keys.map((k) => `<Object><Key>${xmlEscape(k)}</Key></Object>`).join('')
82
+ + `<Quiet>true</Quiet></Delete>`
83
+ const md5 = createHash('md5').update(body).digest('base64')
84
+ const res = await send('POST', `${bucketUrl}?delete`, {
85
+ body,
86
+ headers: { 'content-type': 'application/xml', 'content-md5': md5 },
87
+ })
88
+ if (!res.ok) throw new Error(`S3 batch delete failed (${res.status})`)
89
+ // Quiet mode still returns per-key failures as <Error> elements in the 200 body (retention lock,
90
+ // AccessDenied on some keys). Without parsing them a partial delete passes silently while objects
91
+ // remain — fail loud like every other method here, so removeDir/GC callers never claim a false success.
92
+ const text = await res.text()
93
+ if (text.includes('<Error>')) {
94
+ const failed = [...text.matchAll(/<Key>([^<]*)<\/Key>/g)].map((m) => m[1])
95
+ throw new Error(`S3 batch delete reported errors for ${failed.length || 'some'} key(s): ${failed.slice(0, 5).join(', ')}`)
96
+ }
97
+ }
98
+
99
+ return {
100
+ async list() {
101
+ // Page through every object under the driver prefix, returning keys relative to it so they line
102
+ // up with the keys `put`/`delete` take (the deploy compares them against freshly-uploaded keys).
103
+ const listPrefix = prefix ? `${prefix}/` : ''
104
+ const out: string[] = []
105
+ let token: string | undefined
106
+ do {
107
+ const { keys, next } = await listPage(listPrefix, token)
108
+ for (const k of keys) out.push(listPrefix && k.startsWith(listPrefix) ? k.slice(listPrefix.length) : k)
109
+ token = next
110
+ } while (token)
111
+ return out
112
+ },
113
+ async listPrefix(rel) {
114
+ // Keys under `<driverPrefix>/<rel>/`, returned relative to the driver root (same space as list()).
115
+ const r = trimSlashes(rel)
116
+ if (!r) return this.list!()
117
+ const base = prefix ? `${prefix}/` : ''
118
+ const listPrefix = `${fullKey(r)}/`
119
+ const out: string[] = []
120
+ let token: string | undefined
121
+ do {
122
+ const { keys, next } = await listPage(listPrefix, token)
123
+ for (const k of keys) out.push(base && k.startsWith(base) ? k.slice(base.length) : k)
124
+ token = next
125
+ } while (token)
126
+ return out
127
+ },
128
+ async put(key, bytes, contentType, opts) {
129
+ const headers: Record<string, string> = { 'content-type': contentType }
130
+ if (opts?.cacheControl) headers['cache-control'] = opts.cacheControl
131
+ if (opts?.contentEncoding) headers['content-encoding'] = opts.contentEncoding
132
+ const res = await send('PUT', objectUrl(key), { body: bytes, headers })
133
+ if (!res.ok) throw new Error(`S3 put failed (${res.status}) for ${key}`)
134
+ },
135
+ async copy(srcKey, dstKey) {
136
+ const res = await send('PUT', objectUrl(dstKey), {
137
+ headers: { 'x-amz-copy-source': `/${bucket}/${encodePath(fullKey(srcKey))}` },
138
+ })
139
+ if (!res.ok) throw new Error(`S3 copy failed (${res.status}) ${srcKey} → ${dstKey}`)
140
+ // CopyObject can fail *after* the request was accepted and still answer 200, reporting it as an
141
+ // <Error> body instead of a status. Callers (relocate/duplicate) delete the source once copy
142
+ // resolves, so a silent success here destroys the only bytes — parse the body and fail loud.
143
+ // A real success is `<CopyObjectResult>…`; some S3-compatibles send an empty body.
144
+ const text = await res.text()
145
+ if (text.includes('<Error>')) {
146
+ const code = text.match(/<Code>([^<]*)<\/Code>/)?.[1] ?? 'unknown'
147
+ throw new Error(`S3 copy failed (200 with error body: ${code}) ${srcKey} → ${dstKey}`)
148
+ }
149
+ },
150
+ async delete(key) {
151
+ const res = await send('DELETE', objectUrl(key))
152
+ // DELETE is idempotent on S3; a 404 just means it was already gone (matches the local driver).
153
+ if (!res.ok && res.status !== 404) throw new Error(`S3 delete failed (${res.status}) for ${key}`)
154
+ },
155
+ publicUrl(key) {
156
+ return `${stripTrailing(publicBaseUrl)}/${fullKey(key)}`
157
+ },
158
+ async get(key) {
159
+ const res = await send('GET', objectUrl(key))
160
+ // A read has no idempotent empty result: a 404 (or any non-200) is a genuine error the caller
161
+ // handles (backfill skips, GC keeps-on-doubt) rather than a silently-empty buffer.
162
+ if (!res.ok) throw new Error(`S3 get failed (${res.status}) for ${key}`)
163
+ return Buffer.from(await res.arrayBuffer())
164
+ },
165
+ async exists(key) {
166
+ const res = await send('HEAD', objectUrl(key))
167
+ if (res.status === 200) return true
168
+ if (res.status === 404) return false
169
+ throw new Error(`S3 head failed (${res.status}) for ${key}`)
170
+ },
171
+ async stat(key) {
172
+ const res = await send('HEAD', objectUrl(key))
173
+ if (res.status === 404) return null
174
+ if (res.status !== 200) throw new Error(`S3 head failed (${res.status}) for ${key}`)
175
+ const lastModified = res.headers.get('last-modified')
176
+ const mtimeMs = lastModified ? Date.parse(lastModified) : NaN
177
+ // A gateway/CDN that strips (or garbles) Last-Modified leaves the age unknown; reporting 0 would
178
+ // date the object to 1970 and let every age/grace-window comparison treat it as ancient.
179
+ return { mtimeMs: Number.isFinite(mtimeMs) ? mtimeMs : null }
180
+ },
181
+ async ensureDir() {
182
+ // No-op: S3 has a flat keyspace with no real directories. Empty folders live only as DB rows.
183
+ },
184
+ async removeDir(folder) {
185
+ const f = stripTrailing(String(folder ?? '')).trim()
186
+ if (!f) return // root guard: never list/delete the whole bucket
187
+ const listPrefix = `${fullKey(f)}/`
188
+ if (listPrefix === '/') return // defense in depth: an empty prefix would match every object
189
+ let token: string | undefined
190
+ do {
191
+ const { keys, next } = await listPage(listPrefix, token)
192
+ await batchDelete(keys)
193
+ token = next
194
+ } while (token)
195
+ },
196
+ }
197
+ }
@@ -0,0 +1,90 @@
1
+ import { createLocalDriver } from './storage.local'
2
+ import { createS3Driver } from './storage.s3'
3
+ import type { ResolvedImagePolicy } from './kestrel-config'
4
+
5
+ /** Optional per-object write metadata. Omitted by media uploads; the static-output deploy sets `cacheControl`. */
6
+ export interface PutOptions {
7
+ /** `Cache-Control` value (S3 stores it as object metadata served on GET; the local driver ignores it). */
8
+ cacheControl?: string
9
+ /** `Content-Encoding` (e.g. `br`/`gzip`) for a pre-compressed object, so a reverse proxy can serve it
10
+ * as-is. S3 stores + serves it as object metadata; the local driver ignores it. */
11
+ contentEncoding?: string
12
+ }
13
+
14
+ export interface DeleteOptions {
15
+ /** Local driver only: after removing the file, also remove parent dirs left empty (up to, never
16
+ * including, the root). The publisher opts in so an unpublished page leaves no orphan folder; media
17
+ * uploads leave it OFF so a user-created library folder survives deleting its last file. S3 has no
18
+ * real directories, so the S3 driver ignores it. */
19
+ pruneEmptyDirs?: boolean
20
+ }
21
+
22
+ export interface StorageDriver {
23
+ put(key: string, bytes: Buffer | Uint8Array, contentType: string, opts?: PutOptions): Promise<void>
24
+ copy(srcKey: string, dstKey: string): Promise<void>
25
+ delete(key: string, opts?: DeleteOptions): Promise<void>
26
+ publicUrl(key: string): string
27
+ /** Read an object's bytes back. Rejects when the key is missing (a read, unlike idempotent delete, has
28
+ * no empty fallback). Backfill / GC re-derivation and the dev on-demand deriver need the original. */
29
+ get?(key: string): Promise<Buffer>
30
+ exists?(key: string): Promise<boolean>
31
+ /** An object's last-modified time (epoch ms), or null when the key is missing. Used by the gallery blob
32
+ * GC to spare recently-uploaded (not-yet-indexed) blobs from a racing reconcile. `mtimeMs: null` means
33
+ * the store does not expose an age — callers must treat that as unknown, not as old. */
34
+ stat?(key: string): Promise<{ mtimeMs: number | null } | null>
35
+ ensureDir?(folder: string): Promise<void>
36
+ removeDir?(folder: string): Promise<void>
37
+ /** Every object key under the driver's configured prefix, relative to it (i.e. the put/delete space).
38
+ * Rejects when the enumeration itself fails: callers delete on the strength of an empty listing, so
39
+ * "could not enumerate" must never arrive as "there is nothing here". */
40
+ list?(): Promise<string[]>
41
+ /** Like `list()` but restricted to keys under `prefix` (a sub-path of the put/delete space), returned
42
+ * relative to the driver root. Used to reconcile a per-record namespace against its index (drop strays),
43
+ * and as the media guard against recursively removing a path that still holds unmanaged objects — so it
44
+ * rejects on a failed enumeration just like `list()`. */
45
+ listPrefix?(prefix: string): Promise<string[]>
46
+ }
47
+
48
+ /** The `runtimeConfig.media` namespace as the `kestrel` module writes it. */
49
+ export interface MediaRuntimeConfig {
50
+ driver: string
51
+ maxUploadBytes: number
52
+ allowedMimes?: string
53
+ imagePolicy?: ResolvedImagePolicy
54
+ local: { dir: string; baseUrl: string }
55
+ s3: {
56
+ bucket: string; region: string; endpoint: string; prefix: string; publicBaseUrl: string
57
+ accessKeyId: string; secretAccessKey: string; sessionToken: string
58
+ }
59
+ }
60
+
61
+ /** Reads that namespace with its real shape. Nuxt derives runtimeConfig types from the values present at
62
+ * type generation, so the nested variant specs arrive widened to `{}[]` and every consumer would otherwise
63
+ * assert its own ad-hoc shape. One assertion here also makes a read of a key the module never writes
64
+ * (e.g. a top-level `baseUrl`) a compile error instead of a silent `undefined`. */
65
+ export function mediaRuntimeConfig(): MediaRuntimeConfig {
66
+ return useRuntimeConfig().media as MediaRuntimeConfig
67
+ }
68
+
69
+ export function useStorageDriver(): StorageDriver {
70
+ const media = mediaRuntimeConfig()
71
+ if (media.driver === 's3') {
72
+ const s = media.s3
73
+ // publicBaseUrl is required too: without it publicUrl() would emit relative `/key` URLs that resolve
74
+ // against the app origin (which serves no uploads on S3) instead of the bucket/CDN — breaking media.
75
+ if (!s?.bucket || !s.publicBaseUrl || !s.accessKeyId || !s.secretAccessKey) {
76
+ throw createError({ statusCode: 500, statusMessage: 'S3 media driver is not configured: bucket, publicBaseUrl, and credentials (KESTREL_S3_ACCESS_KEY_ID / _SECRET_ACCESS_KEY) are required' })
77
+ }
78
+ return createS3Driver({
79
+ bucket: s.bucket,
80
+ region: s.region,
81
+ endpoint: s.endpoint || undefined,
82
+ prefix: s.prefix,
83
+ publicBaseUrl: s.publicBaseUrl,
84
+ accessKeyId: s.accessKeyId,
85
+ secretAccessKey: s.secretAccessKey,
86
+ sessionToken: s.sessionToken || undefined,
87
+ })
88
+ }
89
+ return createLocalDriver(media.local)
90
+ }
@@ -0,0 +1,31 @@
1
+ import type { CollectionDef } from './defineCollection'
2
+
3
+ /**
4
+ * A tiny write-event bus (mirrors the populate registry): the CRUD writes emit, and a higher layer
5
+ * subscribes — so `core` stays independent of `public`'s publish concern. The publish plugin registers
6
+ * a listener that classifies the write and enqueues an incremental republish. A listener that throws is
7
+ * swallowed: a publishing failure must never break the actual content write.
8
+ */
9
+ export interface WriteEvent {
10
+ def: CollectionDef
11
+ before: Record<string, unknown> | null
12
+ after: Record<string, unknown> | null
13
+ }
14
+
15
+ type WriteListener = (event: WriteEvent) => void
16
+
17
+ const listeners: WriteListener[] = []
18
+
19
+ export function registerWriteListener(fn: WriteListener): void {
20
+ listeners.push(fn)
21
+ }
22
+
23
+ export function clearWriteListeners(): void {
24
+ listeners.length = 0
25
+ }
26
+
27
+ export function emitWrite(def: CollectionDef, before: WriteEvent['before'], after: WriteEvent['after']): void {
28
+ for (const fn of listeners) {
29
+ try { fn({ def, before, after }) } catch (error) { console.error('[kestrel] write listener failed:', error) }
30
+ }
31
+ }