Repository: nuxt/image
Pull request #880 (2023-06-19 20:31:30)

feat: allow setting global `format` defaults in config
Hi there 👋 

At the moment, we can only define formats for Nuxt Picture at the component level. This PR would also allow configuring the Nuxt Picture at the global level.

I'm only now getting into Nuxt Module development (kudos to @danielroe streams), so let me know if is there anything that can be done in another way or in a better way. 💪 

Thank you very much for the amazing work and community 🙏 

nuxt-studio[bot]: 
### <span aria-hidden="true">✅</span> Live Preview ready!

| Name | Edit | Preview | Latest Commit |
| :--- | :----- | :------ | :------ |
| **Image** | [Edit on Studio ↗︎](https://nuxt.studio/@nuxt/image/studio/content?refId=%23880) | [View Live Preview](https://v1.image.nuxtjs.org?preview=dcccdc0a84a189d25b5ac1b6a6cdee81) | 3a75851fadf7cca0ae06b926b2ae6d1b79dd570c |

<!-- This comment is generated by [Nuxt Studio](https://nuxt.studio) -->
<!-- Do not remove or edit this comment. -->

netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy request for *nuxt-image* pending review.
[Visit the deploys page to approve it](https://app.netlify.com/sites/nuxt-image/deploys)

|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 3a75851fadf7cca0ae06b926b2ae6d1b79dd570c |
hartmut-co-uk: It would be great if we also could add `quality` and apply it to `<nuxt-image>`.
ref https://github.com/nuxt/image/issues/657

For nuxt-image we'd use the first elem `format[0]`..?
(This could be a separate PR)
danielroe: @hartmut-co-uk That makes sense to me too.
danielroe: Yes, it's a separate PR 👍 
hartmut-co-uk: Could you also add unit tests for the new config?

Test cases brainstormed: 
- with global config, no prop -> global config is used
  - global config, single format -> all expected image variants
  - global config, multiple formats -> all expected image variants in all formats
- with global config, with prop -> prop is used (takes precedence over global)
- global config invalid value -> Error thrown (??) || or warning logged & ignored?!?
- no global config, no prop -> default applies ('webp'?)
- global config is ignored for 'svg'

@danielroe do we have an example of setting module config to be used in unit tests...?
codecov-commenter: ## [Codecov](https://app.codecov.io/gh/nuxt/image/pull/880?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt) Report
Patch coverage: **`100.00`**% and project coverage change: **`-0.03`** :warning:
> Comparison is base [(`74daa25`)](https://app.codecov.io/gh/nuxt/image/commit/74daa253a4e963b575a7fa09b953799e93a41c82?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt) 89.73% compared to head [(`5f59f29`)](https://app.codecov.io/gh/nuxt/image/pull/880?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt) 89.71%.

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


```diff
@@            Coverage Diff             @@
##             main     #880      +/-   ##
==========================================
- Coverage   89.73%   89.71%   -0.03%     
==========================================
  Files          43       43              
  Lines        2768     2771       +3     
  Branches      312      311       -1     
==========================================
+ Hits         2484     2486       +2     
- Misses        283      284       +1     
  Partials        1        1              
```


| [Impacted Files](https://app.codecov.io/gh/nuxt/image/pull/880?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt) | Coverage Δ | |
|---|---|---|
| [src/module.ts](https://app.codecov.io/gh/nuxt/image/pull/880?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt#diff-c3JjL21vZHVsZS50cw==) | `90.90% <100.00%> (-0.45%)` | :arrow_down: |
| [src/runtime/components/nuxt-picture.ts](https://app.codecov.io/gh/nuxt/image/pull/880?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt#diff-c3JjL3J1bnRpbWUvY29tcG9uZW50cy9udXh0LXBpY3R1cmUudHM=) | `82.45% <100.00%> (-0.16%)` | :arrow_down: |
| [src/types/image.ts](https://app.codecov.io/gh/nuxt/image/pull/880?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt#diff-c3JjL3R5cGVzL2ltYWdlLnRz) | `100.00% <100.00%> (ø)` | |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/nuxt/image/pull/880?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt).

danielroe: Thank you both ❤️ 

Patch:
diff --git a/docs/content/5.configuration.md b/docs/content/5.configuration.md
index 2c5db20..c03ed06 100644
--- a/docs/content/5.configuration.md
+++ b/docs/content/5.configuration.md
@@ -31,6 +31,22 @@ export default defineNuxtConfig({
 })
 ```
 
+## `format`
+
+Default: `['webp']`
+
+You can use this option to configure the default format for your images used by `<nuxt-picture>`. The available formats are `webp`, `avif`, `jpeg`, `jpg`, `png`, and `gif`. The order of the formats is important, as the first format that is supported by the browser will be used. You can pass multiple values like `['avif', 'webp']`.
+
+You can also override this option at the component level by using the [`format` prop.](/components/nuxt-picture#format)
+
+```ts [nuxt.config.ts]
+export default defineNuxtConfig({
+  image: {
+    format: ['webp']
+  }
+})
+```
+
 ## `screens`
 
 List of predefined screen sizes.
@@ -184,8 +200,8 @@ You can use this option to change filename and location for the static image gen
 ```ts [nuxt.config.ts]
 export default defineNuxtConfig({
   image: {
-      // Generate images to `/_nuxt/images/file.png`
-      staticFilename: '[publicPath]/images/[name]-[hash][ext]'
+    // Generate images to `/_nuxt/images/file.png`
+    staticFilename: '[publicPath]/images/[name]-[hash][ext]'
   }
 })
 ```
diff --git a/src/module.ts b/src/module.ts
index 8664a6f..d62790d 100644
--- a/src/module.ts
+++ b/src/module.ts
@@ -17,6 +17,7 @@ export interface ModuleOptions extends ImageProviders {
   internalUrl: string
   providers: { [name: string]: InputProvider | any } & ImageProviders
   densities: number[]
+  format: CreateImageOptions['format']
   [key: string]: any
 }
 
@@ -31,6 +32,7 @@ export default defineNuxtModule<ModuleOptions>({
     presets: {},
     domains: [] as string[],
     sharp: {},
+    format: ['webp'],
     // https://tailwindcss.com/docs/breakpoints
     screens: {
       xs: 320,
@@ -80,7 +82,8 @@ export default defineNuxtModule<ModuleOptions>({
       'provider',
       'domains',
       'alias',
-      'densities'
+      'densities',
+      'format'
     ])
 
     const providers = await resolveProviders(nuxt, options)
diff --git a/src/runtime/components/nuxt-picture.ts b/src/runtime/components/nuxt-picture.ts
index 3e05776..afb446d 100644
--- a/src/runtime/components/nuxt-picture.ts
+++ b/src/runtime/components/nuxt-picture.ts
@@ -18,9 +18,8 @@ export default defineComponent({
     const $img = useImage()
     const _base = useBaseImage(props)
 
-    const isTransparent = computed(() => ['png', 'webp', 'gif', 'svg'].includes(originalFormat.value))
-
     const originalFormat = computed(() => getFileExtension(props.src))
+    const isTransparent = computed(() => ['png', 'webp', 'gif', 'svg'].includes(originalFormat.value))
 
     const legacyFormat = computed(() => {
       if (props.legacyFormat) { return props.legacyFormat }
@@ -29,9 +28,8 @@ export default defineComponent({
 
     type Source = { srcset?: string, src?: string, type?: string, sizes?: string }
     const sources = computed<Source[]>(() => {
-      const format = props.format || (originalFormat.value === 'svg' ? 'svg' : 'webp')
-      const formats = format.split(',')
-      if (format === 'svg') {
+      const formats = props.format?.split(',') || (originalFormat.value === 'svg' ? ['svg'] : ($img.options.format?.length ? [...$img.options.format] : ['webp']))
+      if (formats[0] === 'svg') {
         return [<Source>{ src: props.src }]
       }
 
diff --git a/src/types/image.ts b/src/types/image.ts
index cddc056..23877bb 100644
--- a/src/types/image.ts
+++ b/src/types/image.ts
@@ -44,6 +44,7 @@ export interface CreateImageOptions {
   alias: Record<string, string>
   domains: string[]
   densities: number[]
+  format: string[]
 }
 
 export interface ImageInfo {


Test patch:
diff --git a/test/unit/picture.test.ts b/test/unit/picture.test.ts
index f52bf85..4ab51ea 100644
--- a/test/unit/picture.test.ts
+++ b/test/unit/picture.test.ts
@@ -3,6 +3,10 @@
 import { VueWrapper, mount } from '@vue/test-utils'
 import { beforeEach, describe, expect, it } from 'vitest'
 import { NuxtPicture } from '#components'
+import { useNuxtApp, useRuntimeConfig } from '#imports'
+// @ts-expect-error virtual file
+import { imageOptions } from '#build/image-options'
+import { createImage } from '#image'
 
 describe('Renders simple image', () => {
   let wrapper: VueWrapper<any>
@@ -158,3 +162,119 @@ describe('Renders simple image', () => {
     expect(img.find('img').exists()).toBe(true)
   })
 })
+
+describe('Renders image, applies module config', () => {
+  const nuxtApp = useNuxtApp()
+  const config = useRuntimeConfig()
+  const src = '/image.png'
+
+  beforeEach(() => {
+    delete nuxtApp._img
+  })
+
+  it('Default module config', () => {
+    const picture = mount(NuxtPicture, {
+      propsData: {
+        width: 200,
+        height: 200,
+        sizes: '200,500:500,900:900',
+        src
+      }
+    })
+    expect(picture.html()).toMatchInlineSnapshot(`
+      "<picture>
+        <source type=\\"image/webp\\" sizes=\\"(max-width: 200px) 200px, (max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_webp&s_200x200/image.png 200w, /_ipx/f_webp&s_400x400/image.png 400w, /_ipx/f_webp&s_500x500/image.png 500w, /_ipx/f_webp&s_900x900/image.png 900w, /_ipx/f_webp&s_1000x1000/image.png 1000w, /_ipx/f_webp&s_1800x1800/image.png 1800w\\"><img width=\\"200\\" height=\\"200\\" data-nuxt-pic=\\"\\" src=\\"/_ipx/f_png&s_1800x1800/image.png\\" sizes=\\"(max-width: 200px) 200px, (max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_png&s_200x200/image.png 200w, /_ipx/f_png&s_400x400/image.png 400w, /_ipx/f_png&s_500x500/image.png 500w, /_ipx/f_png&s_900x900/image.png 900w, /_ipx/f_png&s_1000x1000/image.png 1000w, /_ipx/f_png&s_1800x1800/image.png 1800w\\">
+      </picture>"
+    `)
+  })
+
+  it('Module config .format, single entry, no prop.format => module config applies', () => {
+    nuxtApp._img = createImage({
+      ...imageOptions,
+      nuxt: {
+        baseURL: config.app.baseURL
+      },
+      format: ['avif']
+    })
+    const picture = mount(NuxtPicture, {
+      propsData: {
+        width: 200,
+        height: 200,
+        sizes: '200,500:500,900:900',
+        src
+      }
+    })
+    expect(picture.html()).toMatchInlineSnapshot(`
+      "<picture>
+  <source type=\\"image/avif\\" sizes=\\"(max-width: 200px) 200px, (max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_avif&s_200x200/image.png 200w, /_ipx/f_avif&s_400x400/image.png 400w, /_ipx/f_avif&s_500x500/image.png 500w, /_ipx/f_avif&s_900x900/image.png 900w, /_ipx/f_avif&s_1000x1000/image.png 1000w, /_ipx/f_avif&s_1800x1800/image.png 1800w\\"><img width=\\"200\\" height=\\"200\\" data-nuxt-pic=\\"\\" src=\\"/_ipx/f_png&s_1800x1800/image.png\\" sizes=\\"(max-width: 200px) 200px, (max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_png&s_200x200/image.png 200w, /_ipx/f_png&s_400x400/image.png 400w, /_ipx/f_png&s_500x500/image.png 500w, /_ipx/f_png&s_900x900/image.png 900w, /_ipx/f_png&s_1000x1000/image.png 1000w, /_ipx/f_png&s_1800x1800/image.png 1800w\\">
+      </picture>"
+    `)
+  })
+
+  it('Module config .format, multiple entries, no prop.format => module config applies', () => {
+    nuxtApp._img = createImage({
+      ...imageOptions,
+      nuxt: {
+        baseURL: config.app.baseURL
+      },
+      format: ['avif', 'webp']
+    })
+    const picture = mount(NuxtPicture, {
+      propsData: {
+        width: 200,
+        height: 200,
+        sizes: '200,500:500,900:900',
+        src
+      }
+    })
+    expect(picture.html()).toMatchInlineSnapshot(`
+      "<picture>
+        <source type=\\"image/avif\\" sizes=\\"(max-width: 200px) 200px, (max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_avif&s_200x200/image.png 200w, /_ipx/f_avif&s_400x400/image.png 400w, /_ipx/f_avif&s_500x500/image.png 500w, /_ipx/f_avif&s_900x900/image.png 900w, /_ipx/f_avif&s_1000x1000/image.png 1000w, /_ipx/f_avif&s_1800x1800/image.png 1800w\\">
+        <source type=\\"image/webp\\" sizes=\\"(max-width: 200px) 200px, (max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_webp&s_200x200/image.png 200w, /_ipx/f_webp&s_400x400/image.png 400w, /_ipx/f_webp&s_500x500/image.png 500w, /_ipx/f_webp&s_900x900/image.png 900w, /_ipx/f_webp&s_1000x1000/image.png 1000w, /_ipx/f_webp&s_1800x1800/image.png 1800w\\"><img width=\\"200\\" height=\\"200\\" data-nuxt-pic=\\"\\" src=\\"/_ipx/f_png&s_1800x1800/image.png\\" sizes=\\"(max-width: 200px) 200px, (max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_png&s_200x200/image.png 200w, /_ipx/f_png&s_400x400/image.png 400w, /_ipx/f_png&s_500x500/image.png 500w, /_ipx/f_png&s_900x900/image.png 900w, /_ipx/f_png&s_1000x1000/image.png 1000w, /_ipx/f_png&s_1800x1800/image.png 1800w\\">
+      </picture>"
+    `)
+  })
+
+  it('Module config .format, multiple entries, prop.format override => prop is used (takes precedence)', () => {
+    nuxtApp._img = createImage({
+      ...imageOptions,
+      nuxt: {
+        baseURL: config.app.baseURL
+      },
+      format: ['avif', 'webp']
+    })
+    const picture = mount(NuxtPicture, {
+      propsData: {
+        width: 200,
+        height: 200,
+        sizes: '200,500:500,900:900',
+        format: 'avif',
+        src
+      }
+    })
+    expect(picture.html()).toMatchInlineSnapshot(`
+      "<picture>
+  <source type=\\"image/avif\\" sizes=\\"(max-width: 200px) 200px, (max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_avif&s_200x200/image.png 200w, /_ipx/f_avif&s_400x400/image.png 400w, /_ipx/f_avif&s_500x500/image.png 500w, /_ipx/f_avif&s_900x900/image.png 900w, /_ipx/f_avif&s_1000x1000/image.png 1000w, /_ipx/f_avif&s_1800x1800/image.png 1800w\\"><img width=\\"200\\" height=\\"200\\" data-nuxt-pic=\\"\\" src=\\"/_ipx/f_png&s_1800x1800/image.png\\" sizes=\\"(max-width: 200px) 200px, (max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_png&s_200x200/image.png 200w, /_ipx/f_png&s_400x400/image.png 400w, /_ipx/f_png&s_500x500/image.png 500w, /_ipx/f_png&s_900x900/image.png 900w, /_ipx/f_png&s_1000x1000/image.png 1000w, /_ipx/f_png&s_1800x1800/image.png 1800w\\">
+      </picture>"
+    `)
+  })
+
+  it('Module config .format, multiple entries, no prop.format, svg image => renders src with svg only', () => {
+    nuxtApp._img = createImage({
+      ...imageOptions,
+      nuxt: {
+        baseURL: config.app.baseURL
+      },
+      format: ['avif', 'webp']
+    })
+    const picture = mount(NuxtPicture, {
+      propsData: {
+        width: 200,
+        height: 200,
+        sizes: '200,500:500,900:900',
+        src: 'image.svg'
+      }
+    })
+    expect(picture.html()).toMatchInlineSnapshot('"<picture><img width=\\"200\\" height=\\"200\\" data-nuxt-pic=\\"\\" src=\\"image.svg\\"></picture>"')
+  })
+})