@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,127 @@
1
+ import { createError } from 'h3'
2
+ import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
3
+ import type { CollectionDef } from './defineCollection'
4
+ import type { BuiltCollection } from './collection-types'
5
+ import { slugify } from '../../app/utils/slugify'
6
+ import { routeOf, findRouteConflict } from './page-route'
7
+
8
+ /**
9
+ * The KEY of the text field Kestrel derives an auto-slug from: the `title` field if present, else the
10
+ * first text field (or `undefined` when the collection has no text field). Text fields keep their key as
11
+ * their column key (no `Id` suffix), so both the record value and a write target read directly by this key
12
+ * — which keeps this in `core` without importing the `fields` naming layer. Exported so the duplicate
13
+ * operation appends its `(copy)` suffix to the SAME field the slug derives from.
14
+ */
15
+ export function slugSourceKey(def: CollectionDef): string | undefined {
16
+ const fields = Object.entries(def.fields)
17
+ const pick = fields.find(([k, f]) => k === 'title' && f.type === 'text') ?? fields.find(([, f]) => f.type === 'text')
18
+ return pick?.[0]
19
+ }
20
+
21
+ /** The trimmed string value of that slug-source field on a record (empty when absent / non-string). */
22
+ export function slugSourceValue(def: CollectionDef, record: Record<string, unknown>): string {
23
+ const key = slugSourceKey(def)
24
+ if (!key) return ''
25
+ const v = record[key]
26
+ return typeof v === 'string' ? v.trim() : ''
27
+ }
28
+
29
+ // Canonicalise to exactly what the render-side resolver (resolvePublicRoute + the [...slug] segment
30
+ // filter) looks up: lowercase, drop empty segments (leading/trailing/duplicate slashes). Otherwise a slug
31
+ // typed with a trailing slash stores as `/blog/` but resolves as `/blog` → the page 404s and the sitemap
32
+ // advertises a dead URL.
33
+ const normalizePath = (p: string): string => `/${p.trim().toLowerCase().split('/').filter(Boolean).join('/')}`
34
+
35
+ export interface SlugCtx {
36
+ /** The id of the record being saved (null on create), excluded from the uniqueness scan. */
37
+ id: number | null
38
+ /** The existing row (on update) — supplies the title for auto-gen + the locale when the body omits it. */
39
+ existing?: Record<string, unknown> | null
40
+ /** Every collection to scan for a route collision (the caller passes the registry). */
41
+ collections: BuiltCollection[]
42
+ primary: string
43
+ /** Whether the primary locale is prefixed in routes too — folded into the resolved route. */
44
+ prefixPrimary: boolean
45
+ }
46
+
47
+ /**
48
+ * Resolve and validate a pageLike record's slug, mutating `values.path` in place (no-op for non-pageLike):
49
+ * - blank slug → auto-generate from the title field (slugify), de-duped (`-2`, `-3`, …) against the
50
+ * GLOBAL resolved-route set; reject 400 only when there's no title to derive one from.
51
+ * - explicit slug → normalize (single leading slash + lowercase) and reject 409 when its resolved route
52
+ * is already taken by another pageLike record.
53
+ * Uniqueness is on the resolved route (`localePath`), GLOBAL across all pageLike collections — one route =
54
+ * one output file — so `/de/x` ≠ `/en/x` (different locale) but a bare `/x` in `pages` and `posts` collide.
55
+ */
56
+ export function resolvePageSlug(db: BetterSQLite3Database, c: BuiltCollection, values: Record<string, unknown>, ctx: SlugCtx): void {
57
+ if (!c.def.pageLike) return
58
+ const raw = values.path
59
+ const explicit = typeof raw === 'string' && raw.trim() !== ''
60
+ const translatable = !!c.def.translatable
61
+ const locale = translatable
62
+ ? (typeof values.locale === 'string' && values.locale ? values.locale : (ctx.existing?.locale as string) ?? ctx.primary)
63
+ : ctx.primary
64
+ const exclude = { collection: c.name, id: ctx.id }
65
+ const routeFor = (p: string) => routeOf({ path: p, locale }, translatable, ctx.primary, ctx.prefixPrimary)!
66
+
67
+ if (explicit) {
68
+ const path = normalizePath(raw as string)
69
+ const conflict = findRouteConflict(db, routeFor(path), ctx.primary, ctx.collections, exclude, ctx.prefixPrimary)
70
+ if (conflict) throw createError({ statusCode: 409, statusMessage: 'Path already in use', data: { path, conflict } })
71
+ values.path = path
72
+ return
73
+ }
74
+
75
+ const slug = slugify(slugSourceValue(c.def, { ...(ctx.existing ?? {}), ...values }))
76
+ if (!slug) {
77
+ throw createError({
78
+ statusCode: 400,
79
+ statusMessage: 'Validation failed',
80
+ data: [{ path: ['path'], message: 'A slug is required (no title to derive one from).' }],
81
+ })
82
+ }
83
+ values.path = dedupeRoute(db, `/${slug}`, routeFor, ctx.primary, ctx.collections, exclude, ctx.prefixPrimary)
84
+ }
85
+
86
+ /**
87
+ * The ONE `-N` de-dup loop: return `base` when its resolved route is free, else `base-2`, `base-3`, … the
88
+ * first candidate not already claimed by another pageLike record. Extracted so BOTH the auto-generated-slug
89
+ * branch above AND the duplicate op (`dedupeSourcePath`) share a single route-uniqueness implementation.
90
+ */
91
+ function dedupeRoute(
92
+ db: BetterSQLite3Database,
93
+ base: string,
94
+ routeFor: (path: string) => string,
95
+ primary: string,
96
+ collections: BuiltCollection[],
97
+ exclude: { collection: string; id: number | null },
98
+ prefixPrimary: boolean,
99
+ ): string {
100
+ let candidate = base
101
+ for (let n = 2; n < 1000 && findRouteConflict(db, routeFor(candidate), primary, collections, exclude, prefixPrimary); n++) {
102
+ candidate = `${base}-${n}`
103
+ }
104
+ return candidate
105
+ }
106
+
107
+ /**
108
+ * The free route a pageLike COPY should claim when its collection has NO slug-source text field for the
109
+ * auto-gen branch to derive from: the SOURCE row's own explicit `path`, de-duped (`-2`, `-3`, …) to the
110
+ * first free global route via the same `dedupeRoute` loop. Seeding this (instead of leaving `path` blank →
111
+ * a 400, or re-using the source's colliding path → a 409) lets `create()`'s explicit branch accept it.
112
+ * Mirrors resolvePageSlug's own locale/route derivation so a copy resolves under the source's locale.
113
+ * Returns null when the source has no usable path (the caller lets create()'s own 400 surface).
114
+ */
115
+ export function dedupeSourcePath(
116
+ db: BetterSQLite3Database,
117
+ c: BuiltCollection,
118
+ sourceRow: Record<string, unknown>,
119
+ ctx: Pick<SlugCtx, 'collections' | 'primary' | 'prefixPrimary'>,
120
+ ): string | null {
121
+ const raw = sourceRow.path
122
+ if (typeof raw !== 'string' || raw.trim() === '') return null
123
+ const translatable = !!c.def.translatable
124
+ const locale = translatable && typeof sourceRow.locale === 'string' && sourceRow.locale ? sourceRow.locale : ctx.primary
125
+ const routeFor = (p: string) => routeOf({ path: p, locale }, translatable, ctx.primary, ctx.prefixPrimary)!
126
+ return dedupeRoute(db, normalizePath(raw), routeFor, ctx.primary, ctx.collections, { collection: c.name, id: null }, ctx.prefixPrimary)
127
+ }
@@ -0,0 +1,82 @@
1
+ import { and, count, eq, getTableColumns, inArray, sql } from 'drizzle-orm'
2
+ import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
3
+ import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
4
+ import { MAX_BULK_IDS } from '../../app/utils/list-limits'
5
+ import type { BuiltCollection } from './collection-types'
6
+ import { resolveLocale } from './locale'
7
+ import { escapeLike } from './sql'
8
+
9
+ type DB = BetterSQLite3Database
10
+
11
+ export interface PickerQuery {
12
+ search?: string
13
+ ids?: number[]
14
+ label?: string
15
+ page?: number
16
+ perPage?: number
17
+ locale?: string
18
+ /** Published-scope read (anonymous / SSG): hide draft rows + labels, like list()/getOne(). */
19
+ publishedOnly?: boolean
20
+ }
21
+
22
+ export interface PickerResult {
23
+ data: { id: number; label: string }[]
24
+ total: number
25
+ page: number
26
+ perPage: number
27
+ }
28
+
29
+ /** The field key used as the human label: an explicit text field, else the first text field, else 'id'. */
30
+ function resolveLabelKey(c: BuiltCollection, requested?: string): string {
31
+ const fields = c.def.fields
32
+ if (requested && fields[requested]?.type === 'text') return requested
33
+ for (const [key, field] of Object.entries(fields)) if (field.type === 'text') return key
34
+ return 'id'
35
+ }
36
+
37
+ export function pickerOptions(db: DB, c: BuiltCollection, q: PickerQuery): PickerResult {
38
+ const cols = getTableColumns(c.table) as Record<string, never>
39
+ const t = c.table as AnySQLiteTable
40
+ const labelKey = resolveLabelKey(c, q.label)
41
+ const labelCol = cols[labelKey] ?? cols.id
42
+
43
+ // Validate the locale (consistent 400) up front; only apply it as a filter in search/list mode.
44
+ const localeCond = c.def.translatable ? eq(cols.locale, resolveLocale(q.locale)) : undefined
45
+
46
+ // A blank label must fall back too (not just nullish): the editor's combobox mirrors this label into
47
+ // its input text, and an empty label would leave a validly selected record looking unselected.
48
+ const toOption = (r: Record<string, unknown>) => {
49
+ const label = String(r[labelKey] ?? '').trim()
50
+ return { id: Number(r.id), label: label || `#${r.id}` }
51
+ }
52
+
53
+ // Published-scope read hides drafts (like list()/getOne()), so a picker on a public surface never leaks
54
+ // draft titles. Only applies when the collection has a status column.
55
+ const statusCond = q.publishedOnly && Object.hasOwn(cols, 'status') ? eq(cols.status, 'published') : undefined
56
+
57
+ // ids mode: resolve the picker's current value(s) to labels. A present-but-empty ids list
58
+ // (e.g. all non-numeric) is an explicit empty result, not a full list. The id is a globally
59
+ // unique PK (each locale is its own row), so the locale predicate is intentionally NOT applied
60
+ // here — it could only drop an already-valid selection across locales.
61
+ if (q.ids) {
62
+ if (!q.ids.length) return { data: [], total: 0, page: 1, perPage: 0 }
63
+ const rows = db.select().from(t).where(and(inArray(cols.id, q.ids.slice(0, MAX_BULK_IDS)), statusCond)).limit(MAX_BULK_IDS).all() as Record<string, unknown>[]
64
+ return { data: rows.map(toOption), total: rows.length, page: 1, perPage: rows.length }
65
+ }
66
+
67
+ // search / list mode.
68
+ const conds = []
69
+ if (localeCond) conds.push(localeCond)
70
+ if (statusCond) conds.push(statusCond)
71
+ if (q.search && labelKey !== 'id') conds.push(sql`${labelCol} like ${`%${escapeLike(q.search)}%`} escape '\\'`)
72
+ const where = conds.length ? and(...conds) : undefined
73
+
74
+ const num = (v: number | undefined, fallback: number) => (Number.isFinite(v) ? (v as number) : fallback)
75
+ const page = Math.max(1, Math.floor(num(q.page, 1)))
76
+ const perPage = Math.min(100, Math.max(1, Math.floor(num(q.perPage, 25))))
77
+
78
+ const rows = db.select().from(t).where(where)
79
+ .orderBy(labelCol).limit(perPage).offset((page - 1) * perPage).all() as Record<string, unknown>[]
80
+ const totalRow = db.select({ value: count() }).from(t).where(where).get() as { value: number } | undefined
81
+ return { data: rows.map(toOption), total: Number(totalRow?.value ?? 0), page, perPage }
82
+ }
@@ -0,0 +1,48 @@
1
+ import type { CollectionDef, FieldDef } from './defineCollection'
2
+
3
+ export interface PopulateCtx { depth: number; locale: string; def: CollectionDef }
4
+ export type Populator = (row: Record<string, unknown>, ctx: PopulateCtx) => Record<string, unknown>
5
+
6
+ // A composed list: each registered populator runs in turn over the row (e.g. media attaches `$media`,
7
+ // links resolve internal hrefs). They are order-independent — each clones the slice it touches.
8
+ const registered: Populator[] = []
9
+
10
+ export function registerPopulator(fn: Populator): void { registered.push(fn) }
11
+ export function clearPopulator(): void { registered.length = 0 }
12
+
13
+ export function populateRow(row: Record<string, unknown>, ctx: PopulateCtx): Record<string, unknown> {
14
+ if (ctx.depth <= 0) return row
15
+ return registered.reduce((acc, fn) => fn(acc, ctx), row)
16
+ }
17
+
18
+ /**
19
+ * Key-mode captures the storage asymmetry a field populator must read around: a SINGLE relation/media is
20
+ * keyed `${name}Id` in TOP-LEVEL row COLUMNS but BARE `name` in block props / repeater entries (PROPS).
21
+ * Everything else (links, richtext, many-relation, multiple-media) is bare-keyed in both. Mirrors the
22
+ * `KeyMode` in `extract-refs.ts`.
23
+ */
24
+ export type KeyMode = 'columns' | 'props'
25
+
26
+ /**
27
+ * A per-field-type read populator (the Pruvious-style seam). Receives the containing — already cloned —
28
+ * value bag, the field's key, its def, the populate ctx, and the key-mode; MUTATES `bag` in place (attach
29
+ * `$media` / a `$<name>` relation sibling, resolve a link value, …). Runs only while `ctx.depth > 0`.
30
+ * SYNCHRONOUS — SQLite reads are synchronous, so the whole populate path stays sync.
31
+ */
32
+ export type FieldPopulator = (
33
+ bag: Record<string, unknown>,
34
+ key: string,
35
+ field: FieldDef,
36
+ ctx: PopulateCtx,
37
+ keyMode: KeyMode,
38
+ ) => void
39
+
40
+ // One populator per field-type name (last-wins, like the field-type registry). The shared field-tree
41
+ // walker (`layers/fields`) dispatches into this; the owning layer registers its populator (media →
42
+ // `media`, public → `link`/`richtext`, collections → `relation`) so no layer bakes another's read logic
43
+ // into a lower-layer field-type descriptor.
44
+ const fieldPopulators = new Map<string, FieldPopulator>()
45
+
46
+ export function registerFieldPopulator(type: string, fn: FieldPopulator): void { fieldPopulators.set(type, fn) }
47
+ export function getFieldPopulator(type: string): FieldPopulator | undefined { return fieldPopulators.get(type) }
48
+ export function clearFieldPopulators(): void { fieldPopulators.clear() }
@@ -0,0 +1,23 @@
1
+ /** Memoize a resolver by a string key. The cache lives for the returned function's lifetime, so only
2
+ * use it where the underlying data is immutable for that lifetime. Caches misses (null/undefined) too. */
3
+ export function memoize<A extends unknown[], R>(fn: (...args: A) => R, key: (...args: A) => string): (...args: A) => R {
4
+ const cache = new Map<string, R>()
5
+ return (...args: A): R => {
6
+ const k = key(...args)
7
+ if (cache.has(k)) return cache.get(k) as R
8
+ const value = fn(...args)
9
+ cache.set(k, value)
10
+ return value
11
+ }
12
+ }
13
+
14
+ /**
15
+ * Like `memoize`, but active ONLY during a `nuxt generate` prerender run (`import.meta.prerender`), where
16
+ * the DB is read-only for the whole build and a referenced media asset / link target is identical across
17
+ * every page that embeds it — so a shared header image reused on 100 pages is resolved once, not 100×.
18
+ * Outside prerender (dev SSR, tests) it returns the resolver untouched, so a live edit is never served
19
+ * stale and there is no cross-request cache to invalidate.
20
+ */
21
+ export function memoDuringPrerender<A extends unknown[], R>(fn: (...args: A) => R, key: (...args: A) => string): (...args: A) => R {
22
+ return (import.meta as { prerender?: boolean }).prerender ? memoize(fn, key) : fn
23
+ }
@@ -0,0 +1,37 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+
3
+ // Records which collections/records a render reads, so the publisher can map a content write back to
4
+ // exactly the routes that embed it (precise incremental invalidation). The CRUD read seams call
5
+ // `captureRead`; the publisher wraps each route render in `withReadCapture`. Outside a capture run
6
+ // `captureRead` is a cheap no-op, so normal request/CRUD traffic is unaffected.
7
+ const storage = new AsyncLocalStorage<{ tags: Set<string> }>()
8
+
9
+ /** Tag that the current render read collection `coll` (a `list`/singleton) or record `coll:id` (a `getOne`). */
10
+ export function captureRead(coll: string, id?: number | null): void {
11
+ const store = storage.getStore()
12
+ if (!store) return
13
+ store.tags.add(typeof id === 'number' ? `${coll}:${id}` : coll)
14
+ }
15
+
16
+ /** Run `fn` while collecting the data tags its reads touch; returns the result + the captured tags.
17
+ * Async-safe: AsyncLocalStorage propagates through the awaits of an SSR render. */
18
+ export async function withReadCapture<T>(fn: () => Promise<T> | T): Promise<{ result: T; tags: string[] }> {
19
+ const store = { tags: new Set<string>() }
20
+ const result = await storage.run(store, fn)
21
+ return { result, tags: [...store.tags] }
22
+ }
23
+
24
+ /** Sync variant for the synchronous populate path: collects `fn`'s tags into a FRESH set (the enclosing
25
+ * capture does NOT see them — the caller re-emits, e.g. the resolve-scope memo replaying tags on hits). */
26
+ export function withReadCaptureSync<T>(fn: () => T): { result: T; tags: string[] } {
27
+ const store = { tags: new Set<string>() }
28
+ const result = storage.run(store, fn)
29
+ return { result, tags: [...store.tags] }
30
+ }
31
+
32
+ /** Re-emit previously captured tags into the CURRENT capture context (no-op outside one). */
33
+ export function replayReadTags(tags: readonly string[]): void {
34
+ const store = storage.getStore()
35
+ if (!store) return
36
+ for (const t of tags) store.tags.add(t)
37
+ }
@@ -0,0 +1,200 @@
1
+ import { and, eq, getTableColumns, inArray } from 'drizzle-orm'
2
+ import type { AnySQLiteColumn } from 'drizzle-orm/sqlite-core'
3
+ import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
4
+ import { recordRefs } from '../database/record-refs'
5
+ import { extractRecordRefs, extractLocatedRecordRefs, type FieldRef, type LocatedRef } from '../../../fields/server/utils/extract-refs'
6
+ import type { CollectionDef, FieldDef } from './defineCollection'
7
+ import { fieldIs } from './defineCollection'
8
+ import type { BuiltCollection } from './collection-types'
9
+ import { getCollection } from './registry'
10
+ import type { WriteEvent } from './write-events'
11
+
12
+ type DB = BetterSQLite3Database
13
+
14
+ /**
15
+ * Maintain the `record_refs` edges for one content write (the write-events listener calls this).
16
+ * Replace-on-write: a create / update / singleton PUT clears the source's existing edges and inserts the
17
+ * freshly extracted set; a delete just clears them. Touches only the written source row's edges, so other
18
+ * records' edges are untouched. Best-effort — the bus isolates a throwing listener, so a failure to keep
19
+ * the index current never breaks the content write itself. The `before`/`after` rows are read, never mutated.
20
+ */
21
+ export function maintainRecordRefs(db: DB, event: WriteEvent): void {
22
+ const { def, before, after } = event
23
+ const source = after ?? before
24
+ const sourceId = source?.id
25
+ if (typeof sourceId !== 'number') return
26
+
27
+ // Clear the source's edges first — both the delete path and the create/update replace path need this.
28
+ db.delete(recordRefs).where(and(eq(recordRefs.sourceColl, def.name), eq(recordRefs.sourceId, sourceId))).run()
29
+ if (!after) return // delete: edges removed, nothing to re-insert.
30
+
31
+ const refs = extractRecordRefs(def, after)
32
+ if (!refs.length) return
33
+ db.insert(recordRefs)
34
+ .values(refs.map((r) => ({ sourceColl: def.name, sourceId, targetColl: r.collection, targetId: r.id })))
35
+ .run()
36
+ }
37
+
38
+ /** Why a reference is stale: its target row is gone, or present but not published. */
39
+ export type DeadReason = 'missing' | 'unpublished'
40
+
41
+ /** A reference plus its dead-ness reason, with field/block location for the editor's per-field warnings. */
42
+ export interface LocatedDeadRef extends LocatedRef {
43
+ reason: DeadReason
44
+ }
45
+
46
+ /** Whether a collection has any field/block that can hold a reference — used to skip the derivation (and
47
+ * to decide whether to offer the dead-reference column) for collections that can never have stale refs. */
48
+ export function collectionMayReference(def: CollectionDef): boolean {
49
+ if (def.blocks?.enabled) return true
50
+ if (def.seo) return true // the `seo` system column carries a media ref (the social image), tracked like any
51
+ return Object.values(def.fields).some(fieldMayReference)
52
+ }
53
+ function fieldMayReference(field: FieldDef): boolean {
54
+ // `fieldIs` not `switch`: the open consumer arm makes `type` a non-discriminant (no switch narrowing).
55
+ if (fieldIs(field, 'repeater')) return Object.values(field.options.fields).some(fieldMayReference)
56
+ return field.type === 'relation' || field.type === 'media' || field.type === 'link' || field.type === 'richtext'
57
+ }
58
+
59
+ /**
60
+ * Classify a batch of reference targets as dead, in ONE query per target collection (no N+1). A target is
61
+ * dead when its row is GONE, or — if its collection's table has a `status` column — present but not
62
+ * `'published'` (so a consumer-added status on any collection is honoured automatically). An
63
+ * unknown/unregistered target collection counts as missing. A not-yet-migrated target table (e.g. a bare
64
+ * prerender DB) is treated as indeterminate (not dead), mirroring the link resolver's tolerance. Returns a
65
+ * map keyed `"collection:id"` → reason, holding ONLY the dead targets.
66
+ */
67
+ export function deadTargets(db: DB, targets: FieldRef[]): Map<string, DeadReason> {
68
+ const dead = new Map<string, DeadReason>()
69
+ const byCollection = new Map<string, Set<number>>()
70
+ for (const t of targets) {
71
+ let ids = byCollection.get(t.collection)
72
+ if (!ids) byCollection.set(t.collection, (ids = new Set()))
73
+ ids.add(t.id)
74
+ }
75
+ for (const [collection, ids] of byCollection) {
76
+ const c = getCollection(collection)
77
+ if (!c) {
78
+ for (const id of ids) dead.set(`${collection}:${id}`, 'missing')
79
+ continue
80
+ }
81
+ const cols = getTableColumns(c.table) as Record<string, AnySQLiteColumn>
82
+ const hasStatus = Object.hasOwn(cols, 'status')
83
+ let rows: { id: number; status?: unknown }[]
84
+ try {
85
+ rows = db.select({ id: cols.id, ...(hasStatus ? { status: cols.status } : {}) })
86
+ .from(c.table as never).where(inArray(cols.id, [...ids])).all() as { id: number; status?: unknown }[]
87
+ } catch {
88
+ continue // table not migrated yet — don't false-flag.
89
+ }
90
+ const byId = new Map(rows.map((r) => [r.id, r]))
91
+ for (const id of ids) {
92
+ const row = byId.get(id)
93
+ if (!row) dead.set(`${collection}:${id}`, 'missing')
94
+ else if (hasStatus && row.status !== 'published') dead.set(`${collection}:${id}`, 'unpublished')
95
+ }
96
+ }
97
+ return dead
98
+ }
99
+
100
+ /** Every stale reference a record currently holds, with its field/block location and reason — derived
101
+ * freshly from the record's value (so it auto-clears when the link is removed or the target restored). */
102
+ export function deriveLocatedDeadRefs(db: DB, def: CollectionDef, row: Record<string, unknown>): LocatedDeadRef[] {
103
+ const located = extractLocatedRecordRefs(def, row)
104
+ if (!located.length) return []
105
+ const dead = deadTargets(db, located)
106
+ if (!dead.size) return []
107
+ const out: LocatedDeadRef[] = []
108
+ for (const ref of located) {
109
+ const reason = dead.get(`${ref.collection}:${ref.id}`)
110
+ if (reason) out.push({ ...ref, reason })
111
+ }
112
+ return out
113
+ }
114
+
115
+ /** Load a record by id and derive its stale references (for the editor's `/dead-refs` endpoint). */
116
+ export function recordDeadRefs(db: DB, c: BuiltCollection, id: number): LocatedDeadRef[] {
117
+ const cols = getTableColumns(c.table) as Record<string, never>
118
+ let row: Record<string, unknown> | undefined
119
+ try {
120
+ row = db.select().from(c.table as never).where(eq(cols.id, id)).get() as Record<string, unknown> | undefined
121
+ } catch {
122
+ return []
123
+ }
124
+ return row ? deriveLocatedDeadRefs(db, c.def, row) : []
125
+ }
126
+
127
+ /** The distinct records that reference a given target — the reverse "what links here" lookup (powers the
128
+ * pre-delete/unpublish warning). Read-only; tolerant of a not-yet-migrated index — returns `null` rather
129
+ * than `[]` on failure, so a caller can tell "checked, no referrers" from "could not check" and must not
130
+ * treat the latter as a green light to delete unwarned. */
131
+ export function findReferrers(db: DB, targetColl: string, targetId: number): FieldRef[] | null {
132
+ try {
133
+ return db.selectDistinct({ sourceColl: recordRefs.sourceColl, sourceId: recordRefs.sourceId })
134
+ .from(recordRefs)
135
+ .where(and(eq(recordRefs.targetColl, targetColl), eq(recordRefs.targetId, targetId)))
136
+ .all()
137
+ .map((r) => ({ collection: r.sourceColl, id: r.sourceId }))
138
+ } catch {
139
+ return null
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Referrer COUNTS for a whole selection of targets in ONE query — the batched reverse lookup powering the
145
+ * bulk-delete "what links here" warning. Returns `targetId -> number of distinct referrers`; a target with
146
+ * no referrers is simply omitted. A referrer that is ITSELF in the selection is excluded, so deleting a
147
+ * linked pair together raises no false warning. Read-only; tolerant of a not-yet-migrated index — returns
148
+ * `null` (not `{}`) on failure, mirroring `findReferrers`'s indeterminate-vs-empty distinction.
149
+ */
150
+ export function findReferrersForMany(db: DB, targetColl: string, ids: number[]): Record<number, number> | null {
151
+ if (!ids.length) return {}
152
+ try {
153
+ const rows = db.selectDistinct({ sourceColl: recordRefs.sourceColl, sourceId: recordRefs.sourceId, targetId: recordRefs.targetId })
154
+ .from(recordRefs)
155
+ .where(and(eq(recordRefs.targetColl, targetColl), inArray(recordRefs.targetId, ids)))
156
+ .all()
157
+ const selected = new Set(ids)
158
+ const counts: Record<number, number> = {}
159
+ for (const r of rows) {
160
+ if (r.sourceColl === targetColl && selected.has(r.sourceId)) continue // in-selection referrer → not counted
161
+ counts[r.targetId] = (counts[r.targetId] ?? 0) + 1
162
+ }
163
+ return counts
164
+ } catch {
165
+ return null
166
+ }
167
+ }
168
+
169
+ /** A referrer → dead-target edge, for the global broken-references report. */
170
+ export interface BrokenRef {
171
+ source: FieldRef
172
+ target: FieldRef
173
+ reason: DeadReason
174
+ }
175
+
176
+ /** Every edge in the index whose target is currently dead — the global broken-references report. Scans the
177
+ * whole index (an admin maintenance view, not a hot path) and classifies all targets in one batch. Returns
178
+ * `null` (not `[]`) when the index itself could not be read, mirroring `findReferrers`. */
179
+ export function findBrokenRefs(db: DB): BrokenRef[] | null {
180
+ let edges: { sourceColl: string; sourceId: number; targetColl: string; targetId: number }[]
181
+ try {
182
+ edges = db.select({
183
+ sourceColl: recordRefs.sourceColl,
184
+ sourceId: recordRefs.sourceId,
185
+ targetColl: recordRefs.targetColl,
186
+ targetId: recordRefs.targetId,
187
+ }).from(recordRefs).all()
188
+ } catch {
189
+ return null
190
+ }
191
+ if (!edges.length) return []
192
+ const dead = deadTargets(db, edges.map((e) => ({ collection: e.targetColl, id: e.targetId })))
193
+ if (!dead.size) return []
194
+ const out: BrokenRef[] = []
195
+ for (const e of edges) {
196
+ const reason = dead.get(`${e.targetColl}:${e.targetId}`)
197
+ if (reason) out.push({ source: { collection: e.sourceColl, id: e.sourceId }, target: { collection: e.targetColl, id: e.targetId }, reason })
198
+ }
199
+ return out
200
+ }
@@ -0,0 +1,24 @@
1
+ import type { BuiltCollection } from './collection-types'
2
+
3
+ const registry = new Map<string, BuiltCollection>()
4
+
5
+ export function registerCollection(collection: BuiltCollection): void {
6
+ // A consumer collection sharing a name with a built-in (or another) silently overwrites it in the Map.
7
+ // Warn so an accidental clash is visible, while still allowing a deliberate override (last one wins).
8
+ if (registry.has(collection.name)) {
9
+ console.warn(`[kestrel] collection "${collection.name}" is registered more than once — the later definition wins`)
10
+ }
11
+ registry.set(collection.name, collection)
12
+ }
13
+
14
+ export function getCollection(name: string): BuiltCollection | undefined {
15
+ return registry.get(name)
16
+ }
17
+
18
+ export function allCollections(): BuiltCollection[] {
19
+ return [...registry.values()]
20
+ }
21
+
22
+ export function clearRegistry(): void {
23
+ registry.clear()
24
+ }
@@ -0,0 +1,79 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import { withReadCaptureSync, replayReadTags } from './read-capture'
3
+
4
+ /**
5
+ * Per-request/per-run resolver memoization + fan-out budget. `memoDuringPrerender` only dedupes during a
6
+ * `nuxt generate` build; a LIVE request re-resolved every repeated reference, so an anonymous
7
+ * `?depth=10&perPage=500` read could multiply into a huge number of synchronous DB reads (each blocking
8
+ * the single event-loop thread). A scope gives the populate resolvers (relation/media/link):
9
+ * - a shared cache: each distinct target is resolved ONCE per scope (repeated refs are free), and
10
+ * - a budget on DISTINCT resolves: past it, further NEW targets resolve to null — the same degrade
11
+ * path as a stale reference — bounding the worst-case work of one request.
12
+ * Nested scopes reuse the outermost one (a relation's recursive getOne inherits the request's scope; the
13
+ * runtime publisher wraps a whole run in an UNBUDGETED scope, giving it the per-run dedup a generate
14
+ * build gets from `memoDuringPrerender`). Outside any scope a resolver runs raw — no cross-request cache.
15
+ */
16
+ interface CacheEntry {
17
+ value: unknown
18
+ /** The read-tags the resolver captured while producing `value` — replayed on every hit, so a page
19
+ * served from the memo still records the SAME publish deps as the page that resolved it. */
20
+ tags: string[]
21
+ }
22
+
23
+ interface ResolveScope {
24
+ cache: Map<string, CacheEntry>
25
+ max: number
26
+ used: number
27
+ tripped: boolean
28
+ label: string
29
+ }
30
+
31
+ const storage = new AsyncLocalStorage<ResolveScope>()
32
+
33
+ /** Per-request distinct-resolve ceiling. Sized so a LEGITIMATE full page never trips it — the dominant
34
+ * protection is the per-scope dedup (distinct resolves are bounded by the referenced record set, not the
35
+ * breadth^depth fan-out), so this is only a backstop against a pathologically dense reference graph.
36
+ * `resolveBudgetFor(perPage)` scales it with the page size so a `perPage=500, depth≥1` read of richly-
37
+ * referenced rows still populates every row. */
38
+ export const REQUEST_RESOLVE_BUDGET = 20_000
39
+ export function resolveBudgetFor(perPage?: number): number {
40
+ const scaled = (perPage && perPage > 0 ? perPage : 1) * 200 // ~200 distinct refs/row headroom
41
+ return Math.max(REQUEST_RESOLVE_BUDGET, scaled)
42
+ }
43
+
44
+ export function withResolveScope<T>(fn: () => T, max = Number.POSITIVE_INFINITY, label = 'read'): T {
45
+ if (storage.getStore()) return fn() // nested — the outermost scope owns cache + budget
46
+ return storage.run({ cache: new Map(), max, used: 0, tripped: false, label }, fn)
47
+ }
48
+
49
+ /** Wrap a resolver: inside a scope it memoizes by `key` and enforces the scope's budget (over-budget new
50
+ * targets → null); outside a scope it calls through untouched. */
51
+ export function memoResolver<A extends unknown[], R>(
52
+ fn: (...args: A) => R,
53
+ key: (...args: A) => string,
54
+ ): (...args: A) => R | null {
55
+ return (...args: A): R | null => {
56
+ const scope = storage.getStore()
57
+ if (!scope) return fn(...args)
58
+ const k = key(...args)
59
+ const hit = scope.cache.get(k)
60
+ if (hit) {
61
+ replayReadTags(hit.tags)
62
+ return hit.value as R
63
+ }
64
+ if (scope.used >= scope.max) {
65
+ // Over budget: skip (like a stale ref) but make it OBSERVABLE — a legitimate large read silently
66
+ // dropping `$media`/`$relation` would otherwise be invisible. Warn ONCE per scope.
67
+ if (!scope.tripped) {
68
+ scope.tripped = true
69
+ console.warn(`[kestrel] resolve budget (${scope.max}) exceeded during "${scope.label}" — some references were left unpopulated. Reduce perPage/depth or raise the budget.`)
70
+ }
71
+ return null
72
+ }
73
+ scope.used++
74
+ const { result, tags } = withReadCaptureSync(() => fn(...args))
75
+ replayReadTags(tags) // the miss's own render must record them too (the sync capture is isolating)
76
+ scope.cache.set(k, { value: result, tags })
77
+ return result
78
+ }
79
+ }
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod'
2
+ import type { FieldDef } from './defineCollection'
3
+
4
+ export interface SeoMeta {
5
+ title?: string
6
+ description?: string
7
+ noindex?: boolean
8
+ /** Media id of the social-share image (og:image / twitter card); resolved under `seo.$media.image`. */
9
+ image?: number | null
10
+ }
11
+
12
+ export const seoSchema = z.object({
13
+ title: z.string().optional(),
14
+ description: z.string().optional(),
15
+ noindex: z.boolean().optional(),
16
+ image: z.number().int().positive().nullish(),
17
+ })
18
+
19
+ // The synthetic field set the row populator walks over the `seo` system column (PROPS key-mode), so the
20
+ // social image resolves through the registered media populator — same `$media` bag, same read-capture
21
+ // (a media change re-publishes every page whose og:image embeds it) as any real media field.
22
+ export const seoPopulateFields: Record<string, FieldDef> = {
23
+ image: { type: 'media', options: { accept: 'image' } } as FieldDef,
24
+ }