@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,85 @@
1
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
2
+ import { currentSessionEpoch } from './session-epoch'
3
+
4
+ export function deriveSigningKey(rawSecret: string, passwordHash?: string, epoch = 0): string {
5
+ // The `epoch` is a server-side revocation counter: bumping it (on logout / force-logout) changes the key
6
+ // so every previously-issued token fails verification immediately — the stateless model's "hard logout".
7
+ return createHmac('sha256', rawSecret).update('kestrel-session-v1').update(passwordHash ?? '').update(`e${epoch}`).digest('base64url')
8
+ }
9
+
10
+ export function signSession(secret: string, expMs: number): string {
11
+ const payload = Buffer.from(JSON.stringify({ exp: expMs })).toString('base64url')
12
+ const sig = createHmac('sha256', secret).update(payload).digest('base64url')
13
+ return `${payload}.${sig}`
14
+ }
15
+
16
+ export function verifySession(secret: string, value: string | undefined, nowMs: number): { valid: boolean; exp?: number } {
17
+ if (!value || typeof value !== 'string') return { valid: false }
18
+ const dot = value.indexOf('.')
19
+ if (dot <= 0 || dot === value.length - 1) return { valid: false }
20
+ const payload = value.slice(0, dot)
21
+ const sig = value.slice(dot + 1)
22
+ const expected = createHmac('sha256', secret).update(payload).digest('base64url')
23
+ const sigBuf = Buffer.from(sig)
24
+ const expBuf = Buffer.from(expected)
25
+ if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) return { valid: false }
26
+ let exp: unknown
27
+ try {
28
+ exp = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')).exp
29
+ } catch {
30
+ return { valid: false }
31
+ }
32
+ if (typeof exp !== 'number' || exp <= nowMs) return { valid: false }
33
+ return { valid: true, exp }
34
+ }
35
+
36
+ /**
37
+ * Sliding-expiry decision: refresh the session cookie once its remaining lifetime drops below HALF the
38
+ * idle window. Activity within the window keeps the session alive (each refresh resets exp to now+maxAge);
39
+ * inactivity longer than the window lets it expire → the admin must log in again. The halfway threshold
40
+ * bounds Set-Cookie churn to at most once per half-window instead of on every request.
41
+ */
42
+ export function shouldRefreshSession(exp: number, nowMs: number, maxAgeMs: number): boolean {
43
+ return exp - nowMs < maxAgeMs / 2
44
+ }
45
+
46
+ let devSecret: string | undefined
47
+
48
+ export interface SessionSettings {
49
+ secret: string
50
+ maxAge: number
51
+ secureCookies: boolean
52
+ cookieName: string
53
+ }
54
+
55
+ export function sessionSettings(): SessionSettings {
56
+ // Treat anything that isn't an EXPLICIT dev signal as production for these safeguards, so a deployment
57
+ // that simply omits NODE_ENV (a common slip when launching `.output/server/index.mjs`) is hardened —
58
+ // not silently downgraded to dev, which would tolerate a missing secret + non-Secure cookies. Vitest
59
+ // sets NODE_ENV=test; KESTREL_DEV=1 is an explicit escape hatch for other dev contexts.
60
+ const explicitDev = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test' || process.env.KESTREL_DEV === '1'
61
+ const isProd = !explicitDev
62
+ const secureCookies = process.env.KESTREL_SECURE_COOKIES !== 'false'
63
+ if (isProd && !secureCookies) {
64
+ throw new Error('KESTREL_SECURE_COOKIES=false is not allowed in production')
65
+ }
66
+ const rawMaxAge = Number(process.env.KESTREL_SESSION_MAX_AGE)
67
+ const maxAge = Number.isFinite(rawMaxAge) && rawMaxAge > 0 ? rawMaxAge : 604800
68
+ let secret = process.env.KESTREL_SESSION_SECRET
69
+ if (!secret) {
70
+ if (isProd) throw new Error('KESTREL_SESSION_SECRET is required in production')
71
+ if (!devSecret) {
72
+ devSecret = randomBytes(32).toString('base64url')
73
+ console.warn('[kestrel auth] KESTREL_SESSION_SECRET not set — using a random per-process dev secret; sessions reset on restart')
74
+ }
75
+ secret = devSecret
76
+ } else if (isProd && Buffer.byteLength(secret) < 32) {
77
+ throw new Error('KESTREL_SESSION_SECRET must be at least 32 bytes in production')
78
+ }
79
+ return {
80
+ secret: deriveSigningKey(secret, process.env.KESTREL_ADMIN_PASSWORD_HASH, currentSessionEpoch()),
81
+ maxAge,
82
+ secureCookies,
83
+ cookieName: secureCookies ? '__Host-kestrel_session' : 'kestrel_session',
84
+ }
85
+ }
@@ -0,0 +1 @@
1
+ export default defineNuxtConfig({})
@@ -0,0 +1,19 @@
1
+ import { buildCollection } from '../../../fields/server/utils/buildCollection'
2
+ import { defineCollection } from '../../../core/server/utils/defineCollection'
3
+
4
+ const built = buildCollection(defineCollection({
5
+ name: 'pages',
6
+ mode: 'multi',
7
+ translatable: true,
8
+ pageLike: true,
9
+ seo: true,
10
+ blocks: { enabled: true },
11
+ status: true,
12
+ builtin: true,
13
+ label: { singular: { en: 'Page', de: 'Seite' }, plural: { en: 'Pages', de: 'Seiten' }, new: { en: 'New Page', de: 'Neue Seite' } },
14
+ icon: 'file-text',
15
+ fields: { title: { type: 'text', required: true } },
16
+ }))
17
+
18
+ export const pages = built.table
19
+ export default built
@@ -0,0 +1,32 @@
1
+ import collections from '#kestrel/collections'
2
+ import blocks from '#kestrel/blocks'
3
+ import { registerBlock, type BlockDef } from '../../../fields/server/utils/defineBlock'
4
+ import { ensureBuilt } from '../../../fields/server/utils/buildCollection'
5
+ import { resolveServerKestrel, serverRuntimeConfig } from '../../../core/server/utils/server-config'
6
+ import { collectionEnabled } from '../../../core/server/schema/bootstrap'
7
+ import type { CollectionDef } from '../../../core/server/utils/defineCollection'
8
+ import type { BuiltCollection } from '../../../core/server/utils/collection-types'
9
+
10
+ // Auto-discovered collection files default-export either a plain CollectionDef (the common consumer
11
+ // form, `export default defineCollection(…)`) or an already-built collection (advanced). `ensureBuilt`
12
+ // normalizes both so the registry only ever holds BuiltCollections.
13
+ //
14
+ // NOTE on ordering: the `01.` prefix sorts only WITHIN this (collections) layer. Nitro runs plugins by
15
+ // layer-then-filename, so this plugin does NOT run second — in-repo it runs LAST (after core's
16
+ // 00.migrate/02.schema-sync/03.record-refs), and the cross-layer order differs again for an
17
+ // `extends: ['@michaelthielemann/kestrel']` consumer. That is safe ONLY because nothing reads the registry at plugin init:
18
+ // migrate/schema-sync read the `#kestrel/collections` virtual directly, and the ref/cleanup/publish
19
+ // plugins register deferred listeners. Keep that invariant — do not add an init-time allCollections() read.
20
+ export default defineNitroPlugin(() => {
21
+ for (const block of blocks as BlockDef[]) registerBlock(block)
22
+ // Built-in collections (`pages`, `media`) register by default but can be disabled per consumer via
23
+ // `kestrel: { collections: { <name>: false } }`. Prefer the value the kestrel module put on runtimeConfig
24
+ // (the consumer's `kestrel: {}`); fall back to Kestrel's own resolved config for non-Nitro / dev callers.
25
+ const toggles = (serverRuntimeConfig()?.kestrel?.collections ?? resolveServerKestrel().collections) as Record<string, boolean>
26
+ for (const collection of collections as (CollectionDef | BuiltCollection)[]) {
27
+ const built = ensureBuilt(collection)
28
+ // The shared predicate (schema/bootstrap) — the schema engine gates on exactly the same rule.
29
+ if (!collectionEnabled(built.def, toggles)) continue
30
+ registerCollection(built)
31
+ }
32
+ })
@@ -0,0 +1,17 @@
1
+ import { registerFieldPopulator } from '../../../core/server/utils/populate'
2
+ import { buildRelationFieldPopulator, skipMissing } from '../utils/populate-relations'
3
+ import { getCollection } from '../../../core/server/utils/registry'
4
+ import { getOne } from '../../../core/server/utils/crud'
5
+
6
+ // Expand relation fields to the fully-populated related record(s) under `$<name>` at read time (depth ≥ 1).
7
+ // The related read is published-only (a draft target is skipped, never leaked to a public read), tolerates a
8
+ // stale/deleted id (getOne 404 → null via `skipMissing`, other errors propagate — fail-loud), and runs at
9
+ // `depth - 1`, so `populateRow` bails at 0 and cycles terminate. Registered per-type; the shared field-tree
10
+ // walker (fields layer) dispatches it.
11
+ export default defineNitroPlugin(() => {
12
+ registerFieldPopulator('relation', buildRelationFieldPopulator((collection, id, depth, locale) => {
13
+ const built = getCollection(collection)
14
+ if (!built) return null
15
+ return skipMissing(() => getOne(useDb(), built, id, depth, locale, true) as Record<string, unknown>)
16
+ }))
17
+ })
@@ -0,0 +1,66 @@
1
+ import { fieldIs } from '../../../core/server/utils/defineCollection'
2
+ import type { FieldPopulator } from '../../../core/server/utils/populate'
3
+ import { memoDuringPrerender } from '../../../core/server/utils/prerender-memo'
4
+ import { memoResolver } from '../../../core/server/utils/resolve-scope'
5
+
6
+ /** Fetches a related record FULLY populated (its own media / links / nested relations resolved), or null
7
+ * when the id is stale / deleted / draft. Injected so the populator stays pure + unit-testable; the plugin
8
+ * wires the real `getOne`. `depth` is the ALREADY-decremented depth for the related read (the cycle guard). */
9
+ export type ResolveRecord = (
10
+ collection: string,
11
+ id: number,
12
+ depth: number,
13
+ locale: string,
14
+ ) => Record<string, unknown> | null
15
+
16
+ /**
17
+ * Run a related-record fetch, mapping `getOne`'s 404 (a stale / deleted / draft target — the intended
18
+ * "skip this reference" signal) to `null`, but letting ANY OTHER error propagate. Swallowing every error
19
+ * here would drop a relation on a real fault (a DB error, a downstream populator throwing on malformed
20
+ * stored data) while the page still renders + records `success` — violating fail-loud. Pure + testable.
21
+ */
22
+ export function skipMissing(fetch: () => Record<string, unknown>): Record<string, unknown> | null {
23
+ try {
24
+ return fetch()
25
+ } catch (e) {
26
+ if ((e as { statusCode?: number })?.statusCode === 404) return null
27
+ throw e
28
+ }
29
+ }
30
+
31
+ /**
32
+ * The `relation` field populator: expands a relation id (single, `${name}Id` in columns / bare in props)
33
+ * or id array (many, always bare) into the fully-populated related record(s) under a `$<name>` sibling,
34
+ * leaving the raw id column intact — so write round-trips and the admin relation-picker keep working.
35
+ * Stale / deleted / draft ids are skipped (single → null, many → filtered out) so one bad reference never
36
+ * fails the whole read. The related read passes `ctx.depth - 1`; `populateRow` bails at depth 0, so a
37
+ * relation cycle terminates. Registered per-type via `registerFieldPopulator('relation', …)`; the shared
38
+ * field-tree walker drives it over top-level fields, block props, slots, and repeater entries.
39
+ */
40
+ export function buildRelationFieldPopulator(resolveRecord: ResolveRecord): FieldPopulator {
41
+ // The same target (collection+id+depth+locale) is resolved once — build-wide during a generate run
42
+ // (memoDuringPrerender), request-/publish-run-wide via the resolve scope (which also budgets the
43
+ // distinct fan-out of one live request and replays read-tags on hits, so publish deps stay complete).
44
+ // memoResolver OUTERMOST: the per-scope budget verdict must stay scope-local. If memoDuringPrerender
45
+ // wrapped memoResolver, a build-wide memoize would cache a budget-skip `null` and poison every later
46
+ // page of a `nuxt generate`. With this order the build-wide memo only ever caches REAL resolver results.
47
+ const key = (collection: string, id: number, depth: number, locale: string) => `rel:${collection}:${id}:${depth}:${locale}`
48
+ const resolve = memoResolver(memoDuringPrerender(resolveRecord, key), key)
49
+ return (bag, key, field, ctx, keyMode) => {
50
+ if (!fieldIs(field, 'relation')) return
51
+ const collection = field.relation.collection
52
+ const depth = ctx.depth - 1
53
+ if (field.relation.many) {
54
+ const ids = bag[key]
55
+ if (Array.isArray(ids)) {
56
+ bag['$' + key] = ids
57
+ .filter((n): n is number => typeof n === 'number')
58
+ .map((id) => resolve(collection, id, depth, ctx.locale))
59
+ .filter((r): r is Record<string, unknown> => r != null)
60
+ }
61
+ } else {
62
+ const id = bag[keyMode === 'columns' ? `${key}Id` : key]
63
+ if (typeof id === 'number') bag['$' + key] = resolve(collection, id, depth, ctx.locale)
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,9 @@
1
+ // The batch operations the bulk endpoint accepts — the single source of truth shared by the server
2
+ // handler (`bulk.post.ts`) and any client that builds a bulk request. Pure, so the allow-list is
3
+ // unit-testable without a Nitro context.
4
+ export const BULK_ACTIONS = ['delete', 'publish', 'unpublish', 'duplicate'] as const
5
+ export type BulkAction = typeof BULK_ACTIONS[number]
6
+
7
+ export function isBulkAction(value: unknown): value is BulkAction {
8
+ return typeof value === 'string' && (BULK_ACTIONS as readonly string[]).includes(value)
9
+ }
@@ -0,0 +1,98 @@
1
+ import type { SerializedField } from '../../server/utils/serialize-collection' // type-only, erased client-side
2
+
3
+ /**
4
+ * The single shared source of truth for list filtering: which normalized "kind" a field/meta column maps
5
+ * to, and which operators that kind allows. Both the server (validation + predicate building) and the admin
6
+ * UI (operator select + typed value control) import this — so the allowed-operators-per-field-type table is
7
+ * defined exactly once. Kept in `core/app/utils` (the server-and-client shared home) and free of any DB or
8
+ * column-naming detail: each side maps its own column keys.
9
+ */
10
+
11
+ export type FilterOp = 'eq' | 'ne' | 'lt' | 'lte' | 'gt' | 'gte' | 'contains' | 'notContains'
12
+
13
+ /**
14
+ * A field's storage/semantics bucket for filtering. `text`/`richtext` do substring LIKE; `stringSet`/`idSet`
15
+ * are JSON-array membership (multi choice / many relation+media); `ref` is a single FK id (eq/ne only);
16
+ * `enum`/`boolean` are equality-only; `number`/`datetime` support the full comparison set.
17
+ */
18
+ export type FilterKind = 'number' | 'text' | 'datetime' | 'boolean' | 'enum' | 'ref' | 'richtext' | 'stringSet' | 'idSet'
19
+
20
+ const CMP = ['eq', 'ne', 'lt', 'lte', 'gt', 'gte'] as const
21
+
22
+ export const OPS_BY_KIND: Record<FilterKind, readonly FilterOp[]> = {
23
+ number: CMP,
24
+ datetime: CMP,
25
+ text: ['eq', 'ne', 'contains'],
26
+ richtext: ['contains'], // `contains` matches the stored HTML SOURCE (searching 'p' can hit a '<p>' tag) —
27
+ // a stripped shadow column / FTS index is a later enhancement (documented caveat).
28
+ boolean: ['eq', 'ne'],
29
+ enum: ['eq', 'ne'],
30
+ ref: ['eq', 'ne'], // a single relation/media FK id — lt/gt on an id is meaningless.
31
+ stringSet: ['contains', 'notContains'],
32
+ idSet: ['contains', 'notContains'],
33
+ }
34
+
35
+ /** The operator a column's filter row defaults to in the admin UI. `datetime` defaults to `gte` ("on or
36
+ * after") — `eq` on a millisecond timestamp practically never matches, so it made the filter useless. The
37
+ * admin always emits the operator explicitly, so this does NOT change the wire parser's separate
38
+ * absent-operator default (a bare `filter[field]` still parses as `eq`, URLs round-trip unchanged). */
39
+ export const DEFAULT_OP: Record<FilterKind, FilterOp> = {
40
+ number: 'eq', datetime: 'gte', text: 'eq', boolean: 'eq', enum: 'eq', ref: 'eq',
41
+ richtext: 'contains', stringSet: 'contains', idSet: 'contains',
42
+ }
43
+
44
+ const ALL_OPS = new Set<string>(['eq', 'ne', 'lt', 'lte', 'gt', 'gte', 'contains', 'notContains'])
45
+
46
+ /** Whether a raw wire token is a known operator at all (parse-time guard → a clean 400 for garbage). */
47
+ export const isFilterOp = (s: string): s is FilterOp => ALL_OPS.has(s)
48
+
49
+ /** Whether `op` is allowed for a column of this kind (list-time guard → a clean 400, never a 500). */
50
+ export const opAllowed = (kind: FilterKind, op: FilterOp): boolean => OPS_BY_KIND[kind].includes(op)
51
+
52
+ /**
53
+ * The wire key for a filter clause: `filter[field]` or `filter[field][op]`. `[^\]]+` keeps the field/op
54
+ * tokens bracket-free; the optional second group is the operator (absent → `eq`, so a bare
55
+ * `filter[status]=x` stays backward compatible). Defined ONCE here (the shared client+server home) so the
56
+ * admin URL parser (`parseListQuery`) and the server wire parser (`parseFilter`) use exactly one regex.
57
+ */
58
+ export const FILTER_RE = /^filter\[([^\]]+)\](?:\[([^\]]+)\])?$/
59
+
60
+ /**
61
+ * The FilterKind for a field, or `null` when it is not filterable (link / json / repeater / range-datetime).
62
+ * Unknown consumer types fall back to the conservative `text` (equality + substring on their scalar column).
63
+ */
64
+ export function fieldFilterKind(f: SerializedField): FilterKind | null {
65
+ switch (f.type) {
66
+ case 'text':
67
+ case 'slug':
68
+ return 'text'
69
+ case 'richtext':
70
+ return 'richtext'
71
+ case 'number':
72
+ return 'number'
73
+ case 'boolean':
74
+ return 'boolean'
75
+ case 'datetime':
76
+ return f.options?.range ? null : 'datetime'
77
+ case 'choice':
78
+ return f.options?.multiple ? 'stringSet' : 'enum'
79
+ case 'relation':
80
+ case 'media':
81
+ return f.single ? 'ref' : 'idSet'
82
+ case 'link':
83
+ case 'json':
84
+ case 'repeater':
85
+ return null
86
+ default:
87
+ return 'text'
88
+ }
89
+ }
90
+
91
+ /** The FilterKind of each filterable meta/system column (keyed by its wire key). */
92
+ export const META_FILTER_KIND: Record<string, FilterKind> = {
93
+ id: 'number',
94
+ path: 'text',
95
+ status: 'enum',
96
+ createdAt: 'datetime',
97
+ updatedAt: 'datetime',
98
+ }
@@ -0,0 +1,27 @@
1
+ // The single source of truth for collection-list page sizes and, derived from them, the bulk-operation id
2
+ // cap. Lives in `core/app/utils` (the established server+client shared home — same as `filter-ops.ts`) so
3
+ // the admin list UI, the `list()` query engine, and the `bulk` endpoint all agree on ONE set of limits
4
+ // instead of each hardcoding its own.
5
+
6
+ /** The page sizes offered in the list's per-page selector. */
7
+ export const PER_PAGE_OPTIONS = [25, 50, 100, 250, 500] as const
8
+
9
+ /** The page size a list falls back to when none (or a garbage value) is supplied. */
10
+ export const DEFAULT_PER_PAGE = 25
11
+
12
+ /** The largest page a single list request may return — also the ceiling `clampPerPage` clamps to. */
13
+ export const MAX_PER_PAGE = 500
14
+
15
+ /** Coerce an untrusted page size to a sane integer: a non-finite input (undefined / NaN / Infinity / a
16
+ * non-numeric string) falls back to `DEFAULT_PER_PAGE`; anything else is floored and clamped to
17
+ * `[1, MAX_PER_PAGE]`. Guards against `LIMIT NaN` (which SQLite binds as "no limit") — an unauthenticated
18
+ * cap-bypass / DoS lever. */
19
+ export function clampPerPage(n: unknown): number {
20
+ const v = Number(n)
21
+ if (!Number.isFinite(v)) return DEFAULT_PER_PAGE
22
+ return Math.min(MAX_PER_PAGE, Math.max(1, Math.floor(v)))
23
+ }
24
+
25
+ /** A bulk op can never carry more ids than one page can show (you select what you can see), so the id cap
26
+ * is DERIVED from the max page size, not guessed. */
27
+ export const MAX_BULK_IDS = MAX_PER_PAGE
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The public route path for a page row: other locales are prefixed with `/<locale>`; the primary locale
3
+ * is unprefixed unless `prefixPrimary` is set (then it is prefixed too, root `/` → `/<primary>`). Single
4
+ * source of truth for the URL scheme shared by the prerender routes, the sitemap, `populate-links`, the
5
+ * `[...slug].vue` resolution, the slug-uniqueness check, and the admin SEO preview. Pure, so it is
6
+ * unit-testable and safe to import from both server and app code.
7
+ */
8
+ export function localePath(path: string, locale: string, primaryLocale: string, prefixPrimary = false): string {
9
+ const p = path.startsWith('/') ? path : `/${path}`
10
+ if (locale === primaryLocale && !prefixPrimary) return p
11
+ return p === '/' ? `/${locale}` : `/${locale}${p}`
12
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Turn arbitrary text (a record title) into a url-safe slug: lowercase, fold diacritics to ASCII
3
+ * (`Über` → `uber`, `Café` → `cafe`) via NFKD + combining-mark removal, then collapse every run of
4
+ * non-`[a-z0-9]` into a single hyphen and trim the ends. Letters with no ASCII fold (ß, CJK, …) act as
5
+ * separators — Kestrel does NO language-specific transliteration, so the result is predictable. Returns
6
+ * `''` for blank / symbol-only input (the caller decides: fall back to the manual slug, or reject). Pure.
7
+ */
8
+ export function slugify(input: string): string {
9
+ if (typeof input !== 'string') return ''
10
+ return input
11
+ .normalize('NFKD')
12
+ .replace(/\p{M}/gu, '') // strip combining diacritical marks left by NFKD (é → e, ü → u)
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9]+/g, '-')
15
+ .replace(/^-+|-+$/g, '')
16
+ }
@@ -0,0 +1,176 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { basename } from 'node:path'
3
+ import { parse } from '@babel/parser'
4
+ import type { FieldDef } from '../../server/utils/defineCollection'
5
+ import * as factoryModule from '../../../fields/app/utils/field-factories'
6
+ import { KESTREL_FIELD } from '../../../fields/app/utils/field-factories'
7
+
8
+ /** The extracted block definition (shape mirrors `BlockDef`, kept structural so this build-time module
9
+ * needs no `layers/fields` runtime import). */
10
+ export interface ExtractedBlock {
11
+ name: string
12
+ fields: Record<string, FieldDef>
13
+ slots?: string[]
14
+ label?: unknown
15
+ icon?: string
16
+ image?: string
17
+ }
18
+
19
+ // Only the callable factories are injected into the schema-eval scope (exclude the KESTREL_FIELD symbol).
20
+ const FACTORIES: Record<string, unknown> = Object.fromEntries(
21
+ Object.entries(factoryModule).filter(([, v]) => typeof v === 'function'),
22
+ )
23
+ // The factory functions themselves, to catch an uncalled factory (`heading: textField` vs `textField()`).
24
+ const FACTORY_SET = new Set(Object.values(FACTORIES))
25
+
26
+ /** `Hero.vue` → `hero`, `BoxedContainer.vue` → `boxedContainer` (camel-case; `BlockRenderer` pascal-cases it
27
+ * back to the `Blocks<Name>` component). */
28
+ export function blockNameFromFile(fileBase: string): string {
29
+ const name = fileBase.replace(/\.vue$/, '')
30
+ return name.charAt(0).toLowerCase() + name.slice(1)
31
+ }
32
+
33
+ /** The raw `<script setup>` source. `setup` is matched in ANY attribute position (`<script setup lang="ts">`
34
+ * and `<script lang="ts" setup>` are both valid), so a plain `<script>` block (no `setup` word) is skipped.
35
+ * (A real Vite build could use `@vue/compiler-sfc parse` for full robustness — e.g. a `</script>` inside a
36
+ * string literal; a scoped match is adequate + dependency-light here.) */
37
+ function scriptSetup(sfc: string, where: string): string {
38
+ const m = sfc.match(/<script\b[^>]*\bsetup\b[^>]*>([\s\S]*?)<\/script>/)
39
+ if (!m) throw new Error(`${where}: a Kestrel block SFC must have a <script setup> block`)
40
+ return m[1]
41
+ }
42
+
43
+ interface Node { type: string; start: number; end: number; [k: string]: unknown }
44
+
45
+ /** Peel the wrappers a macro call is commonly written behind so the CallExpression underneath is reached:
46
+ * a TS as-cast / satisfies (`defineProps({…}) as T`), a non-null assertion (`defineProps({…})!`), and
47
+ * parentheses. */
48
+ function unwrapExpr(node: unknown): Record<string, unknown> | undefined {
49
+ let n = node as Record<string, unknown> | undefined
50
+ while (n && (n.type === 'TSAsExpression' || n.type === 'TSSatisfiesExpression' || n.type === 'TSNonNullExpression' || n.type === 'ParenthesizedExpression')) {
51
+ n = n.expression as Record<string, unknown> | undefined
52
+ }
53
+ return n
54
+ }
55
+
56
+ /** If `call` resolves to `<macro>(…)`, its first arg node + whether it carried a type parameter
57
+ * (`defineProps<T>()`, unsupported — no runtime schema). Also looks INSIDE `withDefaults(defineProps(…), …)`
58
+ * — the most common props idiom — so that form is not silently extracted as an empty schema. */
59
+ function macroInCall(call: unknown, macro: string): { arg?: Node; hasTypeParam: boolean } | undefined {
60
+ const n = unwrapExpr(call)
61
+ if (n?.type !== 'CallExpression') return undefined
62
+ const callee = n.callee as Record<string, unknown> | undefined
63
+ if (callee?.type === 'Identifier' && callee.name === macro) {
64
+ return { arg: (n.arguments as Node[])[0], hasTypeParam: !!(n.typeParameters || n.typeArguments) }
65
+ }
66
+ // withDefaults(defineProps(…), { … }) — recurse into its arguments to find the wrapped macro call.
67
+ if (callee?.type === 'Identifier' && callee.name === 'withDefaults') {
68
+ for (const a of (n.arguments as unknown[]) ?? []) {
69
+ const hit = macroInCall(a, macro)
70
+ if (hit) return hit
71
+ }
72
+ }
73
+ return undefined
74
+ }
75
+
76
+ /** The FIRST TOP-LEVEL `<macro>(…)` call — a bare `defineBlock({…})` (ExpressionStatement), a
77
+ * `const props = defineProps({…})` (VariableDeclaration init, incl. `export const` and wrapper forms).
78
+ * Vue's macros are top-level by contract, so scanning only `program.body` (not a deep DFS) means a call
79
+ * nested in a helper function can't shadow it. */
80
+ function macroCall(ast: unknown, macro: string): { arg?: Node; hasTypeParam: boolean } | undefined {
81
+ const body = (ast as { program?: { body?: unknown[] } }).program?.body
82
+ if (!Array.isArray(body)) return undefined
83
+ for (const raw of body as Array<Record<string, unknown>>) {
84
+ // Unwrap `export const props = …` → the VariableDeclaration it wraps.
85
+ const stmt = raw.type === 'ExportNamedDeclaration' && raw.declaration ? (raw.declaration as Record<string, unknown>) : raw
86
+ if (stmt.type === 'ExpressionStatement') {
87
+ const hit = macroInCall(stmt.expression, macro)
88
+ if (hit) return hit
89
+ } else if (stmt.type === 'VariableDeclaration') {
90
+ for (const d of (stmt.declarations as Array<Record<string, unknown>>) ?? []) {
91
+ const hit = macroInCall(d.init, macro)
92
+ if (hit) return hit
93
+ }
94
+ }
95
+ }
96
+ return undefined
97
+ }
98
+
99
+ function evalObjectExpr(content: string, node: Node, scope: Record<string, unknown>, where: string): Record<string, unknown> {
100
+ const src = content.slice(node.start, node.end)
101
+ const names = Object.keys(scope)
102
+ try {
103
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
104
+ const fn = new Function(...names, `return (${src})`)
105
+ const out = fn(...names.map((k) => scope[k]))
106
+ if (!out || typeof out !== 'object') throw new Error('expected an object literal')
107
+ return out as Record<string, unknown>
108
+ } catch (e) {
109
+ throw new Error(
110
+ `${where}: could not evaluate the block declaration. Field/block args must be self-contained literals + ` +
111
+ `field-factory calls (no imported constants, computed values, or TS type-args). Cause: ${(e as Error).message}`,
112
+ )
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Extract a `BlockDef` from a block SFC's source: `defineProps({ … field factories … })` → `fields`,
118
+ * `defineBlock({ label, slots, icon })` → metadata, `name` ← filename. Static (no component execution): the
119
+ * `defineProps`/`defineBlock` argument object-literals are sliced from source and evaluated with the field
120
+ * factories in scope; each field's `[KESTREL_FIELD]` is lifted into the schema.
121
+ */
122
+ export function extractBlockDef(sfcSource: string, fileBase: string): ExtractedBlock {
123
+ const name = blockNameFromFile(fileBase)
124
+ const content = scriptSetup(sfcSource, fileBase)
125
+ let ast: ReturnType<typeof parse>
126
+ try {
127
+ ast = parse(content, { sourceType: 'module', plugins: ['typescript'] })
128
+ } catch (e) {
129
+ throw new Error(`${fileBase}: could not parse <script setup> — ${(e as Error).message}`)
130
+ }
131
+
132
+ const props = macroCall(ast, 'defineProps')
133
+ if (props?.hasTypeParam && !props.arg) {
134
+ throw new Error(`${fileBase}: use the runtime form defineProps({ … }) with field factories, not defineProps<T>() — the type-only form carries no schema`)
135
+ }
136
+ const propsObj = props?.arg ? evalObjectExpr(content, props.arg, FACTORIES, fileBase) : {}
137
+
138
+ const block = macroCall(ast, 'defineBlock')
139
+ const meta = block?.arg ? evalObjectExpr(content, block.arg, {}, fileBase) : {}
140
+
141
+ const fields: Record<string, FieldDef> = {}
142
+ for (const [key, value] of Object.entries(propsObj)) {
143
+ const carried = value && typeof value === 'object' ? (value as Record<symbol, unknown>)[KESTREL_FIELD] : undefined
144
+ if (carried === undefined) {
145
+ // An UNCALLED field factory (`heading: textField` instead of `textField()`) evaluates to the factory
146
+ // function itself — catch that common slip loudly rather than silently dropping the field.
147
+ if (typeof value === 'function' && FACTORY_SET.has(value)) {
148
+ throw new Error(`${fileBase}: prop "${key}" is a field factory that was not called — write ${(value as { name?: string }).name ?? 'xField'}({ … })`)
149
+ }
150
+ // Otherwise it's a plain (display-only) Vue prop (e.g. `media: Object`, the server-resolved $media bag
151
+ // BlockRenderer passes) — not part of the block schema. A misspelled factory throws at eval time above.
152
+ continue
153
+ }
154
+ const def = carried as FieldDef & { default?: unknown }
155
+ if (typeof def.default === 'function') {
156
+ // The registry is inlined as JSON (renderBlockRegistry) — a function default would be silently dropped.
157
+ throw new Error(`${fileBase}: prop "${key}" has a function \`default\` — block field defaults must be JSON-serializable literals`)
158
+ }
159
+ fields[key] = carried as FieldDef
160
+ }
161
+
162
+ const out: ExtractedBlock = { name, fields }
163
+ if (meta.label !== undefined) out.label = meta.label
164
+ if (Array.isArray(meta.slots) && meta.slots.length) out.slots = meta.slots as string[]
165
+ if (typeof meta.icon === 'string') out.icon = meta.icon
166
+ if (typeof meta.image === 'string') out.image = meta.image
167
+ return out
168
+ }
169
+
170
+ /** The `#kestrel/blocks` virtual body: extract each block SFC and inline the definitions as JSON data
171
+ * (the block registry is plain, JSON-safe data — no imports needed beyond the field-type side effect the
172
+ * caller prepends). */
173
+ export function renderBlockRegistry(vuePaths: string[]): string {
174
+ const defs = vuePaths.map((p) => extractBlockDef(readFileSync(p, 'utf8'), basename(p)))
175
+ return `export default ${JSON.stringify(defs)}`
176
+ }
@@ -0,0 +1,44 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { addComponentsDir, addTypeTemplate, createResolver, defineNuxtModule } from '@nuxt/kit'
4
+ import { collectBlockSfcs, collectDefinitions, renderRegistry } from './scan'
5
+ import { renderBlockRegistry } from './extract-block'
6
+
7
+ export default defineNuxtModule({
8
+ meta: { name: 'kestrel-auto-discovery' },
9
+ setup(_options, nuxt) {
10
+ const roots = nuxt.options._layers.map((layer) => layer.cwd)
11
+ // The `#kestrel/*` registries are Nitro VIRTUAL modules (runtime only) — tsc can't see their shape, so
12
+ // server code importing them would be `Cannot find module`. Register the ambient declarations into BOTH
13
+ // the Nitro and Nuxt tsconfigs so the typecheck resolves them.
14
+ const { resolve } = createResolver(import.meta.url)
15
+ addTypeTemplate({ filename: 'types/kestrel-virtual.d.ts', src: resolve('./virtual.d.ts') }, { nitro: true, nuxt: true })
16
+
17
+ // Blocks are authored as single `.vue` files under each layer's `app/blocks/` — the SFC is BOTH the
18
+ // schema source (extracted at build into `#kestrel/blocks`) AND the display component. Register the dir
19
+ // so `app/blocks/Hero.vue` resolves as the global `BlocksHero` that the public BlockRenderer renders.
20
+ for (const root of roots) {
21
+ const dir = join(root, 'app/blocks')
22
+ if (existsSync(dir)) addComponentsDir({ path: dir, prefix: 'Blocks', global: true, pathPrefix: false })
23
+ }
24
+
25
+ nuxt.hook('nitro:config', (nitro) => {
26
+ nitro.virtual ||= {}
27
+ // Consumer field types register as a side effect on import, and the schema engine builds a table the
28
+ // moment a collection/block module loads. So the collections + blocks registries import the field-type
29
+ // registry FIRST — guaranteeing custom types are registered before any column / validator is built.
30
+ nitro.virtual['#kestrel/field-types'] = () =>
31
+ renderRegistry(collectDefinitions(roots, 'server/field-types'))
32
+ nitro.virtual['#kestrel/collections'] = () =>
33
+ `import '#kestrel/field-types'\n` + renderRegistry(collectDefinitions(roots, 'server/collections'))
34
+ // Extract each `app/blocks/*.vue` into a plain BlockDef inlined as JSON (no per-file imports needed).
35
+ nitro.virtual['#kestrel/blocks'] = () =>
36
+ `import '#kestrel/field-types'\n` + renderBlockRegistry(collectBlockSfcs(roots))
37
+ // Standalone (non-collection) tables: each layer's `server/schema-tables/*.ts` default-exports one
38
+ // drizzle table (media → folders, public → publish_deps/publish_status; consumers can add their own).
39
+ // The schema engine consumes this instead of core hardcoding upper-layer tables.
40
+ nitro.virtual['#kestrel/schema-tables'] = () =>
41
+ renderRegistry(collectDefinitions(roots, 'server/schema-tables'))
42
+ })
43
+ },
44
+ })