diff --git a/src/module.ts b/src/module.ts index 3ff65a943..a1c35a064 100644 --- a/src/module.ts +++ b/src/module.ts @@ -102,6 +102,11 @@ export default defineNuxtModule({ from: resolver.resolve('runtime/composables') }) + addImports({ + name: 'useBackgroundImage', + from: resolver.resolve('runtime/composables') + }) + // Add components addComponent({ name: 'NuxtImg', diff --git a/src/runtime/composables.ts b/src/runtime/composables.ts index 1e014859b..f4f036432 100644 --- a/src/runtime/composables.ts +++ b/src/runtime/composables.ts @@ -1,9 +1,9 @@ -import type { $Img } from '../types' - +import type { $Img, ImageSizesOptions } from '../types' +import { generateRandomString } from './utils' import { createImage } from './image' // @ts-expect-error virtual file import { imageOptions } from '#build/image-options' -import { useNuxtApp, useRuntimeConfig } from '#imports' +import { useNuxtApp, useRuntimeConfig, useHead } from '#imports' export const useImage = (): $Img => { const config = useRuntimeConfig() @@ -16,3 +16,68 @@ export const useImage = (): $Img => { } })) } + +export const useBackgroundImage = ( + src: string, + options: Partial & { preload?: boolean; nonce?: string } +) => { + const $img = useImage() + const { sizes: bgSizes, imagesizes, imagesrcset } = $img.getBgSizes(src, options) + + // Use this to prevent different class names on client and server + const classStates = useState>('_nuxt-img-bg', () => ({})) + // TODO: handle Map item type + const toCSS = (bgs: any[]) => { + const imageSets = bgs.map((bg) => { + const density = bg.density ? `${bg.density}x` : '' + const type = bg.type ? `type("${bg.type}")` : '' + return `url('${bg.src}') ${density} ${type}` + }) + return `background-image: url(${ + bgs[0].src + });background-image: image-set(${imageSets.join(', ')});` + } + const placeholder = '[placeholder]' + + let css = Array.from(bgSizes) + .reverse() + .map(([key, value]) => { + if (key === 'default') { + return value ? `.${placeholder}{${toCSS(value)}}` : '' + } else { + return `@media (max-width: ${key}px) { .${placeholder} { ${toCSS(value)} } }` + } + }).join(' ') + + // use generated css as key + let cls = '' + if (classStates.value[css]) { + cls = classStates.value[css] + } else { + cls = 'nuxt-bg-' + generateRandomString() + classStates.value[css] = cls + } + css = css.replace(/\[placeholder\]/gm, cls) + + if (options.preload) { + useHead({ + link: [ + { + rel: 'preload', + as: 'image', + nonce: options.nonce, + href: bgSizes.get('default')?.[0].src, + ...(bgSizes.size > 1 ? { imagesizes, imagesrcset } : {}) + } + ] + }) + } + + useHead({ + style: [ + { key: cls, innerHTML: css } + ] + }) + + return cls +} diff --git a/src/runtime/image.ts b/src/runtime/image.ts index a304fcc55..2ef861fc0 100644 --- a/src/runtime/image.ts +++ b/src/runtime/image.ts @@ -1,6 +1,16 @@ import { defu } from 'defu' import { hasProtocol, parseURL, joinURL, withLeadingSlash } from 'ufo' -import type { ImageOptions, ImageSizesOptions, CreateImageOptions, ResolvedImage, ImageCTX, $Img, ImageSizes, ImageSizesVariant } from '../types/image' +import type { + ImageOptions, + ImageSizesOptions, + CreateImageOptions, + ResolvedImage, + ImageCTX, + $Img, + ImageSizes, + ImageSizesVariant, + BgImageSizes +} from '../types/image' import { imageMeta } from './utils/meta' import { checkDensities, parseDensities, parseSize, parseSizes } from './utils' import { prerenderStaticImages } from './utils/prerender' @@ -20,6 +30,9 @@ export function createImage (globalOptions: CreateImageOptions) { return image } + const getBgSizes: $Img['getBgSizes'] = (input, options = {}) => { + return createBgSizes(ctx, input, options) + } const $img = ((input, modifiers = {}, options = {}) => { return getImage(input, { @@ -35,6 +48,7 @@ export function createImage (globalOptions: CreateImageOptions) { $img.options = globalOptions $img.getImage = getImage + $img.getBgSizes = getBgSizes $img.getMeta = ((input: string, options?: ImageOptions) => getMeta(ctx, input, options)) as $Img['getMeta'] $img.getSizes = ((input: string, options: ImageSizesOptions) => getSizes(ctx, input, options)) as $Img['getSizes'] @@ -268,3 +282,93 @@ function finaliseSrcsetVariants (srcsetVariants: any[]) { previousWidth = sizeVariant.width } } + +export function createBgSizes ( + ctx: ImageCTX, + input: string, + opts: Partial +): { imagesrcset: string, imagesizes: string, sizes: BgImageSizes } { + const width = parseSize(opts.modifiers?.width) + const height = parseSize(opts.modifiers?.height) + const sizes = parseSizes(opts.sizes || {}) + const hwRatio = width && height ? height / width : 0 + const variants = Object.entries(sizes) + .map(([key, size]) => { + return getSizesVariant(key, String(size), height, hwRatio, ctx) + }) + .filter((v) => { + return v !== undefined + }) as ImageSizesVariant[] + + // sort by screenMaxWidth (ascending) + variants.sort((v1, v2) => v1.screenMaxWidth - v2.screenMaxWidth) + + const densities = opts.densities?.trim() + ? parseDensities(opts.densities.trim()) + : ctx.options.densities + // sort densities ascending + densities.sort((a, b) => a - b) + checkDensities(densities) + + const quality = opts.modifiers?.quality ? opts.modifiers.quality : ctx.options.quality + + const result: BgImageSizes = new Map() + const imagesizesList: string[] = [] + + variants.forEach((variant, i) => { + const bpsWidth = String(variants[i + 1]?.screenMaxWidth || 'default') + if (result.has(bpsWidth)) { + return + } + if (bpsWidth === 'default') { + imagesizesList.push(`${variant._cWidth}px`) + } else { + imagesizesList.push(`(max-width: ${bpsWidth}px) ${variant._cWidth}px`) + } + result.set( + bpsWidth, + densities.map((d) => { + return { + src: getVariantSrc(ctx, input, { + ...opts, + modifiers: { + ...opts.modifiers, + quality + } + } as ImageSizesOptions, variant, d), + density: d.toString(), + type: opts.modifiers?.format ? `image/${opts.modifiers.format}` : '', + _cWidth: variant._cWidth * d + } + }) + ) + }) + if (result.size === 0) { + result.set( + 'default', + densities.map((d) => { + return { + src: ctx.$img!( + input, + { + ...opts.modifiers, + quality, + width: width ? width * d : undefined, + height: height ? height * d : undefined + }, + opts), + density: d.toString(), + type: opts.modifiers?.format ? `image/${opts.modifiers.format}` : '', + _cWidth: width ? width * d : undefined + } + }) + ) + } + // TODO: prerender static images: just add common version of current function + + return { + imagesizes: imagesizesList.join(', '), + imagesrcset: Array.from(result).map(([_, value]) => value.map(v => `${v.src} ${v._cWidth}w`).join(', ')).join(', '), + sizes: result + } +} diff --git a/src/runtime/utils/index.ts b/src/runtime/utils/index.ts index 3f4532fac..891568243 100644 --- a/src/runtime/utils/index.ts +++ b/src/runtime/utils/index.ts @@ -133,3 +133,12 @@ export function parseSizes (input: Record | string): Re } return sizes } + +export function generateRandomString (length: number = 6): string { + let result = '' + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * characters.length)) + } + return result +} diff --git a/src/types/image.ts b/src/types/image.ts index fd8ed6805..db9d1cae0 100644 --- a/src/types/image.ts +++ b/src/types/image.ts @@ -66,12 +66,20 @@ export interface ImageSizes { src: string } +export type BgImageSizes = Map + export interface Img { (source: string, modifiers?: ImageOptions['modifiers'], options?: ImageOptions): ResolvedImage['url'] options: CreateImageOptions getImage: (source: string, options?: ImageOptions) => ResolvedImage getSizes: (source: string, options?: ImageOptions, sizes?: string[]) => ImageSizes getMeta: (source: string, options?: ImageOptions) => Promise + getBgSizes: (source: string, options?: Partial) => { imagesrcset: string, imagesizes: string, sizes: BgImageSizes } } export type $Img = Img & { diff --git a/test/unit/backgroundImage.test.ts b/test/unit/backgroundImage.test.ts new file mode 100644 index 000000000..ac2503754 --- /dev/null +++ b/test/unit/backgroundImage.test.ts @@ -0,0 +1,271 @@ +// @vitest-environment nuxt + +import type { Mock } from 'vitest' +import { beforeEach, describe, it, expect, vi } from 'vitest' +import { mockNuxtImport } from '@nuxt/test-utils/runtime' +// @ts-expect-error virtual file +import { imageOptions } from '#build/image-options' +import { createImage } from '#image' +import { useBackgroundImage, useHead, useNuxtApp } from '#imports' + +mockNuxtImport('useHead', () => { + return vi.fn() +}) +describe('Renders simple image', () => { + const src = '/image.png' + + beforeEach(() => { + (useHead as Mock).mockClear() + }) + + it('only src', () => { + const cls = useBackgroundImage(src, {}) + expect(useHead).toBeCalledWith({ + style: [ + { + key: cls, + innerHTML: `.${cls}{background-image: url(/_ipx/_/image.png);background-image: image-set(url('/_ipx/_/image.png') 1x , url('/_ipx/_/image.png') 2x );}` + } + ] + }) + }) + + it('applies sizes', () => { + const cls = useBackgroundImage(src, { sizes: '200,500:500,900:900' }) + expect(useHead).toBeCalledWith({ + style: [ + { + key: cls, + innerHTML: `.${cls}{background-image: url(/_ipx/w_900/image.png);background-image: image-set(url('/_ipx/w_900/image.png') 1x , url('/_ipx/w_1800/image.png') 2x );} @media (max-width: 900px) { .${cls} { background-image: url(/_ipx/w_500/image.png);background-image: image-set(url('/_ipx/w_500/image.png') 1x , url('/_ipx/w_1000/image.png') 2x ); } } @media (max-width: 500px) { .${cls} { background-image: url(/_ipx/w_200/image.png);background-image: image-set(url('/_ipx/w_200/image.png') 1x , url('/_ipx/w_400/image.png') 2x ); } }` + } + ] + }) + }) + + it('applies densities', () => { + const cls = useBackgroundImage(src, { + sizes: '200,500:500,900:900', + densities: '1x 2x 3x' + }) + + expect(useHead).toBeCalledWith({ + style: [ + { + key: cls, + innerHTML: `.${cls}{background-image: url(/_ipx/w_900/image.png);background-image: image-set(url('/_ipx/w_900/image.png') 1x , url('/_ipx/w_1800/image.png') 2x , url('/_ipx/w_2700/image.png') 3x );} @media (max-width: 900px) { .${cls} { background-image: url(/_ipx/w_500/image.png);background-image: image-set(url('/_ipx/w_500/image.png') 1x , url('/_ipx/w_1000/image.png') 2x , url('/_ipx/w_1500/image.png') 3x ); } } @media (max-width: 500px) { .${cls} { background-image: url(/_ipx/w_200/image.png);background-image: image-set(url('/_ipx/w_200/image.png') 1x , url('/_ipx/w_400/image.png') 2x , url('/_ipx/w_600/image.png') 3x ); } }` + } + ] + }) + }) + + it('empty densities (fallback to global)', () => { + const cls = useBackgroundImage(src, { + sizes: '200,500:500,900:900', + densities: '' + }) + + expect(useHead).toBeCalledWith({ + style: [ + { + key: cls, + innerHTML: `.${cls}{background-image: url(/_ipx/w_900/image.png);background-image: image-set(url('/_ipx/w_900/image.png') 1x , url('/_ipx/w_1800/image.png') 2x );} @media (max-width: 900px) { .${cls} { background-image: url(/_ipx/w_500/image.png);background-image: image-set(url('/_ipx/w_500/image.png') 1x , url('/_ipx/w_1000/image.png') 2x ); } } @media (max-width: 500px) { .${cls} { background-image: url(/_ipx/w_200/image.png);background-image: image-set(url('/_ipx/w_200/image.png') 1x , url('/_ipx/w_400/image.png') 2x ); } }` + } + ] + }) + }) + + it('empty string densities (fallback to global)', () => { + const cls = useBackgroundImage(src, { + sizes: '200,500:500,900:900', + densities: ' ' + }) + + expect(useHead).toBeCalledWith({ + style: [ + { + key: cls, + innerHTML: `.${cls}{background-image: url(/_ipx/w_900/image.png);background-image: image-set(url('/_ipx/w_900/image.png') 1x , url('/_ipx/w_1800/image.png') 2x );} @media (max-width: 900px) { .${cls} { background-image: url(/_ipx/w_500/image.png);background-image: image-set(url('/_ipx/w_500/image.png') 1x , url('/_ipx/w_1000/image.png') 2x ); } } @media (max-width: 500px) { .${cls} { background-image: url(/_ipx/w_200/image.png);background-image: image-set(url('/_ipx/w_200/image.png') 1x , url('/_ipx/w_400/image.png') 2x ); } }` + } + ] + }) + }) + + it('error on invalid densities', () => { + expect(() => + useBackgroundImage(src, { sizes: '200,500:500,900:900', densities: 'x' }) + ).toThrow(Error) + }) + + it('with single sizes entry', () => { + const cls = useBackgroundImage(src, { + sizes: '150', + modifiers: { width: 300, height: 400 } + }) + expect(useHead).toBeCalledWith({ + style: [ + { + key: cls, + innerHTML: `.${cls}{background-image: url(/_ipx/s_150x200/image.png);background-image: image-set(url('/_ipx/s_150x200/image.png') 1x , url('/_ipx/s_300x400/image.png') 2x );}` + } + ] + }) + }) + + it('with single sizes entry (responsive)', () => { + const cls = useBackgroundImage(src, { + sizes: 'sm:150', + modifiers: { width: 300, height: 400 } + }) + expect(useHead).toBeCalledWith({ + style: [ + { + key: cls, + innerHTML: `.${cls}{background-image: url(/_ipx/s_150x200/image.png);background-image: image-set(url('/_ipx/s_150x200/image.png') 1x , url('/_ipx/s_300x400/image.png') 2x );}` + } + ] + }) + }) + + it('de-duplicates sizes', () => { + const cls = useBackgroundImage(src, { + sizes: '200:200px,300:200px,400:400px,400:400px,500:500px,800:800px', + modifiers: { width: 200, height: 300 } + }) + + expect(useHead).toBeCalledWith({ + style: [ + { + key: cls, + innerHTML: `.${cls}{background-image: url(/_ipx/s_800x1200/image.png);background-image: image-set(url('/_ipx/s_800x1200/image.png') 1x , url('/_ipx/s_1600x2400/image.png') 2x );} @media (max-width: 800px) { .${cls} { background-image: url(/_ipx/s_500x750/image.png);background-image: image-set(url('/_ipx/s_500x750/image.png') 1x , url('/_ipx/s_1000x1500/image.png') 2x ); } } @media (max-width: 500px) { .${cls} { background-image: url(/_ipx/s_400x600/image.png);background-image: image-set(url('/_ipx/s_400x600/image.png') 1x , url('/_ipx/s_800x1200/image.png') 2x ); } } @media (max-width: 400px) { .${cls} { background-image: url(/_ipx/s_200x300/image.png);background-image: image-set(url('/_ipx/s_200x300/image.png') 1x , url('/_ipx/s_400x600/image.png') 2x ); } } @media (max-width: 300px) { .${cls} { background-image: url(/_ipx/s_200x300/image.png);background-image: image-set(url('/_ipx/s_200x300/image.png') 1x , url('/_ipx/s_400x600/image.png') 2x ); } }` + } + ] + }) + }) + + it('encodes characters', () => { + const cls = useBackgroundImage('/汉字.png', { + sizes: '200,500:500,900:900' + }) + + expect(useHead).toBeCalledWith({ + style: [ + { + key: cls, + innerHTML: `.${cls}{background-image: url(/_ipx/w_900/%E6%B1%89%E5%AD%97.png);background-image: image-set(url('/_ipx/w_900/%E6%B1%89%E5%AD%97.png') 1x , url('/_ipx/w_1800/%E6%B1%89%E5%AD%97.png') 2x );} @media (max-width: 900px) { .${cls} { background-image: url(/_ipx/w_500/%E6%B1%89%E5%AD%97.png);background-image: image-set(url('/_ipx/w_500/%E6%B1%89%E5%AD%97.png') 1x , url('/_ipx/w_1000/%E6%B1%89%E5%AD%97.png') 2x ); } } @media (max-width: 500px) { .${cls} { background-image: url(/_ipx/w_200/%E6%B1%89%E5%AD%97.png);background-image: image-set(url('/_ipx/w_200/%E6%B1%89%E5%AD%97.png') 1x , url('/_ipx/w_400/%E6%B1%89%E5%AD%97.png') 2x ); } }` + } + ] + }) + }) + + it('correctly sets crop', () => { + const cls = useBackgroundImage('/image.png', { + modifiers: { + width: 1000, + height: 2000 + }, + sizes: 'xs:100vw sm:100vw md:300px lg:350px xl:350px 2xl:350px' + }) + + expect(useHead).toBeCalledWith({ + style: [ + { + key: cls, + innerHTML: `.${cls}{background-image: url(/_ipx/s_350x700/image.png);background-image: image-set(url('/_ipx/s_350x700/image.png') 1x , url('/_ipx/s_700x1400/image.png') 2x );} @media (max-width: 1536px) { .${cls} { background-image: url(/_ipx/s_350x700/image.png);background-image: image-set(url('/_ipx/s_350x700/image.png') 1x , url('/_ipx/s_700x1400/image.png') 2x ); } } @media (max-width: 1280px) { .${cls} { background-image: url(/_ipx/s_350x700/image.png);background-image: image-set(url('/_ipx/s_350x700/image.png') 1x , url('/_ipx/s_700x1400/image.png') 2x ); } } @media (max-width: 1024px) { .${cls} { background-image: url(/_ipx/s_300x600/image.png);background-image: image-set(url('/_ipx/s_300x600/image.png') 1x , url('/_ipx/s_600x1200/image.png') 2x ); } } @media (max-width: 768px) { .${cls} { background-image: url(/_ipx/s_640x1280/image.png);background-image: image-set(url('/_ipx/s_640x1280/image.png') 1x , url('/_ipx/s_1280x2560/image.png') 2x ); } } @media (max-width: 640px) { .${cls} { background-image: url(/_ipx/s_320x640/image.png);background-image: image-set(url('/_ipx/s_320x640/image.png') 1x , url('/_ipx/s_640x1280/image.png') 2x ); } }` + } + ] + }) + }) + + it('without sizes, but densities', () => { + const cls = useBackgroundImage(src, { + modifiers: { width: 300, height: 400 }, + densities: '1x 2x 3x' + }) + expect(useHead).toBeCalledWith({ + style: [ + { + key: cls, + innerHTML: `.${cls}{background-image: url(/_ipx/s_300x400/image.png);background-image: image-set(url('/_ipx/s_300x400/image.png') 1x , url('/_ipx/s_600x800/image.png') 2x , url('/_ipx/s_900x1200/image.png') 3x );}` + } + ] + }) + }) +}) + +describe('Renders image, applies module config', () => { + const nuxtApp = useNuxtApp() + const config = useRuntimeConfig() + const src = '/image.png' + + beforeEach(() => { + delete nuxtApp._img + }) + + it('Module config .quality applies', () => { + nuxtApp._img = createImage({ + ...imageOptions, + nuxt: { + baseURL: config.app.baseURL + }, + quality: 75 + }) + const cls = useBackgroundImage(src, { + modifiers: { width: 200, height: 200 }, + sizes: '200,500:500,900:900' + }) + + expect(useHead).toBeCalledWith({ + style: [ + { + key: cls, + innerHTML: `.${cls}{background-image: url(/_ipx/q_75&s_900x900/image.png);background-image: image-set(url('/_ipx/q_75&s_900x900/image.png') 1x , url('/_ipx/q_75&s_1800x1800/image.png') 2x );} @media (max-width: 900px) { .${cls} { background-image: url(/_ipx/q_75&s_500x500/image.png);background-image: image-set(url('/_ipx/q_75&s_500x500/image.png') 1x , url('/_ipx/q_75&s_1000x1000/image.png') 2x ); } } @media (max-width: 500px) { .${cls} { background-image: url(/_ipx/q_75&s_200x200/image.png);background-image: image-set(url('/_ipx/q_75&s_200x200/image.png') 1x , url('/_ipx/q_75&s_400x400/image.png') 2x ); } }` + } + ] + }) + }) + + it('Module config .quality + props.quality => props.quality applies', () => { + nuxtApp._img = createImage({ + ...imageOptions, + nuxt: { + baseURL: config.app.baseURL + }, + quality: 75 + }) + const cls = useBackgroundImage(src, { + modifiers: { width: 200, height: 200, quality: 90 }, + sizes: '200,500:500,900:900' + }) + + expect(useHead).toBeCalledWith({ + style: [ + { + key: cls, + innerHTML: `.${cls}{background-image: url(/_ipx/q_90&s_900x900/image.png);background-image: image-set(url('/_ipx/q_90&s_900x900/image.png') 1x , url('/_ipx/q_90&s_1800x1800/image.png') 2x );} @media (max-width: 900px) { .${cls} { background-image: url(/_ipx/q_90&s_500x500/image.png);background-image: image-set(url('/_ipx/q_90&s_500x500/image.png') 1x , url('/_ipx/q_90&s_1000x1000/image.png') 2x ); } } @media (max-width: 500px) { .${cls} { background-image: url(/_ipx/q_90&s_200x200/image.png);background-image: image-set(url('/_ipx/q_90&s_200x200/image.png') 1x , url('/_ipx/q_90&s_400x400/image.png') 2x ); } }` + } + ] + }) + }) + + it('Without quality config => default image', () => { + nuxtApp._img = createImage({ + ...imageOptions, + nuxt: { + baseURL: config.app.baseURL + } + }) + const cls = useBackgroundImage(src, { + modifiers: { width: 200, height: 200 }, + sizes: '200,500:500,900:900' + }) + + expect(useHead).toBeCalledWith({ + style: [ + { + key: cls, + innerHTML: `.${cls}{background-image: url(/_ipx/s_900x900/image.png);background-image: image-set(url('/_ipx/s_900x900/image.png') 1x , url('/_ipx/s_1800x1800/image.png') 2x );} @media (max-width: 900px) { .${cls} { background-image: url(/_ipx/s_500x500/image.png);background-image: image-set(url('/_ipx/s_500x500/image.png') 1x , url('/_ipx/s_1000x1000/image.png') 2x ); } } @media (max-width: 500px) { .${cls} { background-image: url(/_ipx/s_200x200/image.png);background-image: image-set(url('/_ipx/s_200x200/image.png') 1x , url('/_ipx/s_400x400/image.png') 2x ); } }` + } + ] + }) + }) +})