From 6ed397f22c5cae21563de095f1f1a6d6e26ba100 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 26 Nov 2020 13:13:56 +0000 Subject: [PATCH] feat(gatsby-plugin-image): Add image plugin helpers (#28110) * Add image helper * Fix type * Fix size calculation * Update test * Fix package.json * Add support for empty metadata * Add CdnImage component * Hooks are nicer * Add resolver utils * Quality shouldn't be a default * Add tests * Move resolver utils into gatsby-plugin-image/graphql * Change export to /graphql-utils Co-authored-by: gatsbybot --- packages/gatsby-plugin-image/graphql-utils.js | 1 + packages/gatsby-plugin-image/package.json | 2 +- .../src/__tests__/image-utils.ts | 291 ++++++++++ .../__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 | 15 +- .../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 | 535 ++++++++++++++++++ .../gatsby-plugin-image/src/index.browser.ts | 12 +- packages/gatsby-plugin-image/src/index.ts | 12 +- .../gatsby-plugin-image/src/resolver-utils.ts | 129 +++++ .../gatsby-plugin-sharp/src/image-data.ts | 10 +- 16 files changed, 1024 insertions(+), 43 deletions(-) create mode 100644 packages/gatsby-plugin-image/graphql-utils.js create mode 100644 packages/gatsby-plugin-image/src/__tests__/image-utils.ts create mode 100644 packages/gatsby-plugin-image/src/image-utils.ts create mode 100644 packages/gatsby-plugin-image/src/resolver-utils.ts diff --git a/packages/gatsby-plugin-image/graphql-utils.js b/packages/gatsby-plugin-image/graphql-utils.js new file mode 100644 index 0000000000000..d91f29ee3492b --- /dev/null +++ b/packages/gatsby-plugin-image/graphql-utils.js @@ -0,0 +1 @@ +export * from "./dist/resolver-utils" \ No newline at end of file diff --git a/packages/gatsby-plugin-image/package.json b/packages/gatsby-plugin-image/package.json index 4dcbe30f02c13..352d59c92c320 100644 --- a/packages/gatsby-plugin-image/package.json +++ b/packages/gatsby-plugin-image/package.json @@ -3,7 +3,7 @@ "version": "0.3.0-next.1", "scripts": { "build": "npm-run-all -s clean -p build:*", - "build:gatsby-node": "tsc --jsx react --downlevelIteration true --skipLibCheck true --esModuleInterop true --outDir dist/ src/gatsby-node.ts src/babel-plugin-parse-static-images.ts src/types.d.ts", + "build:gatsby-node": "tsc --jsx react --downlevelIteration true --skipLibCheck true --esModuleInterop true --outDir dist/ src/gatsby-node.ts src/babel-plugin-parse-static-images.ts src/resolver-utils.ts src/types.d.ts", "build:gatsby-ssr": "microbundle -i src/gatsby-ssr.tsx -f cjs -o ./[name].js --no-pkg-main --jsx React.createElement --no-compress --external=common-tags,react --no-sourcemap", "build:server": "microbundle -f cjs,es --jsx React.createElement --define SERVER=true", "build:browser": "microbundle -i src/index.browser.ts -f cjs,modern,es --jsx React.createElement -o dist/gatsby-image.browser --define SERVER=false", 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..7d93f4e866188 --- /dev/null +++ b/packages/gatsby-plugin-image/src/__tests__/image-utils.ts @@ -0,0 +1,291 @@ +import { + formatFromFilename, + generateImageData, + IGatsbyImageHelperArgs, + IImage, +} from "../image-utils" + +const generateImageSource = ( + file: string, + width: number, + height: number, + format +): IImage => { + return { + src: `https://example.com/${file}/${width}/${height}/image.${format}`, + width, + height, + format, + } +} + +const args: IGatsbyImageHelperArgs = { + pluginName: `gatsby-plugin-fake`, + filename: `afile.jpg`, + generateImageSource, + width: 400, + sourceMetadata: { + width: 800, + height: 600, + format: `jpg`, + }, + reporter: { + warn: jest.fn(), + }, +} + +const fluidArgs: IGatsbyImageHelperArgs = { + ...args, + width: undefined, + maxWidth: 400, + layout: `fluid`, +} + +const constrainedArgs: IGatsbyImageHelperArgs = { + ...fluidArgs, + layout: `constrained`, +} + +describe(`the image data helper`, () => { + beforeEach(() => { + jest.resetAllMocks() + }) + it(`throws if there's not a valid generateImageData function`, () => { + const generateImageSource = `this should be a function` + + expect(() => + generateImageData(({ + ...args, + generateImageSource, + } as any) as IGatsbyImageHelperArgs) + ).toThrow() + }) + + it(`warns if generateImageSource function returns invalid values`, () => { + const generateImageSource = jest + .fn() + .mockReturnValue({ width: 100, height: 200, src: undefined }) + + const myArgs = { + ...args, + generateImageSource, + } + + generateImageData(myArgs) + + expect(args.reporter?.warn).toHaveBeenCalledWith( + `[gatsby-plugin-fake] The resolver for image afile.jpg returned an invalid value.` + ) + ;(args.reporter?.warn as jest.Mock).mockReset() + + generateImageSource.mockReturnValue({ + width: 100, + height: undefined, + src: `example`, + format: `jpg`, + }) + generateImageData(myArgs) + + expect(args.reporter?.warn).toHaveBeenCalledWith( + `[gatsby-plugin-fake] The resolver for image afile.jpg returned an invalid value.` + ) + ;(args.reporter?.warn as jest.Mock).mockReset() + + generateImageSource.mockReturnValue({ + width: undefined, + height: 100, + src: `example`, + format: `jpg`, + }) + generateImageData(myArgs) + + expect(args.reporter?.warn).toHaveBeenCalledWith( + `[gatsby-plugin-fake] The resolver for image afile.jpg returned an invalid value.` + ) + ;(args.reporter?.warn as jest.Mock).mockReset() + + generateImageSource.mockReturnValue({ + width: 100, + height: 100, + src: `example`, + format: undefined, + }) + generateImageData(myArgs) + + expect(args.reporter?.warn).toHaveBeenCalledWith( + `[gatsby-plugin-fake] The resolver for image afile.jpg returned an invalid value.` + ) + ;(args.reporter?.warn as jest.Mock).mockReset() + generateImageSource.mockReturnValue({ + width: 100, + height: 100, + src: `example`, + format: `jpg`, + }) + generateImageData(myArgs) + expect(args.reporter?.warn).not.toHaveBeenCalled() + }) + + it(`warns if there's no plugin name`, () => { + generateImageData(({ + ...args, + pluginName: undefined, + } as any) as IGatsbyImageHelperArgs) + expect(args.reporter?.warn).toHaveBeenCalledWith( + `[gatsby-plugin-image] "generateImageData" was not passed a plugin name` + ) + }) + + it(`calls the generateImageSource function`, () => { + const generateImageSource = jest.fn() + generateImageData({ ...args, generateImageSource }) + expect(generateImageSource).toHaveBeenCalledWith( + `afile.jpg`, + 800, + 600, + `jpg`, + undefined, + undefined + ) + }) + + it(`calculates sizes for fixed`, () => { + const data = generateImageData(args) + expect(data.images.fallback?.sizes).toEqual(`400px`) + }) + + it(`calculates sizes for fluid`, () => { + const data = generateImageData(fluidArgs) + expect(data.images.fallback?.sizes).toEqual(`100vw`) + }) + + it(`calculates sizes for constrained`, () => { + const data = generateImageData(constrainedArgs) + expect(data.images.fallback?.sizes).toEqual( + `(min-width: 400px) 400px, 100vw` + ) + }) + + it(`returns URLs for fixed`, () => { + const data = generateImageData(args) + expect(data?.images?.fallback?.src).toEqual( + `https://example.com/afile.jpg/400/300/image.jpg` + ) + + expect(data.images?.sources?.[0].srcSet).toEqual( + `https://example.com/afile.jpg/400/300/image.webp 400w,\nhttps://example.com/afile.jpg/800/600/image.webp 800w` + ) + }) + + it(`returns URLs for fluid`, () => { + const data = generateImageData(fluidArgs) + expect(data?.images?.fallback?.src).toEqual( + `https://example.com/afile.jpg/400/300/image.jpg` + ) + + expect(data.images?.sources?.[0].srcSet).toEqual( + `https://example.com/afile.jpg/100/75/image.webp 100w,\nhttps://example.com/afile.jpg/200/150/image.webp 200w,\nhttps://example.com/afile.jpg/400/300/image.webp 400w,\nhttps://example.com/afile.jpg/800/600/image.webp 800w` + ) + }) + + it(`converts to PNG if requested`, () => { + const data = generateImageData({ ...args, formats: [`png`] }) + expect(data?.images?.fallback?.src).toEqual( + `https://example.com/afile.jpg/400/300/image.png` + ) + }) + + it(`does not include sources if only jpg or png format is specified`, () => { + let data = generateImageData({ ...args, formats: [`auto`] }) + expect(data.images?.sources?.length).toBe(0) + + data = generateImageData({ ...args, formats: [`png`] }) + expect(data.images?.sources?.length).toBe(0) + + data = generateImageData({ ...args, formats: [`jpg`] }) + expect(data.images?.sources?.length).toBe(0) + }) + + it(`does not include fallback if only webp format is specified`, () => { + const data = generateImageData({ ...args, formats: [`webp`] }) + expect(data.images?.sources?.length).toBe(1) + expect(data.images?.fallback).toBeUndefined() + }) + + it(`does not include fallback if only avif format is specified`, () => { + const data = generateImageData({ ...args, formats: [`avif`] }) + expect(data.images?.sources?.length).toBe(1) + expect(data.images?.fallback).toBeUndefined() + }) + + it(`generates the same output as the input format if output is auto`, () => { + const sourceMetadata = { + width: 800, + height: 600, + format: `jpg`, + } + + let data = generateImageData({ ...args, formats: [`auto`] }) + expect(data?.images?.fallback?.src).toEqual( + `https://example.com/afile.jpg/400/300/image.jpg` + ) + expect(data.images?.sources?.length).toBe(0) + + data = generateImageData({ + ...args, + sourceMetadata: { ...sourceMetadata, format: `png` }, + formats: [`auto`], + }) + expect(data?.images?.fallback?.src).toEqual( + `https://example.com/afile.jpg/400/300/image.png` + ) + expect(data.images?.sources?.length).toBe(0) + }) + + it(`treats empty formats or empty string as auto`, () => { + let data = generateImageData({ ...args, formats: [``] }) + expect(data?.images?.fallback?.src).toEqual( + `https://example.com/afile.jpg/400/300/image.jpg` + ) + expect(data.images?.sources?.length).toBe(0) + + data = generateImageData({ ...args, formats: [] }) + expect(data?.images?.fallback?.src).toEqual( + `https://example.com/afile.jpg/400/300/image.jpg` + ) + expect(data.images?.sources?.length).toBe(0) + }) +}) + +describe(`the helper utils`, () => { + it(`gets file format from filename`, () => { + const names = [ + `filename.jpg`, + `filename.jpeg`, + `filename.png`, + `filename.heic`, + `filename.jp`, + `filename.jpgjpg`, + `file.name.jpg`, + `file.name.`, + `filenamejpg`, + `.jpg`, + ] + const expected = [ + `jpg`, + `jpg`, + `png`, + `heic`, + undefined, + undefined, + `jpg`, + undefined, + undefined, + `jpg`, + ] + for (const idx in names) { + const ext = formatFromFilename(names[idx]) + expect(ext).toBe(expected[idx]) + } + }) +}) 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..f7cf75a636e53 100644 --- a/packages/gatsby-plugin-image/src/components/hooks.ts +++ b/packages/gatsby-plugin-image/src/components/hooks.ts @@ -12,7 +12,8 @@ import { Node } from "gatsby" import { PlaceholderProps } from "./placeholder" import { MainImageProps } from "./main-image" import { Layout } from "../utils" -import { ISharpGatsbyImageData } from "./gatsby-image.browser" +import type { IGatsbyImageData } from "./gatsby-image.browser" +import { IGatsbyImageHelperArgs, generateImageData } from "../image-utils" const imageCache = new Set() // Native lazy-loading support: https://addyosmani.com/blog/lazy-loading/ @@ -32,11 +33,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( @@ -66,6 +67,14 @@ export function getWrapperProps( } } +export function useGatsbyImage({ + pluginName = `useGatsbyImage`, + ...args +}: IGatsbyImageHelperArgs): IGatsbyImageData { + // TODO: use context to get default plugin options and spread them in here + return generateImageData({ pluginName, ...args }) +} + export function getMainProps( isLoading: boolean, isLoaded: boolean, 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..e0686311957c6 --- /dev/null +++ b/packages/gatsby-plugin-image/src/image-utils.ts @@ -0,0 +1,535 @@ +/* eslint-disable no-unused-expressions */ +import { stripIndent } from "common-tags" +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?: Layout + 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 formatFromFilename(filename: string): ImageFormat | undefined { + const dot = filename.lastIndexOf(`.`) + if (dot !== -1) { + const ext = filename.substr(dot + 1) + if (ext === `jpeg`) { + return `jpg` + } + if (ext.length === 3 || ext.length === 4) { + return ext as ImageFormat + } + } + return undefined +} + +export function generateImageData( + args: IGatsbyImageHelperArgs +): IGatsbyImageData { + let { + pluginName, + sourceMetadata, + generateImageSource, + layout = `fixed`, + fit, + options, + width, + maxWidth, + height, + maxHeight, + 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`) + } + if (!sourceMetadata || (!sourceMetadata.width && !sourceMetadata.height)) { + // No metadata means we let the CDN handle max size etc, aspect ratio etc + sourceMetadata = { + width: width || maxWidth, + height: height || maxHeight, + format: formatFromFilename(filename), + } + } else if (!sourceMetadata.format) { + sourceMetadata.format = formatFromFilename(filename) + } + // + const formats = new Set(args.formats || [`auto`, `webp`]) + + if (formats.size === 0 || formats.has(`auto`) || formats.has(``)) { + formats.delete(`auto`) + formats.delete(``) + 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, sourceMetadata }) + + 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}`, + }) + } + }) + + const imageProps: IGatsbyImageData = { images: result, layout } + switch (layout) { + case `fixed`: + imageProps.width = imageSizes.presentationWidth + imageProps.height = imageSizes.presentationHeight + break + + case `fluid`: + imageProps.width = 1 + imageProps.height = 1 / imageSizes.aspectRatio + break + + case `constrained`: + imageProps.width = args.maxWidth || imageSizes.presentationWidth || 1 + imageProps.height = (imageProps.width || 1) / imageSizes.aspectRatio + } + + return imageProps +} + +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 = { warn }, +}: 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..f210f3d2e4595 100644 --- a/packages/gatsby-plugin-image/src/index.browser.ts +++ b/packages/gatsby-plugin-image/src/index.browser.ts @@ -1,9 +1,17 @@ export { GatsbyImage, 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" -export { getImage } from "./components/hooks" +export { getImage, useGatsbyImage } from "./components/hooks" +export { + generateImageData, + IGatsbyImageHelperArgs, + IImage, + ImageFormat, + Layout, + Fit, +} from "./image-utils" diff --git a/packages/gatsby-plugin-image/src/index.ts b/packages/gatsby-plugin-image/src/index.ts index 8f1edd30d9dee..ba63263e49204 100644 --- a/packages/gatsby-plugin-image/src/index.ts +++ b/packages/gatsby-plugin-image/src/index.ts @@ -1,9 +1,17 @@ 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 { getImage, useGatsbyImage } from "./components/hooks" +export { + generateImageData, + IGatsbyImageHelperArgs, + IImage, + ImageFormat, + Layout, + Fit, +} from "./image-utils" diff --git a/packages/gatsby-plugin-image/src/resolver-utils.ts b/packages/gatsby-plugin-image/src/resolver-utils.ts new file mode 100644 index 0000000000000..d86f906dd3d70 --- /dev/null +++ b/packages/gatsby-plugin-image/src/resolver-utils.ts @@ -0,0 +1,129 @@ +import { + GraphQLNonNull, + GraphQLJSON, + GraphQLInt, + GraphQLList, + GraphQLString, + GraphQLFieldConfig, + GraphQLFieldResolver, + GraphQLFieldConfigArgumentMap, + GraphQLEnumType, + GraphQLFloat, +} from "gatsby/graphql" +import { stripIndent } from "common-tags" + +export const ImageFormatType = new GraphQLEnumType({ + name: `GatsbyImageFormat`, + values: { + NO_CHANGE: { value: `` }, + AUTO: { value: `` }, + JPG: { value: `jpg` }, + PNG: { value: `png` }, + WEBP: { value: `webp` }, + }, +}) + +export const ImageLayoutType = new GraphQLEnumType({ + name: `GatsbyImageLayout`, + values: { + FIXED: { value: `fixed` }, + FLUID: { value: `fluid` }, + CONSTRAINED: { value: `constrained` }, + }, +}) + +export const ImagePlaceholderType = new GraphQLEnumType({ + name: `GatsbyImagePlaceholder`, + values: { + DOMINANT_COLOR: { value: `dominantColor` }, + TRACED_SVG: { value: `tracedSVG` }, + BLURRED: { value: `blurred` }, + NONE: { value: `none` }, + }, +}) + +export function getGatsbyImageFieldConfig( + resolve: GraphQLFieldResolver, + extraArgs?: GraphQLFieldConfigArgumentMap +): GraphQLFieldConfig { + return { + type: new GraphQLNonNull(GraphQLJSON), + args: { + layout: { + type: ImageLayoutType, + defaultValue: `fixed`, + description: stripIndent` + The layout for the image. + FIXED: A static image sized, that does not resize according to the screen width + FLUID: The image resizes to fit its container. Pass a "sizes" option if it isn't going to be the full width of the screen. + CONSTRAINED: Resizes to fit its container, up to a maximum width, at which point it will remain fixed in size. + `, + }, + maxWidth: { + type: GraphQLInt, + description: stripIndent` + Maximum display width of generated files. + The actual largest image resolution will be this value multipled by the largest value in outputPixelDensities + This only applies when layout = FLUID or CONSTRAINED. For other layout types, use "width"`, + }, + maxHeight: { + type: GraphQLInt, + description: stripIndent` + If set, the generated image is a maximum of this height, cropping if necessary. + If the image layout is "constrained" then the image will be limited to this height. + If the aspect ratio of the image is different than the source, then the image will be cropped.`, + }, + width: { + type: GraphQLInt, + description: stripIndent` + The display width of the generated image. + The actual largest image resolution will be this value multipled by the largest value in outputPixelDensities + Ignored if layout = FLUID or CONSTRAINED, where you should use "maxWidth" instead. + `, + }, + height: { + type: GraphQLInt, + description: stripIndent` + If set, the height of the generated image. If omitted, it is calculated from the supplied width, matching the aspect ratio of the source image.`, + }, + placeholder: { + type: ImagePlaceholderType, + defaultValue: `blurred`, + description: stripIndent` + Format of generated placeholder image, displayed while the main image loads. + BLURRED: a blurred, low resolution image, encoded as a base64 data URI (default) + DOMINANT_COLOR: a solid color, calculated from the dominant color of the image. + TRACED_SVG: a low-resolution traced SVG of the image. + NONE: no placeholder. Set "background" to use a fixed background color.`, + }, + formats: { + type: GraphQLList(ImageFormatType), + description: stripIndent` + The image formats to generate. Valid values are "AUTO" (meaning the same format as the source image), "JPG", "PNG" and "WEBP". + The default value is [AUTO, WEBP], and you should rarely need to change this. Take care if you specify JPG or PNG when you do + not know the formats of the source images, as this could lead to unwanted results such as converting JPEGs to PNGs. Specifying + both PNG and JPG is not supported and will be ignored. + `, + defaultValue: [`auto`, `webp`], + }, + outputPixelDensities: { + type: GraphQLList(GraphQLFloat), + description: stripIndent` + A list of image pixel densities to generate. It will never generate images larger than the source, and will always include a 1x image. + Default is [ 1, 2 ] for fixed images, meaning 1x, 2x, 3x, and [0.25, 0.5, 1, 2] for fluid. In this case, an image with a fluid layout and width = 400 would generate images at 100, 200, 400 and 800px wide + `, + }, + sizes: { + type: GraphQLString, + defaultValue: ``, + description: stripIndent` + The "sizes" property, passed to the img tag. This describes the display size of the image. + This does not affect the generated images, but is used by the browser to decide which images to download. You can leave this blank for fixed images, or if the responsive image + container will be the full width of the screen. In these cases we will generate an appropriate value. + `, + }, + ...extraArgs, + }, + resolve, + } +} 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: {