@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,207 @@
1
+ import { validateFieldLayoutsDeep } from './field-layout'
2
+
3
+ export type FieldType =
4
+ | 'text' | 'slug' | 'richtext' | 'number' | 'boolean' | 'datetime'
5
+ | 'choice' | 'link' | 'media' | 'relation' | 'repeater' | 'json'
6
+ // Consumer-defined types (registered via `defineFieldType`) — keeps built-in autocomplete while
7
+ // accepting any registered name.
8
+ | (string & {})
9
+
10
+ /** A display string that may be localized per admin language: either a plain string (one language /
11
+ * language-agnostic) or a `{ <adminLang>: string }` map resolved against the active admin language
12
+ * (falling back to `en`, then any present value). Works for built-in AND consumer-defined collections. */
13
+ export type Localized = string | Record<string, string>
14
+
15
+ interface BaseFieldDef {
16
+ required?: boolean
17
+ default?: unknown
18
+ translatable?: boolean
19
+ unique?: boolean
20
+ /** Add a non-unique DB index on this field's column — for a field frequently FILTERED or SORTED by but
21
+ * not unique (e.g. `media.folder`). `unique` already implies an index, so this is only the non-unique case. */
22
+ index?: boolean
23
+ /** Editor label, optionally localized per admin language. Defaults to the humanized field key
24
+ * (e.g. `siteName` → "Site Name") so the editor label matches the collection-list header. */
25
+ label?: Localized
26
+ /** Show this field only when the condition matches other fields' values (see `Condition`). A hidden
27
+ * field is exempt from `required` and gets a nullable column; its stored value is left as-is (v1). */
28
+ condition?: Condition
29
+ /** The field's previous key. On a schema sync the column is RENAMED (data preserved) instead of being
30
+ * dropped + re-added. Remove once applied everywhere. */
31
+ renamedFrom?: string
32
+ /** Per-instance read populator override (Pruvious `additional.population`). When set, the field-tree
33
+ * walker runs THIS instead of the field type's default populator for this one field — e.g. a relation
34
+ * that should project only a couple of columns, or a masked value. Server-only (a function; never
35
+ * serialized to the admin). Typed inline (structurally identical to the populate seam's `FieldPopulator`)
36
+ * so this foundational, import-free types file needs no dependency on `populate.ts`. */
37
+ populate?: (
38
+ bag: Record<string, unknown>,
39
+ key: string,
40
+ field: FieldDef,
41
+ ctx: { depth: number; locale: string; def: CollectionDef },
42
+ keyMode: 'columns' | 'props',
43
+ ) => void
44
+ }
45
+
46
+ export type ConditionScalar = string | number | boolean | null
47
+
48
+ /** A single operator test against the referenced field's value. Multiple keys are ANDed. */
49
+ export interface ConditionOperator {
50
+ eq?: ConditionScalar
51
+ ne?: ConditionScalar
52
+ gt?: number | string
53
+ gte?: number | string
54
+ lt?: number | string
55
+ lte?: number | string
56
+ in?: ConditionScalar[]
57
+ notIn?: ConditionScalar[]
58
+ /** Case-sensitive `new RegExp(value).test(dep)`; a non-string dependency never matches. */
59
+ regexp?: string
60
+ /** `true` matches null/undefined/''/[]; `false` matches a present value. */
61
+ empty?: boolean
62
+ }
63
+
64
+ /** A leaf rule: test another field's value. Use `is` (strict-equality shorthand) OR `op`; a bare rule
65
+ * (neither) means "present" (non-empty). `field` is a sibling field's key (v1 scope). */
66
+ export interface ConditionRule {
67
+ field: string
68
+ is?: ConditionScalar
69
+ op?: ConditionOperator
70
+ }
71
+
72
+ /** Field visibility logic. A discriminated tree: leaf `ConditionRule`s combined by explicit
73
+ * `and`/`or`/`not` (no overloaded magic keys, so a real field named "and"/"or" never collides). */
74
+ export type Condition =
75
+ | ConditionRule
76
+ | { and: Condition[] }
77
+ | { or: Condition[] }
78
+ | { not: Condition }
79
+
80
+ export type LinkType = 'internal' | 'external' | 'email' | 'tel'
81
+
82
+ export type LinkValue =
83
+ | { type: 'internal'; collection: string; id: number; hash?: string; label?: string }
84
+ | { type: 'external'; url: string; label?: string }
85
+ | { type: 'email'; email: string; label?: string }
86
+ | { type: 'tel'; tel: string; label?: string }
87
+
88
+ export type FieldDef =
89
+ | (BaseFieldDef & { type: 'text'; options?: { minLength?: number; maxLength?: number; multiline?: boolean } })
90
+ // A url-safe slug (text-backed). `from` = the field key to auto-generate from when left blank; `prefix` is
91
+ // display-only in the editor widget (e.g. '/galleries/').
92
+ | (BaseFieldDef & { type: 'slug'; options?: { from?: string; prefix?: string } })
93
+ | (BaseFieldDef & { type: 'richtext' })
94
+ // `unit` is a display-only suffix shown after the value in the editor widget (e.g. 'rem'); the stored
95
+ // value stays a bare number. `units` is reserved for a future selectable-unit variant and is not yet
96
+ // honoured server-side.
97
+ | (BaseFieldDef & { type: 'number'; options?: { min?: number; max?: number; integer?: boolean; decimals?: number; unit?: string; units?: string[] } })
98
+ | (BaseFieldDef & { type: 'boolean' })
99
+ | (BaseFieldDef & { type: 'datetime'; options?: { precision?: 'date' | 'datetime' | 'time'; range?: boolean } })
100
+ | (BaseFieldDef & { type: 'choice'; options: { choices: { label: string; value: string }[]; multiple?: boolean; display?: 'select' | 'buttons' | 'checkboxes' } })
101
+ | (BaseFieldDef & { type: 'link'; options?: { types?: LinkType[]; collections?: string[] } })
102
+ | (BaseFieldDef & { type: 'media'; options?: { multiple?: boolean; accept?: 'image' | 'any' } })
103
+ | (BaseFieldDef & { type: 'relation'; relation: { collection: string; many?: boolean; labelField?: string } })
104
+ | (BaseFieldDef & { type: 'repeater'; options: { fields: Record<string, FieldDef>; fieldLayout?: FieldLayoutDSL } })
105
+ | (BaseFieldDef & { type: 'json' })
106
+ // A consumer-defined field type (registered via `defineFieldType`): any other `type` string, with
107
+ // free-form `options` forwarded to its server descriptor + editor widget.
108
+ | (BaseFieldDef & { type: string & {}; options?: Record<string, unknown> })
109
+
110
+ /** The specific `FieldDef` arm for a built-in field `type`; falls back to the full union for consumer
111
+ * types (whose arm is the open `type: string & {}`). The `[…] extends [never]` tuple wrap stops the
112
+ * conditional from distributing over `never`. */
113
+ export type FieldOf<T extends FieldType> = [Extract<FieldDef, { type: T }>] extends [never]
114
+ ? FieldDef
115
+ : Extract<FieldDef, { type: T }>
116
+
117
+ /** Type-guard narrowing a `FieldDef` to a specific built-in arm. Needed because the open consumer arm
118
+ * (`type: string & {}`) makes `type` a non-discriminant, so a bare `f.type === 'x'` won't narrow `f`. */
119
+ export function fieldIs<T extends FieldType>(field: FieldDef, type: T): field is FieldOf<T> {
120
+ return field.type === type
121
+ }
122
+
123
+ /** Whether a relation/media field is stored as (and addressed by) a single `<name>Id` FK column —
124
+ * true unless it is a many-relation or a multiple-media field. The single source of the `Id`-suffix
125
+ * rule shared by column naming (`resolveColumnName`), serialization (the `single` flag), and the editor. */
126
+ export function isSingleRefColumn(field: FieldDef): boolean {
127
+ return (fieldIs(field, 'relation') && !field.relation.many)
128
+ || (fieldIs(field, 'media') && !field.options?.multiple)
129
+ }
130
+
131
+ /**
132
+ * Author-facing field-layout DSL (resolved by `resolveFieldLayout`). Each entry is one of:
133
+ * - a string — a single full-width row (`'title'`), optionally widthed (`'title|50%'`);
134
+ * - a string[] — a side-by-side row, equal columns by default, or per-field `field|2` (flex weight) /
135
+ * `field|30%` (a CSS length/percent);
136
+ * - a single-key object — a named group of rows: `{ 'SEO': [ 'metaTitle', ['a', 'b'] ] }` (one level deep).
137
+ */
138
+ export type FieldLayoutEntry = string | string[] | Record<string, FieldLayoutEntry[]>
139
+ export type FieldLayoutDSL = FieldLayoutEntry[]
140
+
141
+ export interface CollectionDef {
142
+ name: string
143
+ mode: 'multi' | 'single'
144
+ /** Per-locale content. Optional — defaults to false (a single, non-localized record set). */
145
+ translatable?: boolean
146
+ pageLike?: boolean
147
+ seo?: boolean
148
+ blocks?: { enabled: true; allowed?: string[] }
149
+ status?: boolean
150
+ /** Which admin editor renders this collection's body (the presentation axis, kept separate from the
151
+ * `blocks` schema flag). Defaults to `'blocks'` when `blocks.enabled`, else `'fields'`. Open string:
152
+ * extensions register additional editor types (e.g. `'node-graph'`) via `registerCollectionEditor`. */
153
+ editor?: string
154
+ fields: Record<string, FieldDef>
155
+ /** Admin editor layout: rows of fields. A string is its own full-width row; a nested array puts fields
156
+ * side by side (equal columns, or `field|2` / `field|30%` for flex weights / CSS lengths); a single-key
157
+ * object is a named group `{ 'SEO': [ … ] }`. Fields omitted from the layout append as full-width rows,
158
+ * so adding a field never hides it. Absent → today's one-field-per-row. (Deviates from Pruvious, which
159
+ * nests this under a `dashboard` wrapper — Kestrel keeps its flat CollectionDef idiom.) */
160
+ fieldLayout?: FieldLayoutDSL
161
+ /** Display labels. `new` is the complete, per-locale "create" phrase (e.g. de `'Neue Seite'` /
162
+ * `'Neuer Beitrag'`) — supplying the whole phrase sidesteps German gender agreement, which no
163
+ * `'Neu {x}'` template can get right. Falls back to a generic phrase from `singular` when absent. */
164
+ label?: { singular?: Localized; plural?: Localized; new?: Localized }
165
+ /** Nav/dashboard icon: a UI registry icon name (e.g. `'settings'`) or raw inline SVG markup. Lets
166
+ * each collection — including custom ones — be visually distinct in the admin rail. */
167
+ icon?: string
168
+ /** Marks a Kestrel-shipped built-in (`pages`, `media`). Built-ins register by default but a consumer
169
+ * can disable them via `kestrel: { collections: { <name>: false } }` (config → `KESTREL_COLLECTIONS_<NAME>`
170
+ * env → default-on). Ignored for user-defined collections. */
171
+ builtin?: boolean
172
+ /** Show this collection in the admin rail (default true). Set `false` for a system/config store — e.g.
173
+ * a settings singleton — that a layer manages internally and surfaces (if at all) through its own UI, so
174
+ * it never appears as a flat top-level rail item beside the layer's main page. */
175
+ nav?: boolean
176
+ }
177
+
178
+ export function defineCollection(def: CollectionDef): CollectionDef {
179
+ // A pageLike record is governed by the slug engine (required + auto-generated + globally unique route),
180
+ // which only runs through the multi create/update path — `putSingleton` writes directly. A routable
181
+ // singleton would silently skip that enforcement, so refuse the combo loudly at definition time.
182
+ if (def.pageLike && def.mode === 'single') {
183
+ throw new Error(`[kestrel] pageLike collection "${def.name}" must be mode: 'multi' (a routable record needs the slug engine)`)
184
+ }
185
+ // The built-in `blocks` editor body persists to the `content` column, which the write schema only exposes
186
+ // when blocks are enabled. editor:'blocks' without blocks:{enabled:true} would mount a working page builder
187
+ // whose every edit is silently dropped on save — refuse the combo loudly at definition time.
188
+ if (def.editor === 'blocks' && !def.blocks?.enabled) {
189
+ throw new Error(`[kestrel] collection "${def.name}" sets editor: 'blocks' but is missing blocks: { enabled: true } — its block edits would never persist`)
190
+ }
191
+ // Framework tool endpoints share the flat /api/<segment> namespace. A pageLike collection whose name
192
+ // matches one enters the anonymous public-read set (publicReadableResources) and makes that endpoint
193
+ // anonymously reachable — e.g. a `links` collection exposes /api/links/resolve, leaking draft slugs.
194
+ // Refuse the collision loudly. (`media` is served by the generic router, so it is NOT a tool namespace.)
195
+ if (RESERVED_API_NAMESPACES.has(def.name)) {
196
+ throw new Error(`[kestrel] collection name "${def.name}" is a reserved framework API namespace — it would shadow /api/${def.name} and leak it into the anonymous public-read set; choose another name`)
197
+ }
198
+ // Fail loud at definition time on a bad field layout (unknown/duplicate field, invalid width, malformed
199
+ // group) — top level plus every nested repeater — so it surfaces at import/startup, not on first render.
200
+ validateFieldLayoutsDeep(def)
201
+ return def
202
+ }
203
+
204
+ const RESERVED_API_NAMESPACES = new Set([
205
+ 'auth', 'blocks', 'collections', 'links', 'publish-status', 'references', 'route',
206
+ 'galleries-secure', 'galleries-secure-proofing',
207
+ ])
@@ -0,0 +1,162 @@
1
+ import { eq, getTableColumns } from 'drizzle-orm'
2
+ import { createError } from 'h3'
3
+ import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
4
+ import type { AnySQLiteColumn, AnySQLiteTable } from 'drizzle-orm/sqlite-core'
5
+ import type { BuiltCollection } from './collection-types'
6
+ import { create } from './crud'
7
+ import { slugSourceKey, dedupeSourcePath } from './page-slug'
8
+ import { primaryLocale, prefixPrimaryLocale } from './locale'
9
+ import { allCollections } from './registry'
10
+ import { fieldIs, type FieldOf } from './defineCollection'
11
+ import { slugify } from '../../app/utils/slugify'
12
+ import { resolveColumnName } from '../../../fields/server/field-registry/naming'
13
+ import { isHardRequired } from '../../../fields/server/field-registry/index'
14
+ import { regenerateBlockIds } from '../../../fields/server/utils/block-ids'
15
+
16
+ type DB = BetterSQLite3Database
17
+ type Row = Record<string, unknown>
18
+
19
+ /**
20
+ * Append (or increment) a plain `(copy)` suffix on a title-like string:
21
+ * `Foo` → `Foo (copy)` → `Foo (copy 2)` → `Foo (copy 3)` → …
22
+ * Pure and non-localized; drives BOTH the list label and (for pageLike / slug fields) the derived slug.
23
+ * When `maxLength` is given (the slug-source field's `options.maxLength`), the BASE is truncated so the
24
+ * whole `base + suffix` result still fits — so a record at (or within a suffix of) its length limit stays
25
+ * duplicable instead of tripping create()'s re-validated `.max`. When the maxLength cannot fit even the
26
+ * suffix on its own, the source text is returned unchanged (no suffix); for a pageLike copy the `-N` route
27
+ * de-dup then still guarantees a unique slug. The increment rule holds under truncation.
28
+ */
29
+ export function withCopySuffix(text: string, maxLength?: number): string {
30
+ const m = /^(.*) \(copy(?: (\d+))?\)$/.exec(text)
31
+ const base = m ? m[1]! : text
32
+ const n = (m ? (m[2] ? Number.parseInt(m[2], 10) : 1) : 0) + 1
33
+ const suffix = n === 1 ? '(copy)' : `(copy ${n})`
34
+ const full = base ? `${base} ${suffix}` : suffix
35
+ if (maxLength === undefined || full.length <= maxLength) return full
36
+ // The suffix alone overflows the budget → drop it (a pageLike copy's `-N` route de-dup still disambiguates).
37
+ if (suffix.length > maxLength) return text.slice(0, maxLength)
38
+ // Truncate the base so `base + ' ' + suffix` fits exactly within maxLength (−1 for the joining space).
39
+ const room = maxLength - suffix.length - 1
40
+ const truncated = room > 0 ? base.slice(0, room).trimEnd() : ''
41
+ return truncated ? `${truncated} ${suffix}` : suffix
42
+ }
43
+
44
+ /** The one field whose verbatim copy CANNOT be reconciled: required + unique + not a slug (a slug re-derives,
45
+ * the pageLike path re-derives+de-dupes). Returns its def key, or `undefined` when the collection has none. */
46
+ function blockingUniqueField(c: BuiltCollection): string | undefined {
47
+ for (const [key, f] of Object.entries(c.def.fields)) {
48
+ if (f.unique && isHardRequired(f) && f.type !== 'slug') return key
49
+ }
50
+ return undefined
51
+ }
52
+
53
+ /**
54
+ * Duplicate one record into a NEW draft copy, composing `crud.create()` so validation, the fresh
55
+ * translationGroup nanoid, the slug de-dup, field transforms, the unique-slug check, the insert AND
56
+ * `emitWrite` (which rebuilds `record_refs` and drives the publish queue) all come for free — there is NO
57
+ * parallel insert path. The copy:
58
+ * • strips id / timestamps → a fresh PK + timestamps (create() also strips them);
59
+ * • drops `translationGroup` → create() mints a new group, so the copy is a LONE row in a brand-new group
60
+ * for its single locale (Option A — keeping the group would violate the UNIQUE(group, locale) index);
61
+ * • blanks `path` (pageLike) → `resolvePageSlug` re-derives + `-2`/`-3` de-dupes from the suffixed title,
62
+ * or — for a pageLike collection with NO slug-source text field — seeds the source's path, de-duped;
63
+ * • forces `status: 'draft'` → a copy of a published record is NOT itself published;
64
+ * • regenerates every block id → the copy never shares block identity with its source;
65
+ * • appends a `(copy)` suffix to the slug-source text field → the label and derived slug read "Foo (copy)";
66
+ * • blanks non-required unique fields EXCEPT the slug-source (its `(copy)` suffix already makes it distinct);
67
+ * • SHARES referenced media / relations / links verbatim (scalar column values copied, never deep-copied).
68
+ * Throws: 405 for a singleton · 404 for an unknown id · 422 (naming the field) when a REQUIRED UNIQUE field
69
+ * that is not the slug/path would collide verbatim (surfaced up-front instead of a raw SQLite 409).
70
+ */
71
+ export function duplicateRecord(db: DB, c: BuiltCollection, id: number): Row {
72
+ if (c.def.mode === 'single') {
73
+ throw createError({ statusCode: 405, statusMessage: 'Cannot duplicate a singleton' })
74
+ }
75
+ const blocking = blockingUniqueField(c)
76
+ if (blocking) {
77
+ throw createError({ statusCode: 422, statusMessage: `Cannot duplicate: the required unique field "${blocking}" would collide` })
78
+ }
79
+
80
+ const cols = getTableColumns(c.table) as Record<string, AnySQLiteColumn>
81
+ // RAW row (NOT getOne — no populate): populate nests media/relations into objects that would not round-
82
+ // trip through the insert schema; the duplicate must copy the stored scalar FKs so the copy SHARES them.
83
+ const src = db.select().from(c.table as AnySQLiteTable).where(eq(cols.id, id)).get() as Row | undefined
84
+ if (!src) throw createError({ statusCode: 404, statusMessage: `${c.name} ${id} not found` })
85
+
86
+ const body: Row = { ...src }
87
+ delete body.id
88
+ delete body.createdAt
89
+ delete body.updatedAt
90
+ delete body.translationGroup
91
+ delete body.path
92
+ if (c.def.status) body.status = 'draft'
93
+ if (c.def.blocks?.enabled) body.content = regenerateBlockIds(src.content)
94
+
95
+ appendCopySuffix(c, body)
96
+ blankNonRequiredUniqueFields(db, c, body)
97
+ seedCopyPath(db, c, src, body)
98
+
99
+ return create(db, c, body)
100
+ }
101
+
102
+ /** Suffix the slug-source text field (the same field `slugSourceValue`/`resolvePageSlug` derive from),
103
+ * budgeting the suffix against the field's `options.maxLength` so a record at its length limit stays
104
+ * duplicable (create() re-validates the text `.max`). */
105
+ function appendCopySuffix(c: BuiltCollection, body: Row): void {
106
+ const key = slugSourceKey(c.def)
107
+ if (!key) return
108
+ const value = body[key]
109
+ if (typeof value !== 'string') return
110
+ const field = c.def.fields[key]
111
+ const maxLength = field && fieldIs(field, 'text') ? field.options?.maxLength : undefined
112
+ body[key] = withCopySuffix(value, maxLength)
113
+ }
114
+
115
+ /** A unique field can't be copied verbatim (it would collide). A unique `slug` is re-derived from the
116
+ * now-suffixed source AND de-duped with a `-N` suffix against existing rows (so duplicating the SAME
117
+ * record twice yields `foo-copy`, then `foo-copy-2` — a plain slug field has no pageLike `-N` path dedup
118
+ * of its own, so two copies would otherwise collide on create's hard unique-slug check). Any OTHER unique
119
+ * field (guaranteed non-hard-required by the pre-flight — its column is nullable) falls to NULL, which
120
+ * SQLite permits many of. The slug-SOURCE text field is EXEMPT even when itself `unique`: its `(copy)`
121
+ * suffix already distinguishes it, so nulling it would be silent data loss (and, for a pageLike
122
+ * collection, leave resolvePageSlug no title → a 400). */
123
+ function blankNonRequiredUniqueFields(db: DB, c: BuiltCollection, body: Row): void {
124
+ const slugSource = slugSourceKey(c.def)
125
+ const cols = getTableColumns(c.table) as Record<string, AnySQLiteColumn>
126
+ for (const [key, f] of Object.entries(c.def.fields)) {
127
+ if (!f.unique || key === slugSource) continue
128
+ const { jsKey } = resolveColumnName(key, f)
129
+ if (fieldIs(f, 'slug')) body[jsKey] = dedupedCopySlug(db, c, cols, key, f, body)
130
+ else body[jsKey] = null
131
+ }
132
+ }
133
+
134
+ /** The de-duped slug for a copy: slugify the (already `(copy)`-suffixed) `from` source, else the copied
135
+ * slug value, then append `-2`/`-3`/… until it is free. Empty base → undefined (let create's transform try). */
136
+ function dedupedCopySlug(db: DB, c: BuiltCollection, cols: Record<string, AnySQLiteColumn>, key: string, f: FieldOf<'slug'>, body: Row): string | undefined {
137
+ const { jsKey } = resolveColumnName(key, f)
138
+ const from = f.options?.from
139
+ const source = from ? body[from] : undefined
140
+ const base = typeof source === 'string' && source ? slugify(source)
141
+ : typeof body[jsKey] === 'string' ? slugify(body[jsKey] as string) : ''
142
+ if (!base) return undefined
143
+ let candidate = base
144
+ for (let n = 2; db.select({ id: cols.id }).from(c.table as AnySQLiteTable).where(eq(cols[jsKey]!, candidate)).get(); n++) {
145
+ candidate = `${base}-${n}`
146
+ }
147
+ return candidate
148
+ }
149
+
150
+ /** For a pageLike collection with NO slug-source text field, create()'s auto-gen branch has no title to
151
+ * derive a slug from (and duplicate blanked `path`), so seed the copy's `path` off the SOURCE row's own
152
+ * explicit path — de-duped to a free route — which create()'s explicit branch then accepts. A no-op for
153
+ * every other collection (the suffixed title drives the slug) and when the source carries no usable path. */
154
+ function seedCopyPath(db: DB, c: BuiltCollection, src: Row, body: Row): void {
155
+ if (!c.def.pageLike || slugSourceKey(c.def)) return
156
+ const path = dedupeSourcePath(db, c, src, {
157
+ collections: allCollections(),
158
+ primary: primaryLocale(),
159
+ prefixPrimary: prefixPrimaryLocale(),
160
+ })
161
+ if (path) body.path = path
162
+ }
@@ -0,0 +1,125 @@
1
+ import type { CollectionDef, FieldDef, FieldLayoutDSL, Localized } from './defineCollection'
2
+
3
+ /**
4
+ * Field layout: the pure, node-testable resolver that turns the author DSL (`CollectionDef.fieldLayout` /
5
+ * a repeater's `options.fieldLayout`) into a normalized, JSON-safe structure the admin renders. It runs at
6
+ * definition time (fail-loud) and again in serialize (the wire copy); no runtime deps — only the erased
7
+ * type imports above.
8
+ */
9
+
10
+ /** A neutral column track: a flex WEIGHT (a plain number → the UI renders it as an `fr` unit) or an
11
+ * explicit LENGTH (`'30%'`, `'12rem'`, …). The engine stays free of CSS-grid vocabulary — the UI layer
12
+ * (Layout.vue) is the single place that turns tracks into a `grid-template-columns` value. */
13
+ export type LayoutTrack = number | string
14
+
15
+ /** A rendered row: the fields (in source/reading order) and one column track per field. */
16
+ export interface LayoutRow {
17
+ kind: 'row'
18
+ fields: string[]
19
+ /** One track per field (positionally aligned); default weight `1`. Never an author-supplied CSS string. */
20
+ tracks: LayoutTrack[]
21
+ }
22
+ /** A single named group (one level deep): a labelled `<fieldset>` wrapping stacked rows. */
23
+ export interface LayoutGroup {
24
+ kind: 'group'
25
+ label: Localized
26
+ rows: LayoutRow[]
27
+ }
28
+ export type LayoutNode = LayoutRow | LayoutGroup
29
+
30
+ // Width grammar, strict allow-lists so a field width can NEVER inject arbitrary CSS into the grid template.
31
+ const WEIGHT = /^\d+(?:\.\d+)?$/ // bare number -> `<n>fr` (flex weight)
32
+ const LENGTH = /^\d+(?:\.\d+)?(?:fr|%|px|rem|em)$/ // an explicit CSS length/percent (allow-listed units)
33
+
34
+ interface Cell {
35
+ field: string
36
+ /** A flex weight (number) or an explicit length (string), or undefined → default weight 1. */
37
+ track?: LayoutTrack
38
+ }
39
+
40
+ /** Parse one `'name'` / `'name|2'` / `'name|30%'` token into its field + optional grid track width. */
41
+ function parseToken(tok: unknown, ctx: string): Cell {
42
+ if (typeof tok !== 'string') throw new Error(`[kestrel] ${ctx}: a row field must be a string (got ${typeof tok})`)
43
+ const parts = tok.split('|')
44
+ if (parts.length > 2) throw new Error(`[kestrel] ${ctx}: "${tok}" has more than one "|"`)
45
+ const field = parts[0]!.trim()
46
+ if (!field) throw new Error(`[kestrel] ${ctx}: empty field name in "${tok}"`)
47
+ if (parts.length === 1) return { field }
48
+ const w = parts[1]!.trim()
49
+ // A zero (or, defensively, negative) track collapses the cell to a sliver via `.ui-field-cell { min-inline-size:0 }`
50
+ // — silently hiding it. Only a strictly positive value is a real column, so require it alongside the grammar.
51
+ // Strictly positive AND finite: a huge digit run (`'9'.repeat(400)`) parses to Infinity, which passes
52
+ // the digit-only grammar but is not a real column and serializes to JSON `null` on the wire — reject it.
53
+ const positive = (tok: string) => { const n = parseFloat(tok); return n > 0 && Number.isFinite(n) }
54
+ // A bare number or an `fr` length are both FLEX WEIGHTS → emit a plain number (no CSS unit in the wire).
55
+ if (WEIGHT.test(w) && positive(w)) return { field, track: parseFloat(w) }
56
+ if (LENGTH.test(w) && positive(w)) return { field, track: w.endsWith('fr') ? parseFloat(w) : w }
57
+ throw new Error(`[kestrel] ${ctx}: invalid width "${w}" for "${field}" (use a positive number weight like "2", or a length like "30%"/"12rem"/"200px")`)
58
+ }
59
+
60
+ /**
61
+ * Resolve a layout DSL against the field set. A lone string is a full-width row; a string[] is a
62
+ * side-by-side row; a single-key object is a named group of rows (one level, never nested). Fields not
63
+ * mentioned append as full-width rows at the end (declaration order) so adding a field never hides it.
64
+ * Throws on an unknown/duplicate field, a bad width, or a malformed group.
65
+ */
66
+ export function resolveFieldLayout(dsl: FieldLayoutDSL, fieldKeys: string[], ctx: string): LayoutNode[] {
67
+ const known = new Set(fieldKeys)
68
+ const seen = new Set<string>()
69
+ const nodes: LayoutNode[] = []
70
+
71
+ const use = (field: string) => {
72
+ if (!known.has(field)) throw new Error(`[kestrel] ${ctx}: unknown field "${field}" in the layout`)
73
+ if (seen.has(field)) throw new Error(`[kestrel] ${ctx}: field "${field}" appears more than once in the layout`)
74
+ seen.add(field)
75
+ }
76
+ const toRow = (entry: string | unknown[]): LayoutRow => {
77
+ const tokens = Array.isArray(entry) ? entry : [entry]
78
+ if (!tokens.length) throw new Error(`[kestrel] ${ctx}: a row must contain at least one field`)
79
+ const cells = tokens.map((t) => parseToken(t, ctx))
80
+ cells.forEach((c) => use(c.field))
81
+ return { kind: 'row', fields: cells.map((c) => c.field), tracks: cells.map((c) => c.track ?? 1) }
82
+ }
83
+
84
+ for (const entry of dsl) {
85
+ if (typeof entry === 'string' || Array.isArray(entry)) {
86
+ nodes.push(toRow(entry))
87
+ continue
88
+ }
89
+ // A single-key object → a named group of rows.
90
+ const names = Object.keys(entry)
91
+ if (names.length !== 1) throw new Error(`[kestrel] ${ctx}: a group must have exactly one name (got ${names.length})`)
92
+ const label = names[0]!
93
+ const inner = entry[label]!
94
+ if (!Array.isArray(inner) || !inner.length) throw new Error(`[kestrel] ${ctx}: group "${label}" needs at least one row`)
95
+ const rows = inner.map((r) => {
96
+ if (typeof r !== 'string' && !Array.isArray(r)) throw new Error(`[kestrel] ${ctx}: group "${label}" may not nest another group (one level only)`)
97
+ return toRow(r)
98
+ })
99
+ nodes.push({ kind: 'group', label, rows })
100
+ }
101
+
102
+ // Any field missing from the layout appends as a full-width row (declaration order) — never hidden.
103
+ for (const field of fieldKeys) if (!seen.has(field)) nodes.push({ kind: 'row', fields: [field], tracks: [1] })
104
+ return nodes
105
+ }
106
+
107
+ /**
108
+ * Validate every field layout a definition carries — the collection's own plus each (nested) repeater's —
109
+ * for the throwing side effect. Called from `defineCollection()` so a bad layout fails at import/startup.
110
+ */
111
+ export function validateFieldLayoutsDeep(def: CollectionDef): void {
112
+ if (def.fieldLayout) resolveFieldLayout(def.fieldLayout, Object.keys(def.fields), `collection "${def.name}"`)
113
+ walkRepeaterLayouts(def.fields, '')
114
+ }
115
+
116
+ function walkRepeaterLayouts(fields: Record<string, FieldDef>, prefix: string): void {
117
+ for (const [key, field] of Object.entries(fields)) {
118
+ if (field.type !== 'repeater') continue
119
+ // Structural narrow (avoids a runtime import of `fieldIs` — keeps this file's only runtime edge one-way).
120
+ const options = (field as Extract<FieldDef, { type: 'repeater' }>).options
121
+ const ctx = `${prefix}repeater "${key}"`
122
+ if (options.fieldLayout) resolveFieldLayout(options.fieldLayout, Object.keys(options.fields), ctx)
123
+ walkRepeaterLayouts(options.fields, `${ctx} > `)
124
+ }
125
+ }
@@ -0,0 +1,101 @@
1
+ import { eq, ne, lt, lte, gt, gte, sql, or, isNull, type SQL } from 'drizzle-orm'
2
+ import { createError } from 'h3'
3
+ import type { AnySQLiteColumn } from 'drizzle-orm/sqlite-core'
4
+ import { escapeLike } from './sql'
5
+ import type { FilterKind, FilterOp } from '../../app/utils/filter-ops'
6
+
7
+ /** Coerce a (stringified) filter value to the target COLUMN's storage type before the predicate. Keyed on
8
+ * the resolved Drizzle column (not the builtin def type-name) so a CUSTOM field type backed by a
9
+ * boolean/number column coerces too. parseFilter stringifies every query param, and drizzle maps any
10
+ * non-empty string (incl. 'false') to 1 for a boolean column, so an uncoerced boolean filter matches the
11
+ * wrong rows. (Owns this here — its single home — so both crud.list and filterCondition share it without a
12
+ * crud↔predicate import cycle.) */
13
+ export function coerceFilterValue(col: { getSQLType(): string; mode?: string }, value: unknown): unknown {
14
+ if (col.mode === 'boolean') return value === true || value === 'true' || value === '1'
15
+ // A timestamp column stores a Date and drizzle maps it via Date.getTime(); a raw string/number reaches
16
+ // mapToDriverValue and throws `value.getTime is not a function` → an unhandled 500 (reachable
17
+ // unauthenticated on any public collection). Coerce to a Date; an unparseable value is a clean 400.
18
+ if (col.mode === 'timestamp' || col.mode === 'timestamp_ms') {
19
+ const d = new Date(typeof value === 'number' ? value : String(value))
20
+ if (Number.isNaN(d.getTime())) throw createError({ statusCode: 400, statusMessage: `Invalid timestamp filter value: ${String(value)}` })
21
+ return d
22
+ }
23
+ const sqlType = col.getSQLType()
24
+ if ((sqlType === 'integer' || sqlType === 'real') && typeof value === 'string') {
25
+ const n = Number(value)
26
+ return Number.isFinite(n) ? n : value
27
+ }
28
+ return value
29
+ }
30
+
31
+ /** JSON-array membership: EXISTS an element of the array column equal to the (typed) value. Fully
32
+ * parameterised — only the schema-derived column identifier is interpolated, never user input — matching
33
+ * media/usages.ts:25. A `stringSet` (multi choice) matches string members; an `idSet` (many relation/media)
34
+ * matches an integer id (a non-numeric filter value is a clean 400, not a mismatch that silently matches
35
+ * nothing). */
36
+ function member(col: AnySQLiteColumn, kind: 'stringSet' | 'idSet', raw: string): SQL {
37
+ let value: string | number = raw
38
+ if (kind === 'idSet') {
39
+ // Number('') / Number(' ') are 0 (finite) and Number('1e3') is 1000 — none is a valid id token. Require a
40
+ // plain (optionally signed) integer so a blank/garbage filter is a clean 400, not a silent empty match.
41
+ const t = raw.trim()
42
+ if (t === '' || !/^-?\d+$/.test(t)) throw createError({ statusCode: 400, statusMessage: `Invalid id filter value: ${raw}` })
43
+ value = Number(t)
44
+ }
45
+ return sql`EXISTS (SELECT 1 FROM json_each(${sql.identifier(col.name)}) WHERE value = ${value})`
46
+ }
47
+
48
+ /**
49
+ * The exclusive end of the DAY a date-only value names, or null when the rule does not apply. A datetime
50
+ * column stores an instant, but `YYYY-MM-DD` (what a date picker and the `filter[c][op]=YYYY-MM-DD` wire
51
+ * contract carry) names a whole day: compared against that day's midnight, "on or before" would drop the
52
+ * day itself and "after" would return it. The next UTC day works for both storages — a `Date` for a
53
+ * timestamp column, and a lexicographically correct string for a TEXT one ('2026-07-25T16:00' < '2026-07-26').
54
+ */
55
+ function exclusiveDayEnd(kind: FilterKind, raw: string): string | null {
56
+ if (kind !== 'datetime' || !/^\d{4}-\d{2}-\d{2}$/.test(raw)) return null
57
+ const d = new Date(`${raw}T00:00:00Z`)
58
+ // A non-existent calendar date is left to the normal coercion rather than widened: '2026-13-01' is
59
+ // unparseable (a clean 400 on a timestamp column) and '2026-02-31' rolls forward three days, so shifting
60
+ // it would silently ask a different question than the URL states.
61
+ if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== raw) return null
62
+ d.setUTCDate(d.getUTCDate() + 1)
63
+ return d.toISOString().slice(0, 10)
64
+ }
65
+
66
+ /**
67
+ * Build the WHERE predicate for ONE already-validated filter clause (the caller allow-lists `kind`/`op`).
68
+ * Every user value crosses as a drizzle bound param; the only interpolation is `sql.identifier(col.name)`
69
+ * (schema-derived, never user input) — injection-safe, matching picker.ts / usages.ts.
70
+ *
71
+ * The json_each membership and LIKE ESCAPE are SQLite-specific but consistent with the existing query code
72
+ * and the SQLite-only engine; a future Postgres query dialect would have to supply @>/ILIKE variants here
73
+ * (a loud compile failure, never a silent mis-query). NOTE: `contains` on text/richtext matches the STORED
74
+ * string — for richtext that is the HTML source, so 'p' can hit a '<p>' tag (documented caveat; a stripped
75
+ * shadow column / FTS index is a later enhancement).
76
+ */
77
+ export function filterCondition(col: AnySQLiteColumn, kind: FilterKind, op: FilterOp, raw: string): SQL {
78
+ switch (op) {
79
+ case 'eq': return eq(col, coerceFilterValue(col, raw))
80
+ // SQL <> excludes NULL by three-valued logic; "is not X" must still surface an uncategorised (NULL) row.
81
+ case 'ne': { const v = coerceFilterValue(col, raw); return col.notNull ? ne(col, v) : or(ne(col, v), isNull(col))! }
82
+ case 'lt': return lt(col, coerceFilterValue(col, raw))
83
+ // Only the two bounds that would fall on the wrong side of a whole day get the boundary swap; `lt` /
84
+ // `gte` already mean "before the day" / "from the day on" when compared against its midnight.
85
+ case 'lte': {
86
+ const end = exclusiveDayEnd(kind, raw)
87
+ return end ? lt(col, coerceFilterValue(col, end)) : lte(col, coerceFilterValue(col, raw))
88
+ }
89
+ case 'gt': {
90
+ const end = exclusiveDayEnd(kind, raw)
91
+ return end ? gte(col, coerceFilterValue(col, end)) : gt(col, coerceFilterValue(col, raw))
92
+ }
93
+ case 'gte': return gte(col, coerceFilterValue(col, raw))
94
+ case 'contains':
95
+ if (kind === 'stringSet' || kind === 'idSet') return member(col, kind, raw)
96
+ // text / richtext substring (SQLite LIKE is ASCII case-insensitive); LIKE metacharacters escaped.
97
+ return sql`${col} like ${`%${escapeLike(raw)}%`} escape '\\'`
98
+ case 'notContains':
99
+ return sql`NOT ${member(col, kind as 'stringSet' | 'idSet', raw)}`
100
+ }
101
+ }
@@ -0,0 +1,50 @@
1
+ import { createError, getRouterParam, type H3Event } from 'h3'
2
+ import { getCollection } from './registry'
3
+ import type { BuiltCollection } from './collection-types'
4
+
5
+ export function getCollectionOr404(name: string): BuiltCollection {
6
+ const collection = getCollection(name)
7
+ if (!collection) throw createError({ statusCode: 404, statusMessage: `Unknown collection: ${name}` })
8
+ return collection
9
+ }
10
+
11
+ export function requireCollection(event: H3Event): BuiltCollection {
12
+ return getCollectionOr404(getRouterParam(event, 'collection') as string)
13
+ }
14
+
15
+ export function parseId(raw: string): number {
16
+ if (!/^[1-9][0-9]*$/.test(raw)) throw createError({ statusCode: 400, statusMessage: `Invalid id: ${raw}` })
17
+ return Number(raw)
18
+ }
19
+
20
+ export function requireId(event: H3Event): number {
21
+ return parseId(getRouterParam(event, 'id') as string)
22
+ }
23
+
24
+ /**
25
+ * Parse a list of record ids — from a bulk request body (`number[]`) or a comma-separated query string
26
+ * (`?ids=1,2,3`). Dedupes (preserving first-seen order) and rejects, with a clean 400: an empty list, any
27
+ * element that is not a positive integer, or more than `cap` ids. The cap is enforced up front on the raw
28
+ * input, so an oversized body is rejected without being fully walked and allocated. Shared by the `bulk`
29
+ * command endpoint and the batched referrer lookup so both enforce the same id contract.
30
+ */
31
+ export function parseIdList(raw: unknown, cap: number): number[] {
32
+ const parts: unknown[] = Array.isArray(raw)
33
+ ? raw
34
+ : typeof raw === 'string'
35
+ ? raw.split(',').map((s) => s.trim()).filter(Boolean)
36
+ : []
37
+ // Bound the work before parsing: reject an oversized input up front rather than iterating and building a
38
+ // Set of a million ids just to 400. `parts.length` is an upper bound on the deduped result, so this also
39
+ // subsumes any post-dedupe cap — a raw list that fits here can never exceed `cap` after deduping.
40
+ if (parts.length > cap) throw createError({ statusCode: 400, statusMessage: `Too many ids (max ${cap})` })
41
+ const ids: number[] = []
42
+ const seen = new Set<number>()
43
+ for (const p of parts) {
44
+ const n = typeof p === 'number' ? p : typeof p === 'string' ? Number(p) : Number.NaN
45
+ if (!Number.isInteger(n) || n <= 0) throw createError({ statusCode: 400, statusMessage: `Invalid id: ${String(p)}` })
46
+ if (!seen.has(n)) { seen.add(n); ids.push(n) }
47
+ }
48
+ if (!ids.length) throw createError({ statusCode: 400, statusMessage: 'ids must be a non-empty list' })
49
+ return ids
50
+ }