Repository: nuxt/image
Pull request #1813 (2025-04-08 17:44:43)

fix!: use `URLSearchParams` as default formatter
### 🔗 Linked issue

<!-- Please ensure there is an open issue and mention its number. For example, "resolves #123" -->

### ❓ Type of change

<!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply. -->

- [ ] 📖 Documentation (updates to the documentation or readme)
- [x] 🐞 Bug fix (a non-breaking change that fixes an issue)
- [ ] 👌 Enhancement (improving an existing functionality)
- [ ] ✨ New feature (a non-breaking change that adds functionality)
- [ ] 🧹 Chore (updates to the build process or auxiliary tools and libraries)
- [ ] ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

### 📚 Description

spotted an issue when testing `nuxi generate playground` - one of the URLs was invalid (caused by passing `30%` as a value`). You can test with `decodeURIComponent("https://demo.sirv.com/t-shirt-man.jpg?watermark=/watermark-v1.png&watermark.position=center&watermark.scale.width=30%&h=500")`

This PR switches across to using URLSearchParams to handle encodings across all providers. If a user wants to provide a custom `joinWith` they now _must_ provide a custom formatter (to avoid unnecessary bundling)

I think it should be safe - but @pi0 may have other thoughts.

pkg-pr-new[bot]: 
[Open in StackBlitz](https://pkg.pr.new/template/d4f0cf48-a68f-4936-9ddc-55f4494de60e)


```
npm i https://pkg.pr.new/@nuxt/image@1813
```


_commit: <a href="https://github.com/nuxt/image/runs/40197234033"><code>3f12d96</code></a>_

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/nuxt/image/pull/1813?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt) Report
Attention: Patch coverage is `94.11765%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 62.97%. Comparing base [(`3989629`)](https://app.codecov.io/gh/nuxt/image/commit/3989629d6369a51cf99e428fc5cd28a15b9e8ce0?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt) to head [(`3f12d96`)](https://app.codecov.io/gh/nuxt/image/commit/3f12d96557ddbe7fb4fec5b06e45461a3f39ce6c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt).
> Report is 1 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/nuxt/image/pull/1813?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt) | Patch % | Lines |
|---|---|---|
| [src/runtime/providers/directus.ts](https://app.codecov.io/gh/nuxt/image/pull/1813?src=pr&el=tree&filepath=src%2Fruntime%2Fproviders%2Fdirectus.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt#diff-c3JjL3J1bnRpbWUvcHJvdmlkZXJzL2RpcmVjdHVzLnRz) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/nuxt/image/pull/1813?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1813      +/-   ##
==========================================
+ Coverage   62.96%   62.97%   +0.01%     
==========================================
  Files          79       79              
  Lines        3769     3760       -9     
  Branches      430      423       -7     
==========================================
- Hits         2373     2368       -5     
+ Misses       1370     1366       -4     
  Partials       26       26              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/nuxt/image/pull/1813?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
cloudflare-workers-and-pages[bot]: ## Deploying nuxt-image with &nbsp;<a href="https://pages.dev"><img alt="Cloudflare Pages" src="https://user-images.githubusercontent.com/23264/106598434-9e719e00-654f-11eb-9e59-6167043cfa01.png" width="16"></a> &nbsp;Cloudflare Pages

<table><tr><td><strong>Latest commit:</strong> </td><td>
<code>1e8901c</code>
</td></tr>
<tr><td><strong>Status:</strong></td><td>⚡️&nbsp; Build in progress...</td></tr>
</table>

[View logs](https://dash.cloudflare.com/f1e84ebed8564d989bffd46d5a988296/pages/view/nuxt-image/a7a08a0d-dfcd-466e-90ca-7bf67dbb6733)


Patch:
diff --git a/src/runtime/providers/aliyun.ts b/src/runtime/providers/aliyun.ts
index 2f72c76..29f5dcc 100644
--- a/src/runtime/providers/aliyun.ts
+++ b/src/runtime/providers/aliyun.ts
@@ -1,13 +1,13 @@
-import { joinURL } from 'ufo'
+import { joinURL, encodePath } from 'ufo'
 import { defineProvider, createOperationsGenerator } from '#image'
 
 const operationsGenerator = createOperationsGenerator({
   joinWith: '/',
   formatter: (key: string | number, value: string | number | ReturnType<typeof getResizeValue>) => {
     if (typeof value === 'object') {
-      return `${key},${Object.entries(value).map(([k, v]) => `${k}_${v}`).join(',')}`
+      return `${key},${encodePath(Object.entries(value).map(([k, v]) => `${k}_${v}`).join(','))}`
     }
-    return `${key},${value}`
+    return encodePath(`${key},${value}`)
   },
 })
 
diff --git a/src/runtime/providers/awsAmplify.ts b/src/runtime/providers/awsAmplify.ts
index 8223368..3f44f1c 100644
--- a/src/runtime/providers/awsAmplify.ts
+++ b/src/runtime/providers/awsAmplify.ts
@@ -45,8 +45,6 @@ const operationsGenerator = createOperationsGenerator({
       left: 'left',
     },
   },
-  joinWith: '&',
-  formatter: (key, value) => `${key}=${value}`,
 })
 
 interface AmplifyOptions {
diff --git a/src/runtime/providers/bunny.ts b/src/runtime/providers/bunny.ts
index f0c3b40..bf3004f 100644
--- a/src/runtime/providers/bunny.ts
+++ b/src/runtime/providers/bunny.ts
@@ -20,8 +20,6 @@ const operationsGenerator = createOperationsGenerator({
     autoOptimize: 'auto_optimize',
     sepia: 'sepia',
   },
-  joinWith: '&',
-  formatter: (key, value) => `${key}=${value}`,
 })
 
 interface BunnyOptions {
diff --git a/src/runtime/providers/caisy.ts b/src/runtime/providers/caisy.ts
index bb1386b..ca48bd8 100644
--- a/src/runtime/providers/caisy.ts
+++ b/src/runtime/providers/caisy.ts
@@ -7,8 +7,6 @@ const operationsGenerator = createOperationsGenerator({
     height: 'h',
     quality: 'q',
   },
-  joinWith: '&',
-  formatter: (key, value) => `${key}=${value}`,
 })
 
 export default defineProvider({
diff --git a/src/runtime/providers/cloudflare.ts b/src/runtime/providers/cloudflare.ts
index 4d5691f..42b5f50 100644
--- a/src/runtime/providers/cloudflare.ts
+++ b/src/runtime/providers/cloudflare.ts
@@ -1,4 +1,4 @@
-import { joinURL, encodeQueryItem } from 'ufo'
+import { encodeQueryItem, joinURL } from 'ufo'
 import { defineProvider, createOperationsGenerator } from '#image'
 
 const operationsGenerator = createOperationsGenerator({
@@ -26,7 +26,7 @@ const operationsGenerator = createOperationsGenerator({
     },
   },
   joinWith: ',',
-  formatter: (key, val) => encodeQueryItem(key, val),
+  formatter: (key, value) => encodeQueryItem(key, value),
 })
 
 const defaultModifiers = {}
diff --git a/src/runtime/providers/cloudimage.ts b/src/runtime/providers/cloudimage.ts
index 8341bf6..36fe028 100644
--- a/src/runtime/providers/cloudimage.ts
+++ b/src/runtime/providers/cloudimage.ts
@@ -16,8 +16,6 @@ const operationsGenerator = createOperationsGenerator({
       outside: 'boundmin',
     },
   },
-  joinWith: '&',
-  formatter: (key, value) => `${key}=${value}`,
 })
 
 interface CloudimageOptions {
diff --git a/src/runtime/providers/cloudinary.ts b/src/runtime/providers/cloudinary.ts
index 1d8be0f..4fc30f0 100644
--- a/src/runtime/providers/cloudinary.ts
+++ b/src/runtime/providers/cloudinary.ts
@@ -103,7 +103,7 @@ const operationsGenerator = createOperationsGenerator({
     },
   },
   joinWith: ',',
-  formatter: (key, value) => key.includes('_') ? `${key}:${value}` : `${key}_${value}`,
+  formatter: (key, value) => encodePath(key.includes('_') ? `${key}:${value}` : `${key}_${value}`),
 })
 
 const defaultModifiers = {
diff --git a/src/runtime/providers/contentful.ts b/src/runtime/providers/contentful.ts
index 38c5972..f6e7d28 100644
--- a/src/runtime/providers/contentful.ts
+++ b/src/runtime/providers/contentful.ts
@@ -25,8 +25,6 @@ const operationsGenerator = createOperationsGenerator({
       thumbnail: 'thumb',
     },
   },
-  joinWith: '&',
-  formatter: (key, value) => `${key}=${value}`,
 })
 
 interface ContentfulOptions {
diff --git a/src/runtime/providers/directus.ts b/src/runtime/providers/directus.ts
index 24f1e5c..4f5ba46 100644
--- a/src/runtime/providers/directus.ts
+++ b/src/runtime/providers/directus.ts
@@ -1,9 +1,7 @@
 import { joinURL } from 'ufo'
 import { defineProvider, createOperationsGenerator } from '#image'
 
-const operationsGenerator = createOperationsGenerator({
-  joinWith: '&',
-})
+const operationsGenerator = createOperationsGenerator()
 
 interface DirectusOptions {
   baseURL: string
@@ -16,7 +14,7 @@ export default defineProvider<DirectusOptions>({
   getImage: (src, { modifiers, baseURL }) => {
     // Separating the transforms from the rest of the modifiers
     const transforms = modifiers.transforms && Array.isArray(modifiers.transforms) && modifiers.transforms.length > 0
-      ? new URLSearchParams(JSON.stringify(Array.from(new Set(modifiers.transforms.map(obj => JSON.stringify(obj)))).map(value => JSON.parse(value)))).toString().replace(/=+$/, '')
+      ? JSON.stringify(Array.from(new Set(modifiers.transforms.map(obj => JSON.stringify(obj)))).map(value => JSON.parse(value)))
       : undefined
 
     const operations = operationsGenerator({
@@ -24,7 +22,7 @@ export default defineProvider<DirectusOptions>({
       transforms,
     })
     return {
-      url: joinURL(baseURL, src + (operations ? ('?' + operations) : '')),
+      url: joinURL(baseURL, src + (operations ? ('?' + operations.replace(/=+$/, '')) : '')),
     }
   },
 })
diff --git a/src/runtime/providers/fastly.ts b/src/runtime/providers/fastly.ts
index 479914d..66e7d8c 100644
--- a/src/runtime/providers/fastly.ts
+++ b/src/runtime/providers/fastly.ts
@@ -11,8 +11,6 @@ const operationsGenerator = createOperationsGenerator({
       contain: 'bounds',
     },
   },
-  joinWith: '&',
-  formatter: (key, value) => `${key}=${value}`,
 })
 
 interface FastlyOptions {
diff --git a/src/runtime/providers/filerobot.ts b/src/runtime/providers/filerobot.ts
index fa6e77b..eb8271c 100644
--- a/src/runtime/providers/filerobot.ts
+++ b/src/runtime/providers/filerobot.ts
@@ -18,8 +18,6 @@ const operationsGenerator = createOperationsGenerator({
       outside: 'boundmin',
     },
   },
-  joinWith: '&',
-  formatter: (key, value) => `${key}=${value}`,
 })
 
 interface FilerobotOptions {
diff --git a/src/runtime/providers/glide.ts b/src/runtime/providers/glide.ts
index 1f0767b..8485a97 100644
--- a/src/runtime/providers/glide.ts
+++ b/src/runtime/providers/glide.ts
@@ -1,6 +1,6 @@
 // https://glide.thephpleague.com/2.0/api/quick-reference/
 
-import { joinURL, encodeQueryItem, encodePath, withBase } from 'ufo'
+import { joinURL, encodePath, withBase } from 'ufo'
 import { defineProvider, createOperationsGenerator } from '#image'
 
 const operationsGenerator = createOperationsGenerator({
@@ -41,8 +41,6 @@ const operationsGenerator = createOperationsGenerator({
       contain: 'contain',
     },
   },
-  joinWith: '&',
-  formatter: (key, val) => encodeQueryItem(key, val),
 })
 
 interface GlideOptions {
diff --git a/src/runtime/providers/gumlet.ts b/src/runtime/providers/gumlet.ts
index 879250a..50dfe64 100644
--- a/src/runtime/providers/gumlet.ts
+++ b/src/runtime/providers/gumlet.ts
@@ -65,8 +65,6 @@ const operationsGenerator = createOperationsGenerator({
       auto: 'auto',
     },
   },
-  joinWith: '&',
-  formatter: (key, value) => `${key}=${value}`,
 })
 
 interface GumletOptions {
diff --git a/src/runtime/providers/imageengine.ts b/src/runtime/providers/imageengine.ts
index daa528e..842c593 100644
--- a/src/runtime/providers/imageengine.ts
+++ b/src/runtime/providers/imageengine.ts
@@ -1,4 +1,4 @@
-import { joinURL } from 'ufo'
+import { joinURL, encodePath } from 'ufo'
 import { defineProvider, createOperationsGenerator } from '#image'
 
 const operationsGenerator = createOperationsGenerator({
@@ -44,7 +44,7 @@ const operationsGenerator = createOperationsGenerator({
     },
   },
   joinWith: '/',
-  formatter: (key, value: string | number) => `${key}_${value}`,
+  formatter: (key, value: string | number) => encodePath(`${key}_${value}`),
 })
 
 interface ImageEngineOptions {
diff --git a/src/runtime/providers/imagekit.ts b/src/runtime/providers/imagekit.ts
index 662e01b..510b316 100644
--- a/src/runtime/providers/imagekit.ts
+++ b/src/runtime/providers/imagekit.ts
@@ -1,4 +1,4 @@
-import { joinURL, withQuery } from 'ufo'
+import { joinURL, withQuery, encodePath } from 'ufo'
 import { defineProvider, createOperationsGenerator } from '#image'
 
 const operationsGenerator = createOperationsGenerator({
@@ -99,7 +99,7 @@ const operationsGenerator = createOperationsGenerator({
     },
   },
   joinWith: ',',
-  formatter: (key, value: string | number) => `${key}-${value}`,
+  formatter: (key, value: string | number) => encodePath(`${key}-${value}`),
 })
 
 interface ImagekitOptions {
diff --git a/src/runtime/providers/imgix.ts b/src/runtime/providers/imgix.ts
index 6c5a6b4..b28eb65 100644
--- a/src/runtime/providers/imgix.ts
+++ b/src/runtime/providers/imgix.ts
@@ -159,8 +159,6 @@ export const operationsGenerator = createOperationsGenerator({
       blurhash: 'blurhash',
     },
   },
-  joinWith: '&',
-  formatter: (key, value) => `${key}=${value}`,
 })
 
 interface ImgixOptions {
diff --git a/src/runtime/providers/ipx.ts b/src/runtime/providers/ipx.ts
index 224ff0b..7c92118 100644
--- a/src/runtime/providers/ipx.ts
+++ b/src/runtime/providers/ipx.ts
@@ -48,7 +48,6 @@ export const operationsGenerator = createOperationsGenerator({
     quality: 'q',
     background: 'b',
   },
-  joinWith: '&',
   formatter: (key, val: string | number | boolean) => encodeParam(key) + '_' + encodeParam(val.toString()),
 })
 
diff --git a/src/runtime/providers/netlifyImageCdn.ts b/src/runtime/providers/netlifyImageCdn.ts
index 7554511..1beb5bf 100644
--- a/src/runtime/providers/netlifyImageCdn.ts
+++ b/src/runtime/providers/netlifyImageCdn.ts
@@ -1,4 +1,3 @@
-import { encodeQueryItem } from 'ufo'
 import { defineProvider, createOperationsGenerator } from '#image'
 
 // https://docs.netlify.com/image-cdn/overview/
@@ -33,8 +32,6 @@ const operationsGenerator = createOperationsGenerator({
       center: 'center',
     },
   },
-  joinWith: '&',
-  formatter: (key, value) => encodeQueryItem(key, value),
 })
 
 interface NetlifyImageCDNOptions {
diff --git a/src/runtime/providers/netlifyLargeMedia.ts b/src/runtime/providers/netlifyLargeMedia.ts
index 39cd945..b31753a 100644
--- a/src/runtime/providers/netlifyLargeMedia.ts
+++ b/src/runtime/providers/netlifyLargeMedia.ts
@@ -1,4 +1,4 @@
-import { encodeQueryItem, joinURL } from 'ufo'
+import { joinURL } from 'ufo'
 import { defineProvider, createOperationsGenerator } from '#image'
 
 const operationsGenerator = createOperationsGenerator({
@@ -13,8 +13,6 @@ const operationsGenerator = createOperationsGenerator({
       contain: 'fit',
     },
   },
-  joinWith: '&',
-  formatter: (key, value) => encodeQueryItem(key, value),
 })
 
 interface NetlifyLargeMediaOptions {
diff --git a/src/runtime/providers/sanity.ts b/src/runtime/providers/sanity.ts
index 6e09904..54067c4 100644
--- a/src/runtime/providers/sanity.ts
+++ b/src/runtime/providers/sanity.ts
@@ -1,4 +1,4 @@
-import { joinURL } from 'ufo'
+import { joinURL, encodePath } from 'ufo'
 import { defineProvider, createOperationsGenerator } from '#image'
 
 const sanityCDN = 'https://cdn.sanity.io/images'
@@ -37,8 +37,7 @@ const operationsGenerator = createOperationsGenerator({
       outside: 'max',
     },
   },
-  joinWith: '&',
-  formatter: (key, value: string | true | number | Record<string, unknown>) => String(value) === 'true' ? key : `${key}=${value}`,
+  formatter: (key, value: string | true | number | Record<string, unknown>) => String(value) === 'true' ? key : encodePath(`${key}=${value}`),
 })
 
 const getMetadata = (id: string) => {
diff --git a/src/runtime/providers/sirv.ts b/src/runtime/providers/sirv.ts
index b454f93..bf0c99e 100644
--- a/src/runtime/providers/sirv.ts
+++ b/src/runtime/providers/sirv.ts
@@ -98,8 +98,6 @@ const operationsGenerator = createOperationsGenerator({
     },
     ...generateColorKeys(),
   },
-  joinWith: '&',
-  formatter: (key: any, value: any) => `${key}=${value}`,
 })
 
 interface SirvOptions {
diff --git a/src/runtime/providers/twicpics.ts b/src/runtime/providers/twicpics.ts
index 0bc3bd3..53936ea 100644
--- a/src/runtime/providers/twicpics.ts
+++ b/src/runtime/providers/twicpics.ts
@@ -1,4 +1,4 @@
-import { joinURL } from 'ufo'
+import { encodeQueryItem, joinURL } from 'ufo'
 import { defineProvider } from '#image'
 import { createMapper, createOperationsGenerator, type InferModifiers } from '#image'
 
@@ -47,13 +47,14 @@ const operationsGenerator = createOperationsGenerator({
     },
   },
   joinWith: '/',
-  formatter: (key, value: string | number) => `${key}=${value}`,
-})
+  formatter: (key: 'output' | 'format' | 'fit' | 'quality' | 'background' | 'focus' | 'zoom', value) => encodeQueryItem(key, value),
+} as const)
 
 interface TwicpicsOptions {
   baseURL?: string
   modifiers?: InferModifiers<typeof operationsGenerator>
     & { fit?: 'fill' | 'inside' | 'outside' | 'cover' | 'contain' }
+    & Partial<Record<'resize' | 'fill' | 'contain' | 'inside' | 'outside' | 'cover' | 'missingValue', string>>
     & Partial<Record<typeof fits extends (fit: string) => infer Fit ? NonNullable<Fit> : string, string>>
 }
 
diff --git a/src/runtime/providers/weserv.ts b/src/runtime/providers/weserv.ts
index 7a485e5..e0fd601 100644
--- a/src/runtime/providers/weserv.ts
+++ b/src/runtime/providers/weserv.ts
@@ -58,8 +58,6 @@ const operationsGenerator = createOperationsGenerator({
       'heart': 'heart',
     },
   },
-  joinWith: '&',
-  formatter: (key, value) => `${key}=${value}`,
 })
 
 export interface WeservOptions {
diff --git a/src/runtime/utils/index.ts b/src/runtime/utils/index.ts
index 97c3a5d..bfc4412 100644
--- a/src/runtime/utils/index.ts
+++ b/src/runtime/utils/index.ts
@@ -32,13 +32,8 @@ export function createMapper<Key extends string, Value>(map: Partial<Record<Key,
   return (key => key !== undefined ? map[key as Extract<Key, string>] || key : map.missingValue) as Mapper<Key, Value>
 }
 
-type Formatter<Key, Value> = (key: Key, value: Value) => string
-
-const defaultFormatter = (key: string, value: string | number) => `${key}=${value}`
-
-export function createOperationsGenerator<ModifierKey extends string, ModifierValue = string | number, FinalKey = ModifierKey, FinalValue = ModifierValue>(config: OperationGeneratorConfig<ModifierKey, ModifierValue, FinalKey, FinalValue>) {
-  const formatter = config.formatter || (defaultFormatter as Formatter<FinalKey, FinalValue>)
-
+export function createOperationsGenerator<ModifierKey extends string, ModifierValue = string | number, FinalKey = ModifierKey, FinalValue = ModifierValue>(config: OperationGeneratorConfig<ModifierKey, ModifierValue, FinalKey, FinalValue> = {}) {
+  const formatter = config.formatter
   const keyMap = config.keyMap && typeof config.keyMap !== 'function' ? createMapper<ModifierKey, FinalKey>(config.keyMap) : config.keyMap
 
   const map: Record<string, Mapper<ModifierValue, FinalValue>> = {}
@@ -51,7 +46,7 @@ export function createOperationsGenerator<ModifierKey extends string, ModifierVa
   }
 
   return (modifiers: Partial<Record<Extract<ModifierKey | FinalKey, string>, ModifierValue | FinalValue>>): string => {
-    const operations: string[] = []
+    const operations: [key: FinalKey, value: FinalValue][] = []
     for (const _key in modifiers) {
       const key = _key as keyof typeof modifiers
       if (typeof modifiers[key] === 'undefined') {
@@ -61,10 +56,14 @@ export function createOperationsGenerator<ModifierKey extends string, ModifierVa
         ? map[key](modifiers[key] as ModifierValue)
         : modifiers[key]
 
-      operations.push(formatter(((keyMap ? keyMap(key as ModifierKey) : key) as FinalKey), value as FinalValue))
+      operations.push([(keyMap ? keyMap(key as ModifierKey) : key) as FinalKey, value as FinalValue])
+    }
+
+    if (formatter) {
+      return operations.map(entry => formatter(...entry)).join(config.joinWith ?? '&')
     }
 
-    return operations.join(config.joinWith ?? '/')
+    return new URLSearchParams(operations as [string, string][]).toString()
   }
 }
 
diff --git a/src/types/image.ts b/src/types/image.ts
index 0d732c3..837051e 100644
--- a/src/types/image.ts
+++ b/src/types/image.ts
@@ -93,12 +93,16 @@ export interface ImageCTX {
 
 export type OperationMapper<From, To> = Record<string | Extract<From, string | number>, To> | ((key?: From) => To | From | undefined)
 
-export interface OperationGeneratorConfig<Key extends string, Value, FinalKey, FinalValue> {
+export type OperationGeneratorConfig<Key extends string, Value, FinalKey, FinalValue> = {
   keyMap?: Partial<Record<Key, FinalKey>>
+  valueMap?: Partial<Record<Key, Partial<Record<Extract<Value, string>, FinalValue>> | ((key: Value) => Value | FinalValue)>>
+} & ({
   formatter?: (key: FinalKey, value: FinalValue) => string
-  joinWith?: string
-  valueMap?: Partial<Record<Key, Record<Extract<Value, string>, FinalValue> | ((key: Value) => Value | FinalValue)>>
-}
+  joinWith?: undefined
+} | {
+  formatter: (key: FinalKey, value: FinalValue) => string
+  joinWith: string
+})
 
 export interface ImageSizesVariant {
   size?: string


Test patch:
diff --git a/test/e2e/__snapshots__/bunny.json5 b/test/e2e/__snapshots__/bunny.json5
index dd21a52..48e639a 100644
--- a/test/e2e/__snapshots__/bunny.json5
+++ b/test/e2e/__snapshots__/bunny.json5
@@ -1,12 +1,12 @@
 {
   "requests": [
-    "https://bunnyoptimizerdemo.b-cdn.net/bunny1.jpg?sharpen=true&aspect_ratio=16:9&width=1000&quality=80",
-    "https://bunnyoptimizerdemo.b-cdn.net/bunny2.jpg?crop=750,750&crop_gravity=north",
+    "https://bunnyoptimizerdemo.b-cdn.net/bunny1.jpg?sharpen=true&aspect_ratio=16%3A9&width=1000&quality=80",
+    "https://bunnyoptimizerdemo.b-cdn.net/bunny2.jpg?crop=750%2C750&crop_gravity=north",
     "https://bunnyoptimizerdemo.b-cdn.net/bunny3.jpg?flop=true&auto_optimize=high",
   ],
   "sources": [
-    "https://bunnyoptimizerdemo.b-cdn.net/bunny1.jpg?sharpen=true&aspect_ratio=16:9&width=1000&quality=80",
-    "https://bunnyoptimizerdemo.b-cdn.net/bunny2.jpg?crop=750,750&crop_gravity=north",
+    "https://bunnyoptimizerdemo.b-cdn.net/bunny1.jpg?sharpen=true&aspect_ratio=16%3A9&width=1000&quality=80",
+    "https://bunnyoptimizerdemo.b-cdn.net/bunny2.jpg?crop=750%2C750&crop_gravity=north",
     "https://bunnyoptimizerdemo.b-cdn.net/bunny3.jpg?flop=true&auto_optimize=high",
   ],
 }
\ No newline at end of file
diff --git a/test/e2e/__snapshots__/sirv.json5 b/test/e2e/__snapshots__/sirv.json5
index bdba9c4..ab762d0 100644
--- a/test/e2e/__snapshots__/sirv.json5
+++ b/test/e2e/__snapshots__/sirv.json5
@@ -6,7 +6,7 @@
     "https://demo.sirv.com/look-big.jpg?crop.type=face&h=500",
     "https://demo.sirv.com/look-big.jpg?h=500&scale.option=fit",
     "https://demo.sirv.com/look-big.jpg?text=Hello&text.align=center&text.position.gravity=south&text.background.color=ffff&text.size=60&text.font.family=Arial&text.color=white&h=500",
-    "https://demo.sirv.com/t-shirt-man.jpg?watermark=/watermark-v1.png&watermark.position=center&watermark.scale.width=30%&h=500",
+    "https://demo.sirv.com/t-shirt-man.jpg?watermark=%2Fwatermark-v1.png&watermark.position=center&watermark.scale.width=30%25&h=500",
     "https://demo.sirv.com/test.png?w=750&q=75",
   ],
   "sources": [
@@ -17,6 +17,6 @@
     "https://demo.sirv.com/look-big.jpg?crop.type=face&h=500",
     "https://demo.sirv.com/QW.pdf?page=1&w=500",
     "https://demo.sirv.com/look-big.jpg?text=Hello&text.align=center&text.position.gravity=south&text.background.color=ffff&text.size=60&text.font.family=Arial&text.color=white&h=500",
-    "https://demo.sirv.com/t-shirt-man.jpg?watermark=/watermark-v1.png&watermark.position=center&watermark.scale.width=30%&h=500",
+    "https://demo.sirv.com/t-shirt-man.jpg?watermark=%2Fwatermark-v1.png&watermark.position=center&watermark.scale.width=30%25&h=500",
   ],
 }
\ No newline at end of file
diff --git a/test/e2e/__snapshots__/weserv.json5 b/test/e2e/__snapshots__/weserv.json5
index 23edc28..dc938f1 100644
--- a/test/e2e/__snapshots__/weserv.json5
+++ b/test/e2e/__snapshots__/weserv.json5
@@ -1,13 +1,13 @@
 {
   "requests": [
-    "https://wsrv.nl/?filename=200&we&mask=circle&filt=sepia&output=webp&url=https://picsum.photos/200",
-    "https://wsrv.nl/?filename=200&we&mask=circle&output=webp&url=https://picsum.photos/200",
-    "https://wsrv.nl/?filename=200&we&output=webp&url=https://picsum.photos/200",
+    "https://wsrv.nl/?filename=200&we&mask=circle&filt=sepia&output=webp&url=https%3A%2F%2Fpicsum.photos%2F200",
+    "https://wsrv.nl/?filename=200&we&mask=circle&output=webp&url=https%3A%2F%2Fpicsum.photos%2F200",
+    "https://wsrv.nl/?filename=200&we&output=webp&url=https%3A%2F%2Fpicsum.photos%2F200",
   ],
   "sources": [
-    "https://wsrv.nl/?filename=200&we&output=webp&url=https://picsum.photos/200",
-    "https://wsrv.nl/?filename=200&we&mask=circle&output=webp&url=https://picsum.photos/200",
-    "https://wsrv.nl/?filename=200&we&mask=circle&filt=sepia&output=webp&url=https://picsum.photos/200",
-    "https://wsrv.nl/?filename=200&we&mask=circle&filt=sepia&output=webp&url=https://picsum.photos/200",
+    "https://wsrv.nl/?filename=200&we&output=webp&url=https%3A%2F%2Fpicsum.photos%2F200",
+    "https://wsrv.nl/?filename=200&we&mask=circle&output=webp&url=https%3A%2F%2Fpicsum.photos%2F200",
+    "https://wsrv.nl/?filename=200&we&mask=circle&filt=sepia&output=webp&url=https%3A%2F%2Fpicsum.photos%2F200",
+    "https://wsrv.nl/?filename=200&we&mask=circle&filt=sepia&output=webp&url=https%3A%2F%2Fpicsum.photos%2F200",
   ],
 }
\ No newline at end of file
diff --git a/test/e2e/no-ssr.test.ts b/test/e2e/no-ssr.test.ts
index 5b8b3f0..f889bc9 100644
--- a/test/e2e/no-ssr.test.ts
+++ b/test/e2e/no-ssr.test.ts
@@ -46,6 +46,12 @@ describe('browser (ssr: false)', () => {
           .sort(),
       }).toMatchFileSnapshot(`./__snapshots__/${provider.name}.json5`)
 
+      for (const source of sources) {
+        if (source) {
+          expect(() => decodeURIComponent(source)).not.toThrow()
+        }
+      }
+
       await page.close()
     })
   }
diff --git a/test/e2e/ssr.test.ts b/test/e2e/ssr.test.ts
index 24f81cb..eea401c 100644
--- a/test/e2e/ssr.test.ts
+++ b/test/e2e/ssr.test.ts
@@ -45,6 +45,12 @@ describe('browser (ssr: true)', () => {
           .sort(),
       }).toMatchFileSnapshot(`./__snapshots__/${provider.name}.json5`)
 
+      for (const source of sources) {
+        if (source) {
+          expect(() => decodeURIComponent(source)).not.toThrow()
+        }
+      }
+
       await page.close()
     })
   }
diff --git a/test/nuxt/providers.test.ts b/test/nuxt/providers.test.ts
index 3083b38..fe7db7b 100644
--- a/test/nuxt/providers.test.ts
+++ b/test/nuxt/providers.test.ts
@@ -50,6 +50,13 @@ const emptyContext = {
 } as any
 
 describe('Providers', () => {
+  it('validate returned urls', async () => {
+    for (const image of images) {
+      for (const entry of Object.values(image)) {
+        expect(() => decodeURIComponent(entry)).not.toThrow()
+      }
+    }
+  })
   it('ipx', () => {
     const providerOptions = {}
 
diff --git a/test/providers.ts b/test/providers.ts
index 9c8149e..55e1b00 100644
--- a/test/providers.ts
+++ b/test/providers.ts
@@ -29,7 +29,7 @@ export const images = [
     wagtail: { url: '329944/original|format-webp|webpquality-70' },
     directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4' },
     uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/' },
-    weserv: { url: 'https://wsrv.nl/?filename=test.png&we&url=https://my-website.com/test.png' },
+    weserv: { url: 'https://wsrv.nl/?filename=test.png&we&url=https%3A%2F%2Fmy-website.com%2Ftest.png' },
     sirv: { url: 'https://demo.sirv.com/test.png' },
     hygraph: { url: 'https://eu-central-1-shared-euc1-02.graphassets.com/cltsj3mii0pvd07vwb5cyh1ig/resize=/auto_image/cltsrex89477t08unlckqx9ue' },
     caisy: { url: 'https://assets.caisy.io/assets/b76210be-a043-4989-98df-ecaf6c6e68d8/056c27e2-81f5-4cd3-b728-cef181dfe7dc/d83ea6f0-f90a-462c-aebd-b8bc615fdce0pexelsmiguelapadrinan1591056.jpg' },
@@ -65,7 +65,7 @@ export const images = [
     wagtail: { url: '329944/width-200|format-webp|webpquality-70' },
     directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?width=200' },
     uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/200x/' },
-    weserv: { url: 'https://wsrv.nl/?filename=test.png&we&w=200&url=https://my-website.com/test.png' },
+    weserv: { url: 'https://wsrv.nl/?filename=test.png&we&w=200&url=https%3A%2F%2Fmy-website.com%2Ftest.png' },
     sirv: { url: 'https://demo.sirv.com/test.png?w=200' },
     hygraph: { url: 'https://eu-central-1-shared-euc1-02.graphassets.com/cltsj3mii0pvd07vwb5cyh1ig/resize=width:200/auto_image/cltsrex89477t08unlckqx9ue' },
     caisy: { url: 'https://assets.caisy.io/assets/b76210be-a043-4989-98df-ecaf6c6e68d8/056c27e2-81f5-4cd3-b728-cef181dfe7dc/d83ea6f0-f90a-462c-aebd-b8bc615fdce0pexelsmiguelapadrinan1591056.jpg?w=200' },
@@ -101,7 +101,7 @@ export const images = [
     wagtail: { url: '329944/height-200|format-webp|webpquality-70' },
     directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?height=200' },
     uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/x200/' },
-    weserv: { url: 'https://wsrv.nl/?filename=test.png&we&h=200&url=https://my-website.com/test.png' },
+    weserv: { url: 'https://wsrv.nl/?filename=test.png&we&h=200&url=https%3A%2F%2Fmy-website.com%2Ftest.png' },
     sirv: { url: 'https://demo.sirv.com/test.png?h=200' },
     hygraph: { url: 'https://eu-central-1-shared-euc1-02.graphassets.com/cltsj3mii0pvd07vwb5cyh1ig/resize=height:200/auto_image/cltsrex89477t08unlckqx9ue' },
     caisy: { url: 'https://assets.caisy.io/assets/b76210be-a043-4989-98df-ecaf6c6e68d8/056c27e2-81f5-4cd3-b728-cef181dfe7dc/d83ea6f0-f90a-462c-aebd-b8bc615fdce0pexelsmiguelapadrinan1591056.jpg?h=200' },
@@ -137,7 +137,7 @@ export const images = [
     wagtail: { url: '329944/fill-200x200-c0|format-webp|webpquality-70' },
     directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?width=200&height=200' },
     uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/200x200/' },
-    weserv: { url: 'https://wsrv.nl/?filename=test.png&we&w=200&h=200&url=https://my-website.com/test.png' },
+    weserv: { url: 'https://wsrv.nl/?filename=test.png&we&w=200&h=200&url=https%3A%2F%2Fmy-website.com%2Ftest.png' },
     sirv: { url: 'https://demo.sirv.com/test.png?w=200&h=200' },
     hygraph: { url: 'https://eu-central-1-shared-euc1-02.graphassets.com/cltsj3mii0pvd07vwb5cyh1ig/resize=width:200,height:200/auto_image/cltsrex89477t08unlckqx9ue' },
     caisy: { url: 'https://assets.caisy.io/assets/b76210be-a043-4989-98df-ecaf6c6e68d8/056c27e2-81f5-4cd3-b728-cef181dfe7dc/d83ea6f0-f90a-462c-aebd-b8bc615fdce0pexelsmiguelapadrinan1591056.jpg?w=200&h=200' },
@@ -173,7 +173,7 @@ export const images = [
     wagtail: { url: '329944/fill-200x200-c0|format-webp|webpquality-70' },
     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/' },
-    weserv: { url: 'https://wsrv.nl/?filename=test.png&we&w=200&h=200&fit=contain&url=https://my-website.com/test.png' },
+    weserv: { url: 'https://wsrv.nl/?filename=test.png&we&w=200&h=200&fit=contain&url=https%3A%2F%2Fmy-website.com%2Ftest.png' },
     sirv: { url: 'https://demo.sirv.com/test.png?w=200&h=200&scale.option=fit' },
     hygraph: { url: 'https://eu-central-1-shared-euc1-02.graphassets.com/cltsj3mii0pvd07vwb5cyh1ig/resize=width:200,height:200,fit:max/auto_image/cltsrex89477t08unlckqx9ue' },
     caisy: { url: 'https://assets.caisy.io/assets/b76210be-a043-4989-98df-ecaf6c6e68d8/056c27e2-81f5-4cd3-b728-cef181dfe7dc/d83ea6f0-f90a-462c-aebd-b8bc615fdce0pexelsmiguelapadrinan1591056.jpg?w=200&h=200' },
@@ -209,7 +209,7 @@ export const images = [
     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' },
     uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/format/jpeg/-/resize/200x200/-/stretch/off/' },
-    weserv: { url: 'https://wsrv.nl/?filename=test.png&we&w=200&h=200&fit=contain&output=jpg&url=https://my-website.com/test.png' },
+    weserv: { url: 'https://wsrv.nl/?filename=test.png&we&w=200&h=200&fit=contain&output=jpg&url=https%3A%2F%2Fmy-website.com%2Ftest.png' },
     sirv: { url: 'https://demo.sirv.com/test.png?w=200&h=200&scale.option=fit&format=jpg' },
     hygraph: { url: 'https://eu-central-1-shared-euc1-02.graphassets.com/cltsj3mii0pvd07vwb5cyh1ig/resize=width:200,height:200,fit:max/output=format:jpeg/cltsrex89477t08unlckqx9ue' },
     caisy: { url: 'https://assets.caisy.io/assets/b76210be-a043-4989-98df-ecaf6c6e68d8/056c27e2-81f5-4cd3-b728-cef181dfe7dc/d83ea6f0-f90a-462c-aebd-b8bc615fdce0pexelsmiguelapadrinan1591056.jpg?w=200&h=200' },
diff --git a/test/unit/operations.test.ts b/test/unit/operations.test.ts
index fee3c2b..e0a36e5 100644
--- a/test/unit/operations.test.ts
+++ b/test/unit/operations.test.ts
@@ -21,7 +21,6 @@ describe('operations generator', () => {
         return `woohoo-${key}`
       },
     },
-    joinWith: '&',
     formatter: (key, val) => encodeParam(key) + '_' + encodeParam(val),
   })