@michaelthielemann/kestrel 4.1.3 → 5.0.2

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 (342) hide show
  1. package/README.md +26 -201
  2. package/dist/boot.d.ts +45 -0
  3. package/dist/boot.js +284 -0
  4. package/dist/cast.d.ts +3 -0
  5. package/dist/cast.js +6 -0
  6. package/dist/catalogue.d.ts +11 -0
  7. package/dist/catalogue.js +1 -0
  8. package/dist/cli.d.ts +2 -0
  9. package/dist/cli.js +55 -0
  10. package/dist/context.d.ts +62 -0
  11. package/dist/context.js +61 -0
  12. package/dist/dataflow.d.ts +3 -0
  13. package/dist/dataflow.js +65 -0
  14. package/dist/defineConfig.d.ts +162 -0
  15. package/dist/defineConfig.js +42 -0
  16. package/dist/defineContract.d.ts +12 -0
  17. package/dist/defineContract.js +16 -0
  18. package/dist/defineModule.d.ts +75 -0
  19. package/dist/defineModule.js +9 -0
  20. package/dist/definePipeline.d.ts +7 -0
  21. package/dist/definePipeline.js +11 -0
  22. package/dist/errors.d.ts +26 -0
  23. package/dist/errors.js +48 -0
  24. package/dist/index.d.ts +19 -0
  25. package/dist/index.js +14 -0
  26. package/dist/load.d.ts +7 -0
  27. package/dist/load.js +79 -0
  28. package/dist/logger.d.ts +18 -0
  29. package/dist/logger.js +36 -0
  30. package/dist/registry.d.ts +14 -0
  31. package/dist/registry.js +87 -0
  32. package/dist/result.d.ts +19 -0
  33. package/dist/result.js +19 -0
  34. package/dist/runner.d.ts +29 -0
  35. package/dist/runner.js +133 -0
  36. package/dist/schema.d.ts +7 -0
  37. package/dist/schema.js +142 -0
  38. package/dist/sort.d.ts +2 -0
  39. package/dist/sort.js +50 -0
  40. package/dist/testing/runPipeline.d.ts +9 -0
  41. package/dist/testing/runPipeline.js +22 -0
  42. package/dist/triggers/allowlist.d.ts +9 -0
  43. package/dist/triggers/allowlist.js +49 -0
  44. package/dist/triggers/cron.d.ts +10 -0
  45. package/dist/triggers/cron.js +44 -0
  46. package/dist/triggers/cronMatch.d.ts +10 -0
  47. package/dist/triggers/cronMatch.js +68 -0
  48. package/dist/triggers/http.d.ts +59 -0
  49. package/dist/triggers/http.js +300 -0
  50. package/package.json +25 -151
  51. package/NOTICE +0 -12
  52. package/kestrel.config.ts +0 -28
  53. package/layers/access/nuxt.config.ts +0 -5
  54. package/layers/access/server/middleware/00.ip-allowlist.ts +0 -52
  55. package/layers/access/server/middleware/access-guard.ts +0 -32
  56. package/layers/access/server/types.d.ts +0 -7
  57. package/layers/admin/app/components/AdminAccount.vue +0 -212
  58. package/layers/admin/app/components/AdminNav.vue +0 -44
  59. package/layers/admin/app/components/BlockFields.vue +0 -67
  60. package/layers/admin/app/components/BlockPreview.vue +0 -413
  61. package/layers/admin/app/components/BlockTree.vue +0 -433
  62. package/layers/admin/app/components/BlocksBody.vue +0 -163
  63. package/layers/admin/app/components/CollectionDeleteDialog.vue +0 -38
  64. package/layers/admin/app/components/CollectionEditor.vue +0 -324
  65. package/layers/admin/app/components/CollectionList.vue +0 -429
  66. package/layers/admin/app/components/CollectionListBulkBar.vue +0 -62
  67. package/layers/admin/app/components/CollectionListColumnsPanel.vue +0 -37
  68. package/layers/admin/app/components/CollectionListFilterPanel.vue +0 -145
  69. package/layers/admin/app/components/CollectionListPager.vue +0 -70
  70. package/layers/admin/app/components/CollectionListTable.vue +0 -347
  71. package/layers/admin/app/components/EditorStatus.vue +0 -211
  72. package/layers/admin/app/components/EditorUnsupported.vue +0 -28
  73. package/layers/admin/app/components/FieldsBody.vue +0 -23
  74. package/layers/admin/app/components/LocaleBar.vue +0 -107
  75. package/layers/admin/app/components/PageFields.vue +0 -129
  76. package/layers/admin/app/components/PageFieldsPane.vue +0 -39
  77. package/layers/admin/app/components/SeoFields.vue +0 -210
  78. package/layers/admin/app/components/SingletonEditor.vue +0 -93
  79. package/layers/admin/app/composables/useAuth.ts +0 -68
  80. package/layers/admin/app/composables/useBlockTree.ts +0 -95
  81. package/layers/admin/app/composables/useBlocks.ts +0 -7
  82. package/layers/admin/app/composables/useCachedResource.ts +0 -35
  83. package/layers/admin/app/composables/useCollectionOps.ts +0 -102
  84. package/layers/admin/app/composables/useCollections.ts +0 -6
  85. package/layers/admin/app/composables/useContentLocales.ts +0 -23
  86. package/layers/admin/app/composables/useEditForm.ts +0 -402
  87. package/layers/admin/app/composables/useLinkResolver.ts +0 -40
  88. package/layers/admin/app/composables/useListBatchActions.ts +0 -78
  89. package/layers/admin/app/composables/useListColumns.ts +0 -53
  90. package/layers/admin/app/composables/useListFilterDraft.ts +0 -129
  91. package/layers/admin/app/composables/useListRows.ts +0 -75
  92. package/layers/admin/app/composables/useListSelection.ts +0 -28
  93. package/layers/admin/app/composables/useListUrlState.ts +0 -53
  94. package/layers/admin/app/composables/usePendingPublishPoll.ts +0 -15
  95. package/layers/admin/app/composables/usePreviewViewport.ts +0 -30
  96. package/layers/admin/app/composables/usePublishStatus.ts +0 -107
  97. package/layers/admin/app/composables/useRailCollapsed.ts +0 -14
  98. package/layers/admin/app/composables/useTheme.ts +0 -36
  99. package/layers/admin/app/composables/useToolbarPanel.ts +0 -38
  100. package/layers/admin/app/composables/useUnsavedGuard.ts +0 -22
  101. package/layers/admin/app/layouts/admin.vue +0 -266
  102. package/layers/admin/app/middleware/admin-auth.ts +0 -4
  103. package/layers/admin/app/pages/admin/[collection]/[id].vue +0 -203
  104. package/layers/admin/app/pages/admin/[collection]/index.vue +0 -62
  105. package/layers/admin/app/pages/admin/index.vue +0 -118
  106. package/layers/admin/app/pages/admin/login.vue +0 -68
  107. package/layers/admin/app/pages/admin/media.vue +0 -28
  108. package/layers/admin/app/pages/admin/references.vue +0 -146
  109. package/layers/admin/app/plugins/admin-style-guard.client.ts +0 -76
  110. package/layers/admin/app/plugins/reauth.client.ts +0 -20
  111. package/layers/admin/app/utils/admin-style-guard.ts +0 -20
  112. package/layers/admin/app/utils/block-tree.ts +0 -214
  113. package/layers/admin/app/utils/collection-ops.ts +0 -69
  114. package/layers/admin/app/utils/dead-refs.ts +0 -30
  115. package/layers/admin/app/utils/edit-form.ts +0 -267
  116. package/layers/admin/app/utils/editor-expose.ts +0 -36
  117. package/layers/admin/app/utils/editor-form-context.ts +0 -82
  118. package/layers/admin/app/utils/editor-registry.ts +0 -20
  119. package/layers/admin/app/utils/field-keys.ts +0 -11
  120. package/layers/admin/app/utils/list-cell.ts +0 -46
  121. package/layers/admin/app/utils/list-columns.ts +0 -142
  122. package/layers/admin/app/utils/list-query.ts +0 -96
  123. package/layers/admin/app/utils/nav-active.ts +0 -9
  124. package/layers/admin/app/utils/populate-blocks.ts +0 -288
  125. package/layers/admin/app/utils/preview-channel.ts +0 -62
  126. package/layers/admin/app/utils/preview-viewport.ts +0 -80
  127. package/layers/admin/app/utils/reauth.ts +0 -67
  128. package/layers/admin/app/utils/record-title.ts +0 -15
  129. package/layers/admin/app/utils/register-builtin-editors.ts +0 -9
  130. package/layers/admin/app/utils/safe-redirect.ts +0 -12
  131. package/layers/admin/nuxt.config.ts +0 -20
  132. package/layers/auth/nuxt.config.ts +0 -1
  133. package/layers/auth/server/plugins/01.register-auth-pipelines.ts +0 -7
  134. package/layers/collections/nuxt.config.ts +0 -1
  135. package/layers/collections/server/plugins/01.register.ts +0 -29
  136. package/layers/collections/server/plugins/02.register-relation-populate.ts +0 -18
  137. package/layers/core/app/composables/layouts.ts +0 -5
  138. package/layers/core/app/utils/field-factories.ts +0 -129
  139. package/layers/core/app/utils/layouts.ts +0 -38
  140. package/layers/core/modules/auto-discovery/extract-block.ts +0 -179
  141. package/layers/core/modules/auto-discovery/index.ts +0 -112
  142. package/layers/core/modules/auto-discovery/package-registry.ts +0 -19
  143. package/layers/core/modules/auto-discovery/scan.ts +0 -170
  144. package/layers/core/modules/auto-discovery/virtual.d.ts +0 -20
  145. package/layers/core/modules/component-namespace/index.ts +0 -44
  146. package/layers/core/modules/component-namespace/shared.ts +0 -24
  147. package/layers/core/modules/kestrel/app-shell.ts +0 -55
  148. package/layers/core/modules/kestrel/auto-imports.ts +0 -44
  149. package/layers/core/modules/kestrel/index.ts +0 -144
  150. package/layers/core/modules/plugin-order/index.ts +0 -69
  151. package/layers/core/modules/plugin-order/plugin-order.ts +0 -273
  152. package/layers/core/nuxt.config.ts +0 -10
  153. package/layers/core/server/api/[...path].ts +0 -98
  154. package/layers/core/server/plugins/00.config.ts +0 -12
  155. package/layers/core/server/plugins/00.migrate.ts +0 -12
  156. package/layers/core/server/plugins/01.register-introspection-pipeline.ts +0 -5
  157. package/layers/core/server/plugins/01.register-openapi-pipeline.ts +0 -5
  158. package/layers/core/server/plugins/01.register-tooling-pipelines.ts +0 -5
  159. package/layers/core/server/plugins/02.schema-sync.ts +0 -58
  160. package/layers/core/server/plugins/04.outbox-worker.ts +0 -31
  161. package/layers/core/server/plugins/05.reindex-refs.ts +0 -5
  162. package/layers/core/server/routes/__kestrel/dashboard.get.ts +0 -29
  163. package/layers/core/server/tasks/db/migrate-module.ts +0 -83
  164. package/layers/core/server/tasks/db/migrate-revisions.ts +0 -20
  165. package/layers/core/server/tasks/db/migrate.ts +0 -50
  166. package/layers/core/server/utils/server-config.ts +0 -81
  167. package/layers/fields/nuxt.config.ts +0 -1
  168. package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +0 -7
  169. package/layers/fields/server/plugins/01.register-field-populate.ts +0 -11
  170. package/layers/media/app/components/KestrelImg.vue +0 -94
  171. package/layers/media/app/components/MediaConflictDialog.vue +0 -35
  172. package/layers/media/app/components/MediaDeleteDialog.vue +0 -52
  173. package/layers/media/app/components/MediaGrid.vue +0 -113
  174. package/layers/media/app/components/MediaImage.vue +0 -70
  175. package/layers/media/app/components/MediaLibrary.vue +0 -456
  176. package/layers/media/app/components/MediaNewFolderDialog.vue +0 -32
  177. package/layers/media/app/components/MediaPathBar.vue +0 -163
  178. package/layers/media/app/components/MediaPicker.vue +0 -19
  179. package/layers/media/app/components/MediaRenameDialog.vue +0 -32
  180. package/layers/media/app/components/MediaTable.vue +0 -75
  181. package/layers/media/app/components/MediaThumb.vue +0 -25
  182. package/layers/media/app/components/MediaToolbar.vue +0 -90
  183. package/layers/media/app/components/MediaUploadDialog.vue +0 -87
  184. package/layers/media/app/components/MediaViewer.vue +0 -137
  185. package/layers/media/app/components/field/Media.vue +0 -263
  186. package/layers/media/app/composables/useBlurUp.ts +0 -55
  187. package/layers/media/app/composables/useMediaClipboard.ts +0 -14
  188. package/layers/media/app/composables/useMediaContextMenu.ts +0 -56
  189. package/layers/media/app/composables/useMediaDnd.ts +0 -56
  190. package/layers/media/app/composables/useMediaLibrary.ts +0 -152
  191. package/layers/media/app/composables/useMediaOps.ts +0 -50
  192. package/layers/media/app/composables/useMediaRelocate.ts +0 -39
  193. package/layers/media/app/composables/useMediaResolver.ts +0 -50
  194. package/layers/media/app/composables/useMediaUpload.ts +0 -93
  195. package/layers/media/app/composables/useMediaVariant.ts +0 -34
  196. package/layers/media/app/plugins/field-media.client.ts +0 -6
  197. package/layers/media/app/utils/ai-disclosure.ts +0 -19
  198. package/layers/media/app/utils/dnd.ts +0 -69
  199. package/layers/media/app/utils/library.ts +0 -81
  200. package/layers/media/app/utils/ops.ts +0 -60
  201. package/layers/media/app/utils/picture-model.ts +0 -125
  202. package/layers/media/app/utils/thumbhash.ts +0 -19
  203. package/layers/media/app/utils/variant-name.ts +0 -20
  204. package/layers/media/nuxt.config.ts +0 -7
  205. package/layers/media/server/middleware/ondemand-variants.ts +0 -38
  206. package/layers/media/server/plugins/01.register-media-pipelines.ts +0 -7
  207. package/layers/media/server/plugins/02.register-media.ts +0 -22
  208. package/layers/media/server/plugins/04.variant-capture.ts +0 -27
  209. package/layers/media/server/plugins/05.media-cleanup.ts +0 -5
  210. package/layers/media/server/tasks/media/backfill.ts +0 -17
  211. package/layers/public/app/app.vue +0 -5
  212. package/layers/public/app/components/BlockRenderer.vue +0 -56
  213. package/layers/public/app/components/KestrelLink.vue +0 -26
  214. package/layers/public/app/components/KestrelPreviewBridge.vue +0 -133
  215. package/layers/public/app/composables/public-page.ts +0 -24
  216. package/layers/public/app/error.vue +0 -12
  217. package/layers/public/app/layouts/default.vue +0 -5
  218. package/layers/public/app/pages/[...slug].vue +0 -239
  219. package/layers/public/app/pages/__kestrel/preview.vue +0 -46
  220. package/layers/public/app/utils/block-edit-context.ts +0 -16
  221. package/layers/public/app/utils/json-ld.ts +0 -139
  222. package/layers/public/app/utils/link-href.ts +0 -46
  223. package/layers/public/app/utils/page-head.ts +0 -84
  224. package/layers/public/app/utils/page-layout.ts +0 -18
  225. package/layers/public/app/utils/preview-protocol.ts +0 -117
  226. package/layers/public/app/utils/public-route.ts +0 -46
  227. package/layers/public/app/utils/site-head.ts +0 -43
  228. package/layers/public/modules/deploy-output/index.ts +0 -80
  229. package/layers/public/modules/prerender-routes/discover.ts +0 -37
  230. package/layers/public/modules/prerender-routes/index.ts +0 -88
  231. package/layers/public/modules/prerender-routes/routes.ts +0 -16
  232. package/layers/public/modules/prune-media/index.ts +0 -48
  233. package/layers/public/modules/prune-media/prune-media.ts +0 -81
  234. package/layers/public/nuxt.config.ts +0 -14
  235. package/layers/public/server/delivery-live/serve.ts +0 -111
  236. package/layers/public/server/middleware/delivery-live-catchall.ts +0 -7
  237. package/layers/public/server/pipelines/index.ts +0 -23
  238. package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +0 -14
  239. package/layers/public/server/plugins/01.register-public-pipelines.ts +0 -7
  240. package/layers/public/server/plugins/02.register-links.ts +0 -16
  241. package/layers/public/server/plugins/03.redirects.ts +0 -55
  242. package/layers/public/server/plugins/05.plan-publish.ts +0 -5
  243. package/layers/public/server/plugins/zz.publish.ts +0 -127
  244. package/layers/public/server/routes/llms-full.txt.get.ts +0 -99
  245. package/layers/public/server/routes/llms.txt.get.ts +0 -66
  246. package/layers/public/server/routes/redirects.json.get.ts +0 -58
  247. package/layers/public/server/routes/robots.txt.get.ts +0 -11
  248. package/layers/public/server/routes/sitemap.xml.get.ts +0 -79
  249. package/layers/public/server/tasks/publish/run.ts +0 -32
  250. package/layers/public/server/utils/publish/render-live.ts +0 -26
  251. package/layers/ui/app/assets/scss/_base.scss +0 -16
  252. package/layers/ui/app/assets/scss/_datepicker.scss +0 -82
  253. package/layers/ui/app/assets/scss/_mixins.scss +0 -50
  254. package/layers/ui/app/assets/scss/_reset.scss +0 -65
  255. package/layers/ui/app/assets/scss/_tokens.scss +0 -183
  256. package/layers/ui/app/assets/scss/main.scss +0 -4
  257. package/layers/ui/app/components/field/Boolean.vue +0 -30
  258. package/layers/ui/app/components/field/Choice.vue +0 -73
  259. package/layers/ui/app/components/field/Datetime.vue +0 -98
  260. package/layers/ui/app/components/field/DeadRefNote.vue +0 -28
  261. package/layers/ui/app/components/field/Json.vue +0 -62
  262. package/layers/ui/app/components/field/Layout.vue +0 -138
  263. package/layers/ui/app/components/field/Link.vue +0 -112
  264. package/layers/ui/app/components/field/LinkInternalPicker.vue +0 -95
  265. package/layers/ui/app/components/field/Number.vue +0 -30
  266. package/layers/ui/app/components/field/Relation.vue +0 -49
  267. package/layers/ui/app/components/field/Renderer.vue +0 -30
  268. package/layers/ui/app/components/field/Repeater.vue +0 -460
  269. package/layers/ui/app/components/field/Richtext.vue +0 -26
  270. package/layers/ui/app/components/field/Slug.vue +0 -65
  271. package/layers/ui/app/components/field/Text.vue +0 -35
  272. package/layers/ui/app/components/field/Unsupported.vue +0 -34
  273. package/layers/ui/app/components/ui/Alert.vue +0 -50
  274. package/layers/ui/app/components/ui/Brand.vue +0 -22
  275. package/layers/ui/app/components/ui/Button.vue +0 -116
  276. package/layers/ui/app/components/ui/ButtonGroup.vue +0 -96
  277. package/layers/ui/app/components/ui/Checkbox.vue +0 -53
  278. package/layers/ui/app/components/ui/CheckboxGroup.vue +0 -51
  279. package/layers/ui/app/components/ui/Combobox.vue +0 -345
  280. package/layers/ui/app/components/ui/DatePicker.vue +0 -92
  281. package/layers/ui/app/components/ui/DateRangePicker.vue +0 -100
  282. package/layers/ui/app/components/ui/Dialog.vue +0 -107
  283. package/layers/ui/app/components/ui/EmptyState.vue +0 -46
  284. package/layers/ui/app/components/ui/Field.vue +0 -58
  285. package/layers/ui/app/components/ui/Fieldset.vue +0 -65
  286. package/layers/ui/app/components/ui/Icon.vue +0 -44
  287. package/layers/ui/app/components/ui/Menu.vue +0 -54
  288. package/layers/ui/app/components/ui/NumberInput.vue +0 -175
  289. package/layers/ui/app/components/ui/Richtext.vue +0 -141
  290. package/layers/ui/app/components/ui/RichtextToolbar.vue +0 -251
  291. package/layers/ui/app/components/ui/Select.vue +0 -52
  292. package/layers/ui/app/components/ui/TextInput.vue +0 -111
  293. package/layers/ui/app/components/ui/Textarea.vue +0 -39
  294. package/layers/ui/app/components/ui/TimeInput.vue +0 -18
  295. package/layers/ui/app/components/ui/Toasts.vue +0 -103
  296. package/layers/ui/app/components/ui/Tooltip.vue +0 -45
  297. package/layers/ui/app/composables/useAdminLang.ts +0 -8
  298. package/layers/ui/app/composables/useDragReorder.ts +0 -41
  299. package/layers/ui/app/composables/useEchoGuard.ts +0 -28
  300. package/layers/ui/app/composables/useLinkField.ts +0 -70
  301. package/layers/ui/app/composables/useRecordOptions.ts +0 -84
  302. package/layers/ui/app/composables/useRepeater.ts +0 -99
  303. package/layers/ui/app/composables/useT.ts +0 -32
  304. package/layers/ui/app/composables/useToast.ts +0 -46
  305. package/layers/ui/app/i18n/de.ts +0 -432
  306. package/layers/ui/app/i18n/en.ts +0 -434
  307. package/layers/ui/app/utils/boolean-field.ts +0 -10
  308. package/layers/ui/app/utils/field-component.ts +0 -22
  309. package/layers/ui/app/utils/field-empty.ts +0 -21
  310. package/layers/ui/app/utils/field-registry.ts +0 -36
  311. package/layers/ui/app/utils/humanize.ts +0 -14
  312. package/layers/ui/app/utils/icons.ts +0 -143
  313. package/layers/ui/app/utils/iso-date.ts +0 -18
  314. package/layers/ui/app/utils/localized.ts +0 -13
  315. package/layers/ui/app/utils/record-context.ts +0 -15
  316. package/layers/ui/app/utils/reorder.ts +0 -8
  317. package/layers/ui/app/utils/richtext-preserve-class.ts +0 -31
  318. package/layers/ui/app/utils/useFieldA11y.ts +0 -40
  319. package/layers/ui/nuxt.config.ts +0 -9
  320. package/nuxt.config.ts +0 -108
  321. package/scripts/consumer-template-ci.mjs +0 -160
  322. package/scripts/copy-create-payload.mjs +0 -49
  323. package/scripts/coverage-all.mjs +0 -163
  324. package/scripts/dashboard.mjs +0 -89
  325. package/scripts/hash-password.mjs +0 -29
  326. package/scripts/kestrel.mjs +0 -240
  327. package/scripts/lib/cli.mjs +0 -129
  328. package/scripts/lib/coverage-model.mjs +0 -51
  329. package/scripts/lib/password.mjs +0 -19
  330. package/scripts/lib/scaffold.mjs +0 -205
  331. package/scripts/port-graph.mjs +0 -350
  332. package/scripts/typecheck.mjs +0 -62
  333. package/templates/starter/README.md +0 -65
  334. package/templates/starter/_env.example +0 -17
  335. package/templates/starter/_gitignore +0 -26
  336. package/templates/starter/_package.json +0 -22
  337. package/templates/starter/app/app.vue +0 -7
  338. package/templates/starter/app/blocks/Prose.vue +0 -13
  339. package/templates/starter/app/layouts/default.vue +0 -6
  340. package/templates/starter/nuxt.config.ts +0 -23
  341. package/templates/starter/pnpm-workspace.yaml +0 -8
  342. package/templates/starter/tsconfig.json +0 -3
@@ -1,38 +0,0 @@
1
- import type { H3Event } from 'h3'
2
- import { clientIp } from '@michaelthielemann/kestrel-auth'
3
- import { allowlistMode, ipAllowed, parseAllowlist } from '@michaelthielemann/kestrel-access'
4
- import { deriveOnDemand, variantKeyFromPath, useStorageDriver, mediaRuntimeConfig, useMediaDb } from '@michaelthielemann/kestrel-media'
5
- import { DEFAULT_IMAGE_POLICY } from '@michaelthielemann/kestrel-core'
6
-
7
- /**
8
- * Whether the IP allow-list would deny this request, checked directly rather than relied on by ordering:
9
- * Nitro sorts middleware by layer scan dir first, and media's precedes access's, so the real
10
- * access/00.ip-allowlist gate has NOT run yet when this middleware short-circuits with a derived response.
11
- */
12
- export function deniedByAllowlist(event: H3Event): boolean {
13
- const mode = allowlistMode(process.env.KESTREL_IP_ALLOWLIST_MODE, process.env.KESTREL_IP_ALLOWLIST)
14
- if (mode !== 'enforce') return false
15
- return !ipAllowed(clientIp(event), parseAllowlist(process.env.KESTREL_IP_ALLOWLIST))
16
- }
17
-
18
- // Dev only: a GET for a not-yet-generated variant under the media baseUrl derives it from the original and
19
- // serves it, so a newly-declared size/format shows in the editor preview without a full publish. On a hit
20
- // this is a no-op — the static /uploads handler serves the cached file.
21
- export default defineEventHandler(async (event) => {
22
- if (!import.meta.dev || event.method !== 'GET') return
23
- if (deniedByAllowlist(event)) return
24
- const cfg = mediaRuntimeConfig()
25
- const key = variantKeyFromPath(event.path, cfg.baseUrl)
26
- if (key == null) return
27
- const driver = useStorageDriver()
28
- if (await driver.exists?.(key)) return
29
- // Dev is the diagnosis environment, so surface a real derive failure (missing original, sharp reject)
30
- // instead of silently 404ing — a broken preview <img> with no log violates the fail-loud convention.
31
- const result = await deriveOnDemand(useMediaDb().db, driver, cfg.imagePolicy ?? DEFAULT_IMAGE_POLICY, key).catch((error) => {
32
- console.warn(`[kestrel] on-demand variant derive failed for ${key}:`, (error as Error)?.message ?? error)
33
- return null
34
- })
35
- if (!result) return
36
- setResponseHeader(event, 'content-type', result.mime)
37
- return result.bytes
38
- })
@@ -1,7 +0,0 @@
1
- import { registerPipeline } from '@michaelthielemann/kestrel-core'
2
- import { buildMediaPipelines } from '@michaelthielemann/kestrel-media'
3
-
4
- // Registration only — nothing resolves a pipeline here; the registry is read on the first request.
5
- export default defineNitroPlugin(() => {
6
- for (const def of buildMediaPipelines()) registerPipeline(def)
7
- })
@@ -1,22 +0,0 @@
1
- import { eq, getTableColumns } from 'drizzle-orm'
2
- import { registerFieldPopulator } from '@michaelthielemann/kestrel-core'
3
- import {
4
- media as mediaTable,
5
- buildMediaFieldPopulator,
6
- resolveMedia,
7
- useStorageDriver,
8
- mediaCollectionEnabled,
9
- useMediaDb,
10
- } from '@michaelthielemann/kestrel-media'
11
-
12
- export default defineNitroPlugin(() => {
13
- // A consumer who turned the built-in off has no `media` table, so a row that still carries a media id
14
- // must stay unpopulated rather than blow up the read path.
15
- if (!mediaCollectionEnabled()) return
16
- const cols = getTableColumns(mediaTable) as Record<string, never>
17
- registerFieldPopulator('media', buildMediaFieldPopulator((id, locale) => {
18
- const driver = useStorageDriver()
19
- const row = useMediaDb().db.select().from(mediaTable).where(eq(cols.id, id)).get()
20
- return row ? resolveMedia(row as never, locale, (k) => driver.publicUrl(k)) : null
21
- }))
22
- })
@@ -1,27 +0,0 @@
1
- import { isRendererContext } from '@michaelthielemann/kestrel-access'
2
- import { recordVariants, saveDiscoveredVariants, useMediaDb } from '@michaelthielemann/kestrel-media'
3
-
4
- /**
5
- * Auto-discovery capture. `KestrelImg`/`useMediaVariant` stash their concrete specs on the render's
6
- * `event.context.kestrelVariants` during SSR. Renders count when they are either a PUBLISH render (the
7
- * incremental publisher runs it under `runAsRenderer`, see `render-live.ts`) OR a build-time PRERENDER
8
- * render (the classic `nuxt generate` topology) — so live preview / stray runtime renders never pollute the
9
- * scan. For a publish, `publishFull` reconciles the drained accumulator once at the end; the generate
10
- * topology has no such orchestrator, so each prerendered route reconciles inline WITHOUT draining and the
11
- * growing accumulator converges to the full used set by the last route.
12
- *
13
- * Gating uses the unforgeable AsyncLocalStorage signal `isRendererContext()` (the same one
14
- * `delivery-live`'s catch-all uses), not the spoofable `x-kestrel-publish` header.
15
- *
16
- * NOTE: relies on `beforeResponse` firing for the publisher's `localFetch` renders / the prerenderer, and on
17
- * the app→server `event.context` bridge — verified by a real publish/generate, not the headless unit suite.
18
- */
19
- export default defineNitroPlugin((nitroApp) => {
20
- nitroApp.hooks.hook('beforeResponse', (event) => {
21
- const isPrerender = import.meta.prerender === true
22
- if (!isRendererContext() && !isPrerender) return
23
- const specs = (event.context as { kestrelVariants?: unknown[] }).kestrelVariants
24
- if (Array.isArray(specs) && specs.length) recordVariants(specs as never)
25
- if (isPrerender) saveDiscoveredVariants(useMediaDb().db, { clear: false })
26
- })
27
- })
@@ -1,5 +0,0 @@
1
- import { registerMediaCleanup } from '@michaelthielemann/kestrel-media'
2
-
3
- export default defineNitroPlugin(() => {
4
- registerMediaCleanup()
5
- })
@@ -1,17 +0,0 @@
1
- import { runBackfill, useStorageDriver, mediaRuntimeConfig, useMediaDb } from '@michaelthielemann/kestrel-media'
2
- import { DEFAULT_IMAGE_POLICY } from '@michaelthielemann/kestrel-core'
3
-
4
- /**
5
- * Reconcile every media row's derivatives to the active variant registry: generate the missing sizes/formats
6
- * (from the stored original) and prune the deregistered ones. `{check:true}` is a dry-run (report only).
7
- * Trigger (like `db:migrate` — no `nuxi task run`): dev `GET /_nitro/tasks/media:backfill`; prod `runTask(
8
- * 'media:backfill', { payload })` from the admin endpoint (`POST /api/media/backfill`) or a cron scheduledTask.
9
- */
10
- export default defineTask({
11
- meta: { name: 'media:backfill', description: 'Generate missing image variants + prune deregistered ones; {check:true}=dry-run' },
12
- async run({ payload }) {
13
- const policy = mediaRuntimeConfig().imagePolicy ?? DEFAULT_IMAGE_POLICY
14
- const result = await runBackfill(useMediaDb().db, useStorageDriver(), policy, { check: !!(payload as { check?: boolean })?.check })
15
- return { result }
16
- },
17
- })
@@ -1,5 +0,0 @@
1
- <template>
2
- <NuxtLayout>
3
- <NuxtPage />
4
- </NuxtLayout>
5
- </template>
@@ -1,56 +0,0 @@
1
- <script setup lang="ts">
2
- import { computed, inject, resolveDynamicComponent } from 'vue'
3
- import BlockRenderer from './BlockRenderer.vue'
4
- import { blockEditKey } from '../utils/block-edit-context'
5
-
6
- interface BlockNode { id?: string; type: string; props?: Record<string, unknown>; slots?: Record<string, BlockNode[]> }
7
- const props = defineProps<{ blocks: BlockNode[] }>()
8
-
9
- // Absent on the public site (plain render); present inside the admin preview (selection-aware wrapper).
10
- const edit = inject(blockEditKey, null)
11
-
12
- const pascal = (s: string) => s.replace(/(^|[-_])(\w)/g, (_m, _sep, c: string) => c.toUpperCase())
13
-
14
- const items = computed(() =>
15
- props.blocks.map((block, i) => {
16
- // A block type with no display component is a content condition, not a programming error: this
17
- // resolver hands back the bare name for it silently, leaving the message below as the one diagnostic.
18
- const resolved = resolveDynamicComponent('Blocks' + pascal(block.type))
19
- const component = typeof resolved === 'string' ? null : resolved
20
- if (!component && import.meta.dev) console.warn(`[kestrel] no display component for block type "${block.type}"`)
21
- const { $media, ...fields } = (block.props ?? {}) as Record<string, unknown>
22
- return { key: block.id ?? i, id: block.id, component, fields, media: $media as Record<string, unknown> | undefined, slots: block.slots }
23
- }),
24
- )
25
- </script>
26
-
27
- <template>
28
- <template v-for="item in items" :key="item.key">
29
- <!-- Public render path: no edit context → plain markup, unchanged. -->
30
- <component :is="item.component" v-if="item.component && !edit" v-bind="item.fields" :media="item.media">
31
- <!-- Each declared slot's child blocks recurse through the same renderer (props + nested $media). -->
32
- <template v-for="(slotBlocks, slotName) in item.slots" #[slotName]>
33
- <BlockRenderer :blocks="slotBlocks" />
34
- </template>
35
- </component>
36
- <!-- Editor preview path: same component, wrapped in a clickable, highlightable marker. `.prevent`
37
- cancels any anchor navigation from a link-bearing block; `.stop` keeps a nested-block click from
38
- also selecting its ancestors. The marker is `display: contents` (see KestrelPreviewBridge), so it
39
- generates no box: the block root stays the parent's real flex/grid item, so flex/grid-item props,
40
- `gap`, and root-keyed breakpoints render as on the published site. (`>` combinators are unaffected
41
- either way — the marker element stays in the DOM tree.) -->
42
- <div
43
- v-else-if="item.component"
44
- class="block-edit-marker"
45
- :class="{ 'block-edit-marker--selected': item.id != null && edit!.selectedId.value === item.id }"
46
- :data-block-id="item.id"
47
- @click.prevent.stop="item.id != null && edit!.select(item.id)"
48
- >
49
- <component :is="item.component" v-bind="item.fields" :media="item.media">
50
- <template v-for="(slotBlocks, slotName) in item.slots" #[slotName]>
51
- <BlockRenderer :blocks="slotBlocks" />
52
- </template>
53
- </component>
54
- </div>
55
- </template>
56
- </template>
@@ -1,26 +0,0 @@
1
- <script setup lang="ts">
2
- import { computed, useSlots } from 'vue'
3
- import type { LinkValue } from '@michaelthielemann/kestrel-core'
4
- import { linkToHref, linkLabel } from '../utils/link-href'
5
-
6
- // Generic renderer for the `link` field type, for consumer projects + blocks. Renders an <a> for any
7
- // LinkValue; external links open in a new, isolated tab. Renders nothing for a null/empty value.
8
- const props = defineProps<{ value?: LinkValue | null }>()
9
- const slots = useSlots()
10
- const href = computed(() => linkToHref(props.value))
11
- const isExternal = computed(() => props.value?.type === 'external')
12
- // An unresolved internal link (draft/missing target) falls back to '#' with no target-derived label —
13
- // only render it if it still has an accessible name from elsewhere, or it's a tabbable link to nowhere.
14
- const dead = computed(() => props.value?.type === 'internal' && href.value === '#')
15
- const named = computed(() => !!linkLabel(props.value) || !!slots.default)
16
- </script>
17
-
18
- <template>
19
- <a
20
- v-if="value && href && (!dead || named)"
21
- class="kestrel-link"
22
- :href="href"
23
- :target="isExternal ? '_blank' : undefined"
24
- :rel="isExternal ? 'noopener noreferrer' : undefined"
25
- ><slot>{{ linkLabel(value) }}</slot></a>
26
- </template>
@@ -1,133 +0,0 @@
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>
@@ -1,24 +0,0 @@
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
- }
@@ -1,12 +0,0 @@
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>
@@ -1,5 +0,0 @@
1
- <template>
2
- <main>
3
- <slot />
4
- </main>
5
- </template>
@@ -1,239 +0,0 @@
1
- <script setup lang="ts">
2
- import type { LayoutKey } from '#app'
3
- import type { SiteHead } from '../utils/site-head'
4
-
5
- // The record decides its own layout, so route-meta resolution is opted out of and this page renders the
6
- // `<NuxtLayout>` itself. Side effect worth knowing: the layout becomes a CHILD of the page, so it can read
7
- // `usePublicPageState()` during SSR — as its parent it rendered before the page had written it.
8
- definePageMeta({ layout: false })
9
-
10
- interface RenderedPage {
11
- title?: string
12
- layout?: string | null
13
- seo?: {
14
- title?: string
15
- description?: string
16
- noindex?: boolean
17
- $media?: { image?: { src: string; width: number | null; height: number | null } | null }
18
- author?: string
19
- publishedDate?: string
20
- keywords?: string
21
- }
22
- content?: unknown[]
23
- status?: string
24
- }
25
-
26
- const route = useRoute()
27
- // Locale routing is config-driven (KESTREL_LOCALES / KESTREL_PRIMARY_LOCALE → runtimeConfig.public), NOT
28
- // hardcoded to en/de — otherwise every non-default locale's prerendered pages 404. Mirrors the server's
29
- // prefix scheme (primary unprefixed, others `/<locale>/…`) via the pure `resolvePublicRoute`.
30
- const { locales, primary, prefixPrimary } = pickPublicLocales(
31
- useRuntimeConfig().public as { locales?: unknown; primaryLocale?: unknown; prefixPrimary?: unknown },
32
- )
33
- const { locale, path } = resolvePublicRoute(route.path.split('/').filter(Boolean), locales, primary, prefixPrimary)
34
-
35
- // Resolve across ALL pageLike collections (not just `pages`) via the generic route resolver, so a
36
- // consumer's own pageLike collection renders to static HTML too. `useRequestFetch` forwards the
37
- // incoming request's cookies to the SSR sub-fetch, so an authenticated admin's session reaches
38
- // `/api/route` and a draft renders at its real URL (the live preview); anonymous + the static render
39
- // stay published-only (the handler enforces it).
40
- const requestFetch = useRequestFetch()
41
- const { data: resolved, error: resolveError } = await useAsyncData(`page:${locale}:${path}`, () =>
42
- requestFetch('/api/route', {
43
- query: { path, locale },
44
- }).then((r) => r as {
45
- collection: string | null
46
- page: (RenderedPage & Record<string, unknown>) | null
47
- alternates?: Array<{ locale: string; path: string }>
48
- ancestors?: Array<{ path: string; title?: string; locale?: string }>
49
- site?: SiteHead | null
50
- }),
51
- )
52
- // Ticket preview (ADR-0008): `?kestrel-preview-token=…` carries the editor's UNSAVED state, so an external
53
- // tab can show work in progress without a save and without publishing. The ticket is admin-only and
54
- // session-bound server-side; an expired/foreign/unknown one reads as null and the saved record renders.
55
- const previewToken = route.query[PREVIEW_TOKEN_QUERY]
56
- const { data: ticket } = typeof previewToken === 'string' && previewToken
57
- ? await useAsyncData(`kestrel-preview-ticket:${previewToken}`, () =>
58
- requestFetch('/api/preview', { query: { token: previewToken } })
59
- .then((r) => r as { payload?: { values?: Record<string, unknown> } } | null)
60
- .catch(() => null))
61
- : { data: ref<{ payload?: { values?: Record<string, unknown> } } | null>(null) }
62
- const previewValues = computed(() => ticket.value?.payload?.values ?? null)
63
- const previewingTicket = computed(() => previewValues.value !== null)
64
-
65
- const page = computed(() => previewPage(resolved.value?.page ?? null, previewValues.value) as (RenderedPage & Record<string, unknown>) | null)
66
- // `fallback` below only rescues a truthy name that is missing from the layout map, so the empty cases have
67
- // to be coalesced here — see resolvePageLayout. The cast is the one honest bridge in this file: the stored
68
- // name is arbitrary editor data, while `NuxtLayout` types `name` as the union of layouts that existed at
69
- // build time. Narrowing to that union is impossible for a value read from the DB, and `fallback` is exactly
70
- // the runtime guard for a name outside it.
71
- const pageLayout = computed(() => resolvePageLayout(page.value?.layout) as LayoutKey)
72
-
73
- // The layout (language menu & co.) needs the resolved record and its collection; pages and layouts share
74
- // no other channel, so mirror the fetch result into the shared state — reactively, so client-side
75
- // navigations update it too.
76
- const pageState = usePublicPageState()
77
- watchEffect(() => {
78
- pageState.value = {
79
- collection: resolved.value?.collection ?? null,
80
- page: page.value,
81
- }
82
- })
83
-
84
- // A draft only ever resolves here for an authenticated admin (anonymous + the static render are
85
- // published-only), so its presence is an unambiguous "you are previewing an unpublished page" signal.
86
- const isDraftPreview = computed(() => page.value?.status === 'draft')
87
-
88
- // What this tab is actually showing, when it is not the live page. A ticket outranks the draft notice: the
89
- // content on screen was never saved at all, which is the stronger caveat.
90
- const previewNotice = computed(() => {
91
- if (previewingTicket.value) return 'Preview — unsaved changes, not published'
92
- return isDraftPreview.value ? 'Draft preview — not published' : ''
93
- })
94
-
95
- // Editor live-preview mode: `?kestrel-preview=1` AND an authenticated admin session. The session is
96
- // checked server-side (cookies forwarded, same seam as the draft fetch above) so SSR and hydration
97
- // agree on which branch renders; an anonymous visitor with the query param gets the normal page.
98
- // The bridge component is Lazy — its chunk (message channel + marker chrome) loads only in this mode.
99
- const previewRequested = route.query[PREVIEW_QUERY] === '1'
100
- const { data: previewSession } = previewRequested
101
- ? await useAsyncData('kestrel-preview-session', () =>
102
- requestFetch<{ authenticated: boolean }>('/api/session').catch(() => ({ authenticated: false })))
103
- : { data: ref<{ authenticated: boolean } | null>(null) }
104
- const previewActive = computed(() => previewRequested && previewSession.value?.authenticated === true)
105
-
106
- // `useAsyncData` resolves even when the fetch threw, so the resolver's own failure has to be re-raised here
107
- // or the root's empty document (a 200 WITH a body) reads as a successful render and the publisher bakes it
108
- // over the live page. Unconditional on purpose, not just for `/`: on any other path the failure would fall
109
- // through to the 404 below, which asserts "no such page" from a lookup that never completed — a claim
110
- // crawlers and caches act on, and one the publisher files as a skip instead of the error the editor shows.
111
- if (resolveError.value) throw resolveError.value
112
-
113
- // The site root stays reachable (empty document) before a home page is published;
114
- // any other unmatched path is a genuine 404.
115
- if (!page.value && path !== '/') throw createError({ statusCode: 404, statusMessage: 'Page not found' })
116
-
117
- // Canonical / Open Graph / twitter card / hreflang: pure model (`buildPageHead`) fed from the resolved
118
- // page + the public runtime config. Absolute-URL emissions (canonical, og:url, hreflang, relative
119
- // og:image) require a configured siteUrl and degrade away without one.
120
- const publicRc = useRuntimeConfig().public as { siteUrl?: string; siteName?: string; seoArticleMeta?: boolean }
121
- const seo = page.value?.seo ?? {}
122
- const siteHead = resolved.value?.site ?? null
123
- const fallbacks = siteHeadFallbacks(seo, siteHead)
124
- // og:title stays the bare page title (og:site_name already carries the site); only <title> is composed.
125
- const pageTitle = seo.title || page.value?.title
126
- const documentTitle = composeTitle(pageTitle, siteHead)
127
- const head = buildPageHead({
128
- siteUrl: typeof publicRc.siteUrl === 'string' ? publicRc.siteUrl : '',
129
- siteName: typeof publicRc.siteName === 'string' ? publicRc.siteName : '',
130
- path,
131
- locale,
132
- primary,
133
- prefixPrimary,
134
- title: pageTitle,
135
- description: fallbacks.description,
136
- image: fallbacks.image,
137
- alternates: resolved.value?.alternates ?? [],
138
- })
139
-
140
- // schema.org JSON-LD — the one grounding signal every major answer engine documents. Same inputs as the
141
- // head above, so the two can never disagree; it degrades away without a siteUrl and is suppressed for a
142
- // noindex page or an unsaved ticket preview. Article metadata is published ONLY with `seo.articleMeta`
143
- // on: the fields may hold values (the column always round-trips them) that this installation must not
144
- // disclose, so the flag gates emission, not storage.
145
- const jsonLd = buildJsonLd({
146
- siteUrl: typeof publicRc.siteUrl === 'string' ? publicRc.siteUrl : '',
147
- siteName: typeof publicRc.siteName === 'string' ? publicRc.siteName : '',
148
- canonical: head.canonical,
149
- locale,
150
- primary,
151
- prefixPrimary,
152
- title: pageTitle,
153
- description: fallbacks.description,
154
- imageUrl: head.meta.ogImage,
155
- noindex: previewingTicket.value || seo.noindex,
156
- ancestors: resolved.value?.ancestors ?? [],
157
- article: publicRc.seoArticleMeta === true
158
- ? { author: seo.author, publishedDate: seo.publishedDate, keywords: seo.keywords }
159
- : null,
160
- })
161
-
162
- // Set the document language from the resolved locale so prerendered /de pages ship <html lang="de">
163
- // (WCAG 2.2 SC 3.1.1); without this every page would carry the build-default language.
164
- // Point AI agents at the generated llms.txt (alongside the robots.txt comment + sitemap) on every page.
165
- useHead({
166
- htmlAttrs: { lang: locale },
167
- // `textContent` (not innerHTML) is unhead's XSS-safe arm for a data script: it takes the object and
168
- // serializes it itself, so editor-authored strings can never close the <script>.
169
- script: jsonLd ? [{ type: 'application/ld+json' as const, textContent: jsonLd }] : [],
170
- link: [
171
- // `rel` needs the literal type: unhead keys its link union on it, and inside an array literal that
172
- // reaches `link:` through a spread there is no contextual type to stop TS widening it to `string`.
173
- ...(head.canonical ? [{ rel: 'canonical' as const, href: head.canonical }] : []),
174
- ...head.links,
175
- { rel: 'alternate' as const, type: 'text/markdown', href: '/llms.txt', title: 'llms.txt' },
176
- ],
177
- })
178
- useSeoMeta({
179
- title: documentTitle,
180
- description: fallbacks.description,
181
- // A ticket preview is unsaved content at a real URL — never indexable, whatever the record's own SEO says.
182
- robots: previewingTicket.value || seo.noindex ? 'noindex, nofollow' : undefined,
183
- ogTitle: head.meta.ogTitle,
184
- ogDescription: head.meta.ogDescription,
185
- ogUrl: head.meta.ogUrl,
186
- ogType: head.meta.ogType,
187
- ogSiteName: head.meta.ogSiteName,
188
- ogImage: head.meta.ogImage,
189
- ogImageWidth: head.meta.ogImageWidth,
190
- ogImageHeight: head.meta.ogImageHeight,
191
- twitterCard: head.meta.twitterCard,
192
- })
193
- </script>
194
-
195
- <template>
196
- <NuxtLayout :name="pageLayout" fallback="default">
197
- <article>
198
- <!-- Only ever shown to an authenticated admin previewing an unpublished page (drafts never resolve
199
- for anonymous visitors or the static render), so it never ships to the public/static site.
200
- Suppressed inside the editor preview iframe — the editor's own status ampel covers it. -->
201
- <div v-if="previewNotice && !previewActive" class="kestrel-draft-badge" role="status">
202
- <span class="kestrel-draft-badge__dot" aria-hidden="true" />
203
- {{ previewNotice }}
204
- </div>
205
- <!-- Editor preview: the bridge swaps in the editor's live (unsaved) tree over postMessage and makes
206
- blocks selectable; the saved content renders until the first message. Normal path unchanged. -->
207
- <LazyKestrelPreviewBridge v-if="previewActive" :blocks="(page?.content as any[]) ?? []" v-slot="{ blocks }">
208
- <KestrelBlockRenderer :blocks="(blocks as any[])" />
209
- </LazyKestrelPreviewBridge>
210
- <KestrelBlockRenderer v-else :blocks="(page?.content as any[]) ?? []" />
211
- </article>
212
- </NuxtLayout>
213
- </template>
214
-
215
- <style scoped>
216
- /* Self-contained (no token dependency) so it renders identically whatever layout the consumer ships. */
217
- .kestrel-draft-badge {
218
- position: fixed;
219
- inset-block-start: 0.75rem;
220
- inset-inline-end: 0.75rem;
221
- z-index: 2147483647;
222
- display: inline-flex;
223
- align-items: center;
224
- gap: 0.4rem;
225
- padding: 0.35rem 0.7rem;
226
- border-radius: 999px;
227
- background: #1e293b;
228
- color: #fff;
229
- font: 600 0.78rem/1 ui-sans-serif, system-ui, sans-serif;
230
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
231
- pointer-events: none;
232
- }
233
- .kestrel-draft-badge__dot {
234
- width: 0.5rem;
235
- height: 0.5rem;
236
- border-radius: 50%;
237
- background: #fbbf24;
238
- }
239
- </style>