@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,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
- })