@michaelthielemann/kestrel 4.1.2 → 5.0.0

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 (340) hide show
  1. package/README.md +26 -201
  2. package/dist/boot.d.ts +45 -0
  3. package/dist/boot.js +274 -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 +157 -0
  15. package/dist/defineConfig.js +41 -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/cron.d.ts +10 -0
  43. package/dist/triggers/cron.js +44 -0
  44. package/dist/triggers/cronMatch.d.ts +10 -0
  45. package/dist/triggers/cronMatch.js +68 -0
  46. package/dist/triggers/http.d.ts +58 -0
  47. package/dist/triggers/http.js +292 -0
  48. package/package.json +25 -151
  49. package/NOTICE +0 -12
  50. package/kestrel.config.ts +0 -28
  51. package/layers/access/nuxt.config.ts +0 -5
  52. package/layers/access/server/middleware/00.ip-allowlist.ts +0 -52
  53. package/layers/access/server/middleware/access-guard.ts +0 -32
  54. package/layers/access/server/types.d.ts +0 -7
  55. package/layers/admin/app/components/AdminAccount.vue +0 -212
  56. package/layers/admin/app/components/AdminNav.vue +0 -44
  57. package/layers/admin/app/components/BlockFields.vue +0 -67
  58. package/layers/admin/app/components/BlockPreview.vue +0 -413
  59. package/layers/admin/app/components/BlockTree.vue +0 -433
  60. package/layers/admin/app/components/BlocksBody.vue +0 -163
  61. package/layers/admin/app/components/CollectionDeleteDialog.vue +0 -38
  62. package/layers/admin/app/components/CollectionEditor.vue +0 -324
  63. package/layers/admin/app/components/CollectionList.vue +0 -429
  64. package/layers/admin/app/components/CollectionListBulkBar.vue +0 -62
  65. package/layers/admin/app/components/CollectionListColumnsPanel.vue +0 -37
  66. package/layers/admin/app/components/CollectionListFilterPanel.vue +0 -145
  67. package/layers/admin/app/components/CollectionListPager.vue +0 -70
  68. package/layers/admin/app/components/CollectionListTable.vue +0 -347
  69. package/layers/admin/app/components/EditorStatus.vue +0 -211
  70. package/layers/admin/app/components/EditorUnsupported.vue +0 -28
  71. package/layers/admin/app/components/FieldsBody.vue +0 -23
  72. package/layers/admin/app/components/LocaleBar.vue +0 -107
  73. package/layers/admin/app/components/PageFields.vue +0 -129
  74. package/layers/admin/app/components/PageFieldsPane.vue +0 -39
  75. package/layers/admin/app/components/SeoFields.vue +0 -210
  76. package/layers/admin/app/components/SingletonEditor.vue +0 -93
  77. package/layers/admin/app/composables/useAuth.ts +0 -68
  78. package/layers/admin/app/composables/useBlockTree.ts +0 -95
  79. package/layers/admin/app/composables/useBlocks.ts +0 -7
  80. package/layers/admin/app/composables/useCachedResource.ts +0 -35
  81. package/layers/admin/app/composables/useCollectionOps.ts +0 -102
  82. package/layers/admin/app/composables/useCollections.ts +0 -6
  83. package/layers/admin/app/composables/useContentLocales.ts +0 -23
  84. package/layers/admin/app/composables/useEditForm.ts +0 -402
  85. package/layers/admin/app/composables/useLinkResolver.ts +0 -40
  86. package/layers/admin/app/composables/useListBatchActions.ts +0 -78
  87. package/layers/admin/app/composables/useListColumns.ts +0 -53
  88. package/layers/admin/app/composables/useListFilterDraft.ts +0 -129
  89. package/layers/admin/app/composables/useListRows.ts +0 -75
  90. package/layers/admin/app/composables/useListSelection.ts +0 -28
  91. package/layers/admin/app/composables/useListUrlState.ts +0 -53
  92. package/layers/admin/app/composables/usePendingPublishPoll.ts +0 -15
  93. package/layers/admin/app/composables/usePreviewViewport.ts +0 -30
  94. package/layers/admin/app/composables/usePublishStatus.ts +0 -107
  95. package/layers/admin/app/composables/useRailCollapsed.ts +0 -14
  96. package/layers/admin/app/composables/useTheme.ts +0 -36
  97. package/layers/admin/app/composables/useToolbarPanel.ts +0 -38
  98. package/layers/admin/app/composables/useUnsavedGuard.ts +0 -22
  99. package/layers/admin/app/layouts/admin.vue +0 -266
  100. package/layers/admin/app/middleware/admin-auth.ts +0 -4
  101. package/layers/admin/app/pages/admin/[collection]/[id].vue +0 -203
  102. package/layers/admin/app/pages/admin/[collection]/index.vue +0 -62
  103. package/layers/admin/app/pages/admin/index.vue +0 -118
  104. package/layers/admin/app/pages/admin/login.vue +0 -68
  105. package/layers/admin/app/pages/admin/media.vue +0 -28
  106. package/layers/admin/app/pages/admin/references.vue +0 -146
  107. package/layers/admin/app/plugins/admin-style-guard.client.ts +0 -76
  108. package/layers/admin/app/plugins/reauth.client.ts +0 -20
  109. package/layers/admin/app/utils/admin-style-guard.ts +0 -20
  110. package/layers/admin/app/utils/block-tree.ts +0 -214
  111. package/layers/admin/app/utils/collection-ops.ts +0 -69
  112. package/layers/admin/app/utils/dead-refs.ts +0 -30
  113. package/layers/admin/app/utils/edit-form.ts +0 -267
  114. package/layers/admin/app/utils/editor-expose.ts +0 -36
  115. package/layers/admin/app/utils/editor-form-context.ts +0 -82
  116. package/layers/admin/app/utils/editor-registry.ts +0 -20
  117. package/layers/admin/app/utils/field-keys.ts +0 -11
  118. package/layers/admin/app/utils/list-cell.ts +0 -46
  119. package/layers/admin/app/utils/list-columns.ts +0 -142
  120. package/layers/admin/app/utils/list-query.ts +0 -96
  121. package/layers/admin/app/utils/nav-active.ts +0 -9
  122. package/layers/admin/app/utils/populate-blocks.ts +0 -288
  123. package/layers/admin/app/utils/preview-channel.ts +0 -62
  124. package/layers/admin/app/utils/preview-viewport.ts +0 -80
  125. package/layers/admin/app/utils/reauth.ts +0 -67
  126. package/layers/admin/app/utils/record-title.ts +0 -15
  127. package/layers/admin/app/utils/register-builtin-editors.ts +0 -9
  128. package/layers/admin/app/utils/safe-redirect.ts +0 -12
  129. package/layers/admin/nuxt.config.ts +0 -20
  130. package/layers/auth/nuxt.config.ts +0 -1
  131. package/layers/auth/server/plugins/01.register-auth-pipelines.ts +0 -7
  132. package/layers/collections/nuxt.config.ts +0 -1
  133. package/layers/collections/server/plugins/01.register.ts +0 -29
  134. package/layers/collections/server/plugins/02.register-relation-populate.ts +0 -18
  135. package/layers/core/app/composables/layouts.ts +0 -5
  136. package/layers/core/app/utils/field-factories.ts +0 -129
  137. package/layers/core/app/utils/layouts.ts +0 -38
  138. package/layers/core/modules/auto-discovery/extract-block.ts +0 -179
  139. package/layers/core/modules/auto-discovery/index.ts +0 -112
  140. package/layers/core/modules/auto-discovery/package-registry.ts +0 -19
  141. package/layers/core/modules/auto-discovery/scan.ts +0 -170
  142. package/layers/core/modules/auto-discovery/virtual.d.ts +0 -20
  143. package/layers/core/modules/component-namespace/index.ts +0 -44
  144. package/layers/core/modules/component-namespace/shared.ts +0 -24
  145. package/layers/core/modules/kestrel/app-shell.ts +0 -55
  146. package/layers/core/modules/kestrel/auto-imports.ts +0 -44
  147. package/layers/core/modules/kestrel/index.ts +0 -144
  148. package/layers/core/modules/plugin-order/index.ts +0 -69
  149. package/layers/core/modules/plugin-order/plugin-order.ts +0 -273
  150. package/layers/core/nuxt.config.ts +0 -10
  151. package/layers/core/server/api/[...path].ts +0 -98
  152. package/layers/core/server/plugins/00.config.ts +0 -12
  153. package/layers/core/server/plugins/00.migrate.ts +0 -12
  154. package/layers/core/server/plugins/01.register-introspection-pipeline.ts +0 -5
  155. package/layers/core/server/plugins/01.register-openapi-pipeline.ts +0 -5
  156. package/layers/core/server/plugins/01.register-tooling-pipelines.ts +0 -5
  157. package/layers/core/server/plugins/02.schema-sync.ts +0 -58
  158. package/layers/core/server/plugins/04.outbox-worker.ts +0 -31
  159. package/layers/core/server/plugins/05.reindex-refs.ts +0 -5
  160. package/layers/core/server/routes/__kestrel/dashboard.get.ts +0 -29
  161. package/layers/core/server/tasks/db/migrate-module.ts +0 -83
  162. package/layers/core/server/tasks/db/migrate-revisions.ts +0 -20
  163. package/layers/core/server/tasks/db/migrate.ts +0 -50
  164. package/layers/core/server/utils/server-config.ts +0 -81
  165. package/layers/fields/nuxt.config.ts +0 -1
  166. package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +0 -7
  167. package/layers/fields/server/plugins/01.register-field-populate.ts +0 -11
  168. package/layers/media/app/components/KestrelImg.vue +0 -94
  169. package/layers/media/app/components/MediaConflictDialog.vue +0 -35
  170. package/layers/media/app/components/MediaDeleteDialog.vue +0 -52
  171. package/layers/media/app/components/MediaGrid.vue +0 -113
  172. package/layers/media/app/components/MediaImage.vue +0 -70
  173. package/layers/media/app/components/MediaLibrary.vue +0 -456
  174. package/layers/media/app/components/MediaNewFolderDialog.vue +0 -32
  175. package/layers/media/app/components/MediaPathBar.vue +0 -163
  176. package/layers/media/app/components/MediaPicker.vue +0 -19
  177. package/layers/media/app/components/MediaRenameDialog.vue +0 -32
  178. package/layers/media/app/components/MediaTable.vue +0 -75
  179. package/layers/media/app/components/MediaThumb.vue +0 -25
  180. package/layers/media/app/components/MediaToolbar.vue +0 -90
  181. package/layers/media/app/components/MediaUploadDialog.vue +0 -87
  182. package/layers/media/app/components/MediaViewer.vue +0 -137
  183. package/layers/media/app/components/field/Media.vue +0 -263
  184. package/layers/media/app/composables/useBlurUp.ts +0 -55
  185. package/layers/media/app/composables/useMediaClipboard.ts +0 -14
  186. package/layers/media/app/composables/useMediaContextMenu.ts +0 -56
  187. package/layers/media/app/composables/useMediaDnd.ts +0 -56
  188. package/layers/media/app/composables/useMediaLibrary.ts +0 -152
  189. package/layers/media/app/composables/useMediaOps.ts +0 -50
  190. package/layers/media/app/composables/useMediaRelocate.ts +0 -39
  191. package/layers/media/app/composables/useMediaResolver.ts +0 -50
  192. package/layers/media/app/composables/useMediaUpload.ts +0 -93
  193. package/layers/media/app/composables/useMediaVariant.ts +0 -34
  194. package/layers/media/app/plugins/field-media.client.ts +0 -6
  195. package/layers/media/app/utils/ai-disclosure.ts +0 -19
  196. package/layers/media/app/utils/dnd.ts +0 -69
  197. package/layers/media/app/utils/library.ts +0 -81
  198. package/layers/media/app/utils/ops.ts +0 -60
  199. package/layers/media/app/utils/picture-model.ts +0 -125
  200. package/layers/media/app/utils/thumbhash.ts +0 -19
  201. package/layers/media/app/utils/variant-name.ts +0 -20
  202. package/layers/media/nuxt.config.ts +0 -7
  203. package/layers/media/server/middleware/ondemand-variants.ts +0 -38
  204. package/layers/media/server/plugins/01.register-media-pipelines.ts +0 -7
  205. package/layers/media/server/plugins/02.register-media.ts +0 -22
  206. package/layers/media/server/plugins/04.variant-capture.ts +0 -27
  207. package/layers/media/server/plugins/05.media-cleanup.ts +0 -5
  208. package/layers/media/server/tasks/media/backfill.ts +0 -17
  209. package/layers/public/app/app.vue +0 -5
  210. package/layers/public/app/components/BlockRenderer.vue +0 -56
  211. package/layers/public/app/components/KestrelLink.vue +0 -26
  212. package/layers/public/app/components/KestrelPreviewBridge.vue +0 -133
  213. package/layers/public/app/composables/public-page.ts +0 -24
  214. package/layers/public/app/error.vue +0 -12
  215. package/layers/public/app/layouts/default.vue +0 -5
  216. package/layers/public/app/pages/[...slug].vue +0 -239
  217. package/layers/public/app/pages/__kestrel/preview.vue +0 -46
  218. package/layers/public/app/utils/block-edit-context.ts +0 -16
  219. package/layers/public/app/utils/json-ld.ts +0 -139
  220. package/layers/public/app/utils/link-href.ts +0 -46
  221. package/layers/public/app/utils/page-head.ts +0 -84
  222. package/layers/public/app/utils/page-layout.ts +0 -18
  223. package/layers/public/app/utils/preview-protocol.ts +0 -117
  224. package/layers/public/app/utils/public-route.ts +0 -46
  225. package/layers/public/app/utils/site-head.ts +0 -43
  226. package/layers/public/modules/deploy-output/index.ts +0 -80
  227. package/layers/public/modules/prerender-routes/discover.ts +0 -37
  228. package/layers/public/modules/prerender-routes/index.ts +0 -88
  229. package/layers/public/modules/prerender-routes/routes.ts +0 -16
  230. package/layers/public/modules/prune-media/index.ts +0 -48
  231. package/layers/public/modules/prune-media/prune-media.ts +0 -81
  232. package/layers/public/nuxt.config.ts +0 -14
  233. package/layers/public/server/delivery-live/serve.ts +0 -111
  234. package/layers/public/server/middleware/delivery-live-catchall.ts +0 -7
  235. package/layers/public/server/pipelines/index.ts +0 -23
  236. package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +0 -14
  237. package/layers/public/server/plugins/01.register-public-pipelines.ts +0 -7
  238. package/layers/public/server/plugins/02.register-links.ts +0 -16
  239. package/layers/public/server/plugins/03.redirects.ts +0 -55
  240. package/layers/public/server/plugins/05.plan-publish.ts +0 -5
  241. package/layers/public/server/plugins/zz.publish.ts +0 -127
  242. package/layers/public/server/routes/llms-full.txt.get.ts +0 -99
  243. package/layers/public/server/routes/llms.txt.get.ts +0 -66
  244. package/layers/public/server/routes/redirects.json.get.ts +0 -58
  245. package/layers/public/server/routes/robots.txt.get.ts +0 -11
  246. package/layers/public/server/routes/sitemap.xml.get.ts +0 -79
  247. package/layers/public/server/tasks/publish/run.ts +0 -32
  248. package/layers/public/server/utils/publish/render-live.ts +0 -26
  249. package/layers/ui/app/assets/scss/_base.scss +0 -16
  250. package/layers/ui/app/assets/scss/_datepicker.scss +0 -82
  251. package/layers/ui/app/assets/scss/_mixins.scss +0 -50
  252. package/layers/ui/app/assets/scss/_reset.scss +0 -65
  253. package/layers/ui/app/assets/scss/_tokens.scss +0 -183
  254. package/layers/ui/app/assets/scss/main.scss +0 -4
  255. package/layers/ui/app/components/field/Boolean.vue +0 -30
  256. package/layers/ui/app/components/field/Choice.vue +0 -73
  257. package/layers/ui/app/components/field/Datetime.vue +0 -98
  258. package/layers/ui/app/components/field/DeadRefNote.vue +0 -28
  259. package/layers/ui/app/components/field/Json.vue +0 -62
  260. package/layers/ui/app/components/field/Layout.vue +0 -138
  261. package/layers/ui/app/components/field/Link.vue +0 -112
  262. package/layers/ui/app/components/field/LinkInternalPicker.vue +0 -95
  263. package/layers/ui/app/components/field/Number.vue +0 -30
  264. package/layers/ui/app/components/field/Relation.vue +0 -49
  265. package/layers/ui/app/components/field/Renderer.vue +0 -30
  266. package/layers/ui/app/components/field/Repeater.vue +0 -460
  267. package/layers/ui/app/components/field/Richtext.vue +0 -26
  268. package/layers/ui/app/components/field/Slug.vue +0 -65
  269. package/layers/ui/app/components/field/Text.vue +0 -35
  270. package/layers/ui/app/components/field/Unsupported.vue +0 -34
  271. package/layers/ui/app/components/ui/Alert.vue +0 -50
  272. package/layers/ui/app/components/ui/Brand.vue +0 -22
  273. package/layers/ui/app/components/ui/Button.vue +0 -116
  274. package/layers/ui/app/components/ui/ButtonGroup.vue +0 -96
  275. package/layers/ui/app/components/ui/Checkbox.vue +0 -53
  276. package/layers/ui/app/components/ui/CheckboxGroup.vue +0 -51
  277. package/layers/ui/app/components/ui/Combobox.vue +0 -345
  278. package/layers/ui/app/components/ui/DatePicker.vue +0 -92
  279. package/layers/ui/app/components/ui/DateRangePicker.vue +0 -100
  280. package/layers/ui/app/components/ui/Dialog.vue +0 -107
  281. package/layers/ui/app/components/ui/EmptyState.vue +0 -46
  282. package/layers/ui/app/components/ui/Field.vue +0 -58
  283. package/layers/ui/app/components/ui/Fieldset.vue +0 -65
  284. package/layers/ui/app/components/ui/Icon.vue +0 -44
  285. package/layers/ui/app/components/ui/Menu.vue +0 -54
  286. package/layers/ui/app/components/ui/NumberInput.vue +0 -175
  287. package/layers/ui/app/components/ui/Richtext.vue +0 -141
  288. package/layers/ui/app/components/ui/RichtextToolbar.vue +0 -251
  289. package/layers/ui/app/components/ui/Select.vue +0 -52
  290. package/layers/ui/app/components/ui/TextInput.vue +0 -111
  291. package/layers/ui/app/components/ui/Textarea.vue +0 -39
  292. package/layers/ui/app/components/ui/TimeInput.vue +0 -18
  293. package/layers/ui/app/components/ui/Toasts.vue +0 -103
  294. package/layers/ui/app/components/ui/Tooltip.vue +0 -45
  295. package/layers/ui/app/composables/useAdminLang.ts +0 -8
  296. package/layers/ui/app/composables/useDragReorder.ts +0 -41
  297. package/layers/ui/app/composables/useEchoGuard.ts +0 -28
  298. package/layers/ui/app/composables/useLinkField.ts +0 -70
  299. package/layers/ui/app/composables/useRecordOptions.ts +0 -84
  300. package/layers/ui/app/composables/useRepeater.ts +0 -99
  301. package/layers/ui/app/composables/useT.ts +0 -32
  302. package/layers/ui/app/composables/useToast.ts +0 -46
  303. package/layers/ui/app/i18n/de.ts +0 -432
  304. package/layers/ui/app/i18n/en.ts +0 -434
  305. package/layers/ui/app/utils/boolean-field.ts +0 -10
  306. package/layers/ui/app/utils/field-component.ts +0 -22
  307. package/layers/ui/app/utils/field-empty.ts +0 -21
  308. package/layers/ui/app/utils/field-registry.ts +0 -36
  309. package/layers/ui/app/utils/humanize.ts +0 -14
  310. package/layers/ui/app/utils/icons.ts +0 -143
  311. package/layers/ui/app/utils/iso-date.ts +0 -18
  312. package/layers/ui/app/utils/localized.ts +0 -13
  313. package/layers/ui/app/utils/record-context.ts +0 -15
  314. package/layers/ui/app/utils/reorder.ts +0 -8
  315. package/layers/ui/app/utils/richtext-preserve-class.ts +0 -31
  316. package/layers/ui/app/utils/useFieldA11y.ts +0 -40
  317. package/layers/ui/nuxt.config.ts +0 -9
  318. package/nuxt.config.ts +0 -108
  319. package/scripts/consumer-template-ci.mjs +0 -160
  320. package/scripts/copy-create-payload.mjs +0 -49
  321. package/scripts/coverage-all.mjs +0 -163
  322. package/scripts/dashboard.mjs +0 -89
  323. package/scripts/hash-password.mjs +0 -29
  324. package/scripts/kestrel.mjs +0 -240
  325. package/scripts/lib/cli.mjs +0 -129
  326. package/scripts/lib/coverage-model.mjs +0 -51
  327. package/scripts/lib/password.mjs +0 -19
  328. package/scripts/lib/scaffold.mjs +0 -205
  329. package/scripts/port-graph.mjs +0 -350
  330. package/scripts/typecheck.mjs +0 -62
  331. package/templates/starter/README.md +0 -65
  332. package/templates/starter/_env.example +0 -17
  333. package/templates/starter/_gitignore +0 -26
  334. package/templates/starter/_package.json +0 -22
  335. package/templates/starter/app/app.vue +0 -7
  336. package/templates/starter/app/blocks/Prose.vue +0 -13
  337. package/templates/starter/app/layouts/default.vue +0 -6
  338. package/templates/starter/nuxt.config.ts +0 -23
  339. package/templates/starter/pnpm-workspace.yaml +0 -8
  340. package/templates/starter/tsconfig.json +0 -3
@@ -1,46 +0,0 @@
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 — or, when opened in a separate tab with `?kestrel-preview-token=…`, around the
7
- * ticket's unsaved content (ADR-0008), since a tab with no parent window has no bridge to listen to.
8
- * Saved pageLike records preview at their REAL URL instead (higher fidelity); this page is the graceful
9
- * fallback so previews never regress to "save first".
10
- *
11
- * Admin-gated server-side: `useRequestFetch` forwards the incoming cookies to `/api/session`
12
- * (the same seam the catch-all uses for draft rendering), and anonymous requests get a 404 — the
13
- * page's existence is not advertised. It is never linked, prerendered, or published (the publisher
14
- * enumerates DB page rows only), and carries noindex as belt-and-braces.
15
- */
16
- const requestFetch = useRequestFetch()
17
- const { data: session } = await useAsyncData('kestrel-preview-session', () =>
18
- requestFetch<{ authenticated: boolean }>('/api/session').catch(() => ({ authenticated: false })),
19
- )
20
- if (!session.value?.authenticated) throw createError({ statusCode: 404, statusMessage: 'Page not found' })
21
-
22
- // Content locale from the editor (drives <html lang> for faithful per-locale rendering).
23
- const route = useRoute()
24
- const lang = typeof route.query.locale === 'string' && route.query.locale ? route.query.locale : undefined
25
-
26
- // Ticket content for the external-tab case; null (→ the bridge's own empty tree) without one.
27
- const token = route.query[PREVIEW_TOKEN_QUERY]
28
- const { data: ticket } = typeof token === 'string' && token
29
- ? await useAsyncData(`kestrel-preview-ticket:${token}`, () =>
30
- requestFetch('/api/preview', { query: { token } })
31
- .then((r) => r as { payload?: { values?: Record<string, unknown> } } | null)
32
- .catch(() => null))
33
- : { data: ref<{ payload?: { values?: Record<string, unknown> } } | null>(null) }
34
- const ticketBlocks = computed(() => (ticket.value?.payload?.values?.content as unknown[] | undefined) ?? [])
35
- useHead({
36
- title: 'Preview',
37
- meta: [{ name: 'robots', content: 'noindex, nofollow' }],
38
- ...(lang ? { htmlAttrs: { lang } } : {}),
39
- })
40
- </script>
41
-
42
- <template>
43
- <KestrelPreviewBridge :blocks="ticketBlocks" v-slot="{ blocks }">
44
- <KestrelBlockRenderer :blocks="(blocks as any[])" />
45
- </KestrelPreviewBridge>
46
- </template>
@@ -1,16 +0,0 @@
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')
@@ -1,139 +0,0 @@
1
- import { localePath } from '@michaelthielemann/kestrel-core/client'
2
-
3
- /** One published ancestor of the rendered page, outermost first — the breadcrumb trail. */
4
- export interface JsonLdAncestor {
5
- /** The ancestor's own path, unprefixed (the emitter locale-prefixes it, as `buildPageHead` does). */
6
- path: string
7
- title?: string
8
- /** The ancestor's OWN locale. Absent for a record in a non-translatable collection, whose single
9
- * published URL is the primary-locale one — the same `row.locale ?? primary` rule the sitemap uses. */
10
- locale?: string
11
- }
12
-
13
- /** The opt-in article metadata (`kestrel.seo.articleMeta`). Absent ⇒ the page stays a plain `WebPage`
14
- * and no authorship or date is published — the default, and the only behaviour a consumer who never
15
- * turns the flag on can get. */
16
- export interface JsonLdArticle {
17
- author?: string
18
- /** ISO date (`YYYY-MM-DD`) or ISO datetime; anything else is dropped rather than emitted invalid. */
19
- publishedDate?: string
20
- /** Free-form comma-separated list — schema.org accepts that spelling for `keywords` verbatim. */
21
- keywords?: string
22
- }
23
-
24
- export interface JsonLdInput {
25
- /** Absolute site origin ('' when unconfigured). */
26
- siteUrl: string
27
- siteName?: string
28
- /** The page's absolute canonical URL (from `buildPageHead`); absent ⇒ nothing is emitted. */
29
- canonical?: string
30
- locale: string
31
- primary: string
32
- prefixPrimary: boolean
33
- title?: string
34
- description?: string
35
- /** The already-absolute og:image URL, so the image-resolution rules live in one place. */
36
- imageUrl?: string
37
- /** Excluded from the graph entirely — see `buildJsonLd`. */
38
- noindex?: boolean
39
- ancestors?: JsonLdAncestor[]
40
- article?: JsonLdArticle | null
41
- }
42
-
43
- /** A type alias, not an interface, for the same reason as `PageHeadLink`: unhead's `script` entry types
44
- * `textContent` as `string | Record<string, unknown>`, and TS derives the implicit index signature that
45
- * needs for an alias but never for an interface. */
46
- export type JsonLd = {
47
- '@context': 'https://schema.org'
48
- '@graph': Record<string, unknown>[]
49
- }
50
-
51
- const trimmed = (v: unknown): string | undefined => {
52
- const s = typeof v === 'string' ? v.trim() : ''
53
- return s || undefined
54
- }
55
-
56
- // schema.org dates are ISO 8601; an editor free-text value that is not one would be published as a
57
- // broken `datePublished`, which is worse for a consuming engine than no date at all.
58
- const ISO_DATE = /^\d{4}-\d{2}-\d{2}([T ]|$)/
59
-
60
- /**
61
- * The JSON-LD graph for a rendered public page: a site-wide `WebSite`, the page itself as a `WebPage`
62
- * (or an `Article`, when the consumer opted into article metadata AND the record carries some), and a
63
- * `BreadcrumbList` built from the page's real published ancestors. Pure, like `buildPageHead`, and fed
64
- * the same already-resolved values — the precedence chain stays outside (ADR-0007).
65
- *
66
- * Two whole-graph veto rules, both because the alternative is a false signal rather than a missing one:
67
- * without an absolute canonical every `@id`/`url` would be a relative path that resolves against
68
- * whatever host fetched it, and a `noindex` page asking to be excluded from search has no business
69
- * shipping structured data that exists to be indexed.
70
- *
71
- * Breadcrumb items are REAL pages only (the resolver hands over the published ancestors it found), so a
72
- * trail never advertises an intermediate URL that 404s; a trail of one is dropped, since "you are here"
73
- * carries no information.
74
- */
75
- export function buildJsonLd(input: JsonLdInput): JsonLd | null {
76
- const canonical = trimmed(input.canonical)
77
- if (!canonical || input.noindex) return null
78
- const base = input.siteUrl.replace(/\/+$/, '')
79
- const abs = (path: string, locale: string) => `${base}${localePath(path, locale, input.primary, input.prefixPrimary)}`
80
-
81
- const graph: Record<string, unknown>[] = []
82
- const siteName = trimmed(input.siteName)
83
- // A nameless WebSite node is an empty assertion — omit it, and with it the isPartOf edge that would
84
- // otherwise dangle at an @id nothing defines.
85
- const websiteId = siteName ? `${base}/#website` : undefined
86
- if (websiteId) graph.push({ '@type': 'WebSite', '@id': websiteId, url: `${base}/`, name: siteName })
87
-
88
- const article = usableArticle(input.article)
89
- const page: Record<string, unknown> = {
90
- '@type': article ? 'Article' : 'WebPage',
91
- '@id': `${canonical}#webpage`,
92
- url: canonical,
93
- }
94
- const title = trimmed(input.title)
95
- // `headline` is the Article spelling of the same value; keeping them apart avoids a node that claims
96
- // both and matches what validators expect per type.
97
- if (title) page[article ? 'headline' : 'name'] = title
98
- const description = trimmed(input.description)
99
- if (description) page.description = description
100
- page.inLanguage = input.locale
101
- if (websiteId) page.isPartOf = { '@id': websiteId }
102
- const imageUrl = trimmed(input.imageUrl)
103
- if (imageUrl) page.image = imageUrl
104
- if (article) {
105
- if (article.author) page.author = { '@type': 'Person', name: article.author }
106
- if (article.publishedDate) page.datePublished = article.publishedDate
107
- if (article.keywords) page.keywords = article.keywords
108
- }
109
-
110
- const crumbs = [
111
- ...(input.ancestors ?? []).map((a) => ({ name: trimmed(a.title) ?? a.path, item: abs(a.path, a.locale ?? input.primary) })),
112
- { name: title ?? canonical, item: canonical },
113
- ]
114
- if (crumbs.length >= 2) {
115
- const breadcrumbId = `${canonical}#breadcrumb`
116
- page.breadcrumb = { '@id': breadcrumbId }
117
- graph.push(page, {
118
- '@type': 'BreadcrumbList',
119
- '@id': breadcrumbId,
120
- itemListElement: crumbs.map((c, i) => ({ '@type': 'ListItem', position: i + 1, name: c.name, item: c.item })),
121
- })
122
- } else {
123
- graph.push(page)
124
- }
125
-
126
- return { '@context': 'https://schema.org', '@graph': graph }
127
- }
128
-
129
- /** The article bag reduced to the values worth publishing, or null — which keeps the node a `WebPage`.
130
- * A bag of blanks (an editor who opened the fields and typed nothing) must not upgrade the type. */
131
- function usableArticle(article: JsonLdArticle | null | undefined): Required<JsonLdArticle> | null {
132
- if (!article) return null
133
- const author = trimmed(article.author) ?? ''
134
- const rawDate = trimmed(article.publishedDate) ?? ''
135
- const publishedDate = ISO_DATE.test(rawDate) ? rawDate : ''
136
- const keywords = trimmed(article.keywords) ?? ''
137
- // An unparseable date alone must not upgrade the type either — nothing would be emitted from it.
138
- return author || publishedDate || keywords ? { author, publishedDate, keywords } : null
139
- }
@@ -1,46 +0,0 @@
1
- import type { LinkValue } from '@michaelthielemann/kestrel-core'
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
- }
@@ -1,84 +0,0 @@
1
- import { localePath } from '@michaelthielemann/kestrel-core/client'
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
- }
@@ -1,18 +0,0 @@
1
- /** The layout every page falls back to. Nuxt's own name for the unnamed layout, and the one the public
2
- * layer ships (`layers/public/app/layouts/default.vue`), so it is always present. */
3
- export const DEFAULT_LAYOUT = 'default'
4
-
5
- /**
6
- * The layout name to render a page in, from its stored `layout` column.
7
- *
8
- * Must never return an empty value. The catch-all declares `definePageMeta({ layout: false })` so the page
9
- * owns its own `<NuxtLayout>`; that makes `route.meta.layout` the literal `false`, and NuxtLayout resolves
10
- * `unref(props.name) ?? route.meta.layout ?? …` — `??` keeps `false`, which fails its `hasLayout` check and
11
- * renders the page with NO layout wrapper. The `fallback` prop does not cover this: it only applies to a
12
- * truthy name absent from the layout map. Coalescing here is what keeps an unset column rendering the
13
- * normal site frame.
14
- */
15
- export function resolvePageLayout(stored: string | null | undefined): string {
16
- const name = typeof stored === 'string' ? stored.trim() : ''
17
- return name || DEFAULT_LAYOUT
18
- }
@@ -1,117 +0,0 @@
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
- /** Query carrying a preview TICKET — the editor's unsaved state, rendered in a normal tab (ADR-0008). */
17
- export const PREVIEW_TOKEN_QUERY = 'kestrel-preview-token'
18
- /** Dedicated preview page for records without a public URL (new/unsaved, non-pageLike). Admin-gated. */
19
- export const PREVIEW_FALLBACK_PATH = '/__kestrel/preview'
20
-
21
- /** The block-node shape the renderer consumes (already media/link-populated by the editor). */
22
- export interface PreviewBlockNode { id?: string; type: string; props?: Record<string, unknown>; slots?: Record<string, unknown> }
23
-
24
- /** editor → frame: the live (populated) block tree + the current selection. */
25
- export interface PreviewContentMessage { kestrel: 'preview:content'; blocks: PreviewBlockNode[]; selectedId: string | null }
26
- /** editor → frame: selection changed (tree click) — highlight + scroll into view. */
27
- export interface PreviewSelectedMessage { kestrel: 'preview:selected'; selectedId: string | null }
28
- /** frame → editor: the bridge mounted (or the iframe reloaded) — (re)send the full state. */
29
- export interface PreviewReadyMessage { kestrel: 'preview:ready' }
30
- /** frame → editor: a block was clicked in the preview. */
31
- export interface PreviewSelectMessage { kestrel: 'preview:select'; id: string }
32
-
33
- export type EditorToFrameMessage = PreviewContentMessage | PreviewSelectedMessage
34
- export type FrameToEditorMessage = PreviewReadyMessage | PreviewSelectMessage
35
-
36
- export const contentMessage = (blocks: PreviewBlockNode[], selectedId: string | null): PreviewContentMessage =>
37
- ({ kestrel: 'preview:content', blocks, selectedId })
38
- export const selectedMessage = (selectedId: string | null): PreviewSelectedMessage =>
39
- ({ kestrel: 'preview:selected', selectedId })
40
- export const readyMessage = (): PreviewReadyMessage => ({ kestrel: 'preview:ready' })
41
- export const selectMessage = (id: string): PreviewSelectMessage => ({ kestrel: 'preview:select', id })
42
-
43
- function tagOf(data: unknown): string | null {
44
- if (typeof data !== 'object' || data === null || Array.isArray(data)) return null
45
- const tag = (data as { kestrel?: unknown }).kestrel
46
- return typeof tag === 'string' ? tag : null
47
- }
48
-
49
- /** Parse a message the FRAME received (editor → frame); `null` for anything else. */
50
- export function parseEditorMessage(data: unknown): EditorToFrameMessage | null {
51
- const tag = tagOf(data)
52
- if (tag === 'preview:content') {
53
- const m = data as PreviewContentMessage
54
- return Array.isArray(m.blocks) && (m.selectedId === null || typeof m.selectedId === 'string') ? m : null
55
- }
56
- if (tag === 'preview:selected') {
57
- const m = data as PreviewSelectedMessage
58
- return m.selectedId === null || typeof m.selectedId === 'string' ? m : null
59
- }
60
- return null
61
- }
62
-
63
- /** Parse a message the EDITOR received (frame → editor); `null` for anything else. */
64
- export function parseFrameMessage(data: unknown): FrameToEditorMessage | null {
65
- const tag = tagOf(data)
66
- if (tag === 'preview:ready') return data as PreviewReadyMessage
67
- if (tag === 'preview:select') {
68
- const m = data as PreviewSelectMessage
69
- return typeof m.id === 'string' ? m : null
70
- }
71
- return null
72
- }
73
-
74
- /**
75
- * The iframe URL for the editor's preview pane: the record's real public URL when it has one (saved
76
- * pageLike record — server-populated first paint, drafts included for the admin session), else the
77
- * dedicated fallback page (new/unsaved records, non-pageLike collections). Both carry the preview flag.
78
- */
79
- /**
80
- * The record a ticket preview renders: the saved row with the editor's unsaved values laid over it. Both
81
- * halves are column-keyed (the editor sends what a save would send), so this is a shallow override — a
82
- * field the editor did not touch keeps the stored value, and a page that does not exist yet (an unsaved
83
- * slug) renders from the payload alone. Pure.
84
- *
85
- * One override is not shallow: a single-valued relation/media field is column-keyed `<name>Id` (the
86
- * `resolveColumnName`/`isSingleRefColumn` convention — many-relations and multi-media stay bare-keyed, so
87
- * they're untouched by this), while its populated sidecar sits ALONGSIDE the id, not under it — `$<name>`
88
- * for a relation (`buildRelationFieldPopulator`), `$media.<name>` for media (`populate.ts`'s `attach`).
89
- * `values` here is raw unsaved editor state, never itself populated, so clearing such a field to null
90
- * leaves `saved`'s old sidecar with nothing to overwrite it — a plain spread would let a removed
91
- * author/cover keep rendering. Drop it explicitly for every `<name>Id` key the editor cleared.
92
- */
93
- export function previewPage(
94
- saved: Record<string, unknown> | null,
95
- values: Record<string, unknown> | null | undefined,
96
- ): Record<string, unknown> | null {
97
- if (!values) return saved
98
- const merged: Record<string, unknown> = { ...(saved ?? {}), ...values }
99
- for (const key of Object.keys(values)) {
100
- if (values[key] !== null || !key.endsWith('Id')) continue
101
- const name = key.slice(0, -2)
102
- if (!name) continue
103
- if (Object.hasOwn(merged, `$${name}`)) delete merged[`$${name}`]
104
- const media = merged.$media as Record<string, unknown> | undefined
105
- if (media && Object.hasOwn(media, name)) {
106
- const { [name]: _dropped, ...rest } = media
107
- merged.$media = rest
108
- }
109
- }
110
- return merged
111
- }
112
-
113
- export function previewSrc(publicUrl: string | null, locale: string): string {
114
- if (publicUrl) return `${publicUrl}?${PREVIEW_QUERY}=1`
115
- const loc = locale ? `&locale=${encodeURIComponent(locale)}` : ''
116
- return `${PREVIEW_FALLBACK_PATH}?${PREVIEW_QUERY}=1${loc}`
117
- }
@@ -1,46 +0,0 @@
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
- }
@@ -1,43 +0,0 @@
1
- export interface SiteHead {
2
- baseTitle?: string | null
3
- titleSeparator?: string | null
4
- titlePosition?: 'before' | 'after' | null
5
- description?: string | null
6
- $media?: { image?: { src: string, width: number | null, height: number | null } | null } | null
7
- }
8
-
9
- const DEFAULT_SEPARATOR = '|'
10
-
11
- const trimmed = (v: unknown): string | undefined => {
12
- const s = typeof v === 'string' ? v.trim() : ''
13
- return s || undefined
14
- }
15
-
16
- /**
17
- * The `<title>` for a page. Only the document title is composed — `og:title` keeps the bare page title,
18
- * because `og:site_name` already carries the site.
19
- */
20
- export function composeTitle(pageTitle: string | undefined | null, site: SiteHead | null | undefined): string | undefined {
21
- const page = trimmed(pageTitle)
22
- const base = trimmed(site?.baseTitle)
23
- if (!base) return page
24
- if (!page) return base
25
- // Migrated content often carries the site name in the page title already; appending it again reads as a
26
- // bug to every visitor who looks at the tab.
27
- if (page === base || page.endsWith(base)) return page
28
- // The separator is stored as a bare token and padded here: a `text` field trims on write, so a stored
29
- // " | " would come back as "|" and glue the two titles together.
30
- const separator = trimmed(site?.titleSeparator) ?? DEFAULT_SEPARATOR
31
- return site?.titlePosition === 'before' ? `${base} ${separator} ${page}` : `${page} ${separator} ${base}`
32
- }
33
-
34
- /** The page wins, the site stands in, and both degrade to absent so the tags disappear entirely. */
35
- export function siteHeadFallbacks(
36
- seo: { description?: string | null, $media?: { image?: { src: string, width: number | null, height: number | null } | null } | null } | null | undefined,
37
- site: SiteHead | null | undefined,
38
- ): { description?: string, image: { src: string, width: number | null, height: number | null } | null } {
39
- return {
40
- description: trimmed(seo?.description) ?? trimmed(site?.description),
41
- image: seo?.$media?.image ?? site?.$media?.image ?? null,
42
- }
43
- }
@@ -1,80 +0,0 @@
1
- import { defineNuxtModule } from '@nuxt/kit'
2
- import { createS3Driver } from '@michaelthielemann/kestrel-core'
3
- import { resolveOutputCreds, deployStaticOutput, isEnvTrue, planS3Deploy, readRouteDiscovery } from '@michaelthielemann/kestrel-delivery-static'
4
- import { resolveKestrel } from '@michaelthielemann/kestrel-core'
5
- import type { KestrelConfig } from '@michaelthielemann/kestrel-core'
6
- /**
7
- * Ships `nuxt generate`'s static output (`.output/public`) to an S3 bucket when `kestrel.output.driver`
8
- * is `s3` — the deploy half of FEATURES "Output target: local directory or S3 bucket". Runs on Nitro's
9
- * `compiled` hook (which fires once the full output is on disk — prerendered HTML, the `_nuxt` client
10
- * bundle + static `public/` assets copied by `copyPublicAssets`, and any compressed variants — i.e. after
11
- * prerendering, which does NOT abort on a route error unless `prerender.failOnError` is set: hence the
12
- * `incomplete` signal below), and only when `nitro.options.static` is set, i.e. a real `nuxt generate`.
13
- * A plain `nuxt build`/`dev` never deploys.
14
- * Nor does it with `output.auto` on — then the running server owns publishing (see the `auto` note below).
15
- * With `driver: local` (the default) it does nothing — the local `.output/public` tree is the artifact.
16
- * S3 credentials are env-only (`KESTREL_OUTPUT_S3_*`, falling back to the shared media `KESTREL_S3_*`).
17
- */
18
- export default defineNuxtModule({
19
- meta: { name: 'kestrel-deploy-output' },
20
- setup(_options, nuxt) {
21
- // Build-time: resolve from the consumer's config through the one shared resolver, so driver, prefixes
22
- // and `auto` here are byte-identical to what the runtime publisher and the media driver see — an
23
- // ad-hoc env probe that disagrees (env-first vs config-first) would silently disarm the guards below.
24
- const c = resolveKestrel(nuxt.options.kestrel as KestrelConfig, process.env, nuxt.options.rootDir)
25
- if (c.output.driver !== 's3') return
26
- const target = c.output.s3
27
-
28
- // Where the S3 media driver keeps live uploads — used to stop the reconcile from wiping them when the
29
- // generated site shares its bucket. Undefined (media on the local driver) makes the guard a no-op.
30
- const mediaS3 = c.media.driver === 's3' ? c.media.s3 : undefined
31
-
32
- nuxt.hook('nitro:init', (nitro) => {
33
- // Nitro prerenders BEFORE the build that fires `compiled`, and `prerender.failOnError` is off by
34
- // default — a route that 5xx'd leaves no file and does not abort the generate. Remember that here:
35
- // its live page is still there and must not be mistaken for content removed from the CMS.
36
- let failedRoutes = 0
37
- nitro.hooks.hook('prerender:done', ({ failedRoutes: failed }) => { failedRoutes = failed?.length ?? 0 })
38
-
39
- nitro.hooks.hook('compiled', async () => {
40
- const env = process.env
41
- const creds = resolveOutputCreds(env)
42
- const dryRun = isEnvTrue(env.KESTREL_OUTPUT_DRY_RUN)
43
- // Skip on a plain build (no static generate) or when the runtime publisher owns the bucket;
44
- // throw on a misconfigured real deploy or unsafe reconcile.
45
- if (planS3Deploy({
46
- isStaticGenerate: nitro.options.static === true,
47
- autoPublish: c.output.auto,
48
- dryRun,
49
- bucket: target.bucket,
50
- accessKeyId: creds.accessKeyId,
51
- secretAccessKey: creds.secretAccessKey,
52
- prefix: target.prefix,
53
- mediaBucket: mediaS3?.bucket,
54
- mediaPrefix: mediaS3?.prefix,
55
- }) === 'skip') {
56
- // A plain build skips silently; someone who actually ran `nuxt generate` deserves to hear why
57
- // nothing shipped.
58
- if (nitro.options.static === true && c.output.auto) {
59
- console.log('[kestrel] output.auto is on — the running server publishes to this bucket; not shipping'
60
- + ' .output/public. Set output.auto=false (or KESTREL_OUTPUT_AUTO=false) for the build-time model.')
61
- }
62
- return
63
- }
64
-
65
- const driver = createS3Driver({ ...target, publicBaseUrl: '', ...creds })
66
- console.log(`[kestrel] ${dryRun ? 'dry-run: ' : ''}shipping static output → s3://${target.bucket || '<bucket>'}/${target.prefix}`)
67
- // Both signals are REPORTED failures of a step that knows: route discovery could not enumerate the
68
- // pages, or Nitro could not render some of them. Neither is inferred from how the tree looks —
69
- // no shape of output distinguishes a degraded build from a small site.
70
- const discovery = readRouteDiscovery(nuxt)
71
- const res = await deployStaticOutput(nitro.options.output.publicDir, driver, {
72
- dryRun,
73
- incomplete: discovery?.incomplete ?? (failedRoutes ? `${failedRoutes} route(s) failed to prerender` : undefined),
74
- log: (m) => console.log(`[kestrel] ${m}`),
75
- })
76
- console.log(`[kestrel] static output ${dryRun ? 'walk' : 'deploy'} complete — ${res.keys.length} file(s), ${res.pruned} pruned.`)
77
- })
78
- })
79
- },
80
- })