├── .github ├── ISSUE_TEMPLATE.md └── workflows │ ├── e2e.yml │ └── unit.yml ├── test ├── unit │ ├── utils │ │ ├── stub.js │ │ ├── tick.ts │ │ ├── observer.ts │ │ └── mount.ts │ ├── __snapshots__ │ │ ├── image.test.ts.snap │ │ └── picture.test.ts.snap │ ├── generate.test.ts │ ├── config.test.ts │ ├── image.test.ts │ ├── plugin.test.ts │ ├── picture.test.ts │ └── providers.test.ts ├── setup.ts ├── fixture │ ├── static │ │ ├── 1280px-K2_2006b.jpg │ │ └── 2000px-Aconcagua2016.jpg │ ├── providers │ │ └── random │ │ │ └── index.ts │ ├── nuxt.config.ts │ ├── pages │ │ ├── dataurl.vue │ │ └── index.vue │ └── tsconfig.json ├── tsconfig.json ├── e2e │ ├── ssr.test.ts │ └── no-ssr.test.ts └── providers.ts ├── renovate.json ├── src ├── index.ts ├── runtime │ ├── index.ts │ ├── ipx.ts │ ├── providers │ │ ├── static.ts │ │ ├── unsplash.ts │ │ ├── fastly.ts │ │ ├── prismic.ts │ │ ├── ipx.ts │ │ ├── storyblok.ts │ │ ├── glide.ts │ │ ├── vercel.ts │ │ ├── netlify.ts │ │ ├── twicpics.ts │ │ ├── sanity.ts │ │ ├── cloudinary.ts │ │ ├── imagekit.ts │ │ └── imgix.ts │ ├── utils │ │ ├── static-map.ts │ │ ├── meta.ts │ │ └── index.ts │ ├── plugin.js │ ├── components │ │ ├── nuxt-img.vue │ │ ├── nuxt-picture.vue │ │ └── image.mixin.ts │ └── image.ts ├── types │ ├── index.ts │ ├── global.ts │ ├── vue.ts │ ├── module.ts │ └── image.ts ├── utils.ts ├── ipx.ts ├── generate.ts ├── provider.ts └── module.ts ├── example ├── .gitignore ├── static │ └── images │ │ └── mountains.jpg ├── package.json ├── nuxt.config.js ├── pages │ └── index.vue └── layouts │ └── default.vue ├── .eslintignore ├── docs ├── static │ ├── icon.png │ ├── social.png │ ├── nuxt-icon.png │ ├── nuxt_image_hero.svg │ ├── logo-dark.svg │ └── logo-light.svg ├── components │ ├── HeaderNavigation.vue │ ├── IconUsers.vue │ ├── IconTrash.vue │ ├── IconInbox.vue │ ├── IconCollection.vue │ ├── IconCDN.vue │ ├── HomeFeature.vue │ ├── IconSparkles.vue │ ├── IconResize.vue │ ├── HomeFeatures.vue │ ├── IconSSG.vue │ └── HomeHero.vue ├── package.json ├── content │ ├── index.md │ └── en │ │ ├── 4.providers │ │ ├── twicpics.md │ │ ├── fastly.md │ │ ├── glide.md │ │ ├── netlify.md │ │ ├── prismic.md │ │ ├── vercel.md │ │ ├── unsplash.md │ │ ├── sanity.md │ │ ├── ipx.md │ │ ├── imgix.md │ │ └── storyblok.md │ │ ├── 1.getting-started │ │ ├── 3.static.md │ │ ├── 2.providers.md │ │ └── 1.installation.md │ │ ├── 2.components │ │ ├── 2.nuxt-picture.md │ │ └── 1.nuxt-img.md │ │ ├── 5.advanced │ │ └── 1.custom-provider.md │ │ └── 3.api │ │ ├── 2.$img.md │ │ └── 1.options.md ├── docus.config.ts ├── README.md ├── windi.config.js └── nuxt.config.js ├── playground ├── static │ ├── favicon.ico │ ├── logos │ │ └── nuxt.png │ └── images │ │ ├── colors.jpg │ │ ├── everest.jpg │ │ └── damavand.jpg ├── pages │ ├── nuxt-img.vue │ ├── nuxt-picture.vue │ ├── index.vue │ ├── provider │ │ └── _provider.vue │ └── responsive.vue ├── providers │ └── custom │ │ └── index.ts ├── tsconfig.json ├── components │ └── provider-selector.vue ├── assets │ └── nuxt-white.svg ├── layouts │ └── default.vue └── nuxt.config.ts ├── .eslintrc.js ├── .gitignore ├── index.d.ts ├── .netlify.toml ├── tsconfig.json ├── jest.config.js ├── vetur ├── tags.json └── attributes.json ├── LICENSE ├── README.md └── package.json /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/unit/utils/stub.js: -------------------------------------------------------------------------------- 1 | module.exports = {} 2 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@nuxtjs" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './module' 2 | export * from './types' 3 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | .nuxt 2 | dist 3 | node_modules 4 | *.log* 5 | .output 6 | -------------------------------------------------------------------------------- /src/runtime/index.ts: -------------------------------------------------------------------------------- 1 | export * from './image' 2 | export * from './utils' 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Common 2 | node_modules 3 | dist 4 | .nuxt 5 | coverage 6 | docs 7 | -------------------------------------------------------------------------------- /docs/static/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codegram/image/main/docs/static/icon.png -------------------------------------------------------------------------------- /docs/static/social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codegram/image/main/docs/static/social.png -------------------------------------------------------------------------------- /docs/static/nuxt-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codegram/image/main/docs/static/nuxt-icon.png -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | import './global' 2 | 3 | export * from './image' 4 | export * from './module' 5 | -------------------------------------------------------------------------------- /playground/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codegram/image/main/playground/static/favicon.ico -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import consola from 'consola' 2 | 3 | consola.wrapAll() 4 | consola.mockTypes(() => jest.fn()) 5 | -------------------------------------------------------------------------------- /playground/static/logos/nuxt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codegram/image/main/playground/static/logos/nuxt.png -------------------------------------------------------------------------------- /example/static/images/mountains.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codegram/image/main/example/static/images/mountains.jpg -------------------------------------------------------------------------------- /playground/static/images/colors.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codegram/image/main/playground/static/images/colors.jpg -------------------------------------------------------------------------------- /playground/static/images/everest.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codegram/image/main/playground/static/images/everest.jpg -------------------------------------------------------------------------------- /playground/static/images/damavand.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codegram/image/main/playground/static/images/damavand.jpg -------------------------------------------------------------------------------- /test/fixture/static/1280px-K2_2006b.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codegram/image/main/test/fixture/static/1280px-K2_2006b.jpg -------------------------------------------------------------------------------- /test/fixture/static/2000px-Aconcagua2016.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codegram/image/main/test/fixture/static/2000px-Aconcagua2016.jpg -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | '@nuxtjs', 5 | '@nuxtjs/eslint-config-typescript' 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .nuxt 3 | *.log 4 | cache/ 5 | dist/ 6 | .DS_Store 7 | coverage 8 | sw.* 9 | .vscode 10 | .vercel_build_output 11 | -------------------------------------------------------------------------------- /playground/pages/nuxt-img.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /test/fixture/providers/random/index.ts: -------------------------------------------------------------------------------- 1 | export function getImage () { 2 | return { 3 | url: 'https://source.unsplash.com/random/600x400' 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /playground/pages/nuxt-picture.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/runtime/ipx.ts: -------------------------------------------------------------------------------- 1 | import { createIPX, createIPXMiddleware } from 'ipx' 2 | 3 | const ipx = createIPX('__IPX_OPTIONS__') 4 | 5 | export default createIPXMiddleware(ipx) 6 | -------------------------------------------------------------------------------- /test/unit/utils/tick.ts: -------------------------------------------------------------------------------- 1 | export async function nextTick () { 2 | await new Promise(resolve => 3 | process.nextTick(() => 4 | resolve(null) 5 | ) 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { $Img } from './src/types' 2 | 3 | declare global { 4 | // Convenience declaration to avoid importing types into runtime templates 5 | const $Img: $Img 6 | } 7 | -------------------------------------------------------------------------------- /.netlify.toml: -------------------------------------------------------------------------------- 1 | # https://docs.netlify.com/configure-builds/file-based-configuration 2 | 3 | [build] 4 | base = "docs" 5 | command = "yarn build" 6 | publish = "dist" 7 | ignore = "git diff --quiet HEAD^ HEAD . ../package.json" 8 | -------------------------------------------------------------------------------- /test/fixture/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import imageModule from '../../src/module' 2 | 3 | export default { 4 | components: true, 5 | modules: [ 6 | imageModule 7 | ], 8 | buildModules: [ 9 | '@nuxt/typescript-build' 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /docs/components/HeaderNavigation.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/runtime/providers/static.ts: -------------------------------------------------------------------------------- 1 | import { getImage as _getImage } from './ipx' 2 | 3 | export const getImage: typeof _getImage = (src, options, ctx) => ({ 4 | ..._getImage(src, options, ctx), 5 | isStatic: true 6 | }) 7 | 8 | export const supportsAlias = true 9 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["DOM"], 5 | "paths": { 6 | "~image/*": ["src/runtime/*"], 7 | "~image": ["src/runtime"], 8 | "~/*": ["src/*"] 9 | } 10 | }, 11 | } -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "generate": "nuxt generate", 5 | "build": "nuxt generate", 6 | "dev": "nuxt dev", 7 | "start": "nuxt start" 8 | }, 9 | "devDependencies": { 10 | "docus": "^0.8.14", 11 | "vue-plausible": "^1.1.4" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/unit/__snapshots__/image.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Renders simple image Matches snapshot 1`] = `""`; 4 | -------------------------------------------------------------------------------- /docs/content/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | template: page 3 | title: Optimized images for Nuxt 4 | description: >- 5 | Plug-and-play image optimization for Nuxt apps. Resize and transform your images in your code using built-in optimizer or your favorite images CDN. 6 | navigation: false 7 | --- 8 | 9 | :home-hero 10 | 11 | :home-features 12 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "nuxt", 5 | "build": "nuxt build", 6 | "generate": "nuxt generate", 7 | "start": "nuxt start" 8 | }, 9 | "devDependencies": { 10 | "@nuxt/image": "^0.5.0", 11 | "nuxt": "^2.15.7", 12 | "nuxt-vite": "^0.1.1" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "baseUrl": ".", 5 | "moduleResolution": "Node", 6 | "esModuleInterop": true, 7 | "resolveJsonModule": true, 8 | "types": ["node", "jest"], 9 | "paths": { 10 | "~image/*": ["src/runtime/*"], 11 | "~image": ["src/runtime"] 12 | } 13 | }, 14 | "exclude": ["dist"] 15 | } 16 | -------------------------------------------------------------------------------- /test/fixture/pages/dataurl.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /playground/providers/custom/index.ts: -------------------------------------------------------------------------------- 1 | import { joinURL } from 'ufo' 2 | import type { ProviderGetImage } from '../../../src/types' // '@nuxt/image' 3 | 4 | export const getImage: ProviderGetImage = (src, { modifiers = {}, baseURL = '/' } = {}) => { 5 | const operationsString = `w_${modifiers.width}&h_${modifiers.height}` 6 | return { 7 | url: joinURL(baseURL, operationsString, src) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /docs/docus.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | title: 'Nuxt Image', 3 | url: 'https://image.nuxtjs.org', 4 | theme: { 5 | colors: { 6 | primary: '#00BB6F' 7 | }, 8 | header: { 9 | title: false, 10 | logo: { 11 | light: '/logo-light.svg', 12 | dark: '/logo-dark.svg' 13 | } 14 | } 15 | }, 16 | twitter: 'nuxt_js', 17 | github: { 18 | repo: 'nuxt/image', 19 | dir: 'docs', 20 | branch: 'main' 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | ## Development 4 | 5 | ```bash 6 | # install dependencies 7 | $ yarn install 8 | 9 | # serve with hot reload at localhost:3000 10 | $ yarn dev 11 | ``` 12 | 13 | Then you can start edit the [content](./content) directory. 14 | 15 | # Test for production 16 | 17 | ```bash 18 | $ yarn generate 19 | $ serve dist/ # npm install -g serve 20 | ``` 21 | 22 | For detailed explanation on how things work, check out [Nuxt.js docs](https://nuxtjs.org). 23 | -------------------------------------------------------------------------------- /playground/pages/index.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 23 | -------------------------------------------------------------------------------- /docs/components/IconUsers.vue: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /test/unit/__snapshots__/picture.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Renders simple image Matches snapshot 1`] = ` 4 | " 5 | " 6 | `; 7 | -------------------------------------------------------------------------------- /test/fixture/pages/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 25 | -------------------------------------------------------------------------------- /docs/components/IconTrash.vue: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: '@nuxt/test-utils', 3 | transform: { 4 | '^.+\\.vue$': 'vue-jest' 5 | }, 6 | moduleNameMapper: { 7 | '~image/(.*)': '/src/runtime/$1', 8 | '~image': '/src/runtime/index.ts', 9 | '~/(.*)': '/src/$1', 10 | '^.+\\.css$': '/test/utils/stub.js' 11 | }, 12 | setupFilesAfterEnv: [ 13 | '/test/setup.ts' 14 | ], 15 | collectCoverageFrom: [ 16 | 'src/**', 17 | '!src/runtime/plugin.js' 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /example/nuxt.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | components: true, 3 | head: { 4 | title: 'Nuxt Image Example', 5 | htmlAttrs: { 6 | lang: 'en' 7 | }, 8 | meta: [ 9 | { charset: 'utf-8' }, 10 | { name: 'viewport', content: 'width=device-width, initial-scale=1' }, 11 | { hid: 'description', name: 'description', content: '' } 12 | ] 13 | }, 14 | modules: [ 15 | '@nuxt/image' 16 | ], 17 | image: { 18 | domains: ['https://images.unsplash.com', 'https://source.unsplash.com'] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /docs/content/en/4.providers/twicpics.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Twicpics Provider 3 | description: 'Nuxt Image internally use Twicpics as static provider.' 4 | navigation: 5 | title: Twicpics 6 | --- 7 | 8 | Integration between [Twicpics](https://www.twicpics.com) and the image module. 9 | 10 | To use this provider you just need to specify the base url of your project in Twicpics. 11 | 12 | ```js{}[nuxt.config.js] 13 | export default { 14 | image: { 15 | twicpics: { 16 | baseURL: 'https://nuxt-demo.twic.pics' 17 | } 18 | } 19 | } 20 | ``` 21 | -------------------------------------------------------------------------------- /docs/content/en/4.providers/fastly.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Fastly Provider 3 | description: 'Nuxt Image has first class integration with Fastly' 4 | navigation: 5 | title: Fastly 6 | --- 7 | 8 | Integration between [Fastly](https://docs.fastly.com/en/guides/image-optimization-api) and the image module. 9 | 10 | To use this provider you just need to specify the base url of your service in Fastly. 11 | 12 | ```js{}[nuxt.config.js] 13 | export default { 14 | image: { 15 | fastly: { 16 | baseURL: 'https://www.fastly.io' 17 | } 18 | } 19 | } 20 | ``` 21 | -------------------------------------------------------------------------------- /test/unit/generate.test.ts: -------------------------------------------------------------------------------- 1 | import { expectFileToBeGenerated, expectFileNotToBeGenerated, setupTest } from '@nuxt/test-utils' 2 | 3 | describe.skip('no config', () => { 4 | setupTest({ 5 | generate: true, 6 | config: { 7 | target: 'static', 8 | image: { 9 | provider: 'static' 10 | } 11 | } 12 | }) 13 | 14 | // TODO: test for generated/optimized files 15 | test('render index', () => { 16 | expectFileToBeGenerated('/_nuxt/image/cc1019.jpg') 17 | expectFileNotToBeGenerated('/2000px-Aconcagua2016.jpg') 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /docs/content/en/1.getting-started/3.static.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Static images 3 | description: 'Optimizing images for static websites' 4 | --- 5 | 6 | If you are building a static site, Nuxt Image will optimize and save your images locally when your site is generated - and deploy them alongside your generated pages. 7 | 8 | :::alert{type="info"} 9 | Even if you are using another provider, you can opt-in to this generate behaviour for a particular image by setting `provider="static"` directly. (See [component documentation](/components/nuxt-img) for more information.) 10 | ::: 11 | -------------------------------------------------------------------------------- /src/runtime/providers/unsplash.ts: -------------------------------------------------------------------------------- 1 | // https://unsplash.com/documentation#dynamically-resizable-images 2 | 3 | import { joinURL, withBase } from 'ufo' 4 | import type { ProviderGetImage } from 'src' 5 | import { operationsGenerator } from './imgix' 6 | 7 | const unsplashCDN = 'https://images.unsplash.com/' 8 | 9 | export const getImage: ProviderGetImage = (src, { modifiers = {}, baseURL = unsplashCDN } = {}) => { 10 | const operations = operationsGenerator(modifiers) 11 | return { 12 | url: withBase(joinURL(src + (operations ? ('?' + operations) : '')), baseURL) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /docs/components/IconInbox.vue: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /docs/content/en/4.providers/glide.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Glide Provider 3 | description: 'Nuxt Image has first class integration with Glide' 4 | navigation: 5 | title: Glide 6 | --- 7 | 8 | Integration between [Glide](https://glide.thephpleague.com/) and the image module. 9 | 10 | To use this provider you just need to specify the base url of your service in glide. 11 | 12 | ```js{}[nuxt.config.js] 13 | export default { 14 | image: { 15 | glide: { 16 | // baseURL of your laravel application 17 | baseURL: 'https://glide.herokuapp.com/1.0/' 18 | } 19 | } 20 | } 21 | ``` 22 | -------------------------------------------------------------------------------- /docs/windi.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | theme: { 3 | colors: { 4 | transparent: 'transparent', 5 | current: 'currentColor', 6 | 'primary-green': '#00DC82', 7 | 'primary-green-darkest': '#003C3C', 8 | secondary: '#002E3B', 9 | 'secondary-light': '#064A5B', 10 | 'secondary-dark': '#01232D', 11 | 'secondary-darker': '#001D25', 12 | 'secondary-darkest': '#003543', 13 | 'mint-lighter': '#A8DDDB', 14 | 'mint-darker': '#003C3C', 15 | 'cloud-surface': '#E6F0F0', 16 | black: '#000', 17 | white: '#fff' 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/types/global.ts: -------------------------------------------------------------------------------- 1 | import { } from '@nuxt/types' 2 | import { ModuleOptions } from './module' 3 | import type { $Img } from './image' 4 | 5 | declare module '@nuxt/types' { 6 | interface Context { 7 | $img: $Img 8 | } 9 | 10 | interface NuxtAppOptions { 11 | $img: $Img 12 | } 13 | 14 | interface Configuration { 15 | image?: Partial 16 | } 17 | } 18 | 19 | declare module 'vue/types/vue' { 20 | interface Vue { 21 | $img: $Img 22 | } 23 | } 24 | 25 | declare module 'vuex/types/index' { 26 | // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars 27 | interface Store { 28 | $img: $Img 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /vetur/tags.json: -------------------------------------------------------------------------------- 1 | { 2 | "nuxt-picture":{ 3 | "attributes":[ 4 | "src", 5 | "width", 6 | "height", 7 | "sizes", 8 | "provider", 9 | "preset", 10 | "format", 11 | "quality", 12 | "fit", 13 | "modifiers" 14 | ], 15 | "description":"drop-in replacement for the native `` tag" 16 | }, 17 | "nuxt-img":{ 18 | "attributes":[ 19 | "src", 20 | "width", 21 | "height", 22 | "sizes", 23 | "provider", 24 | "preset", 25 | "format", 26 | "quality", 27 | "fit", 28 | "modifiers" 29 | ], 30 | "description":"drop-in replacement for the native `` tag" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/fixture/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "lib": [ 7 | "ESNext", 8 | "ESNext.AsyncIterable", 9 | "DOM" 10 | ], 11 | "esModuleInterop": true, 12 | "resolveJsonModule": true, 13 | "allowJs": true, 14 | "sourceMap": true, 15 | "strict": true, 16 | "noEmit": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "~/*": [ 20 | "./*" 21 | ], 22 | "@/*": [ 23 | "./*" 24 | ] 25 | }, 26 | "types": [ 27 | "@types/node", 28 | "@nuxt/types" 29 | ] 30 | }, 31 | "exclude": [ 32 | "node_modules" 33 | ] 34 | } -------------------------------------------------------------------------------- /docs/components/IconCollection.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "lib": [ 7 | "ESNext", 8 | "ESNext.AsyncIterable", 9 | "DOM" 10 | ], 11 | "esModuleInterop": true, 12 | "allowJs": true, 13 | "sourceMap": true, 14 | "strict": false, 15 | "noEmit": true, 16 | "baseUrl": ".", 17 | "paths": { 18 | "~/*": [ 19 | "./*" 20 | ], 21 | "@/*": [ 22 | "./*" 23 | ] 24 | }, 25 | "types": [ 26 | "@types/node", 27 | "@nuxt/types", 28 | "../src/types/global" 29 | ] 30 | }, 31 | "exclude": [ 32 | "node_modules" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /playground/components/provider-selector.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 31 | -------------------------------------------------------------------------------- /src/runtime/providers/fastly.ts: -------------------------------------------------------------------------------- 1 | import { joinURL } from 'ufo' 2 | import type { ProviderGetImage } from 'src' 3 | import { createOperationsGenerator } from '~image' 4 | 5 | const operationsGenerator = createOperationsGenerator({ 6 | valueMap: { 7 | fit: { 8 | fill: 'crop', 9 | inside: 'crop', 10 | outside: 'crop', 11 | cover: 'bounds', 12 | contain: 'bounds' 13 | } 14 | }, 15 | joinWith: '&', 16 | formatter: (key, value) => `${key}=${value}` 17 | }) 18 | 19 | export const getImage: ProviderGetImage = (src, { modifiers = {}, baseURL = '/' } = {}) => { 20 | const operations = operationsGenerator(modifiers) 21 | return { 22 | url: joinURL(baseURL, src + (operations ? ('?' + operations) : '')) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import consola from 'consola' 2 | import hasha from 'hasha/index.js' 3 | import { name, version } from '../package.json' 4 | 5 | export const logger = consola.withScope('@nuxt/image') 6 | 7 | export const pkg = { name, version } 8 | 9 | export function hash (value: string, length = 6) { 10 | return hasha(value).substr(0, length) 11 | } 12 | 13 | export function pick, K extends keyof O> (obj: O, keys: K[]): Pick { 14 | const newobj = {} as Pick 15 | for (const key of keys) { 16 | newobj[key] = obj[key] 17 | } 18 | return newobj 19 | } 20 | 21 | export function guessExt (input: string = '') { 22 | const ext = input.split('.').pop()?.split('?')[0] 23 | if (ext && /^[\w0-9]+$/.test(ext)) { 24 | return '.' + ext 25 | } 26 | return '' 27 | } 28 | -------------------------------------------------------------------------------- /example/pages/index.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 23 | 24 | 26 | -------------------------------------------------------------------------------- /docs/components/IconCDN.vue: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /src/runtime/providers/prismic.ts: -------------------------------------------------------------------------------- 1 | import { joinURL, parseQuery, parseURL, stringifyQuery } from 'ufo' 2 | import type { ProviderGetImage } from 'src' 3 | import { operationsGenerator } from './imgix' 4 | 5 | const PRISMIC_IMGIX_BUCKET = 'https://images.prismic.io' 6 | 7 | // Prismic image bucket is left configurable in order to test on other environments 8 | export const getImage: ProviderGetImage = ( 9 | src, 10 | { modifiers = {}, baseURL = PRISMIC_IMGIX_BUCKET } = {} 11 | ) => { 12 | const operations = operationsGenerator(modifiers) 13 | 14 | const parsedURL = parseURL(src) 15 | 16 | return { 17 | url: joinURL( 18 | baseURL, 19 | parsedURL.pathname + '?' + 20 | // Remove duplicated keys, prioritizing override from developers 21 | stringifyQuery(Object.assign(parseQuery(parsedURL.search), parseQuery(operations))) 22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /docs/content/en/4.providers/netlify.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Netlify Provider 3 | description: Optimize images with Netlify's dynamic image transformation service. 4 | navigation: 5 | title: Netlify 6 | --- 7 | 8 | Netlify offers dynamic image transformation for all JPEG, PNG, and GIF files you have set to be tracked with [Netlify Large Media](https://docs.netlify.com/large-media/overview/). 9 | 10 | :::alert{type="warning"} 11 | Before setting `provider: 'netlify'`, make sure you have followed the steps to enable [Netlify Large Media](https://docs.netlify.com/large-media/overview/). 12 | ::: 13 | 14 | ## Modifiers 15 | 16 | In addition to `height` and `width`, the Netlify provider supports the following modifiers: 17 | 18 | ### `fit` 19 | 20 | * **Default**: `contain` 21 | * **Valid options**: `contain` (equivalent to `nf_resize=fit`) and `fill` (equivalent to `nf_resize=smartcrop`) 22 | -------------------------------------------------------------------------------- /docs/content/en/2.components/2.nuxt-picture.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 3 | description: Discover how to use and configure the nuxt-picture component. 4 | --- 5 | 6 | `` is a drop-in replacement for the native `` tag. 7 | 8 | Usage of `` is almost identical to [``](nuxt-img) 9 | but also allows serving modern formats like `webp` when possible. 10 | 11 | Learn more about the [`` tag on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture) 12 | 13 | ## Props 14 | 15 | :::alert{type="info"} 16 | See props supported by nuxt-img 17 | ::: 18 | 19 | ### legacyFormat 20 | 21 | Format used for fallback. Default is conditional: 22 | 23 | - If original format supports transparency (`png`, `webp` and `gif`), `png` is used for fallback 24 | - Otherwise `jpeg` is used for fallback 25 | -------------------------------------------------------------------------------- /docs/components/HomeFeature.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 38 | -------------------------------------------------------------------------------- /test/unit/utils/observer.ts: -------------------------------------------------------------------------------- 1 | export function mockObserver () { 2 | const observer = { 3 | targets: [] as Element[], 4 | wasAdded: false, 5 | wasDestroyed: false, 6 | callbacks: [] as Array<(entries: IntersectionObserverEntry[]) => any>, 7 | triggerVisibility () { 8 | this.callbacks.forEach(cb => cb(this.targets.map(target => ({ target, isIntersecting: true, intersectionRatio: 1 } as any)))) 9 | } 10 | } 11 | window.IntersectionObserver = class IntersectionObserver { 12 | constructor (callback: () => any) { 13 | observer.callbacks.push(callback) 14 | } 15 | 16 | observe (target: Element) { 17 | observer.targets.push(target) 18 | observer.wasAdded = true 19 | } 20 | 21 | disconnect () { 22 | observer.wasDestroyed = true 23 | } 24 | 25 | unobserve () { 26 | observer.wasDestroyed = true 27 | } 28 | } as any 29 | 30 | return observer 31 | } 32 | -------------------------------------------------------------------------------- /test/unit/config.test.ts: -------------------------------------------------------------------------------- 1 | import { get, setupTest } from '@nuxt/test-utils' 2 | 3 | describe('undefined config', () => { 4 | setupTest({ 5 | server: true, 6 | config: {} 7 | }) 8 | 9 | test('defaults to ipx to optimize images', async () => { 10 | const { body } = await get('/') 11 | expect(body).toContain('') 12 | }) 13 | }) 14 | 15 | describe('Custom provider', () => { 16 | setupTest({ 17 | server: true, 18 | config: { 19 | image: { 20 | provider: 'random', 21 | providers: { 22 | random: { 23 | provider: '~/providers/random' 24 | } 25 | } 26 | } 27 | } 28 | }) 29 | 30 | test('render index', async () => { 31 | const { body } = await get('/') 32 | expect(body).toContain('') 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /docs/content/en/4.providers/prismic.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Prismic Provider 3 | description: 'Nuxt Image has first class integration with Prismic' 4 | navigation: 5 | title: Prismic 6 | --- 7 | 8 | Integration between [Prismic](https://prismic.io/docs) and the image module. 9 | 10 | No specific configuration is required for Prismic support. You just need to specify `provider: 'prismic'` in your configuration to make it the default: 11 | 12 | ```js{}[nuxt.config.js] 13 | export default { 14 | image: { 15 | prismic: {} 16 | } 17 | } 18 | ``` 19 | 20 | You can also pass it directly to your component when you need it, for example: 21 | 22 | ```html[*.vue] 23 | 24 | ``` 25 | 26 | :::alert{type="info"} 27 | Prismic allows content writer to manipulate images through its UI (cropping, rezising, etc.). To preserve that behavior this provider does not strip query parameters coming from Prismic. Instead it only overrides them when needed, keeping developers in control. 28 | ::: 29 | -------------------------------------------------------------------------------- /playground/pages/provider/_provider.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 33 | 34 | 39 | -------------------------------------------------------------------------------- /src/runtime/utils/static-map.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from '@nuxt/types' 2 | 3 | const staticImageMap = {} 4 | 5 | function updateImageMap () { 6 | if (typeof window.$nuxt !== 'undefined') { 7 | // Client-side navigation 8 | const pageImages = (window.$nuxt as any)._pagePayload?.data?.[0]?._img || {} 9 | Object.assign(staticImageMap, pageImages) 10 | } else if (typeof (window as any).__NUXT__ !== 'undefined') { 11 | // Initial load 12 | const pageImages = (window as any).__NUXT__?._img || {} 13 | Object.assign(staticImageMap, pageImages) 14 | } 15 | } 16 | 17 | export function useStaticImageMap (nuxtContext?: Context) { 18 | // Update on initialization 19 | updateImageMap() 20 | 21 | // Merge new mappings on route change 22 | if (nuxtContext) { 23 | nuxtContext.app.router?.afterEach(updateImageMap) 24 | } 25 | 26 | // Make sure manifest is initialized 27 | if ((window as any).onNuxtReady) { 28 | (window as any).onNuxtReady(updateImageMap) 29 | } 30 | 31 | return staticImageMap 32 | } 33 | -------------------------------------------------------------------------------- /test/unit/utils/mount.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import { createImage } from '~image/image' 3 | 4 | export function getSrc (path: string) { 5 | return '/_custom' + path 6 | } 7 | 8 | export function mountWithImg (Component: any, propsData: Record) { 9 | const $img = createImage( 10 | { 11 | providers: { 12 | custom: { 13 | defaults: {}, 14 | provider: { 15 | getImage (url, options) { 16 | const segments = url.split('.') 17 | const path = [segments.slice(0, -1), (options.modifiers?.format || segments.slice(-1))].join('.') 18 | return { 19 | url: getSrc(path) 20 | } 21 | } 22 | } 23 | } 24 | }, 25 | presets: {}, 26 | provider: 'custom' 27 | }, 28 | {} 29 | ) 30 | 31 | return mount( 32 | { 33 | inject: ['$img'], 34 | ...Component 35 | }, 36 | { 37 | propsData, 38 | provide: { 39 | $img 40 | } 41 | } 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/runtime/providers/ipx.ts: -------------------------------------------------------------------------------- 1 | import { ProviderGetImage } from 'src' 2 | import { joinURL, encodePath, encodeParam } from 'ufo' 3 | import { createOperationsGenerator } from '~image' 4 | 5 | const operationsGenerator = createOperationsGenerator({ 6 | keyMap: { 7 | format: 'f', 8 | fit: 'fit', 9 | width: 'w', 10 | height: 'h', 11 | resize: 's', 12 | quality: 'q', 13 | background: 'b' 14 | }, 15 | joinWith: ',', 16 | formatter: (key, val) => encodeParam(key) + '_' + encodeParam(val) 17 | }) 18 | 19 | export const getImage: ProviderGetImage = (src, { modifiers = {}, baseURL = '/_ipx' } = {}, { nuxtContext: { base: nuxtBase = '/' } = {} }) => { 20 | if (modifiers.width && modifiers.height) { 21 | modifiers.resize = `${modifiers.width}x${modifiers.height}` 22 | delete modifiers.width 23 | delete modifiers.height 24 | } 25 | 26 | const params = operationsGenerator(modifiers) || '_' 27 | 28 | return { 29 | url: joinURL(nuxtBase, baseURL, params, encodePath(src)) 30 | } 31 | } 32 | 33 | export const validateDomains = true 34 | export const supportsAlias = true 35 | -------------------------------------------------------------------------------- /src/runtime/plugin.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { createImage} from '~image' 3 | import NuxtImg from '~image/components/nuxt-img.vue' 4 | import NuxtPicture from '~image/components/nuxt-picture.vue' 5 | 6 | <%=options.providers.map(p => `import * as ${p.importName} from '${p.runtime}'`).join('\n')%> 7 | 8 | const imageOptions = <%= JSON.stringify(options.imageOptions, null, 2) %> 9 | 10 | imageOptions.providers = { 11 | <%=options.providers.map(p => ` ['${p.name}']: { provider: ${p.importName}, defaults: ${JSON.stringify(p.runtimeOptions)} }`).join(',\n') %> 12 | } 13 | 14 | 15 | Vue.component(NuxtImg.name, NuxtImg) 16 | Vue.component(NuxtPicture.name, NuxtPicture) 17 | Vue.component('NImg', NuxtImg) 18 | Vue.component('NPicture', NuxtPicture) 19 | 20 | export default function (nuxtContext, inject) { 21 | const $img = createImage(imageOptions, nuxtContext) 22 | 23 | if (process.static && process.server) { 24 | nuxtContext.beforeNuxtRender(({ nuxtState }) => { 25 | const ssrData = nuxtState.data[0] || {} 26 | ssrData._img = nuxtState._img || {} 27 | }) 28 | } 29 | 30 | inject('img', $img) 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Nuxt.js 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/content/en/4.providers/vercel.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Vercel Provider 3 | navigation.title: Vercel 4 | description: Optimize images at Vercel's Edge Network 5 | navigation: 6 | title: Vercel 7 | --- 8 | 9 | When deploying your nuxt applications to [Vercel](https://vercel.com/) platform, image module can use Vercel's [Edge Network](https://vercel.com/docs/edge-network/overview) to optimize images on demand. 10 | 11 | This provider will be enabled by default in vercel deployments. 12 | 13 | ## Domains 14 | 15 | To use external URLs (images not in `static/` directory), hostnames should be whitelisted. 16 | 17 | **Example:** 18 | 19 | ```ts [nuxt.config] 20 | export default { 21 | image: { 22 | domains: [ 23 | 'avatars0.githubusercontent.com' 24 | ] 25 | } 26 | } 27 | ``` 28 | 29 | ## Sizes 30 | 31 | Specify any custom `width` property you use in ``, `` and `$img`. 32 | 33 | If a width is not defined, image will fallback to closest possible width. 34 | 35 | **Example:** 36 | 37 | ```ts [nuxt.config] 38 | export default { 39 | image: { 40 | screens: { 41 | icon: 40, 42 | avatar: 24 43 | } 44 | } 45 | } 46 | ``` 47 | -------------------------------------------------------------------------------- /src/types/vue.ts: -------------------------------------------------------------------------------- 1 | import type Vue from 'vue' 2 | import type { ThisTypedComponentOptionsWithRecordProps } from 'vue/types/options' 3 | import type { ExtendedVue, VueConstructor } from 'vue/types/vue' 4 | 5 | export interface DefineMixin { 6 | (options?: ThisTypedComponentOptionsWithRecordProps): Data & Methods & Computed & Props & VueConstructor 7 | } 8 | 9 | export interface DefineComponentWithMixin { 10 | // this is currently a hack - ideally we wouldn't need to duplicate this for multiple mixins 11 | , Mixin2 extends Record>(options?: ThisTypedComponentOptionsWithRecordProps>, Methods, Computed, Props> & { mixins: [Mixin1, Mixin2] }): ExtendedVue; 12 | >(options?: ThisTypedComponentOptionsWithRecordProps>, Methods, Computed, Props> & { mixins: Mixin[] }): ExtendedVue; 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test-e2e: 13 | runs-on: ${{ matrix.os }} 14 | 15 | strategy: 16 | matrix: 17 | os: [ubuntu-latest] 18 | node: [14] 19 | 20 | steps: 21 | - uses: actions/setup-node@v1 22 | with: 23 | node-version: ${{ matrix.node }} 24 | 25 | - name: checkout 26 | uses: actions/checkout@master 27 | 28 | - name: Get yarn cache directory path 29 | id: yarn-cache-dir-path 30 | run: echo "::set-output name=dir::$(yarn cache dir)" 31 | 32 | - uses: actions/cache@v2 33 | id: yarn-cache # check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 34 | with: 35 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 36 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 37 | restore-keys: | 38 | ${{ runner.os }}-yarn- 39 | 40 | - name: Install dependencies 41 | run: yarn 42 | 43 | - name: Test 44 | run: yarn test:e2e 45 | 46 | - name: Coverage 47 | uses: codecov/codecov-action@v1 48 | -------------------------------------------------------------------------------- /playground/assets/nuxt-white.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/runtime/providers/storyblok.ts: -------------------------------------------------------------------------------- 1 | import { withBase, joinURL, parseURL } from 'ufo' 2 | import type { ProviderGetImage } from 'src' 3 | 4 | // https://www.storyblok.com/docs/image-service 5 | const storyblockCDN = 'https://img2.storyblok.com' 6 | 7 | export const getImage: ProviderGetImage = (src, { modifiers = {}, baseURL = storyblockCDN } = {}) => { 8 | const { 9 | fit, 10 | smart, 11 | width = '0', 12 | height = '0', 13 | filters = {}, 14 | format, 15 | quality 16 | } = modifiers 17 | 18 | const doResize = width !== '0' || height !== '0' 19 | 20 | if (format) { 21 | filters.format = format + '' 22 | } 23 | 24 | if (quality) { 25 | filters.quality = quality + '' 26 | } 27 | 28 | const _filters = Object.entries(filters || {}).map(e => `${e[0]}(${e[1]})`).join(':') 29 | 30 | const options = joinURL( 31 | fit ? `fit-${fit}` : '', 32 | doResize ? `${width}x${height}` : '', 33 | smart ? 'smart' : '', 34 | _filters ? ('filters:' + _filters) : '' 35 | ) 36 | 37 | // TODO: check if hostname is https://a.storyblok.com ? 38 | const { pathname } = parseURL(src) 39 | 40 | const url = withBase(joinURL(options, pathname), baseURL) 41 | 42 | return { 43 | url 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /vetur/attributes.json: -------------------------------------------------------------------------------- 1 | { 2 | "src":{ 3 | "description":"Path to image file" 4 | }, 5 | "width":{ 6 | "description":"Specify width of the image." 7 | }, 8 | "height":{ 9 | "description":"Specify height of the image." 10 | }, 11 | "sizes":{ 12 | "description":"Specify responsive reiszes. `sm:100vw md:50vw lg:400px`" 13 | }, 14 | "provider":{ 15 | "description":"Use other provider instead of default provider option specified in nuxt.config`" 16 | }, 17 | "preset":{ 18 | "description":"Presets are predefined sets of image modifiers that can be used create unified form of images in your projects." 19 | }, 20 | "format":{ 21 | "description":"In case you want to serve images in a specific format, use this prop. `webp`, `jpeg`, `jpg`, `png`, `gif` and `svg`" 22 | }, 23 | "quality":{ 24 | "description":"The quality for the generated image(s)." 25 | }, 26 | "fit":{ 27 | "description":"The fit property specifies the size of the images. There are five standard values you can use with this property. `cover` `contain` `fill` `inside` `outside`" 28 | }, 29 | "modifiers":{ 30 | "description":"In addition to standard modifiers, every provider can have their own modifiers. `:modifiers=\"{ roundCorner: '0:100' }\"`" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /docs/components/IconSparkles.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/e2e/ssr.test.ts: -------------------------------------------------------------------------------- 1 | import { setupTest, createPage, url } from '@nuxt/test-utils' 2 | import type { Page } from 'playwright' 3 | 4 | describe('browser (ssr: true)', () => { 5 | setupTest({ 6 | browser: true, 7 | config: { 8 | image: { 9 | provider: 'ipx' 10 | } 11 | } 12 | }) 13 | let page: Page 14 | const requests: string[] = [] 15 | 16 | test('should render image', async () => { 17 | page = await createPage() 18 | page.route('**', (route) => { 19 | requests.push(route.request().url()) 20 | return route.continue() 21 | }) 22 | page.goto(url('/')) 23 | const body = await page.innerHTML('body') 24 | expect(body).toContain('/_ipx/s_300x200/2000px-Aconcagua2016.jpg') 25 | 26 | const positiveRequest = requests.find(request => request.match('/_ipx/s_300x200/2000px-Aconcagua2016.jpg')) 27 | expect(positiveRequest).toBeTruthy() 28 | const negativeRequest = requests.find(request => request.match('1280px-K2_2006b.jpg')) 29 | expect(negativeRequest).toBeFalsy() 30 | }) 31 | 32 | test('change image location', async () => { 33 | await page.click('#button') 34 | const positiveRequest = requests.find(request => request.match('/_ipx/s_300x200/1280px-K2_2006b.jpg')) 35 | expect(positiveRequest).toBeTruthy() 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![@nuxt/image](./docs/static/social.svg "Nuxt Image")](https://image.nuxtjs.org) 2 | 3 | [![npm version][npm-version-src]][npm-version-href] 4 | [![npm downloads][npm-downloads-src]][npm-downloads-href] 5 | [![Checks][checks-src]][checks-href] 6 | [![Codecov][codecov-src]][codecov-href] 7 | 8 | - [📖  Read Documentation](https://image.nuxtjs.org) 9 | - [▶️  Play online](https://githubbox.com/nuxt/image/tree/main/example) 10 | 11 | 12 | ### Contributing 13 | 14 | 1. Clone this repository 15 | 2. Install dependencies using `yarn install` 16 | 3. Start development server using `yarn dev` 17 | 18 | 19 | ## 📑 License 20 | 21 | Copyright (c) Nuxt Team 22 | 23 | 24 | 25 | 26 | [npm-version-src]: https://flat.badgen.net/npm/v/@nuxt/image 27 | [npm-version-href]: https://npmjs.com/package/@nuxt/image 28 | [npm-downloads-src]: https://flat.badgen.net/npm/dm/@nuxt/image 29 | [npm-downloads-href]: https://npmjs.com/package/@nuxt/image 30 | [checks-src]: https://flat.badgen.net/github/checks/nuxt/image/master 31 | [checks-href]: https://github.com/nuxt/image/actions 32 | [codecov-src]: https://flat.badgen.net/codecov/c/github/nuxt/image 33 | [codecov-href]: https://codecov.io/gh/nuxt/image 34 | [license-src]: https://img.shields.io/npm/l/@nuxt/image.svg 35 | [license-href]: https://github.com/nuxt/image/blob/main/LICENSE 36 | -------------------------------------------------------------------------------- /playground/pages/responsive.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 40 | 41 | 61 | -------------------------------------------------------------------------------- /docs/nuxt.config.js: -------------------------------------------------------------------------------- 1 | import { withDocus } from 'docus' 2 | 3 | export default withDocus({ 4 | rootDir: __dirname, 5 | buildModules: ['vue-plausible'], 6 | head: { 7 | meta: [ 8 | { charset: 'utf-8' }, 9 | { name: 'viewport', content: 'width=device-width, initial-scale=1' }, 10 | { hid: 'og:site_name', property: 'og:site_name', content: 'Nuxt/Image' }, 11 | { hid: 'og:type', property: 'og:type', content: 'website' }, 12 | { hid: 'twitter:site', name: 'twitter:site', content: '@nuxt_js' }, 13 | { 14 | hid: 'twitter:card', 15 | name: 'twitter:card', 16 | content: 'summary_large_image' 17 | }, 18 | { 19 | hid: 'og:image', 20 | property: 'og:image', 21 | content: 'https://image.nuxtjs.org/social.png' 22 | }, 23 | { 24 | hid: 'og:image:secure_url', 25 | property: 'og:image:secure_url', 26 | content: 'https://image.nuxtjs.org/social.png' 27 | }, 28 | { 29 | hid: 'og:image:alt', 30 | property: 'og:image:alt', 31 | content: 'Nuxt/Image' 32 | }, 33 | { 34 | hid: 'twitter:image', 35 | name: 'twitter:image', 36 | content: 'https://image.nuxtjs.org/social.png' 37 | } 38 | ] 39 | }, 40 | plausible: { 41 | domain: 'image.nuxtjs.org' 42 | } 43 | }) 44 | -------------------------------------------------------------------------------- /.github/workflows/unit.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test-unit: 13 | runs-on: ${{ matrix.os }} 14 | 15 | strategy: 16 | matrix: 17 | os: [ubuntu-latest] 18 | node: [14] 19 | 20 | steps: 21 | - uses: actions/setup-node@v1 22 | with: 23 | node-version: ${{ matrix.node }} 24 | 25 | - name: checkout 26 | uses: actions/checkout@master 27 | 28 | 29 | - name: Get yarn cache directory path 30 | id: yarn-cache-dir-path 31 | run: echo "::set-output name=dir::$(yarn cache dir)" 32 | 33 | - uses: actions/cache@v2 34 | id: yarn-cache # check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 35 | with: 36 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 37 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 38 | restore-keys: | 39 | ${{ runner.os }}-yarn- 40 | 41 | - name: Install dependencies 42 | if: steps.cache.outputs.cache-hit != 'true' 43 | run: yarn 44 | 45 | - name: Lint 46 | run: yarn lint 47 | 48 | - name: Test 49 | run: yarn test:unit 50 | 51 | - name: Coverage 52 | uses: codecov/codecov-action@v1 53 | -------------------------------------------------------------------------------- /test/e2e/no-ssr.test.ts: -------------------------------------------------------------------------------- 1 | import { setupTest, createPage, url } from '@nuxt/test-utils' 2 | import type { Page } from 'playwright' 3 | 4 | describe('browser (ssr: false)', () => { 5 | setupTest({ 6 | config: { 7 | ssr: false, 8 | image: { 9 | provider: 'ipx' 10 | } 11 | }, 12 | browser: true 13 | }) 14 | let page: Page 15 | const requests: string[] = [] 16 | 17 | test('should render image', async () => { 18 | page = await createPage() 19 | 20 | page.route('**', (route) => { 21 | requests.push(route.request().url()) 22 | return route.continue() 23 | }) 24 | 25 | page.goto(url('/')) 26 | await page.waitForEvent('domcontentloaded') 27 | const body = await page.innerHTML('body') 28 | expect(body).toContain('/_ipx/s_300x200/2000px-Aconcagua2016.jpg') 29 | 30 | const positiveRequest = requests.find(request => request.match('/_ipx/s_300x200/2000px-Aconcagua2016.jpg')) 31 | expect(positiveRequest).toBeTruthy() 32 | const negativeRequest = requests.find(request => request.match('1280px-K2_2006b.jpg')) 33 | expect(negativeRequest).toBeFalsy() 34 | }) 35 | 36 | test('change image location', async () => { 37 | await page.click('#button') 38 | const positiveRequest = requests.find(request => request.match('/_ipx/s_300x200/1280px-K2_2006b.jpg')) 39 | expect(positiveRequest).toBeTruthy() 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /docs/components/IconResize.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /playground/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 27 | 28 | 46 | 47 | 69 | -------------------------------------------------------------------------------- /src/types/module.ts: -------------------------------------------------------------------------------- 1 | import type { IPXOptions } from 'ipx' 2 | import type { ImageOptions, CreateImageOptions } from './image' 3 | 4 | // eslint-disable-next-line no-use-before-define 5 | export type ProviderSetup = (providerOptions: ImageModuleProvider, moduleOptions: ModuleOptions, nuxt: any) 6 | => void | Promise 7 | 8 | export interface InputProvider { 9 | name?: string 10 | provider?: string 11 | options?: T 12 | setup?: ProviderSetup 13 | } 14 | 15 | export interface ImageProviders { 16 | cloudinary?: any 17 | fastly?: any 18 | glide?: any 19 | imagekit?: any 20 | imgix?: any 21 | prismic?: any 22 | twicpics?: any 23 | storyblok?: any, 24 | ipx?: Partial 25 | static?: Partial 26 | } 27 | 28 | // TODO: use types from CreateImageOptions 29 | export interface ModuleOptions extends ImageProviders { 30 | staticFilename: string, 31 | provider: CreateImageOptions['provider'] 32 | presets: { [name: string]: ImageOptions } 33 | dir: string 34 | domains: string[] 35 | sharp: any 36 | alias: Record 37 | screens: CreateImageOptions['screens'], 38 | internalUrl: string 39 | providers: { [name: string]: InputProvider | any } & ImageProviders 40 | [key: string]: any 41 | } 42 | 43 | export interface ImageModuleProvider { 44 | name: string 45 | importName: string 46 | options: any 47 | provider: string 48 | runtime: string 49 | runtimeOptions: any 50 | setup: ProviderSetup 51 | } 52 | -------------------------------------------------------------------------------- /src/runtime/providers/glide.ts: -------------------------------------------------------------------------------- 1 | // https://glide.thephpleague.com/2.0/api/quick-reference/ 2 | 3 | import { ProviderGetImage } from 'src' 4 | import { joinURL, encodeQueryItem, encodePath, withBase } from 'ufo' 5 | import { createOperationsGenerator } from '~image' 6 | 7 | const operationsGenerator = createOperationsGenerator({ 8 | keyMap: { 9 | orientation: 'or', 10 | flip: 'flip', 11 | crop: 'crop', 12 | width: 'w', 13 | height: 'h', 14 | fit: 'fit', 15 | dpr: 'dpr', 16 | bri: 'bri', 17 | con: 'con', 18 | gam: 'gam', 19 | sharp: 'sharp', 20 | blur: 'blur', 21 | pixel: 'pixel', 22 | filt: 'filt', 23 | mark: 'mark', 24 | markw: 'markw', 25 | markh: 'markh', 26 | markx: 'markx', 27 | marky: 'marky', 28 | markpad: 'markpad', 29 | markpos: 'markpos', 30 | markalpha: 'markalpha', 31 | background: 'bg', 32 | border: 'border', 33 | quality: 'q', 34 | format: 'fm' 35 | }, 36 | valueMap: { 37 | fit: { 38 | fill: 'fill', 39 | inside: 'max', 40 | outside: 'stretch', 41 | cover: 'crop', 42 | contain: 'contain' 43 | } 44 | }, 45 | joinWith: '&', 46 | formatter: (key, val) => encodeQueryItem(key, val) 47 | }) 48 | 49 | export const getImage: ProviderGetImage = (src, { modifiers = {}, baseURL = '/' } = {}) => { 50 | const params = operationsGenerator(modifiers) 51 | 52 | return { 53 | url: withBase(joinURL(encodePath(src) + (params ? '?' + params : '')), baseURL) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/runtime/providers/vercel.ts: -------------------------------------------------------------------------------- 1 | import { ProviderGetImage } from 'src' 2 | import { stringifyQuery } from 'ufo' 3 | 4 | // https://vercel.com/docs/more/adding-your-framework#images 5 | 6 | export const getImage: ProviderGetImage = (src, { modifiers, baseURL = '/_vercel/image' } = {}, ctx) => { 7 | const validWidths = Object.values(ctx.options.screens || {}).sort() 8 | const largestWidth = validWidths[validWidths.length - 1] 9 | let width = Number(modifiers?.width || 0) 10 | 11 | if (!width) { 12 | width = largestWidth 13 | if (process.env.NODE_ENV === 'development') { 14 | // eslint-disable-next-line 15 | console.warn(`A defined width should be provided to use the \`vercel\` provider. Defaulting to \`${largestWidth}\`. Warning originated from \`${src}\`.`) 16 | } 17 | } else if (!validWidths.includes(width)) { 18 | width = validWidths.find(validWidth => validWidth > width) || largestWidth 19 | if (process.env.NODE_ENV === 'development') { 20 | // eslint-disable-next-line 21 | console.warn(`The width being used (\`${modifiers?.width}\`) should be added to \`image.screens\`. Defaulting to \`${width}\`. Warning originated from \`${src}\`.`) 22 | } 23 | } 24 | 25 | if (process.env.NODE_ENV === 'development') { 26 | return { url: src } 27 | } 28 | 29 | return { 30 | url: baseURL + '?' + stringifyQuery({ 31 | url: src, 32 | w: String(width), 33 | q: String(modifiers?.quality || '100') 34 | }) 35 | } 36 | } 37 | 38 | export const validateDomains = true 39 | -------------------------------------------------------------------------------- /test/unit/image.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { Wrapper } from '@vue/test-utils' 6 | 7 | import type Vue from 'vue' 8 | import { getSrc, mountWithImg } from './utils/mount' 9 | 10 | import NuxtImg from '~/runtime/components/nuxt-img.vue' 11 | 12 | describe('Renders simple image', () => { 13 | let wrapper: Wrapper 14 | const src = '/image.png' 15 | 16 | beforeEach(() => { 17 | wrapper = mountWithImg(NuxtImg, { 18 | width: 200, 19 | height: 200, 20 | sizes: '200,500:500,900:900', 21 | src 22 | }) 23 | }) 24 | 25 | test('Matches snapshot', () => { 26 | expect(wrapper.html()).toMatchSnapshot() 27 | }) 28 | 29 | test.todo('alt attribute is generated') 30 | // () => { 31 | // expect((wrapper.vm as any).generatedAlt).toEqual('image') 32 | // const domAlt = wrapper.element.getAttribute('alt') 33 | // expect(domAlt).toEqual('image') 34 | // } 35 | 36 | test('props.src is picked up by getImage()', () => { 37 | const domSrc = wrapper.element.getAttribute('src') 38 | expect(domSrc).toEqual(getSrc(src)) 39 | }) 40 | 41 | test('props.src is reactive', (done) => { 42 | const newSource = '/image.jpeg' 43 | wrapper.setProps({ src: newSource }) 44 | process.nextTick(() => { 45 | const domSrc = wrapper.find('img').element.getAttribute('src') 46 | expect(domSrc).toEqual(getSrc(newSource)) 47 | return done() 48 | }) 49 | }) 50 | 51 | test('sizes', () => { 52 | const sizes = wrapper.find('img').element.getAttribute('sizes') 53 | expect(sizes).toBe('(max-width: 500px) 500px, 900px') 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /src/runtime/providers/netlify.ts: -------------------------------------------------------------------------------- 1 | import { joinURL } from 'ufo' 2 | import type { ProviderGetImage } from 'src' 3 | import { createOperationsGenerator } from '~image' 4 | 5 | export const operationsGenerator = createOperationsGenerator({ 6 | keyMap: { 7 | height: 'h', 8 | fit: 'nf_resize', 9 | width: 'w' 10 | }, 11 | valueMap: { 12 | fit: { 13 | fill: 'smartcrop', 14 | contain: 'fit' 15 | } 16 | }, 17 | joinWith: '&', 18 | formatter: (key, value) => `${key}=${value}` 19 | }) 20 | 21 | const isDev = process.env.NODE_ENV === 'development' 22 | 23 | // https://docs.netlify.com/large-media/transform-images/ 24 | 25 | export const getImage: ProviderGetImage = (src, { modifiers = {}, baseURL = '/' } = {}) => { 26 | if (modifiers.format) { 27 | // Not currently supported 28 | delete modifiers.format 29 | } 30 | const hasTransformation = modifiers.height || modifiers.width 31 | if (!modifiers.fit && hasTransformation) { 32 | // fit is required for resizing images 33 | modifiers.fit = 'contain' 34 | } 35 | if (hasTransformation && modifiers.fit !== 'contain' && !(modifiers.height && modifiers.width)) { 36 | // smartcrop is only supported with both height and width 37 | if (isDev) { 38 | // eslint-disable-next-line 39 | console.warn(`Defaulting to fit=contain as smart cropping is only supported when providing both height and width. Warning originated from \`${src}\`.`) 40 | } 41 | modifiers.fit = 'contain' 42 | } 43 | if (isDev) { 44 | return { url: src } 45 | } 46 | const operations = operationsGenerator(modifiers) 47 | return { 48 | url: joinURL(baseURL, src + (operations ? ('?' + operations) : '')) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /docs/components/HomeFeatures.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 61 | -------------------------------------------------------------------------------- /src/runtime/components/nuxt-img.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 63 | -------------------------------------------------------------------------------- /src/runtime/providers/twicpics.ts: -------------------------------------------------------------------------------- 1 | import { joinURL } from 'ufo' 2 | import type { ProviderGetImage } from 'src' 3 | import { createMapper, createOperationsGenerator } from '~image' 4 | 5 | const fits = createMapper({ 6 | fill: 'resize', 7 | inside: 'contain-max', 8 | outside: 'contain-min', 9 | cover: 'cover', 10 | contain: 'contain', 11 | missingValue: 'cover' 12 | }) 13 | 14 | const operationsGenerator = createOperationsGenerator({ 15 | keyMap: { 16 | format: 'output', 17 | quality: 'quality', 18 | background: 'background', 19 | focus: 'focus', 20 | zoom: 'zoom' 21 | }, 22 | valueMap: { 23 | format (value: string) { 24 | if (value === 'jpg') { 25 | return 'jpeg' 26 | } 27 | return value 28 | }, 29 | background (value: string) { 30 | if (value.startsWith('#')) { 31 | return value.replace('#', '') 32 | } 33 | return value 34 | }, 35 | focus: { 36 | auto: 'auto', 37 | faces: 'faces', 38 | north: '50px0p', 39 | northEast: '100px0p', 40 | northWest: '0px0p', 41 | west: '0px50p', 42 | southWest: '100px100p', 43 | south: '50px100p', 44 | southEast: '0px100p', 45 | east: '100px50p', 46 | center: '50px50p' 47 | } 48 | }, 49 | joinWith: '/', 50 | formatter: (key, value) => `${key}=${value}` 51 | }) 52 | 53 | export const getImage: ProviderGetImage = (src, { modifiers = {}, baseURL = '/' } = {}) => { 54 | const { width, height, fit, ...providerModifiers } = modifiers 55 | 56 | if (width || height) { 57 | providerModifiers[fits(fit)] = `${width || '-'}x${height || '-'}` 58 | } 59 | const operations = operationsGenerator(providerModifiers) 60 | return { 61 | url: joinURL(baseURL, src + (operations ? ('?twic=v1/' + operations) : '')) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /docs/content/en/4.providers/unsplash.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Unsplash Provider 3 | description: 'Nuxt Image has first class integration with Unsplash' 4 | navigation: 5 | title: Unsplash 6 | --- 7 | 8 | Integration between [Unsplash](https://unsplash.com/documentation#dynamically-resizable-images) and the image module. See [Unsplash License](Unsplash photos are made to be used freely.) for what usage is permitted. 9 | 10 | ## Dynamically resizable images 11 | 12 | Every image returned by the Unsplash API is a dynamic image URL, which means that it can be manipulated to create new transformations of the image by simply adjusting the query parameters of the image URL. 13 | 14 | This enables resizing, cropping, compression, and changing the format of the image in realtime client-side, without any API calls. 15 | 16 | Under the hood, Unsplash uses [Imgix](/providers/imgix), a powerful image manipulation service to provide dynamic image URLs. 17 | 18 | ## Supported parameters 19 | 20 | Unsplash officially support the parameters: 21 | 22 | `w, h`: for adjusting the width and height of a photo 23 | `crop`: for applying cropping to the photo 24 | `fm`: for converting image format 25 | `auto=format`: for automatically choosing the optimal image format depending on user browser 26 | `q`: for changing the compression quality when using lossy file formats 27 | `fit`: for changing the fit of the image within the specified dimensions 28 | `dpr`: for adjusting the device pixel ratio of the image 29 | The other parameters offered by Imgix can be used, but we don’t officially support them and may remove support for them at any time in the future. 30 | 31 | >💫 Tip 32 | >The API returns image URLs containing an ixid parameter. All resizing and manipulations of image URLs must keep this parameter as it allows for your application to report photo views and be compliant with the API Guidelines. 33 | -------------------------------------------------------------------------------- /playground/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import type { NuxtConfig } from '@nuxt/types' 2 | import type { } from '../src/types' 3 | 4 | export default { 5 | components: true, 6 | target: 'static', 7 | head: { 8 | meta: [ 9 | { name: 'viewport', content: 'width=device-width, initial-scale=1' } 10 | ] 11 | }, 12 | buildModules: [ 13 | '../src/module.ts', 14 | '@nuxt/typescript-build' 15 | ], 16 | image: { 17 | domains: [ 18 | 'https://nuxtjs.org', 19 | 'https://images.unsplash.com', 20 | 'https://upload.wikimedia.org' 21 | ], 22 | screens: { 23 | 750: 750 24 | }, 25 | alias: { 26 | unsplash: 'https://images.unsplash.com', // ipx 27 | blog: '/remote/nuxt-org/blog' // cloudinary 28 | }, 29 | twicpics: { 30 | baseURL: 'https://demo.twic.pics/' 31 | }, 32 | storyblok: { 33 | baseURL: 'https://img2.storyblok.com/' 34 | }, 35 | cloudinary: { 36 | baseURL: 'https://res.cloudinary.com/nuxt/image/upload/' 37 | }, 38 | fastly: { 39 | baseURL: 'https://www.fastly.io' 40 | }, 41 | glide: { 42 | baseURL: 'https://glide.herokuapp.com/1.0/' 43 | }, 44 | imgix: { 45 | baseURL: 'https://assets.imgix.net' 46 | }, 47 | imagekit: { 48 | baseURL: 'https://ik.imagekit.io/demo' 49 | }, 50 | netlify: { 51 | baseURL: 'https://netlify-photo-gallery.netlify.app' 52 | }, 53 | prismic: {}, 54 | sanity: { 55 | projectId: 'zp7mbokg' 56 | }, 57 | unsplash: {}, 58 | vercel: { 59 | baseURL: 'https://image-component.nextjs.gallery/_next/image' 60 | }, 61 | providers: { 62 | custom: { 63 | provider: '~/providers/custom', 64 | options: { 65 | baseURL: 'https://site.my' 66 | } 67 | } 68 | }, 69 | presets: { 70 | s50: { 71 | modifiers: { 72 | width: 50, 73 | height: 50 74 | } 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /docs/content/en/1.getting-started/2.providers.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Providers 3 | description: Nuxt Image supports multiple providers for high performances. 4 | --- 5 | 6 | Providers are integrations between Nuxt Image and third-party image transformation services. Each provider is responsible for generating correct URLs for that image transformation service. 7 | 8 | Nuxt Image can be configured to work with any external image transformation service. Checkout sidebar for list of preconfigured providers. 9 | 10 | If you are looking for a specific provider that is not already supported, you can [create your own provider](/advanced/custom-provider). 11 | 12 | Nuxt Image will automatically optimize `` or `` sources and accepts all [options](/api/options/) for specified target, except for modifiers that are specific to other providers. 13 | 14 | ## Default Provider 15 | 16 | The default provider for Nuxt Image is [ipx](/providers/ipx) or [static](/getting-started/static) (for `target: static`). Either option can be used without any configuration. 17 | 18 | [Learn more about Nuxt deployment targets](https://nuxtjs.org/docs/2.x/features/deployment-targets) 19 | 20 | ### Local Images 21 | 22 | Images should be stored in the `static/` directory of your project. 23 | 24 | For example, when using ``, it should be placed in `static/` folder under the path `static/nuxt-icon.png`. 25 | 26 | Image stored in the `assets/` directory are **not** proccessed with Nuxt Image because those images are managed by webpack. 27 | 28 | For more information, you can learn more about the [static directory here](https://nuxtjs.org/docs/2.x/directory-structure/static). 29 | 30 | ### Remote Images 31 | 32 | Using default provider, you can also optimize external URLs. For this, you need to add them to [`domains`](/api/options#domains) option. 33 | 34 | ### Environment Detection 35 | 36 | You can set default provider using `NUXT_IMAGE_PROVIDER` environment variable. Providers below, are automatically detected: 37 | 38 | - [Vercel](/providers/vercel) 39 | -------------------------------------------------------------------------------- /docs/static/nuxt_image_hero.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/unit/plugin.test.ts: -------------------------------------------------------------------------------- 1 | import { setupTest, getContext } from '@nuxt/test-utils' 2 | 3 | import type { $Img } from '~/index' 4 | 5 | describe('Plugin', () => { 6 | let testContext, plugin 7 | const nuxtContext = { 8 | nuxtState: { 9 | data: [{}] 10 | }, 11 | $img: null as null | $Img 12 | } 13 | 14 | setupTest({ 15 | server: true, 16 | config: { 17 | image: { 18 | presets: { 19 | circle: { 20 | modifiers: { 21 | r: '100' 22 | } 23 | } 24 | }, 25 | providers: { 26 | random: { 27 | name: 'random', 28 | provider: '~/providers/random' 29 | } 30 | }, 31 | cloudinary: { 32 | baseURL: 'https://res.cloudinary.com/nuxt/image/upload' 33 | } 34 | } 35 | } 36 | }) 37 | 38 | test('Setup local test context', async () => { 39 | testContext = getContext() 40 | plugin = (await import(testContext.nuxt!.options.buildDir + '/image.js')).default 41 | // @ts-ignore 42 | plugin(nuxtContext, (_, data) => { nuxtContext.$img = data }) 43 | }) 44 | 45 | test.skip('Generate placeholder', async () => { 46 | // TODO: see https://github.com/nuxt/image/issues/189) 47 | // const placeholder = nuxtContext.$img?.getPlaceholder('/test.png') 48 | // expect(placeholder).toEqual('/_image/local/_/w_30/test.png') 49 | }) 50 | 51 | test('Generate Random Image', () => { 52 | const { url } = nuxtContext.$img?.getImage('/test.png', { provider: 'random' })! 53 | expect(url).toEqual('https://source.unsplash.com/random/600x400') 54 | }) 55 | 56 | test('Generate Circle Image with Cloudinary', () => { 57 | const { url } = nuxtContext.$img?.getImage('/test.png', { provider: 'cloudinary', preset: 'circle' })! 58 | expect(url).toEqual('https://res.cloudinary.com/nuxt/image/upload/f_auto,q_auto,r_100/test') 59 | }) 60 | 61 | test('Deny undefined provider', () => { 62 | expect(() => nuxtContext.$img?.getImage('/test.png', { provider: 'invalid' })).toThrow(Error) 63 | }) 64 | 65 | test('Deny undefined preset', () => { 66 | expect(() => nuxtContext.$img?.getImage('/test.png', { preset: 'invalid' })).toThrow(Error) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /docs/components/IconSSG.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /test/unit/picture.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { Wrapper } from '@vue/test-utils' 6 | 7 | import type Vue from 'vue' 8 | import { getSrc, mountWithImg } from './utils/mount' 9 | import { nextTick } from './utils/tick' 10 | 11 | import NuxtPicture from '~/runtime/components/nuxt-picture.vue' 12 | 13 | describe('Renders simple image', () => { 14 | let wrapper: Wrapper 15 | const src = '/image.png' 16 | 17 | const observer = { 18 | wasAdded: false, 19 | wasDestroyed: false 20 | } 21 | 22 | beforeEach(() => { 23 | window.IntersectionObserver = class IntersectionObserver { 24 | root: any 25 | rootMargin: any 26 | thresholds: any 27 | takeRecords: any 28 | 29 | observe (_target: Element) { 30 | observer.wasAdded = true 31 | } 32 | 33 | disconnect () { 34 | observer.wasDestroyed = true 35 | } 36 | 37 | unobserve () { 38 | observer.wasDestroyed = true 39 | } 40 | } 41 | wrapper = mountWithImg(NuxtPicture, { 42 | loading: 'lazy', 43 | width: 200, 44 | height: 200, 45 | sizes: '200,500:500,900:900', 46 | src 47 | }) 48 | }) 49 | 50 | test('Matches snapshot', () => { 51 | expect(wrapper.html()).toMatchSnapshot() 52 | }) 53 | 54 | test.todo('alt attribute is generated') 55 | 56 | test('props.src is picked up by getImage()', () => { 57 | ;[['source', 'srcset', '/image.webp'], ['img', 'src']].forEach(([element, attribute, customSrc]) => { 58 | const domSrc = wrapper.find(element).element.getAttribute(attribute) 59 | expect(domSrc).toContain(getSrc(customSrc || src)) 60 | }) 61 | }) 62 | 63 | test('renders webp image source', () => { 64 | expect(wrapper.find('[type="image/webp"]').exists()).toBe(true) 65 | }) 66 | 67 | test('props.src is reactive', async () => { 68 | const newSource = '/image.jpeg' 69 | wrapper.setProps({ src: newSource }) 70 | 71 | await nextTick() 72 | 73 | ;[['source', 'srcset', '/image.webp'], ['img', 'src']].forEach(([element, attribute, src]) => { 74 | const domSrc = wrapper.find(element).element.getAttribute(attribute) 75 | expect(domSrc).toContain(getSrc(src || newSource)) 76 | }) 77 | }) 78 | 79 | test('sizes', () => { 80 | const sizes = wrapper.find('source').element.getAttribute('sizes') 81 | expect(sizes).toBe('(max-width: 500px) 500px, 900px') 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /docs/content/en/5.advanced/1.custom-provider.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Custom Providers 3 | description: If CDN provider is not supported, you can define it yourself. 4 | --- 5 | 6 | ## Provider Entry 7 | 8 | The runtime will receive a source, image modifiers and its provider options. It is responsible for generating a url for optimized images, and needs to be isomorphic because it may be called on either server or client. 9 | 10 | ```js 11 | import { joinURL } from 'ufo' 12 | // import {} from '~image' 13 | 14 | export function getImage(src, { modifiers, baseURL } = {}, { options, nuxtContext, $img }) { 15 | const { width, height, format, fit, ...providerModifiers } = modifiers 16 | const operations = [] 17 | // process modifiers 18 | const operationsString = operations.join(',') 19 | return { 20 | url: joinURL(baseURL, operationsString, src) 21 | } 22 | } 23 | ``` 24 | 25 | ### Parameters 26 | 27 | - `src`: Source path of the image. 28 | - `modifiers`: List of image modifiers that are defined in the image component or as a preset. 29 | - `ctx`: (`ImageCTX`) Image module runtime context 30 | - `options`: (`CreateImageOptions`) Image module global runtime options 31 | - `nuxtContext`: [Nuxt runtime context](https://nuxtjs.org/docs/2.x/internals-glossary/context/) 32 | - `$img`: The [$img helper](/api/$img) 33 | 34 | **Note:** Values in `ctx` might change. Use it with caution. 35 | 36 | ### Return 37 | 38 | - `url`: Absolute or relative url of optimized image. 39 | - `isStatic`: A boolean value that determines whether the image should generate on static generation or not. If it is `true` during `nuxt generate` this image will be downloaded and saved in `generate.outDir` to be served as a static image. 40 | 41 | ## Use your provider 42 | 43 | ### Register provider 44 | 45 | After you create your own provider, you should register it in the `nuxt.config`. In order to do that create a property inside `image.provider`. 46 | 47 | ```js 48 | export default { 49 | ... 50 | image: { 51 | providers: { 52 | customProvider: { 53 | name: 'customProvider', // optional value to overrider provider name 54 | provider: '~/providers/custom', // Path to custom provider 55 | options: { 56 | // ... provider options 57 | } 58 | } 59 | } 60 | } 61 | } 62 | ``` 63 | 64 | There are plenty of useful utilities that can be used to write providers by importing from `~img`. See [src/runtime/providers](https://github.com/nuxt/image/tree/dev/src/runtime/providers) for more info. 65 | -------------------------------------------------------------------------------- /src/runtime/utils/meta.ts: -------------------------------------------------------------------------------- 1 | import type { ImageInfo, ImageCTX } from '../../types/image' 2 | 3 | export async function imageMeta (ctx: ImageCTX, url: string): Promise { 4 | const cache = getCache(ctx) 5 | 6 | const cacheKey = 'image:meta:' + url 7 | if (cache.has(cacheKey)) { 8 | return cache.get(cacheKey) 9 | } 10 | 11 | const meta = await _imageMeta(url).catch((err) => { 12 | // eslint-disable-next-line no-console 13 | console.error('Failed to get image meta for ' + url, err + '') 14 | return { 15 | width: 0, 16 | height: 0, 17 | ratio: 0 18 | } 19 | }) 20 | 21 | cache.set(cacheKey, meta) 22 | return meta 23 | } 24 | 25 | async function _imageMeta (url: string): Promise { 26 | if (process.server) { 27 | const imageMeta = await import('image-meta').then(r => r.default || r) 28 | const data: Buffer = await fetch(url).then((res: any) => res.buffer()) 29 | const metadata = imageMeta(data) 30 | if (!metadata) { 31 | throw new Error(`No metadata could be extracted from the image \`${url}\`.`) 32 | } 33 | const { width, height } = metadata 34 | const meta = { 35 | width: width!, 36 | height: height!, 37 | ratio: width && height ? width / height : undefined 38 | } 39 | 40 | return meta 41 | } 42 | if (typeof Image === 'undefined') { 43 | throw new TypeError('Image not supported') 44 | } 45 | 46 | return new Promise((resolve, reject) => { 47 | const img = new Image() 48 | img.onload = () => { 49 | const meta = { 50 | width: img.width, 51 | height: img.height, 52 | ratio: img.width / img.height 53 | } 54 | resolve(meta) 55 | } 56 | img.onerror = err => reject(err) 57 | img.src = url 58 | }) 59 | } 60 | 61 | interface Cache { 62 | get: (id: string) => T, 63 | set: (id: string, value: T) => void, 64 | has: (id: string) => boolean 65 | } 66 | 67 | function getCache (ctx: ImageCTX): Cache { 68 | if (!ctx.nuxtContext.cache) { 69 | if (ctx.nuxtContext.ssrContext && ctx.nuxtContext.ssrContext.cache) { 70 | ctx.nuxtContext.cache = ctx.nuxtContext.ssrContext.cache 71 | } else { 72 | const _cache: Record = {} 73 | ctx.nuxtContext.cache = { 74 | get: (id: string) => _cache[id], 75 | set: (id: string, value: any) => { _cache[id] = value }, 76 | has: (id: string) => typeof _cache[id] !== 'undefined' 77 | } 78 | } 79 | } 80 | return ctx.nuxtContext.cache 81 | } 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nuxt/image", 3 | "version": "0.5.0", 4 | "description": "Nuxt Image Module", 5 | "repository": "nuxt/image", 6 | "license": "MIT", 7 | "sideEffects": false, 8 | "main": "dist/module.js", 9 | "types": "dist/types.d.ts", 10 | "files": [ 11 | "dist", 12 | "vetur" 13 | ], 14 | "scripts": { 15 | "build": "siroc build && mkdist --src src/runtime --dist dist/runtime -d --ext js", 16 | "dev": "yarn nuxt playground", 17 | "docs:build": "cd docs && nuxt generate", 18 | "docs:dev": "yarn nuxt dev docs", 19 | "lint": "eslint --ext .ts --ext .vue .", 20 | "prepublishOnly": "yarn build", 21 | "release": "standard-version && git push --follow-tags && npm publish", 22 | "test": "yarn lint && yarn jest --forceExit", 23 | "test:e2e": "jest test/e2e --forceExit", 24 | "test:unit": "jest test/unit --forceExit" 25 | }, 26 | "dependencies": { 27 | "consola": "^2.15.3", 28 | "defu": "^5.0.0", 29 | "fs-extra": "^10.0.0", 30 | "hasha": "^5.2.2", 31 | "image-meta": "^0.0.1", 32 | "ipx": "^0.7.1", 33 | "is-https": "^4.0.0", 34 | "lru-cache": "^6.0.0", 35 | "node-fetch": "^2.6.1", 36 | "p-limit": "^3.1.0", 37 | "rc9": "^1.2.0", 38 | "requrl": "^3.0.2", 39 | "semver": "^7.3.5", 40 | "ufo": "^0.7.7", 41 | "upath": "^2.0.1" 42 | }, 43 | "devDependencies": { 44 | "@babel/preset-env": "latest", 45 | "@babel/preset-typescript": "latest", 46 | "@cyrilf/vue-dat-gui": "latest", 47 | "@nuxt/test-utils": "latest", 48 | "@nuxt/types": "latest", 49 | "@nuxt/typescript-build": "latest", 50 | "@nuxt/typescript-runtime": "latest", 51 | "@nuxtjs/eslint-config-typescript": "latest", 52 | "@types/fs-extra": "latest", 53 | "@types/jest": "latest", 54 | "@types/lru-cache": "latest", 55 | "@types/node-fetch": "latest", 56 | "@types/semver": "^7.3.7", 57 | "@vue/test-utils": "latest", 58 | "babel-core": "^7.0.0-bridge.0", 59 | "babel-eslint": "latest", 60 | "eslint": "latest", 61 | "jest": "latest", 62 | "jsdom": "latest", 63 | "jsdom-global": "latest", 64 | "mkdist": "latest", 65 | "nuxt": "^2.15.7", 66 | "playwright": "latest", 67 | "siroc": "latest", 68 | "standard-version": "latest", 69 | "ts-loader": "^8", 70 | "typescript": "latest", 71 | "vue-jest": "latest" 72 | }, 73 | "publishConfig": { 74 | "access": "public" 75 | }, 76 | "vetur": { 77 | "tags": "vetur/tags.json", 78 | "attributes": "vetur/attributes.json" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/ipx.ts: -------------------------------------------------------------------------------- 1 | import { relative, resolve } from 'upath' 2 | import { update as updaterc } from 'rc9' 3 | import { mkdirp, readFile, writeFile } from 'fs-extra' 4 | import { lt } from 'semver' 5 | 6 | import type { ProviderSetup, ImageProviders } from './types' 7 | 8 | export const ipxSetup: ProviderSetup = async (_providerOptions, moduleOptions, nuxt) => { 9 | const isStatic = nuxt.options.target === 'static' 10 | const runtimeDir = resolve(__dirname, 'runtime') 11 | const ipxOptions: ImageProviders['ipx'] = { 12 | dir: resolve(nuxt.options.rootDir, moduleOptions.dir), 13 | domains: moduleOptions.domains, 14 | sharp: moduleOptions.sharp, 15 | alias: moduleOptions.alias 16 | } 17 | 18 | // Add IPX middleware unless nuxtrc or user added a custom middleware 19 | const hasUserProvidedIPX = !!nuxt.options.serverMiddleware 20 | .find((mw: { path: string }) => mw.path && mw.path.startsWith('/_ipx')) 21 | 22 | if (!hasUserProvidedIPX) { 23 | const { createIPX, createIPXMiddleware } = await import('ipx') 24 | const ipx = createIPX(ipxOptions) 25 | nuxt.options.serverMiddleware.push({ 26 | path: '/_ipx', 27 | handle: createIPXMiddleware(ipx) 28 | }) 29 | } 30 | 31 | // Warn if unhandled /_ipx endpoint only if not using `modules` 32 | const installedInModules = nuxt.options.modules.some( 33 | (mod: string | (() => any)) => typeof mod === 'string' && mod.includes('@nuxt/image') 34 | ) 35 | 36 | if (!isStatic && !hasUserProvidedIPX && !installedInModules && lt(nuxt.constructor.version, '2.16.0')) { 37 | // eslint-disable-next-line no-console 38 | console.warn('[@nuxt/image] If you would like to use the `ipx` provider at runtime.\nMake sure to follow the instructions at https://image.nuxtjs.org/providers/ipx .') 39 | } 40 | 41 | if (nuxt.options.dev || hasUserProvidedIPX) { 42 | return 43 | } 44 | 45 | // In production, add IPX module to nuxtrc (used in Nuxt 2.16+) 46 | nuxt.hook('build:done', async () => { 47 | const handler = await readFile(resolve(runtimeDir, 'ipx.js'), 'utf-8') 48 | const distDir = resolve(nuxt.options.buildDir, 'dist') 49 | const apiDir = resolve(distDir, 'api') 50 | const apiFile = resolve(apiDir, 'ipx.js') 51 | const relativeApiFile = '~~/' + relative(nuxt.options.rootDir, apiFile) 52 | 53 | await mkdirp(apiDir) 54 | await writeFile(apiFile, handler.replace(/.__IPX_OPTIONS__./, JSON.stringify(ipxOptions))) 55 | 56 | updaterc({ serverMiddleware: [{ path: '/_ipx', handler: relativeApiFile }] }, { dir: distDir, name: 'nuxtrc' }) 57 | }) 58 | } 59 | -------------------------------------------------------------------------------- /docs/content/en/3.api/2.$img.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: $img 3 | category: API 4 | description: 'Using $img utility to generate provider specific URLs' 5 | --- 6 | 7 | Sometimes it's required to directly use a generated image URL with applied transformations instead of `` and `` components. 8 | 9 | ## Usage 10 | 11 | ```js 12 | $img(src, modifiers, options) 13 | ``` 14 | 15 | **Example:** Generate image URL for `backgroundImage` style. 16 | 17 | ```js 18 | export default { 19 | computed: { 20 | backgroundStyles() { 21 | const imgUrl = this.$img('https://github.com/nuxt.png', { width: 100 }) 22 | return { 23 | backgroundImage: `url('${imgUrl}')` 24 | } 25 | } 26 | } 27 | } 28 | ``` 29 | 30 | ### `$img.getSizes` 31 | 32 | ```js 33 | $img.getSizes(src, { sizes, modifiers }); 34 | ``` 35 | 36 | :::alert{type="warning"} 37 | Unstable: `getSizes` API might change or be removed. 38 | ::: 39 | 40 | **Parameters:** 41 | 42 | - `src`: (string) Source to original image id 43 | - `sizes`: (string) List of responsive image sizes ({breakpoint}:{size}{unit}) 44 | - `modifiers`: (object) Modifiers passed to provider for resizing and optimizing 45 | - `width`: resize to the specified width (in pixels) 46 | - `height`: resize to specified height (in pixels) 47 | - `quality`: Change image quality (0 to 100) 48 | - `format`: Change the image format 49 | - (any other custom provider modifier) 50 | - `options`: (object) 51 | - `provider`: (string) Provider name other than default (see [providers](https://image.nuxtjs.org/api/options#providers)) 52 | - `preset`: Use a [preset](/api/options#presets) 53 | 54 | **Example:** Responsive srcset with Vuetify `v-img` 55 | 56 | ```html 57 | 66 | 89 | ``` 90 | -------------------------------------------------------------------------------- /docs/content/en/4.providers/sanity.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Sanity Provider 3 | description: 'Nuxt Image has first class integration with Sanity' 4 | navigation: 5 | title: Sanity 6 | --- 7 | 8 | Integration between [Sanity](https://www.sanity.io/docs/image-urls) and Nuxt Image. 9 | 10 | To use this provider you just need to specify the `projectId` of your project in Sanity. 11 | 12 | ```js{}[nuxt.config.js] 13 | export default { 14 | image: { 15 | sanity: { 16 | projectId: 'yourprojectid', 17 | // Defaults to 'production' 18 | // dataset: 'development' 19 | } 20 | } 21 | } 22 | ``` 23 | 24 | ## Modifiers 25 | 26 | The Sanity provider supports a number of additional modifiers. For a full list, check out [the Sanity documentation](https://www.sanity.io/docs/image-urls). All of the modifiers mentioned in the Sanity docs are supported, with the following notes. 27 | 28 | ### Extra convenience modifiers 29 | 30 | The following more readable modifiers are also supported: 31 | 32 | - `background` - equivalent to `bg` 33 | - `download` - equivalent to `dl` 34 | - `sharpen` - equivalent to `sharp` 35 | - `orientation` - equivalent to `or` 36 | - `minHeight` or `min-height` - equivalent to `min-h` 37 | - `maxHeight` or `max-height` - equivalent to `max-h` 38 | - `minWidth` or `min-width` - equivalent to `min-w` 39 | - `maxWidth` or `max-width` - equivalent to `max-w` 40 | - `saturation` - equivalent to `sat` 41 | 42 | ### `fit` 43 | 44 | In addition to the values specified in the Sanity docs, which are respected, the following options from the [default fit behavior](/components/nuxt-img#fit) are supported: 45 | 46 | - `cover` - this will behave like the Sanity modifier `crop` 47 | - `contain` - this will behave like the Sanity modifier `fill`, and defaults to filling with a white background. (You can specify your own background color with the `background` modifier.) 48 | - `inside` - this will behave like the Sanity modifier `min` 49 | - `outside` - this will behave like the Sanity modifier `max` 50 | - `fill` - this will behave like the Sanity modifier `scale` 51 | 52 | :::alert{type="warning"} 53 | For compatibility with other providers, `fit: fill` is equivalent to the Sanity parameter `?fit=scale`. If you need the Sanity `?fit=fill` behavior, use `fit: contain` instead. 54 | ::: 55 | 56 | ### `format` 57 | 58 | You can specify any of the formats suppored by Sanity. If this is omitted, the Sanity provider will default to `auto=format`. 59 | 60 | ### `crop` and `hotspot` 61 | 62 | You can pass your Sanity crop and hotspot image data as modifiers and Nuxt Image will correctly generate the `rect`, `fp-x` and `fp-y` parameters for you. -------------------------------------------------------------------------------- /src/generate.ts: -------------------------------------------------------------------------------- 1 | 2 | import { createWriteStream } from 'fs' 3 | import { promisify } from 'util' 4 | import stream from 'stream' 5 | import { mkdirp } from 'fs-extra' 6 | import { dirname, join, relative, basename, trimExt } from 'upath' 7 | import fetch from 'node-fetch' 8 | import { joinURL, hasProtocol, parseURL, withoutTrailingSlash } from 'ufo' 9 | import pLimit from 'p-limit' 10 | import { ModuleOptions, MapToStatic, ResolvedImage } from './types' 11 | import { hash, logger, guessExt } from './utils' 12 | 13 | const pipeline = promisify(stream.pipeline) 14 | 15 | export function setupStaticGeneration (nuxt: any, options: ModuleOptions) { 16 | const staticImages: Record = {} // url ~> hashed file name 17 | 18 | nuxt.hook('vue-renderer:ssr:prepareContext', (renderContext: any) => { 19 | renderContext.image = renderContext.image || {} 20 | renderContext.image.mapToStatic = function ({ url, format }: ResolvedImage, input: string) { 21 | if (!staticImages[url]) { 22 | const { pathname } = parseURL(input) 23 | const params: any = { 24 | name: trimExt(basename(pathname)), 25 | ext: (format && `.${format}`) || guessExt(input), 26 | hash: hash(url), 27 | // TODO: pass from runtimeConfig to mapStatic as param 28 | publicPath: nuxt.options.app.cdnURL ? '/' : withoutTrailingSlash(nuxt.options.build.publicPath) 29 | } 30 | 31 | staticImages[url] = options.staticFilename.replace(/\[(\w+)]/g, (match, key) => params[key] || match) 32 | } 33 | return joinURL(nuxt.options.app.cdnURL || nuxt.options.app.basePath, staticImages[url]) 34 | } 35 | }) 36 | 37 | nuxt.hook('generate:done', async () => { 38 | const limit = pLimit(8) 39 | const downloads = Object.entries(staticImages).map(([url, name]) => { 40 | if (!hasProtocol(url)) { 41 | url = joinURL(options.internalUrl, url) 42 | } 43 | return limit(() => downloadImage({ 44 | url, 45 | name, 46 | outDir: nuxt.options.generate.dir 47 | })) 48 | }) 49 | await Promise.all(downloads) 50 | }) 51 | } 52 | 53 | async function downloadImage ({ url, name, outDir }: { url: string, name: string, outDir: string }) { 54 | try { 55 | const response = await fetch(url) 56 | if (!response.ok) { throw new Error(`Unexpected response ${response.statusText}`) } 57 | const dstFile = join(outDir, name) 58 | await mkdirp(dirname(dstFile)) 59 | await pipeline(response.body, createWriteStream(dstFile)) 60 | logger.success('Generated static image ' + relative(process.cwd(), dstFile)) 61 | } catch (error) { 62 | logger.error(error.message) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /docs/content/en/4.providers/ipx.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: IPX Provider 3 | description: 'Self hosted image provider' 4 | navigation: 5 | title: IPX 6 | --- 7 | 8 | Nuxt Image comes with a [preconfigured instance](/getting-started/providers#default-provider) of [ipx](https://github.com/unjs/ipx). An open source, self-hosted image optimizer based on [sharp](https://github.com/lovell/sharp). 9 | 10 | ## Using `ipx` in production 11 | 12 | Use IPX for self-hosting as an alternative to use service providers for production. 13 | 14 | :::alert{type="info"} 15 | You don't need to follow this section if using `target: 'static'`. 16 | ::: 17 | 18 | ### Runtime Module 19 | 20 | Just add `@nuxt/image` to `modules` (instead of `buildModules`) in `nuxt.config`. This will ensure that the `/_ipx` endpoint continues to work in production. 21 | 22 | ### Advanced: Custom ServerMiddleware 23 | 24 | If you have an usecase of a custom IPX instance serving other that `static/` dir, you may instead create a server Middleware that handles the `/_ipx` endpoint: 25 | 26 | 1. Add `ipx` as a dependency: 27 | 28 | :::::code-group 29 | ::::code-block{label="yarn" active} 30 | 31 | ```bash 32 | yarn add ipx 33 | ``` 34 | 35 | :::: 36 | ::::code-block{label="npm"} 37 | 38 | ```bash 39 | npm install ipx 40 | ``` 41 | 42 | :::: 43 | ::::: 44 | 45 | 2. Create `server/middleware/ipx.js`: 46 | 47 | ```js [server/middleware/ipx.js] 48 | import { createIPX, createIPXMiddleware } from 'ipx' 49 | 50 | // https://github.com/unjs/ipx 51 | const ipx = createIPX({ 52 | dir: '', // absolute path to images dir 53 | domains: [], // allowed external domains (should match domains option in nuxt.config) 54 | alias: {}, // base alias 55 | sharp: {}, // sharp options 56 | }) 57 | 58 | export default createIPXMiddleware(ipx) 59 | ``` 60 | 61 | 3. Add `/_ipx` to `serverMiddleware`: 62 | 63 | 64 | ```js [nuxt.config.js] 65 | 66 | export default { 67 | serverMiddleware: { 68 | '/_ipx': '~/server/middleware/ipx.js' 69 | } 70 | } 71 | ``` 72 | 73 | ## Additional Modifiers 74 | 75 | You can use [additional modifiers](https://github.com/unjs/ipx/#modifiers) supported by IPX. 76 | 77 | **Example:** 78 | 79 | ```html 80 | 81 | ``` 82 | 83 | ### Animated Images 84 | 85 | :::alert{type="info"} 86 | This feature is currently experimental. When using, `gif` format is converted to `webp` 87 | ([check browser support](https://caniuse.com/webp)). Setting size is also not supported yet (check [lovell/sharp#2275](https://github.com/lovell/sharp/issues/2275) and [unjs/ipx#35](https://github.com/unjs/ipx/issues/35)). 88 | ::: 89 | 90 | **Example:** 91 | 92 | ```html 93 | 94 | ``` 95 | -------------------------------------------------------------------------------- /src/runtime/components/nuxt-picture.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 99 | -------------------------------------------------------------------------------- /src/runtime/utils/index.ts: -------------------------------------------------------------------------------- 1 | import type { OperationGeneratorConfig } from '../../types/image' 2 | 3 | export default function imageFetch (url: string) { 4 | return fetch(cleanDoubleSlashes(url)) 5 | } 6 | 7 | export function getInt (x: unknown): number | undefined { 8 | if (typeof x === 'number') { 9 | return x 10 | } 11 | if (typeof x === 'string') { 12 | return parseInt(x, 10) 13 | } 14 | return undefined 15 | } 16 | 17 | export function getFileExtension (url: string = '') { 18 | const extension = url.split(/[?#]/).shift()!.split('/').pop()!.split('.').pop()! 19 | return extension 20 | } 21 | 22 | export function cleanDoubleSlashes (path: string = '') { 23 | return path.replace(/(https?:\/\/)|(\/)+/g, '$1$2') 24 | } 25 | 26 | export function createMapper (map: any) { 27 | return (key?: string) => { 28 | return key ? map[key] || key : map.missingValue 29 | } 30 | } 31 | 32 | export function createOperationsGenerator ({ formatter, keyMap, joinWith = '/', valueMap }: OperationGeneratorConfig = {}) { 33 | if (!formatter) { 34 | formatter = (key, value) => `${key}=${value}` 35 | } 36 | if (keyMap && typeof keyMap !== 'function') { 37 | keyMap = createMapper(keyMap) 38 | } 39 | const map = valueMap || {} 40 | Object.keys(map).forEach((valueKey) => { 41 | if (typeof map[valueKey] !== 'function') { 42 | map[valueKey] = createMapper(map[valueKey]) 43 | } 44 | }) 45 | 46 | return (modifiers: { [key: string]: string } = {}) => { 47 | const operations = Object.entries(modifiers) 48 | .filter(([_, value]) => typeof value !== 'undefined') 49 | .map(([key, value]) => { 50 | const mapper = map[key] 51 | if (typeof mapper === 'function') { 52 | value = mapper(modifiers[key]) 53 | } 54 | 55 | key = typeof keyMap === 'function' ? keyMap(key) : key 56 | 57 | return formatter!(key, value) 58 | }) 59 | 60 | return operations.join(joinWith) 61 | } 62 | } 63 | 64 | type Attrs = { [key: string]: string|number } 65 | 66 | export function renderAttributesToString (attributes: Attrs = {}) { 67 | return Object.entries(attributes) 68 | .map(([key, value]) => value ? `${key}="${value}"` : '') 69 | .filter(Boolean).join(' ') 70 | } 71 | 72 | export function renderTag (tag: string, attrs: Attrs, contents?: string) { 73 | const html = `<${tag} ${renderAttributesToString(attrs)}>` 74 | if (!contents) { 75 | return html 76 | } 77 | return html + contents + `` 78 | } 79 | 80 | export function generateAlt (src: string = '') { 81 | return src.split(/[?#]/).shift()!.split('/').pop()!.split('.').shift() 82 | } 83 | 84 | export function parseSize (input: string | number | undefined = '') { 85 | if (typeof input === 'number') { 86 | return input 87 | } 88 | if (typeof input === 'string') { 89 | if (input.replace('px', '').match(/^\d+$/g)) { 90 | return parseInt(input, 10) 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/provider.ts: -------------------------------------------------------------------------------- 1 | import { normalize, resolve, dirname } from 'upath' 2 | import { writeJson, mkdirp } from 'fs-extra' 3 | import { hash } from './utils' 4 | import type { ModuleOptions, InputProvider, ImageModuleProvider, ProviderSetup } from './types' 5 | import { ipxSetup } from './ipx' 6 | 7 | const BuiltInProviders = [ 8 | 'cloudinary', 9 | 'fastly', 10 | 'glide', 11 | 'imagekit', 12 | 'imgix', 13 | 'ipx', 14 | 'netlify', 15 | 'prismic', 16 | 'sanity', 17 | 'static', 18 | 'twicpics', 19 | 'storyblok', 20 | 'unsplash', 21 | 'vercel' 22 | ] 23 | 24 | export const providerSetup: Record = { 25 | // IPX 26 | ipx: ipxSetup, 27 | static: ipxSetup, 28 | 29 | // https://vercel.com/docs/more/adding-your-framework#images 30 | async vercel (_providerOptions, moduleOptions, nuxt) { 31 | const imagesConfig = resolve(nuxt.options.rootDir, '.vercel_build_output/config/images.json') 32 | await mkdirp(dirname(imagesConfig)) 33 | await writeJson(imagesConfig, { 34 | domains: moduleOptions.domains, 35 | sizes: Array.from(new Set(Object.values(moduleOptions.screens || {}))) 36 | }) 37 | } 38 | } 39 | 40 | export function resolveProviders (nuxt: any, options: ModuleOptions): ImageModuleProvider[] { 41 | const providers: ImageModuleProvider[] = [] 42 | 43 | for (const key in options) { 44 | if (BuiltInProviders.includes(key)) { 45 | providers.push(resolveProvider(nuxt, key, { provider: key, options: options[key] })) 46 | } 47 | } 48 | 49 | for (const key in options.providers) { 50 | providers.push(resolveProvider(nuxt, key, options.providers[key])) 51 | } 52 | 53 | return providers 54 | } 55 | 56 | export function resolveProvider (nuxt: any, key: string, input: InputProvider): ImageModuleProvider { 57 | if (typeof input === 'string') { 58 | input = { name: input } 59 | } 60 | 61 | if (!input.name) { 62 | input.name = key 63 | } 64 | 65 | if (!input.provider) { 66 | input.provider = input.name 67 | } 68 | 69 | input.provider = BuiltInProviders.includes(input.provider) 70 | ? require.resolve('./runtime/providers/' + input.provider) 71 | : nuxt.resolver.resolvePath(input.provider) 72 | 73 | const setup = input.setup || providerSetup[input.name] 74 | 75 | return { 76 | ...input, 77 | setup, 78 | runtime: normalize(input.provider!), 79 | importName: `${key}Runtime$${hash(input.provider!, 4)}`, 80 | runtimeOptions: input.options 81 | } 82 | } 83 | 84 | export function detectProvider (userInput?: string, isStatic: boolean = false) { 85 | if (process.env.NUXT_IMAGE_PROVIDER) { 86 | return process.env.NUXT_IMAGE_PROVIDER 87 | } 88 | 89 | if (userInput && userInput !== 'auto') { 90 | return userInput 91 | } 92 | 93 | if (process.env.VERCEL || process.env.VERCEL_ENV || process.env.NOW_BUILDER) { 94 | return 'vercel' 95 | } 96 | 97 | return isStatic ? 'static' : 'ipx' 98 | } 99 | -------------------------------------------------------------------------------- /docs/content/en/1.getting-started/1.installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Using image module in your Nuxt project is only one command away. ✨ 3 | --- 4 | 5 | # Installation 6 | 7 | Add `@nuxt/image` devDependency to your project: 8 | 9 | ::code-group 10 | ```bash [yarn] 11 | yarn add --dev @nuxt/image 12 | ``` 13 | 14 | ```bash [npm] 15 | npm install -D @nuxt/image 16 | ``` 17 | :: 18 | 19 | Add the module to `buildModules` in your `nuxt.config`: 20 | 21 | ```ts [nuxt.config.js] 22 | export default { 23 | target: 'static', 24 | buildModules: [ 25 | '@nuxt/image', 26 | ] 27 | } 28 | ``` 29 | 30 | If you use `server` target (default) and are using the default provider, add `@nuxt/image` to `modules` section instead: 31 | 32 | ```ts [nuxt.config.js] 33 | export default { 34 | modules: [ 35 | '@nuxt/image', 36 | ] 37 | } 38 | ``` 39 | 40 | ::alert{type="success"} 41 | You can now start using [``](/components/nuxt-img) and [``](/components/nuxt-picture) components in your Nuxt app ✨ 42 | :: 43 | 44 | ## Configuration 45 | 46 | Add an `image` section in your `nuxt.config.js`: 47 | 48 | ```ts [nuxt.config.js] 49 | export default { 50 | image: { 51 | // Options 52 | } 53 | } 54 | ``` 55 | 56 | See [module options](/api/options) for available options. 57 | 58 | ## TypeScript 59 | 60 | If you're using Typescript, add the types to your "types" array in `tsconfig.json` after the `@nuxt/types` (Nuxt 2.9.0+) or `@nuxt/vue-app` entry. 61 | 62 | ```json [tsconfig.json] 63 | { 64 | "compilerOptions": { 65 | "types": ["@nuxt/types", "@nuxt/image"] 66 | } 67 | } 68 | ``` 69 | 70 | ## Upgrading 71 | 72 | :::alert{type="warning"} 73 | Behavior and API changes might happen in 0.x releases of image module. Update with caution. 74 | ::: 75 | 76 | ## Troubleshooting 77 | 78 | If an error occurs during installation: 79 | 80 | - Ensure using LTS version of NodeJS or latest update of `12.x`, `14.x` or `16.x` ([NodeJS Downloads page](https://nodejs.org/en/download/)) 81 | 82 | - Try to upgrade to latest versions: 83 | 84 | ::code-group 85 | ```bash [yarn] 86 | yarn upgrade @nuxt/image 87 | ``` 88 | 89 | ```bash [npm] 90 | npm up @nuxt/image 91 | ``` 92 | :: 93 | 94 | - Try recreating lock-file: 95 | 96 | ::code-group 97 | ```bash [yarn] 98 | rm yarn.lock && yarn 99 | ``` 100 | 101 | ```bash [npm] 102 | rm package-lock.json && npm i 103 | ``` 104 | :: 105 | 106 | - If there is still an error related to `sharp` and `node-gyp`, it is is probably becase your OS architecture or NodeJS version is not included in pre-built binaries and needs to built from source (for example, this sometimes occurs on Apple M1). Checkout [node-gyp](https://github.com/nodejs/node-gyp#installation) for install requirements. 107 | 108 | - If none of the above worked, please [open an issue](https://github.com/nuxt/image/issues) and include error trace, OS, Node version and the package manager used for installing. 109 | -------------------------------------------------------------------------------- /src/types/image.ts: -------------------------------------------------------------------------------- 1 | export interface ImageModifiers { 2 | width: number 3 | height: number 4 | fit: string 5 | format: string 6 | [key: string]: any 7 | } 8 | 9 | export interface ImageOptions { 10 | provider?: string, 11 | preset?: string, 12 | modifiers?: Partial 13 | [key: string]: any 14 | } 15 | 16 | export interface ImageSizesOptions extends ImageOptions { 17 | sizes: Record | string 18 | } 19 | 20 | // eslint-disable-next-line no-use-before-define 21 | export type ProviderGetImage = (src: string, options: ImageOptions, ctx: ImageCTX) => ResolvedImage 22 | 23 | export interface ImageProvider { 24 | defaults?: any 25 | getImage: ProviderGetImage 26 | validateDomains?: Boolean 27 | supportsAlias?: Boolean 28 | } 29 | 30 | export interface CreateImageOptions { 31 | providers: { 32 | [name: string]: { 33 | defaults: any, 34 | provider: ImageProvider 35 | } 36 | } 37 | presets: { [name: string]: ImageOptions } 38 | provider: string 39 | screens: Record, 40 | alias: Record, 41 | domains: string[] 42 | } 43 | 44 | export interface ImageInfo { 45 | width: number, 46 | height: number, 47 | placeholder?: string, 48 | } 49 | 50 | export interface ResolvedImage { 51 | url: string, 52 | format?: string 53 | isStatic?: boolean 54 | getMeta?: () => Promise 55 | } 56 | 57 | export interface ImageSizes { 58 | srcset: string 59 | sizes: string 60 | src: string 61 | } 62 | 63 | export interface Img { 64 | (source: string, modifiers?: ImageOptions['modifiers'], options?: ImageOptions): ResolvedImage['url'] 65 | options: CreateImageOptions 66 | getImage: (source: string, options?: ImageOptions) => ResolvedImage 67 | getSizes: (source: string, options?: ImageOptions, sizes?: string[]) => ImageSizes 68 | getMeta: (source: string, options?: ImageOptions) => Promise 69 | } 70 | 71 | export type $Img = Img & { 72 | [preset: string]: $Img 73 | } 74 | 75 | export interface ImageCTX { 76 | options: CreateImageOptions, 77 | nuxtContext: { 78 | ssrContext: any 79 | cache?: any 80 | isDev: boolean 81 | isStatic: boolean 82 | nuxtState?: any 83 | } 84 | $img?: $Img 85 | } 86 | 87 | export interface ImageSize { 88 | width: number; 89 | media: string; 90 | breakpoint: number; 91 | format: string; 92 | url: string; 93 | } 94 | 95 | export interface RuntimePlaceholder extends ImageInfo { 96 | url: string; 97 | } 98 | 99 | export type OperationFormatter = (key: string, value: string) => string 100 | 101 | export type OperationMapper = { [key: string]: string | false } | ((key: string) => string) 102 | 103 | export interface OperationGeneratorConfig { 104 | keyMap?: OperationMapper 105 | formatter?: OperationFormatter 106 | joinWith?: string 107 | valueMap?: { 108 | [key: string]: OperationMapper 109 | } 110 | } 111 | 112 | export type MapToStatic = (image: ResolvedImage, input: string) => string 113 | -------------------------------------------------------------------------------- /src/runtime/components/image.mixin.ts: -------------------------------------------------------------------------------- 1 | import { parseSize } from '../utils' 2 | import type { DefineMixin } from '../../types/vue' 3 | 4 | const defineMixin: DefineMixin = (opts: any) => opts 5 | 6 | // @vue/component 7 | export const imageMixin = defineMixin({ 8 | props: { 9 | // input source 10 | src: { type: String, required: true }, 11 | 12 | // modifiers 13 | format: { type: String, default: undefined }, 14 | quality: { type: [Number, String], default: undefined }, 15 | background: { type: String, default: undefined }, 16 | fit: { type: String, default: undefined }, 17 | modifiers: { type: Object as () => Record, default: undefined }, 18 | 19 | // options 20 | preset: { type: String, default: undefined }, 21 | provider: { type: String, default: undefined }, 22 | 23 | sizes: { type: [Object, String] as unknown as () => string | Record, default: undefined }, 24 | 25 | // attributes 26 | width: { type: [String, Number], default: undefined }, 27 | height: { type: [String, Number], default: undefined }, 28 | alt: { type: String, default: undefined }, 29 | referrerpolicy: { type: String, default: undefined }, 30 | usemap: { type: String, default: undefined }, 31 | longdesc: { type: String, default: undefined }, 32 | ismap: { type: Boolean, default: undefined }, 33 | crossorigin: { type: [Boolean, String] as unknown as () => boolean | '' | 'anonymous' | 'use-credentials', default: undefined, validator: val => ['anonymous', 'use-credentials', '', true, false].includes(val) }, 34 | loading: { type: String, default: undefined }, 35 | decoding: { type: String as () => 'async' | 'auto' | 'sync', default: undefined, validator: val => ['async', 'auto', 'sync'].includes(val) } 36 | }, 37 | computed: { 38 | nImgAttrs (): { 39 | width?: number 40 | height?: number 41 | alt?: string 42 | referrerpolicy?: string 43 | usemap?: string 44 | longdesc?: string 45 | ismap?: boolean 46 | crossorigin?: '' | 'anonymous' | 'use-credentials' 47 | loading?: string 48 | decoding?: 'async' | 'auto' | 'sync' 49 | } { 50 | return { 51 | width: parseSize(this.width), 52 | height: parseSize(this.height), 53 | alt: this.alt, 54 | referrerpolicy: this.referrerpolicy, 55 | usemap: this.usemap, 56 | longdesc: this.longdesc, 57 | ismap: this.ismap, 58 | crossorigin: this.crossorigin === true ? 'anonymous' : this.crossorigin || undefined, 59 | loading: this.loading, 60 | decoding: this.decoding 61 | } 62 | }, 63 | nModifiers (): { width?: number, height?: number, format?: string, quality?: string | number, background?: string, fit?: string } & Record { 64 | return { 65 | ...this.modifiers, 66 | width: parseSize(this.width), 67 | height: parseSize(this.height), 68 | format: this.format, 69 | quality: this.quality, 70 | background: this.background, 71 | fit: this.fit 72 | } 73 | }, 74 | nOptions (): { provider?: string, preset?: string } { 75 | return { 76 | provider: this.provider, 77 | preset: this.preset 78 | } 79 | } 80 | } 81 | }) 82 | -------------------------------------------------------------------------------- /docs/content/en/4.providers/imgix.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Imgix Provider 3 | description: 'Nuxt Image has first class integration with Imgix' 4 | navigation: 5 | title: Imgix 6 | --- 7 | 8 | Integration between [Imgix](https://docs.imgix.com/) and the image module. 9 | 10 | To use this provider you just need to specify the base url of your service in Imgix. 11 | 12 | ```js{}[nuxt.config.js] 13 | export default { 14 | image: { 15 | imgix: { 16 | baseURL: 'https://assets.imgix.net' 17 | } 18 | } 19 | } 20 | ``` 21 | 22 | ## imgix `fit` values 23 | 24 | Beside [the standard values for `fit` property](/components/nuxt-img#fit) of Nuxt image and Nuxt picture, imgix offers the following for extra resizing experience: 25 | 26 | * `clamp` - Resizes the image to fit within the width and height dimensions without cropping or distorting the image, and the remaining space is filled with extended pixels from the edge of the image. The resulting image will match the constraining dimensions. The pixel extension is called an affine clamp, hence the value name, "clamp". 27 | 28 | * `clip` - The default fit setting for imgix images. Resizes the image to fit within the width and height boundaries without cropping or distorting the image. The resulting image will match one of the constraining dimensions, while the other dimension is altered to maintain the same aspect ratio of the input image. 29 | 30 | * `facearea` - Finds the area containing all faces, or a specific face in an image, and scales it to specified width and height dimensions. Can be used in conjunction with [faceindex](https://docs.imgix.com/apis/rendering/face-detection/faceindex) to identify a specific face, as well as [facepad](https://docs.imgix.com/apis/rendering/face-detection/facepad) to include additional padded area around the face to zoom out from the immediate area around the faces. 31 | 32 | * `fillMax` - Resizes the image to fit within the requested width and height dimensions while preserving the original aspect ratio and without discarding any original image data. If the requested width or height exceeds that of the original, the original image remains the same size. The excess space is filled with a solid color or blurred version of the image. The resulting image exactly matches the requested dimensions. 33 | 34 | ## imgix modifiers 35 | 36 | Beside the [standard modifiers](/components/nuxt-img#modifiers), you can also pass all imgix-specific render API parameters to the `modifiers` prop. 37 | 38 | For a full list of these modifiers and their uses, check out [imgix's image Rendering API documentation](https://docs.imgix.com/apis/rendering). 39 | 40 | ## imgix best practices 41 | 42 | Some common best practices when using imgix, would be to include our auto parameter, which will automatically apply the best format for an image and compress the image as well. Combine this with some top of intelligent cropping and resizing and you will have a great image! 43 | 44 | ```html 45 | 53 | ``` 54 | 55 | This will return a 300 x 500 image, which has been compressed, will display next-gen formats for a browser, and has been cropped intelligently to the face of the [woman in the hat](https://assets.imgix.net/blog/woman-hat.jpg?w=300&h=500&fit=crop&crop=faces). 56 | 57 | -------------------------------------------------------------------------------- /src/runtime/providers/sanity.ts: -------------------------------------------------------------------------------- 1 | import { joinURL } from 'ufo' 2 | import type { ProviderGetImage } from 'src' 3 | import { createOperationsGenerator } from '~image' 4 | 5 | const sanityCDN = 'https://cdn.sanity.io/images' 6 | 7 | const operationsGenerator = createOperationsGenerator({ 8 | keyMap: { 9 | format: 'fm', 10 | height: 'h', 11 | quality: 'q', 12 | width: 'w', 13 | // Convenience modifiers 14 | background: 'bg', 15 | download: 'dl', 16 | sharpen: 'sharp', 17 | orientation: 'or', 18 | 'min-height': 'min-h', 19 | 'max-height': 'max-h', 20 | 'min-width': 'min-w', 21 | 'max-width': 'max-w', 22 | minHeight: 'min-h', 23 | maxHeight: 'max-h', 24 | minWidth: 'min-w', 25 | maxWidth: 'max-w', 26 | saturation: 'sat' 27 | }, 28 | valueMap: { 29 | format: { 30 | jpeg: 'jpg' 31 | }, 32 | fit: { 33 | cover: 'crop', 34 | contain: 'fill', 35 | fill: 'scale', 36 | inside: 'min', 37 | outside: 'max' 38 | } 39 | }, 40 | joinWith: '&', 41 | formatter: (key, value) => String(value) === 'true' ? key : `${key}=${value}` 42 | }) 43 | 44 | const isDev = process.env.NODE_ENV === 'development' 45 | 46 | const getMetadata = (id: string) => { 47 | const result = id.match(/-(?\d*)x(?\d*)-(?.*)$/) 48 | if (!result || !result.groups) { 49 | // Invalid Sanity image asset ID 50 | if (isDev) { 51 | // eslint-disable-next-line 52 | console.warn(`An invalid image asset ID was passed in: ${id}`) 53 | } 54 | return { width: undefined, height: undefined, format: undefined } 55 | } 56 | 57 | const width = Number(result.groups.width) 58 | const height = Number(result.groups.height) 59 | 60 | return { 61 | width, 62 | height, 63 | format: result.groups.format 64 | } 65 | } 66 | 67 | export const getImage: ProviderGetImage = (src, { modifiers = {}, projectId, dataset = 'production' } = {}) => { 68 | const { height: sourceHeight, width: sourceWidth } = getMetadata(src) 69 | if (modifiers.crop && typeof modifiers.crop !== 'string' && sourceWidth && sourceHeight) { 70 | const left = modifiers.crop.left * sourceWidth 71 | const top = modifiers.crop.top * sourceHeight 72 | const right = sourceWidth - modifiers.crop.right * sourceWidth 73 | const bottom = sourceHeight - modifiers.crop.bottom * sourceHeight 74 | modifiers.rect = [left, top, right - left, bottom - top].map(i => i.toFixed(0)).join(',') 75 | delete modifiers.crop 76 | } 77 | if (modifiers.hotspot && typeof modifiers.hotspot !== 'string') { 78 | modifiers['fp-x'] = modifiers.hotspot.x 79 | modifiers['fp-y'] = modifiers.hotspot.y 80 | delete modifiers.hotspot 81 | } 82 | if (!modifiers.format || modifiers.format === 'auto') { 83 | if (modifiers.format === 'auto') { 84 | delete modifiers.format 85 | } 86 | modifiers.auto = 'format' 87 | } 88 | if (modifiers.fit === 'contain' && !modifiers.bg) { 89 | modifiers.bg = 'ffffff' 90 | } 91 | const operations = operationsGenerator(modifiers) 92 | 93 | const parts = src.split('-').slice(1) 94 | const format = parts.pop() 95 | 96 | const filenameAndQueries = parts.join('-') + '.' + format + (operations ? ('?' + operations) : '') 97 | 98 | return { 99 | url: joinURL(sanityCDN, projectId, dataset, filenameAndQueries) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'upath' 2 | import defu from 'defu' 3 | import { parseURL, withLeadingSlash } from 'ufo' 4 | import type { Module } from '@nuxt/types' 5 | import { setupStaticGeneration } from './generate' 6 | import { resolveProviders, detectProvider } from './provider' 7 | import { pick, pkg } from './utils' 8 | import type { ModuleOptions, CreateImageOptions } from './types' 9 | 10 | const imageModule: Module = async function imageModule (moduleOptions) { 11 | const { nuxt, addPlugin } = this 12 | 13 | const defaults: ModuleOptions = { 14 | staticFilename: '[publicPath]/image/[hash][ext]', 15 | provider: 'auto', 16 | presets: {}, 17 | dir: resolve(nuxt.options.srcDir, nuxt.options.dir.static), 18 | domains: [], 19 | sharp: {}, 20 | // https://tailwindcss.com/docs/breakpoints 21 | screens: { 22 | xs: 320, 23 | sm: 640, 24 | md: 768, 25 | lg: 1024, 26 | xl: 1280, 27 | xxl: 1536, 28 | '2xl': 1536 29 | }, 30 | internalUrl: '', 31 | providers: {}, 32 | static: {}, 33 | alias: {} 34 | } 35 | 36 | const options: ModuleOptions = defu(moduleOptions, nuxt.options.image, defaults) 37 | 38 | // Normalize domains to hostname 39 | options.domains = options.domains 40 | .map(domain => parseURL(domain, 'https://').host) 41 | .filter(Boolean) as string[] 42 | 43 | // Normalize alias to start with leading slash 44 | options.alias = Object.fromEntries(Object.entries(options.alias).map(e => [withLeadingSlash(e[0]), e[1]])) 45 | 46 | options.provider = detectProvider(options.provider, nuxt.options.target === 'static') 47 | options[options.provider] = options[options.provider] || {} 48 | 49 | const imageOptions: Omit = pick(options, [ 50 | 'screens', 51 | 'presets', 52 | 'provider', 53 | 'domains', 54 | 'alias' 55 | ]) 56 | 57 | const providers = resolveProviders(nuxt, options) 58 | 59 | // Run setup 60 | for (const p of providers) { 61 | if (typeof p.setup === 'function') { 62 | await p.setup(p, options, nuxt) 63 | } 64 | } 65 | 66 | // Transpile and alias runtime 67 | const runtimeDir = resolve(__dirname, 'runtime') 68 | nuxt.options.alias['~image'] = runtimeDir 69 | nuxt.options.build.transpile.push(runtimeDir, '@nuxt/image', 'allowlist', 'defu', 'ufo') 70 | 71 | // Add plugin 72 | addPlugin({ 73 | fileName: 'image.js', 74 | src: resolve(runtimeDir, 'plugin.js'), 75 | options: { 76 | imageOptions, 77 | providers 78 | } 79 | }) 80 | 81 | // Transform asset urls that pass to `src` attribute on image components 82 | nuxt.options.build.loaders = defu({ 83 | vue: { transformAssetUrls: { 'nuxt-img': 'src', 'nuxt-picture': 'src', NuxtPicture: 'src', NuxtImg: 'src' } } 84 | }, nuxt.options.build.loaders || {}) 85 | 86 | nuxt.hook('generate:before', () => { 87 | setupStaticGeneration(nuxt, options) 88 | }) 89 | 90 | const LruCache = await import('lru-cache').then(r => r.default || r) 91 | const cache = new LruCache() 92 | nuxt.hook('vue-renderer:context', (ssrContext: any) => { 93 | ssrContext.cache = cache 94 | }) 95 | 96 | nuxt.hook('listen', (_: any, listener: any) => { 97 | options.internalUrl = `http://localhost:${listener.port}` 98 | }) 99 | } 100 | 101 | ; (imageModule as any).meta = pkg 102 | 103 | export default imageModule 104 | -------------------------------------------------------------------------------- /src/runtime/providers/cloudinary.ts: -------------------------------------------------------------------------------- 1 | import { joinURL, encodePath } from 'ufo' 2 | import defu from 'defu' 3 | import type { ProviderGetImage } from 'src' 4 | import { createOperationsGenerator } from '~image' 5 | 6 | const convertHextoRGBFormat = (value: string) => value.startsWith('#') ? value.replace('#', 'rgb_') : value 7 | const removePathExtension = (value: string) => value.replace(/\.[^/.]+$/, '') 8 | 9 | const operationsGenerator = createOperationsGenerator({ 10 | keyMap: { 11 | fit: 'c', 12 | width: 'w', 13 | height: 'h', 14 | format: 'f', 15 | quality: 'q', 16 | background: 'b', 17 | rotate: 'a', 18 | roundCorner: 'r', 19 | gravity: 'g', 20 | effect: 'e', 21 | color: 'co', 22 | flags: 'fl', 23 | dpr: 'dpr', 24 | opacity: 'o', 25 | overlay: 'l', 26 | underlay: 'u', 27 | transformation: 't', 28 | zoom: 'z', 29 | colorSpace: 'cs', 30 | customFunc: 'fn', 31 | density: 'dpi' 32 | }, 33 | valueMap: { 34 | fit: { 35 | fill: 'fill', 36 | inside: 'pad', 37 | outside: 'lpad', 38 | cover: 'fit', 39 | contain: 'scale', 40 | minCover: 'mfit', 41 | minInside: 'mpad', 42 | thumbnail: 'thumb', 43 | cropping: 'crop', 44 | coverLimit: 'limit' 45 | }, 46 | format: { 47 | jpeg: 'jpg' 48 | }, 49 | background (value: string) { 50 | return convertHextoRGBFormat(value) 51 | }, 52 | color (value: string) { 53 | return convertHextoRGBFormat(value) 54 | }, 55 | gravity: { 56 | auto: 'auto', 57 | subject: 'auto:subject', 58 | face: 'face', 59 | sink: 'sink', 60 | faceCenter: 'face:center', 61 | multipleFaces: 'faces', 62 | multipleFacesCenter: 'faces:center', 63 | north: 'north', 64 | northEast: 'north_east', 65 | northWest: 'north_west', 66 | west: 'west', 67 | southWest: 'south_west', 68 | south: 'south', 69 | southEast: 'south_east', 70 | east: 'east', 71 | center: 'center' 72 | } 73 | }, 74 | joinWith: ',', 75 | formatter: (key, value) => `${key}_${value}` 76 | }) 77 | 78 | const defaultModifiers = { 79 | format: 'auto', 80 | quality: 'auto' 81 | } 82 | 83 | export const getImage: ProviderGetImage = (src, { modifiers = {}, baseURL = '/' } = {}) => { 84 | const mergeModifiers = defu(modifiers, defaultModifiers) 85 | const operations = operationsGenerator(mergeModifiers as any) 86 | 87 | const remoteFolderMapping = baseURL.match(/\/image\/upload\/(.*)/) 88 | // Handle delivery remote media file URLs 89 | // see: https://cloudinary.com/documentation/fetch_remote_images 90 | // Note: Non-remote images will pass into this function if the baseURL is not using a sub directory 91 | if (remoteFolderMapping?.length >= 1) { 92 | // need to do some weird logic to get the remote folder after image/upload after the operations and before the src 93 | const remoteFolder = remoteFolderMapping[1] 94 | const baseURLWithoutRemoteFolder = baseURL.replace(remoteFolder, '') 95 | 96 | return { 97 | url: joinURL(baseURLWithoutRemoteFolder, operations, remoteFolder, src) 98 | } 99 | } else if (/\/image\/fetch\/?/.test(baseURL)) { 100 | // need to encode the src as a path in case it contains special characters 101 | src = encodePath(src) 102 | } else { 103 | // If the src is not a remote media file then we need to remove the extension (if it exists) 104 | src = removePathExtension(src) 105 | } 106 | 107 | return { 108 | url: joinURL(baseURL, operations, src) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /test/providers.ts: -------------------------------------------------------------------------------- 1 | export const images = [ 2 | { 3 | args: ['/test.png', {}], 4 | ipx: { url: '/_ipx/_/test.png' }, 5 | cloudinary: { url: '/f_auto,q_auto/test' }, 6 | twicpics: { url: '/test.png' }, 7 | fastly: { url: '/test.png' }, 8 | glide: { url: '/test.png' }, 9 | imgix: { url: '/test.png' }, 10 | unsplash: { url: '/test.png' }, 11 | imagekit: { url: '/test.png' }, 12 | netlify: { url: '/test.png' }, 13 | prismic: { url: '/test.png?auto=compress,format&rect=0,0,200,200&w=100&h=100' }, 14 | sanity: { url: 'https://cdn.sanity.io/images/projectid/production/test-300x450.png?auto=format' } 15 | }, 16 | { 17 | args: ['/test.png', { width: 200 }], 18 | ipx: { url: '/_ipx/w_200/test.png' }, 19 | cloudinary: { url: '/f_auto,q_auto,w_200/test' }, 20 | twicpics: { url: '/test.png?twic=v1/cover=200x-' }, 21 | fastly: { url: '/test.png?width=200' }, 22 | glide: { url: '/test.png?w=200' }, 23 | imgix: { url: '/test.png?w=200' }, 24 | unsplash: { url: '/test.png?w=200' }, 25 | imagekit: { url: '/test.png?tr=w-200' }, 26 | netlify: { url: '/test.png?w=200&nf_resize=fit' }, 27 | prismic: { url: '/test.png?auto=compress,format&rect=0,0,200,200&w=200&h=100' }, 28 | sanity: { url: 'https://cdn.sanity.io/images/projectid/production/test-300x450.png?w=200&auto=format' } 29 | }, 30 | { 31 | args: ['/test.png', { height: 200 }], 32 | ipx: { url: '/_ipx/h_200/test.png' }, 33 | cloudinary: { url: '/f_auto,q_auto,h_200/test' }, 34 | twicpics: { url: '/test.png?twic=v1/cover=-x200' }, 35 | fastly: { url: '/test.png?height=200' }, 36 | glide: { url: '/test.png?h=200' }, 37 | imgix: { url: '/test.png?h=200' }, 38 | unsplash: { url: '/test.png?h=200' }, 39 | imagekit: { url: '/test.png?tr=h-200' }, 40 | netlify: { url: '/test.png?h=200&nf_resize=fit' }, 41 | prismic: { url: '/test.png?auto=compress,format&rect=0,0,200,200&w=100&h=200' }, 42 | sanity: { url: 'https://cdn.sanity.io/images/projectid/production/test-300x450.png?h=200&auto=format' } 43 | }, 44 | { 45 | args: ['/test.png', { width: 200, height: 200 }], 46 | ipx: { url: '/_ipx/s_200x200/test.png' }, 47 | cloudinary: { url: '/f_auto,q_auto,w_200,h_200/test' }, 48 | twicpics: { url: '/test.png?twic=v1/cover=200x200' }, 49 | fastly: { url: '/test.png?width=200&height=200' }, 50 | glide: { url: '/test.png?w=200&h=200' }, 51 | imgix: { url: '/test.png?w=200&h=200' }, 52 | unsplash: { url: '/test.png?w=200&h=200' }, 53 | imagekit: { url: '/test.png?tr=w-200,h-200' }, 54 | netlify: { url: '/test.png?w=200&h=200&nf_resize=fit' }, 55 | prismic: { url: '/test.png?auto=compress,format&rect=0,0,200,200&w=200&h=200' }, 56 | sanity: { url: 'https://cdn.sanity.io/images/projectid/production/test-300x450.png?w=200&h=200&auto=format' } 57 | }, 58 | { 59 | args: ['/test.png', { width: 200, height: 200, fit: 'contain' }], 60 | ipx: { url: '/_ipx/fit_contain,s_200x200/test.png' }, 61 | cloudinary: { url: '/f_auto,q_auto,w_200,h_200,c_scale/test' }, 62 | twicpics: { url: '/test.png?twic=v1/contain=200x200' }, 63 | fastly: { url: '/test.png?width=200&height=200&fit=bounds' }, 64 | glide: { url: '/test.png?w=200&h=200&fit=contain' }, 65 | imgix: { url: '/test.png?w=200&h=200&fit=fill' }, 66 | unsplash: { url: '/test.png?w=200&h=200&fit=fill' }, 67 | imagekit: { url: '/test.png?tr=w-200,h-200,cm-pad_resize' }, 68 | netlify: { url: '/test.png?w=200&h=200&nf_resize=fit' }, 69 | prismic: { url: '/test.png?auto=compress,format&rect=0,0,200,200&w=200&h=200&fit=fill' }, 70 | sanity: { url: 'https://cdn.sanity.io/images/projectid/production/test-300x450.png?w=200&h=200&fit=fill&auto=format&bg=ffffff' } 71 | }, 72 | { 73 | args: ['/test.png', { width: 200, height: 200, fit: 'contain', format: 'jpeg' }], 74 | ipx: { url: '/_ipx/fit_contain,f_jpeg,s_200x200/test.png' }, 75 | cloudinary: { url: '/f_jpg,q_auto,w_200,h_200,c_scale/test' }, 76 | twicpics: { url: '/test.png?twic=v1/output=jpeg/contain=200x200' }, 77 | fastly: { url: '/test.png?width=200&height=200&fit=bounds&format=jpeg' }, 78 | glide: { url: '/test.png?w=200&h=200&fit=contain&fm=jpeg' }, 79 | imgix: { url: '/test.png?w=200&h=200&fit=fill&fm=jpeg' }, 80 | unsplash: { url: '/test.png?w=200&h=200&fit=fill&fm=jpeg' }, 81 | imagekit: { url: '/test.png?tr=w-200,h-200,cm-pad_resize,f-jpeg' }, 82 | netlify: { url: '/test.png?w=200&h=200&nf_resize=fit' }, 83 | prismic: { url: '/test.png?auto=compress,format&rect=0,0,200,200&w=200&h=200&fit=fill&fm=jpeg' }, 84 | sanity: { url: 'https://cdn.sanity.io/images/projectid/production/test-300x450.png?w=200&h=200&fit=fill&fm=jpg&bg=ffffff' } 85 | } 86 | ] as const 87 | 88 | export const modifierFixtures = images.map(image => image.args[1]) 89 | -------------------------------------------------------------------------------- /src/runtime/providers/imagekit.ts: -------------------------------------------------------------------------------- 1 | import { joinURL } from 'ufo' 2 | import type { ProviderGetImage } from 'src' 3 | import { createOperationsGenerator } from '~image' 4 | 5 | const operationsGenerator = createOperationsGenerator({ 6 | keyMap: { 7 | fit: 'c', 8 | width: 'w', 9 | height: 'h', 10 | format: 'f', 11 | quality: 'q', 12 | bg: 'bg', 13 | background: 'bg', 14 | crop: 'c', 15 | cropMode: 'cm', 16 | aspectRatio: 'ar', 17 | x: 'x', 18 | y: 'y', 19 | xc: 'xc', 20 | yc: 'yc', 21 | oix: 'oix', 22 | oiy: 'oiy', 23 | oixc: 'oixc', 24 | oiyc: 'oiyc', 25 | focus: 'fo', 26 | radius: 'r', 27 | border: 'b', 28 | rotate: 'rt', 29 | blur: 'bl', 30 | named: 'n', 31 | overlayX: 'ox', 32 | overlayY: 'oy', 33 | overlayFocus: 'ofo', 34 | overlayHeight: 'oh', 35 | overlayWidth: 'ow', 36 | overlayImage: 'oi', 37 | overlayImageTrim: 'oit', 38 | overlayImageAspectRatio: 'oiar', 39 | overlayImageBackground: 'oibg', 40 | overlayImageBorder: 'oib', 41 | overlayImageDPR: 'oidpr', 42 | overlayImageQuality: 'oiq', 43 | overlayImageCropping: 'oic', 44 | overlayImageCropMode: 'oicm', 45 | overlayText: 'ot', 46 | overlayTextFontSize: 'ots', 47 | overlayTextFontFamily: 'otf', 48 | overlayTextColor: 'otc', 49 | overlayTextTransparency: 'oa', 50 | overlayTextTypography: 'ott', 51 | overlayBackground: 'obg', 52 | overlayTextEncoded: 'ote', 53 | overlayTextWidth: 'otw', 54 | overlayTextBackground: 'otbg', 55 | overlayTextPadding: 'otp', 56 | overlayTextInnerAlignment: 'otia', 57 | overlayRadius: 'or', 58 | progressive: 'pr', 59 | lossless: 'lo', 60 | trim: 't', 61 | metadata: 'md', 62 | colorProfile: 'cp', 63 | defaultImage: 'di', 64 | dpr: 'dpr', 65 | effectSharpen: 'e-sharpen', 66 | effectUSM: 'e-usm', 67 | effectContrast: 'e-contrast', 68 | effectGray: 'e-grayscale', 69 | original: 'orig' 70 | }, 71 | valueMap: { 72 | fit: { 73 | cover: 'maintain_ratio', 74 | contain: 'pad_resize', 75 | fill: 'force', 76 | inside: 'at_max', 77 | outside: 'at_least', 78 | extract: 'extract', 79 | pad_extract: 'pad_extract' 80 | }, 81 | background (value: string) { 82 | if (value.startsWith('#')) { 83 | return value.replace('#', '') 84 | } 85 | return value 86 | }, 87 | crop: { 88 | maintain_ratio: 'maintain_ratio', 89 | force: 'force', 90 | at_max: 'at_max', 91 | at_least: 'at_least' 92 | }, 93 | cropMode: { 94 | pad_resize: 'pad_resize', 95 | pad_extract: 'pad_extract', 96 | extract: 'extract' 97 | }, 98 | format: { 99 | auto: 'auto', 100 | jpg: 'jpg', 101 | jpeg: 'jpeg', 102 | webp: 'webp', 103 | avif: 'avif', 104 | png: 'png' 105 | }, 106 | focus: { 107 | left: 'left', 108 | right: 'right', 109 | top: 'top', 110 | bottom: 'bottom', 111 | custom: 'custom', 112 | center: 'center', 113 | top_left: 'top_left', 114 | top_right: 'top_right', 115 | bottom_left: 'bottom_left', 116 | bottom_right: 'bottom_right', 117 | auto: 'auto', 118 | face: 'face' 119 | }, 120 | rotate: { 121 | auto: 'auto', 122 | 0: '0', 123 | 90: '90', 124 | 180: '180', 125 | 270: '270', 126 | 360: '360' 127 | }, 128 | overlayFocus: { 129 | left: 'left', 130 | right: 'right', 131 | top: 'top', 132 | bottom: 'bottom', 133 | custom: 'custom', 134 | center: 'center', 135 | top_left: 'top_left', 136 | top_right: 'top_right', 137 | bottom_left: 'bottom_left', 138 | bottom_right: 'bottom_right', 139 | auto: 'auto', 140 | face: 'face' 141 | }, 142 | overlayImageCropping: { 143 | maintain_ratio: 'maintain_ratio', 144 | force: 'force', 145 | at_max: 'at_max', 146 | at_least: 'at_least' 147 | }, 148 | overlayImageCropMode: { 149 | pad_resize: 'pad_resize', 150 | pad_extract: 'pad_extract', 151 | extract: 'extract' 152 | }, 153 | overlayTextTypography: { 154 | b: 'b', 155 | i: 'i' 156 | }, 157 | overlayTextInnerAlignment: { 158 | left: 'left', 159 | right: 'right', 160 | center: 'center' 161 | } 162 | }, 163 | joinWith: ',', 164 | formatter: (key, value) => `${key}-${value}` 165 | }) 166 | 167 | export const getImage: ProviderGetImage = (src, { modifiers = {}, baseURL = '/' } = {}) => { 168 | let operations = operationsGenerator(modifiers) 169 | 170 | operations = operations.replace('c-pad_resize', 'cm-pad_resize') 171 | operations = operations.replace('c-pad_extract', 'cm-pad_extract') 172 | operations = operations.replace('c-extract', 'cm-extract') 173 | 174 | return { 175 | url: joinURL(baseURL, src + (operations ? `?tr=${operations}` : '')) 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/runtime/providers/imgix.ts: -------------------------------------------------------------------------------- 1 | import { joinURL } from 'ufo' 2 | import type { ProviderGetImage } from 'src' 3 | import { createOperationsGenerator } from '~image' 4 | 5 | export const operationsGenerator = createOperationsGenerator({ 6 | keyMap: { 7 | width: 'w', 8 | height: 'h', 9 | format: 'fm', 10 | quality: 'q', 11 | backgroundColor: 'bg', 12 | rotate: 'rot', 13 | mask: 'mask', 14 | auto: 'auto', 15 | crop: 'crop', 16 | brightness: 'bri', 17 | contrast: 'con', 18 | exposure: 'exp', 19 | gamma: 'gam', 20 | highlight: 'high', 21 | hueShift: 'hue', 22 | invert: 'invert', 23 | saturation: 'sat', 24 | shadow: 'shad', 25 | sharpen: 'sharp', 26 | unsharpMask: 'usm', 27 | unsharpMaskRadius: 'usmrad', 28 | vibrance: 'vib', 29 | blend: 'blend', 30 | blendAlign: 'blend-align', 31 | blendAlpha: 'blend-alpha', 32 | blendColor: 'blend-color', 33 | blendCrop: 'blend-crop', 34 | blendFit: 'blend-fit', 35 | blendHeight: 'blend-h', 36 | blendMode: 'blend-mode', 37 | blendPadding: 'blend-pad', 38 | blendSize: 'blend-size', 39 | blendWidth: 'blend-w', 40 | blendXPosition: 'blend-x', 41 | blendYPosition: 'blend-y', 42 | padding: 'pad', 43 | borderBottom: 'border-bottom', 44 | borderLeft: 'border-left', 45 | innerBorderRadius: 'border-radius-inner', 46 | outerBorderRadius: 'border-radius', 47 | borderRight: 'border-right', 48 | borderTop: 'border-top', 49 | borderSizeColor: 'border', 50 | paddingBottom: 'pad-bottom', 51 | paddingLeft: 'pad-left', 52 | paddingRight: 'pad-right', 53 | paddingTop: 'pad-top', 54 | paletteColorCount: 'colors', 55 | colorPaletteExtraction: 'palette', 56 | cssPrefix: 'prefix', 57 | expirationTimestamp: 'expires', 58 | faceIndex: 'faceindex', 59 | facePadding: 'facepad', 60 | jsonFaceData: 'faces', 61 | fillMode: 'fill', 62 | fillColor: 'fill-color', 63 | gridColors: 'grid-colors', 64 | gridSize: 'grid-size', 65 | transparency: 'transparency', 66 | focalPointDebug: 'fp-debug', 67 | focalPointXPosition: 'fp-x', 68 | focalPointYPosition: 'fp-y', 69 | focalPointZoom: 'fp-z', 70 | clientHints: 'ch', 71 | chromaSubsampling: 'chromasub', 72 | colorQuantization: 'colorquant', 73 | colorSpace: 'cs', 74 | download: 'dl', 75 | dotsPerInch: 'dpi', 76 | losslessCompression: 'lossless', 77 | maskBackgroundColor: 'mask-bg', 78 | maskCornerRadius: 'corner-radius', 79 | noiseReductionSharp: 'nrs', 80 | noiseReductionBound: 'nr', 81 | pdfPageNumber: 'page', 82 | pdfAnnotation: 'pdf-annotation', 83 | pixelDensity: 'dpr', 84 | orientation: 'orient', 85 | flipAxis: 'flip', 86 | aspectRatio: 'ar', 87 | maximumHeight: 'max-h', 88 | maximumWidth: 'max-w', 89 | minimumHeight: 'min-h', 90 | minimumWidth: 'min-w', 91 | sourceRectangleRegion: 'rect', 92 | gaussianBlur: 'blur', 93 | duotoneAlpha: 'duotone-alpha', 94 | duotone: 'duotone', 95 | halftone: 'htn', 96 | monochrome: 'monochrome', 97 | pixellate: 'px', 98 | sepiaTone: 'sepia', 99 | textAlign: 'txt-align', 100 | textClippingMode: 'txt-clip', 101 | textColor: 'txt-color', 102 | textFitMode: 'txt-fit', 103 | textFont: 'txt-font', 104 | textLigatures: 'txt-lig', 105 | textOutlineColor: 'txt-line-color', 106 | textOutline: 'txt-line', 107 | textPadding: 'txt-pad', 108 | textShadow: 'txt-shad', 109 | textFontSize: 'txt-size', 110 | textWidth: 'txt-width', 111 | textString: 'txt', 112 | trimColor: 'trim-color', 113 | trimMeanDifference: 'trim-md', 114 | trimStandardDeviation: 'trim-sd', 115 | trimTolerance: 'trim-tol', 116 | trimImage: 'trim', 117 | textLeading: 'txt-lead', 118 | textTracking: 'txt-track', 119 | typesettingEndpoint: '~text', 120 | watermarkAlignment: 'mark-align', 121 | watermarkAlpha: 'mark-alpha', 122 | watermarkBaseURL: 'mark-base', 123 | watermarkFitMode: 'mark-fit', 124 | watermarkHeight: 'mark-h', 125 | watermarkPadding: 'mark-pad', 126 | watermarkRotation: 'mark-rot', 127 | watermarkScale: 'mark-sclae', 128 | watermarkTile: 'mark-tile', 129 | watermarkWidth: 'mark-w', 130 | watermarkXPosition: 'mark-x', 131 | watermarkYPosition: 'mark-y', 132 | watermarkImageURL: 'mark' 133 | }, 134 | valueMap: { 135 | fit: { 136 | fill: 'scale', 137 | inside: 'max', 138 | outside: 'min', 139 | cover: 'crop', 140 | contain: 'fill', 141 | clamp: 'clamp', 142 | clip: 'clip', 143 | facearea: 'facearea', 144 | fillMax: 'fillmax' 145 | } 146 | }, 147 | joinWith: '&', 148 | formatter: (key, value) => `${key}=${value}` 149 | }) 150 | 151 | export const getImage: ProviderGetImage = (src, { modifiers = {}, baseURL = '/' } = {}) => { 152 | const operations = operationsGenerator(modifiers) 153 | return { 154 | url: joinURL(baseURL, src + (operations ? ('?' + operations) : '')) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /docs/content/en/4.providers/storyblok.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Storyblok Provider 3 | description: 'Nuxt Image internally use Storyblok as static provider.' 4 | navigation: 5 | title: Storyblok 6 | --- 7 | 8 | Integration between [Storyblok](https://www.storyblok.com/docs/image-service/) and the image module. To use this provider you just need to specify the base url of your service in Storyblok. 9 | 10 | ```js{}[nuxt.config.js] 11 | export default { 12 | image: { 13 | storyblok: { 14 | baseURL: 'https://img2.storyblok.com' 15 | } 16 | } 17 | } 18 | ``` 19 | 20 | ## Storyblok modifiers 21 | 22 | I am following all modifiers present on [Storyblok image service](https://www.storyblok.com/docs/image-service/) 23 | 24 | ### Resizing 25 | 26 | Check [Storyblok documentation](https://www.storyblok.com/docs/image-service#resizing) if you want to know more. 27 | 28 | the logic is: 29 | 30 | - If you not defining the width or height it's taking the original image size. 31 | - If you defined only the width or height it will resize proportionaly based ont he one defined 32 | 33 | Example: 34 | 35 | ```html 36 |
Original
37 | 41 | 42 |
Resized static
43 | 49 | 50 |
Proportional to Width
51 | 56 | 57 |
Proportional to Height
58 | 63 | ``` 64 | 65 | ### Fit in with background or not 66 | 67 | Check [Storyblok documentation](https://www.storyblok.com/docs/image-service#fit-in) if you want to know more. 68 | 69 | If you want to use it just add a props `fit="in"`. Take care that storyblok only support `fit-in`. 70 | 71 | You can also use the fill filters to fill your fit-in with a specific background. If you not defining value it will be transparent. 72 | 73 | Example: 74 | 75 | ```html 76 |
Fit in with background CCCCCC
77 | 85 | ``` 86 | 87 | ### Format 88 | 89 | Check [Storyblok documentation](https://www.storyblok.com/docs/image-service#changing-the-format) if you want to know more. 90 | 91 | You can modify your image format. Supported format are `webp`, `jpeg` and `png`. 92 | 93 | Example: 94 | 95 | ```html 96 |

Format

97 | 103 | ``` 104 | 105 | ### Quality 106 | 107 | Check [Storyblok documentation](https://www.storyblok.com/docs/image-service#quality-optimization) if you want to know more. 108 | 109 | You can update your image quality by defining the quality filters. 110 | 111 | Example: 112 | 113 | ```html 114 |
115 |
Resized and 10% Quality
116 | 122 |
123 | ``` 124 | 125 | ### Facial detection 126 | 127 | Check [Storyblok documentation](https://www.storyblok.com/docs/image-service#facial-detection-and-smart-cropping) if you want to know more. 128 | 129 | To have a smart crop just define a smart property inside modifier. 130 | 131 | Example: 132 | 133 | ```html 134 |

Facial detection

135 | 136 |
Resized without Smart Crop
137 | 143 | 144 |
Resized with Smart Crop
145 | 152 | ``` 153 | 154 | ### Custom focal point 155 | 156 | Check [Storyblok documentation](https://www.storyblok.com/docs/image-service#custom-focal-point) if you want to know more. 157 | 158 | Storyblok offer you the focalize on a specific part of your image. Just use `focal` filters. 159 | 160 | Example: 161 | 162 | ```html 163 |
Focus on the bottom of the image
164 | 171 | 172 |
Focus on the top of the image
173 | 180 | ``` 181 | -------------------------------------------------------------------------------- /docs/components/HomeHero.vue: -------------------------------------------------------------------------------- 1 | 104 | 105 | 113 | -------------------------------------------------------------------------------- /docs/content/en/2.components/1.nuxt-img.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 3 | description: Discover how to use and configure the nuxt-img component. 4 | --- 5 | 6 | `` is a drop-in replacement for the native `` tag: 7 | 8 | - Uses built-in provider to optimize local and remote images 9 | - Converts `src` to provider optimized URLs 10 | - Automatically resizes images based on `width` and `height` 11 | - Generates responsive sizes when providing `sizes` option 12 | - Supports native lazy loading as well as other `` attributes 13 | 14 | ## Usage 15 | 16 | `nuxt-img` outputs a native `img` tag directly (without any wrapper around it). Use it like you would use the `` tag: 17 | 18 | ```html 19 | 20 | ``` 21 | 22 | Will result in: 23 | 24 | ```html 25 | 26 | ``` 27 | 28 | :::alert{type="info"} 29 | With [default provider](/getting-started/providers#default-provider), you should put `/nuxt-icon.png` inside `static/` directory for making above example working. 30 | ::: 31 | 32 | ## Props 33 | 34 | ### `src` 35 | 36 | Path to image file 37 | 38 | `src` should be in the form of an absolute path for static images in `static/` directory. 39 | Otherwise path that is expected by provider that starts with `/` or a URL. 40 | 41 | ```html 42 | 43 | ``` 44 | 45 | For image optimization when using external urls in `src`, we need to whitelist them using [`domains`](/api/options#domains) option. 46 | 47 | ### `width` / `height` 48 | 49 | Specify width/height of the image. 50 | 51 | - Use desired width/height for static sized images like icons or avatars 52 | - Use original image width/height for responsive images (when using [`sizes`](#sizes)) 53 | 54 | ### `sizes` 55 | 56 | Specify responsive sizes. 57 | 58 | This a space-separated list of screen size/width pairs. You can [see a list of the defined screen sizes here](/api/options#screens)). 59 | 60 | **Example:** 61 | 62 | ```html 63 | 67 | ``` 68 | 69 | ### `provider` 70 | 71 | Use other provider instead of default [provider option](/api/options#provider) specified in `nuxt.config` 72 | 73 | **Example:** 74 | 75 | :::code-group 76 | 77 | ```html [index.vue] 78 | 86 | ``` 87 | 88 | ```js [nuxt.config.js] 89 | export default { 90 | image: { 91 | cloudinary: { 92 | baseURL: "https://res.cloudinary.com/nuxt/image/upload/", 93 | }, 94 | }, 95 | }; 96 | ``` 97 | 98 | ::: 99 | 100 | ### `preset` 101 | 102 | Presets are predefined sets of image modifiers that can be used create unified form of images in your projects. 103 | 104 | :::alert{type="info"} 105 | We can define presets using presets option in nuxt.config 106 | ::: 107 | 108 | ::code-group 109 | 110 | ```html [index.vue] 111 | 114 | ``` 115 | 116 | ```ts [nuxt.config.js] 117 | export default { 118 | image: { 119 | presets: { 120 | cover: { 121 | modifiers: { 122 | fit: "cover", 123 | format: "jpg", 124 | width: 300, 125 | height: 300, 126 | }, 127 | }, 128 | }, 129 | }, 130 | }; 131 | ``` 132 | 133 | :: 134 | 135 | ### `format` 136 | 137 | In case you want to serve images in a specific format, use this prop. 138 | 139 | ```html 140 | 141 | ``` 142 | 143 | Available formats are `webp`, `jpeg`, `jpg`, `png`, `gif` and `svg`. If the format is not specified, it will respect the default image format. 144 | 145 | ### `quality` 146 | 147 | The quality for the generated image(s). 148 | 149 | ```html 150 | 151 | ``` 152 | 153 | ### `fit` 154 | 155 | The `fit` property specifies the size of the images. 156 | There are five standard values you can use with this property. 157 | 158 | - `cover`: (default) Preserving aspect ratio, ensure the image covers both provided dimensions by cropping/clipping to fit 159 | - `contain`: Preserving aspect ratio, contain within both provided dimensions using "letterboxing" where necessary. 160 | - `fill`: Ignore the aspect ratio of the input and stretch to both provided dimensions. 161 | - `inside`: Preserving aspect ratio, resize the image to be as large as possible while ensuring its dimensions are less than or equal to both those specified. 162 | - `outside`: Preserving aspect ratio, resize the image to be as small as possible while ensuring its dimensions are greater than or equal to both those specified. 163 | 164 | ```html 165 | 166 | ``` 167 | 168 | :::alert{type="info"} 169 | Some providers support other values 170 | ::: 171 | 172 | ### `modifiers` 173 | 174 | In addition to the standard modifiers, each provider might have its own additional modifiers. Because these modifiers depend on the provider, refer to its documentation to know what can be used. 175 | 176 | Using the `modifiers` prop lets you use any of these transformations. 177 | 178 | **Example:** 179 | 180 | ```html 181 | 188 | ``` 189 | -------------------------------------------------------------------------------- /docs/content/en/3.api/1.options.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Module Options 3 | category: API 4 | description: Nuxt Image is configured with sensible defaults. 5 | navigation: 6 | title: Options 7 | --- 8 | 9 | To configure the image module and customize its behavior, you can use the `image` property in your `nuxt.config`: 10 | 11 | ```js{}[nuxt.config] 12 | export default { 13 | image: { 14 | // Options 15 | } 16 | } 17 | ``` 18 | 19 | ## `screens` 20 | 21 | List of predefined screen sizes. 22 | 23 | These sizes will be used to generate resized and optimized versions of an image (for example, with the `sizes` modifier). 24 | 25 | ```ts [nuxt.config.js] 26 | export default { 27 | image: { 28 | // The screen sizes predefined by `@nuxt/image`: 29 | screens: { 30 | xs: 320, 31 | sm: 640, 32 | md: 768, 33 | lg: 1024, 34 | xl: 1280, 35 | xxl: 1536, 36 | '2xl': 1536 37 | }, 38 | } 39 | } 40 | ``` 41 | 42 | ## `domains` 43 | 44 | To enable image optimization on an external website, specify which domains are allowed to be optimized. This option will be used to detect whether a remote image should be optimized or not. This is needed to ensure that external urls can't be abused. 45 | 46 | ```ts [nuxt.config.js] 47 | export default { 48 | image: { 49 | domains: ['https://nuxtjs.org'] 50 | } 51 | } 52 | ``` 53 | 54 | ## `presets` 55 | 56 | Presets are collections of pre-defined configurations for your projects. Presets will help you to unify images all over your project. 57 | 58 | ::code-group 59 | 60 | ```ts [nuxt.config.js] 61 | export default { 62 | image: { 63 | presets: { 64 | avatar: { 65 | modifiers: { 66 | format: 'jpg', 67 | width: 50, 68 | height: 50 69 | } 70 | } 71 | } 72 | } 73 | } 74 | ``` 75 | 76 | ```html [index.vue] 77 | 80 | ``` 81 | 82 | :: 83 | 84 | ## `providers` 85 | 86 | In order to create and use a [custom provider](/advanced/custom-provider), you need to use the `providers` option and define your custom providers. 87 | 88 | ::code-group 89 | 90 | ```js{}[nuxt.config.js] 91 | export default { 92 | image: { 93 | providers: { 94 | random: { 95 | provider: '~/providers/random', 96 | options: {} 97 | } 98 | } 99 | } 100 | } 101 | ``` 102 | 103 | ```vue{}[index.vue] 104 | 107 | ``` 108 | 109 | :: 110 | 111 | ## `provider` 112 | 113 | Default: `static` 114 | 115 | We can specify default provider to be used when not specified in component or when calling `$img`. 116 | 117 | ```ts [nuxt.config.js] 118 | export default { 119 | image: { 120 | provider: 'twicpics', 121 | twicpics: { 122 | baseURL: 'https://nuxt-demo.twic.pics' 123 | } 124 | } 125 | } 126 | ``` 127 | 128 | ## `staticFilename` 129 | 130 | You can use this option to change filename and location for the static image generation. 131 | 132 | ### Parameters 133 | 134 | - `[name]`: Only filename, without extension or path 135 | - `[hash]`: The hash of url 136 | - `[ext]`: Extension with leading dot `.png` 137 | - `[publicPath]`: Default is `build.publicPath` (`/_nuxt`) 138 | 139 | ```ts [nuxt.config.js] 140 | export default { 141 | image: { 142 | // Generate images to `/_nuxt/image/file.png` 143 | staticFilename: '[publicPath]/images/[name]-[hash][ext]' 144 | } 145 | } 146 | ``` 147 | 148 | ## `dir` 149 | 150 | Default: `static` 151 | 152 | This option allows you to specify the location of the source images when using the `static` or `ipx` provider. 153 | 154 | For example you might want the source images in `assets/images` directory rather than the default `static` directory so the source images don't get copied into `dist` and deployed: 155 | 156 | ```ts [nuxt.config.js] 157 | export default { 158 | image: { 159 | dir: 'assets/images' 160 | } 161 | } 162 | ``` 163 | 164 | **Notes:** 165 | - For `static` provider, if images weren't crawled during generation (unreachable modals, pages or dynamic runtime size), changing `dir` from `static` causes 404 errors. 166 | - For `ipx` provider, make sure to deploy customized `dir` as well. 167 | - For some providers (like vercel), using a directory other than `static/` for assets is not supported since resizing happens at runtime (instead of build/generate time) and source fetched from the `static/` directory (deployment URL) 168 | 169 | ## `alias` 170 | 171 | This option allows you to specify aliases for `src`. 172 | 173 | When using the default ipx provider, URL aliases are shortened on the server-side. 174 | This is especially useful for optimizing external URLs and not including them in HTML. 175 | 176 | When using other providers, aliases are resolved in runtime and included in HTML. (only the usage is simplified) 177 | 178 | **Example:** 179 | 180 | ```ts [nuxt.config.js] 181 | export default { 182 | image: { 183 | domains: [ 184 | 'images.unsplash.com' 185 | ], 186 | alias: { 187 | unsplash: 'https://images.unsplash.com' 188 | } 189 | } 190 | } 191 | ``` 192 | 193 | **Before** using alias: 194 | 195 | ```html 196 | 197 | ``` 198 | 199 | Generates: 200 | 201 | ```html 202 | 203 | ``` 204 | 205 | **After** using alias: 206 | 207 | 208 | ```html 209 | 210 | ``` 211 | 212 | Generates: 213 | 214 | ```html 215 | 216 | ``` 217 | 218 | Both usage and output are simplified! 219 | -------------------------------------------------------------------------------- /example/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 48 | -------------------------------------------------------------------------------- /test/unit/providers.test.ts: -------------------------------------------------------------------------------- 1 | import { images } from '../providers' 2 | 3 | import { cleanDoubleSlashes } from '~/runtime/utils' 4 | import * as ipx from '~/runtime/providers/ipx' 5 | import * as cloudinary from '~/runtime/providers/cloudinary' 6 | import * as twicpics from '~/runtime/providers/twicpics' 7 | import * as fastly from '~/runtime/providers/fastly' 8 | import * as glide from '~/runtime/providers/glide' 9 | import * as imgix from '~/runtime/providers/imgix' 10 | import * as unsplash from '~/runtime/providers/unsplash' 11 | import * as imagekit from '~/runtime/providers/imagekit' 12 | import * as netlify from '~/runtime/providers/netlify' 13 | import * as prismic from '~/runtime/providers/prismic' 14 | import * as sanity from '~/runtime/providers/sanity' 15 | 16 | const emptyContext = { options: {} } as any 17 | 18 | describe('Providers', () => { 19 | test('ipx', () => { 20 | const providerOptions = {} 21 | 22 | for (const image of images) { 23 | const [src, modifiers] = image.args 24 | const generated = ipx.getImage(src, { modifiers: { ...modifiers }, ...providerOptions }, emptyContext) 25 | generated.url = cleanDoubleSlashes(generated.url) 26 | expect(generated).toMatchObject(image.ipx) 27 | } 28 | }) 29 | 30 | test('ipx router base', () => { 31 | const context = { ...emptyContext, nuxtContext: { base: '/app/' } } 32 | 33 | const src = '/images/test.png' 34 | const generated = ipx.getImage(src, { modifiers: {} }, context) 35 | generated.url = cleanDoubleSlashes(generated.url) 36 | expect(generated).toMatchObject({ 37 | url: '/app/_ipx/_/images/test.png' 38 | }) 39 | }) 40 | 41 | test('cloudinary', () => { 42 | const providerOptions = { 43 | baseURL: '/' 44 | } 45 | 46 | for (const image of images) { 47 | const [src, modifiers] = image.args 48 | const generated = cloudinary.getImage(src, { modifiers, ...providerOptions }, emptyContext) 49 | expect(generated).toMatchObject(image.cloudinary) 50 | } 51 | }) 52 | 53 | test('cloudinary fetch', () => { 54 | const providerOptions = { 55 | baseURL: 'https://res.cloudinary.com/demo/image/fetch/' 56 | } 57 | // see: https://cloudinary.com/documentation/fetch_remote_images#remote_image_fetch_url 58 | const remoteUrl = 'https://upload.wikimedia.org/wikipedia/commons/1/13/Benedict_Cumberbatch_2011.png' 59 | const generated = cloudinary.getImage( 60 | remoteUrl, 61 | { 62 | modifiers: { 63 | width: 300, 64 | height: 300 65 | }, 66 | ...providerOptions 67 | }, emptyContext 68 | ) 69 | expect(generated).toMatchObject({ 70 | url: `https://res.cloudinary.com/demo/image/fetch/f_auto,q_auto,w_300,h_300/${remoteUrl}` 71 | }) 72 | }) 73 | 74 | test('cloudinary upload', () => { 75 | const providerOptions = { 76 | baseURL: 'https://res.cloudinary.com/demo/image/upload/remote' 77 | } 78 | const generated = cloudinary.getImage( 79 | '/1/13/Benedict_Cumberbatch_2011.png', 80 | { 81 | modifiers: { 82 | width: 300, 83 | height: 300 84 | }, 85 | ...providerOptions 86 | }, emptyContext 87 | ) 88 | expect(generated).toMatchObject({ 89 | url: 'https://res.cloudinary.com/demo/image/upload/f_auto,q_auto,w_300,h_300/remote/1/13/Benedict_Cumberbatch_2011.png' 90 | }) 91 | }) 92 | 93 | test('twicpics', () => { 94 | const providerOptions = { 95 | baseURL: '' 96 | } 97 | 98 | for (const image of images) { 99 | const [src, modifiers] = image.args 100 | const generated = twicpics.getImage(src, { modifiers, ...providerOptions }, emptyContext) 101 | expect(generated).toMatchObject(image.twicpics) 102 | } 103 | }) 104 | 105 | test('glide', () => { 106 | const providerOptions = { 107 | baseURL: '' 108 | } 109 | for (const image of images) { 110 | const [src, modifiers] = image.args 111 | const generated = glide.getImage(src, { modifiers, ...providerOptions }, emptyContext) 112 | expect(generated).toMatchObject(image.glide) 113 | } 114 | }) 115 | 116 | test('fastly', () => { 117 | const providerOptions = { 118 | baseURL: '' 119 | } 120 | for (const image of images) { 121 | const [src, modifiers] = image.args 122 | const generated = fastly.getImage(src, { modifiers, ...providerOptions }, emptyContext) 123 | expect(generated).toMatchObject(image.fastly) 124 | } 125 | }) 126 | 127 | test('imgix', () => { 128 | const providerOptions = { 129 | baseURL: '' 130 | } 131 | 132 | for (const image of images) { 133 | const [src, modifiers] = image.args 134 | const generated = imgix.getImage(src, { modifiers, ...providerOptions }, emptyContext) 135 | expect(generated).toMatchObject(image.imgix) 136 | } 137 | }) 138 | 139 | test('unsplash', () => { 140 | const providerOptions = { 141 | baseURL: '' 142 | } 143 | 144 | for (const image of images) { 145 | const [src, modifiers] = image.args 146 | const generated = unsplash.getImage(src, { modifiers, ...providerOptions }, emptyContext) 147 | expect(generated).toMatchObject(image.unsplash) 148 | } 149 | }) 150 | 151 | test('imagekit', () => { 152 | const providerOptions = { 153 | baseURL: '' 154 | } 155 | 156 | for (const image of images) { 157 | const [src, modifiers] = image.args 158 | const generated = imagekit.getImage(src, { modifiers, ...providerOptions }, emptyContext) 159 | expect(generated).toMatchObject(image.imagekit) 160 | } 161 | }) 162 | 163 | test('netlify', () => { 164 | const providerOptions = { 165 | baseURL: '' 166 | } 167 | 168 | for (const image of images) { 169 | const [src, modifiers] = image.args 170 | const generated = netlify.getImage(src, { modifiers: { ...modifiers }, ...providerOptions }, emptyContext) 171 | expect(generated).toMatchObject(image.netlify) 172 | } 173 | }) 174 | 175 | test('prismic', () => { 176 | const providerOptions = { 177 | baseURL: '' // Use empty base URL for the sake of simplicity 178 | } 179 | 180 | const EXISTING_QUERY_PARAMETERS = 181 | '?auto=compress,format&rect=0,0,200,200&w=100&h=100' 182 | 183 | for (const image of images) { 184 | const [src, modifiers] = image.args 185 | const generated = prismic.getImage(`${src}${EXISTING_QUERY_PARAMETERS}`, { modifiers, ...providerOptions }, emptyContext) 186 | expect(generated).toMatchObject(image.prismic) 187 | } 188 | }) 189 | 190 | test('sanity', () => { 191 | const providerOptions = { 192 | baseURL: '', 193 | projectId: 'projectid' 194 | } 195 | 196 | for (const image of images) { 197 | const [, modifiers] = image.args 198 | const generated = sanity.getImage('image-test-300x450-png', { modifiers: { ...modifiers }, ...providerOptions }, emptyContext) 199 | expect(generated).toMatchObject(image.sanity) 200 | } 201 | }) 202 | }) 203 | -------------------------------------------------------------------------------- /docs/static/logo-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/static/logo-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/runtime/image.ts: -------------------------------------------------------------------------------- 1 | import defu from 'defu' 2 | import { hasProtocol, parseURL, joinURL, withLeadingSlash } from 'ufo' 3 | import type { ImageOptions, ImageSizesOptions, CreateImageOptions, ResolvedImage, MapToStatic, ImageCTX, $Img } from '../types/image' 4 | import { imageMeta } from './utils/meta' 5 | import { parseSize } from './utils' 6 | import { useStaticImageMap } from './utils/static-map' 7 | 8 | export function createImage (globalOptions: CreateImageOptions, nuxtContext: any) { 9 | const staticImageManifest: Record = (process.client && process.static) ? useStaticImageMap(nuxtContext) : {} 10 | 11 | const ctx: ImageCTX = { 12 | options: globalOptions, 13 | nuxtContext 14 | } 15 | 16 | const getImage: $Img['getImage'] = function (input: string, options = {}) { 17 | const image = resolveImage(ctx, input, options) 18 | if (image.isStatic) { 19 | handleStaticImage(image, input) 20 | } 21 | return image 22 | } 23 | 24 | const $img = function $img (input, modifiers = {}, options = {}) { 25 | return getImage(input, { 26 | ...options, 27 | modifiers: defu(modifiers, options.modifiers || {}) 28 | }).url 29 | } as $Img 30 | 31 | function handleStaticImage (image: ResolvedImage, input: string) { 32 | if (process.static) { 33 | if (process.client && 'fetchPayload' in window.$nuxt) { 34 | const mappedURL = staticImageManifest[image.url] 35 | image.url = mappedURL || input 36 | return image 37 | } 38 | 39 | if (process.server) { 40 | const { ssrContext } = ctx.nuxtContext 41 | if (ssrContext) { 42 | const ssrState = ssrContext.nuxt || {} 43 | const staticImages = ssrState._img = ssrState._img || {} 44 | const ssrData = ssrState.data?.[0] 45 | if (ssrData) { 46 | ssrData._img = staticImages 47 | } 48 | const mapToStatic: MapToStatic = ssrContext.image?.mapToStatic 49 | if (typeof mapToStatic === 'function') { 50 | const mappedURL = mapToStatic(image, input) 51 | if (mappedURL) { 52 | staticImages[image.url] = mappedURL 53 | image.url = mappedURL 54 | } 55 | } 56 | } 57 | } 58 | } else if (process.env.NODE_ENV === 'production') { 59 | image.url = input 60 | } 61 | } 62 | 63 | for (const presetName in globalOptions.presets) { 64 | $img[presetName] = ((source, modifiers, options) => 65 | $img(source, modifiers, { ...globalOptions.presets[presetName], ...options })) as $Img[string] 66 | } 67 | 68 | $img.options = globalOptions 69 | $img.getImage = getImage 70 | $img.getMeta = ((input: string, options?: ImageOptions) => getMeta(ctx, input, options)) as $Img['getMeta'] 71 | $img.getSizes = ((input: string, options: ImageSizesOptions) => getSizes(ctx, input, options)) as $Img['getSizes'] 72 | 73 | ctx.$img = $img as $Img 74 | 75 | return $img 76 | } 77 | 78 | async function getMeta (ctx: ImageCTX, input: string, options?: ImageOptions) { 79 | const image = resolveImage(ctx, input, { ...options }) 80 | 81 | if (typeof image.getMeta === 'function') { 82 | return await image.getMeta() 83 | } else { 84 | return await imageMeta(ctx, image.url) 85 | } 86 | } 87 | 88 | function resolveImage (ctx: ImageCTX, input: string, options: ImageOptions): ResolvedImage { 89 | if (typeof input !== 'string' || input === '') { 90 | throw new TypeError(`input must be a string (received ${typeof input}: ${JSON.stringify(input)})`) 91 | } 92 | 93 | if (input.startsWith('data:')) { 94 | return { 95 | url: input 96 | } 97 | } 98 | 99 | const { provider, defaults } = getProvider(ctx, options.provider || ctx.options.provider) 100 | const preset = getPreset(ctx, options.preset) 101 | 102 | // Normalize input with leading slash 103 | input = hasProtocol(input) ? input : withLeadingSlash(input) 104 | 105 | // Resolve alias if provider is not ipx 106 | if (!provider.supportsAlias) { 107 | for (const base in ctx.options.alias) { 108 | if (input.startsWith(base)) { 109 | input = joinURL(ctx.options.alias[base], input.substr(base.length)) 110 | } 111 | } 112 | } 113 | 114 | // Externalize remote images if domain does not match with `domains` 115 | if (provider.validateDomains && hasProtocol(input)) { 116 | const inputHost = parseURL(input).host 117 | // Domains are normalized to hostname in module 118 | if (!ctx.options.domains.find(d => d === inputHost)) { 119 | return { 120 | url: input 121 | } 122 | } 123 | } 124 | 125 | const _options: ImageOptions = defu(options, preset, defaults) 126 | _options.modifiers = { ..._options.modifiers } 127 | const expectedFormat = _options.modifiers.format 128 | 129 | if (_options.modifiers?.width) { 130 | _options.modifiers.width = parseSize(_options.modifiers.width) 131 | } 132 | if (_options.modifiers?.height) { 133 | _options.modifiers.height = parseSize(_options.modifiers.height) 134 | } 135 | 136 | const image = provider.getImage(input, _options, ctx) 137 | 138 | image.format = image.format || expectedFormat || '' 139 | 140 | return image 141 | } 142 | 143 | function getProvider (ctx: ImageCTX, name: string): ImageCTX['options']['providers'][0] { 144 | const provider = ctx.options.providers[name] 145 | if (!provider) { 146 | throw new Error('Unknown provider: ' + name) 147 | } 148 | return provider 149 | } 150 | 151 | function getPreset (ctx: ImageCTX, name?: string): ImageOptions { 152 | if (!name) { 153 | return {} 154 | } 155 | if (!ctx.options.presets[name]) { 156 | throw new Error('Unknown preset: ' + name) 157 | } 158 | return ctx.options.presets[name] 159 | } 160 | 161 | function getSizes (ctx: ImageCTX, input: string, opts: ImageSizesOptions) { 162 | const width = parseSize(opts.modifiers?.width) 163 | const height = parseSize(opts.modifiers?.height) 164 | const hwRatio = (width && height) ? height / width : 0 165 | const variants = [] 166 | 167 | const sizes: Record = {} 168 | 169 | // string => object 170 | if (typeof opts.sizes === 'string') { 171 | for (const entry of opts.sizes.split(/[\s,]+/).filter(e => e)) { 172 | const s = entry.split(':') 173 | if (s.length !== 2) { continue } 174 | sizes[s[0].trim()] = s[1].trim() 175 | } 176 | } else { 177 | Object.assign(sizes, opts.sizes) 178 | } 179 | 180 | for (const key in sizes) { 181 | const screenMaxWidth = (ctx.options.screens && ctx.options.screens[key]) || parseInt(key) 182 | let size = String(sizes[key]) 183 | const isFluid = size.endsWith('vw') 184 | if (!isFluid && /^\d+$/.test(size)) { 185 | size = size + 'px' 186 | } 187 | if (!isFluid && !size.endsWith('px')) { 188 | continue 189 | } 190 | let _cWidth = parseInt(size) 191 | if (!screenMaxWidth || !_cWidth) { 192 | continue 193 | } 194 | if (isFluid) { 195 | _cWidth = Math.round((_cWidth / 100) * screenMaxWidth) 196 | } 197 | const _cHeight = hwRatio ? Math.round(_cWidth * hwRatio) : height 198 | variants.push({ 199 | width: _cWidth, 200 | size, 201 | screenMaxWidth, 202 | media: `(max-width: ${screenMaxWidth}px)`, 203 | src: ctx.$img!(input, { ...opts.modifiers, width: _cWidth, height: _cHeight }, opts) 204 | }) 205 | } 206 | 207 | variants.sort((v1, v2) => v1.screenMaxWidth - v2.screenMaxWidth) 208 | 209 | const defaultVar = variants[variants.length - 1] 210 | if (defaultVar) { 211 | defaultVar.media = '' 212 | } 213 | 214 | return { 215 | sizes: variants.map(v => `${v.media ? v.media + ' ' : ''}${v.size}`).join(', '), 216 | srcset: variants.map(v => `${v.src} ${v.width}w`).join(', '), 217 | src: defaultVar?.src 218 | } 219 | } 220 | --------------------------------------------------------------------------------