@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,13 @@
1
+ import { getHeader, type H3Event } from 'h3'
2
+
3
+ /** The optimistic-concurrency precondition header: the epoch-ms `updatedAt` the client last loaded. A
4
+ * bad/absent value means "no precondition" (unconditional write) rather than an error — so a plain API
5
+ * client that doesn't opt in still works. */
6
+ export const IF_UNMODIFIED_HEADER = 'x-kestrel-if-unmodified-since'
7
+
8
+ export function readIfUnmodifiedSince(event: H3Event): number | undefined {
9
+ const raw = getHeader(event, IF_UNMODIFIED_HEADER)
10
+ if (!raw) return undefined
11
+ const n = Number(raw)
12
+ return Number.isFinite(n) ? n : undefined
13
+ }
@@ -0,0 +1,410 @@
1
+ import { resolve } from 'node:path'
2
+
3
+ // The single source of non-auth configuration. Lives in `kestrel.config.ts` (repo root, plain TS so
4
+ // it's importable by nuxt.config, drizzle.config AND server utils), surfaced as `kestrel: { … }` in
5
+ // nuxt.config. Auth/session settings stay env-only (secrets don't belong in committed config).
6
+ /**
7
+ * Non-secret S3 settings (used when `media.driver === 's3'`). The access-key id and secret are
8
+ * deliberately **absent** here — they are env-only (`KESTREL_S3_ACCESS_KEY_ID` /
9
+ * `KESTREL_S3_SECRET_ACCESS_KEY`), read at driver construction, never in committed config.
10
+ */
11
+ export interface KestrelS3Config {
12
+ /** Target bucket name. */
13
+ bucket?: string
14
+ /** Signing region (default `us-east-1`; use `auto` for Cloudflare R2). */
15
+ region?: string
16
+ /** Custom endpoint origin for S3-compatible services (R2/MinIO); omit for AWS S3. */
17
+ endpoint?: string
18
+ /** Key prefix prepended to every object key (slashes are normalised away). */
19
+ prefix?: string
20
+ /** Public base URL objects are served from (CDN / bucket origin); `publicUrl` joins this + key. */
21
+ publicBaseUrl?: string
22
+ }
23
+ export interface KestrelMediaConfig {
24
+ /** Directory uploads are written to AND served from (relative → resolved against the project root). */
25
+ uploadDir?: string
26
+ /** Public URL prefix uploads are served under (default `/uploads`). */
27
+ baseUrl?: string
28
+ driver?: 'local' | 's3'
29
+ maxBytes?: number
30
+ allowedMimes?: string
31
+ s3?: KestrelS3Config
32
+ /** Responsive-image derivation policy. `widths` is the legacy proportional ladder (desugars into
33
+ * `variants` as `w<width>` webp presets); `variants` is the usage-driven set of named size × format
34
+ * presets. `variants` is config-only (a structured list doesn't round-trip a scalar/CSV env var); the
35
+ * quality scalars keep their `KESTREL_MEDIA_IMAGE_*_QUALITY` env overrides. */
36
+ image?: { widths?: number[]; webpQuality?: number; jpegQuality?: number; variants?: VariantSpec[] }
37
+ }
38
+
39
+ /** sharp fit modes for a fixed-box (crop) variant. */
40
+ export type VariantFit = 'cover' | 'contain' | 'inside' | 'outside' | 'fill'
41
+ /** Output formats Kestrel emits (WebP + a JPEG fallback). */
42
+ export type VariantFormat = 'webp' | 'jpeg'
43
+
44
+ /** A single image variant to derive on upload — the authoring shape (config or, later, the registry). */
45
+ export interface VariantSpec {
46
+ /** Stable identifier; the manifest key + object-key stem. Sanitised to `[A-Za-z0-9_-]`. */
47
+ name: string
48
+ /** Target width in px. */
49
+ width: number
50
+ /** Fixed box height (crop). Omitted ⇒ proportional width-only resize (never upscaled). */
51
+ height?: number
52
+ /** Fit for a fixed box (default `cover`). */
53
+ fit?: VariantFit
54
+ /** sharp crop position/gravity for `cover`/`contain` (default `centre`). */
55
+ position?: string
56
+ /** Output formats (default `['webp']`). */
57
+ formats?: VariantFormat[]
58
+ }
59
+
60
+ /** A fully-resolved variant (every field defaulted) threaded to `deriveImage`. */
61
+ export interface ResolvedVariant {
62
+ name: string
63
+ width: number
64
+ /** null ⇒ proportional (no fixed box). */
65
+ height: number | null
66
+ fit: VariantFit
67
+ position: string
68
+ formats: VariantFormat[]
69
+ }
70
+
71
+ /** Resolved responsive-image derivation policy threaded to `deriveImage` on upload. */
72
+ export interface ResolvedImagePolicy {
73
+ /** Legacy proportional widths (kept until every consumer reads `variants`); each is clamped to the source. */
74
+ widths: number[]
75
+ /** WebP encode quality, 1–100. */
76
+ webpQuality: number
77
+ /** JPEG encode quality, 1–100. */
78
+ jpegQuality: number
79
+ /** The variant set to derive: named config presets unioned with the desugared legacy widths (name-deduped). */
80
+ variants: ResolvedVariant[]
81
+ /** The config-authored named presets only (`image.variants`). Explicit, name-referenced declarations that
82
+ * stay active through usage-driven narrowing — kept separate from the discardable legacy-width ladder. */
83
+ presets: ResolvedVariant[]
84
+ }
85
+
86
+ export const DEFAULT_IMAGE_POLICY: ResolvedImagePolicy = {
87
+ widths: [320, 640, 960, 1280, 1920],
88
+ webpQuality: 78,
89
+ jpegQuality: 80,
90
+ variants: [320, 640, 960, 1280, 1920].map((w): ResolvedVariant => ({
91
+ name: `w${w}`, width: w, height: null, fit: 'cover', position: 'centre', formats: ['webp'],
92
+ })),
93
+ presets: [],
94
+ }
95
+ export interface KestrelConfig {
96
+ /** SQLite path (relative → resolved against root; `:memory:` passes through). */
97
+ db?: string
98
+ /** Absolute site origin for sitemap `<loc>` + robots `Sitemap:`. */
99
+ siteUrl?: string
100
+ /** Human site name + one-line description for the generated `llms.txt` (AI-agent site map). The name
101
+ * falls back to the `siteUrl` host. Env: `KESTREL_SITE_NAME` / `KESTREL_SITE_DESCRIPTION`. */
102
+ siteName?: string
103
+ siteDescription?: string
104
+ /** Website/content locales. */
105
+ locales?: string[]
106
+ primaryLocale?: string
107
+ /** Prefix the PRIMARY locale in URLs too (`/en/about` instead of `/about`); default false. When on, the
108
+ * bare `/` has no page — redirect it to `/<primary>` at the edge. */
109
+ prefixPrimaryLocale?: boolean
110
+ media?: KestrelMediaConfig
111
+ /** Where the published static site is written — both the build-time `nuxt generate` deploy AND the
112
+ * runtime incremental publisher write here: a local dir (default) or an S3 bucket. S3 credentials are
113
+ * env-only (`KESTREL_OUTPUT_S3_*`), never in committed config. Separate from `media` (uploads) so the
114
+ * pages tree and the uploads can target different dirs/buckets. */
115
+ output?: {
116
+ driver?: 'local' | 's3'
117
+ /** Local dir the published static tree (HTML + synced `_nuxt`) is written to (relative → root).
118
+ * Default `.data/published`. Separate from `media.uploadDir` by design. */
119
+ dir?: string
120
+ /** On-disk source of the built client bundle the publisher mirrors into the output (the running
121
+ * build's `_nuxt/**` + public assets, relative → root). Default `.output/public`. */
122
+ publicDir?: string
123
+ /** Auto-publish affected pages on every content write (default true). */
124
+ auto?: boolean
125
+ /** Run a FULL reconcile every N minutes (default 0 = off) — self-heals missed invalidations and
126
+ * picks up time-based `publishDate` publishing that no write event would trigger. */
127
+ reconcileMinutes?: number
128
+ /** Verbose publish logging: emit a timestamped per-route line (rendered / pruned) on each incremental
129
+ * republish, on top of the summary line. Default false (`KESTREL_OUTPUT_VERBOSE`). */
130
+ verbose?: boolean
131
+ s3?: { bucket?: string; region?: string; endpoint?: string; prefix?: string }
132
+ }
133
+ /** Disable Kestrel's built-in collections (default on). Set in `kestrel: {}` (consumer nuxt.config) or
134
+ * `kestrel.config.ts`; per-setting env `KESTREL_COLLECTIONS_PAGES` / `_MEDIA` overrides. */
135
+ collections?: { pages?: boolean; media?: boolean }
136
+ /** Admin page-builder live preview. `desktopWidth` is the reference viewport width (px) the "Desktop"
137
+ * preset renders at before the editor's scale-to-fit shrinks it to the pane; default 1440. Env:
138
+ * `KESTREL_PREVIEW_DESKTOP_WIDTH`. */
139
+ preview?: { desktopWidth?: number }
140
+ }
141
+
142
+ export interface ResolvedKestrel {
143
+ dbPath: string
144
+ siteUrl: string
145
+ siteName: string
146
+ siteDescription: string
147
+ supportedLocales: string[]
148
+ primaryLocale: string
149
+ prefixPrimary: boolean
150
+ media: {
151
+ dir: string; baseUrl: string; driver: 'local' | 's3'; maxUploadBytes: number; allowedMimes: string
152
+ s3: { bucket: string; region: string; endpoint: string; prefix: string; publicBaseUrl: string }
153
+ imagePolicy: ResolvedImagePolicy
154
+ }
155
+ /** Resolved static-publish target (runtime publisher + build deploy). */
156
+ output: {
157
+ driver: 'local' | 's3'
158
+ dir: string
159
+ publicDir: string
160
+ auto: boolean
161
+ reconcileMinutes: number
162
+ verbose: boolean
163
+ s3: ResolvedS3Settings
164
+ }
165
+ /** Resolved built-in-collection toggles (default on). The register plugin skips a built-in whose flag is false. */
166
+ collections: { pages: boolean; media: boolean }
167
+ /** Resolved admin-preview settings (surfaced to the client via `runtimeConfig.public`). */
168
+ preview: { desktopWidth: number }
169
+ }
170
+
171
+ type Env = Record<string, string | undefined>
172
+
173
+ /**
174
+ * Gate the layer's OWN committed `kestrel.config.ts` so it is contributed to Nuxt only when Kestrel runs
175
+ * in-repo — NOT when it is consumed as a dependency. Why: Nuxt/c12 merges layer config with `defu`, which
176
+ * CONCATENATES array options, so the engine's demo `locales: ['en','de']` would merge back into a consumer
177
+ * that set `locales: ['de']` (→ `['de','en','de']`). Scalars overwrite cleanly (consumer wins), but any
178
+ * array field leaks. Detected via the module URL: a consumed package is loaded from `node_modules`, so it
179
+ * contributes `{}` and the consumer's own `kestrel: {}` (resolved by `resolveKestrel`: env → config →
180
+ * default) is the sole source. In-repo, the file is contributed as-is (the unified DX: nuxt/drizzle/runtime
181
+ * all derive the same values from the one file). Pure + tested so `nuxt.config` stays declarative. */
182
+ export function layerKestrelOption(config: KestrelConfig, moduleUrl: string): KestrelConfig {
183
+ return moduleUrl.includes('/node_modules/') ? {} : config
184
+ }
185
+
186
+ /** Parse a comma-separated locale list (trim + lowercase + dedupe, first-seen order); fall back when blank. */
187
+ export function parseLocaleList(raw: string | undefined, fallback: string[]): string[] {
188
+ const list = (raw ?? '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)
189
+ return list.length ? [...new Set(list)] : fallback
190
+ }
191
+
192
+ function clean(value: string | undefined): string | undefined {
193
+ const t = value?.trim()
194
+ return t ? t : undefined
195
+ }
196
+
197
+ /**
198
+ * Normalise + validate the media driver. Case-insensitive (so `S3`/`Local` are accepted), but an unknown
199
+ * value is a fail-loud misconfig — NOT a silent fall-through to local. Without this, the `=== 'local'`
200
+ * guard in the kestrel module (which registers `/uploads`) and the `!== 's3'` branch in the storage
201
+ * resolver (which returns the local driver) disagree, so a typo writes uploads to disk that are never served.
202
+ */
203
+ function resolveDriver(value: string | undefined): 'local' | 's3' {
204
+ const driver = (value ?? 'local').toLowerCase()
205
+ if (driver !== 'local' && driver !== 's3') {
206
+ throw new Error(`kestrel: unknown media driver "${value}" — expected "local" or "s3"`)
207
+ }
208
+ return driver
209
+ }
210
+
211
+ /** The four non-secret S3 settings, used by `bucket`/`region`/`endpoint`/`prefix`. */
212
+ export interface ResolvedS3Settings { bucket: string; region: string; endpoint: string; prefix: string }
213
+
214
+ /**
215
+ * Resolve the non-secret S3 block (bucket/region/endpoint/prefix), precedence **`<PREFIX>_*` env → config
216
+ * → default**. Shared by media uploads (`KESTREL_S3_*`) and the generate-output deploy
217
+ * (`KESTREL_OUTPUT_S3_*`) so both buckets resolve identically; secrets are never read here.
218
+ */
219
+ export function resolveS3Settings(
220
+ cfg: { bucket?: string; region?: string; endpoint?: string; prefix?: string } | undefined,
221
+ env: Env,
222
+ envPrefix: string,
223
+ ): ResolvedS3Settings {
224
+ const s = cfg ?? {}
225
+ return {
226
+ bucket: clean(env[`${envPrefix}_BUCKET`]) ?? clean(s.bucket) ?? '',
227
+ region: clean(env[`${envPrefix}_REGION`]) ?? clean(s.region) ?? 'us-east-1',
228
+ endpoint: clean(env[`${envPrefix}_ENDPOINT`]) ?? clean(s.endpoint) ?? '',
229
+ prefix: clean(env[`${envPrefix}_PREFIX`]) ?? clean(s.prefix) ?? '',
230
+ }
231
+ }
232
+
233
+ function resolveMaybe(rootDir: string, value: string): string {
234
+ return value === ':memory:' ? value : resolve(rootDir, value)
235
+ }
236
+
237
+ /** Boolean-ish env var: true/1/yes/on → true, false/0/no/off → false, undefined/garbage → fallback. */
238
+ function envBool(raw: string | undefined, fallback: boolean): boolean {
239
+ const v = raw?.trim().toLowerCase()
240
+ if (!v) return fallback
241
+ if (['true', '1', 'yes', 'on'].includes(v)) return true
242
+ if (['false', '0', 'no', 'off'].includes(v)) return false
243
+ return fallback
244
+ }
245
+
246
+ /** Non-negative integer (e.g. minutes); env wins over config; invalid → fall through (env→config→0). Routes
247
+ * the env value through `clean()` like every sibling resolver: `Number('')`/`Number(' ')` is 0 (finite,
248
+ * >= 0) and would otherwise override a configured value with a blank `KEY=` env-file line. */
249
+ function resolveNonNegInt(cfg: number | undefined, env: string | undefined): number {
250
+ const raw = clean(env)
251
+ const n = raw !== undefined ? Number(raw) : NaN
252
+ if (Number.isFinite(n) && n >= 0) return Math.floor(n)
253
+ if (typeof cfg === 'number' && Number.isFinite(cfg) && cfg >= 0) return Math.floor(cfg)
254
+ return 0
255
+ }
256
+
257
+ /** Positive integer (e.g. a px width); env wins over config; invalid or ≤0 → fall through (env→config→fallback). */
258
+ function resolvePosInt(cfg: number | undefined, env: string | undefined, fallback: number): number {
259
+ const n = env !== undefined ? Number(env) : NaN
260
+ if (Number.isFinite(n) && n > 0) return Math.floor(n)
261
+ if (typeof cfg === 'number' && Number.isFinite(cfg) && cfg > 0) return Math.floor(cfg)
262
+ return fallback
263
+ }
264
+
265
+ /** Floor to ints first, then keep only widths >= 1 (so a sub-1 fraction is dropped, not floored to 0); dedupe, sort. */
266
+ function sanitizeWidths(list: readonly number[]): number[] {
267
+ const clean = list.map((n) => Math.floor(n)).filter((n) => Number.isFinite(n) && n >= 1)
268
+ return [...new Set(clean)].sort((a, b) => a - b)
269
+ }
270
+
271
+ /** widths: KESTREL_MEDIA_IMAGE_WIDTHS (csv) → config array → default; each level used only if non-empty after sanitising. */
272
+ function resolveWidths(cfg: number[] | undefined, env: string | undefined): number[] {
273
+ const fromEnv = sanitizeWidths((env ?? '').split(',').map(Number))
274
+ if (fromEnv.length) return fromEnv
275
+ const fromCfg = sanitizeWidths(cfg ?? [])
276
+ return fromCfg.length ? fromCfg : [...DEFAULT_IMAGE_POLICY.widths]
277
+ }
278
+
279
+ /** A quality is valid only as an integer in [1, 100]; anything else is ignored at that level. */
280
+ function validQuality(n: number | undefined): number | undefined {
281
+ return n !== undefined && Number.isFinite(n) && n >= 1 && n <= 100 ? Math.round(n) : undefined
282
+ }
283
+
284
+ function resolveQuality(cfg: number | undefined, env: string | undefined, fallback: number = DEFAULT_IMAGE_POLICY.webpQuality): number {
285
+ return validQuality(env !== undefined ? Number(env) : undefined) ?? validQuality(cfg) ?? fallback
286
+ }
287
+
288
+ const VARIANT_FITS = new Set<VariantFit>(['cover', 'contain', 'inside', 'outside', 'fill'])
289
+
290
+ /** Keep only `[A-Za-z0-9_-]`; collapse runs to a single `-`; trim edge `-`. Empty ⇒ the spec is dropped. */
291
+ function sanitizeVariantName(raw: unknown): string {
292
+ return String(raw ?? '').replace(/[^A-Za-z0-9_-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
293
+ }
294
+
295
+ /** Keep only known formats, first-seen deduped; an empty result falls back to `['webp']`. */
296
+ function sanitizeFormats(raw: unknown): VariantFormat[] {
297
+ const arr = Array.isArray(raw) ? raw : []
298
+ const out = [...new Set(arr.filter((f): f is VariantFormat => f === 'webp' || f === 'jpeg'))]
299
+ return out.length ? out : ['webp']
300
+ }
301
+
302
+ /** Validate + default each authored spec; drop those with no usable name or a width < 1; dedupe by name. */
303
+ function sanitizeVariants(list: readonly VariantSpec[]): ResolvedVariant[] {
304
+ const out: ResolvedVariant[] = []
305
+ const seen = new Set<string>()
306
+ for (const v of list) {
307
+ const name = sanitizeVariantName(v?.name)
308
+ const width = Math.floor(Number(v?.width))
309
+ if (!name || seen.has(name) || !Number.isFinite(width) || width < 1) continue
310
+ const hn = Math.floor(Number(v?.height))
311
+ const height = v?.height === undefined || v?.height === null || !Number.isFinite(hn) || hn < 1 ? null : hn
312
+ const fit = v?.fit && VARIANT_FITS.has(v.fit) ? v.fit : 'cover'
313
+ const position = typeof v?.position === 'string' && v.position.trim() ? v.position.trim() : 'centre'
314
+ seen.add(name)
315
+ out.push({ name, width, height, fit, position, formats: sanitizeFormats(v?.formats) })
316
+ }
317
+ return out
318
+ }
319
+
320
+ /** Legacy proportional widths → `w<width>` single-webp presets (the back-compat bridge into `variants`). */
321
+ function desugarWidths(widths: number[]): ResolvedVariant[] {
322
+ return widths.map((w): ResolvedVariant => ({ name: `w${w}`, width: w, height: null, fit: 'cover', position: 'centre', formats: ['webp'] }))
323
+ }
324
+
325
+ /**
326
+ * The variant set to derive: named config presets (config-only — no env) unioned with the desugared legacy
327
+ * `widths` (which still honour `KESTREL_MEDIA_IMAGE_WIDTHS`). A named preset WINS a `w<width>` name collision.
328
+ */
329
+ export function resolveVariants(cfg: VariantSpec[] | undefined, cfgWidths: number[] | undefined, envWidths: string | undefined): ResolvedVariant[] {
330
+ const byName = new Map<string, ResolvedVariant>()
331
+ for (const v of sanitizeVariants(cfg ?? [])) byName.set(v.name, v)
332
+ for (const v of desugarWidths(resolveWidths(cfgWidths, envWidths))) if (!byName.has(v.name)) byName.set(v.name, v)
333
+ return [...byName.values()]
334
+ }
335
+
336
+ /**
337
+ * Resolve every non-auth setting from one place, precedence **`KESTREL_*` (env) → `kestrel.*` (config) →
338
+ * built-in default**. The committed `kestrel.config.ts` is the normal source of truth; an explicit
339
+ * `KESTREL_*` env var OVERRIDES it — the test/deploy escape-hatch (an isolated test DB, a per-env path),
340
+ * so the e2e suite isolates by setting `KESTREL_DB`/`KESTREL_MEDIA_LOCAL_DIR`/`KESTREL_SITE_URL` per run.
341
+ * Normal dev / build / drizzle-kit set no `KESTREL_*`, so config wins and they never drift. (Auth/session
342
+ * settings are env-ONLY and resolved elsewhere; they never appear here.) Pure (only `node:path`), so
343
+ * nuxt.config / the kestrel module / drizzle.config / server utils all derive identical values. Relative
344
+ * paths resolve against `rootDir`; `siteUrl` is returned raw (the caller normalises it).
345
+ */
346
+ export function resolveKestrel(config: KestrelConfig | undefined, env: Env, rootDir: string): ResolvedKestrel {
347
+ const c = config ?? {}
348
+
349
+ const dbPath = resolveMaybe(rootDir, clean(env.KESTREL_DB) ?? clean(c.db) ?? '.data/db.sqlite')
350
+
351
+ const supportedLocales = parseLocaleList(
352
+ clean(env.KESTREL_LOCALES) ?? (c.locales?.length ? c.locales.join(',') : undefined),
353
+ ['en', 'de'],
354
+ )
355
+ const wantedPrimary = clean(env.KESTREL_PRIMARY_LOCALE)?.toLowerCase() ?? clean(c.primaryLocale)?.toLowerCase()
356
+ const primaryLocale = wantedPrimary && supportedLocales.includes(wantedPrimary) ? wantedPrimary : (supportedLocales[0] ?? 'en')
357
+ const prefixPrimary = envBool(env.KESTREL_PREFIX_PRIMARY_LOCALE, c.prefixPrimaryLocale ?? false)
358
+
359
+ const siteUrl = clean(env.KESTREL_SITE_URL) ?? clean(c.siteUrl) ?? ''
360
+ const siteName = clean(env.KESTREL_SITE_NAME) ?? clean(c.siteName) ?? ''
361
+ const siteDescription = clean(env.KESTREL_SITE_DESCRIPTION) ?? clean(c.siteDescription) ?? ''
362
+
363
+ const m = c.media ?? {}
364
+ // maxBytes: a valid env count wins, else a valid config count, else the default (a garbage env falls through).
365
+ const validBytes = (n: number | undefined) => (typeof n === 'number' && Number.isFinite(n) && n > 0 ? n : undefined)
366
+ const maxUploadBytes = validBytes(env.KESTREL_MEDIA_MAX_BYTES !== undefined ? Number(env.KESTREL_MEDIA_MAX_BYTES) : undefined)
367
+ ?? validBytes(m.maxBytes) ?? 26214400
368
+ // Non-secret S3 settings only — secrets (access key id / secret) are env-only and read by the driver.
369
+ const s = m.s3 ?? {}
370
+ const s3 = {
371
+ ...resolveS3Settings(s, env, 'KESTREL_S3'),
372
+ publicBaseUrl: clean(env.KESTREL_S3_PUBLIC_BASE_URL) ?? clean(s.publicBaseUrl) ?? '',
373
+ }
374
+ const img = m.image ?? {}
375
+ const media = {
376
+ dir: resolveMaybe(rootDir, clean(env.KESTREL_MEDIA_LOCAL_DIR) ?? clean(m.uploadDir) ?? '.data/uploads'),
377
+ baseUrl: clean(env.KESTREL_MEDIA_BASE_URL) ?? clean(m.baseUrl) ?? '/uploads',
378
+ driver: resolveDriver(clean(env.KESTREL_MEDIA_DRIVER) ?? clean(m.driver)),
379
+ maxUploadBytes,
380
+ allowedMimes: clean(env.KESTREL_MEDIA_ALLOWED_MIME) ?? clean(m.allowedMimes) ?? '',
381
+ s3,
382
+ imagePolicy: {
383
+ widths: resolveWidths(img.widths, env.KESTREL_MEDIA_IMAGE_WIDTHS),
384
+ webpQuality: resolveQuality(img.webpQuality, env.KESTREL_MEDIA_IMAGE_WEBP_QUALITY),
385
+ jpegQuality: resolveQuality(img.jpegQuality, env.KESTREL_MEDIA_IMAGE_JPEG_QUALITY, DEFAULT_IMAGE_POLICY.jpegQuality),
386
+ variants: resolveVariants(img.variants, img.widths, env.KESTREL_MEDIA_IMAGE_WIDTHS),
387
+ presets: sanitizeVariants(img.variants ?? []),
388
+ },
389
+ }
390
+
391
+ const o = c.output ?? {}
392
+ const output = {
393
+ driver: resolveDriver(clean(env.KESTREL_OUTPUT_DRIVER) ?? clean(o.driver)),
394
+ dir: resolveMaybe(rootDir, clean(env.KESTREL_OUTPUT_DIR) ?? clean(o.dir) ?? '.data/published'),
395
+ publicDir: resolveMaybe(rootDir, clean(env.KESTREL_OUTPUT_PUBLIC_DIR) ?? clean(o.publicDir) ?? '.output/public'),
396
+ auto: envBool(env.KESTREL_OUTPUT_AUTO, o.auto ?? true),
397
+ reconcileMinutes: resolveNonNegInt(o.reconcileMinutes, env.KESTREL_OUTPUT_RECONCILE_MINUTES),
398
+ verbose: envBool(env.KESTREL_OUTPUT_VERBOSE, o.verbose ?? false),
399
+ s3: resolveS3Settings(o.s3, env, 'KESTREL_OUTPUT_S3'),
400
+ }
401
+
402
+ const collections = {
403
+ pages: envBool(env.KESTREL_COLLECTIONS_PAGES, c.collections?.pages ?? true),
404
+ media: envBool(env.KESTREL_COLLECTIONS_MEDIA, c.collections?.media ?? true),
405
+ }
406
+
407
+ const preview = { desktopWidth: resolvePosInt(c.preview?.desktopWidth, env.KESTREL_PREVIEW_DESKTOP_WIDTH, 1440) }
408
+
409
+ return { dbPath, siteUrl, siteName, siteDescription, supportedLocales, primaryLocale, prefixPrimary, media, output, collections, preview }
410
+ }
@@ -0,0 +1,33 @@
1
+ // An in-process async mutex keyed by string: operations sharing a key run STRICTLY one-at-a-time (FIFO),
2
+ // while different keys run concurrently. Kestrel is single-process SSR (the publish queue is single-flight
3
+ // the same way), so this is sufficient to serialize storage+DB mutations that would otherwise race —
4
+ // concurrent uploads to the same key, a folder delete/move interleaving an upload under it, or a backfill
5
+ // re-deriving a row an overwrite is replacing. (A horizontally-scaled deployment would need a distributed
6
+ // lock instead; that is a separate concern flagged where it matters.)
7
+
8
+ const tails = new Map<string, Promise<unknown>>()
9
+
10
+ /**
11
+ * Run `fn` once every previously-enqueued task for `key` has settled. Returns fn's result/rejection
12
+ * unchanged; a failing task never blocks the queue (the next task still runs). The key's chain entry is
13
+ * dropped once it drains, so the map doesn't grow unbounded.
14
+ */
15
+ export function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
16
+ const prev = tails.get(key) ?? Promise.resolve()
17
+ // Chain after prev regardless of how prev settled (don't let one task's rejection skip the next).
18
+ const result = prev.then(fn, fn)
19
+ const tail = result.then(() => {}, () => {})
20
+ tails.set(key, tail)
21
+ void tail.then(() => { if (tails.get(key) === tail) tails.delete(key) })
22
+ return result
23
+ }
24
+
25
+ /**
26
+ * The lock key for a media mutation on a specific object: the exact storage key. Same-key ops serialize
27
+ * (a concurrent upload to the same key, or a backfill re-deriving a row an overwrite is replacing), while
28
+ * different objects stay fully concurrent. (Folder delete/move races are handled separately by the
29
+ * removeDir listPrefix guard, so they need no lock here.)
30
+ */
31
+ export function mediaLockKey(storageKey: string): string {
32
+ return `media:${storageKey.replace(/^\/+/, '')}`
33
+ }
@@ -0,0 +1,53 @@
1
+ import { createError } from 'h3'
2
+ import { resolveServerKestrel, serverRuntimeConfig } from './server-config'
3
+
4
+ // Website/content locales — the languages published *content* exists in. NOT the admin-UI language
5
+ // (that stays single-language English for now). Resolved lazily: prefer `runtimeConfig.public` (populated
6
+ // by the kestrel module from the consumer's `kestrel: {}`), falling back to Kestrel's own config file +
7
+ // env for non-Nitro callers (node tests, build time). Lazy so the consumer's config wins at request time
8
+ // rather than baking in the package's empty config at module-load.
9
+ function resolved(): { supported: readonly string[]; primary: string; prefixPrimary: boolean } {
10
+ const pub = serverRuntimeConfig()?.public as { locales?: string[]; primaryLocale?: string; prefixPrimary?: boolean } | undefined
11
+ if (pub?.locales?.length && pub.primaryLocale) return { supported: pub.locales, primary: pub.primaryLocale, prefixPrimary: pub.prefixPrimary === true }
12
+ const r = resolveServerKestrel()
13
+ return { supported: r.supportedLocales, primary: r.primaryLocale, prefixPrimary: r.prefixPrimary }
14
+ }
15
+
16
+ export function supportedLocales(): readonly string[] {
17
+ return resolved().supported
18
+ }
19
+
20
+ export function primaryLocale(): string {
21
+ return resolved().primary
22
+ }
23
+
24
+ /** Whether the primary locale is prefixed in URLs too (`/en/about`) — the `localePath` /
25
+ * `resolvePublicRoute` `prefixPrimary` flag, resolved from config. Default false. */
26
+ export function prefixPrimaryLocale(): boolean {
27
+ return resolved().prefixPrimary
28
+ }
29
+
30
+ export type Locale = string
31
+
32
+ function normalizeLocale(value?: string | string[] | null): string {
33
+ const single = Array.isArray(value) ? value[0] : value
34
+ return (single ?? '').trim().toLowerCase()
35
+ }
36
+
37
+ export function isSupportedLocale(value: string): value is Locale {
38
+ return supportedLocales().includes(value)
39
+ }
40
+
41
+ /**
42
+ * Normalize a locale from a write body or a read query: trim + lowercase, default to the
43
+ * primary locale when absent, throw 400 when explicitly given but unsupported. Shared by the
44
+ * write and read paths so a WHERE filter and the populate locale can never drift apart.
45
+ */
46
+ export function resolveLocale(value?: string | string[] | null): Locale {
47
+ const normalized = normalizeLocale(value)
48
+ if (!normalized) return primaryLocale()
49
+ if (!isSupportedLocale(normalized)) {
50
+ throw createError({ statusCode: 400, statusMessage: `Unsupported locale: ${normalized}` })
51
+ }
52
+ return normalized
53
+ }
@@ -0,0 +1,58 @@
1
+ import { getTableColumns } from 'drizzle-orm'
2
+ import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
3
+ import type { BuiltCollection } from './collection-types'
4
+ import { localePath } from '../../app/utils/locale-path'
5
+
6
+ /**
7
+ * THE single definition of "stored page-like row → public output route": `localePath(path, the row's
8
+ * OWN locale, …)`, or null when the row is missing or has no path. Every route-deriving site delegates
9
+ * here — slug-uniqueness (`routeOf`/`findRouteConflict`), internal-link targets (`link-resolve`), the
10
+ * publish-status endpoint (`routeForRecord`), and the publisher + its invalidation — so the URL scheme
11
+ * lives in one place. A non-translatable row simply carries no `locale`, so it falls back to `primary`.
12
+ */
13
+ export function pageRowHref(row: { path?: unknown; locale?: unknown } | null | undefined, primary: string, prefixPrimary = false): string | null {
14
+ if (!row || typeof row.path !== 'string' || !row.path) return null
15
+ const locale = typeof row.locale === 'string' && row.locale ? row.locale : primary
16
+ return localePath(row.path, locale, primary, prefixPrimary)
17
+ }
18
+
19
+ /**
20
+ * Route for a pageLike row honoring a collection's `translatable` flag: a translatable collection
21
+ * localizes by the row's `locale`, a non-translatable one is always primary-locale (ignores any stray
22
+ * `locale`). Thin wrapper over `pageRowHref`, used by the slug-uniqueness scan.
23
+ */
24
+ export function routeOf(row: { path?: unknown; locale?: unknown }, translatable: boolean, primary: string, prefixPrimary = false): string | null {
25
+ return pageRowHref(translatable ? row : { path: row.path }, primary, prefixPrimary)
26
+ }
27
+
28
+ export interface RouteConflict { collection: string; id: number }
29
+
30
+ /**
31
+ * The first pageLike record — across ALL given collections — whose resolved route equals `targetRoute`,
32
+ * excluding the record being saved. Enforces GLOBAL per-locale slug uniqueness: two pageLike records may
33
+ * never resolve to the same static route (one route = one output file). Scanning resolved routes (not raw
34
+ * `(path, locale)`) is what makes it correct for the non-injective cases — a primary-locale row whose bare
35
+ * path looks prefixed (`/de/x`) aliases a `de`-locale row's `/x`, and a stored path with/without a leading
36
+ * slash normalizes the same. O(pageLike rows): a single-user admin save, the same scope the publisher walks.
37
+ */
38
+ export function findRouteConflict(
39
+ db: BetterSQLite3Database,
40
+ targetRoute: string,
41
+ primary: string,
42
+ collections: BuiltCollection[],
43
+ exclude: { collection: string; id: number | null },
44
+ prefixPrimary = false,
45
+ ): RouteConflict | null {
46
+ for (const c of collections) {
47
+ if (!c.def.pageLike) continue
48
+ const cols = getTableColumns(c.table) as Record<string, never>
49
+ const proj: Record<string, unknown> = { id: cols.id, path: cols.path }
50
+ if (c.def.translatable) proj.locale = cols.locale
51
+ const rows = db.select(proj as never).from(c.table).all() as { id: number; path: unknown; locale?: unknown }[]
52
+ for (const r of rows) {
53
+ if (c.name === exclude.collection && r.id === exclude.id) continue
54
+ if (routeOf(r, !!c.def.translatable, primary, prefixPrimary) === targetRoute) return { collection: c.name, id: r.id }
55
+ }
56
+ }
57
+ return null
58
+ }