Repository: nuxt/image (ts)

Issue:
adding support for uploadcare provider
[Uploadcare](https://uploadcare.com/) comes pre-configured with Netlify CMS, popular for managing a Nuxt websites.

Trying to follow the doc to create a custom provider but it's a bit too obscure for my skill level.

Any help is welcome

Cheers,

Pull request description:
feat: add `uploadcare` provider
This PR resolves #513 by implementing a new built-in provider for uploadcare.

 ### Operations 
 * [x] Image Compression
 * [x] Image Geometry
 * [ ] Image Overlays
 * [ ] Image Colours
 * [ ] Image Definition
 * [ ] Image Rotations
 
 ### Other stuff to think about later:
 * Signed URLs
 * File Groups
 * Custom CNAME: https://uploadcare.com/docs/delivery/cdn/#custom-cdn-cname

Patch:
diff --git a/docs/content/5.providers/uploadcare.md b/docs/content/5.providers/uploadcare.md
new file mode 100644
index 0000000..58107e5
--- /dev/null
+++ b/docs/content/5.providers/uploadcare.md
@@ -0,0 +1,57 @@
+# Uploadcare
+
+> Power up file uploading, processing and delivery for your app in one sitting.
+
+Integration between [Uploadcare](https://uploadcare.com/) and the Nuxt Image module.
+
+## Usage
+To use images from uploadcare, specify the provider as `uploadcare` and set the image src to the UUID provided on the dashboard.
+
+```vue [page.vue]
+<NuxtImg
+  provider="uploadcare"
+  src="c160afba-8b42-45a9-a46a-d393248b0072"
+  alt="My image from uploadcare"
+/>
+```
+
+
+## Modifiers
+To see all possible modifiers and their options, check out the [image transformations documentation](https://uploadcare.com/docs/transformations/image/) or the [URL API Reference](https://uploadcare.com/api-refs/url-api/).
+
+Types are provided for the following modifiers:
+```ts [src/types/module.ts]
+  // Image Compression
+  format: 'jpeg'|'png'|'webp'|'auto'
+  quality: 'smart'|'smart_retina'|'normal'|'better'|'best'|'lighter'|'lightest'
+  progressive: 'yes'|'no'
+  strip_meta: 'all'|'none'|'sensitive'
+
+  // Image Geometry
+  preview: `${number}x${number}` // Height x Width
+  resize: `${number}x${number}` | `${number}x`| `x${number}` 
+  smart_resize: `${number}x${number}`
+  crop: string | string[]
+  scale_crop: string | string[]
+  border_radius: string | string[]
+  setfill: string // 3, 6 or 8 digit hex color
+  zoom_objects: string // 1 to 100
+```
+
+Please feel free to open a PR to improve support for additional operations.
+
+## Configuration
+
+> By default, all file URLs use the `ucarecdn.com` domain. By setting a custom CDN CNAME, file URLs can use `cdn.mycompany.com` instead.
+
+See the [Uploadcare documentation](https://uploadcare.com/docs/delivery/cdn/#custom-cdn-cname) for how to enable a custom domain in your project. To tell Nuxt Image about the custom CDN name, use the following configuration:
+
+```ts  [nuxt.config.ts]
+export default defineNuxtConfig({
+  image: {
+    uploadcare: {
+      cdnURL: 'cdn.mycompany.com',
+    }
+  }
+})
+```
\ No newline at end of file
diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts
index 6d4b1ef..8fe8b4d 100644
--- a/playground/nuxt.config.ts
+++ b/playground/nuxt.config.ts
@@ -81,6 +81,8 @@ export default defineNuxtConfig({
     imageengine: {
       baseURL: 'https://abc123.imgeng.in'
     },
+    uploadcare: {
+    },
     providers: {
       custom: {
         provider: '~/providers/custom',
diff --git a/playground/providers.ts b/playground/providers.ts
index 269140d..50c02a1 100644
--- a/playground/providers.ts
+++ b/playground/providers.ts
@@ -768,6 +768,58 @@ export const providers: Provider[] = [
       }
     ]
   },
+  // Uploadcare
+  {
+    name: 'uploadcare',
+    samples: [
+      {
+        src: 'c160afba-8b42-45a9-a46a-d393248b0072',
+        width: 512,
+        height: 512
+      },
+      {
+        src: '4740215e-ed0f-478f-8dbd-4859d8ea4e60',
+        width: 512
+      },
+      {
+        src: '2e36710d-21ea-42b3-ac9e-3caa80f7e7bc',
+        height: 512
+      },
+      {
+        src: '678053e2-ad98-42be-8443-e114b8a1b294',
+        width: 1080,
+        height: 300,
+        fit: 'cover'
+      },
+      {
+        src: '034e80f0-14af-408a-9eae-1e233c361256',
+        width: 1080,
+        height: 300,
+        fit: 'contain',
+        modifiers: {
+          setfill: '000000'
+        }
+      },
+      {
+        src: '6d7dd597-a829-4f03-a3cc-f87ff8a94d14',
+        width: 1080,
+        height: 300,
+        fit: 'fill'
+      },
+      {
+        src: 'beb68506-f5c3-4b85-8d8c-143ad62d5390',
+        width: 1080,
+        height: 300,
+        fit: 'inside'
+      },
+      {
+        src: '31e84033-f22e-4ed0-9462-76f9179e13e1',
+        width: 1080,
+        height: 300,
+        fit: 'outside'
+      }
+    ]
+  },
   // wagtail
   {
     name: 'wagtail',
diff --git a/src/ipx.ts b/src/ipx.ts
index 4b774f9..a18daa4 100644
--- a/src/ipx.ts
+++ b/src/ipx.ts
@@ -3,7 +3,15 @@ import { eventHandler } from 'h3'
 import { useNuxt, createResolver, useNitro } from '@nuxt/kit'
 import type { NitroEventHandler, NitroDevEventHandler } from 'nitropack'
 
-import type { ProviderSetup, ImageProviders } from './types'
+import type { ProviderSetup } from './types'
+
+interface IPXRuntimeConfig {
+  dir: string
+  maxAge?: any
+  domains: string[]
+  sharp: any
+  alias: Record<string, string>
+}
 
 export const ipxSetup: ProviderSetup = async (providerOptions, moduleOptions) => {
   const nitro = useNitro()
@@ -18,13 +26,13 @@ export const ipxSetup: ProviderSetup = async (providerOptions, moduleOptions) =>
   }
 
   // Options
-  const ipxOptions: ImageProviders['ipx'] = {
+  const ipxOptions = {
     dir: resolve(nuxt.options.srcDir, moduleOptions.dir || nuxt.options.dir.public),
     maxAge: providerOptions.options?.maxAge,
     domains: moduleOptions.domains,
     sharp: moduleOptions.sharp,
     alias: moduleOptions.alias
-  }
+  } satisfies IPXRuntimeConfig
 
   // Add handler for production
   if (!nuxt.options.dev) {
@@ -68,3 +76,9 @@ export const ipxSetup: ProviderSetup = async (providerOptions, moduleOptions) =>
   // TODO: Workaround for prerender support
   nitro.options._config.devHandlers!.push(devHandler)
 }
+
+declare module 'nitropack' {
+  interface NitroRuntimeConfig {
+    ipx?: IPXRuntimeConfig
+  }
+}
diff --git a/src/provider.ts b/src/provider.ts
index 5e6dce8..9de485b 100644
--- a/src/provider.ts
+++ b/src/provider.ts
@@ -34,6 +34,7 @@ const BuiltInProviders = [
   'strapi',
   'twicpics',
   'unsplash',
+  'uploadcare',
   'vercel',
   'wagtail'
 ]
diff --git a/src/runtime/providers/uploadcare.ts b/src/runtime/providers/uploadcare.ts
new file mode 100644
index 0000000..2be59f8
--- /dev/null
+++ b/src/runtime/providers/uploadcare.ts
@@ -0,0 +1,68 @@
+/**
+ * Image provider for Uploadcare
+ * URL API reference:
+ * @link https://uploadcare.com/api-refs/url-api/
+ *
+ * URL format: https://ucarecdn.com/:uuid/-/:operation/:params/:filename
+ *
+ * Operations:
+ * [X] Image Compression
+ * [/] Image Geometry
+ * [ ] Image Overlays
+ * [ ] Image Colours
+ * [ ] Image Definition
+ * [ ] Image Rotations
+ *
+ * Other stuff to think about later:
+ * - Signed URLs
+ * - File Groups
+ *  */
+
+import { joinURL, withTrailingSlash } from 'ufo'
+import { ProviderGetImage } from '../../types'
+import { createOperationsGenerator } from '#image'
+
+const operationsGenerator = createOperationsGenerator({
+  joinWith: '',
+  formatter: (key:string, value:string | string[]) => `-/${key}/${Array.isArray(value) ? value.join('/') : value}/`
+})
+
+export const getImage: ProviderGetImage = (
+  uuid,
+  { modifiers, cdnURL = '' } = {}
+) => {
+  // If width or height is specified, use resize instead
+  if (modifiers?.width || modifiers?.height) {
+    modifiers.resize = `${modifiers?.width || ''}x${modifiers?.height || ''}`
+
+    delete modifiers?.width
+    delete modifiers?.height
+  }
+
+  // If fit is specified, use a different operation
+  if (modifiers?.fit) {
+    switch (modifiers.fit) {
+      case 'cover':
+        modifiers.scale_crop = [modifiers.resize, 'center']
+        delete modifiers.resize
+        break
+      case 'contain':
+        modifiers.stretch = 'off'
+        break
+        //   case 'fill':
+        //   case 'inside':
+        //   case 'outside':
+        //     modifiers.crop = modifiers.smart_resize
+        //     break
+      default:
+        modifiers.smart_resize = modifiers.resize
+        delete modifiers.resize
+        break
+    }
+    delete modifiers.fit
+  }
+
+  const operations = operationsGenerator(modifiers)
+  const url = withTrailingSlash(joinURL(cdnURL || 'https://ucarecdn.com', uuid, operations))
+  return { url }
+}
diff --git a/src/types/module.ts b/src/types/module.ts
index bef8623..d124b91 100644
--- a/src/types/module.ts
+++ b/src/types/module.ts
@@ -41,6 +41,29 @@ export interface CloudinaryOptions {
   [key: string]: any
 }
 
+export interface UploadcareModifiers extends ImageModifiers{
+  // Image Compression
+  format: 'jpeg'|'png'|'webp'|'auto'
+  quality: 'smart'|'smart_retina'|'normal'|'better'|'best'|'lighter'|'lightest'
+  progressive: 'yes'|'no'
+  strip_meta: 'all'|'none'|'sensitive'
+  // Image Geometry
+  preview: `${number}x${number}`
+  resize: `${number}x${number}` | `${number}x`| `x${number}`
+  smart_resize: `${number}x${number}`
+  crop: string | string[]
+  scale_crop: string | string[]
+  border_radius: string | string[]
+  setfill: string // 3, 6 or 8 digit hex color
+  zoom_objects: string // 1 to 100
+}
+
+export interface UploadcareOptions {
+  cdnURL: string
+  modifiers: Partial<UploadcareModifiers>
+  [key: string]: any
+}
+
 export interface ImageProviders {
   cloudflare?: any
   cloudinary?: Partial<CloudinaryOptions>
@@ -59,6 +82,7 @@ export interface ImageProviders {
   wagtail?: any,
   strapi?: any,
   imageengine?: any,
+  uploadcare?: Partial<UploadcareOptions>,
   ipx?: Partial<IPXOptions>
   static?: Partial<IPXOptions>
 }


Test patch:
diff --git a/test/e2e/__snapshots__/no-ssr.test.ts.snap b/test/e2e/__snapshots__/no-ssr.test.ts.snap
index 3fa9408..43ec1a6 100644
--- a/test/e2e/__snapshots__/no-ssr.test.ts.snap
+++ b/test/e2e/__snapshots__/no-ssr.test.ts.snap
@@ -443,6 +443,32 @@ exports[`browser (ssr: false) > unsplash should render images 2`] = `
 ]
 `;
 
+exports[`browser (ssr: false) > uploadcare should render images 1`] = `
+[
+  "https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/512x512/",
+  "https://ucarecdn.com/4740215e-ed0f-478f-8dbd-4859d8ea4e60/-/resize/512x/",
+  "https://ucarecdn.com/2e36710d-21ea-42b3-ac9e-3caa80f7e7bc/-/resize/x512/",
+  "https://ucarecdn.com/678053e2-ad98-42be-8443-e114b8a1b294/-/scale_crop/1080x300/center/",
+  "https://ucarecdn.com/034e80f0-14af-408a-9eae-1e233c361256/-/setfill/000000/-/resize/1080x300/-/stretch/off/",
+  "https://ucarecdn.com/6d7dd597-a829-4f03-a3cc-f87ff8a94d14/-/smart_resize/1080x300/",
+  "https://ucarecdn.com/beb68506-f5c3-4b85-8d8c-143ad62d5390/-/smart_resize/1080x300/",
+  "https://ucarecdn.com/31e84033-f22e-4ed0-9462-76f9179e13e1/-/smart_resize/1080x300/",
+]
+`;
+
+exports[`browser (ssr: false) > uploadcare should render images 2`] = `
+[
+  "https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/512x512/",
+  "https://ucarecdn.com/4740215e-ed0f-478f-8dbd-4859d8ea4e60/-/resize/512x/",
+  "https://ucarecdn.com/2e36710d-21ea-42b3-ac9e-3caa80f7e7bc/-/resize/x512/",
+  "https://ucarecdn.com/678053e2-ad98-42be-8443-e114b8a1b294/-/scale_crop/1080x300/center/",
+  "https://ucarecdn.com/034e80f0-14af-408a-9eae-1e233c361256/-/setfill/000000/-/resize/1080x300/-/stretch/off/",
+  "https://ucarecdn.com/6d7dd597-a829-4f03-a3cc-f87ff8a94d14/-/smart_resize/1080x300/",
+  "https://ucarecdn.com/beb68506-f5c3-4b85-8d8c-143ad62d5390/-/smart_resize/1080x300/",
+  "https://ucarecdn.com/31e84033-f22e-4ed0-9462-76f9179e13e1/-/smart_resize/1080x300/",
+]
+`;
+
 exports[`browser (ssr: false) > vercel should render images 1`] = `
 [
   "https://image-component.nextjs.gallery/_next/image?url=/colors.jpg&w=750&q=75",
diff --git a/test/e2e/__snapshots__/ssr.test.ts.snap b/test/e2e/__snapshots__/ssr.test.ts.snap
index 08e3ee1..1786b12 100644
--- a/test/e2e/__snapshots__/ssr.test.ts.snap
+++ b/test/e2e/__snapshots__/ssr.test.ts.snap
@@ -443,6 +443,32 @@ exports[`browser (ssr: true) > unsplash should render images 2`] = `
 ]
 `;
 
+exports[`browser (ssr: true) > uploadcare should render images 1`] = `
+[
+  "https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/512x512/",
+  "https://ucarecdn.com/4740215e-ed0f-478f-8dbd-4859d8ea4e60/-/resize/512x/",
+  "https://ucarecdn.com/2e36710d-21ea-42b3-ac9e-3caa80f7e7bc/-/resize/x512/",
+  "https://ucarecdn.com/678053e2-ad98-42be-8443-e114b8a1b294/-/scale_crop/1080x300/center/",
+  "https://ucarecdn.com/034e80f0-14af-408a-9eae-1e233c361256/-/setfill/000000/-/resize/1080x300/-/stretch/off/",
+  "https://ucarecdn.com/6d7dd597-a829-4f03-a3cc-f87ff8a94d14/-/smart_resize/1080x300/",
+  "https://ucarecdn.com/beb68506-f5c3-4b85-8d8c-143ad62d5390/-/smart_resize/1080x300/",
+  "https://ucarecdn.com/31e84033-f22e-4ed0-9462-76f9179e13e1/-/smart_resize/1080x300/",
+]
+`;
+
+exports[`browser (ssr: true) > uploadcare should render images 2`] = `
+[
+  "https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/512x512/",
+  "https://ucarecdn.com/4740215e-ed0f-478f-8dbd-4859d8ea4e60/-/resize/512x/",
+  "https://ucarecdn.com/2e36710d-21ea-42b3-ac9e-3caa80f7e7bc/-/resize/x512/",
+  "https://ucarecdn.com/678053e2-ad98-42be-8443-e114b8a1b294/-/scale_crop/1080x300/center/",
+  "https://ucarecdn.com/034e80f0-14af-408a-9eae-1e233c361256/-/setfill/000000/-/resize/1080x300/-/stretch/off/",
+  "https://ucarecdn.com/6d7dd597-a829-4f03-a3cc-f87ff8a94d14/-/smart_resize/1080x300/",
+  "https://ucarecdn.com/beb68506-f5c3-4b85-8d8c-143ad62d5390/-/smart_resize/1080x300/",
+  "https://ucarecdn.com/31e84033-f22e-4ed0-9462-76f9179e13e1/-/smart_resize/1080x300/",
+]
+`;
+
 exports[`browser (ssr: true) > vercel should render images 1`] = `
 [
   "https://image-component.nextjs.gallery/_next/image?url=/colors.jpg&w=750&q=75",
diff --git a/test/providers.ts b/test/providers.ts
index 4e8f187..cd098ce 100644
--- a/test/providers.ts
+++ b/test/providers.ts
@@ -23,7 +23,8 @@ export const images = [
     storyblok: { url: 'https://a.storyblok.com/test.png' },
     vercel: { url: '/_vercel/image?url=/test.png&w=1536&q=100' },
     wagtail: { url: '329944/original|format-webp|webpquality-70' },
-    directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4' }
+    directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4' },
+    uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/' }
   },
   {
     args: ['/test.png', { width: 200 }],
@@ -49,7 +50,8 @@ export const images = [
     storyblok: { url: 'https://a.storyblok.com/test.png/m/200x0' },
     vercel: { url: '/_vercel/image?url=/test.png&w=320&q=100' },
     wagtail: { url: '329944/width-200|format-webp|webpquality-70' },
-    directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?width=200' }
+    directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?width=200' },
+    uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/200x/' }
   },
   {
     args: ['/test.png', { height: 200 }],
@@ -75,7 +77,8 @@ export const images = [
     storyblok: { url: 'https://a.storyblok.com/test.png/m/0x200' },
     vercel: { url: '/_vercel/image?url=/test.png&w=1536&q=100' },
     wagtail: { url: '329944/height-200|format-webp|webpquality-70' },
-    directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?height=200' }
+    directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?height=200' },
+    uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/x200/' }
   },
   {
     args: ['/test.png', { width: 200, height: 200 }],
@@ -101,7 +104,8 @@ export const images = [
     storyblok: { url: 'https://a.storyblok.com/test.png/m/200x200' },
     vercel: { url: '/_vercel/image?url=/test.png&w=320&q=100' },
     wagtail: { url: '329944/fill-200x200-c0|format-webp|webpquality-70' },
-    directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?width=200&height=200' }
+    directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?width=200&height=200' },
+    uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/200x200/' }
   },
   {
     args: ['/test.png', { width: 200, height: 200, fit: 'contain' }],
@@ -127,7 +131,8 @@ export const images = [
     storyblok: { url: 'https://a.storyblok.com/test.png/m/fit-contain/200x200' },
     vercel: { url: '/_vercel/image?url=/test.png&w=320&q=100' },
     wagtail: { url: '329944/fill-200x200-c0|format-webp|webpquality-70' },
-    directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?width=200&height=200&fit=contain' }
+    directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?width=200&height=200&fit=contain' },
+    uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/200x200/-/stretch/off/' }
   },
   {
     args: ['/test.png', { width: 200, height: 200, fit: 'contain', format: 'jpeg' }],
@@ -153,7 +158,8 @@ export const images = [
     storyblok: { url: 'https://a.storyblok.com/test.png/m/fit-contain/200x200/filters:format(jpeg)' },
     vercel: { url: '/_vercel/image?url=/test.png&w=320&q=100' },
     wagtail: { url: '329944/fill-200x200-c0|format-jpeg|jpegquality-70' },
-    directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?width=200&height=200&fit=contain&format=jpg' }
+    directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?width=200&height=200&fit=contain&format=jpg' },
+    uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/format/jpeg/-/resize/200x200/-/stretch/off/' }
   }
 ] as const
 
diff --git a/test/unit/providers.test.ts b/test/unit/providers.test.ts
index 624d091..b09fd24 100644
--- a/test/unit/providers.test.ts
+++ b/test/unit/providers.test.ts
@@ -29,6 +29,7 @@ import * as storyblok from '#image/providers/storyblok'
 import * as strapi from '#image/providers/strapi'
 import * as vercel from '#image/providers/vercel'
 import * as wagtail from '#image/providers/wagtail'
+import * as uploadcare from '#image/providers/uploadcare'
 
 const emptyContext = {
   options: {
@@ -377,6 +378,15 @@ describe('Providers', () => {
     }
   })
 
+  it('uploadcare', () => {
+    const providerOptions = {}
+    const testImageId = 'c160afba-8b42-45a9-a46a-d393248b0072'
+    for (const image of images) {
+      const generated = uploadcare.getImage(testImageId, { modifiers: { ...image.args[1] }, ...providerOptions }, emptyContext)
+      expect(generated).toMatchObject(image.uploadcare)
+    }
+  })
+
   it('none', () => {
     const providerOptions = {
     }