@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,39 +0,0 @@
1
- import { ref } from 'vue'
2
- import type { OpItem, RelocationReport } from '../utils/ops'
3
-
4
- type Strategy = 'abort' | 'skip' | 'overwrite' | 'rename'
5
-
6
- export function useMediaRelocate(onChanged?: () => void) {
7
- const { t } = useT()
8
- const busy = ref(false)
9
- const error = ref<string | null>(null)
10
-
11
- async function preview(type: 'move' | 'copy', items: OpItem[], dest: string): Promise<RelocationReport> {
12
- error.value = null
13
- try {
14
- return await $fetch<RelocationReport>(`/api/media/${type}`, { method: 'POST', body: { items, dest, dryRun: true } })
15
- } catch (e) {
16
- error.value = (e as { statusMessage?: string })?.statusMessage ?? t('media.error.relocate')
17
- throw e
18
- }
19
- }
20
-
21
- async function execute(type: 'move' | 'copy', items: OpItem[], dest: string, onConflict: Strategy): Promise<void> {
22
- busy.value = true
23
- error.value = null
24
- try {
25
- await $fetch(`/api/media/${type}`, { method: 'POST', body: { items, dest, onConflict } })
26
- onChanged?.()
27
- } catch (e) {
28
- const err = e as { statusCode?: number; statusMessage?: string }
29
- error.value = err.statusCode === 409
30
- ? t('media.error.relocateConflict')
31
- : err.statusMessage ?? t('media.error.relocate')
32
- throw e
33
- } finally {
34
- busy.value = false
35
- }
36
- }
37
-
38
- return { busy, error, preview, execute }
39
- }
@@ -1,50 +0,0 @@
1
- import { reactive, toValue } from 'vue'
2
- import type { MaybeRefOrGetter } from 'vue'
3
- import type { ResolvedMedia } from '@michaelthielemann/kestrel-media'
4
-
5
- // The client-facing resolved-media shape (`GET /api/media/resolve`) is the server resolver's own
6
- // return type — re-exported so consumers keep importing it from here, single-sourced so it can't drift.
7
- export type { ResolvedMedia }
8
-
9
- /**
10
- * Batched, cached client resolution of media ids → `ResolvedMedia`. A reactive cache so callers can
11
- * `resolve(id)` inside a computed and have it re-evaluate once `ensure()` fills the cache. Per
12
- * instance (no shared module state → SSR-safe). The cache is keyed per locale, and `locale` may be
13
- * a ref/getter — a locale change makes its ids "missing" again so `ensure()` re-fetches and
14
- * `resolve()` returns the new-locale entry (translations differ per locale).
15
- */
16
- export function useMediaResolver(locale?: MaybeRefOrGetter<string>) {
17
- const cache = reactive(new Map<string, ResolvedMedia>())
18
- // Keys that resolved to NOTHING (deleted/dangling id). Without this, a missing id is "missing" forever and
19
- // re-requested on every ensure(); the negative cache stops re-fetching it. Keyed per (locale, id) like the
20
- // positive cache, so a locale change still re-resolves. Only populated on a SUCCESSFUL fetch that omitted
21
- // the id — a network error leaves the id retryable.
22
- const absent = new Set<string>()
23
- const pending = new Set<string>()
24
- const loc = () => toValue(locale) || 'en'
25
- const keyFor = (l: string, id: number) => `${l}:${id}`
26
-
27
- async function ensure(ids: number[]): Promise<void> {
28
- const l = loc()
29
- const missing = ids.filter((id) => Number.isInteger(id) && id > 0 && !cache.has(keyFor(l, id)) && !absent.has(keyFor(l, id)) && !pending.has(keyFor(l, id)))
30
- if (!missing.length) return
31
- for (const id of missing) pending.add(keyFor(l, id))
32
- try {
33
- const { data } = await $fetch<{ data: ResolvedMedia[] }>('/api/media/resolve', { query: { ids: missing.join(','), locale: l } })
34
- const returned = new Set<number>()
35
- for (const m of data) { cache.set(keyFor(l, m.id), m); returned.add(m.id) }
36
- // A requested id the server didn't return is gone → remember it so we don't keep asking.
37
- for (const id of missing) if (!returned.has(id)) absent.add(keyFor(l, id))
38
- } catch {
39
- // leave unresolved (NOT absent) — a transient error stays retryable on a later attempt
40
- } finally {
41
- for (const id of missing) pending.delete(keyFor(l, id))
42
- }
43
- }
44
-
45
- function resolve(id: number): ResolvedMedia | null {
46
- return cache.get(keyFor(loc(), id)) ?? null
47
- }
48
-
49
- return { cache, ensure, resolve }
50
- }
@@ -1,93 +0,0 @@
1
- import { ref, computed } from 'vue'
2
- import type { PendingUpload } from '../utils/dnd'
3
-
4
- export interface UploadItem {
5
- id: string; file: File; filename: string; folder: string
6
- status: 'queued' | 'uploading' | 'done' | 'error' | 'conflict' | 'skipped'
7
- message?: string; suggestion?: string; existingId?: number
8
- }
9
-
10
- export interface UploadCallbacks {
11
- /** Fired once a batch has fully settled (no active uploads, no unresolved conflicts). */
12
- onSettled?: () => void
13
- /** Fired for each item that fails (non-conflict). The component surfaces the reason (`item.message`). */
14
- onError?: (item: UploadItem) => void
15
- }
16
-
17
- export function useMediaUpload(cb: UploadCallbacks = {}) {
18
- const queue = ref<UploadItem[]>([])
19
- let seq = 0
20
- const conflicts = computed(() => queue.value.filter((i) => i.status === 'conflict'))
21
- const active = computed(() => queue.value.some((i) => i.status === 'queued' || i.status === 'uploading'))
22
- const counts = computed(() => ({
23
- total: queue.value.length,
24
- done: queue.value.filter((i) => i.status === 'done').length,
25
- error: queue.value.filter((i) => i.status === 'error').length,
26
- conflict: conflicts.value.length,
27
- skipped: queue.value.filter((i) => i.status === 'skipped').length,
28
- }))
29
-
30
- async function uploadItem(item: UploadItem, opts: { overwrite?: boolean; filename?: string } = {}) {
31
- item.status = 'uploading'
32
- const fd = new FormData()
33
- fd.append('file', item.file)
34
- if (item.folder) fd.append('folder', item.folder)
35
- fd.append('filename', opts.filename ?? item.filename)
36
- if (opts.overwrite) fd.append('overwrite', 'true')
37
- try {
38
- await $fetch('/api/media/upload', { method: 'POST', body: fd })
39
- item.status = 'done'
40
- } catch (e) {
41
- type ConflictData = { suggestion?: string; existingId?: number }
42
- const err = e as { statusCode?: number; statusMessage?: string; data?: ConflictData & { statusMessage?: string; data?: ConflictData } }
43
- if (err.statusCode === 409) {
44
- // $fetch wraps the thrown H3 error: the original payload is nested under data.data over HTTP.
45
- const payload = err.data?.data ?? err.data
46
- item.status = 'conflict'; item.suggestion = payload?.suggestion; item.existingId = payload?.existingId
47
- } else if (err.statusCode === 401) {
48
- // A mid-session 401 is owned by the re-auth interceptor (it redirects to login) — don't also
49
- // raise a per-item toast for the same event.
50
- item.status = 'error'
51
- } else {
52
- // Prefer the server reason from the JSON body (transport-independent) over `statusMessage`,
53
- // which mirrors `response.statusText` — blank on HTTP/2 and rewritten by proxies. The component
54
- // localizes a fallback when neither is present.
55
- item.status = 'error'; item.message = err.data?.statusMessage ?? err.statusMessage
56
- cb.onError?.(item)
57
- }
58
- }
59
- }
60
-
61
- function settle() { if (!active.value && !conflicts.value.length) cb.onSettled?.() }
62
-
63
- async function run() {
64
- for (const item of queue.value) if (item.status === 'queued') await uploadItem(item)
65
- settle()
66
- }
67
-
68
- function enqueueUploads(uploads: PendingUpload[]) {
69
- for (const u of uploads) queue.value.push({ id: `u${++seq}`, file: u.file, filename: u.file.name, folder: u.folder, status: 'queued' })
70
- return run()
71
- }
72
-
73
- function enqueue(files: File[], folder: string) {
74
- return enqueueUploads(files.map((file) => ({ file, folder })))
75
- }
76
-
77
- async function resolve(id: string, action: 'overwrite' | 'rename' | 'skip', name?: string) {
78
- const item = queue.value.find((i) => i.id === id)
79
- if (!item || item.status !== 'conflict') return
80
- if (action === 'skip') item.status = 'skipped'
81
- else if (action === 'overwrite') await uploadItem(item, { overwrite: true })
82
- else { item.filename = name ?? item.suggestion ?? item.filename; await uploadItem(item, { filename: item.filename }) }
83
- settle()
84
- }
85
-
86
- async function resolveAll(action: 'overwrite' | 'rename' | 'skip') {
87
- for (const item of [...conflicts.value]) await resolve(item.id, action, action === 'rename' ? item.suggestion : undefined)
88
- }
89
-
90
- function reset() { queue.value = [] }
91
-
92
- return { queue, conflicts, active, counts, enqueue, enqueueUploads, resolve, resolveAll, reset }
93
- }
@@ -1,34 +0,0 @@
1
- import { computed, toValue, type MaybeRefOrGetter } from 'vue'
2
- import type { ResolvedMedia } from '@michaelthielemann/kestrel-media'
3
- import type { ResolvedVariant } from '@michaelthielemann/kestrel-core'
4
- import { buildPictureModel, requestVariantSpecs, type VariantRequest, type PictureModel } from '../utils/picture-model'
5
-
6
- /**
7
- * Reactive `<picture>` view-model for a resolved media + a variant request. The single render-AND-declare
8
- * site: one call may declare MULTIPLE sizes + formats. During a publish/generate SSR render it also stashes
9
- * the call's concrete specs on the request event, so the publish `beforeResponse` hook collects them and the
10
- * end-of-publish reconcile registers exactly what was rendered (usage-driven auto-discovery). Server-guarded,
11
- * so it is a no-op on the client and in non-Nuxt unit mounts.
12
- */
13
- export function useMediaVariant(
14
- media: MaybeRefOrGetter<ResolvedMedia | null | undefined>,
15
- request: MaybeRefOrGetter<VariantRequest>,
16
- opts?: { priority?: MaybeRefOrGetter<boolean> },
17
- ) {
18
- if (import.meta.server) {
19
- const specs = requestVariantSpecs(toValue(request))
20
- if (specs.length) {
21
- const ev = useRequestEvent()
22
- if (ev) {
23
- const ctx = ev.context as { kestrelVariants?: ResolvedVariant[] }
24
- ;(ctx.kestrelVariants ??= []).push(...specs)
25
- }
26
- }
27
- }
28
- const model = computed<PictureModel | null>(() => {
29
- const m = toValue(media)
30
- if (!m) return null
31
- return buildPictureModel(m, toValue(request), { lazy: !toValue(opts?.priority ?? false) })
32
- })
33
- return { model }
34
- }
@@ -1,6 +0,0 @@
1
- // components are NOT auto-imported in a plugin's script context — pull KestrelFieldMedia from #components
2
- import { KestrelFieldMedia } from '#components'
3
-
4
- export default defineNuxtPlugin(() => {
5
- registerFieldComponent('media', KestrelFieldMedia)
6
- })
@@ -1,19 +0,0 @@
1
- import type { AiSourceType } from '@michaelthielemann/kestrel-media'
2
-
3
- /**
4
- * Default human-readable label for an EU AI Act source type — the badge text when no editor note was
5
- * entered. English-only on purpose: it is a fallback, not a translation layer, and a consumer who wants
6
- * their own wording (or locale) writes the `aiNote`, styles `.kestrel-img__ai-badge`, or reads
7
- * `ResolvedMedia.aiDisclosure` and renders their own element. Kept pure so it stays unit-testable.
8
- *
9
- * An unrecognised value is returned VERBATIM rather than mapped to a generic label: the column is plain
10
- * text at the DB level, and quietly relabelling an unknown value would state a disclosure nobody made.
11
- */
12
- export function aiSourceTypeLabel(sourceType: AiSourceType | string): string {
13
- switch (sourceType) {
14
- case 'trainedAlgorithmicMedia': return 'AI-generated'
15
- case 'compositeWithTrainedAlgorithmicMedia': return 'Contains AI-generated content'
16
- case 'algorithmicallyEnhanced': return 'AI-edited'
17
- default: return sourceType
18
- }
19
- }
@@ -1,69 +0,0 @@
1
- import { joinFolder } from './library'
2
-
3
- export interface PendingUpload { file: File; folder: string }
4
- export interface WalkResult { uploads: PendingUpload[]; dirs: string[] }
5
-
6
- export interface DirectoryReaderLike {
7
- readEntries(success: (entries: EntryLike[]) => void, error?: (e: unknown) => void): void
8
- }
9
- export interface EntryLike {
10
- isFile: boolean
11
- isDirectory: boolean
12
- name: string
13
- file?(success: (file: File) => void, error?: (e: unknown) => void): void
14
- createReader?(): DirectoryReaderLike
15
- }
16
-
17
- /** Drain a DirectoryReader — readEntries yields in batches (Chrome caps ~100), loop until empty. */
18
- export function readAllEntries(reader: DirectoryReaderLike): Promise<EntryLike[]> {
19
- return new Promise((resolve, reject) => {
20
- const all: EntryLike[] = []
21
- const next = () => reader.readEntries((batch) => { if (!batch.length) return resolve(all); all.push(...batch); next() }, reject)
22
- next()
23
- })
24
- }
25
-
26
- function entryFile(entry: EntryLike): Promise<File> {
27
- return new Promise((resolve, reject) => entry.file!((f) => resolve(f), reject))
28
- }
29
-
30
- export async function walkEntry(entry: EntryLike, base: string, out: WalkResult): Promise<void> {
31
- if (entry.isFile) {
32
- out.uploads.push({ file: await entryFile(entry), folder: base })
33
- } else if (entry.isDirectory && entry.createReader) {
34
- const dir = joinFolder(base, entry.name)
35
- out.dirs.push(dir)
36
- for (const child of await readAllEntries(entry.createReader())) await walkEntry(child, dir, out)
37
- }
38
- }
39
-
40
- interface DataTransferLike {
41
- items?: ArrayLike<{ webkitGetAsEntry?(): EntryLike | null }> | null
42
- files?: ArrayLike<File> | null
43
- }
44
-
45
- export async function extractUploads(dt: DataTransferLike, base: string): Promise<WalkResult> {
46
- const out: WalkResult = { uploads: [], dirs: [] }
47
- // Grab entries SYNCHRONOUSLY before any await — the DataTransferItemList dies once the handler yields.
48
- const entries = (dt.items ? Array.from(dt.items) : [])
49
- .map((it) => it.webkitGetAsEntry?.() ?? null)
50
- .filter((e): e is EntryLike => !!e)
51
- if (entries.length) {
52
- for (const entry of entries) await walkEntry(entry, base, out)
53
- } else {
54
- for (const file of Array.from(dt.files ?? [])) out.uploads.push({ file, folder: base })
55
- }
56
- return out
57
- }
58
-
59
- /** The minimal set of folders to create explicitly: dirs no uploaded file already creates
60
- * (ensure-on-upload makes a file's whole ancestor chain), reduced to the deepest. */
61
- export function selectEmptyFolders(uploads: PendingUpload[], dirs: string[]): string[] {
62
- const covered = new Set<string>()
63
- for (const u of uploads) {
64
- const segs = u.folder.split('/').filter(Boolean)
65
- for (let i = 1; i <= segs.length; i++) covered.add(segs.slice(0, i).join('/'))
66
- }
67
- const empty = dirs.filter((d) => d && !covered.has(d))
68
- return empty.filter((d) => !empty.some((o) => o !== d && o.startsWith(`${d}/`)))
69
- }
@@ -1,81 +0,0 @@
1
- import type { LibraryFolder } from '@michaelthielemann/kestrel-media'
2
-
3
- // Re-exported from the server util that produces it (`GET /api/media/library`) so the wire shape is
4
- // single-sourced.
5
- export type { LibraryFolder }
6
-
7
- export interface LibraryFile {
8
- id: number; filename: string; mime: string; folder: string; size: number
9
- width?: number; height?: number; thumbhash?: string; src: string; srcset?: string; alt?: string
10
- /** EU AI Act Art. 50 disclosure — always listed; `kestrel.config.ts`'s flag only gates the editor. */
11
- aiDisclosure?: { sourceType: string; note: string | null } | null
12
- createdAt?: string
13
- }
14
- export type LibraryItem =
15
- | { type: 'folder'; folder: LibraryFolder }
16
- | { type: 'file'; file: LibraryFile }
17
-
18
- export function itemKey(item: LibraryItem): string {
19
- return item.type === 'folder' ? `folder:${item.folder.path}` : `file:${item.file.id}`
20
- }
21
-
22
- /** Inclusive range between the anchor and target keys (by position in `orderedKeys`), unioned
23
- * onto `current`. If the anchor is not found, selects only the target. */
24
- export function computeRange(orderedKeys: string[], anchorKey: string, targetKey: string, current: Set<string>): Set<string> {
25
- const out = new Set(current)
26
- const t = orderedKeys.indexOf(targetKey)
27
- if (t < 0) return out
28
- const a = orderedKeys.indexOf(anchorKey)
29
- if (a < 0) { out.add(targetKey); return out }
30
- for (let i = Math.min(a, t); i <= Math.max(a, t); i++) out.add(orderedKeys[i])
31
- return out
32
- }
33
-
34
- export function humanizeSize(bytes: number): string {
35
- if (bytes < 1024) return `${bytes} B`
36
- const kb = bytes / 1024
37
- if (kb < 1024) return `${kb.toFixed(1)} KB`
38
- return `${(kb / 1024).toFixed(1)} MB`
39
- }
40
-
41
- /** The parent of an internal folder path, or `null` at the root — never above it (no path traversal).
42
- * `''` → null (root); `'a'` → `''`; `'a/b'` → `'a'`. */
43
- export function parentFolder(folder: string): string | null {
44
- if (folder === '') return null
45
- const i = folder.lastIndexOf('/')
46
- return i === -1 ? '' : folder.slice(0, i)
47
- }
48
-
49
- /** Split a typed folder path into the existing-parent part and the trailing fragment being typed. */
50
- export function splitPathInput(input: string): { parent: string; fragment: string } {
51
- const i = input.lastIndexOf('/')
52
- return i === -1 ? { parent: '', fragment: input } : { parent: input.slice(0, i), fragment: input.slice(i + 1) }
53
- }
54
-
55
- /** Join folder parts into a clean relative path (drops empty segments). Also parses a typed display
56
- * path back to the internal form: `joinFolder('/test123/')` → `'test123'`, `joinFolder('/')` → `''`. */
57
- export function joinFolder(...parts: string[]): string {
58
- return parts.flatMap((p) => p.split('/')).filter(Boolean).join('/')
59
- }
60
-
61
- /** Internal folder path → the slash-delimited path shown to the user, rooted at the media directory:
62
- * `''` → `'/'`, `'test123'` → `'/test123/'`, `'a/b'` → `'/a/b/'`. Tolerant of stray slashes in input. */
63
- export function displayFolderPath(folder: string): string {
64
- const clean = joinFolder(folder)
65
- return clean === '' ? '/' : `/${clean}/`
66
- }
67
-
68
- /** Longest common FOLDER of clean relative paths; '' (root) when they diverge at root or none given.
69
- * Keeps only equal leading segments and drops empties, so it can never emit '..' or escape root. */
70
- export function commonFolder(folders: string[]): string {
71
- if (!folders.length) return ''
72
- const split = folders.map((f) => f.split('/').filter(Boolean))
73
- let prefix = split[0]!
74
- for (const segs of split.slice(1)) {
75
- let i = 0
76
- while (i < prefix.length && prefix[i] === segs[i]) i++
77
- prefix = prefix.slice(0, i)
78
- if (!prefix.length) break
79
- }
80
- return prefix.join('/')
81
- }
@@ -1,60 +0,0 @@
1
- import type { LibraryItem } from './library'
2
- import type { OpItem, DeleteReport, Conflict, RelocationReport, MediaUsage } from '@michaelthielemann/kestrel-media'
3
-
4
- // Single-source the HTTP wire shapes from the server utils that own them — one definition, no drift.
5
- export type { OpItem, DeleteReport, Conflict, RelocationReport, MediaUsage }
6
-
7
- /** Structural shape of a UiMenu entry (UiMenu declares its own copy; kept in sync structurally). */
8
- export interface MenuItem { label: string; value: string; danger?: boolean; disabled?: boolean }
9
-
10
- /** A menu entry before its label is localized: carries an i18n key (+ a `count` for plural-bearing
11
- * entries) so the pure builder stays free of presentation copy; the composable resolves it via useT. */
12
- export interface MenuItemSpec { labelKey: string; count?: number; value: string; danger?: boolean }
13
-
14
- export function toOpItem(item: LibraryItem): OpItem {
15
- return item.type === 'folder' ? { type: 'folder', path: item.folder.path } : { type: 'file', id: item.file.id }
16
- }
17
-
18
- /** Map a right-clicked element to its LibraryItem via the data-id attributes
19
- * (files carry data-file-id, folders carry data-drop-folder). Null if neither matches. */
20
- export function resolveTargetItem(el: Element | null, items: LibraryItem[]): LibraryItem | null {
21
- const fileEl = el?.closest('[data-file-id]')
22
- if (fileEl) {
23
- const id = Number(fileEl.getAttribute('data-file-id'))
24
- return items.find((i) => i.type === 'file' && i.file.id === id) ?? null
25
- }
26
- const folderEl = el?.closest('[data-drop-folder]')
27
- if (folderEl) {
28
- const path = folderEl.getAttribute('data-drop-folder')
29
- return items.find((i) => i.type === 'folder' && i.folder.path === path) ?? null
30
- }
31
- return null
32
- }
33
-
34
- /** Explorer semantics: right-clicking a selected item targets the whole selection;
35
- * right-clicking elsewhere targets just that item. */
36
- export function effectiveTargets(clicked: LibraryItem, isSelected: (i: LibraryItem) => boolean, selected: LibraryItem[]): LibraryItem[] {
37
- return isSelected(clicked) ? selected : [clicked]
38
- }
39
-
40
- /** Build the context-menu items for the current target + clipboard state. Blank space with an
41
- * empty clipboard returns [] (the caller suppresses the menu). */
42
- export function buildMenuItems(ctx: { targetCount: number; targetType: 'file' | 'folder' | null; clipboardEmpty: boolean }): MenuItemSpec[] {
43
- const { targetCount, targetType, clipboardEmpty } = ctx
44
- if (targetCount === 0) return clipboardEmpty ? [] : [{ labelKey: 'media.menu.paste', value: 'paste' }]
45
- if (targetCount === 1) {
46
- const items: MenuItemSpec[] = [
47
- { labelKey: 'media.menu.cut', value: 'cut' },
48
- { labelKey: 'media.menu.copy', value: 'copy' },
49
- { labelKey: 'media.menu.rename', value: 'rename' },
50
- { labelKey: 'media.menu.delete', value: 'delete', danger: true },
51
- ]
52
- if (targetType === 'folder' && !clipboardEmpty) items.push({ labelKey: 'media.menu.pasteInto', value: 'paste' })
53
- return items
54
- }
55
- return [
56
- { labelKey: 'media.menu.cut', value: 'cut' },
57
- { labelKey: 'media.menu.copy', value: 'copy' },
58
- { labelKey: 'media.menu.deleteN', count: targetCount, value: 'delete', danger: true },
59
- ]
60
- }
@@ -1,125 +0,0 @@
1
- import type { ResolvedMedia } from '@michaelthielemann/kestrel-media'
2
- import type { VariantFit, VariantFormat, ResolvedVariant } from '@michaelthielemann/kestrel-core'
3
- import { variantName } from './variant-name'
4
-
5
- /** What a `KestrelImg` / `useMediaVariant` call declares it needs. One call may declare MULTIPLE sizes and
6
- * formats: `widths` (proportional responsive set) and/or a fixed-box `crop`, plus named config `preset`s,
7
- * crossed with `formats` (preference order, most-modern first). */
8
- export interface VariantRequest {
9
- widths?: number[]
10
- crop?: { width: number; height: number; fit?: VariantFit }
11
- preset?: string | string[]
12
- formats?: VariantFormat[]
13
- /** The `<img sizes>` attribute; `auto` (or `auto, <fallback>`) is honoured on lazy images (see resolveSizes). */
14
- sizes?: string
15
- }
16
-
17
- export interface PictureSource { type: string; srcset: string }
18
- export interface PictureModel {
19
- sources: PictureSource[]
20
- src: string
21
- width: number | null
22
- height: number | null
23
- alt: string
24
- sizes?: string
25
- }
26
-
27
- const MIME: Record<string, string> = { webp: 'image/webp', jpeg: 'image/jpeg', jpg: 'image/jpeg', png: 'image/png', avif: 'image/avif', gif: 'image/gif' }
28
-
29
- /**
30
- * `sizes=auto` lets the browser pick the srcset candidate from the image's rendered width, but ONLY on
31
- * lazy-loaded images — on eager ones it is inert. Non-supporting browsers ignore the `auto` keyword and use
32
- * the fallback after it, so `auto, 100vw` degrades to `100vw`. Hence: keep `auto` only when lazy (with any
33
- * fallback), and on an eager image drop the keyword, leaving just the fallback.
34
- */
35
- export function resolveSizes(sizes: string | undefined, lazy: boolean): string | undefined {
36
- if (!sizes) return undefined
37
- const t = sizes.trim()
38
- if (/^auto\b/i.test(t)) {
39
- const fallback = t.slice(4).replace(/^\s*,\s*/, '').trim()
40
- if (!lazy) return fallback || undefined
41
- return fallback ? `auto, ${fallback}` : 'auto'
42
- }
43
- return t
44
- }
45
-
46
- /**
47
- * The concrete, registrable specs a request declares (for the prerender scan): each `width` → a
48
- * proportional `w<width>` spec, a `crop` → a fixed-box spec, both crossed with the request's formats.
49
- * Named `preset`s are NOT expanded — they are already config-resolved (referenced by name, not discovered).
50
- */
51
- export function requestVariantSpecs(req: VariantRequest): ResolvedVariant[] {
52
- const formats: VariantFormat[] = req.formats?.length ? req.formats : ['webp']
53
- const out: ResolvedVariant[] = []
54
- // props may bind strings; drop any non-positive-integer dim so a bad spec is never registered (would crash sharp later)
55
- for (const raw of req.widths ?? []) {
56
- const w = posInt(raw)
57
- if (w != null) out.push({ name: variantName({ width: w }), width: w, height: null, fit: 'cover', position: 'centre', formats: [...formats] })
58
- }
59
- if (req.crop) {
60
- const w = posInt(req.crop.width)
61
- const h = posInt(req.crop.height)
62
- if (w != null && h != null) out.push({ name: variantName({ width: w, height: h, fit: req.crop.fit }), width: w, height: h, fit: req.crop.fit ?? 'cover', position: 'centre', formats: [...formats] })
63
- }
64
- return out
65
- }
66
-
67
- function posInt(value: unknown): number | null {
68
- const n = Math.floor(Number(value))
69
- return Number.isFinite(n) && n >= 1 ? n : null
70
- }
71
-
72
- /** The variant names a request targets: derived names for widths/crop + any explicit preset names.
73
- * Applies the SAME posInt sanitisation as requestVariantSpecs so the render-lookup name matches the
74
- * registered/derived name — otherwise a fractional width registers `w640` but looks up `w640.5`, and the
75
- * derivative is never used. */
76
- export function requestedNames(req: VariantRequest): string[] {
77
- const names: string[] = []
78
- for (const raw of req.widths ?? []) {
79
- const w = posInt(raw)
80
- if (w != null) names.push(variantName({ width: w }))
81
- }
82
- if (req.crop) {
83
- const w = posInt(req.crop.width)
84
- const h = posInt(req.crop.height)
85
- if (w != null && h != null) names.push(variantName({ width: w, height: h, fit: req.crop.fit }))
86
- }
87
- const presets = req.preset ? (Array.isArray(req.preset) ? req.preset : [req.preset]) : []
88
- names.push(...presets)
89
- return [...new Set(names)]
90
- }
91
-
92
- /**
93
- * Build the `<picture>` view-model for a resolved media + a variant request: one `<source>` per requested
94
- * format (in the request's preference order) whose srcset is the matching derivatives, and an `<img>`
95
- * fallback = the largest derivative of the LAST (most-compatible) format, or the original when none exist.
96
- * Pure — the load-bearing render logic, unit-tested without a DOM.
97
- */
98
- export function buildPictureModel(media: ResolvedMedia, req: VariantRequest, opts: { lazy: boolean }): PictureModel {
99
- const names = new Set(requestedNames(req))
100
- const formats = (req.formats?.length ? req.formats : ['webp']) as string[]
101
- const pool = media.variants.filter((v) => names.has(v.name))
102
-
103
- const sources: PictureSource[] = []
104
- for (const fmt of formats) {
105
- const entries = pool.filter((v) => v.format === fmt).sort((a, b) => a.width - b.width)
106
- if (!entries.length) continue
107
- sources.push({ type: MIME[fmt] ?? `image/${fmt}`, srcset: entries.map((v) => `${v.url} ${v.width}w`).join(', ') })
108
- }
109
-
110
- // <img> fallback: the largest derivative of the last (most-compatible) requested format; else the original
111
- // (which may be a large PNG/AVIF, but is always a valid last resort).
112
- const lastFmt = formats[formats.length - 1]
113
- const lastEntries = pool.filter((v) => v.format === lastFmt).sort((a, b) => a.width - b.width)
114
- const fallback = lastEntries[lastEntries.length - 1]
115
-
116
- return {
117
- sources,
118
- src: fallback?.url ?? media.src,
119
- width: fallback?.width ?? media.width,
120
- height: fallback?.height ?? media.height,
121
- alt: media.alt ?? '',
122
- // Lazy images with no explicit sizes default to `auto, 100vw` — a strict upgrade over the 100vw default.
123
- sizes: resolveSizes(req.sizes ?? (opts.lazy ? 'auto, 100vw' : undefined), opts.lazy),
124
- }
125
- }
@@ -1,19 +0,0 @@
1
- import { thumbHashToDataURL } from 'thumbhash'
2
-
3
- /**
4
- * Decode a base64 thumbhash (as stored by the media pipeline, `derive.ts`) to a tiny PNG data URL — or
5
- * null when absent/malformed. Pure + deterministic. Decoded CLIENT-SIDE only (see `useBlurUp`): the
6
- * ~6 KB PNG data URL must not be inlined in SSR HTML — the ~30-byte thumbhash string ships in the
7
- * payload and is inflated here after mount. Never throws — bad input degrades to null (no placeholder).
8
- */
9
- export function thumbhashToDataUrl(thumbhash: string | null | undefined): string | null {
10
- if (!thumbhash) return null
11
- try {
12
- const bin = atob(thumbhash)
13
- const bytes = new Uint8Array(bin.length)
14
- for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
15
- return thumbHashToDataURL(bytes)
16
- } catch {
17
- return null
18
- }
19
- }
@@ -1,20 +0,0 @@
1
- import type { VariantFit } from '@michaelthielemann/kestrel-core'
2
-
3
- export interface VariantShape {
4
- width: number
5
- /** A fixed-box height (crop). null/undefined ⇒ proportional (width-only). */
6
- height?: number | null
7
- fit?: VariantFit
8
- }
9
-
10
- /**
11
- * Canonical stable name for a variant shape — the manifest/object-key stem. This ONE function is shared by
12
- * the render lookup (`KestrelImg` / `useMediaVariant`), the prerender scan that registers a discovered spec,
13
- * and (via the registry) `deriveImage`, so all three agree on the name. Names stay `[A-Za-z0-9_-]` so the
14
- * `<name>.<format>` manifest key and the relocate parser split cleanly on the last dot.
15
- */
16
- export function variantName(shape: VariantShape): string {
17
- if (shape.height == null) return `w${shape.width}`
18
- const fit = shape.fit ?? 'cover'
19
- return fit === 'cover' ? `c${shape.width}x${shape.height}` : `c${shape.width}x${shape.height}-${fit}`
20
- }
@@ -1,7 +0,0 @@
1
- import { kestrelComponents } from '../core/modules/component-namespace/shared'
2
-
3
- // Marks `media` as a Nuxt layer (so its components/server code are auto-registered). The media config
4
- // + the `kestrel` module that resolves it now live in the core layer (single non-auth config source).
5
- export default defineNuxtConfig({
6
- components: [kestrelComponents(import.meta.url)],
7
- })