@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,141 @@
1
+ import type { SerializedCollection, SerializedField } from '../../../core/server/utils/serialize-collection'
2
+ import { fieldFilterKind, META_FILTER_KIND, type FilterKind } from '../../../core/app/utils/filter-ops'
3
+ import { jsKey } from './field-keys'
4
+
5
+ export type ListColumnType = 'meta' | 'field' | 'translations' | 'deadRefs'
6
+
7
+ export interface ListColumn {
8
+ /** The wire/row key: used to read `row[key]` and as the sort/filter field. The translations column
9
+ * uses the server sidecar key `$translations`, which is never sent as a sort/filter (not a real column). */
10
+ key: string
11
+ type: ListColumnType
12
+ /** Field display name (only for `type:'field'`) — used directly as the column label. */
13
+ name?: string
14
+ /** i18n key for the column label (meta + translations columns). */
15
+ labelKey?: string
16
+ field?: SerializedField
17
+ sortable: boolean
18
+ filterable: boolean
19
+ /** The column's filter kind (present iff `filterable`) — drives the operator set + typed value control. */
20
+ filterKind?: FilterKind
21
+ }
22
+
23
+ /** The sidecar key the server attaches per row for translation status (mirrors `$media`). */
24
+ export const TRANSLATIONS_KEY = '$translations'
25
+
26
+ /** The sidecar key the server attaches per row when any reference it holds points at a dead/unpublished
27
+ * target (the derived stale-reference warning). Like `$translations`, never a real sort/filter column. */
28
+ export const DEAD_REFS_KEY = '$hasDeadRefs'
29
+
30
+ /** Whether a collection holds any field/block that can reference another record — gates the dead-reference
31
+ * column (mirrors the server's `collectionMayReference`, over the serialized schema). */
32
+ export function hasReferenceFields(schema: SerializedCollection): boolean {
33
+ if (schema.blocks.enabled) return true
34
+ if (schema.seo) return true // the `seo` system column carries a media ref (the social image)
35
+ return Object.values(schema.fields).some(fieldMayReference)
36
+ }
37
+ function fieldMayReference(field: SerializedField): boolean {
38
+ switch (field.type) {
39
+ case 'relation':
40
+ case 'media':
41
+ case 'link':
42
+ case 'richtext':
43
+ return true
44
+ case 'repeater':
45
+ return Object.values((field.options?.fields ?? {}) as Record<string, SerializedField>).some(fieldMayReference)
46
+ default:
47
+ return false
48
+ }
49
+ }
50
+
51
+ const META_LABEL: Record<string, string> = {
52
+ id: 'list.col.id',
53
+ path: 'list.col.slug',
54
+ status: 'list.col.status',
55
+ createdAt: 'list.col.createdAt',
56
+ updatedAt: 'list.col.updatedAt',
57
+ }
58
+
59
+ /** Whether a field can be meaningfully SORTED as a single scalar DB column. JSON-backed fields
60
+ * (richtext/json/repeater/link, multi-value choice/relation/media) cannot. (Filterability is a separate,
61
+ * richer question — see `fieldFilterKind`: richtext and the multi-value fields ARE filterable, via
62
+ * substring / JSON-array membership, even though they are not sortable.) */
63
+ function isSortableField(f: SerializedField): boolean {
64
+ switch (f.type) {
65
+ case 'richtext':
66
+ case 'json':
67
+ case 'repeater':
68
+ case 'link':
69
+ return false
70
+ case 'choice':
71
+ return !f.options?.multiple
72
+ case 'relation':
73
+ case 'media':
74
+ return !!f.single
75
+ default:
76
+ return true // text, number, boolean, datetime
77
+ }
78
+ }
79
+
80
+ /** Whether a field column is shown by default — only plain scalar display types. Heavier/structured fields
81
+ * (richtext, json, repeater, link, relation, media, multi-choice) are available but off by default. */
82
+ function isDefaultFieldColumn(f: SerializedField): boolean {
83
+ switch (f.type) {
84
+ case 'text':
85
+ case 'number':
86
+ case 'boolean':
87
+ case 'datetime':
88
+ return true
89
+ case 'choice':
90
+ return !f.options?.multiple
91
+ default:
92
+ return false
93
+ }
94
+ }
95
+
96
+ /** A meta/system column, filterable iff it has a FilterKind (id/path/status/timestamps all do). */
97
+ function metaColumn(key: string): ListColumn {
98
+ const filterKind = META_FILTER_KIND[key]
99
+ return { key, type: 'meta', labelKey: META_LABEL[key], sortable: true, filterable: !!filterKind, ...(filterKind ? { filterKind } : {}) }
100
+ }
101
+
102
+ /** Every column a collection list can offer, in display order: id · fields · slug · status · timestamps ·
103
+ * translations. The user picks which are visible (see `defaultVisibleKeys` / `resolveVisibleColumns`). */
104
+ export function availableColumns(schema: SerializedCollection): ListColumn[] {
105
+ const cols: ListColumn[] = [metaColumn('id')]
106
+ for (const [name, field] of Object.entries(schema.fields)) {
107
+ const filterKind = fieldFilterKind(field)
108
+ cols.push({ key: jsKey(name, field), type: 'field', name, field, sortable: isSortableField(field), filterable: filterKind !== null, ...(filterKind ? { filterKind } : {}) })
109
+ }
110
+ if (schema.pageLike) cols.push(metaColumn('path'))
111
+ if (schema.status) cols.push(metaColumn('status'))
112
+ cols.push(metaColumn('createdAt'))
113
+ cols.push(metaColumn('updatedAt'))
114
+ if (schema.translatable && schema.mode === 'multi') {
115
+ cols.push({ key: TRANSLATIONS_KEY, type: 'translations', labelKey: 'list.translations', sortable: false, filterable: false })
116
+ }
117
+ // A compact warning column (a triangle on rows holding a stale reference) — only where refs are possible.
118
+ if (hasReferenceFields(schema)) {
119
+ cols.push({ key: DEAD_REFS_KEY, type: 'deadRefs', labelKey: 'list.deadRefs', sortable: false, filterable: false })
120
+ }
121
+ return cols
122
+ }
123
+
124
+ /** The default visible set: everything except `id` and the heavier field types — i.e. the scalar fields
125
+ * plus slug, status, timestamps and translations. */
126
+ export function defaultVisibleKeys(schema: SerializedCollection): string[] {
127
+ return availableColumns(schema)
128
+ .filter((c) => {
129
+ if (c.key === 'id') return false
130
+ if (c.type === 'field') return isDefaultFieldColumn(c.field!)
131
+ return true
132
+ })
133
+ .map((c) => c.key)
134
+ }
135
+
136
+ /** Project the requested keys onto the available columns: preserves the canonical column order and silently
137
+ * drops keys that no longer exist (e.g. a removed field still listed in a saved preference). */
138
+ export function resolveVisibleColumns(available: ListColumn[], keys: string[]): ListColumn[] {
139
+ const wanted = new Set(keys)
140
+ return available.filter((c) => wanted.has(c.key))
141
+ }
@@ -0,0 +1,96 @@
1
+ import { FILTER_RE, isFilterOp, opAllowed, type FilterOp } from '../../../core/app/utils/filter-ops'
2
+ import { clampPerPage } from '../../../core/app/utils/list-limits'
3
+ import type { ListColumn } from './list-columns'
4
+
5
+ // The page-size bounds live in ONE shared module (core/app/utils/list-limits) so the client list, the
6
+ // per-page picker and the server crud cap can never drift; `toQuery` clamps through it below.
7
+
8
+ /** One active filter clause in the admin UI: the chosen operator + its (string) value. Kept per-column
9
+ * (`Record<field, FilterCell>`) — the per-column panel shows one clause per column. */
10
+ export interface FilterCell {
11
+ op: FilterOp
12
+ value: string
13
+ }
14
+
15
+ export interface ListState {
16
+ sort: string | null
17
+ page: number
18
+ perPage: number
19
+ filter: Record<string, FilterCell>
20
+ }
21
+
22
+ /** UI state -> the `$fetch` query object the list endpoint expects. An `eq` clause stays the bare
23
+ * `filter[field]` key (so existing URLs round-trip); any other operator serializes as `filter[field][op]`.
24
+ * Empty-valued clauses are dropped. `perPage` is clamped. */
25
+ export function toQuery(state: ListState): Record<string, string | number> {
26
+ const q: Record<string, string | number> = {
27
+ page: Math.max(1, Math.floor(state.page)),
28
+ perPage: clampPerPage(state.perPage),
29
+ }
30
+ if (state.sort) q.sort = state.sort
31
+ for (const [field, cell] of Object.entries(state.filter)) {
32
+ if (cell.value === '' || cell.value == null) continue
33
+ q[cell.op === 'eq' ? `filter[${field}]` : `filter[${field}][${cell.op}]`] = cell.value
34
+ }
35
+ return q
36
+ }
37
+
38
+ /** Pick the first usable scalar from a URL query value. Real `route.query` values are strings (or arrays of
39
+ * strings for a repeated key — we take the first, so `filter[x]=a&filter[x]=b` keeps the FIRST clause); a
40
+ * round-trip through `toQuery` may hand back a number (page/perPage), so coerce that too. Anything else
41
+ * (null / object) yields `undefined`. */
42
+ function firstStr(v: unknown): string | undefined {
43
+ const s = Array.isArray(v) ? v[0] : v
44
+ if (typeof s === 'string') return s
45
+ if (typeof s === 'number') return String(s)
46
+ return undefined
47
+ }
48
+
49
+ /** The pure inverse of `toQuery`: read committed list state back OUT of a URL query. It NEVER throws — every
50
+ * invalid or hostile key is DROPPED so the caller's defaults apply (a junk `page`/`perPage`, an unknown or
51
+ * non-sortable `sort` field, an unknown/unfilterable filter column, or an operator not allowed for that
52
+ * column's kind). A repeated `filter[field]` keeps the FIRST clause (the admin model holds one clause per
53
+ * column). Absent/invalid keys are OMITTED, so `parseListQuery(toQuery(state), columns)` deep-equals the
54
+ * normalized `state`. */
55
+ export function parseListQuery(query: Record<string, unknown>, columns: ListColumn[]): Partial<ListState> {
56
+ const out: Partial<ListState> = {}
57
+
58
+ const p = firstStr(query.page)
59
+ if (p != null) { const n = Number(p); if (Number.isFinite(n) && n >= 1) out.page = Math.floor(n) }
60
+
61
+ const pp = firstStr(query.perPage)
62
+ if (pp) { const n = Number(pp); if (Number.isFinite(n)) out.perPage = clampPerPage(n) } // garbage → omit → cookie
63
+
64
+ const s = firstStr(query.sort)
65
+ if (s) { const base = s.startsWith('-') ? s.slice(1) : s; if (columns.some((c) => c.key === base && c.sortable)) out.sort = s } // unknown/non-sortable → omit → default
66
+
67
+ const filter: Record<string, FilterCell> = {}
68
+ for (const [k, raw] of Object.entries(query)) {
69
+ const m = FILTER_RE.exec(k)
70
+ if (!m) continue
71
+ const field = m[1]!
72
+ if (field in filter) continue // one clause per column: the first key for this field wins
73
+ const op = m[2] ?? 'eq'
74
+ const value = firstStr(raw)
75
+ if (value == null || value === '') continue // empty value → drop
76
+ if (!isFilterOp(op)) continue // unknown operator token → drop
77
+ const col = columns.find((c) => c.key === field && c.filterable)
78
+ if (!col) continue // unknown / unfilterable column → drop
79
+ if (!opAllowed(col.filterKind ?? 'text', op)) continue // op disallowed for the column kind → drop
80
+ filter[field] = { op, value }
81
+ }
82
+ if (Object.keys(filter).length) out.filter = filter
83
+
84
+ return out
85
+ }
86
+
87
+ /** Toggle a column between ascending (`field`) and descending (`-field`); a new field starts ascending. */
88
+ export function toggleSort(current: string | null, field: string): string {
89
+ return current === field ? `-${field}` : field
90
+ }
91
+
92
+ export function sortDirection(current: string | null, field: string): 'asc' | 'desc' | null {
93
+ if (current === field) return 'asc'
94
+ if (current === `-${field}`) return 'desc'
95
+ return null
96
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Whether a rail nav item linking to `base` (e.g. "/admin/posts") should read as active for the
3
+ * current route `path`. Inclusive of descendant routes so a collection stays lit while editing one
4
+ * of its records (/admin/posts/1) — Vue Router's record-based active match does not, because the
5
+ * list and record pages are flat sibling routes that share no matched record.
6
+ */
7
+ export function isNavItemActive(path: string, base: string): boolean {
8
+ return path === base || path.startsWith(`${base}/`)
9
+ }
@@ -0,0 +1,288 @@
1
+ import type { SerializedBlock, SerializedField } from '../../../core/server/utils/serialize-collection'
2
+ import type { ResolvedMedia } from '../../../media/app/composables/useMediaResolver'
3
+ import { collectRichtextRefs, resolveRichtextLinks } from '../../../fields/app/utils/richtext-links'
4
+
5
+ // Single source of truth for the resolved-media shape lives in the media layer (admin → media).
6
+ export type { ResolvedMedia }
7
+
8
+ interface BlockNode {
9
+ id?: string
10
+ type: string
11
+ props?: Record<string, unknown>
12
+ [k: string]: unknown
13
+ }
14
+
15
+ /** The sub-field schema of a `repeater` SerializedField, or null. Serialized as `options.fields` (a
16
+ * name→SerializedField map, recursively) by `serializeField`. */
17
+ function repeaterFields(field: SerializedField): Record<string, SerializedField> | null {
18
+ if (field.type !== 'repeater') return null
19
+ const sub = (field.options as { fields?: Record<string, SerializedField> } | undefined)?.fields
20
+ return sub && typeof sub === 'object' ? sub : null
21
+ }
22
+
23
+ /** A block's `slots` bag from the data (a name→child-array map), or null if it has none. */
24
+ function slotsOf(node: BlockNode): Record<string, unknown> | null {
25
+ const s = (node as { slots?: unknown }).slots
26
+ return s && typeof s === 'object' && !Array.isArray(s) ? (s as Record<string, unknown>) : null
27
+ }
28
+
29
+ /**
30
+ * Rebuild a block's `slots` bag, recursing each child array through `recurse`. Identity-preserving: a
31
+ * slot whose populated children are each `===` the originals keeps the original array ref, and when NO
32
+ * slot changed the whole bag is reported unchanged (`null`) — so the live preview never churns block
33
+ * identities on an unaffected subtree. The `b === arr[i]` short-circuit is load-bearing for reactivity.
34
+ */
35
+ function mapSlots(
36
+ slots: Record<string, unknown> | null,
37
+ recurse: (arr: unknown[]) => BlockNode[],
38
+ ): Record<string, unknown> | null {
39
+ if (!slots) return null
40
+ const rebuilt: Record<string, unknown> = {}
41
+ let changed = false
42
+ for (const [name, arr] of Object.entries(slots)) {
43
+ if (!Array.isArray(arr)) {
44
+ rebuilt[name] = arr
45
+ continue
46
+ }
47
+ const populated = recurse(arr)
48
+ const same = populated.length === arr.length && populated.every((b, i) => b === arr[i])
49
+ rebuilt[name] = same ? arr : populated
50
+ if (!same) changed = true
51
+ }
52
+ return changed ? rebuilt : null
53
+ }
54
+
55
+ export interface LinkRef { collection: string; id: number }
56
+
57
+ /** The `{collection, id}` of an internal LinkValue, or null for external/email/tel/empty. */
58
+ function internalRefOf(value: unknown): LinkRef | null {
59
+ if (!value || typeof value !== 'object') return null
60
+ const v = value as Record<string, unknown>
61
+ return v.type === 'internal' && typeof v.collection === 'string' && typeof v.id === 'number'
62
+ ? { collection: v.collection, id: v.id }
63
+ : null
64
+ }
65
+
66
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
67
+ // Field-bag recursion — the client mirror of the server field-tree walker
68
+ // (`layers/fields/server/utils/field-populate.ts`). A "bag" is a block's props OR a repeater entry;
69
+ // both key media/link/richtext values BARE (props key-mode). Every collector/populator descends
70
+ // `repeater` sub-fields so a repeater-nested media/link resolves in the preview exactly as it does in
71
+ // the generated page — the two must stay in lock-step.
72
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
73
+
74
+ /** Media ids referenced by a value-bag (a block's props or a repeater entry), recursing repeater entries. */
75
+ function collectMediaFromBag(fields: Record<string, SerializedField>, bag: Record<string, unknown>, ids: Set<number>): void {
76
+ for (const [key, field] of Object.entries(fields)) {
77
+ if (field.type === 'media') {
78
+ const v = bag[key]
79
+ if (field.options?.multiple) {
80
+ if (Array.isArray(v)) for (const x of v) if (typeof x === 'number') ids.add(x)
81
+ } else if (typeof v === 'number') {
82
+ ids.add(v)
83
+ }
84
+ } else {
85
+ const sub = repeaterFields(field)
86
+ const entries = sub ? bag[key] : null
87
+ if (sub && Array.isArray(entries)) {
88
+ for (const e of entries) if (e && typeof e === 'object') collectMediaFromBag(sub, e as Record<string, unknown>, ids)
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ /** Every media id referenced by block props (incl. repeater entries), deduped and walked into slot
95
+ * children — for one batched resolve call. */
96
+ export function collectMediaIds(blocks: unknown, byType: Record<string, SerializedBlock>): number[] {
97
+ const ids = new Set<number>()
98
+ const walk = (nodes: unknown): void => {
99
+ if (!Array.isArray(nodes)) return
100
+ for (const node of nodes as BlockNode[]) {
101
+ const def = node?.type ? byType[node.type] : undefined
102
+ if (def && node.props) collectMediaFromBag(def.fields, node.props, ids)
103
+ const slots = node ? slotsOf(node) : null
104
+ if (slots) for (const arr of Object.values(slots)) walk(arr)
105
+ }
106
+ }
107
+ walk(blocks)
108
+ return [...ids]
109
+ }
110
+
111
+ /**
112
+ * Populate media in a value-bag: attach a `$media` sibling for its direct media fields and recurse each
113
+ * repeater entry. Identity-preserving — returns the SAME bag ref when nothing resolved and reuses each
114
+ * unchanged repeater array/entry — so the preview doesn't churn identities on every recompute.
115
+ */
116
+ function populateMediaInBag(
117
+ fields: Record<string, SerializedField>,
118
+ bag: Record<string, unknown>,
119
+ resolve: (id: number) => ResolvedMedia | null,
120
+ ): Record<string, unknown> {
121
+ let media: Record<string, unknown> | null = null
122
+ let repeaters: Record<string, unknown> | null = null
123
+ for (const [key, field] of Object.entries(fields)) {
124
+ if (field.type === 'media') {
125
+ const v = bag[key]
126
+ if (field.options?.multiple) {
127
+ if (Array.isArray(v)) {
128
+ const resolved = v.filter((x): x is number => typeof x === 'number').map(resolve).filter((m): m is ResolvedMedia => !!m)
129
+ if (resolved.length) (media ??= {})[key] = resolved
130
+ }
131
+ } else if (typeof v === 'number') {
132
+ const m = resolve(v)
133
+ if (m) (media ??= {})[key] = m
134
+ }
135
+ } else {
136
+ const sub = repeaterFields(field)
137
+ const entries = sub ? bag[key] : null
138
+ if (sub && Array.isArray(entries)) {
139
+ const populated = entries.map((e) => (e && typeof e === 'object' ? populateMediaInBag(sub, e as Record<string, unknown>, resolve) : e))
140
+ if (!populated.every((e, i) => e === entries[i])) (repeaters ??= {})[key] = populated
141
+ }
142
+ }
143
+ }
144
+ if (!media && !repeaters) return bag
145
+ const out = { ...bag }
146
+ if (repeaters) Object.assign(out, repeaters)
147
+ if (media) out.$media = media
148
+ return out
149
+ }
150
+
151
+ /**
152
+ * Client mirror of the server media populator: attach a `$media` bag to each block's props (and each
153
+ * repeater entry) so the public `BlockRenderer` and `Blocks*` display components render images in the
154
+ * live preview. Pure; `resolve` is the (reactive) id→media lookup. Recurses slot children AND repeater
155
+ * entries. A block whose whole subtree needs no media (media-free, unknown type, empty/unchanged slots,
156
+ * or not-yet-resolved ids) is returned **by reference** so unchanged branches keep their refs all the
157
+ * way up.
158
+ */
159
+ export function populateBlocksMedia(
160
+ blocks: unknown,
161
+ byType: Record<string, SerializedBlock>,
162
+ resolve: (id: number) => ResolvedMedia | null,
163
+ ): BlockNode[] {
164
+ if (!Array.isArray(blocks)) return []
165
+ return (blocks as BlockNode[]).map((node) => {
166
+ const def = node?.type ? byType[node.type] : undefined
167
+ const nextProps = def && node.props ? populateMediaInBag(def.fields, node.props, resolve) : node.props
168
+
169
+ // Rebuild slots only where a child subtree actually changed; otherwise reuse the original array
170
+ // ref so an empty or media-free slot doesn't force a new parent object.
171
+ const nextSlots = mapSlots(node ? slotsOf(node) : null, (arr) => populateBlocksMedia(arr, byType, resolve))
172
+
173
+ if (nextProps === node.props && !nextSlots) return node
174
+ const out: BlockNode = { ...node }
175
+ if (nextProps !== node.props) out.props = nextProps
176
+ if (nextSlots) out.slots = nextSlots
177
+ return out
178
+ })
179
+ }
180
+
181
+ /** Internal-link + richtext-marker refs in a value-bag (a block's props or a repeater entry), recursing
182
+ * repeater entries. */
183
+ function collectLinkRefsFromBag(fields: Record<string, SerializedField>, bag: Record<string, unknown>, add: (ref: LinkRef) => void): void {
184
+ for (const [key, field] of Object.entries(fields)) {
185
+ if (field.type === 'link') {
186
+ const ref = internalRefOf(bag[key])
187
+ if (ref) add(ref)
188
+ } else if (field.type === 'richtext') {
189
+ for (const ref of collectRichtextRefs(bag[key] as string | undefined)) add(ref)
190
+ } else {
191
+ const sub = repeaterFields(field)
192
+ const entries = sub ? bag[key] : null
193
+ if (sub && Array.isArray(entries)) {
194
+ for (const e of entries) if (e && typeof e === 'object') collectLinkRefsFromBag(sub, e as Record<string, unknown>, add)
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ /** Every internal-link `{collection,id}` referenced by block props (incl. repeater entries + richtext
201
+ * markers), deduped, walked into slots — for one batched resolve call (mirror of `collectMediaIds`). */
202
+ export function collectLinkRefs(blocks: unknown, byType: Record<string, SerializedBlock>): LinkRef[] {
203
+ const seen = new Set<string>()
204
+ const refs: LinkRef[] = []
205
+ const add = (ref: LinkRef): void => {
206
+ const k = `${ref.collection}:${ref.id}`
207
+ if (!seen.has(k)) {
208
+ seen.add(k)
209
+ refs.push(ref)
210
+ }
211
+ }
212
+ const walk = (nodes: unknown): void => {
213
+ if (!Array.isArray(nodes)) return
214
+ for (const node of nodes as BlockNode[]) {
215
+ const def = node?.type ? byType[node.type] : undefined
216
+ if (def && node.props) collectLinkRefsFromBag(def.fields, node.props, add)
217
+ const slots = node ? slotsOf(node) : null
218
+ if (slots) for (const arr of Object.values(slots)) walk(arr)
219
+ }
220
+ }
221
+ walk(blocks)
222
+ return refs
223
+ }
224
+
225
+ /**
226
+ * Resolve internal links + richtext markers in a value-bag, recursing repeater entries. Identity-
227
+ * preserving — a link/richtext value that needs no change keeps its ref, an unchanged repeater array/
228
+ * entry keeps its ref, and a bag with nothing to change is returned as-is (lazily cloned only on the
229
+ * first actual change).
230
+ */
231
+ function populateLinksInBag(
232
+ fields: Record<string, SerializedField>,
233
+ bag: Record<string, unknown>,
234
+ resolveHref: (collection: string, id: number) => string | null,
235
+ ): Record<string, unknown> {
236
+ let next: Record<string, unknown> | null = null
237
+ for (const [key, field] of Object.entries(fields)) {
238
+ if (field.type === 'link') {
239
+ const ref = internalRefOf(bag[key])
240
+ if (!ref) continue
241
+ const href = resolveHref(ref.collection, ref.id)
242
+ const current = bag[key] as Record<string, unknown>
243
+ if (href == null || current.href === href) continue
244
+ ;(next ??= { ...bag })[key] = { ...current, href }
245
+ } else if (field.type === 'richtext') {
246
+ const current = bag[key]
247
+ if (typeof current !== 'string') continue
248
+ const resolved = resolveRichtextLinks(current, resolveHref)
249
+ if (resolved === current) continue // no markers (or already identical) → keep ref
250
+ ;(next ??= { ...bag })[key] = resolved
251
+ } else {
252
+ const sub = repeaterFields(field)
253
+ const entries = sub ? bag[key] : null
254
+ if (sub && Array.isArray(entries)) {
255
+ const populated = entries.map((e) => (e && typeof e === 'object' ? populateLinksInBag(sub, e as Record<string, unknown>, resolveHref) : e))
256
+ if (!populated.every((e, i) => e === entries[i])) (next ??= { ...bag })[key] = populated
257
+ }
258
+ }
259
+ }
260
+ return next ?? bag
261
+ }
262
+
263
+ /**
264
+ * Client mirror of the server link populator: replace each block's internal-link field value with a
265
+ * cloned `{...value, href}` (and rewrite richtext markers) so the live preview's `KestrelLink` renders
266
+ * the real path instead of `'#'`. external/email/tel and unresolved internals are left untouched.
267
+ * Recurses slot children AND repeater entries. Identity-preserving like `populateBlocksMedia` — a block
268
+ * (and its slot/repeater subtree) whose links need no change is returned by reference.
269
+ */
270
+ export function populateBlocksLinks(
271
+ blocks: unknown,
272
+ byType: Record<string, SerializedBlock>,
273
+ resolveHref: (collection: string, id: number) => string | null,
274
+ ): BlockNode[] {
275
+ if (!Array.isArray(blocks)) return []
276
+ return (blocks as BlockNode[]).map((node) => {
277
+ const def = node?.type ? byType[node.type] : undefined
278
+ const nextProps = def && node.props ? populateLinksInBag(def.fields, node.props, resolveHref) : node.props
279
+
280
+ const nextSlots = mapSlots(node ? slotsOf(node) : null, (arr) => populateBlocksLinks(arr, byType, resolveHref))
281
+
282
+ if (nextProps === node.props && !nextSlots) return node
283
+ const out: BlockNode = { ...node }
284
+ if (nextProps !== node.props) out.props = nextProps
285
+ if (nextSlots) out.slots = nextSlots
286
+ return out
287
+ })
288
+ }
@@ -0,0 +1,62 @@
1
+ import { contentMessage, selectedMessage, type PreviewBlockNode } from '../../../public/app/utils/preview-protocol'
2
+
3
+ /**
4
+ * Editor→iframe send half of the live-preview channel (the receive half is the public
5
+ * `KestrelPreviewBridge`). Content updates are coalesced to one post per animation frame — a typing
6
+ * burst produces a single message carrying the LATEST tree (read at fire time), so the preview stays
7
+ * keystroke-live (≤ one frame behind) without flooding the channel. Selection changes are tiny and
8
+ * latency-sensitive → posted immediately.
9
+ *
10
+ * `target` is a getter because the iframe's contentWindow appears on load and is replaced on every
11
+ * navigation/reload; posts silently no-op while it is gone. `origin` is passed as postMessage's
12
+ * targetOrigin so a frame that navigated cross-origin can never receive editor content.
13
+ */
14
+ export interface PreviewSender {
15
+ sendContent: (blocks: PreviewBlockNode[]) => void
16
+ sendSelected: (selectedId: string | null) => void
17
+ dispose: () => void
18
+ }
19
+
20
+ export function createPreviewSender(
21
+ target: () => Window | null | undefined,
22
+ origin: string,
23
+ // Read at FIRE time (like the blocks), never captured at schedule time: an immediate selection post
24
+ // can overtake a pending content frame, and a stale captured id in that frame would then revert the
25
+ // bridge's newer selection (content messages carry the selection authoritatively).
26
+ getSelected: () => string | null,
27
+ ): PreviewSender {
28
+ let rafId: number | null = null
29
+ let latest: PreviewBlockNode[] | null = null
30
+
31
+ const post = (msg: unknown) => target()?.postMessage(msg, origin)
32
+
33
+ const fire = () => {
34
+ rafId = null
35
+ if (!latest) return
36
+ // JSON round-trip: detaches Vue reactive proxies (structured clone chokes on some exotic wrappers)
37
+ // and guarantees the payload is the same JSON-safe shape the DB round-trips. Block trees are small;
38
+ // at one clone per frame this is well under a millisecond.
39
+ post(contentMessage(JSON.parse(JSON.stringify(latest)), getSelected()))
40
+ latest = null
41
+ }
42
+
43
+ return {
44
+ sendContent(blocks) {
45
+ latest = blocks
46
+ rafId ??= requestAnimationFrame(fire)
47
+ },
48
+ sendSelected(selectedId) {
49
+ post(selectedMessage(selectedId))
50
+ },
51
+ dispose() {
52
+ if (rafId !== null) cancelAnimationFrame(rafId)
53
+ rafId = null
54
+ latest = null
55
+ },
56
+ }
57
+ }
58
+
59
+ /** Parent-side trust guard: accept a message only from OUR origin AND our preview iframe's window. */
60
+ export function acceptsFrameEvent(event: MessageEvent, frameWindow: Window | null, origin: string): boolean {
61
+ return frameWindow !== null && event.origin === origin && event.source === frameWindow
62
+ }
@@ -0,0 +1,80 @@
1
+ import type { IconName } from '../../../ui/app/utils/icons'
2
+
3
+ /**
4
+ * Pure geometry for the page-builder preview toolbar (device presets + automatic scale-to-fit + custom
5
+ * W×H). Kept framework-free so it's node-testable — the Vue component (`BlockPreview.vue`) only wires refs
6
+ * to these.
7
+ */
8
+
9
+ /** A viewport axis: a fixed CSS px, or `'auto'` (fills the available pane at the current scale). */
10
+ export type Dim = number | 'auto'
11
+
12
+ export interface ViewportPreset {
13
+ key: 'desktop' | 'tablet' | 'mobile'
14
+ /** Toolbar glyph (registry key). */
15
+ icon: IconName
16
+ /** i18n label key (the resolution is shown alongside it in the tooltip). */
17
+ label: string
18
+ /** Reference viewport width/height — the iframe's REAL size (scaled only visually); `'auto'` fills. */
19
+ w: Dim
20
+ h: Dim
21
+ }
22
+
23
+ export type PresetKey = ViewportPreset['key'] | 'custom'
24
+
25
+ /** Bounds shared by the two numeric W×H inputs. */
26
+ export const DIM_MIN = 320
27
+ export const WIDTH_MAX = 3840
28
+ export const HEIGHT_MAX = 4320
29
+
30
+ /**
31
+ * The three quick-select presets. Desktop keeps the config-driven breakpoint WIDTH (`kestrel.config.ts` →
32
+ * `preview.desktopWidth`, default 1440) but an `'auto'` HEIGHT so the frame fills the pane vertically and
33
+ * only the iframe's own document scrolls. Tablet/mobile are canonical fixed device viewports (iPad
34
+ * portrait / iPhone) so `100vh`/sticky/lazy behave like the real device, scaled down to fit both axes.
35
+ */
36
+ export function PRESETS(desktopWidth: number): ViewportPreset[] {
37
+ return [
38
+ { key: 'desktop', icon: 'monitor', label: 'preview.device.desktop', w: desktopWidth, h: 'auto' },
39
+ { key: 'tablet', icon: 'tablet', label: 'preview.device.tablet', w: 768, h: 1024 },
40
+ { key: 'mobile', icon: 'smartphone', label: 'preview.device.mobile', w: 390, h: 844 },
41
+ ]
42
+ }
43
+
44
+ /** The preset a (w,h) exactly matches, else `'custom'` — drives which quick-select button is highlighted. */
45
+ export function matchPreset(w: Dim, h: Dim, presets: ViewportPreset[]): PresetKey {
46
+ return presets.find((p) => p.w === w && p.h === h)?.key ?? 'custom'
47
+ }
48
+
49
+ /**
50
+ * Uniform (no-distortion) scale: fit each FIXED axis into its available space, take the min, and **cap at
51
+ * 1×** (never upscale). An `'auto'` axis doesn't constrain — it fills instead (see `resolveDim`). So
52
+ * desktop (width fixed, height auto) is width-limited; tablet/mobile (both fixed) fit whichever axis is
53
+ * tighter and letterbox the other. Returns 1 before the stage is measured (`avail ≤ 0`) so nothing jumps.
54
+ */
55
+ export function fitScale(availW: number, availH: number, w: Dim, h: Dim): number {
56
+ const ratios = [1]
57
+ if (typeof w === 'number' && w > 0 && availW > 0) ratios.push(availW / w)
58
+ if (typeof h === 'number' && h > 0 && availH > 0) ratios.push(availH / h)
59
+ return Math.min(...ratios)
60
+ }
61
+
62
+ /**
63
+ * Resolve a `Dim` to real px. A fixed value passes straight through; an `'auto'` axis fills the available
64
+ * space at the current scale (`avail / scale`, so the SCALED px exactly equals `avail`). Falls back to
65
+ * `fallback` when unmeasured (SSR/tests: `avail ≤ 0` or `scale ≤ 0`) so the frame still has a size.
66
+ */
67
+ export function resolveDim(avail: number, dim: Dim, scale: number, fallback: number): number {
68
+ if (dim !== 'auto') return dim
69
+ return avail > 0 && scale > 0 ? Math.round(avail / scale) : fallback
70
+ }
71
+
72
+ /**
73
+ * Commit a numeric-input value: clamp into `[min,max]` and floor to an integer px. Returns `null` for
74
+ * empty/non-finite input so the caller keeps the previous value (the field can hold un-parsable editing
75
+ * text whose model is momentarily null — we must not reset the viewport to a default then).
76
+ */
77
+ export function clampDim(n: number | null | undefined, min: number, max: number): number | null {
78
+ if (n == null || !Number.isFinite(n)) return null
79
+ return Math.min(max, Math.max(min, Math.floor(n)))
80
+ }