@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,133 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue'
3
+ import { blockEditKey } from '../utils/block-edit-context'
4
+ import {
5
+ parseEditorMessage, readyMessage, selectMessage,
6
+ type PreviewBlockNode,
7
+ } from '../utils/preview-protocol'
8
+
9
+ /**
10
+ * The iframe half of the editor live preview. Wraps the page's BlockRenderer via a scoped slot: it
11
+ * exposes the LIVE block tree (the editor's in-memory content, received over postMessage) — falling
12
+ * back to the server-rendered `blocks` prop until the first message — and provides the block-edit
13
+ * context so the renderer emits clickable, highlightable markers. Mounted only in preview mode
14
+ * (`?kestrel-preview=1` + admin, or the dedicated fallback page), and lazy-loaded there, so none of
15
+ * this ships in the normal public bundle.
16
+ *
17
+ * SECURITY: messages are trusted only when `event.origin` is OUR origin AND `event.source` is the
18
+ * embedding parent window — a third-party page that frames the site (or is framed by it) can never
19
+ * inject preview content. Outbound posts target our own origin only.
20
+ */
21
+ const props = defineProps<{ blocks?: unknown[] }>()
22
+
23
+ // Live tree: null until the editor's first content message, then always the message payload.
24
+ // shallowRef — each message replaces the whole tree; BlockRenderer keys by block id.
25
+ const live = shallowRef<PreviewBlockNode[] | null>(null)
26
+ const blocks = computed<unknown[]>(() => live.value ?? (Array.isArray(props.blocks) ? props.blocks : []))
27
+
28
+ // Selection: local state, synced both ways (click here → editor tree; tree click → highlight here).
29
+ const selectedId = ref<string | null>(null)
30
+ provide(blockEditKey, {
31
+ selectedId,
32
+ select: (id: string) => {
33
+ selectedId.value = id // highlight immediately; the editor echoes the selection back
34
+ post(selectMessage(id))
35
+ },
36
+ })
37
+
38
+ function post(msg: unknown) {
39
+ // Posting to self when opened directly (parent === window) is harmless — our own listener ignores
40
+ // frame→editor types — and keeping it unconditional keeps the handshake testable.
41
+ window.parent.postMessage(msg, window.location.origin)
42
+ }
43
+
44
+ function onMessage(e: MessageEvent) {
45
+ if (e.origin !== window.location.origin || e.source !== window.parent) return
46
+ const m = parseEditorMessage(e.data)
47
+ if (!m) return
48
+ if (m.kestrel === 'preview:content') {
49
+ const prev = selectedId.value
50
+ live.value = m.blocks
51
+ selectedId.value = m.selectedId
52
+ // A selection made while the frame was still loading arrives with the first content push (not as a
53
+ // separate selected message) — scroll to it like a live selection would.
54
+ if (m.selectedId && m.selectedId !== prev) void scrollToSelected(m.selectedId)
55
+ } else {
56
+ selectedId.value = m.selectedId
57
+ void scrollToSelected(m.selectedId)
58
+ }
59
+ }
60
+
61
+ // Bring an externally-selected block into view (tree-pane click in the editor).
62
+ async function scrollToSelected(id: string | null) {
63
+ if (!id) return
64
+ await nextTick()
65
+ const el = Array.from(document.querySelectorAll('[data-block-id]')).find((n) => n.getAttribute('data-block-id') === id)
66
+ // The marker is `display: contents` (no box of its own), so scroll its rendered child instead.
67
+ ;(el?.firstElementChild ?? el)?.scrollIntoView({ block: 'nearest' })
68
+ }
69
+
70
+ // The preview is a canvas, not a browsing context: swallow every link navigation (consumer layout nav,
71
+ // CTA anchors) AND form submission in the CAPTURE phase, so neither native navigation nor Vue Router
72
+ // (which respects defaultPrevented) can steer the iframe away from the previewed page. Block clicks
73
+ // still select — preventDefault does not stop propagation, so the marker handlers keep working.
74
+ // (Programmatic navigation from consumer script can't be intercepted here; the editor host snaps the
75
+ // frame back on load when its location no longer matches the preview URL.)
76
+ function onClickCapture(e: MouseEvent) {
77
+ const a = (e.target as Element | null)?.closest?.('a[href]')
78
+ if (a) e.preventDefault()
79
+ }
80
+ function onSubmitCapture(e: Event) {
81
+ e.preventDefault()
82
+ }
83
+
84
+ onMounted(() => {
85
+ window.addEventListener('message', onMessage)
86
+ document.addEventListener('click', onClickCapture, true)
87
+ document.addEventListener('submit', onSubmitCapture, true)
88
+ // Handshake: tell the editor this document (fresh mount OR reload) is ready for the current state.
89
+ post(readyMessage())
90
+ })
91
+ onUnmounted(() => {
92
+ window.removeEventListener('message', onMessage)
93
+ document.removeEventListener('click', onClickCapture, true)
94
+ document.removeEventListener('submit', onSubmitCapture, true)
95
+ })
96
+ </script>
97
+
98
+ <template>
99
+ <slot :blocks="blocks" />
100
+ </template>
101
+
102
+ <style lang="scss">
103
+ // Selection chrome for the marker wrappers BlockRenderer emits when the edit context is present.
104
+ // This document is the CONSUMER's public page (no admin design tokens), so the colors are concrete
105
+ // values with overridable custom properties — a consumer can theme them from their own CSS.
106
+ //
107
+ // The marker itself is `display: contents`: it generates NO box, so the block root stays the parent's
108
+ // real flex/grid ITEM exactly as on the published site — this restores flex/grid-item props, `gap`, and
109
+ // breakpoints keyed to the block root, which a wrapper box would break by displacing the root. (It does
110
+ // NOT change `>` combinators: the marker element is still in the DOM tree, so `parent > block-root`
111
+ // rules match the same as before — unchanged either way.) The highlight chrome therefore lives on the
112
+ // block root via `> *`. Events are unaffected — the marker div is still in the DOM/event path.
113
+ .block-edit-marker {
114
+ display: contents;
115
+
116
+ > * {
117
+ cursor: pointer;
118
+ outline: 2px solid transparent;
119
+ outline-offset: -2px;
120
+ transition: outline-color 120ms ease;
121
+ }
122
+ &:hover > * {
123
+ outline-color: var(--kestrel-preview-hover, rgba(100, 116, 139, 0.55));
124
+ }
125
+ &--selected > * {
126
+ // Selection ring drawn with `outline` (not box-shadow) so a consumer block that carries its own root
127
+ // box-shadow keeps it while selected — a box-shadow ring here would replace it. Slightly thicker than
128
+ // the hover ring for contrast on dark/indigo consumer sites.
129
+ outline-color: var(--kestrel-preview-accent, #4f46e5);
130
+ outline-width: 3px;
131
+ }
132
+ }
133
+ </style>
@@ -0,0 +1,24 @@
1
+ import type { ComputedRef } from 'vue'
2
+
3
+ export interface PublicPageState {
4
+ collection: string | null
5
+ page: Record<string, unknown> | null
6
+ }
7
+
8
+ /** The record rendered by the catch-all page, shared with layouts (language menu & co.); the page and a
9
+ * layout share no other channel, so the catch-all mirrors its resolved `{ collection, page }` here. */
10
+ export const usePublicPageState = () =>
11
+ useState<PublicPageState>('public-page', () => ({ collection: null, page: null }))
12
+
13
+ /** Content locale of the current public route, derived from the URL prefix scheme. Reads the locale set
14
+ * from `runtimeConfig.public` via the public-layer `pickPublicLocales` (NOT admin's `useContentLocales`,
15
+ * which would make the public site depend on `layers/admin`). */
16
+ export function usePublicLocale(): ComputedRef<string> {
17
+ const route = useRoute()
18
+ const { locales, primary, prefixPrimary } = pickPublicLocales(
19
+ useRuntimeConfig().public as { locales?: unknown; primaryLocale?: unknown; prefixPrimary?: unknown },
20
+ )
21
+ return computed(
22
+ () => resolvePublicRoute(route.path.split('/').filter(Boolean), locales, primary, prefixPrimary).locale,
23
+ )
24
+ }
@@ -0,0 +1,12 @@
1
+ <script setup lang="ts">
2
+ import type { NuxtError } from '#app'
3
+ defineProps<{ error: NuxtError }>()
4
+ </script>
5
+
6
+ <template>
7
+ <main>
8
+ <h1>{{ error.statusCode }}</h1>
9
+ <p>{{ error.statusCode === 404 ? 'Page not found.' : 'Something went wrong.' }}</p>
10
+ <NuxtLink to="/">Home</NuxtLink>
11
+ </main>
12
+ </template>
@@ -0,0 +1,5 @@
1
+ <template>
2
+ <main>
3
+ <slot />
4
+ </main>
5
+ </template>
@@ -0,0 +1,157 @@
1
+ <script setup lang="ts">
2
+ interface RenderedPage {
3
+ title?: string
4
+ seo?: {
5
+ title?: string
6
+ description?: string
7
+ noindex?: boolean
8
+ $media?: { image?: { src: string; width: number | null; height: number | null } | null }
9
+ }
10
+ content?: unknown[]
11
+ status?: string
12
+ }
13
+
14
+ const route = useRoute()
15
+ // Locale routing is config-driven (KESTREL_LOCALES / KESTREL_PRIMARY_LOCALE → runtimeConfig.public), NOT
16
+ // hardcoded to en/de — otherwise every non-default locale's prerendered pages 404. Mirrors the server's
17
+ // prefix scheme (primary unprefixed, others `/<locale>/…`) via the pure `resolvePublicRoute`.
18
+ const { locales, primary, prefixPrimary } = pickPublicLocales(
19
+ useRuntimeConfig().public as { locales?: unknown; primaryLocale?: unknown; prefixPrimary?: unknown },
20
+ )
21
+ const { locale, path } = resolvePublicRoute(route.path.split('/').filter(Boolean), locales, primary, prefixPrimary)
22
+
23
+ // Resolve across ALL pageLike collections (not just `pages`) via the generic route resolver, so a
24
+ // consumer's own pageLike collection renders to static HTML too. `useRequestFetch` forwards the
25
+ // incoming request's cookies to the SSR sub-fetch, so an authenticated admin's session reaches
26
+ // `/api/route` and a draft renders at its real URL (the live preview); anonymous + the static render
27
+ // stay published-only (the handler enforces it).
28
+ const requestFetch = useRequestFetch()
29
+ const { data: resolved } = await useAsyncData(`page:${locale}:${path}`, () =>
30
+ requestFetch('/api/route', {
31
+ query: { path, locale },
32
+ }).then((r) => r as {
33
+ collection: string | null
34
+ page: (RenderedPage & Record<string, unknown>) | null
35
+ alternates?: Array<{ locale: string; path: string }>
36
+ }),
37
+ )
38
+ const page = computed(() => resolved.value?.page ?? null)
39
+
40
+ // The layout (language menu & co.) needs the resolved record and its collection; pages and layouts share
41
+ // no other channel, so mirror the fetch result into the shared state — reactively, so client-side
42
+ // navigations update it too.
43
+ const pageState = usePublicPageState()
44
+ watchEffect(() => {
45
+ pageState.value = {
46
+ collection: resolved.value?.collection ?? null,
47
+ page: resolved.value?.page ?? null,
48
+ }
49
+ })
50
+
51
+ // A draft only ever resolves here for an authenticated admin (anonymous + the static render are
52
+ // published-only), so its presence is an unambiguous "you are previewing an unpublished page" signal.
53
+ const isDraftPreview = computed(() => page.value?.status === 'draft')
54
+
55
+ // Editor live-preview mode: `?kestrel-preview=1` AND an authenticated admin session. The session is
56
+ // checked server-side (cookies forwarded, same seam as the draft fetch above) so SSR and hydration
57
+ // agree on which branch renders; an anonymous visitor with the query param gets the normal page.
58
+ // The bridge component is Lazy — its chunk (message channel + marker chrome) loads only in this mode.
59
+ const previewRequested = route.query[PREVIEW_QUERY] === '1'
60
+ const { data: previewSession } = previewRequested
61
+ ? await useAsyncData('kestrel-preview-session', () =>
62
+ requestFetch('/api/auth/session').catch(() => ({ authenticated: false })))
63
+ : { data: ref<{ authenticated: boolean } | null>(null) }
64
+ const previewActive = computed(() => previewRequested && previewSession.value?.authenticated === true)
65
+
66
+ // The site root stays reachable (empty document) before a home page is published;
67
+ // any other unmatched path is a genuine 404.
68
+ if (!page.value && path !== '/') throw createError({ statusCode: 404, statusMessage: 'Page not found' })
69
+
70
+ // Canonical / Open Graph / twitter card / hreflang: pure model (`buildPageHead`) fed from the resolved
71
+ // page + the public runtime config. Absolute-URL emissions (canonical, og:url, hreflang, relative
72
+ // og:image) require a configured siteUrl and degrade away without one.
73
+ const publicRc = useRuntimeConfig().public as { siteUrl?: string; siteName?: string }
74
+ const seo = page.value?.seo ?? {}
75
+ const head = buildPageHead({
76
+ siteUrl: typeof publicRc.siteUrl === 'string' ? publicRc.siteUrl : '',
77
+ siteName: typeof publicRc.siteName === 'string' ? publicRc.siteName : '',
78
+ path,
79
+ locale,
80
+ primary,
81
+ prefixPrimary,
82
+ title: seo.title || page.value?.title,
83
+ description: seo.description || undefined,
84
+ image: seo.$media?.image ?? null,
85
+ alternates: resolved.value?.alternates ?? [],
86
+ })
87
+
88
+ // Set the document language from the resolved locale so prerendered /de pages ship <html lang="de">
89
+ // (WCAG 2.2 SC 3.1.1); without this every page would carry the build-default language.
90
+ // Point AI agents at the generated llms.txt (alongside the robots.txt comment + sitemap) on every page.
91
+ useHead({
92
+ htmlAttrs: { lang: locale },
93
+ link: [
94
+ ...(head.canonical ? [{ rel: 'canonical', href: head.canonical }] : []),
95
+ ...head.links,
96
+ { rel: 'alternate', type: 'text/markdown', href: '/llms.txt', title: 'llms.txt' },
97
+ ],
98
+ })
99
+ useSeoMeta({
100
+ title: seo.title || page.value?.title,
101
+ description: seo.description || undefined,
102
+ robots: seo.noindex ? 'noindex, nofollow' : undefined,
103
+ ogTitle: head.meta.ogTitle,
104
+ ogDescription: head.meta.ogDescription,
105
+ ogUrl: head.meta.ogUrl,
106
+ ogType: head.meta.ogType,
107
+ ogSiteName: head.meta.ogSiteName,
108
+ ogImage: head.meta.ogImage,
109
+ ogImageWidth: head.meta.ogImageWidth,
110
+ ogImageHeight: head.meta.ogImageHeight,
111
+ twitterCard: head.meta.twitterCard,
112
+ })
113
+ </script>
114
+
115
+ <template>
116
+ <article>
117
+ <!-- Only ever shown to an authenticated admin previewing an unpublished page (drafts never resolve
118
+ for anonymous visitors or the static render), so it never ships to the public/static site.
119
+ Suppressed inside the editor preview iframe — the editor's own status ampel covers it. -->
120
+ <div v-if="isDraftPreview && !previewActive" class="kestrel-draft-badge" role="status">
121
+ <span class="kestrel-draft-badge__dot" aria-hidden="true" />
122
+ Draft preview — not published
123
+ </div>
124
+ <!-- Editor preview: the bridge swaps in the editor's live (unsaved) tree over postMessage and makes
125
+ blocks selectable; the saved content renders until the first message. Normal path unchanged. -->
126
+ <LazyKestrelPreviewBridge v-if="previewActive" :blocks="(page?.content as any[]) ?? []" v-slot="{ blocks }">
127
+ <BlockRenderer :blocks="(blocks as any[])" />
128
+ </LazyKestrelPreviewBridge>
129
+ <BlockRenderer v-else :blocks="(page?.content as any[]) ?? []" />
130
+ </article>
131
+ </template>
132
+
133
+ <style scoped>
134
+ /* Self-contained (no token dependency) so it renders identically whatever layout the consumer ships. */
135
+ .kestrel-draft-badge {
136
+ position: fixed;
137
+ inset-block-start: 0.75rem;
138
+ inset-inline-end: 0.75rem;
139
+ z-index: 2147483647;
140
+ display: inline-flex;
141
+ align-items: center;
142
+ gap: 0.4rem;
143
+ padding: 0.35rem 0.7rem;
144
+ border-radius: 999px;
145
+ background: #1e293b;
146
+ color: #fff;
147
+ font: 600 0.78rem/1 ui-sans-serif, system-ui, sans-serif;
148
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
149
+ pointer-events: none;
150
+ }
151
+ .kestrel-draft-badge__dot {
152
+ width: 0.5rem;
153
+ height: 0.5rem;
154
+ border-radius: 50%;
155
+ background: #fbbf24;
156
+ }
157
+ </style>
@@ -0,0 +1,34 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Dedicated editor live-preview page for records WITHOUT a public URL — a new/unsaved record, a blank
4
+ * slug, or a blocks-enabled non-pageLike collection. It renders the real public app (default layout,
5
+ * the consumer's CSS/fonts/breakpoints) around an empty BlockRenderer that the editor fills over the
6
+ * postMessage bridge. Saved pageLike records preview at their REAL URL instead (higher fidelity);
7
+ * this page is the graceful fallback so previews never regress to "save first".
8
+ *
9
+ * Admin-gated server-side: `useRequestFetch` forwards the incoming cookies to `/api/auth/session`
10
+ * (the same seam the catch-all uses for draft rendering), and anonymous requests get a 404 — the
11
+ * page's existence is not advertised. It is never linked, prerendered, or published (the publisher
12
+ * enumerates DB page rows only), and carries noindex as belt-and-braces.
13
+ */
14
+ const requestFetch = useRequestFetch()
15
+ const { data: session } = await useAsyncData('kestrel-preview-session', () =>
16
+ requestFetch('/api/auth/session').catch(() => ({ authenticated: false })),
17
+ )
18
+ if (!session.value?.authenticated) throw createError({ statusCode: 404, statusMessage: 'Page not found' })
19
+
20
+ // Content locale from the editor (drives <html lang> for faithful per-locale rendering).
21
+ const route = useRoute()
22
+ const lang = typeof route.query.locale === 'string' && route.query.locale ? route.query.locale : undefined
23
+ useHead({
24
+ title: 'Preview',
25
+ meta: [{ name: 'robots', content: 'noindex, nofollow' }],
26
+ ...(lang ? { htmlAttrs: { lang } } : {}),
27
+ })
28
+ </script>
29
+
30
+ <template>
31
+ <KestrelPreviewBridge v-slot="{ blocks }">
32
+ <BlockRenderer :blocks="(blocks as any[])" />
33
+ </KestrelPreviewBridge>
34
+ </template>
@@ -0,0 +1,16 @@
1
+ import type { InjectionKey, Ref } from 'vue'
2
+
3
+ // Optional editing context for the public BlockRenderer. On the generated public site the renderer is
4
+ // used WITHOUT this context (inject defaults to null) → it renders plain markup, byte-identical to
5
+ // before. The admin preview *provides* it, which makes the renderer wrap each block in a clickable
6
+ // marker carrying its id — so clicking a block in the preview selects it and the selected block is
7
+ // highlighted. This keeps the SSG render path untouched while letting the same component double as a
8
+ // selection-aware editor preview.
9
+ export interface BlockEditContext {
10
+ /** The currently selected block id (or null for the page root). */
11
+ selectedId: Ref<string | null>
12
+ /** Select a block by id (from a preview click). */
13
+ select: (id: string) => void
14
+ }
15
+
16
+ export const blockEditKey: InjectionKey<BlockEditContext> = Symbol('kestrel.blockEdit')
@@ -0,0 +1,46 @@
1
+ import type { LinkValue } from '../../../core/server/utils/defineCollection'
2
+
3
+ // The server link populator (depth > 0) resolves an internal link's localized target path and attaches
4
+ // it as `href`. Until that populator runs (or for a not-yet-resolved value), internal links fall back
5
+ // to '#'. external / email / tel are self-contained and need no resolution.
6
+ type WithHref<T> = T & { href?: string }
7
+
8
+ /** Turn any `LinkValue` into an `href`. external → the url; email → `mailto:`; tel → `tel:`; internal →
9
+ * the populated `href` (localized target path), or '#' when unresolved. Empty string for no value. */
10
+ export function linkToHref(value: WithHref<LinkValue> | null | undefined): string {
11
+ if (!value) return ''
12
+ switch (value.type) {
13
+ case 'external':
14
+ return value.url
15
+ case 'email':
16
+ return `mailto:${value.email}`
17
+ case 'tel':
18
+ return `tel:${value.tel}`
19
+ case 'internal': {
20
+ const base = value.href ?? '#'
21
+ // An unresolved internal (draft/missing target) stays '#'; don't fabricate a fragment-only link
22
+ // out of its hash. A resolved path gets `#<hash>` appended when present.
23
+ return base !== '#' && value.hash ? `${base}#${value.hash}` : base
24
+ }
25
+ default:
26
+ return ''
27
+ }
28
+ }
29
+
30
+ /** A human label for a link: its explicit `label`, else a scheme-appropriate default (the target). */
31
+ export function linkLabel(value: WithHref<LinkValue> | null | undefined): string {
32
+ if (!value) return ''
33
+ if (value.label) return value.label
34
+ switch (value.type) {
35
+ case 'external':
36
+ return value.url
37
+ case 'email':
38
+ return value.email
39
+ case 'tel':
40
+ return value.tel
41
+ case 'internal':
42
+ return value.href ?? ''
43
+ default:
44
+ return ''
45
+ }
46
+ }
@@ -0,0 +1,84 @@
1
+ import { localePath } from '../../../core/app/utils/locale-path'
2
+
3
+ export interface PageHeadImage { src: string; width?: number | null; height?: number | null }
4
+ export interface PageHeadAlternate { locale: string; path: string }
5
+
6
+ export interface PageHeadInput {
7
+ /** Absolute site origin ('' when unconfigured — canonical/hreflang/og:url are omitted then). */
8
+ siteUrl: string
9
+ siteName?: string
10
+ /** The page's unprefixed path + its locale (the emitter locale-prefixes via `localePath`). */
11
+ path: string
12
+ locale: string
13
+ primary: string
14
+ prefixPrimary: boolean
15
+ title?: string
16
+ description?: string
17
+ image?: PageHeadImage | null
18
+ /** Published translation siblings (self included); [] / omitted → no hreflang set. */
19
+ alternates?: PageHeadAlternate[]
20
+ }
21
+
22
+ /** A type alias, not an interface: unhead's `link` entries require an implicit `data-*` index signature,
23
+ * which TS derives for aliases but never for interfaces. */
24
+ export type PageHeadLink = { rel: 'alternate'; hreflang: string; href: string }
25
+
26
+ export interface PageHead {
27
+ canonical?: string
28
+ links: PageHeadLink[]
29
+ meta: {
30
+ ogTitle?: string
31
+ ogDescription?: string
32
+ ogUrl?: string
33
+ ogType: 'website'
34
+ ogSiteName?: string
35
+ ogImage?: string
36
+ ogImageWidth?: number
37
+ ogImageHeight?: number
38
+ twitterCard: 'summary' | 'summary_large_image'
39
+ }
40
+ }
41
+
42
+ const isAbsolute = (src: string) => /^https?:\/\//.test(src)
43
+
44
+ /**
45
+ * The head model for a rendered public page: canonical, Open Graph / twitter card, and the hreflang
46
+ * alternate set — pure, so the emission rules are unit-testable outside a Nuxt context. Everything that
47
+ * requires an absolute URL (canonical, og:url, hreflang, a relative og:image) degrades away when
48
+ * `siteUrl` is unconfigured; `x-default` points at the primary-locale variant when it is present
49
+ * (mirrors the sitemap's rules, so page heads and sitemap never disagree).
50
+ */
51
+ export function buildPageHead(input: PageHeadInput): PageHead {
52
+ const base = input.siteUrl ? input.siteUrl.replace(/\/+$/, '') : ''
53
+ const abs = (path: string, locale: string) => `${base}${localePath(path, locale, input.primary, input.prefixPrimary)}`
54
+ const canonical = base ? abs(input.path, input.locale) : undefined
55
+
56
+ const links: PageHeadLink[] = []
57
+ if (base && input.alternates?.length) {
58
+ for (const a of input.alternates) links.push({ rel: 'alternate', hreflang: a.locale, href: abs(a.path, a.locale) })
59
+ const primary = input.alternates.find((a) => a.locale === input.primary)
60
+ if (primary) links.push({ rel: 'alternate', hreflang: 'x-default', href: abs(primary.path, primary.locale) })
61
+ }
62
+
63
+ let ogImage: string | undefined
64
+ if (input.image?.src) {
65
+ if (isAbsolute(input.image.src)) ogImage = input.image.src
66
+ else if (base) ogImage = `${base}${input.image.src}`
67
+ }
68
+
69
+ return {
70
+ canonical,
71
+ links,
72
+ meta: {
73
+ ogTitle: input.title || undefined,
74
+ ogDescription: input.description || undefined,
75
+ ogUrl: canonical,
76
+ ogType: 'website',
77
+ ogSiteName: input.siteName || undefined,
78
+ ogImage,
79
+ ogImageWidth: ogImage && input.image?.width ? input.image.width : undefined,
80
+ ogImageHeight: ogImage && input.image?.height ? input.image.height : undefined,
81
+ twitterCard: ogImage ? 'summary_large_image' : 'summary',
82
+ },
83
+ }
84
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The postMessage protocol between the admin block editor (parent window) and the live-preview iframe
3
+ * (the real public page in preview mode). Pure — shared by both sides and unit-tested here; the DOM
4
+ * wiring lives in the editor's preview host and the public `KestrelPreviewBridge`.
5
+ *
6
+ * Every message carries a `kestrel: 'preview:*'` discriminant so foreign postMessage traffic (HMR,
7
+ * browser extensions) can never be mistaken for ours. Parsing is direction-specific: the frame only
8
+ * accepts editor→frame types and vice versa — a reflected/echoed message parses to `null`.
9
+ * SECURITY: parsing validates SHAPE only. Both sides must additionally check `event.origin` against
10
+ * their own origin and `event.source` against the expected window before trusting a message —
11
+ * otherwise any page that embeds the site (or is embedded by it) could inject content.
12
+ */
13
+
14
+ /** Query flag that switches the public page into preview mode (value `1`). */
15
+ export const PREVIEW_QUERY = 'kestrel-preview'
16
+ /** Dedicated preview page for records without a public URL (new/unsaved, non-pageLike). Admin-gated. */
17
+ export const PREVIEW_FALLBACK_PATH = '/__kestrel/preview'
18
+
19
+ /** The block-node shape the renderer consumes (already media/link-populated by the editor). */
20
+ export interface PreviewBlockNode { id?: string; type: string; props?: Record<string, unknown>; slots?: Record<string, unknown> }
21
+
22
+ /** editor → frame: the live (populated) block tree + the current selection. */
23
+ export interface PreviewContentMessage { kestrel: 'preview:content'; blocks: PreviewBlockNode[]; selectedId: string | null }
24
+ /** editor → frame: selection changed (tree click) — highlight + scroll into view. */
25
+ export interface PreviewSelectedMessage { kestrel: 'preview:selected'; selectedId: string | null }
26
+ /** frame → editor: the bridge mounted (or the iframe reloaded) — (re)send the full state. */
27
+ export interface PreviewReadyMessage { kestrel: 'preview:ready' }
28
+ /** frame → editor: a block was clicked in the preview. */
29
+ export interface PreviewSelectMessage { kestrel: 'preview:select'; id: string }
30
+
31
+ export type EditorToFrameMessage = PreviewContentMessage | PreviewSelectedMessage
32
+ export type FrameToEditorMessage = PreviewReadyMessage | PreviewSelectMessage
33
+
34
+ export const contentMessage = (blocks: PreviewBlockNode[], selectedId: string | null): PreviewContentMessage =>
35
+ ({ kestrel: 'preview:content', blocks, selectedId })
36
+ export const selectedMessage = (selectedId: string | null): PreviewSelectedMessage =>
37
+ ({ kestrel: 'preview:selected', selectedId })
38
+ export const readyMessage = (): PreviewReadyMessage => ({ kestrel: 'preview:ready' })
39
+ export const selectMessage = (id: string): PreviewSelectMessage => ({ kestrel: 'preview:select', id })
40
+
41
+ function tagOf(data: unknown): string | null {
42
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) return null
43
+ const tag = (data as { kestrel?: unknown }).kestrel
44
+ return typeof tag === 'string' ? tag : null
45
+ }
46
+
47
+ /** Parse a message the FRAME received (editor → frame); `null` for anything else. */
48
+ export function parseEditorMessage(data: unknown): EditorToFrameMessage | null {
49
+ const tag = tagOf(data)
50
+ if (tag === 'preview:content') {
51
+ const m = data as PreviewContentMessage
52
+ return Array.isArray(m.blocks) && (m.selectedId === null || typeof m.selectedId === 'string') ? m : null
53
+ }
54
+ if (tag === 'preview:selected') {
55
+ const m = data as PreviewSelectedMessage
56
+ return m.selectedId === null || typeof m.selectedId === 'string' ? m : null
57
+ }
58
+ return null
59
+ }
60
+
61
+ /** Parse a message the EDITOR received (frame → editor); `null` for anything else. */
62
+ export function parseFrameMessage(data: unknown): FrameToEditorMessage | null {
63
+ const tag = tagOf(data)
64
+ if (tag === 'preview:ready') return data as PreviewReadyMessage
65
+ if (tag === 'preview:select') {
66
+ const m = data as PreviewSelectMessage
67
+ return typeof m.id === 'string' ? m : null
68
+ }
69
+ return null
70
+ }
71
+
72
+ /**
73
+ * The iframe URL for the editor's preview pane: the record's real public URL when it has one (saved
74
+ * pageLike record — server-populated first paint, drafts included for the admin session), else the
75
+ * dedicated fallback page (new/unsaved records, non-pageLike collections). Both carry the preview flag.
76
+ */
77
+ export function previewSrc(publicUrl: string | null, locale: string): string {
78
+ if (publicUrl) return `${publicUrl}?${PREVIEW_QUERY}=1`
79
+ const loc = locale ? `&locale=${encodeURIComponent(locale)}` : ''
80
+ return `${PREVIEW_FALLBACK_PATH}?${PREVIEW_QUERY}=1${loc}`
81
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Resolve the request locale + the lookup path from the URL path segments, matching the server routing
3
+ * (`localePath`): every non-primary configured locale is prefixed `/<locale>/…`; the primary locale is
4
+ * unprefixed unless `prefixPrimary` is set (then `/<primary>/…` too). A leading segment counts as a locale
5
+ * prefix when it is a configured locale (the primary only when `prefixPrimary`); anything else is part of
6
+ * the path under the primary locale. Pure (no runtime-config access) so it is unit-testable and not
7
+ * en/de-hardcoded.
8
+ */
9
+ /** Config shape read off `runtimeConfig.public` (a subset). */
10
+ export interface PublicLocaleConfig {
11
+ locales?: unknown
12
+ primaryLocale?: unknown
13
+ prefixPrimary?: unknown
14
+ }
15
+
16
+ /**
17
+ * Resolve the configured content locales from a `runtimeConfig.public`-shaped object. Pure, so the
18
+ * fallback/primary logic is unit-testable without a Nuxt context, and lives in the PUBLIC layer so
19
+ * public code (the catch-all page + `usePublicLocale`) never has to reach into `layers/admin` for it.
20
+ * The `['en']` fallback is a safe last resort for an absent/empty config (nuxt.config always sets
21
+ * `public.locales`); primary-only is a safer guess than assuming a second locale exists.
22
+ */
23
+ export function pickPublicLocales(pub: PublicLocaleConfig): {
24
+ locales: string[]
25
+ primary: string
26
+ prefixPrimary: boolean
27
+ } {
28
+ const locales = Array.isArray(pub.locales) && pub.locales.length ? pub.locales.map(String) : ['en']
29
+ const primary =
30
+ typeof pub.primaryLocale === 'string' && locales.includes(pub.primaryLocale) ? pub.primaryLocale : locales[0]!
31
+ return { locales, primary, prefixPrimary: pub.prefixPrimary === true }
32
+ }
33
+
34
+ export function resolvePublicRoute(
35
+ segments: string[],
36
+ locales: string[],
37
+ primaryLocale: string,
38
+ prefixPrimary = false,
39
+ ): { locale: string; path: string } {
40
+ const head = segments[0]?.toLowerCase()
41
+ const prefixed = head !== undefined && locales.includes(head) && (head !== primaryLocale || prefixPrimary)
42
+ const locale = prefixed ? head : primaryLocale
43
+ const rest = prefixed ? segments.slice(1) : segments
44
+ const path = ('/' + rest.join('/')).toLowerCase().replace(/\/+$/, '') || '/'
45
+ return { locale, path }
46
+ }