@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,214 @@
1
+ import type { SerializedBlock } from '../../../core/server/utils/serialize-collection'
2
+ import { reorder } from '../../../ui/app/utils/reorder'
3
+ import { initialValues } from './edit-form'
4
+
5
+ // Pure, immutable operations over a nested block tree (`content`), addressed by each block's stable
6
+ // `id` rather than a positional index — this is what lets a single shared selection + one fields pane
7
+ // edit a block at ANY depth: every op walks the whole tree by id and rebuilds only the path to it. No
8
+ // Vue here — fully unit-testable.
9
+
10
+ export interface BlockRow {
11
+ id: string
12
+ type: string
13
+ props: Record<string, unknown>
14
+ slots?: Record<string, unknown>
15
+ }
16
+
17
+ type GenId = () => string
18
+
19
+ /** Stable config + id-addressed operations for the tree pane, threaded through the recursion as one
20
+ * object. Lives here (not in the SFC) so the component, the editor shell, and tests can share the type. */
21
+ export interface BlockTreeCtx {
22
+ byName: Record<string, SerializedBlock>
23
+ allowedTypes: SerializedBlock[]
24
+ ops: {
25
+ select: (id: string | null) => void
26
+ add: (parentId: string | null, slotName: string | null, type: string) => void
27
+ remove: (id: string) => void
28
+ move: (id: string, dir: -1 | 1) => void
29
+ duplicate: (id: string) => void
30
+ }
31
+ }
32
+
33
+ /** A seeded blank block of `type`: fresh id + props defaulted from the type's field schema. */
34
+ export function blankBlock(type: string, schemas: Record<string, SerializedBlock>, genId: GenId): BlockRow {
35
+ return { id: genId(), type, props: initialValues(schemas[type]?.fields ?? {}) }
36
+ }
37
+
38
+ /** Deep-clone a block subtree with fresh ids at every level (duplicate never shares an id or a ref). */
39
+ export function cloneBlockTree(b: BlockRow, genId: GenId): BlockRow {
40
+ const copy: BlockRow = { ...b, id: genId(), props: JSON.parse(JSON.stringify(b.props ?? {})) }
41
+ if (b.slots) {
42
+ copy.slots = Object.fromEntries(
43
+ Object.entries(b.slots).map(([name, arr]) => [name, Array.isArray(arr) ? (arr as BlockRow[]).map((c) => cloneBlockTree(c, genId)) : arr]),
44
+ )
45
+ }
46
+ return copy
47
+ }
48
+
49
+ export interface Found {
50
+ block: BlockRow
51
+ /** The array that directly contains the block. */
52
+ siblings: BlockRow[]
53
+ index: number
54
+ /** The id of the block whose slot contains this one, or null at the root level. */
55
+ parentId: string | null
56
+ /** The slot name this block lives in, or null at the root level. */
57
+ slotName: string | null
58
+ }
59
+
60
+ /** Locate a block anywhere in the tree by id (with its container + position), or null. */
61
+ export function findInTree(blocks: BlockRow[], id: string): Found | null {
62
+ function walk(arr: BlockRow[], parentId: string | null, slotName: string | null): Found | null {
63
+ for (let i = 0; i < arr.length; i++) {
64
+ const b = arr[i]!
65
+ if (b.id === id) return { block: b, siblings: arr, index: i, parentId, slotName }
66
+ if (b.slots) {
67
+ for (const [name, sub] of Object.entries(b.slots)) {
68
+ if (Array.isArray(sub)) {
69
+ const hit = walk(sub as BlockRow[], b.id, name)
70
+ if (hit) return hit
71
+ }
72
+ }
73
+ }
74
+ }
75
+ return null
76
+ }
77
+ return walk(blocks, null, null)
78
+ }
79
+
80
+ /** Immutably transform the block with `id` (block → new block), rebuilding ONLY the path to it; every
81
+ * untouched block AND array keeps its reference. Returning the original `blocks` when nothing changed is
82
+ * what makes the recursive `next !== sub` a correct change-detector (a bare `.map` always allocates, so it
83
+ * would rebuild every slot-bearing block in the tree on any edit). */
84
+ function updateBlock(blocks: BlockRow[], id: string, fn: (b: BlockRow) => BlockRow): BlockRow[] {
85
+ let changed = false
86
+ const out = blocks.map((b) => {
87
+ if (b.id === id) { changed = true; return fn(b) }
88
+ if (!b.slots) return b
89
+ const mapped = withMappedSlots(b)
90
+ if (mapped !== b) changed = true
91
+ return mapped
92
+ })
93
+ return changed ? out : blocks
94
+ function withMappedSlots(b: BlockRow): BlockRow {
95
+ let changed = false
96
+ const slots: Record<string, unknown> = {}
97
+ for (const [name, sub] of Object.entries(b.slots!)) {
98
+ if (Array.isArray(sub)) {
99
+ const next = updateBlock(sub as BlockRow[], id, fn)
100
+ if (next !== sub) changed = true
101
+ slots[name] = next
102
+ } else slots[name] = sub
103
+ }
104
+ return changed ? { ...b, slots } : b
105
+ }
106
+ }
107
+
108
+ /** Immutably transform the ARRAY that directly contains `id` (array+index → new array); every untouched
109
+ * block AND array keeps its reference (same same-ref-when-unchanged rule as `updateBlock`). */
110
+ function updateContaining(blocks: BlockRow[], id: string, fn: (arr: BlockRow[], index: number) => BlockRow[]): BlockRow[] {
111
+ const idx = blocks.findIndex((b) => b.id === id)
112
+ if (idx !== -1) return fn(blocks, idx)
113
+ let changed = false
114
+ const out = blocks.map((b) => {
115
+ if (!b.slots) return b
116
+ let slotChanged = false
117
+ const slots: Record<string, unknown> = {}
118
+ for (const [name, sub] of Object.entries(b.slots)) {
119
+ if (Array.isArray(sub)) {
120
+ const next = updateContaining(sub as BlockRow[], id, fn)
121
+ if (next !== sub) slotChanged = true
122
+ slots[name] = next
123
+ } else slots[name] = sub
124
+ }
125
+ if (!slotChanged) return b
126
+ changed = true
127
+ return { ...b, slots }
128
+ })
129
+ return changed ? out : blocks
130
+ }
131
+
132
+ export function updatePropById(blocks: BlockRow[], id: string, key: string, value: unknown): BlockRow[] {
133
+ return updateBlock(blocks, id, (b) => ({ ...b, props: { ...b.props, [key]: value } }))
134
+ }
135
+
136
+ /** Remove the block with `id` from wherever it lives. */
137
+ export function removeById(blocks: BlockRow[], id: string): BlockRow[] {
138
+ return updateContaining(blocks, id, (arr, i) => arr.filter((_, j) => j !== i))
139
+ }
140
+
141
+ /** Where selection should move when the block with `id` is removed: previous sibling, else next, else
142
+ * the parent (null at the root). Computed against the tree BEFORE removal. */
143
+ export function removalRetarget(blocks: BlockRow[], id: string): string | null {
144
+ const f = findInTree(blocks, id)
145
+ if (!f) return null
146
+ if (f.index > 0) return f.siblings[f.index - 1]!.id
147
+ if (f.index < f.siblings.length - 1) return f.siblings[f.index + 1]!.id
148
+ return f.parentId
149
+ }
150
+
151
+ /** Move the block with `id` one step within its container (`dir` -1 up / +1 down); clamps at the edges. */
152
+ export function moveById(blocks: BlockRow[], id: string, dir: -1 | 1): BlockRow[] {
153
+ return updateContaining(blocks, id, (arr, i) => {
154
+ const to = i + dir
155
+ if (to < 0 || to >= arr.length) return arr
156
+ return reorder(arr, i, to)
157
+ })
158
+ }
159
+
160
+ /** Duplicate the block with `id` (fresh ids throughout) right after it; returns the new tree + new id. */
161
+ export function duplicateById(blocks: BlockRow[], id: string, genId: GenId): { tree: BlockRow[]; newId: string } {
162
+ let newId = ''
163
+ const tree = updateContaining(blocks, id, (arr, i) => {
164
+ const copy = cloneBlockTree(arr[i]!, genId)
165
+ newId = copy.id
166
+ const next = [...arr]
167
+ next.splice(i + 1, 0, copy)
168
+ return next
169
+ })
170
+ return { tree, newId }
171
+ }
172
+
173
+ /** Append a seeded block of `type` at the root (`parentId` null) or into a parent's slot; returns the
174
+ * new tree + the new block's id. */
175
+ export function addBlock(
176
+ blocks: BlockRow[],
177
+ parentId: string | null,
178
+ slotName: string | null,
179
+ type: string,
180
+ schemas: Record<string, SerializedBlock>,
181
+ genId: GenId,
182
+ ): { tree: BlockRow[]; newId: string } {
183
+ const block = blankBlock(type, schemas, genId)
184
+ if (parentId === null || slotName === null) return { tree: [...blocks, block], newId: block.id }
185
+ const tree = updateBlock(blocks, parentId, (parent) => {
186
+ const cur = Array.isArray(parent.slots?.[slotName]) ? (parent.slots![slotName] as BlockRow[]) : []
187
+ return { ...parent, slots: { ...(parent.slots ?? {}), [slotName]: [...cur, block] } }
188
+ })
189
+ return { tree, newId: block.id }
190
+ }
191
+
192
+ /** Ids of every block that itself has an error OR has a descendant with one (ancestor roll-up), for
193
+ * tree badges. `directIds` are the blocks the error map keys directly. */
194
+ export function errorBearingIds(blocks: BlockRow[], directIds: Set<string>): Set<string> {
195
+ const out = new Set<string>()
196
+ function walk(arr: BlockRow[]): boolean {
197
+ let any = false
198
+ for (const b of arr) {
199
+ let descendantHasError = false
200
+ if (b.slots) {
201
+ for (const sub of Object.values(b.slots)) {
202
+ if (Array.isArray(sub) && walk(sub as BlockRow[])) descendantHasError = true
203
+ }
204
+ }
205
+ if (directIds.has(b.id) || descendantHasError) {
206
+ out.add(b.id)
207
+ any = true
208
+ }
209
+ }
210
+ return any
211
+ }
212
+ walk(blocks)
213
+ return out
214
+ }
@@ -0,0 +1,44 @@
1
+ // Wire shapes for the collection batch endpoint, shared by the ops composable and the delete dialog.
2
+ // Mirrors media/app/utils/ops.ts: one definition, no drift between the caller and the confirm UI.
3
+
4
+ /** The four batch actions the command endpoint accepts (a row action is a bulk op with one id). */
5
+ export type BatchAction = 'delete' | 'publish' | 'unpublish' | 'duplicate'
6
+
7
+ /** POST /api/{collection}/bulk response. For `duplicate`, `ids` are the CREATED rows. */
8
+ export interface BulkResult {
9
+ action: BatchAction
10
+ count: number
11
+ ids: number[]
12
+ }
13
+
14
+ /** GET /api/references/referrers?ids=1,2,3 response: id -> how many other records reference it.
15
+ * `checked` is false when the index lookup itself failed, in which case `counts` is empty because nothing
16
+ * could be read — NOT because nothing links to the selection. */
17
+ export interface ReferrerCounts {
18
+ counts: Record<string, number>
19
+ checked: boolean
20
+ }
21
+
22
+ /** The pre-delete summary shown in the confirm dialog: how many rows, and how many of them other
23
+ * records still link to (aggregated from the referrer counts). */
24
+ export interface BatchDeleteReport {
25
+ /** How many rows are about to be deleted. */
26
+ count: number
27
+ /** How many of those rows are referenced by at least one other record. */
28
+ referencedCount: number
29
+ /** Per-id breakdown for the referenced rows (id + inbound referrer count), in input order. */
30
+ referenced: { id: number; referrers: number }[]
31
+ /** Whether the referrer lookup actually ran. `false` means the check FAILED (not "none found"), so
32
+ * the dialog must warn that inbound links are unverified rather than imply a safe delete. */
33
+ checked: boolean
34
+ }
35
+
36
+ /** Fold the referrer-count map into a delete report — pure, so it is unit-testable without a network.
37
+ * `checked` is the lookup's own outcome and is carried through untouched: an empty `counts` is ambiguous
38
+ * on its own, and only this flag tells the dialog whether it may claim nothing links to the selection. */
39
+ export function buildDeleteReport(ids: number[], counts: Record<string, number>, checked: boolean): BatchDeleteReport {
40
+ const referenced = ids
41
+ .map((id) => ({ id, referrers: Number(counts[String(id)] ?? 0) }))
42
+ .filter((r) => r.referrers > 0)
43
+ return { count: ids.length, referencedCount: referenced.length, referenced, checked }
44
+ }
@@ -0,0 +1,30 @@
1
+ // Client-side shaping of the per-record dead-reference map (the `/dead-refs` endpoint payload) into the
2
+ // sets the editor renders: which block nodes carry a stale reference (fed through `errorBearingIds` for
3
+ // the tree roll-up, exactly like block errors) and which field keys are stale at a given location. Pure
4
+ // + Vue-free so it unit-tests directly (happy-dom can't render the teleported field widgets).
5
+
6
+ /** One stale reference a record holds, with its field/block location + why it is dead. Wire shape of the
7
+ * server's `LocatedDeadRef`; kept here so the admin client doesn't import the server (drizzle) module. */
8
+ export interface DeadRef {
9
+ field: string
10
+ blockId?: string
11
+ collection: string
12
+ id: number
13
+ reason: 'missing' | 'unpublished'
14
+ }
15
+
16
+ /** Block-node ids that DIRECTLY hold a dead reference — feed into `errorBearingIds` for the tree roll-up. */
17
+ export function deadBlockIds(refs: DeadRef[]): Set<string> {
18
+ const out = new Set<string>()
19
+ for (const r of refs) if (r.blockId) out.add(r.blockId)
20
+ return out
21
+ }
22
+
23
+ /** Field keys with a dead reference at a location: a block id, or `null` for the record/page root. */
24
+ export function deadFieldsAt(refs: DeadRef[], blockId: string | null): Set<string> {
25
+ const out = new Set<string>()
26
+ for (const r of refs) {
27
+ if (blockId === null ? !r.blockId : r.blockId === blockId) out.add(r.field)
28
+ }
29
+ return out
30
+ }
@@ -0,0 +1,223 @@
1
+ import type { FieldDef } from '../../../core/server/utils/defineCollection'
2
+ import type { SerializedField } from '../../../core/server/utils/serialize-collection'
3
+ import { resolveFieldEmpty } from '../../../ui/app/utils/field-empty'
4
+
5
+ export interface ServerIssue {
6
+ path: (string | number)[]
7
+ message: string
8
+ code?: string
9
+ }
10
+
11
+ export interface MappedErrors {
12
+ fields: Record<string, string>
13
+ form?: string
14
+ }
15
+
16
+ export interface FetchErrorInfo {
17
+ statusCode?: number
18
+ statusMessage?: string
19
+ issues: ServerIssue[]
20
+ }
21
+
22
+ // The wire `SerializedField` is structurally what every Field* widget and `validateField` read
23
+ // (flat type + options record + relation); only its discriminated-union typing differs. Narrow here.
24
+ export function asFieldDef(field: SerializedField): FieldDef {
25
+ return field as unknown as FieldDef
26
+ }
27
+
28
+ // Read an ofetch/H3 error envelope: top-level statusCode/statusMessage, Zod issues nested at err.data.data.
29
+ export function readFetchError(e: unknown): FetchErrorInfo {
30
+ const err = e as { statusCode?: number; statusMessage?: string; data?: { data?: ServerIssue[] } & Record<string, unknown> }
31
+ const raw = err.data?.data ?? err.data
32
+ return {
33
+ statusCode: err.statusCode,
34
+ statusMessage: err.statusMessage,
35
+ issues: Array.isArray(raw) ? (raw as ServerIssue[]) : [],
36
+ }
37
+ }
38
+
39
+ function cloneDefault(value: unknown): unknown {
40
+ return value !== null && typeof value === 'object' ? structuredClone(value) : value
41
+ }
42
+
43
+ function emptyForField(field: SerializedField): unknown {
44
+ switch (field.type) {
45
+ case 'text':
46
+ return ''
47
+ case 'boolean':
48
+ return false
49
+ case 'repeater':
50
+ return []
51
+ case 'choice':
52
+ return field.options?.multiple ? [] : null
53
+ case 'relation':
54
+ return field.relation?.many ? [] : null
55
+ case 'media':
56
+ return field.options?.multiple ? [] : null
57
+ default:
58
+ // A consumer-defined type may register its blank shape (array/object-backed → [] / {}); else null.
59
+ return resolveFieldEmpty(field.type)?.() ?? null
60
+ }
61
+ }
62
+
63
+ /** Build a blank form state: a present (JSON-safe) default wins, else a type-appropriate empty. */
64
+ export function initialValues(fields: Record<string, SerializedField>): Record<string, unknown> {
65
+ const out: Record<string, unknown> = {}
66
+ for (const [name, field] of Object.entries(fields)) {
67
+ out[name] = 'default' in field ? cloneDefault(field.default) : emptyForField(field)
68
+ }
69
+ return out
70
+ }
71
+
72
+ /** Map a 400 body's Zod issues to per-field errors (by path[0]) and a single form-level error (empty path). */
73
+ export function mapServerErrors(issues: ServerIssue[] | null | undefined): MappedErrors {
74
+ const fields: Record<string, string> = {}
75
+ let form: string | undefined
76
+ for (const issue of issues ?? []) {
77
+ const head = issue.path?.[0]
78
+ if (head === undefined || head === null || head === '') {
79
+ if (form === undefined) form = issue.message
80
+ } else {
81
+ const name = String(head)
82
+ if (!(name in fields)) fields[name] = issue.message
83
+ }
84
+ }
85
+ return form === undefined ? { fields } : { fields, form }
86
+ }
87
+
88
+ /** Per-block (keyed by the stable `block.id`), per-field error map for inline block-content errors. */
89
+ export type BlockErrorMap = Record<string, Record<string, string>>
90
+
91
+ interface BlockLike {
92
+ id?: unknown
93
+ props?: Record<string, unknown>
94
+ slots?: Record<string, unknown>
95
+ }
96
+
97
+ function blockId(block: unknown): string | null {
98
+ const id = (block as BlockLike | undefined)?.id
99
+ return typeof id === 'string' && id !== '' ? id : null
100
+ }
101
+
102
+ /**
103
+ * Parse nested block-content issues into a per-block, per-field error map keyed by the block's
104
+ * stable `id`. The server reports a *position* path — `[key, i, 'props', field, …]` for a top-level
105
+ * block, `[key, i, 'slots', name, j, 'props', field, …]` for one nested in a slot (any depth). We
106
+ * walk that path through the submitted `content` to find the target block and pin the error to its
107
+ * id, so it survives a later reorder at any level. First wins per field; an unresolvable path is
108
+ * skipped.
109
+ */
110
+ export function parseBlockErrors(
111
+ issues: ServerIssue[] | null | undefined,
112
+ content: readonly unknown[] | null | undefined,
113
+ key = 'content',
114
+ ): BlockErrorMap {
115
+ const out: BlockErrorMap = {}
116
+ for (const issue of issues ?? []) {
117
+ const path = issue.path ?? []
118
+ if (path[0] !== key) continue
119
+ const hit = resolveBlockField(path.slice(1), content)
120
+ if (!hit) continue
121
+ out[hit.id] ??= {}
122
+ if (!(hit.field in out[hit.id]!)) out[hit.id]![hit.field] = issue.message
123
+ }
124
+ return out
125
+ }
126
+
127
+ /**
128
+ * Walk a position path (after the `content` head) through the block tree to the block whose
129
+ * `props.<field>` the issue targets. Steps over `<index>, 'slots', <name>` segments into nested
130
+ * slot arrays. Returns the target block's stable id + the field name, or null if the path is not a
131
+ * `…props.<field>` leaf or any segment doesn't resolve to an identified block.
132
+ */
133
+ function resolveBlockField(
134
+ path: readonly (string | number)[],
135
+ blocks: readonly unknown[] | null | undefined,
136
+ ): { id: string; field: string } | null {
137
+ let arr: readonly unknown[] | null | undefined = blocks
138
+ for (let i = 0; i < path.length; ) {
139
+ const index = path[i]
140
+ if (typeof index !== 'number' || !Array.isArray(arr)) return null
141
+ const block = arr[index] as BlockLike | undefined
142
+ const next = path[i + 1]
143
+ if (next === 'props') {
144
+ const field = path[i + 2]
145
+ const id = blockId(block)
146
+ return typeof field === 'string' && id !== null ? { id, field } : null
147
+ }
148
+ if (next === 'slots') {
149
+ const name = path[i + 2]
150
+ if (typeof name !== 'string') return null
151
+ const slot = block?.slots?.[name]
152
+ arr = Array.isArray(slot) ? slot : null
153
+ i += 3
154
+ continue
155
+ }
156
+ return null
157
+ }
158
+ return null
159
+ }
160
+
161
+ /**
162
+ * Carry block errors across a `content` change. A block (identified by its stable `id`, top-level or nested in
163
+ * a slot at any depth) keeps its errors while it merely moves — a pure reorder, whether of its slot or of an
164
+ * ancestor — and is dropped when removed; added blocks never gain an error. Reconciliation is PER-FIELD: an
165
+ * edit clears only the edited field's stale message, so a still-invalid sibling field of the same block keeps
166
+ * its error instead of all of them flickering away until the next save.
167
+ */
168
+ export function reconcileBlockErrors(
169
+ errors: BlockErrorMap,
170
+ prev: readonly unknown[] | null | undefined,
171
+ next: readonly unknown[] | null | undefined,
172
+ ): BlockErrorMap {
173
+ const prevById = new Map<string, BlockLike>()
174
+ const nextById = new Map<string, BlockLike>()
175
+ flattenBlocksById(prev, prevById)
176
+ flattenBlocksById(next, nextById)
177
+ const out: BlockErrorMap = {}
178
+ for (const [id, errs] of Object.entries(errors)) {
179
+ const before = prevById.get(id)
180
+ const after = nextById.get(id)
181
+ if (!before || !after) continue // removed → drop
182
+ const beforeProps = (before.props ?? {}) as Record<string, unknown>
183
+ const afterProps = (after.props ?? {}) as Record<string, unknown>
184
+ const kept: Record<string, string> = {}
185
+ for (const [field, message] of Object.entries(errs)) {
186
+ // Keep a field's error only while THAT field's value is unchanged; editing it clears its own stale message.
187
+ if (valuesEqual(beforeProps[field], afterProps[field])) kept[field] = message
188
+ }
189
+ if (Object.keys(kept).length) out[id] = kept
190
+ }
191
+ return out
192
+ }
193
+
194
+ /** Index every block in the tree (top-level and nested in slots) by its stable id. */
195
+ function flattenBlocksById(blocks: readonly unknown[] | null | undefined, into: Map<string, BlockLike>): void {
196
+ for (const block of blocks ?? []) {
197
+ const id = blockId(block)
198
+ if (id !== null) into.set(id, block as BlockLike)
199
+ const slots = (block as BlockLike | undefined)?.slots
200
+ if (slots) for (const arr of Object.values(slots)) if (Array.isArray(arr)) flattenBlocksById(arr, into)
201
+ }
202
+ }
203
+
204
+ /** Structural deep-equality for the value shapes the editor tracks (primitives, arrays, plain objects). */
205
+ export function valuesEqual(a: unknown, b: unknown): boolean {
206
+ if (a === b) return true
207
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false
208
+
209
+ const aArr = Array.isArray(a)
210
+ const bArr = Array.isArray(b)
211
+ if (aArr !== bArr) return false
212
+ if (aArr && bArr) {
213
+ if (a.length !== b.length) return false
214
+ return a.every((item, i) => valuesEqual(item, b[i]))
215
+ }
216
+
217
+ const aRec = a as Record<string, unknown>
218
+ const bRec = b as Record<string, unknown>
219
+ const aKeys = Object.keys(aRec)
220
+ const bKeys = Object.keys(bRec)
221
+ if (aKeys.length !== bKeys.length) return false
222
+ return aKeys.every((k) => Object.prototype.hasOwnProperty.call(bRec, k) && valuesEqual(aRec[k], bRec[k]))
223
+ }
@@ -0,0 +1,26 @@
1
+ import type { PublishStatusData } from '../composables/usePublishStatus'
2
+
3
+ /**
4
+ * The handle `CollectionEditor` exposes via `defineExpose` — the header hosts (`[collection]/[id].vue`
5
+ * and `SingletonEditor.vue`) drive Save / undo / preview and read the status ampel through it. ONE typed
6
+ * contract instead of three drifting inline copies: Vue does not type-check a template `ref="editorRef"`
7
+ * against `defineExpose`, so without a shared interface a renamed/removed key (e.g. `previewUrl`) compiles
8
+ * in both hosts and silently becomes `undefined` at runtime. Type both host refs as `EditorExpose | null`.
9
+ */
10
+ export interface EditorExpose {
11
+ dirty: boolean
12
+ saving: boolean
13
+ canUndo: boolean
14
+ canRedo: boolean
15
+ undo: () => void
16
+ redo: () => void
17
+ hasStatus: boolean
18
+ status: string
19
+ /** The last SAVED publish status (baseline), for the right lamp's draft/published decision. */
20
+ savedStatus: string
21
+ previewUrl: string | null
22
+ pageLike: boolean
23
+ live: PublishStatusData | null
24
+ /** The record's own title (see `recordTitle`), or `''` — the header then shows "Edit {collection} #{id}". */
25
+ recordTitle: string
26
+ }
@@ -0,0 +1,83 @@
1
+ import type { ComputedRef, InjectionKey, Ref } from 'vue'
2
+ import type { FieldDef } from '../../../core/server/utils/defineCollection'
3
+ import type { LayoutNode } from '../../../core/server/utils/field-layout'
4
+ import type { BlockErrorMap } from './edit-form'
5
+ import type { DeadRef } from './dead-refs'
6
+
7
+ /**
8
+ * The form context `CollectionEditor` (the generic shell) provides to whichever editor **body** the
9
+ * registry resolves (fields · blocks · an extension's, e.g. node-graph). It carries the load-bearing
10
+ * `useEditForm` surface a body needs to read and mutate the record, so a body swaps in without a giant
11
+ * prop list — the same provide/inject seam as `recordEditContextKey`, one level up.
12
+ *
13
+ * Provide it BEFORE the shell's top-level `await` (after an await the setup instance is gone and `provide`
14
+ * silently no-ops), exactly like the record-edit context.
15
+ */
16
+ /** Props for the shared page-fields pane. Defined once here so the shell's pre-built bindings and
17
+ * `PageFieldsPane`'s own `defineProps` stay a single type (a widened `Record` would break the v-bind). */
18
+ export interface PageFieldsBindings {
19
+ translatable?: boolean
20
+ collection: string
21
+ id: string
22
+ mode: 'single' | 'multi'
23
+ locale: string
24
+ translations: Record<string, number | null>
25
+ group?: string
26
+ fields: Record<string, FieldDef>
27
+ /** Normalized admin editor layout for the page fields (undefined → one field per row). */
28
+ fieldLayout?: LayoutNode[]
29
+ values: Record<string, unknown>
30
+ errors: Record<string, string>
31
+ deadFields?: Set<string>
32
+ pageLike?: boolean
33
+ seo?: boolean
34
+ status?: boolean
35
+ disabled?: boolean
36
+ }
37
+
38
+ export interface EditorFormContext {
39
+ /** Reactive form values / per-field errors (from `useEditForm`; reactive objects, not refs). */
40
+ values: Record<string, unknown>
41
+ errors: Record<string, string>
42
+ formError: Ref<string>
43
+ blockErrors: Ref<BlockErrorMap>
44
+ /** Route every mutation through this (→ block-error reconciliation, dirty tracking, history). The
45
+ * optional `coalesceAs` lets a caller (e.g. a block-tree structural op) tag its own undo-history key
46
+ * instead of coalescing under the bare field name. */
47
+ setField: (name: string, value: unknown, coalesceAs?: string) => void
48
+ locale: Ref<string>
49
+ translations: Ref<Record<string, number | null>>
50
+ translationGroup: Ref<string | undefined>
51
+ deadRefs: Ref<DeadRef[]>
52
+ saving: Ref<boolean>
53
+ mode: Ref<'multi' | 'single'>
54
+ translatable: Ref<boolean>
55
+ pageLike: Ref<boolean>
56
+ hasSeo: Ref<boolean>
57
+ hasStatus: Ref<boolean>
58
+ blocksAllowed: Ref<string[] | undefined>
59
+ /** Collection (page-root) fields as `FieldDef`s, ready for the field Renderer. */
60
+ renderable: ComputedRef<Record<string, FieldDef>>
61
+ /** The record's localized public URL (saved pageLike record with a path), or null (new/unsaved,
62
+ * non-pageLike). The shell computes it once for the header's open-in-new-tab AND the live preview. */
63
+ previewUrl: ComputedRef<string | null>
64
+ undo: () => void
65
+ redo: () => void
66
+ applyFrom: (row: Record<string, unknown>) => void
67
+ /** Pre-bound props/handlers for the shared page-fields pane, rendered by both the fields and blocks
68
+ * bodies (identical binding, differing only in layout chrome). */
69
+ pageFieldsBindings: ComputedRef<PageFieldsBindings>
70
+ pageFieldsHandlers: Record<string, (...args: never[]) => void>
71
+ /** A body registers how to reveal a validation failure (e.g. the blocks body focuses the offending
72
+ * block). The shell calls it on a failed save, staying agnostic of the body's internals. */
73
+ registerRevealError: (fn: () => void) => void
74
+ }
75
+
76
+ export const editorFormContextKey = Symbol('kestrel.editorForm') as InjectionKey<EditorFormContext>
77
+
78
+ /** Inject the editor form context inside a body component; throws if used outside `CollectionEditor`. */
79
+ export function useEditorFormContext(): EditorFormContext {
80
+ const ctx = inject(editorFormContextKey)
81
+ if (!ctx) throw new Error('[kestrel] editor body used outside CollectionEditor (no editor form context)')
82
+ return ctx
83
+ }
@@ -0,0 +1,20 @@
1
+ import type { Component } from 'vue'
2
+
3
+ /**
4
+ * Registry of admin editor *bodies* keyed by editor type (the `editor` field on a serialized collection).
5
+ * Mirrors the field-widget registry (`layers/ui/app/utils/field-registry.ts`), one level up: instead of
6
+ * picking a widget per field type, it picks the whole editor body per collection.
7
+ *
8
+ * The built-in `fields` / `blocks` bodies register themselves when the admin editor loads (see
9
+ * `register-editors`); extensions add their own via a `*.client.ts` plugin (auto-discovered through
10
+ * `extends`), exactly like `registerFieldComponent`. The frame — the admin rail (layout) and the record
11
+ * header/toolbar + form state (`CollectionEditor` / the host pages) — stays generic; only this body swaps.
12
+ */
13
+ export const editorComponents: Record<string, Component> = {}
14
+
15
+ export const resolveCollectionEditor = (type: string): Component | undefined => editorComponents[type]
16
+
17
+ /** Register an editor body for a type. Mutates the singleton; a later call overrides the same type. */
18
+ export function registerCollectionEditor(type: string, component: Component): void {
19
+ editorComponents[type] = component
20
+ }
@@ -0,0 +1,11 @@
1
+ import type { SerializedField } from '../../../core/server/utils/serialize-collection'
2
+
3
+ /**
4
+ * The jsKey (wire key) for a field — NOT a DB column name. The server marks single relation/media fields
5
+ * with `single` (stored as a `<name>Id` FK column, e.g. `author` -> `authorId`); everything else keeps the
6
+ * field name. The editor binds widgets by field name but must read/write rows, request bodies and list
7
+ * queries by this key — reading the server flag instead of re-deriving the rule (which silently drifts).
8
+ */
9
+ export function jsKey(name: string, field: SerializedField): string {
10
+ return field.single ? `${name}Id` : name
11
+ }