From 2972f07bd322955ea8c3694b2c32b0e1bf14b784 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 16 Nov 2020 17:43:45 +0000 Subject: [PATCH] Add image helper --- e2e-tests/visual-regression/package.json | 4 +- .../src/__tests__/image-utils.ts | 88 ++++ .../__tests__/gatsby-image.server.tsx | 32 +- .../src/components/compat.browser.tsx | 6 +- .../src/components/gatsby-image.browser.tsx | 4 +- .../src/components/gatsby-image.server.tsx | 4 +- .../src/components/hooks.ts | 6 +- .../src/components/static-image.server.tsx | 4 +- .../src/components/static-image.tsx | 4 +- packages/gatsby-plugin-image/src/global.d.ts | 6 +- .../gatsby-plugin-image/src/image-utils.ts | 492 ++++++++++++++++++ .../gatsby-plugin-image/src/index.browser.ts | 2 +- packages/gatsby-plugin-image/src/index.ts | 8 +- .../gatsby-plugin-sharp/src/image-data.ts | 10 +- yarn.lock | 65 +++ 15 files changed, 693 insertions(+), 42 deletions(-) create mode 100644 packages/gatsby-plugin-image/src/__tests__/image-utils.ts create mode 100644 packages/gatsby-plugin-image/src/image-utils.ts diff --git a/e2e-tests/visual-regression/package.json b/e2e-tests/visual-regression/package.json index e5f094e56a307..da1d2784953a1 100644 --- a/e2e-tests/visual-regression/package.json +++ b/e2e-tests/visual-regression/package.json @@ -6,8 +6,8 @@ "dependencies": { "cypress": "^3.1.0", "cypress-image-snapshot": "^3.1.1", - "gatsby": "^2.0.118", - "gatsby-plugin-image": "^0.0.2", + "gatsby": "2.27.0-next.1-dev-1605519933925", + "gatsby-plugin-image": "0.2.0-next.0-dev-1605521028719", "gatsby-plugin-sharp": "^2.0.20", "gatsby-source-filesystem": "^2.3.35", "gatsby-transformer-sharp": "^2.5.19", diff --git a/packages/gatsby-plugin-image/src/__tests__/image-utils.ts b/packages/gatsby-plugin-image/src/__tests__/image-utils.ts new file mode 100644 index 0000000000000..f6379c15cc81b --- /dev/null +++ b/packages/gatsby-plugin-image/src/__tests__/image-utils.ts @@ -0,0 +1,88 @@ +import { + generateImageData, + IGatsbyImageHelperArgs, + IImage, +} from "../image-utils" + +const args: IGatsbyImageHelperArgs = { + pluginName: `gatsby-plugin-fake`, + filename: `afile.jpg`, + generateImageSource: jest.fn(), + width: 400, + sourceMetadata: { + width: 800, + height: 600, + format: `jpg`, + }, + reporter: { + warn: jest.fn(), + }, +} + +describe(`the image data helper`, () => { + beforeEach(() => { + jest.resetAllMocks() + }) + it(`throws if there's not a valid generateURL function`, () => { + const generateImageSource = `this should be a function` + + expect(() => + generateImageData(({ + ...args, + generateImageSource, + } as any) as IGatsbyImageHelperArgs) + ).toThrow() + }) + + it(`calls the generateImageSource function`, () => { + generateImageData(args) + expect(args.generateImageSource).toHaveBeenCalledWith( + `afile.jpg`, + 800, + 600, + `jpg`, + undefined, + undefined + ) + }) + + it(`returns URLs as generated`, () => { + const generateImageSource = ( + file: string, + width: number, + height: number, + format + ): IImage => { + return { + src: `https://example.com/${file}?width=${width}&height=${height}`, + width, + height, + format, + } + } + const data = generateImageData({ ...args, generateImageSource }) + expect(data).toMatchInlineSnapshot(` + Object { + "height": undefined, + "images": Object { + "fallback": Object { + "sizes": "400px", + "src": "https://example.com/afile.jpg?width=400&height=300", + "srcSet": "https://example.com/afile.jpg?width=400&height=300 400w, + https://example.com/afile.jpg?width=800&height=600 800w", + }, + "sources": Array [ + Object { + "sizes": "400px", + "srcSet": "https://example.com/afile.jpg?width=400&height=300 400w, + https://example.com/afile.jpg?width=800&height=600 800w", + "type": "image/webp", + }, + ], + }, + "layout": "fixed", + "width": 400, + } + `) + }) +}) diff --git a/packages/gatsby-plugin-image/src/components/__tests__/gatsby-image.server.tsx b/packages/gatsby-plugin-image/src/components/__tests__/gatsby-image.server.tsx index 60fb237a7fe7a..f58702eb50eb4 100644 --- a/packages/gatsby-plugin-image/src/components/__tests__/gatsby-image.server.tsx +++ b/packages/gatsby-plugin-image/src/components/__tests__/gatsby-image.server.tsx @@ -1,13 +1,13 @@ import React from "react" import { render, screen } from "@testing-library/react" import { GatsbyImage } from "../gatsby-image.server" -import { ISharpGatsbyImageData } from "../gatsby-image.browser" +import { IGatsbyImageData } from "../gatsby-image.browser" import { SourceProps } from "../picture" type GlobalOverride = NodeJS.Global & typeof global.globalThis & { - SERVER: boolean - GATSBY___IMAGE: boolean + SERVER: boolean | undefined + GATSBY___IMAGE: boolean | undefined } // Prevents terser for bailing because we're not in a babel plugin @@ -22,8 +22,8 @@ describe(`GatsbyImage server`, () => { afterEach(() => { jest.clearAllMocks() - ;(global as GlobalOverride).SERVER = undefined - ;(global as GlobalOverride).GATSBY___IMAGE = undefined + ;(global as GlobalOverride).SERVER = false + ;(global as GlobalOverride).GATSBY___IMAGE = false }) it(`shows nothing when the image props is not passed`, () => { @@ -44,7 +44,7 @@ describe(`GatsbyImage server`, () => { it(`has a valid style attributes for fluid layout`, () => { const layout = `fluid` - const image: ISharpGatsbyImageData = { + const image: IGatsbyImageData = { width: 100, height: 100, layout, @@ -77,7 +77,7 @@ describe(`GatsbyImage server`, () => { it(`has a valid style attributes for fixed layout`, () => { const layout = `fixed` - const image: ISharpGatsbyImageData = { + const image: IGatsbyImageData = { width: 100, height: 100, layout, @@ -116,7 +116,7 @@ describe(`GatsbyImage server`, () => { it(`has a valid style attributes for constrained layout`, () => { const layout = `constrained` - const image: ISharpGatsbyImageData = { + const image: IGatsbyImageData = { width: 100, height: 100, layout, @@ -155,7 +155,7 @@ describe(`GatsbyImage server`, () => { // no fallback provided const images = {} - const image: ISharpGatsbyImageData = { + const image: IGatsbyImageData = { width: 100, height: 100, layout: `constrained`, @@ -186,7 +186,7 @@ describe(`GatsbyImage server`, () => { it(`has a valid src value when fallback is provided in images`, () => { const images = { fallback: { src: `some-src-fallback.jpg` } } - const image: ISharpGatsbyImageData = { + const image: IGatsbyImageData = { width: 100, height: 100, layout: `constrained`, @@ -227,7 +227,7 @@ icon.svg`, }, } - const image: ISharpGatsbyImageData = { + const image: IGatsbyImageData = { width: 100, height: 100, layout: `constrained`, @@ -263,7 +263,7 @@ icon.svg`, // no fallback provided const images = {} - const image: ISharpGatsbyImageData = { + const image: IGatsbyImageData = { width: 100, height: 100, layout: `constrained`, @@ -304,7 +304,7 @@ icon.svg`, }, ] - const image: ISharpGatsbyImageData = { + const image: IGatsbyImageData = { width: 100, height: 100, layout: `constrained`, @@ -342,7 +342,7 @@ icon.svg`, describe(`placeholder verifications`, () => { it(`has a placeholder in a div with valid styles for fluid layout`, () => { - const image: ISharpGatsbyImageData = { + const image: IGatsbyImageData = { width: 100, height: 100, layout: `fluid`, @@ -368,7 +368,7 @@ icon.svg`, }) it(`has a placeholder in a div with valid styles for fixed layout`, () => { - const image: ISharpGatsbyImageData = { + const image: IGatsbyImageData = { width: 100, height: 100, layout: `fixed`, @@ -394,7 +394,7 @@ icon.svg`, }) it(`has a placeholder in a div with valid styles for constrained layout`, () => { - const image: ISharpGatsbyImageData = { + const image: IGatsbyImageData = { width: 100, height: 100, layout: `constrained`, diff --git a/packages/gatsby-plugin-image/src/components/compat.browser.tsx b/packages/gatsby-plugin-image/src/components/compat.browser.tsx index e83c0d5f6c207..ebc0bd6f532ae 100644 --- a/packages/gatsby-plugin-image/src/components/compat.browser.tsx +++ b/packages/gatsby-plugin-image/src/components/compat.browser.tsx @@ -1,5 +1,5 @@ import React, { FunctionComponent, ComponentType, ElementType } from "react" -import { GatsbyImageProps, ISharpGatsbyImageData } from "./gatsby-image.browser" +import { GatsbyImageProps, IGatsbyImageData } from "./gatsby-image.browser" import { GatsbyImage as GatsbyImageOriginal } from "./gatsby-image.browser" export interface ICompatProps { @@ -72,7 +72,7 @@ export function _createCompatLayer( fixed = fixed[0] as Exclude } - const image: ISharpGatsbyImageData = { + const image: IGatsbyImageData = { placeholder: undefined, layout: `fixed`, width: fixed.width, @@ -108,7 +108,7 @@ export function _createCompatLayer( fluid = fluid[0] as Exclude } - const image: ISharpGatsbyImageData = { + const image: IGatsbyImageData = { width: 1, height: fluid.aspectRatio, layout: `fluid`, diff --git a/packages/gatsby-plugin-image/src/components/gatsby-image.browser.tsx b/packages/gatsby-plugin-image/src/components/gatsby-image.browser.tsx index 88b136f1a9175..9da3048dd918a 100644 --- a/packages/gatsby-plugin-image/src/components/gatsby-image.browser.tsx +++ b/packages/gatsby-plugin-image/src/components/gatsby-image.browser.tsx @@ -24,13 +24,13 @@ export type GatsbyImageProps = Omit< alt: string as?: ElementType className?: string - image: ISharpGatsbyImageData + image: IGatsbyImageData onLoad?: () => void onError?: () => void onStartLoad?: Function } -export interface ISharpGatsbyImageData { +export interface IGatsbyImageData { layout: Layout height?: number backgroundColor?: string diff --git a/packages/gatsby-plugin-image/src/components/gatsby-image.server.tsx b/packages/gatsby-plugin-image/src/components/gatsby-image.server.tsx index 9a091e44aea7f..2380201a602e0 100644 --- a/packages/gatsby-plugin-image/src/components/gatsby-image.server.tsx +++ b/packages/gatsby-plugin-image/src/components/gatsby-image.server.tsx @@ -1,5 +1,5 @@ import React, { ElementType, FunctionComponent, CSSProperties } from "react" -import { GatsbyImageProps, ISharpGatsbyImageData } from "./gatsby-image.browser" +import { GatsbyImageProps, IGatsbyImageData } from "./gatsby-image.browser" import { getWrapperProps, getMainProps, getPlaceholderProps } from "./hooks" import { Placeholder } from "./placeholder" import { MainImage, MainImageProps } from "./main-image" @@ -44,7 +44,7 @@ export const GatsbyImage: FunctionComponent = function GatsbyI layout ) - const cleanedImages: ISharpGatsbyImageData["images"] = { + const cleanedImages: IGatsbyImageData["images"] = { fallback: undefined, sources: [], } diff --git a/packages/gatsby-plugin-image/src/components/hooks.ts b/packages/gatsby-plugin-image/src/components/hooks.ts index 6d6d4fc4cae45..96e475da29ded 100644 --- a/packages/gatsby-plugin-image/src/components/hooks.ts +++ b/packages/gatsby-plugin-image/src/components/hooks.ts @@ -12,7 +12,7 @@ import { Node } from "gatsby" import { PlaceholderProps } from "./placeholder" import { MainImageProps } from "./main-image" import { Layout } from "../utils" -import { ISharpGatsbyImageData } from "./gatsby-image.browser" +import { IGatsbyImageData } from "./gatsby-image.browser" const imageCache = new Set() // Native lazy-loading support: https://addyosmani.com/blog/lazy-loading/ @@ -32,11 +32,11 @@ export function hasImageLoaded(cacheKey: string): boolean { export type FileNode = Node & { childImageSharp?: Node & { - gatsbyImageData?: ISharpGatsbyImageData + gatsbyImageData?: IGatsbyImageData } } -export const getImage = (file: FileNode): ISharpGatsbyImageData | undefined => +export const getImage = (file: FileNode): IGatsbyImageData | undefined => file?.childImageSharp?.gatsbyImageData export function getWrapperProps( diff --git a/packages/gatsby-plugin-image/src/components/static-image.server.tsx b/packages/gatsby-plugin-image/src/components/static-image.server.tsx index 2a56d42e3db98..35a47d4cd608e 100644 --- a/packages/gatsby-plugin-image/src/components/static-image.server.tsx +++ b/packages/gatsby-plugin-image/src/components/static-image.server.tsx @@ -1,11 +1,11 @@ import React, { FunctionComponent } from "react" import { StaticImageProps } from "../utils" import { GatsbyImage as GatsbyImageServer } from "./gatsby-image.server" -import { GatsbyImageProps, ISharpGatsbyImageData } from "./gatsby-image.browser" +import { GatsbyImageProps, IGatsbyImageData } from "./gatsby-image.browser" // These values are added by Babel. Do not add them manually interface IPrivateProps { - __imageData?: ISharpGatsbyImageData + __imageData?: IGatsbyImageData __error?: string } diff --git a/packages/gatsby-plugin-image/src/components/static-image.tsx b/packages/gatsby-plugin-image/src/components/static-image.tsx index 22e8eb61cd015..c194f412364a7 100644 --- a/packages/gatsby-plugin-image/src/components/static-image.tsx +++ b/packages/gatsby-plugin-image/src/components/static-image.tsx @@ -1,12 +1,12 @@ import { GatsbyImage as GatsbyImageBrowser, - ISharpGatsbyImageData, + IGatsbyImageData, } from "./gatsby-image.browser" import { _getStaticImage } from "./static-image.server" import { StaticImageProps } from "../utils" // These values are added by Babel. Do not add them manually interface IPrivateProps { - __imageData?: ISharpGatsbyImageData + __imageData?: IGatsbyImageData __error?: string } diff --git a/packages/gatsby-plugin-image/src/global.d.ts b/packages/gatsby-plugin-image/src/global.d.ts index 2e069d339addf..80e329fb522c0 100644 --- a/packages/gatsby-plugin-image/src/global.d.ts +++ b/packages/gatsby-plugin-image/src/global.d.ts @@ -1,11 +1,11 @@ -export {}; +export {} declare global { - declare var SERVER: boolean; + declare var SERVER: boolean namespace NodeJS { interface Global { - GATSBY___IMAGE: boolean; + GATSBY___IMAGE: boolean | undefined } } } diff --git a/packages/gatsby-plugin-image/src/image-utils.ts b/packages/gatsby-plugin-image/src/image-utils.ts new file mode 100644 index 0000000000000..0847b3037b10f --- /dev/null +++ b/packages/gatsby-plugin-image/src/image-utils.ts @@ -0,0 +1,492 @@ +/* eslint-disable no-unused-expressions */ +import { stripIndent } from "common-tags" +import { reporter } from "create-gatsby/src/reporter" +import { IGatsbyImageData } from "." + +const DEFAULT_PIXEL_DENSITIES = [0.25, 0.5, 1, 2] +const DEFAULT_FLUID_WIDTH = 800 +const DEFAULT_FIXED_WIDTH = 400 + +export type Fit = "cover" | "fill" | "inside" | "outside" | "contain" + +export type Layout = "fixed" | "fluid" | "constrained" + +/** + * The minimal required reporter, as we don't want to import it from gatsby-cli + */ +export interface IReporter { + warn(message: string): void +} + +export interface IImageSizeArgs { + width?: number + height?: number + maxWidth?: number + maxHeight?: number + layout?: Layout + filename: string + outputPixelDensities?: Array + fit?: Fit + reporter?: IReporter + sourceMetadata: { width: number; height: number } +} + +export interface IImageSizes { + sizes: Array + presentationWidth: number + presentationHeight: number + aspectRatio: number + unscaledWidth: number +} + +const warnForIgnoredParameters = ( + layout: string, + parameters: Record, + filepath: string, + reporter +): void => { + const ignoredParams = Object.entries(parameters).filter(([_, value]) => + Boolean(value) + ) + if (ignoredParams.length) { + reporter.warn( + `The following provided parameter(s): ${ignoredParams + .map(param => param.join(`: `)) + .join( + `, ` + )} for the image at ${filepath} are ignored in ${layout} image layouts.` + ) + } + return +} + +export interface IImage { + src: string + width: number + height: number + format: ImageFormat +} + +export type ImageFormat = "jpg" | "png" | "webp" | "avif" | "auto" + +export interface IGatsbyImageHelperArgs { + pluginName: string + generateImageSource: ( + filename: string, + width: number, + height: number, + format: ImageFormat, + fit?: Fit, + options?: Record + ) => IImage + layout?: "fixed" | "fluid" | "constrained" + formats?: Array + filename: string + placeholderURL?: + | ((args: IGatsbyImageHelperArgs) => string | undefined) + | string + width?: number + height?: number + maxWidth?: number + maxHeight?: number + sizes?: string + reporter?: IReporter + sourceMetadata: { width: number; height: number; format: ImageFormat } + fit?: Fit + options?: Record +} + +const warn = (message: string): void => console.warn(message) + +const sortNumeric = (a: number, b: number): number => a - b + +export const getSizes = (width: number, layout: Layout): string | undefined => { + switch (layout) { + // If screen is wider than the max size, image width is the max size, + // otherwise it's the width of the screen + case `constrained`: + return `(min-width: ${width}px) ${width}px, 100vw` + + // Image is always the same width, whatever the size of the screen + case `fixed`: + return `${width}px` + + // Image is always the width of the screen + case `fluid`: + return `100vw` + + default: + return undefined + } +} + +export const getSrcSet = (images: Array): string => + images.map(image => `${image.src} ${image.width}w`).join(`,\n`) + +export function generateImageData( + args: IGatsbyImageHelperArgs +): IGatsbyImageData { + const { + pluginName, + sourceMetadata, + generateImageSource, + layout = `fixed`, + fit, + options, + width, + height, + filename, + reporter = { warn }, + } = args + + if (!pluginName) { + reporter.warn( + `[gatsby-plugin-image] "generateImageData" was not passed a plugin name` + ) + } + + if (typeof generateImageSource !== `function`) { + throw new Error(`generateImageSource must be a function`) + } + + const formats = new Set(args.formats || [`auto`, `webp`]) + + if (formats.size === 0 || formats.has(`auto`)) { + formats.delete(`auto`) + formats.add(sourceMetadata.format) + } + + if (formats.has(`jpg`) && formats.has(`png`)) { + reporter.warn( + `[${pluginName}] Specifying both 'jpg' and 'png' formats is not supported. Using 'auto' instead` + ) + if (sourceMetadata.format === `jpg`) { + formats.delete(`png`) + } else { + formats.delete(`jpg`) + } + } + + const imageSizes = calculateImageSizes(args) + + const result: IGatsbyImageData["images"] = { + sources: [], + } + + let sizes = args.sizes + if (!sizes) { + sizes = getSizes(imageSizes.presentationWidth, layout) + } + + formats.forEach(format => { + const images = imageSizes.sizes + .map(size => { + const imageSrc = generateImageSource( + filename, + size, + Math.round(size / imageSizes.aspectRatio), + format, + fit, + options + ) + if ( + !imageSrc?.width || + !imageSrc.height || + !imageSrc.src || + !imageSrc.format + ) { + reporter.warn( + `[${pluginName}] The resolver for image ${filename} returned an invalid value.` + ) + return undefined + } + return imageSrc + }) + .filter(Boolean) + + if (format === `jpg` || format === `png`) { + const unscaled = + images.find(img => img.width === imageSizes.unscaledWidth) || images[0] + + if (unscaled) { + result.fallback = { + src: unscaled.src, + srcSet: getSrcSet(images), + sizes, + } + } + } else { + result.sources?.push({ + srcSet: getSrcSet(images), + sizes, + type: `image/${format}`, + }) + } + }) + + return { images: result, layout, width, height } +} + +const dedupeAndSortDensities = (values: Array): Array => + Array.from(new Set([1, ...values])).sort(sortNumeric) + +export function calculateImageSizes(args: IImageSizeArgs): IImageSizes { + const { + width, + maxWidth, + height, + maxHeight, + filename, + layout = `fixed`, + sourceMetadata: imgDimensions, + reporter = { warn }, + } = args + + // check that all dimensions provided are positive + const userDimensions = { width, maxWidth, height, maxHeight } + const erroneousUserDimensions = Object.entries(userDimensions).filter( + ([_, size]) => typeof size === `number` && size < 1 + ) + if (erroneousUserDimensions.length) { + throw new Error( + `Specified dimensions for images must be positive numbers (> 0). Problem dimensions you have are ${erroneousUserDimensions + .map(dim => dim.join(`: `)) + .join(`, `)}` + ) + } + + if (layout === `fixed`) { + return fixedImageSizes(args) + } else if (layout === `fluid` || layout === `constrained`) { + return fluidImageSizes(args) + } else { + reporter.warn( + `No valid layout was provided for the image at ${filename}. Valid image layouts are fixed, fluid, and constrained.` + ) + return { + sizes: [imgDimensions.width], + presentationWidth: imgDimensions.width, + presentationHeight: imgDimensions.height, + aspectRatio: imgDimensions.width / imgDimensions.height, + unscaledWidth: imgDimensions.width, + } + } +} +export function fixedImageSizes({ + filename, + sourceMetadata: imgDimensions, + width, + maxWidth, + height, + maxHeight, + fit = `cover`, + outputPixelDensities = DEFAULT_PIXEL_DENSITIES, + reporter = { warn }, +}: IImageSizeArgs): IImageSizes { + let aspectRatio = imgDimensions.width / imgDimensions.height + // Sort, dedupe and ensure there's a 1 + const densities = dedupeAndSortDensities(outputPixelDensities) + + warnForIgnoredParameters(`fixed`, { maxWidth, maxHeight }, filename, reporter) + + // If both are provided then we need to check the fit + if (width && height) { + const calculated = getDimensionsAndAspectRatio(imgDimensions, { + width, + height, + fit, + }) + width = calculated.width + height = calculated.height + aspectRatio = calculated.aspectRatio + } + + if (!width) { + if (!height) { + width = DEFAULT_FIXED_WIDTH + } else { + width = Math.round(height * aspectRatio) + } + } else if (!height) { + height = Math.round(width / aspectRatio) + } + + const originalWidth = width // will use this for presentationWidth, don't want to lose it + const isTopSizeOverriden = + imgDimensions.width < width || imgDimensions.height < (height as number) + + // If the image is smaller than requested, warn the user that it's being processed as such + // print out this message with the necessary information before we overwrite it for sizing + if (isTopSizeOverriden) { + const fixedDimension = imgDimensions.width < width ? `width` : `height` + reporter.warn(stripIndent` + The requested ${fixedDimension} "${ + fixedDimension === `width` ? width : height + }px" for the image ${filename} was larger than the actual image ${fixedDimension} of ${ + imgDimensions[fixedDimension] + }px. If possible, replace the current image with a larger one.`) + + if (fixedDimension === `width`) { + width = imgDimensions.width + height = Math.round(width / aspectRatio) + } else { + height = imgDimensions.height + width = height * aspectRatio + } + } + + const sizes = densities + .filter(size => size >= 1) // remove smaller densities because fixed images don't need them + .map(density => Math.round(density * (width as number))) + .filter(size => size <= imgDimensions.width) + + return { + sizes, + aspectRatio, + presentationWidth: originalWidth, + presentationHeight: Math.round(originalWidth / aspectRatio), + unscaledWidth: width, + } +} + +export function fluidImageSizes({ + filename, + sourceMetadata: imgDimensions, + width, + maxWidth, + height, + fit = `cover`, + maxHeight, + outputPixelDensities = DEFAULT_PIXEL_DENSITIES, + reporter, +}: IImageSizeArgs): IImageSizes { + // warn if ignored parameters are passed in + warnForIgnoredParameters( + `fluid and constrained`, + { width, height }, + filename, + reporter + ) + let sizes + let aspectRatio = imgDimensions.width / imgDimensions.height + // Sort, dedupe and ensure there's a 1 + const densities = dedupeAndSortDensities(outputPixelDensities) + + // If both are provided then we need to check the fit + if (maxWidth && maxHeight) { + const calculated = getDimensionsAndAspectRatio(imgDimensions, { + width: maxWidth, + height: maxHeight, + fit, + }) + maxWidth = calculated.width + maxHeight = calculated.height + aspectRatio = calculated.aspectRatio + } + + // Case 1: maxWidth of maxHeight were passed in, make sure it isn't larger than the actual image + maxWidth = maxWidth && Math.min(maxWidth, imgDimensions.width) + maxHeight = maxHeight && Math.min(maxHeight, imgDimensions.height) + + // Case 2: neither maxWidth or maxHeight were passed in, use default size + if (!maxWidth && !maxHeight) { + maxWidth = Math.min(DEFAULT_FLUID_WIDTH, imgDimensions.width) + maxHeight = maxWidth / aspectRatio + } + + // if it still hasn't been found, calculate maxWidth from the derived maxHeight. + // TS isn't smart enough to realise the type for maxHeight has been narrowed here + if (!maxWidth) { + maxWidth = (maxHeight as number) * aspectRatio + } + + const originalMaxWidth = maxWidth + const isTopSizeOverriden = + imgDimensions.width < maxWidth || + imgDimensions.height < (maxHeight as number) + if (isTopSizeOverriden) { + maxWidth = imgDimensions.width + maxHeight = imgDimensions.height + } + + maxWidth = Math.round(maxWidth) + + sizes = densities.map(density => Math.round(density * (maxWidth as number))) + sizes = sizes.filter(size => size <= imgDimensions.width) + + // ensure that the size passed in is included in the final output + if (!sizes.includes(maxWidth)) { + sizes.push(maxWidth) + } + sizes = sizes.sort(sortNumeric) + return { + sizes, + aspectRatio, + presentationWidth: originalMaxWidth, + presentationHeight: Math.round(originalMaxWidth / aspectRatio), + unscaledWidth: maxWidth, + } +} + +export function getDimensionsAndAspectRatio( + dimensions, + options +): { width: number; height: number; aspectRatio: number } { + // Calculate the eventual width/height of the image. + const imageAspectRatio = dimensions.width / dimensions.height + + let width = options.width + let height = options.height + + switch (options.fit) { + case `fill`: { + width = options.width ? options.width : dimensions.width + height = options.height ? options.height : dimensions.height + break + } + case `inside`: { + const widthOption = options.width + ? options.width + : Number.MAX_SAFE_INTEGER + const heightOption = options.height + ? options.height + : Number.MAX_SAFE_INTEGER + + width = Math.min(widthOption, Math.round(heightOption * imageAspectRatio)) + height = Math.min( + heightOption, + Math.round(widthOption / imageAspectRatio) + ) + break + } + case `outside`: { + const widthOption = options.width ? options.width : 0 + const heightOption = options.height ? options.height : 0 + + width = Math.max(widthOption, Math.round(heightOption * imageAspectRatio)) + height = Math.max( + heightOption, + Math.round(widthOption / imageAspectRatio) + ) + break + } + + default: { + if (options.width && !options.height) { + width = options.width + height = Math.round(options.width / imageAspectRatio) + } + + if (options.height && !options.width) { + width = Math.round(options.height * imageAspectRatio) + height = options.height + } + } + } + + return { + width, + height, + aspectRatio: width / height, + } +} diff --git a/packages/gatsby-plugin-image/src/index.browser.ts b/packages/gatsby-plugin-image/src/index.browser.ts index 56ed86b193fc0..84287f7e6185d 100644 --- a/packages/gatsby-plugin-image/src/index.browser.ts +++ b/packages/gatsby-plugin-image/src/index.browser.ts @@ -1,7 +1,7 @@ export { GatsbyImage, GatsbyImageProps, - ISharpGatsbyImageData, + IGatsbyImageData, } from "./components/gatsby-image.browser" export { Placeholder } from "./components/placeholder" export { MainImage } from "./components/main-image" diff --git a/packages/gatsby-plugin-image/src/index.ts b/packages/gatsby-plugin-image/src/index.ts index 8f1edd30d9dee..9d6b963f8a609 100644 --- a/packages/gatsby-plugin-image/src/index.ts +++ b/packages/gatsby-plugin-image/src/index.ts @@ -1,9 +1,15 @@ export { GatsbyImage } from "./components/gatsby-image.server" export { GatsbyImageProps, - ISharpGatsbyImageData, + IGatsbyImageData, } from "./components/gatsby-image.browser" export { Placeholder } from "./components/placeholder" export { MainImage } from "./components/main-image" export { StaticImage } from "./components/static-image.server" export { getImage } from "./components/hooks" +export { + generateImageData, + IGatsbyImageHelperArgs, + IImage, + ImageFormat, +} from "./image-utils" diff --git a/packages/gatsby-plugin-sharp/src/image-data.ts b/packages/gatsby-plugin-sharp/src/image-data.ts index 4095cdce76764..be22c4f926929 100644 --- a/packages/gatsby-plugin-sharp/src/image-data.ts +++ b/packages/gatsby-plugin-sharp/src/image-data.ts @@ -1,5 +1,5 @@ /* eslint-disable no-unused-expressions */ -import { ISharpGatsbyImageData } from "gatsby-plugin-image" +import { IGatsbyImageData } from "gatsby-plugin-image" import { GatsbyCache, Node } from "gatsby" import { Reporter } from "gatsby-cli/lib/reporter/reporter" import { rgbToHex, calculateImageSizes, getSrcSet, getSizes } from "./utils" @@ -101,7 +101,7 @@ export async function generateImageData({ pathPrefix, reporter, cache, -}: IImageDataArgs): Promise { +}: IImageDataArgs): Promise { const { layout = `fixed`, placeholder = `blurred`, @@ -115,6 +115,8 @@ export async function generateImageData({ cropFocus = sharp.strategy.attention, } = transformOptions + const metadata = await getImageMetadata(file, placeholder === `dominantColor`) + const formats = new Set(args.formats) let useAuto = formats.has(``) || formats.has(`auto`) || formats.size === 0 @@ -125,8 +127,6 @@ export async function generateImageData({ useAuto = true } - const metadata = await getImageMetadata(file, placeholder === `dominantColor`) - let primaryFormat: ImageFormat | undefined let options: Record | undefined if (useAuto) { @@ -200,7 +200,7 @@ export async function generateImageData({ if (!images?.length) { return undefined } - const imageProps: ISharpGatsbyImageData = { + const imageProps: IGatsbyImageData = { layout, placeholder: undefined, images: { diff --git a/yarn.lock b/yarn.lock index c42adfb3ad664..84f0fd8485b79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1207,6 +1207,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.12.5": + version "7.12.5" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e" + integrity sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/standalone@^7.11.6": version "7.11.6" resolved "https://registry.yarnpkg.com/@babel/standalone/-/standalone-7.11.6.tgz#2ea3c9463c8b1d04ee2dacc5ac4b81674cec2967" @@ -1861,6 +1868,17 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" +"@jest/types@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-26.6.2.tgz#bef5a532030e1d88a2f5a6d933f84e97226ed48e" + integrity sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^15.0.0" + chalk "^4.0.0" + "@jimp/bmp@^0.14.0": version "0.14.0" resolved "https://registry.yarnpkg.com/@jimp/bmp/-/bmp-0.14.0.tgz#6df246026554f276f7b354047c6fff9f5b2b5182" @@ -3513,6 +3531,28 @@ pretty-format "^25.1.0" wait-for-expect "^3.0.2" +"@testing-library/dom@^7.26.6": + version "7.26.6" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.26.6.tgz#d558db63070a3acea5bea7e2497e631cd12541cc" + integrity sha512-/poL7WMpolcGFOHMcxfcFkf1u38DcBUjk3YwNYpBs/MdJ546lg0YdvP2Lq3ujuQzAZxgs8vVvadj3MBnZsBjjA== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^4.2.0" + aria-query "^4.2.2" + chalk "^4.1.0" + dom-accessibility-api "^0.5.4" + lz-string "^1.4.4" + pretty-format "^26.6.2" + +"@testing-library/react@^11.1.1": + version "11.1.2" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-11.1.2.tgz#089b06d3828e76fc1ff0092dd69c7b59c454c998" + integrity sha512-foL0/Mo68M51DdgFwEsO2SDEkUpocuEYidOTcJACGEcoakZDINuERYwVdd6T5e3pPE+BZyGwwURaXcrX1v9RbQ== + dependencies: + "@babel/runtime" "^7.12.1" + "@testing-library/dom" "^7.26.6" + "@testing-library/react@^9.5.0": version "9.5.0" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-9.5.0.tgz#71531655a7890b61e77a1b39452fbedf0472ca5e" @@ -3608,6 +3648,11 @@ resolved "https://registry.yarnpkg.com/@types/anymatch/-/anymatch-1.3.1.tgz#336badc1beecb9dacc38bea2cf32adf627a8421a" integrity sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA== +"@types/aria-query@^4.2.0": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.0.tgz#14264692a9d6e2fa4db3df5e56e94b5e25647ac0" + integrity sha512-iIgQNzCm0v7QMhhe4Jjn9uRh+I6GoPmt03CbEtwx3ao8/EfoQcmgtqH4vQ5Db/lxiIGaWDv6nwvunuh0RyX0+A== + "@types/babel__code-frame@^7.0.2": version "7.0.2" resolved "https://registry.yarnpkg.com/@types/babel__code-frame/-/babel__code-frame-7.0.2.tgz#e0c0f1648cbc09a9d4e5b4ed2ae9a6f7c8f5aeb0" @@ -9050,6 +9095,11 @@ dom-accessibility-api@^0.3.0: resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.3.0.tgz#511e5993dd673b97c87ea47dba0e3892f7e0c983" integrity sha512-PzwHEmsRP3IGY4gv/Ug+rMeaTIyTJvadCb+ujYXYeIylbHJezIyNToe8KfEgHTCEYyC+/bUghYOGg8yMGlZ6vA== +dom-accessibility-api@^0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.4.tgz#b06d059cdd4a4ad9a79275f9d414a5c126241166" + integrity sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ== + dom-converter@~0.1: version "0.1.4" resolved "http://registry.npmjs.org/dom-converter/-/dom-converter-0.1.4.tgz#a45ef5727b890c9bffe6d7c876e7b19cb0e17f3b" @@ -19407,6 +19457,16 @@ pretty-format@^25.1.0, pretty-format@^25.5.0: ansi-styles "^4.0.0" react-is "^16.12.0" +pretty-format@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-26.6.2.tgz#e35c2705f14cb7fe2fe94fa078345b444120fc93" + integrity sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg== + dependencies: + "@jest/types" "^26.6.2" + ansi-regex "^5.0.0" + ansi-styles "^4.0.0" + react-is "^17.0.1" + prettyjson@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prettyjson/-/prettyjson-1.2.1.tgz#fcffab41d19cab4dfae5e575e64246619b12d289" @@ -19988,6 +20048,11 @@ react-is@^16.12.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4, react-i resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-is@^17.0.1: + version "17.0.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339" + integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA== + react-lifecycles-compat@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"