Next Plain Image
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React from 'react' | |
import { | |
ImageConfig, | |
imageConfigDefault, | |
LoaderValue, | |
VALID_LOADERS, | |
} from 'next/dist/server/image-config' | |
if (typeof window === 'undefined') { | |
;(global as any).__NEXT_IMAGE_IMPORTED = true | |
} | |
const VALID_LOADING_VALUES = ['lazy', 'eager', undefined] as const | |
type LoadingValue = typeof VALID_LOADING_VALUES[number] | |
export type ImageLoader = (resolverProps: ImageLoaderProps) => string | |
export type ImageLoaderProps = { | |
src: string | |
width: number | |
quality?: number | |
} | |
type DefaultImageLoaderProps = ImageLoaderProps & { root: string } | |
const loaders = new Map< | |
LoaderValue, | |
(props: DefaultImageLoaderProps) => string | |
>([ | |
['default', defaultLoader], | |
['imgix', imgixLoader], | |
['cloudinary', cloudinaryLoader], | |
['akamai', akamaiLoader], | |
['custom', customLoader], | |
]) | |
const VALID_LAYOUT_VALUES = [ | |
'fill', | |
'fixed', | |
'intrinsic', | |
'responsive', | |
undefined, | |
] as const | |
type LayoutValue = typeof VALID_LAYOUT_VALUES[number] | |
type PlaceholderValue = 'blur' | 'empty' | |
type OnLoadingComplete = (result: { | |
naturalWidth: number | |
naturalHeight: number | |
}) => void | |
type ImgElementStyle = NonNullable<JSX.IntrinsicElements['img']['style']> | |
interface StaticRequire { | |
default: StaticImageData | |
} | |
type StaticImport = StaticRequire | StaticImageData | |
function isStaticRequire( | |
src: StaticRequire | StaticImageData, | |
): src is StaticRequire { | |
return (src as StaticRequire).default !== undefined | |
} | |
function isStaticImageData( | |
src: StaticRequire | StaticImageData, | |
): src is StaticImageData { | |
return (src as StaticImageData).src !== undefined | |
} | |
function isStaticImport(src: string | StaticImport): src is StaticImport { | |
return ( | |
typeof src === 'object' && | |
(isStaticRequire(src as StaticImport) || | |
isStaticImageData(src as StaticImport)) | |
) | |
} | |
export type ImageProps = Omit< | |
JSX.IntrinsicElements['img'], | |
'src' | 'srcSet' | 'ref' | 'width' | 'height' | 'loading' | 'style' | |
> & { | |
src: string | StaticImport | |
width?: number | string | |
height?: number | string | |
layout?: LayoutValue | |
loader?: ImageLoader | |
quality?: number | string | |
priority?: boolean | |
loading?: LoadingValue | |
lazyBoundary?: string | |
placeholder?: PlaceholderValue | |
blurDataURL?: string | |
unoptimized?: boolean | |
objectFit?: ImgElementStyle['objectFit'] | |
objectPosition?: ImgElementStyle['objectPosition'] | |
onLoadingComplete?: OnLoadingComplete | |
} | |
const { | |
deviceSizes: configDeviceSizes, | |
imageSizes: configImageSizes, | |
loader: configLoader, | |
path: configPath, | |
domains: configDomains, | |
} = | |
((process.env.__NEXT_IMAGE_OPTS as any) as ImageConfig) || imageConfigDefault | |
// sort smallest to largest | |
const allSizes = [...configDeviceSizes, ...configImageSizes] | |
configDeviceSizes.sort((a, b) => a - b) | |
allSizes.sort((a, b) => a - b) | |
function getWidths( | |
width: number | undefined, | |
layout: LayoutValue, | |
sizes: string | undefined, | |
): { widths: number[]; kind: 'w' | 'x' } { | |
if (sizes && (layout === 'fill' || layout === 'responsive')) { | |
// Find all the "vw" percent sizes used in the sizes prop | |
const viewportWidthRe = /(^|\s)(1?\d?\d)vw/g | |
const percentSizes = [] | |
for (let match; (match = viewportWidthRe.exec(sizes)); match) { | |
percentSizes.push(parseInt(match[2])) | |
} | |
if (percentSizes.length) { | |
const smallestRatio = Math.min(...percentSizes) * 0.01 | |
return { | |
widths: allSizes.filter(s => s >= configDeviceSizes[0] * smallestRatio), | |
kind: 'w', | |
} | |
} | |
return { widths: allSizes, kind: 'w' } | |
} | |
if ( | |
typeof width !== 'number' || | |
layout === 'fill' || | |
layout === 'responsive' | |
) { | |
return { widths: configDeviceSizes, kind: 'w' } | |
} | |
const widths = [ | |
...new Set( | |
// > This means that most OLED screens that say they are 3x resolution, | |
// > are actually 3x in the green color, but only 1.5x in the red and | |
// > blue colors. Showing a 3x resolution image in the app vs a 2x | |
// > resolution image will be visually the same, though the 3x image | |
// > takes significantly more data. Even true 3x resolution screens are | |
// > wasteful as the human eye cannot see that level of detail without | |
// > something like a magnifying glass. | |
// https://blog.twitter.com/engineering/en_us/topics/infrastructure/2019/capping-image-fidelity-on-ultra-high-resolution-devices.html | |
[width, width * 2 /*, width * 3*/].map( | |
w => allSizes.find(p => p >= w) || allSizes[allSizes.length - 1], | |
), | |
), | |
] | |
return { widths, kind: 'x' } | |
} | |
type GenImgAttrsData = { | |
src: string | |
unoptimized: boolean | |
layout: LayoutValue | |
loader: ImageLoader | |
width?: number | |
quality?: number | |
sizes?: string | |
} | |
type GenImgAttrsResult = { | |
src: string | |
srcSet: string | undefined | |
sizes: string | undefined | |
} | |
function generateImgAttrs({ | |
src, | |
unoptimized, | |
layout, | |
width, | |
quality, | |
sizes, | |
loader, | |
}: GenImgAttrsData): GenImgAttrsResult { | |
if (unoptimized) { | |
return { src, srcSet: undefined, sizes: undefined } | |
} | |
const { widths, kind } = getWidths(width, layout, sizes) | |
const last = widths.length - 1 | |
return { | |
sizes: !sizes && kind === 'w' ? '100vw' : sizes, | |
srcSet: widths | |
.map( | |
(w, i) => | |
`${loader({ src, quality, width: w })} ${ | |
kind === 'w' ? w : i + 1 | |
}${kind}`, | |
) | |
.join(', '), | |
// It's intended to keep `src` the last attribute because React updates | |
// attributes in order. If we keep `src` the first one, Safari will | |
// immediately start to fetch `src`, before `sizes` and `srcSet` are even | |
// updated by React. That causes multiple unnecessary requests if `srcSet` | |
// and `sizes` are defined. | |
// This bug cannot be reproduced in Chrome or Firefox. | |
src: loader({ src, quality, width: widths[last] }), | |
} | |
} | |
function getInt(x: unknown): number | undefined { | |
if (typeof x === 'number') { | |
return x | |
} | |
if (typeof x === 'string') { | |
return parseInt(x, 10) | |
} | |
return undefined | |
} | |
function defaultImageLoader(loaderProps: ImageLoaderProps) { | |
const load = loaders.get(configLoader) | |
if (load) { | |
return load({ root: configPath, ...loaderProps }) | |
} | |
throw new Error( | |
`Unknown "loader" found in "next.config.js". Expected: ${VALID_LOADERS.join( | |
', ', | |
)}. Received: ${configLoader}`, | |
) | |
} | |
export default function Image({ | |
src, | |
sizes, | |
unoptimized = false, | |
priority = false, | |
loading, | |
lazyBoundary = '200px', | |
className, | |
quality, | |
width, | |
height, | |
objectFit, | |
objectPosition, | |
onLoadingComplete, | |
loader = defaultImageLoader, | |
placeholder = 'empty', | |
blurDataURL, | |
...all | |
}: ImageProps) { | |
let rest: Partial<ImageProps> = all | |
let layout: NonNullable<LayoutValue> = sizes ? 'responsive' : 'intrinsic' | |
let staticSrc = '' | |
if (isStaticImport(src)) { | |
const staticImageData = isStaticRequire(src) ? src.default : src | |
blurDataURL = blurDataURL || staticImageData.blurDataURL | |
staticSrc = staticImageData.src | |
if (!layout || layout !== 'fill') { | |
height = height || staticImageData.height | |
width = width || staticImageData.width | |
} | |
} | |
src = typeof src === 'string' ? src : staticSrc | |
const widthInt = getInt(width) | |
const qualityInt = getInt(quality) | |
return ( | |
<> | |
<img | |
{...rest} | |
{...generateImgAttrs({ | |
src, | |
unoptimized, | |
layout, | |
width: widthInt, | |
quality: qualityInt, | |
sizes, | |
loader, | |
})} | |
className={className} | |
/> | |
</> | |
) | |
} | |
function normalizeSrc(src: string): string { | |
return src[0] === '/' ? src.slice(1) : src | |
} | |
function imgixLoader({ | |
root, | |
src, | |
width, | |
quality, | |
}: DefaultImageLoaderProps): string { | |
// Demo: https://static.imgix.net/daisy.png?auto=format&fit=max&w=300 | |
const url = new URL(`${root}${normalizeSrc(src)}`) | |
const params = url.searchParams | |
params.set('auto', params.get('auto') || 'format') | |
params.set('fit', params.get('fit') || 'max') | |
params.set('w', params.get('w') || width.toString()) | |
if (quality) { | |
params.set('q', quality.toString()) | |
} | |
return url.href | |
} | |
function akamaiLoader({ root, src, width }: DefaultImageLoaderProps): string { | |
return `${root}${normalizeSrc(src)}?imwidth=${width}` | |
} | |
function cloudinaryLoader({ | |
root, | |
src, | |
width, | |
quality, | |
}: DefaultImageLoaderProps): string { | |
// Demo: https://res.cloudinary.com/demo/image/upload/w_300,c_limit,q_auto/turtles.jpg | |
const params = ['f_auto', 'c_limit', 'w_' + width, 'q_' + (quality || 'auto')] | |
let paramsString = params.join(',') + '/' | |
return `${root}${paramsString}${normalizeSrc(src)}` | |
} | |
function customLoader({ src }: DefaultImageLoaderProps): string { | |
throw new Error( | |
`Image with src "${src}" is missing "loader" prop.` + | |
`\nRead more: https://nextjs.org/docs/messages/next-image-missing-loader`, | |
) | |
} | |
function defaultLoader({ | |
root, | |
src, | |
width, | |
quality, | |
}: DefaultImageLoaderProps): string { | |
if (process.env.NODE_ENV !== 'production') { | |
const missingValues = [] | |
// these should always be provided but make sure they are | |
if (!src) missingValues.push('src') | |
if (!width) missingValues.push('width') | |
if (missingValues.length > 0) { | |
throw new Error( | |
`Next Image Optimization requires ${missingValues.join( | |
', ', | |
)} to be provided. Make sure you pass them as props to the \`next/image\` component. Received: ${JSON.stringify( | |
{ src, width, quality }, | |
)}`, | |
) | |
} | |
if (src.startsWith('//')) { | |
throw new Error( | |
`Failed to parse src "${src}" on \`next/image\`, protocol-relative URL (//) must be changed to an absolute URL (http:// or https://)`, | |
) | |
} | |
if (!src.startsWith('/') && configDomains) { | |
let parsedSrc: URL | |
try { | |
parsedSrc = new URL(src) | |
} catch (err) { | |
console.error(err) | |
throw new Error( | |
`Failed to parse src "${src}" on \`next/image\`, if using relative image it must start with a leading slash "/" or be an absolute URL (http:// or https://)`, | |
) | |
} | |
if ( | |
process.env.NODE_ENV !== 'test' && | |
!configDomains.includes(parsedSrc.hostname) | |
) { | |
throw new Error( | |
`Invalid src prop (${src}) on \`next/image\`, hostname "${parsedSrc.hostname}" is not configured under images in your \`next.config.js\`\n` + | |
`See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host`, | |
) | |
} | |
} | |
} | |
return `${root}?url=${encodeURIComponent(src)}&w=${width}&q=${quality || 75}` | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment