@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,12 @@
1
+ import { JSDOM } from 'jsdom'
2
+ import createDOMPurify from 'dompurify'
3
+
4
+ const purify = createDOMPurify(new JSDOM('').window as unknown as Parameters<typeof createDOMPurify>[0])
5
+
6
+ export function sanitizeSvg(svg: string): string {
7
+ return purify.sanitize(svg, {
8
+ USE_PROFILES: { svg: true, svgFilters: true },
9
+ FORBID_TAGS: ['script', 'foreignObject'],
10
+ FORBID_ATTR: ['onload', 'onerror', 'onclick'],
11
+ })
12
+ }
@@ -0,0 +1,68 @@
1
+ import { fileTypeFromBuffer } from 'file-type'
2
+
3
+ /** Default upload allow-list — sniffable, safe formats. NO executable/active types (html/js/swf →
4
+ * XSS from the media origin) and no plain-text (txt/csv: no magic bytes → unsniffable). Override
5
+ * the whole set via KESTREL_MEDIA_ALLOWED_MIME (see resolveAllowedMimes). Exact file-type@22 MIMEs. */
6
+ export const DEFAULT_ALLOWED_MIME = new Set([
7
+ 'image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/avif', 'image/svg+xml',
8
+ 'application/pdf',
9
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
10
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
11
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
12
+ 'application/vnd.oasis.opendocument.text',
13
+ 'application/vnd.oasis.opendocument.spreadsheet',
14
+ 'application/vnd.oasis.opendocument.presentation',
15
+ 'video/mp4', 'video/webm', 'video/quicktime',
16
+ // audio (file-type emits 'audio/x-m4a' for typical m4a, 'audio/ogg; codecs=opus' for opus)
17
+ 'audio/mpeg', 'audio/ogg', 'audio/ogg; codecs=opus', 'audio/wav', 'audio/flac', 'audio/x-m4a', 'audio/mp4',
18
+ ])
19
+
20
+ /** Resolve the effective allow-list: a non-empty comma list overrides the default entirely. */
21
+ export function resolveAllowedMimes(configured: string | undefined): Set<string> {
22
+ const list = (configured ?? '').split(',').map((s) => s.trim()).filter(Boolean)
23
+ return list.length ? new Set(list) : DEFAULT_ALLOWED_MIME
24
+ }
25
+
26
+ const EXT_BY_MIME: Record<string, string> = {
27
+ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif',
28
+ 'image/avif': 'avif', 'application/pdf': 'pdf', 'image/svg+xml': 'svg',
29
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
30
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
31
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
32
+ 'application/vnd.oasis.opendocument.text': 'odt',
33
+ 'application/vnd.oasis.opendocument.spreadsheet': 'ods',
34
+ 'application/vnd.oasis.opendocument.presentation': 'odp',
35
+ 'video/mp4': 'mp4', 'video/webm': 'webm', 'video/quicktime': 'mov',
36
+ 'audio/mpeg': 'mp3', 'audio/ogg': 'ogg', 'audio/ogg; codecs=opus': 'opus',
37
+ 'audio/wav': 'wav', 'audio/flac': 'flac', 'audio/x-m4a': 'm4a', 'audio/mp4': 'm4a',
38
+ }
39
+
40
+ function looksLikeSvg(bytes: Buffer): boolean {
41
+ let head = bytes.subarray(0, 1024).toString('utf8').trimStart()
42
+ // Skip any leading XML declaration, DOCTYPE, and comments (some exporters emit these before <svg>),
43
+ // then require the root element to be <svg>.
44
+ for (let prev = ''; head !== prev; ) {
45
+ prev = head
46
+ head = head
47
+ .replace(/^<\?xml\b[^>]*\?>\s*/i, '')
48
+ .replace(/^<!DOCTYPE\b[^>]*>\s*/i, '')
49
+ .replace(/^<!--[\s\S]*?-->\s*/, '')
50
+ .trimStart()
51
+ }
52
+ return /^<svg[\s>]/i.test(head)
53
+ }
54
+
55
+ /** Detect the real MIME from the bytes (magic-byte sniff, then an SVG text heuristic). Pure
56
+ * detection — authorization against the allow-list is the caller's job. */
57
+ export async function sniffMime(bytes: Buffer): Promise<string | null> {
58
+ const ft = await fileTypeFromBuffer(bytes)
59
+ // An SVG that opens with an XML declaration / DOCTYPE is reported by the magic-byte sniff as generic XML;
60
+ // promote it to image/svg+xml when the root element is actually <svg>. A non-XML detection is trusted as-is.
61
+ if (ft && ft.mime !== 'application/xml' && ft.mime !== 'text/xml') return ft.mime
62
+ if (looksLikeSvg(bytes)) return 'image/svg+xml'
63
+ return ft ? ft.mime : null
64
+ }
65
+
66
+ export function extForMime(mime: string): string {
67
+ return EXT_BY_MIME[mime] ?? 'bin'
68
+ }
@@ -0,0 +1,139 @@
1
+ import { eq, getTableColumns } from 'drizzle-orm'
2
+ import { createError } from 'h3'
3
+ import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
4
+ import { buildKey } from './naming'
5
+ import { derivativeKey, type DerivativeManifest } from './record'
6
+ import { media } from '../collections/media'
7
+ import { emitMediaWrite } from './media-write'
8
+ import type { StorageDriver } from '../../../core/server/utils/storage'
9
+ import { withLock, mediaLockKey } from '../../../core/server/utils/key-lock'
10
+
11
+ export interface RelocatePlan {
12
+ storageKey: string
13
+ derivatives: DerivativeManifest
14
+ copies: { src: string; dst: string }[]
15
+ }
16
+
17
+ /** Pure: compute the new storageKey, the rewritten derivative manifest, and the list of object
18
+ * copies (original + each derivative) for moving/renaming a media row to `folder`/`filename`. */
19
+ export function planObjectRelocation(
20
+ row: { storageKey: string; derivatives: DerivativeManifest | null },
21
+ folder: string,
22
+ filename: string,
23
+ ): RelocatePlan {
24
+ const storageKey = buildKey(folder, filename)
25
+ const derivatives: DerivativeManifest = {}
26
+ const copies: { src: string; dst: string }[] = [{ src: row.storageKey, dst: storageKey }]
27
+ for (const [manifestKey, entry] of Object.entries(row.derivatives ?? {})) {
28
+ // manifestKey is `<name>.<format>`; variant names are `[A-Za-z0-9_-]` (no dots), so the LAST dot
29
+ // splits name from format cleanly — rebuild the destination key from the name, not the (now-absent
30
+ // from the key scheme) width.
31
+ const dot = manifestKey.lastIndexOf('.')
32
+ const name = dot > 0 ? manifestKey.slice(0, dot) : manifestKey
33
+ const format = dot > 0 ? manifestKey.slice(dot + 1) : (entry.mime.split('/').pop() ?? 'bin')
34
+ const dst = derivativeKey(storageKey, name, format)
35
+ derivatives[manifestKey] = { ...entry, key: dst }
36
+ copies.push({ src: entry.key, dst })
37
+ }
38
+ return { storageKey, derivatives, copies }
39
+ }
40
+
41
+ interface MediaRowLite { id: number; storageKey: string; folder: string | null; filename: string; derivatives: DerivativeManifest | null }
42
+
43
+ function mediaCols() { return getTableColumns(media) as Record<string, never> }
44
+
45
+ /** Reject if any destination object already exists on disk (e.g. another media's derivative, which
46
+ * carries no `storageKey` row, so the DB clash check misses it). Keys the plan itself vacates are
47
+ * excluded so a plain move never collides with its own source. */
48
+ async function assertDestKeysFree(driver: StorageDriver, plan: RelocatePlan): Promise<void> {
49
+ const sources = new Set(plan.copies.map((c) => c.src))
50
+ for (const c of plan.copies) {
51
+ if (sources.has(c.dst)) continue
52
+ if (await driver.exists?.(c.dst)) {
53
+ throw createError({ statusCode: 409, statusMessage: `A file already exists at ${c.dst}` })
54
+ }
55
+ }
56
+ }
57
+
58
+ /** Move and/or rename a media object (+ its derivatives). Pre-checks the destination key (409 if
59
+ * taken), copies to the new keys, updates the row, then deletes the old keys; on a DB failure the
60
+ * freshly-copied objects are removed so the source is never lost. No-op if the target equals the
61
+ * current key. */
62
+ export async function relocateMedia(
63
+ db: BetterSQLite3Database, driver: StorageDriver, id: number,
64
+ target: { folder?: string; filename?: string },
65
+ ): Promise<void> {
66
+ const cols = mediaCols()
67
+ const row = db.select().from(media).where(eq(cols.id, id)).get() as MediaRowLite | undefined
68
+ if (!row) throw createError({ statusCode: 404, statusMessage: `media ${id} not found` })
69
+
70
+ const folder = target.folder ?? row.folder ?? ''
71
+ const filename = target.filename ?? row.filename
72
+ const plan = planObjectRelocation(row, folder, filename)
73
+ if (plan.storageKey === row.storageKey) return
74
+
75
+ // Same lock the upload path holds across its own exists-check → put → insert (mediaLockKey, keyed on
76
+ // the DESTINATION key only): without it, a concurrent upload or another relocate targeting this same
77
+ // key can observe it free mid-way through this one and land on top of it — or, two relocates racing
78
+ // for the same destination, have the loser's failure-compensation delete the WINNER's just-landed blob.
79
+ await withLock(mediaLockKey(plan.storageKey), async () => {
80
+ const clash = db.select({ id: cols.id }).from(media).where(eq(cols.storageKey, plan.storageKey)).get() as { id: number } | undefined
81
+ if (clash) throw createError({ statusCode: 409, statusMessage: `A file already exists at ${plan.storageKey}` })
82
+ await assertDestKeysFree(driver, plan)
83
+
84
+ for (const c of plan.copies) await driver.copy(c.src, c.dst)
85
+ try {
86
+ db.update(media).set({ storageKey: plan.storageKey, folder: folder || null, filename, derivatives: plan.derivatives, updatedAt: new Date() }).where(eq(cols.id, id)).run()
87
+ } catch (error) {
88
+ for (const c of plan.copies) await driver.delete(c.dst)
89
+ throw error
90
+ }
91
+ for (const c of plan.copies) await driver.delete(c.src)
92
+ })
93
+ // The public URL is built from storageKey — moving/renaming changes it, so embedding pages must re-render.
94
+ emitMediaWrite({ id }, { id })
95
+ }
96
+
97
+ interface MediaRowFull extends MediaRowLite {
98
+ mime: string; ext: string; size: number; width: number | null; height: number | null
99
+ checksum: string | null; thumbhash: string | null
100
+ translations: Record<string, unknown> | null
101
+ }
102
+
103
+ /** Duplicate a media object (+ derivatives) to a new key and insert a new row (same bytes →
104
+ * same checksum/dims/translations). Rejects 409 if the target key is taken. */
105
+ export async function duplicateMedia(
106
+ db: BetterSQLite3Database, driver: StorageDriver, id: number,
107
+ target: { folder?: string; filename?: string },
108
+ ): Promise<{ id: number }> {
109
+ const cols = mediaCols()
110
+ const row = db.select().from(media).where(eq(cols.id, id)).get() as MediaRowFull | undefined
111
+ if (!row) throw createError({ statusCode: 404, statusMessage: `media ${id} not found` })
112
+
113
+ const folder = target.folder ?? row.folder ?? ''
114
+ const filename = target.filename ?? row.filename
115
+ const plan = planObjectRelocation(row, folder, filename)
116
+
117
+ // See relocateMedia: same destination-key lock the upload path holds, so a concurrent upload/relocate
118
+ // targeting this exact key can't observe it free mid-way through this copy+insert.
119
+ return withLock(mediaLockKey(plan.storageKey), async () => {
120
+ const clash = db.select({ id: cols.id }).from(media).where(eq(cols.storageKey, plan.storageKey)).get() as { id: number } | undefined
121
+ if (clash) throw createError({ statusCode: 409, statusMessage: `A file already exists at ${plan.storageKey}` })
122
+ await assertDestKeysFree(driver, plan)
123
+
124
+ for (const c of plan.copies) await driver.copy(c.src, c.dst)
125
+ try {
126
+ const created = db.insert(media).values({
127
+ storageKey: plan.storageKey, folder: folder || null, filename,
128
+ mime: row.mime, ext: row.ext, size: row.size, width: row.width, height: row.height,
129
+ checksum: row.checksum, thumbhash: row.thumbhash, derivatives: plan.derivatives,
130
+ translations: row.translations ?? {},
131
+ } as never).returning({ id: cols.id }).get() as { id: number }
132
+ emitMediaWrite(null, { id: created.id }) // new media row → re-render listings
133
+ return created
134
+ } catch (error) {
135
+ for (const c of plan.copies) await driver.delete(c.dst)
136
+ throw error
137
+ }
138
+ })
139
+ }
@@ -0,0 +1,19 @@
1
+ export type LocaleFields = Record<string, unknown>
2
+ export type Translations = Record<string, LocaleFields>
3
+
4
+ const isPlainObject = (v: unknown): v is Record<string, unknown> =>
5
+ typeof v === 'object' && v !== null && !Array.isArray(v)
6
+
7
+ /** Deep-merge a partial translations patch into the current map: new locales are added, and within a
8
+ * locale the patched fields are merged over the existing ones — so patching `en.alt` keeps `en.title`
9
+ * / `en.description`, and other locales are untouched. Defensive against malformed input: a non-object
10
+ * current/patch or a non-object per-locale value is ignored rather than persisting garbage. */
11
+ export function mergeTranslations(current: Translations | null | undefined, patch: Translations): Translations {
12
+ const out: Translations = isPlainObject(current) ? { ...current } : {}
13
+ if (!isPlainObject(patch)) return out
14
+ for (const [loc, fields] of Object.entries(patch)) {
15
+ if (!isPlainObject(fields)) continue
16
+ out[loc] = { ...(isPlainObject(out[loc]) ? out[loc] : {}), ...fields }
17
+ }
18
+ return out
19
+ }
@@ -0,0 +1,66 @@
1
+ import { sql, getTableColumns, getTableName, type SQL } from 'drizzle-orm'
2
+ import type { AnySQLiteColumn } from 'drizzle-orm/sqlite-core'
3
+ import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
4
+ import { allCollections } from '../../../core/server/utils/registry'
5
+ import { fieldIs } from '../../../core/server/utils/defineCollection'
6
+ import { resolveColumnName } from '../../../fields/server/field-registry/naming'
7
+
8
+ export interface MediaUsage { collection: string; recordId: number; field: string }
9
+
10
+ /**
11
+ * Reverse lookup for a whole set of media ids at once: ONE query per (collection, column), never one per
12
+ * id. The JSON columns can only be searched by a full table scan, and better-sqlite3 is synchronous, so a
13
+ * per-id loop over a folder-sized selection would block the event loop for seconds. Every requested id
14
+ * gets an entry (empty when unreferenced). Mirrors `findReferrersForMany` in core's record-ref index.
15
+ */
16
+ export function findMediaUsagesForMany(db: BetterSQLite3Database, ids: number[]): Record<number, MediaUsage[]> {
17
+ const out: Record<number, MediaUsage[]> = {}
18
+ for (const id of ids) out[id] = []
19
+ if (!ids.length) return out
20
+ // The id set travels as ONE bound parameter (a JSON array), not one placeholder per id: a recursive
21
+ // folder delete can hand us tens of thousands of ids, well past SQLite's 32766-variable ceiling.
22
+ const idSet = sql`(select value from json_each(${JSON.stringify(ids)}))`
23
+
24
+ for (const c of allCollections()) {
25
+ if (c.name === 'media') continue
26
+ const cols = getTableColumns(c.table) as Record<string, AnySQLiteColumn>
27
+ const tbl = sql.identifier(getTableName(c.table))
28
+ const rowId = sql.identifier(cols.id.name)
29
+ const collect = (query: SQL, field: string) => {
30
+ for (const r of db.all(query) as { rid: number; mid: number }[]) {
31
+ out[r.mid]?.push({ collection: c.name, recordId: r.rid, field })
32
+ }
33
+ }
34
+ // Media ids can nest anywhere inside a repeater/json/content/seo value — json_tree the whole column and
35
+ // match any integer atom (a safe over-approximation for usage warnings).
36
+ const scanJson = (column: string, field: string) => collect(
37
+ sql`select distinct t.${rowId} as rid, jt.atom as mid from ${tbl} t, json_tree(t.${sql.identifier(column)}) jt
38
+ where jt.type = 'integer' and jt.atom in ${idSet}`,
39
+ field,
40
+ )
41
+
42
+ for (const [key, field] of Object.entries(c.def.fields)) {
43
+ if (fieldIs(field, 'media') && !field.options?.multiple) {
44
+ const { jsKey } = resolveColumnName(key, field)
45
+ const col = sql.identifier(cols[jsKey].name)
46
+ collect(sql`select t.${rowId} as rid, t.${col} as mid from ${tbl} t where t.${col} in ${idSet}`, key)
47
+ } else if (fieldIs(field, 'media') && field.options?.multiple) {
48
+ const col = sql.identifier(cols[key].name)
49
+ collect(sql`select distinct t.${rowId} as rid, je.value as mid from ${tbl} t, json_each(t.${col}) je
50
+ where je.value in ${idSet}`, key)
51
+ } else if (field.type === 'repeater' || field.type === 'json') {
52
+ const { jsKey } = resolveColumnName(key, field)
53
+ scanJson(cols[jsKey].name, key)
54
+ }
55
+ }
56
+ if (c.def.blocks?.enabled) scanJson('content', 'content')
57
+ // The `seo` system column carries the social-share image, so a page whose only reference to an asset is
58
+ // its og:image must still be reported — otherwise deleting it silently 404s the published meta tag.
59
+ if (c.def.seo) scanJson('seo', 'seo.image')
60
+ }
61
+ return out
62
+ }
63
+
64
+ export function findMediaUsages(db: BetterSQLite3Database, id: number): MediaUsage[] {
65
+ return findMediaUsagesForMany(db, [id])[id]!
66
+ }
@@ -0,0 +1,70 @@
1
+ import { eq, getTableColumns } from 'drizzle-orm'
2
+ import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
3
+ import type { ResolvedVariant } from '../../../core/server/utils/kestrel-config'
4
+ import { mediaSettings } from '../collections/media-settings'
5
+ import { reconcileVariants, type StoredVariant } from './variants'
6
+
7
+ // Auto-discovery accumulator. `KestrelImg`/`useMediaVariant` stash their concrete specs on the render's
8
+ // `event.context.kestrelVariants`; a `beforeResponse` hook (only for publish renders, gated on the
9
+ // `x-kestrel-publish` header) drains them into this module Map, keyed by name and unioning formats across
10
+ // usages. At the end of a FULL publish / generate `saveDiscoveredVariants` reconciles the union into the
11
+ // registry and clears it — so the registry reflects exactly what the last full render actually used.
12
+ const discovered = new Map<string, ResolvedVariant>()
13
+
14
+ /** Merge freshly-declared specs into the accumulator (union formats for same-named variants). */
15
+ export function recordVariants(specs: readonly ResolvedVariant[]): void {
16
+ for (const s of specs) {
17
+ if (!s?.name) continue
18
+ const prev = discovered.get(s.name)
19
+ if (prev) prev.formats = [...new Set([...prev.formats, ...(s.formats ?? [])])]
20
+ else discovered.set(s.name, { ...s, formats: [...(s.formats ?? ['webp'])] })
21
+ }
22
+ }
23
+
24
+ export function collectVariants(): ResolvedVariant[] {
25
+ return [...discovered.values()]
26
+ }
27
+
28
+ export function clearVariants(): void {
29
+ discovered.clear()
30
+ }
31
+
32
+ /**
33
+ * Reconcile the accumulated discovered specs into the `media_settings` registry (scan entries replaced,
34
+ * manual/pinned kept) and clear the accumulator. Called at the end of a full publish / generate.
35
+ *
36
+ * Safety: if NOTHING was discovered this run, leave the registry untouched rather than wiping every scan
37
+ * entry — a zero result almost always means the capture didn't fire (no KestrelImg rendered, or a plumbing
38
+ * gap), not that the site genuinely uses no variants. Narrowing still works whenever ≥1 variant is found.
39
+ *
40
+ * `{ clear: false }` reconciles WITHOUT draining the accumulator — the classic `nuxt generate` topology has
41
+ * no publisher to run this once at the end, so it reconciles after each prerendered route and the growing
42
+ * accumulator converges to the full used set by the last route.
43
+ */
44
+ export function saveDiscoveredVariants(db: BetterSQLite3Database, opts: { clear?: boolean } = {}): void {
45
+ const found = collectVariants()
46
+ if (!found.length) { if (opts.clear !== false) clearVariants(); return }
47
+ const cols = getTableColumns(mediaSettings) as Record<string, never>
48
+ try {
49
+ // The registry is a read-modify-write, and a `nuxt generate` can run against the SAME DB as a live
50
+ // server's publisher — an unguarded select→reconcile→update loses one side's variants. An IMMEDIATE
51
+ // transaction takes SQLite's write lock up front, so a concurrent writer WAITS (busy_timeout) and then
52
+ // reconciles against the already-updated row instead of clobbering it (cross-process safe).
53
+ db.transaction((tx) => {
54
+ const row = tx.select().from(mediaSettings).where(eq(cols.singletonKey, 'media_settings')).get() as
55
+ | { variants?: StoredVariant[] | null }
56
+ | undefined
57
+ // The json field type's column default is the OBJECT shape '{}' (not '[]') — a generic singleton
58
+ // PUT that omits `variants` leaves exactly that, so nullish-only guarding isn't enough here.
59
+ const next = reconcileVariants(Array.isArray(row?.variants) ? row.variants : [], found)
60
+ if (row) tx.update(mediaSettings).set({ variants: next, updatedAt: new Date() } as never).where(eq(cols.singletonKey, 'media_settings')).run()
61
+ else tx.insert(mediaSettings).values({ singletonKey: 'media_settings', variants: next } as never).run()
62
+ }, { behavior: 'immediate' })
63
+ // Drain the accumulator only AFTER a durable write, so a transient failure keeps it for the next pass.
64
+ if (opts.clear !== false) clearVariants()
65
+ } catch (error) {
66
+ // media_settings not migrated yet — skip persisting the registry (narrowing stays off, config fallback
67
+ // applies) rather than crash the publish. See the schema-drift note; run `drizzle-kit generate`.
68
+ console.warn('[kestrel] media_settings registry not available — variant discovery not persisted:', (error as Error)?.message ?? error)
69
+ }
70
+ }
@@ -0,0 +1,108 @@
1
+ import { eq, getTableColumns } from 'drizzle-orm'
2
+ import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
3
+ import type { ResolvedVariant, VariantFit } from '../../../core/server/utils/kestrel-config'
4
+ import { mediaSettings } from '../collections/media-settings'
5
+
6
+ const VARIANT_FITS = new Set<VariantFit>(['cover', 'contain', 'inside', 'outside', 'fill'])
7
+ // sharp's crop gravity/position values (resize({fit:'cover'|'contain', position})).
8
+ const SHARP_POSITIONS = new Set([
9
+ 'top', 'right top', 'right', 'right bottom', 'bottom', 'left bottom', 'left', 'left top', 'centre', 'center',
10
+ 'north', 'northeast', 'east', 'southeast', 'south', 'southwest', 'west', 'northwest', 'entropy', 'attention',
11
+ ])
12
+
13
+ /**
14
+ * A variant persisted in the `media_settings` registry: a resolved variant plus its provenance. `source`
15
+ * distinguishes an auto-discovered (`'scan'`) entry from a hand-authored (`'manual'`) one; a `manual`/`pinned`
16
+ * entry survives the scan reconcile and wins a name collision with a scanned entry.
17
+ */
18
+ export interface StoredVariant extends ResolvedVariant {
19
+ source?: 'manual' | 'scan'
20
+ pinned?: boolean
21
+ }
22
+
23
+ const rank = (v: StoredVariant): number => (v.pinned || v.source === 'manual' ? 1 : 0)
24
+
25
+ /**
26
+ * The effective variant set to derive: the stored registry entries (deduped by name, a manual/pinned entry
27
+ * winning a scan collision, stripped of provenance and re-defaulted defensively), or `fallback` (the resolved
28
+ * config policy variants) when the registry is empty/absent. `presets` are config-authored named variants
29
+ * (`image.variants`): explicit, name-referenced declarations that are never scan-discovered, so they stay
30
+ * active through usage-driven narrowing (unioned into a non-empty registry, winning any name collision).
31
+ * Pure — the load-bearing logic, unit-tested without a DB.
32
+ */
33
+ export function resolveActiveVariants(
34
+ stored: StoredVariant[] | null | undefined, fallback: ResolvedVariant[], presets: ResolvedVariant[] = [],
35
+ ): ResolvedVariant[] {
36
+ // A registry row is hand-authorable via the media_settings JSON PATCH; reject a name outside the
37
+ // derivative-key charset ([A-Za-z0-9_-]) — an out-of-charset char breaks the URL / the prune-media
38
+ // referencedKeys regex (its derivative is pruned though pages reference it) and a `/` nests a pseudo-folder.
39
+ const safeName = (n: unknown): n is string => typeof n === 'string' && /^[A-Za-z0-9_-]+$/.test(n)
40
+ const list = (Array.isArray(stored) ? stored : []).filter(
41
+ (v): v is StoredVariant => !!v && safeName(v.name) && Number.isFinite(v.width) && v.width >= 1,
42
+ )
43
+ // Empty registry ⇒ the fallback already contains the presets (resolveVariants unions them), so return it as-is.
44
+ if (!list.length) return fallback
45
+ const byName = new Map<string, StoredVariant>()
46
+ for (const v of list) {
47
+ const prev = byName.get(v.name)
48
+ if (!prev || rank(v) > rank(prev)) byName.set(v.name, v)
49
+ }
50
+ for (const p of presets) {
51
+ if (p && typeof p.name === 'string' && p.name !== '' && Number.isFinite(p.width) && p.width >= 1) byName.set(p.name, { ...p, source: 'manual' })
52
+ }
53
+ return [...byName.values()].map((v): ResolvedVariant => ({
54
+ name: v.name,
55
+ width: Math.floor(v.width),
56
+ // coerce a garbage/stale height to null so it never reaches sharp's crop resize and 500s the upload
57
+ height: typeof v.height === 'number' && Number.isFinite(v.height) && v.height >= 1 ? Math.floor(v.height) : null,
58
+ // A hand-authored (media_settings PATCH) fit/position outside sharp's enum would otherwise reach
59
+ // sharp.resize() verbatim and throw for EVERY upload that derives this variant — allow-list, don't
60
+ // just default the nullish case.
61
+ fit: v.fit && VARIANT_FITS.has(v.fit) ? v.fit : 'cover',
62
+ position: typeof v.position === 'string' && SHARP_POSITIONS.has(v.position) ? v.position : 'centre',
63
+ formats: v.formats?.length ? v.formats : ['webp'],
64
+ }))
65
+ }
66
+
67
+ /**
68
+ * Reconcile a fresh scan into the stored registry: every hand-authored entry (`source:'manual'` or
69
+ * `pinned`) is KEPT, and the discovered specs replace ALL prior `source:'scan'` entries (so a variant whose
70
+ * last `KestrelImg` usage was removed disappears — usage-driven narrowing). A manual/pinned entry wins a
71
+ * name collision with a discovered one. Pure — the load-bearing reconcile, unit-tested without a DB.
72
+ */
73
+ export function reconcileVariants(existing: StoredVariant[], discovered: ResolvedVariant[]): StoredVariant[] {
74
+ const out: StoredVariant[] = []
75
+ const seen = new Set<string>()
76
+ // Belt-and-braces against the json field type's object-shaped default ('{}', not '[]') reaching here
77
+ // directly — mirrors the Array.isArray guard in resolveActiveVariants.
78
+ for (const e of (Array.isArray(existing) ? existing : [])) {
79
+ if ((e.source === 'manual' || e.pinned) && e.name && !seen.has(e.name)) { out.push(e); seen.add(e.name) }
80
+ }
81
+ for (const d of discovered) {
82
+ if (!d.name || seen.has(d.name)) continue
83
+ out.push({ ...d, source: 'scan' })
84
+ seen.add(d.name)
85
+ }
86
+ return out
87
+ }
88
+
89
+ /**
90
+ * Read the persisted variant registry (the `media_settings` singleton) and resolve the active set,
91
+ * falling back to `fallback` (the resolved config policy variants) when nothing is stored yet. `presets`
92
+ * (config-authored named variants) stay active regardless of the stored set. The upload path calls this
93
+ * so it derives exactly the currently-registered set + presets (narrow generation).
94
+ */
95
+ export function activeVariants(db: BetterSQLite3Database, fallback: ResolvedVariant[], presets: ResolvedVariant[] = []): ResolvedVariant[] {
96
+ const cols = getTableColumns(mediaSettings) as Record<string, never>
97
+ let row: { variants?: StoredVariant[] | null } | undefined
98
+ try {
99
+ row = db.select().from(mediaSettings).where(eq(cols.singletonKey, 'media_settings')).get() as
100
+ | { variants?: StoredVariant[] | null }
101
+ | undefined
102
+ } catch {
103
+ // media_settings not migrated yet (a DB provisioned by committed migrations alone) — degrade to the
104
+ // config fallback rather than 500 the upload. Mirrors attachDeadRefs tolerating a missing record_refs.
105
+ row = undefined
106
+ }
107
+ return resolveActiveVariants(row?.variants ?? null, fallback, presets)
108
+ }
@@ -0,0 +1,5 @@
1
+ <template>
2
+ <NuxtLayout>
3
+ <NuxtPage />
4
+ </NuxtLayout>
5
+ </template>
@@ -0,0 +1,56 @@
1
+ <script setup lang="ts">
2
+ import { computed, inject, resolveDynamicComponent } from 'vue'
3
+ import BlockRenderer from './BlockRenderer.vue'
4
+ import { blockEditKey } from '../utils/block-edit-context'
5
+
6
+ interface BlockNode { id?: string; type: string; props?: Record<string, unknown>; slots?: Record<string, BlockNode[]> }
7
+ const props = defineProps<{ blocks: BlockNode[] }>()
8
+
9
+ // Absent on the public site (plain render); present inside the admin preview (selection-aware wrapper).
10
+ const edit = inject(blockEditKey, null)
11
+
12
+ const pascal = (s: string) => s.replace(/(^|[-_])(\w)/g, (_m, _sep, c: string) => c.toUpperCase())
13
+
14
+ const items = computed(() =>
15
+ props.blocks.map((block, i) => {
16
+ // A block type with no display component is a content condition, not a programming error: this
17
+ // resolver hands back the bare name for it silently, leaving the message below as the one diagnostic.
18
+ const resolved = resolveDynamicComponent('Blocks' + pascal(block.type))
19
+ const component = typeof resolved === 'string' ? null : resolved
20
+ if (!component && import.meta.dev) console.warn(`[kestrel] no display component for block type "${block.type}"`)
21
+ const { $media, ...fields } = (block.props ?? {}) as Record<string, unknown>
22
+ return { key: block.id ?? i, id: block.id, component, fields, media: $media as Record<string, unknown> | undefined, slots: block.slots }
23
+ }),
24
+ )
25
+ </script>
26
+
27
+ <template>
28
+ <template v-for="item in items" :key="item.key">
29
+ <!-- Public render path: no edit context → plain markup, unchanged. -->
30
+ <component :is="item.component" v-if="item.component && !edit" v-bind="item.fields" :media="item.media">
31
+ <!-- Each declared slot's child blocks recurse through the same renderer (props + nested $media). -->
32
+ <template v-for="(slotBlocks, slotName) in item.slots" #[slotName]>
33
+ <BlockRenderer :blocks="slotBlocks" />
34
+ </template>
35
+ </component>
36
+ <!-- Editor preview path: same component, wrapped in a clickable, highlightable marker. `.prevent`
37
+ cancels any anchor navigation from a link-bearing block; `.stop` keeps a nested-block click from
38
+ also selecting its ancestors. The marker is `display: contents` (see KestrelPreviewBridge), so it
39
+ generates no box: the block root stays the parent's real flex/grid item, so flex/grid-item props,
40
+ `gap`, and root-keyed breakpoints render as on the published site. (`>` combinators are unaffected
41
+ either way — the marker element stays in the DOM tree.) -->
42
+ <div
43
+ v-else-if="item.component"
44
+ class="block-edit-marker"
45
+ :class="{ 'block-edit-marker--selected': item.id != null && edit!.selectedId.value === item.id }"
46
+ :data-block-id="item.id"
47
+ @click.prevent.stop="item.id != null && edit!.select(item.id)"
48
+ >
49
+ <component :is="item.component" v-bind="item.fields" :media="item.media">
50
+ <template v-for="(slotBlocks, slotName) in item.slots" #[slotName]>
51
+ <BlockRenderer :blocks="slotBlocks" />
52
+ </template>
53
+ </component>
54
+ </div>
55
+ </template>
56
+ </template>
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ import { computed, useSlots } from 'vue'
3
+ import type { LinkValue } from '../../../core/server/utils/defineCollection'
4
+ import { linkToHref, linkLabel } from '../utils/link-href'
5
+
6
+ // Generic renderer for the `link` field type, for consumer projects + blocks. Renders an <a> for any
7
+ // LinkValue; external links open in a new, isolated tab. Renders nothing for a null/empty value.
8
+ const props = defineProps<{ value?: LinkValue | null }>()
9
+ const slots = useSlots()
10
+ const href = computed(() => linkToHref(props.value))
11
+ const isExternal = computed(() => props.value?.type === 'external')
12
+ // An unresolved internal link (draft/missing target) falls back to '#' with no target-derived label —
13
+ // only render it if it still has an accessible name from elsewhere, or it's a tabbable link to nowhere.
14
+ const dead = computed(() => props.value?.type === 'internal' && href.value === '#')
15
+ const named = computed(() => !!linkLabel(props.value) || !!slots.default)
16
+ </script>
17
+
18
+ <template>
19
+ <a
20
+ v-if="value && href && (!dead || named)"
21
+ class="kestrel-link"
22
+ :href="href"
23
+ :target="isExternal ? '_blank' : undefined"
24
+ :rel="isExternal ? 'noopener noreferrer' : undefined"
25
+ ><slot>{{ linkLabel(value) }}</slot></a>
26
+ </template>