From 53fac802fb2492b07386cc6c19a3622d7099c82e Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Tue, 1 Mar 2022 00:42:37 +0100 Subject: [PATCH 01/46] fix(gatsby-core-utils): fix caching when using remote-file --- packages/gatsby-core-utils/src/fetch-remote-file.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gatsby-core-utils/src/fetch-remote-file.ts b/packages/gatsby-core-utils/src/fetch-remote-file.ts index ecd332dffaef6..e9dc4f59fa77d 100644 --- a/packages/gatsby-core-utils/src/fetch-remote-file.ts +++ b/packages/gatsby-core-utils/src/fetch-remote-file.ts @@ -202,7 +202,7 @@ async function fetchFile({ await fs.move(tmpFilename, filename, { overwrite: true }) - const slashedDirectory = slash(fileDirectory) + const slashedDirectory = slash(finalDirectory) await setInFlightObject(url, BUILD_ID, { cacheKey, extension: ext, From 3ff3a78f95d511bb81ec4c2f69f94b9969be78de Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Tue, 15 Feb 2022 22:32:13 +0100 Subject: [PATCH 02/46] feat: add support for image-cdn --- package.json | 1 - packages/gatsby-plugin-manifest/package.json | 2 +- packages/gatsby-plugin-utils/.babelrc | 2 +- packages/gatsby-plugin-utils/README.md | 4 +- packages/gatsby-plugin-utils/package.json | 35 +- .../graphql/gatsby-image-data-resolver.ts | 514 ++++++++++++++++++ .../graphql/get-remote-file-enums.ts | 77 +++ .../graphql/public-url-resolver.ts | 38 ++ .../graphql/resize-resolver.ts | 110 ++++ .../src/polyfill-remote-file/graphql/utils.ts | 86 +++ .../src/polyfill-remote-file/http-routes.ts | 104 ++++ .../src/polyfill-remote-file/index.ts | 144 +++++ .../polyfill-remote-file/jobs/dispatchers.ts | 107 ++++ .../jobs/gatsby-worker.ts | 58 ++ .../placeholder-handler.ts | 262 +++++++++ .../polyfill-remote-file/transform-images.ts | 151 +++++ .../src/polyfill-remote-file/types.ts | 79 +++ .../src/polyfill-remote-file/utils/cache.ts | 6 + .../utils/get-gatsby-version.ts | 10 + .../utils/mime-type-helpers.ts | 14 + .../utils/strip-indent.ts | 14 + .../utils/url-generator.ts | 40 ++ packages/gatsby/index.d.ts | 2 +- packages/gatsby/package.json | 1 + packages/gatsby/scripts/__tests__/api.js | 4 +- packages/gatsby/scripts/output-api-file.js | 3 +- packages/gatsby/src/commands/serve.ts | 2 +- packages/gatsby/src/schema/schema-composer.ts | 4 + packages/gatsby/src/schema/schema.js | 12 +- .../types/__tests__/remote-file-interface.ts | 258 +++++++++ .../src/schema/types/remote-file-interface.ts | 47 ++ packages/gatsby/src/utils/start-server.ts | 27 +- yarn.lock | 60 +- 33 files changed, 2229 insertions(+), 49 deletions(-) create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/get-remote-file-enums.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/utils.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/http-routes.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/gatsby-worker.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/transform-images.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/cache.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/get-gatsby-version.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/mime-type-helpers.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/strip-indent.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts create mode 100644 packages/gatsby/src/schema/types/__tests__/remote-file-interface.ts create mode 100644 packages/gatsby/src/schema/types/remote-file-interface.ts diff --git a/package.json b/package.json index 4d201b887205e..4bb20c5ce208b 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "@types/bluebird": "^3.5.35", "@types/cache-manager": "^2.10.3", "@types/common-tags": "^1.8.0", - "@types/express": "^4.17.3", "@types/fs-extra": "^9.0.13", "@types/jaeger-client": "^3.18.0", "@types/jest": "^27.0.2", diff --git a/packages/gatsby-plugin-manifest/package.json b/packages/gatsby-plugin-manifest/package.json index e6670d01de10e..59c60389437f0 100644 --- a/packages/gatsby-plugin-manifest/package.json +++ b/packages/gatsby-plugin-manifest/package.json @@ -47,4 +47,4 @@ "engines": { "node": ">=14.15.0" } -} +} \ No newline at end of file diff --git a/packages/gatsby-plugin-utils/.babelrc b/packages/gatsby-plugin-utils/.babelrc index 3af9b5a3ea9ec..7d1e4eb1568a6 100644 --- a/packages/gatsby-plugin-utils/.babelrc +++ b/packages/gatsby-plugin-utils/.babelrc @@ -1,5 +1,5 @@ { - "presets": [["babel-preset-gatsby-package", { "browser": true }]], + "presets": [["babel-preset-gatsby-package"]], "overrides": [ { "test": ["**/*.ts"], diff --git a/packages/gatsby-plugin-utils/README.md b/packages/gatsby-plugin-utils/README.md index a1634b78e6ee8..601b488f54b51 100644 --- a/packages/gatsby-plugin-utils/README.md +++ b/packages/gatsby-plugin-utils/README.md @@ -76,7 +76,7 @@ Here's a list of features: ```js const { hasFeature } = require(`gatsby-plugin-utils`) -if (!hasFeature(`image-service`)) { - // You can polyfill image-service here so older versions have support as well +if (!hasFeature(`image-cdn`)) { + // You can polyfill image-cdn here so older versions have support as well } ``` diff --git a/packages/gatsby-plugin-utils/package.json b/packages/gatsby-plugin-utils/package.json index 576f12e78ab40..d9c6584430a66 100644 --- a/packages/gatsby-plugin-utils/package.json +++ b/packages/gatsby-plugin-utils/package.json @@ -3,6 +3,30 @@ "version": "3.4.0-next.0", "description": "Gatsby utils that help creating plugins", "main": "dist/index.js", + "exports": { + ".": "./dist/index.js", + "./*.js": "./dist/*.js", + "./dist/*": "./dist/*.js", + "./dist/polyfill-remote-file": null, + "./dist/utils": null, + "./polyfill-remote-file": "./dist/polyfill-remote-file/index.js", + "./dist/polyfill-remote-file/jobs/gatsby-worker.js": "./dist/polyfill-remote-file/jobs/gatsby-worker.js" + }, + "typesVersions": { + "*": { + "*": [ + "dist/*.d.ts", + "dist/index.d.ts" + ], + "polyfill-remote-file": [ + "dist/polyfill-remote-file/index.d.ts" + ], + "dist/*": [ + "dist/*.d.ts", + "dist/index.d.ts" + ] + } + }, "scripts": { "build": "babel src --out-dir dist/ --ignore \"**/__tests__\" --extensions \".ts,.js\"", "watch": "babel -w src --out-dir dist/ --ignore \"**/__tests__\" --extensions \".ts,.js\"", @@ -22,7 +46,11 @@ "homepage": "https://github.com/gatsbyjs/gatsby/tree/master/packages/gatsby-plugin-utils#readme", "dependencies": { "@babel/runtime": "^7.15.4", - "joi": "^17.4.2" + "gatsby-core-utils": "3.9.0-next.0", + "graphql-compose": "^9.0.7", + "import-from": "^4.0.0", + "joi": "^17.4.2", + "mime": "^3.0.0" }, "devDependencies": { "@babel/cli": "^7.15.4", @@ -36,10 +64,9 @@ "gatsby": "^4.0.0-next" }, "files": [ - "dist/", - "src/" + "dist/" ], "engines": { "node": ">=14.15.0" } -} +} \ No newline at end of file diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts new file mode 100644 index 0000000000000..9cabaf59c24f9 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts @@ -0,0 +1,514 @@ +import { generatePublicUrl, generateImageArgs } from "../utils/url-generator" +import { getImageFormatFromMimeType } from "../utils/mime-type-helpers" +import { stripIndent } from "../utils/strip-indent" +import { + dispatchLocalImageServiceJob, + shouldDispatch, +} from "../jobs/dispatchers" +import { generatePlaceholder } from "../placeholder-handler" +import { isImage } from "../types" +import { validateAndNormalizeFormats, calculateImageDimensions } from "./utils" + +import type { Store } from "gatsby" +import type { PlaceholderType } from "../placeholder-handler" +import type { + IRemoteFileNode, + IRemoteImageNode, + IGraphQLFieldConfigDefinition, + ImageFormat, + ImageLayout, + CalculateImageSizesArgs, +} from "../types" +import type { getRemoteFileEnums } from "./get-remote-file-enums" + +interface IGatsbyImageData { + sources: Array<{ + srcSet: string + type: string + sizes: string + }> + fallback: { + srcSet: string + src: string + sizes: string + } +} + +interface ISourceMetadata { + width: number + height: number + format: ImageFormat + filename: string +} + +type IGatsbyImageDataArgs = CalculateImageSizesArgs & { + formats: Array + backgroundColor: string + placeholder: PlaceholderType | "none" + aspectRatio: number + sizes: string +} + +type ImageSizeArgs = CalculateImageSizesArgs & { + sourceMetadata: ISourceMetadata +} + +interface IImageSizes { + sizes: Array + presentationWidth: number + presentationHeight: number + aspectRatio: number + unscaledWidth: number +} + +const DEFAULT_PIXEL_DENSITIES = [0.25, 0.5, 1, 2] +const DEFAULT_BREAKPOINTS = [750, 1080, 1366, 1920] + +export async function gatsbyImageDataResolver( + source: IRemoteFileNode, + args: IGatsbyImageDataArgs, + store: Store +): Promise<{ + images: IGatsbyImageData + layout: string + width: number + height: number + backgroundColor: string + placeholder?: { fallback: string } | undefined +} | null> { + if (!isImage(source)) { + return null + } + + let backgroundColor = args.backgroundColor + const sourceMetadata: ISourceMetadata = { + width: source.width, + height: source.height, + format: getImageFormatFromMimeType(source.mimeType), + filename: source.filename, + } + const formats = validateAndNormalizeFormats( + args.formats, + sourceMetadata.format + ) + const imageSizes = calculateImageSizes(sourceMetadata, args) + const sizes = getSizesAttrFromLayout( + args.layout, + imageSizes.presentationWidth + ) + const result: Partial & { + sources: IGatsbyImageData["sources"] + } = { + sources: [], + fallback: undefined, + } + + for (const format of formats) { + let fallbackSrc: string | undefined = undefined + const images = imageSizes.sizes.map(width => { + if (shouldDispatch()) { + dispatchLocalImageServiceJob( + { + url: source.url, + extension: format, + width, + height: Math.round(width / imageSizes.aspectRatio), + format, + fit: args.fit, + contentDigest: source.internal.contentDigest, + }, + store + ) + } + + const src = `${generatePublicUrl(source)}/${generateImageArgs({ + width, + height: Math.round(width / imageSizes.aspectRatio), + format, + fit: args.fit, + })}.${format}` + + if (!fallbackSrc) { + fallbackSrc = src + } + + return { + src, + width, + } + }) + + if (format === sourceMetadata.format && fallbackSrc) { + result.fallback = { + src: fallbackSrc, + srcSet: createSrcSetFromImages(images), + sizes, + } + } else { + result.sources.push({ + srcSet: createSrcSetFromImages(images), + type: `image/${format}`, + sizes, + }) + } + } + + let placeholder: { fallback: string } | undefined + if (args.placeholder !== `none`) { + const { fallback, backgroundColor: bgColor } = await generatePlaceholder( + source, + args.placeholder as PlaceholderType + ) + + if (fallback) { + placeholder = { fallback } + } + if (bgColor) { + backgroundColor = bgColor + } + } + + return { + images: result as IGatsbyImageData, + layout: args.layout, + width: imageSizes.presentationWidth, + height: imageSizes.presentationHeight, + placeholder, + backgroundColor, + } +} + +export function generateGatsbyImageDataFieldConfig( + enums: ReturnType, + store: Store +): IGraphQLFieldConfigDefinition< + IRemoteFileNode | IRemoteImageNode, + ReturnType, + IGatsbyImageDataArgs +> { + return { + type: `JSON`, + args: { + layout: { + type: enums.layout.NonNull.getTypeName(), + description: stripIndent` + The layout for the image. + FIXED: A static image sized, that does not resize according to the screen width + FULL_WIDTH: 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. + `, + }, + width: { + type: `Int`, + description: stripIndent` + The display width of the generated image for layout = FIXED, and the display width of the largest image for layout = CONSTRAINED. + The actual largest image resolution will be this value multiplied by the largest value in outputPixelDensities + Ignored if layout = FLUID. + `, + }, + height: { + type: `Int`, + 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: enums.placeholder.getTypeName(), + defaultValue: enums.placeholder.getField(`DOMINANT_COLOR`).value, + 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 the argument "backgroundColor" to use a fixed background color.`, + }, + aspectRatio: { + type: `Float`, + description: stripIndent` + If set along with width or height, this will set the value of the other dimension to match the provided aspect ratio, cropping the image if needed. + If neither width or height is provided, height will be set based on the intrinsic width of the source image. + `, + }, + formats: { + type: enums.format.NonNull.List.getTypeName(), + description: stripIndent` + The image formats to generate. Valid values are AUTO (meaning the same format as the source image), JPG, PNG, WEBP and AVIF. + The default value is [AUTO, WEBP, AVIF], 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: [ + enums.format.getField(`AUTO`).value, + enums.format.getField(`WEBP`).value, + enums.format.getField(`AVIF`).value, + ], + }, + outputPixelDensities: { + type: `[Float]`, + defaultValue: DEFAULT_PIXEL_DENSITIES, + description: stripIndent` + A list of image pixel densities to generate for FIXED and CONSTRAINED images. You should rarely need to change this. 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, 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. + `, + }, + breakpoints: { + type: `[Int]`, + defaultValue: DEFAULT_BREAKPOINTS, + description: stripIndent` + Specifies the image widths to generate. You should rarely need to change this. For FIXED and CONSTRAINED images it is better to allow these to be determined automatically, + based on the image size. For FULL_WIDTH images this can be used to override the default, which is [750, 1080, 1366, 1920]. + It will never generate any images larger than the source. + `, + }, + sizes: { + type: `String`, + 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. + `, + }, + backgroundColor: { + type: `String`, + description: `Background color applied to the wrapper, or when "letterboxing" an image to another aspect ratio.`, + }, + fit: { + type: enums.fit.getTypeName(), + defaultValue: enums.fit.getField(`COVER`).value, + }, + }, + resolve(source, args): ReturnType { + return gatsbyImageDataResolver(source, args, store) + }, + } +} + +function sortNumeric(a: number, b: number): number { + return a - b +} + +function createSrcSetFromImages( + images: Array<{ src: string; width: number }> +): string { + return images.map(image => `${image.src} ${image.width}w`).join(`,`) +} + +// eslint-disable-next-line consistent-return +function calculateImageSizes( + sourceMetadata: ISourceMetadata, + { + width, + height, + layout, + fit, + outputPixelDensities, + breakpoints, + }: CalculateImageSizesArgs +): IImageSizes { + if (Number(width) <= 0) { + throw new Error( + `The provided width of "${width}" is incorrect. Dimensions should be a positive number.` + ) + } + + if (Number(height) <= 0) { + throw new Error( + `The provided height of "${height}" is incorrect. Dimensions should be a positive number.` + ) + } + + switch (layout) { + case `fixed`: { + return calculateFixedImageSizes({ + width, + height, + fit, + sourceMetadata, + outputPixelDensities, + }) + } + case `constrained`: { + return calculateResponsiveImageSizes({ + sourceMetadata, + width, + height, + fit, + outputPixelDensities, + layout, + }) + } + case `fullWidth`: { + return calculateResponsiveImageSizes({ + sourceMetadata, + width, + height, + fit, + outputPixelDensities, + layout, + breakpoints, + }) + } + } +} + +function calculateFixedImageSizes({ + sourceMetadata, + width, + height, + fit = `cover`, + outputPixelDensities, +}: Omit): IImageSizes { + let aspectRatio = sourceMetadata.width / sourceMetadata.height + + // make sure output outputPixelDensities has a value of 1 + outputPixelDensities.push(1) + const densities = new Set( + outputPixelDensities.sort(sortNumeric).filter(Boolean) + ) + + // If both are provided then we need to check the fit + if (width && height) { + const calculated = calculateImageDimensions(sourceMetadata, { + width, + height, + fit, + }) + width = calculated.width + height = calculated.height + aspectRatio = calculated.aspectRatio + } else { + // if we only get one value calculate the other value based on aspectRatio + if (!width) { + width = Math.round(height * aspectRatio) + } else { + height = Math.round(width / aspectRatio) + } + } + + const presentationWidth = width // will use this for presentationWidth, don't want to lose it + const isRequestedSizeLargerThanOriginal = + sourceMetadata.width < width || sourceMetadata.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 (isRequestedSizeLargerThanOriginal) { + const invalidDimension = sourceMetadata.width < width ? `width` : `height` + console.warn(` + The requested ${invalidDimension} "${ + invalidDimension === `width` ? width : height + }px" for the image ${ + sourceMetadata.filename + } was larger than the actual image ${invalidDimension} of ${ + sourceMetadata[invalidDimension] + }px. If possible, replace the current image with a larger one.`) + + if (invalidDimension === `width`) { + width = sourceMetadata.width + height = width / aspectRatio + } else { + height = sourceMetadata.height + width = height * aspectRatio + } + } + + const sizes = new Set() + for (const density of densities) { + // Screen densities can only be higher or equal to 1 + if (density >= 1) { + const widthFromDensity = density * width + sizes.add(Math.min(widthFromDensity, sourceMetadata.width)) + } + } + + return { + sizes: Array.from(sizes), + aspectRatio, + presentationWidth, + presentationHeight: Math.round(presentationWidth / aspectRatio), + unscaledWidth: width, + } +} + +function calculateResponsiveImageSizes({ + sourceMetadata, + width, + height, + fit = `cover`, + outputPixelDensities, + breakpoints, + layout, +}: ImageSizeArgs): IImageSizes { + let sizes: Array = [] + let aspectRatio = sourceMetadata.width / sourceMetadata.height + // Sort, dedupe and ensure there's a 1 + const densities = new Set( + outputPixelDensities.sort(sortNumeric).filter(Boolean) + ) + + // If both are provided then we need to check the fit + if (width && height) { + const calculated = calculateImageDimensions(sourceMetadata, { + width, + height, + fit, + }) + width = calculated.width + height = calculated.height + aspectRatio = calculated.aspectRatio + } + + // Case 1: width of height were passed in, make sure it isn't larger than the actual image + width = width && Math.round(Math.min(width, sourceMetadata.width)) + height = height && Math.min(height, sourceMetadata.height) + + const originalWidth = width + + if (breakpoints && breakpoints.length > 0) { + sizes = breakpoints.filter(size => size <= sourceMetadata.width) + + // If a larger breakpoint has been filtered-out, add the actual image width instead + if ( + sizes.length < breakpoints.length && + !sizes.includes(sourceMetadata.width) + ) { + sizes.push(sourceMetadata.width) + } + } else { + sizes = Array.from(densities).map(density => + Math.round(density * (width as number)) + ) + sizes = sizes.filter(size => size <= sourceMetadata.width) + } + + // ensure that the size passed in is included in the final output + if (layout === `constrained` && !sizes.includes(width)) { + sizes.push(width) + } + + sizes = sizes.sort(sortNumeric) + + return { + sizes, + aspectRatio, + presentationWidth: originalWidth, + presentationHeight: Math.round(originalWidth / aspectRatio), + unscaledWidth: width, + } +} + +// eslint-disable-next-line consistent-return +function getSizesAttrFromLayout(layout: ImageLayout, width: number): string { + 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 `fullWidth`: + return `100vw` + } +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/get-remote-file-enums.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/get-remote-file-enums.ts new file mode 100644 index 0000000000000..0d76c1f3017a8 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/get-remote-file-enums.ts @@ -0,0 +1,77 @@ +import type { + EnumTypeComposerAsObjectDefinition, + EnumTypeComposer, +} from "graphql-compose" + +interface IEnumArgs { + fit: EnumTypeComposer + layout: EnumTypeComposer + placeholder: EnumTypeComposer + format: EnumTypeComposer + cropFocus: EnumTypeComposer +} + +export function getRemoteFileEnums( + buildEnumType: (obj: EnumTypeComposerAsObjectDefinition) => EnumTypeComposer +): IEnumArgs { + const remoteFileFit = buildEnumType({ + name: `RemoteFileFit`, + values: { + COVER: { value: `cover` }, + FILL: { value: `fill` }, + OUTSIDE: { value: `outside` }, + CONTAIN: { value: `contain` }, + }, + }) + + const remoteFormatEnum = buildEnumType({ + name: `RemoteFileFormat`, + values: { + AUTO: { value: `auto` }, + JPG: { value: `jpg` }, + PNG: { value: `png` }, + WEBP: { value: `webp` }, + AVIF: { value: `avif` }, + }, + }) + + const remoteLayoutEnum = buildEnumType({ + name: `RemoteFileLayout`, + values: { + FIXED: { value: `fixed` }, + FULL_WIDTH: { value: `fullWidth` }, + CONSTRAINED: { value: `constrained` }, + }, + }) + + const remotePlaceholderEnum = buildEnumType({ + name: `RemoteFilePlaceholder`, + values: { + DOMINANT_COLOR: { value: `dominantColor` }, + BLURRED: { value: `blurred` }, + NONE: { value: `none` }, + }, + }) + + const remoteCropFocusEnum = buildEnumType({ + name: `RemoteFileCropFocus`, + values: { + CENTER: { value: `center` }, + TOP: { value: `top` }, + RIGHT: { value: `right` }, + BOTTOM: { value: `bottom` }, + LEFT: { value: `left` }, + ENTROPY: { value: `entropy` }, + EDGES: { value: `edges` }, + FACES: { value: `faces` }, + }, + }) + + return { + fit: remoteFileFit, + format: remoteFormatEnum, + layout: remoteLayoutEnum, + placeholder: remotePlaceholderEnum, + cropFocus: remoteCropFocusEnum, + } +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts new file mode 100644 index 0000000000000..a0a61a68d3b22 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts @@ -0,0 +1,38 @@ +import { generatePublicUrl } from "../utils/url-generator" +import { getFileExtensionFromMimeType } from "../utils/mime-type-helpers" +import { + dispatchLocalFileServiceJob, + shouldDispatch, +} from "../jobs/dispatchers" +import type { Store } from "gatsby" +import type { IRemoteFileNode, IGraphQLFieldConfigDefinition } from "../types" + +export function publicUrlResolver( + source: IRemoteFileNode, + store: Store +): string { + if (shouldDispatch()) { + dispatchLocalFileServiceJob( + { + url: source.url, + mimeType: source.mimeType, + contentDigest: source.internal.contentDigest, + }, + store + ) + } + + const extension = getFileExtensionFromMimeType(source.mimeType) + return generatePublicUrl(source) + `.${extension}` +} + +export function generatePublicUrlFieldConfig( + store: Store +): IGraphQLFieldConfigDefinition { + return { + type: `String!`, + resolve(source): string { + return publicUrlResolver(source, store) + }, + } +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts new file mode 100644 index 0000000000000..8ec9293e3e30c --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts @@ -0,0 +1,110 @@ +import { generatePublicUrl, generateImageArgs } from "../utils/url-generator" +import { getImageFormatFromMimeType } from "../utils/mime-type-helpers" +import { stripIndent } from "../utils/strip-indent" +import { + dispatchLocalImageServiceJob, + shouldDispatch, +} from "../jobs/dispatchers" +import { isImage } from "../types" +import { validateAndNormalizeFormats, calculateImageDimensions } from "./utils" + +import type { Store } from "gatsby" +import type { + IRemoteFileNode, + IGraphQLFieldConfigDefinition, + ImageFit, + ImageFormat, + ImageCropFocus, +} from "../types" +import type { getRemoteFileEnums } from "./get-remote-file-enums" + +interface IResizeArgs { + width: number + height: number + fit: ImageFit + format: ImageFormat + cropFocus: ImageCropFocus +} + +export async function resizeResolver( + source: IRemoteFileNode, + args: IResizeArgs, + store: Store +): Promise<{ + width: number + height: number + src: string +} | null> { + if (!isImage(source)) { + return null + } + + const formats = validateAndNormalizeFormats( + [args.format], + getImageFormatFromMimeType(source.mimeType) + ) + const [format] = formats + const { width, height } = calculateImageDimensions(source, args) + + if (shouldDispatch()) { + dispatchLocalImageServiceJob( + { + url: source.url, + extension: format, + ...args, + format, + contentDigest: source.internal.contentDigest, + }, + store + ) + } + + const src = `${generatePublicUrl(source)}/${generateImageArgs({ + ...args, + format, + })}.${format}` + + return { + src, + width, + height, + } +} + +export function generateResizeFieldConfig( + enums: ReturnType, + store: Store +): IGraphQLFieldConfigDefinition< + IRemoteFileNode, + ReturnType, + IResizeArgs +> { + return { + type: `RemoteFileResize`, + args: { + width: `Int`, + height: `Int`, + fit: { + type: enums.fit.getTypeName(), + defaultValue: enums.fit.getField(`COVER`).value, + }, + format: { + type: enums.format.getTypeName(), + defaultValue: enums.format.getField(`AUTO`).value, + description: stripIndent` + The image formats to generate. Valid values are AUTO (meaning the same format as the source image), JPG, PNG, WEBP and AVIF. + The default value is [AUTO, WEBP, AVIF], 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.`, + }, + cropFocus: { + type: enums.cropFocus.getTypeName(), + defaultValue: enums.cropFocus.getField(`EDGES`) + .value as IResizeArgs["cropFocus"], + }, + }, + resolve(source, args: IResizeArgs): ReturnType { + return resizeResolver(source, args, store) + }, + } +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/utils.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/utils.ts new file mode 100644 index 0000000000000..ee8908b131d8a --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/utils.ts @@ -0,0 +1,86 @@ +import { ImageFormat, ImageFit } from "../types" + +export function validateAndNormalizeFormats( + formats: Array, + sourceFormat: ImageFormat +): Set { + const formatSet = new Set(formats) + + // convert auto in format of source image + if (formatSet.has(`auto`)) { + formatSet.delete(`auto`) + formatSet.add(sourceFormat) + } + + if (formatSet.has(`jpg`) && formatSet.has(`png`)) { + throw new Error(`Cannot specify both JPG and PNG formats`) + } + + return formatSet +} + +/** + * Generate correct width and height like sharp will do + * @see https://sharp.pixelplumbing.com/api-resize#resize + */ +export function calculateImageDimensions( + originalDimensions: { width: number; height: number }, + { + fit, + width: requestedWidth, + height: requestedHeight, + }: { fit: ImageFit; width: number; height: number } +): { width: number; height: number; aspectRatio: number } { + // Calculate the eventual width/height of the image. + const imageAspectRatio = originalDimensions.width / originalDimensions.height + + let width = requestedWidth + let height = requestedHeight + switch (fit) { + case `cover`: { + width = requestedWidth ?? originalDimensions.width + height = requestedHeight ?? originalDimensions.height + break + } + case `inside`: { + const widthOption = requestedWidth ?? Number.MAX_SAFE_INTEGER + const heightOption = requestedHeight ?? 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 = requestedWidth ?? 0 + const heightOption = requestedHeight ?? 0 + + width = Math.max(widthOption, Math.round(heightOption * imageAspectRatio)) + height = Math.max( + heightOption, + Math.round(widthOption / imageAspectRatio) + ) + break + } + + default: { + if (requestedWidth && !requestedHeight) { + width = requestedWidth + height = Math.round(requestedHeight / imageAspectRatio) + } + + if (requestedHeight && !requestedWidth) { + width = Math.round(requestedHeight * imageAspectRatio) + height = requestedHeight + } + } + } + + return { + width, + height, + aspectRatio: width / height, + } +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/http-routes.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/http-routes.ts new file mode 100644 index 0000000000000..26e0c216b1627 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/http-routes.ts @@ -0,0 +1,104 @@ +import path from "path" +import fs from "fs-extra" +import { fetchRemoteFile } from "gatsby-core-utils/fetch-remote-file" +import { hasFeature } from "../has-feature" +import { getFileExtensionFromMimeType } from "./utils/mime-type-helpers" +import { generateImageArgs } from "./utils/url-generator" +import { transformImage } from "./transform-images" + +import type { ImageFit } from "./types" +import type { Application } from "express" + +export function polyfillImageServiceDevRoutes(app: Application): void { + if (hasFeature(`image-cdn`)) { + return + } + + addImageRoutes(app) +} + +export function addImageRoutes(app: Application): Application { + app.get(`/_gatsby/file/:url`, async (req, res) => { + // remove the file extension + const [url] = req.params.url.split(`.`) + const outputDir = path.join( + global.__GATSBY?.root || process.cwd(), + `public`, + `_gatsby`, + `file` + ) + + const filePath = await fetchRemoteFile({ + directory: outputDir, + url: url, + name: req.params.url, + }) + fs.createReadStream(filePath).pipe(res) + }) + + app.get(`/_gatsby/image/:url/:params`, async (req, res) => { + const [params, extension] = req.params.params.split(`.`) + const url = req.params.url + + const searchParams = new URLSearchParams( + Buffer.from(params, `base64`).toString() + ) + + const resizeParams: { + width: number + height: number + format: string + fit: ImageFit + } = { + width: 0, + height: 0, + format: ``, + fit: `cover`, + } + + for (const [key, value] of searchParams) { + switch (key) { + case `w`: { + resizeParams.width = Number(value) + break + } + case `h`: { + resizeParams.height = Number(value) + break + } + case `fm`: { + resizeParams.format = value + break + } + case `fit`: { + resizeParams.fit = value as ImageFit + break + } + } + } + + const remoteUrl = Buffer.from(url, `base64`).toString() + const outputDir = path.join( + global.__GATSBY?.root || process.cwd(), + `public`, + `_gatsby`, + `_image`, + url + ) + + const filePath = await transformImage({ + outputDir, + args: { + url: remoteUrl, + filename: generateImageArgs(resizeParams) + `.${extension}`, + ...resizeParams, + }, + }) + + res.setHeader(`content-type`, getFileExtensionFromMimeType(extension)) + + fs.createReadStream(filePath).pipe(res) + }) + + return app +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts new file mode 100644 index 0000000000000..97f9c26837849 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts @@ -0,0 +1,144 @@ +import path from "path" +import { SchemaComposer } from "graphql-compose" +import { actions } from "gatsby/dist/redux/actions" +import { getRemoteFileEnums } from "./graphql/get-remote-file-enums" +import { getGatsbyVersion } from "./utils/get-gatsby-version" +import { hasFeature } from "../has-feature" +import { + generatePublicUrlFieldConfig, + publicUrlResolver, +} from "./graphql/public-url-resolver" +import { + generateResizeFieldConfig, + resizeResolver, +} from "./graphql/resize-resolver" +import { + generateGatsbyImageDataFieldConfig, + gatsbyImageDataResolver, +} from "./graphql/gatsby-image-data-resolver" + +import type { Store } from "gatsby" +import type { InterfaceTypeComposerAsObjectDefinition } from "graphql-compose" +import type { SchemaBuilder, IRemoteFileNode } from "./types" + +let enums: ReturnType | undefined + +export function getRemoteFileFields( + enums: ReturnType, + store: Store +): Record { + return { + id: `ID!`, + mimeType: `String!`, + filename: `String!`, + filesize: `Int`, + width: `Int`, + height: `Int`, + publicUrl: generatePublicUrlFieldConfig(store), + resize: generateResizeFieldConfig(enums, store), + gatsbyImageData: generateGatsbyImageDataFieldConfig(enums, store), + } +} + +function addRemoteFilePolyfillInterface< + T = ReturnType +>( + type: T, + { + schema, + store, + }: { + schema: SchemaBuilder + store: Store + } +): T { + // When the image-cdn is part of Gatsby we will only add the RemoteFile interface if necessary + if (hasFeature(`image-cdn`)) { + // @ts-ignore - wrong typing by typecomposer + if (!type.config.interfaces.includes(`RemoteFile`)) { + // @ts-ignore - wrong typing by typecomposer + type.config.interfaces.push(`RemoteFile`) + } + + return type + } + + if (!enums) { + // We only want to create the enums and interface once + const composer = new SchemaComposer() + enums = getRemoteFileEnums(composer.createEnumTC.bind(composer)) + + const types: Array< + | string + | ReturnType + | ReturnType + | ReturnType + > = [] + + for (const key in enums) { + if (enums[key]) { + types.push( + schema.buildEnumType({ + name: enums[key].getTypeName(), + values: enums[key].getFields(), + }) + ) + } + } + + types.push( + schema.buildObjectType({ + name: `RemoteFileResize`, + fields: { + width: `Int`, + height: `Int`, + src: `String`, + }, + }), + schema.buildInterfaceType({ + name: `RemoteFile`, + interfaces: [`Node`], + fields: getRemoteFileFields( + enums, + store + ) as InterfaceTypeComposerAsObjectDefinition< + IRemoteFileNode, + unknown + >["fields"], + }) + ) + + store.dispatch( + actions.createTypes(types, { + name: `gatsby`, + version: getGatsbyVersion(), + resolve: path.join(__dirname, `../`), + }) + ) + } + + // @ts-ignore - wrong typing by typecomposer + type.config.interfaces = type.config.interfaces || [] + // @ts-ignore - wrong typing by typecomposer + if (!type.config.interfaces.includes(`RemoteFile`)) { + // @ts-ignore - wrong typing by typecomposer + type.config.interfaces.push(`RemoteFile`) + } + // @ts-ignore - wrong typing by typecomposer + type.config.fields = { + // @ts-ignore - wrong typing by typecomposer + ...type.config.fields, + ...getRemoteFileFields(enums, store), + } + + return type +} + +export { polyfillImageServiceDevRoutes, addImageRoutes } from "./http-routes" +export { + getRemoteFileEnums, + addRemoteFilePolyfillInterface, + gatsbyImageDataResolver, + resizeResolver, + publicUrlResolver, +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts new file mode 100644 index 0000000000000..b49eac8166213 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts @@ -0,0 +1,107 @@ +import path from "path" +import { actions } from "gatsby/dist/redux/actions" +import { getFileExtensionFromMimeType } from "../utils/mime-type-helpers" +import { getGatsbyVersion } from "../utils/get-gatsby-version" +import { generatePublicUrl, generateImageArgs } from "../utils/url-generator" +import type { Store } from "gatsby" +import type { ImageFit } from "../types" + +export function shouldDispatch(): boolean { + return ( + !( + process.env.GATSBY_CLOUD_IMAGE_CDN === `1` || + process.env.GATSBY_CLOUD_IMAGE_CDN === `true` + ) && process.env.NODE_ENV === `production` + ) +} + +export function dispatchLocalFileServiceJob( + { + url, + mimeType, + contentDigest, + }: { url: string; mimeType: string; contentDigest: string }, + store: Store +): void { + const GATSBY_VERSION = getGatsbyVersion() + const publicUrl = generatePublicUrl({ url, mimeType }).split(`/`) + const extension = getFileExtensionFromMimeType(mimeType) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const filename = publicUrl.pop() + publicUrl.unshift(`public`) + + actions.createJobV2( + { + name: `FILE_CDN`, + inputPaths: [], + // we know it's an image so we just mimic an image + outputDir: path.join( + global.__GATSBY?.root || process.cwd(), + publicUrl.filter(Boolean).join(`/`) + ), + args: { + url, + filename: `${filename}.${extension}`, + contentDigest, + }, + }, + { + name: `gatsby`, + version: GATSBY_VERSION, + resolve: __dirname, + } + )(store.dispatch, store.getState) +} + +export function dispatchLocalImageServiceJob( + { + url, + extension, + width, + height, + format, + fit, + contentDigest, + }: { + url: string + extension: string + width: number + height: number + format: string + fit: ImageFit + contentDigest: string + }, + store: Store +): void { + const GATSBY_VERSION = getGatsbyVersion() + const publicUrl = generatePublicUrl({ + url, + mimeType: `image/${extension}`, + }).split(`/`) + publicUrl.unshift(`public`) + actions.createJobV2( + { + name: `IMAGE_CDN`, + inputPaths: [], + outputDir: path.join( + global.__GATSBY?.root || process.cwd(), + publicUrl.filter(Boolean).join(`/`) + ), + args: { + url, + filename: + generateImageArgs({ width, height, format, fit }) + `.${extension}`, + width, + height, + format, + fit, + contentDigest, + }, + }, + { + name: `gatsby`, + version: GATSBY_VERSION, + resolve: __dirname, + } + )(store.dispatch, store.getState) +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/gatsby-worker.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/gatsby-worker.ts new file mode 100644 index 0000000000000..280ae39f4da5d --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/gatsby-worker.ts @@ -0,0 +1,58 @@ +import { fetchRemoteFile } from "gatsby-core-utils/fetch-remote-file" +import { cpuCoreCount } from "gatsby-core-utils/cpu-core-count" +import Queue from "fastq" +import { transformImage } from "../transform-images" + +interface IImageServiceProps { + outputDir: Parameters[0]["outputDir"] + args: Parameters[0]["args"] & { + contentDigest: string + } +} + +const queue = Queue( + async function transform(task, cb): Promise { + try { + return void cb(null, await transformImage(task)) + } catch (e) { + return void cb(e) + } + }, + // When inside query workers, we only want to use the current core + process.env.GATSBY_WORKER_POOL_WORKER ? 1 : Math.max(1, cpuCoreCount() - 1) +) + +// eslint-disable-next-line @typescript-eslint/naming-convention +export async function FILE_CDN({ + outputDir, + args: { url, filename, contentDigest }, +}: { + outputDir: string + args: { url: string; filename: string; contentDigest: string } +}): Promise { + await fetchRemoteFile({ + directory: outputDir, + url: url, + name: filename, + cacheKey: contentDigest, + }) +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +export async function IMAGE_CDN(args: { + outputDir: Parameters[0]["outputDir"] + args: Parameters[0]["args"] & { + contentDigest: string + } +}): Promise { + return new Promise((resolve, reject) => { + queue.push(args, err => { + if (err) { + reject(err) + return + } + + resolve() + }) + }) +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts new file mode 100644 index 0000000000000..a4e83a3be22cd --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts @@ -0,0 +1,262 @@ +import path from "path" +import { createReadStream, readFile, mkdtemp } from "fs-extra" +import { fetchRemoteFile } from "gatsby-core-utils/fetch-remote-file" +import { createMutex } from "gatsby-core-utils/mutex" +import Queue from "fastq" +import getSharpInstance from "gatsby-sharp" +import { getCache } from "./utils/cache" +import { getImageFormatFromMimeType } from "./utils/mime-type-helpers" +import type { IRemoteImageNode } from "./types" + +export enum PlaceholderType { + BLURRED = `blurred`, + DOMINANT_COLOR = `dominantColor`, +} +interface IPlaceholderGenerationArgs { + placeholderUrl: string | undefined + originalUrl: string + format: string + width: number + height: number + contentDigest: string +} + +const QUEUE_CONCURRENCY = 5 + +let tmpDir: string + +function getMutexKey(contentDigest: string): string { + return `gatsby-plugin-utils:placeholder:${contentDigest}` +} + +const queue = Queue< + undefined, + { + url: string + contentDigest: string + width: number + height: number + hasCorrectFormat: boolean + type: PlaceholderType + }, + string + // eslint-disable-next-line consistent-return +>(async function ( + { url, contentDigest, width, height, type }, + cb +): Promise { + const sharp = await getSharpInstance() + + if (!tmpDir) { + const cache = getCache() + tmpDir = await mkdtemp(path.join(cache.directory, `placeholder-`)) + } + + const filePath = await fetchRemoteFile({ + url, + cacheKey: contentDigest, + directory: tmpDir, + }) + + switch (type) { + case PlaceholderType.BLURRED: { + let buffer: Buffer + + try { + const fileStream = createReadStream(filePath) + const pipeline = sharp() + fileStream.pipe(pipeline) + buffer = await pipeline + .resize(20, Math.ceil(20 / (width / height))) + .toFormat(`jpg`) + .toBuffer() + } catch (e) { + buffer = await readFile(filePath) + } + + return cb(null, `data:image/jpg;base64,${buffer.toString(`base64`)}`) + } + case PlaceholderType.DOMINANT_COLOR: { + const fileStream = createReadStream(filePath) + const pipeline = sharp({ failOnError: false }) + fileStream.pipe(pipeline) + const { dominant } = await pipeline.stats() + + return cb( + null, + dominant + ? `rgb(${dominant.r},${dominant.g},${dominant.b})` + : `rgba(0,0,0,0)` + ) + } + } +}, +QUEUE_CONCURRENCY) + +// eslint-disable-next-line consistent-return +export async function generatePlaceholder( + source: IRemoteImageNode, + placeholderType: PlaceholderType +): Promise<{ fallback?: string; backgroundColor?: string }> { + switch (placeholderType) { + case PlaceholderType.BLURRED: { + return { + fallback: await placeholderToBase64({ + placeholderUrl: source.placeholderUrl, + originalUrl: source.url, + format: getImageFormatFromMimeType(source.mimeType), + width: source.width, + height: source.height, + contentDigest: source.internal.contentDigest, + }), + } + } + case PlaceholderType.DOMINANT_COLOR: { + return { + backgroundColor: await placeholderToDominantColor({ + placeholderUrl: source.placeholderUrl, + originalUrl: source.url, + format: getImageFormatFromMimeType(source.mimeType), + width: source.width, + height: source.height, + contentDigest: source.internal.contentDigest, + }), + } + } + } +} + +async function placeholderToBase64({ + placeholderUrl, + originalUrl, + width, + height, + contentDigest, +}: IPlaceholderGenerationArgs): Promise { + const cache = getCache() + const cacheKey = `image-cdn:${contentDigest}:base64` + const cachedValue = await cache.get(cacheKey) + if (cachedValue) { + return cachedValue + } + + const mutex = createMutex(getMutexKey(contentDigest)) + await mutex.acquire() + + try { + let url = originalUrl + let hasWidthOrHeightAttr = false + if (placeholderUrl) { + hasWidthOrHeightAttr = + placeholderUrl.includes(`%width%`) || + placeholderUrl.includes(`%height%`) + url = generatePlaceholderUrl({ + url: placeholderUrl, + width: 20, + originalWidth: width, + originalHeight: height, + }) + } + + const base64Placeholder = await new Promise((resolve, reject) => { + queue.push( + { + url, + contentDigest, + width, + height, + hasCorrectFormat: hasWidthOrHeightAttr, + type: PlaceholderType.BLURRED, + }, + (err, result) => { + if (err) { + reject(err) + return + } + + resolve(result as string) + } + ) + }) + + cache.set(cacheKey, base64Placeholder) + + return base64Placeholder + } finally { + await mutex.release() + } +} + +async function placeholderToDominantColor({ + placeholderUrl, + originalUrl, + width, + height, + contentDigest, +}: IPlaceholderGenerationArgs): Promise { + const cache = getCache() + const cacheKey = `image-cdn:${contentDigest}:dominantColor` + const cachedValue = await cache.get(cacheKey) + if (cachedValue) { + return cachedValue + } + + const mutex = createMutex(getMutexKey(contentDigest)) + await mutex.acquire() + try { + let url = originalUrl + if (placeholderUrl) { + url = generatePlaceholderUrl({ + url: placeholderUrl, + width: 200, + originalWidth: width, + originalHeight: height, + }) + } + + const dominantColor = await new Promise((resolve, reject) => { + queue.push( + { + url, + contentDigest, + width, + height, + hasCorrectFormat: true, + type: PlaceholderType.DOMINANT_COLOR, + }, + (err, result) => { + if (err) { + reject(err) + return + } + + resolve(result as string) + } + ) + }) + + cache.set(cacheKey, dominantColor) + + return dominantColor + } finally { + await mutex.release() + } +} + +function generatePlaceholderUrl({ + url, + width, + originalWidth, + originalHeight, +}: { + url: string + width: number + originalWidth: number + originalHeight: number +}): string { + const aspectRatio = originalWidth / originalHeight + + return url + .replace(`%width%`, String(width)) + .replace(`%height%`, Math.floor(width / aspectRatio).toString()) +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/transform-images.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/transform-images.ts new file mode 100644 index 0000000000000..6db9152dda07e --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/transform-images.ts @@ -0,0 +1,151 @@ +import path from "path" +import { readFile, writeFile, pathExists, mkdirp } from "fs-extra" +import { fetchRemoteFile } from "gatsby-core-utils/fetch-remote-file" +import { createContentDigest } from "gatsby-core-utils/create-content-digest" +import getSharpInstance from "gatsby-sharp" +import { getCache } from "./utils/cache" + +export interface IResizeArgs { + width: number + height: number + format: string + outputPath?: string +} + +// Lots of work to get the sharp instance +type Pipeline = ReturnType>> + +// queue is used inside transformImage to batch multiple transforms together +// more info inside the transformImage function +const queue = new Map< + string, + { transforms: Array; promise: Promise } +>() + +// eslint-disable-next-line @typescript-eslint/naming-convention +export async function transformImage({ + outputDir, + args: { url, filename, contentDigest, ...args }, +}: { + outputDir: string + args: { + url: string + filename: string + width: number + height: number + format: string + fit: import("sharp").FitEnum[keyof import("sharp").FitEnum] + contentDigest?: string + } +}): Promise { + const cache = getCache() + + const digest = createContentDigest({ url, filename, contentDigest, args }) + const cacheKey = `image-cdn:` + digest + `:transform` + const cachedValue = (await cache.get(cacheKey)) as string | undefined + if (cachedValue && (await pathExists(cachedValue))) { + return cachedValue + } + + const [basename, ext] = filename.split(`.`) + const filePath = await fetchRemoteFile({ + directory: cache.directory, + url: url, + name: basename, + ext: `.${ext}`, + cacheKey: contentDigest, + }) + + const outputPath = `${outputDir}/${filename}` + await mkdirp(path.dirname(outputPath)) + + // if the queue already contains the url, we're going to add it to queue so, we can batch the transforms together. + // We use setImmediate to not block the event loop so the queue can fill up. + if (queue.has(url)) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const queued = queue.get(url)! + + queued.transforms.push({ ...args, outputPath }) + + return queued.promise.then(() => { + cache.set(cacheKey, outputPath) + + return outputPath + }) + } else { + const defer = new Promise((resolve, reject) => { + setImmediate(async () => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const transforms = queue.get(url)!.transforms + queue.delete(url) + + try { + await resize(await readFile(filePath), transforms) + await cache.set(cacheKey, outputPath) + + resolve(outputPath) + } catch (err) { + reject(err) + } + }) + }) + + queue.set(url, { + promise: defer, + transforms: [{ ...args, outputPath }], + }) + + return defer + } +} + +async function resizeImageWithSharp( + pipeline: Pipeline | Buffer, + { width, height, format, outputPath }: IResizeArgs +): Promise { + if (pipeline instanceof Buffer) { + if (!outputPath) { + return pipeline + } + + return writeFile(outputPath, pipeline) + } + + const resizedImage = pipeline + .resize(width, height, {}) + .toFormat( + format as unknown as keyof Awaited< + ReturnType + >["format"] + ) + + if (outputPath) { + await writeFile(outputPath, await resizedImage.toBuffer()) + return undefined + } else { + return await resizedImage.toBuffer() + } +} + +async function resize( + buffer: Buffer, + transforms: IResizeArgs | Array +): Promise> { + const sharp = await getSharpInstance() + + let pipeline: Pipeline | undefined + if (sharp) { + pipeline = sharp(buffer) + } + + if (Array.isArray(transforms)) { + const results: Array = [] + for (const transform of transforms) { + results.push(await resizeImageWithSharp(pipeline ?? buffer, transform)) + } + + return results + } else { + return resizeImageWithSharp(pipeline ?? buffer, transforms) + } +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts new file mode 100644 index 0000000000000..8f22fcfa316b4 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts @@ -0,0 +1,79 @@ +import type { Node, GatsbyNode } from "gatsby" + +export interface IRemoteFileNode extends Node { + url: string + mimeType: string + filename: string + filesize?: number +} + +export interface IRemoteImageNode extends IRemoteFileNode { + width: number + height: number + placeholderUrl?: string +} + +type GraphqlType = T extends number + ? "Int" | "Float" + : T extends boolean + ? "Boolean" + : string + +export interface IGraphQLFieldConfigDefinition< + TSource, + R, + TArgs = Record +> { + type: string + args?: { + [Property in keyof TArgs]: + | GraphqlType + | { + type: GraphqlType + description?: string + defaultValue?: TArgs[Property] + } + } + resolve(source: TSource, args: TArgs): R +} + +export type SchemaBuilder = Parameters< + NonNullable +>[0]["schema"] + +export type ImageFit = import("sharp").FitEnum[keyof import("sharp").FitEnum] +export type ImageFormat = "jpg" | "png" | "webp" | "avif" | "auto" +export type ImageLayout = "fixed" | "constrained" | "fullWidth" +export type ImageCropFocus = + | "center" + | "top" + | "right" + | "bottom" + | "left" + | "entropy" + | "edges" + | "faces" + +export type WidthOrHeight = + | { width: number; height: never } + | { width: never; height: number } + | { width: number; height: number } + +export type CalculateImageSizesArgs = WidthOrHeight & { + fit: ImageFit + layout: ImageLayout + outputPixelDensities: Array + breakpoints?: Array +} + +export function isImage(node: { + mimeType: IRemoteFileNode["mimeType"] +}): node is IRemoteImageNode { + if (!node.mimeType) { + throw new Error( + `RemoteFileNode does not have a mimeType. The field is required.` + ) + } + + return node.mimeType.startsWith(`image/`) && node.mimeType !== `image/svg+xml` +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/cache.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/cache.ts new file mode 100644 index 0000000000000..8e9bcc5fde181 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/cache.ts @@ -0,0 +1,6 @@ +import { getCache as getGatsbyCache } from "gatsby/dist/utils/get-cache" +import type { GatsbyCache } from "gatsby" + +export function getCache(): GatsbyCache { + return getGatsbyCache(`gatsby`) +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/get-gatsby-version.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/get-gatsby-version.ts new file mode 100644 index 0000000000000..47af2740a6d09 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/get-gatsby-version.ts @@ -0,0 +1,10 @@ +let GATSBY_VERSION: string + +export function getGatsbyVersion(): string { + if (!GATSBY_VERSION) { + const gatsbyJSON = require(`gatsby/package.json`) + GATSBY_VERSION = gatsbyJSON.version + } + + return GATSBY_VERSION +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/mime-type-helpers.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/mime-type-helpers.ts new file mode 100644 index 0000000000000..8c8f8db444019 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/mime-type-helpers.ts @@ -0,0 +1,14 @@ +import mime from "mime" + +export type ImageFormat = "jpg" | "png" | "webp" | "avif" | "auto" + +export function getImageFormatFromMimeType(mimeType: string): ImageFormat { + return mimeType + .replace(`image/jpeg`, `image/jpg`) + .replace(`image/`, ``) as ImageFormat +} + +export function getFileExtensionFromMimeType(mimeType: string): string { + // convert jpeg to jpg and make up extension if we return null + return mime.getExtension(mimeType)?.replace(`jpeg`, `jpg`) ?? `gatsby` +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/strip-indent.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/strip-indent.ts new file mode 100644 index 0000000000000..d1dcc4461b320 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/strip-indent.ts @@ -0,0 +1,14 @@ +export function stripIndent( + tpl: ReadonlyArray, + ...expressions: ReadonlyArray +): string { + let str = `` + + tpl.forEach((chunk, index) => { + str += + chunk.replace(/^(\\n)*[ ]+/gm, `$1`) + + (expressions[index] ? expressions[index] : ``) + }) + + return str +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts new file mode 100644 index 0000000000000..a1b955a0dd50e --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts @@ -0,0 +1,40 @@ +import { isImage } from "../types" +import type { ImageFit, WidthOrHeight } from "../types" + +export function generatePublicUrl({ + url, + mimeType, +}: { + url: string + mimeType: string +}): string { + const remoteUrl = Buffer.from(url).toString(`base64`) + + let publicUrl = isImage({ mimeType }) ? `/_gatsby/image/` : `/_gatsby/file/` + publicUrl += `${remoteUrl}` + + return publicUrl +} + +export function generateImageArgs({ + width, + height, + format, + fit, +}: WidthOrHeight & { format: string; fit: ImageFit }): string { + const args: Array = [] + if (width) { + args.push(`w=${width}`) + } + if (height) { + args.push(`h=${height}`) + } + if (fit) { + args.push(`fit=${fit}`) + } + if (format) { + args.push(`fm=${format}`) + } + + return Buffer.from(args.join(`&`)).toString(`base64`) +} diff --git a/packages/gatsby/index.d.ts b/packages/gatsby/index.d.ts index 6769f7d744bd2..dc9b48971ac09 100644 --- a/packages/gatsby/index.d.ts +++ b/packages/gatsby/index.d.ts @@ -17,7 +17,7 @@ import { GraphQLOutputType } from "graphql" import { PluginOptionsSchemaJoi, ObjectSchema } from "gatsby-plugin-utils" import { IncomingMessage, ServerResponse } from "http" -export type AvailableFeatures = never // "image-service" +export type AvailableFeatures = "image-cdn" export { default as Link, diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index c26c0bae98e34..747151df83e26 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -168,6 +168,7 @@ "@babel/helper-plugin-utils": "^7.14.5", "@babel/register": "^7.15.3", "@types/eslint": "^8.2.1", + "@types/express": "^4.17.13", "@types/micromatch": "^4.0.1", "@types/normalize-path": "^3.0.0", "@types/reach__router": "^1.3.5", diff --git a/packages/gatsby/scripts/__tests__/api.js b/packages/gatsby/scripts/__tests__/api.js index 6b7a023f63609..bfd0008cf7104 100644 --- a/packages/gatsby/scripts/__tests__/api.js +++ b/packages/gatsby/scripts/__tests__/api.js @@ -30,7 +30,9 @@ it("generates the expected api output", done => { "wrapPageElement": Object {}, "wrapRootElement": Object {}, }, - "features": Array [], + "features": Array [ + "image-service", + ], "node": Object { "createPages": Object {}, "createPagesStatefully": Object {}, diff --git a/packages/gatsby/scripts/output-api-file.js b/packages/gatsby/scripts/output-api-file.js index 94075948aa8de..6e09e74727574 100644 --- a/packages/gatsby/scripts/output-api-file.js +++ b/packages/gatsby/scripts/output-api-file.js @@ -40,7 +40,8 @@ async function outputFile() { return merged }, {}) - output.features = []; + /** @type {Array} */ + output.features = ["image-cdn"]; return fs.writeFile( path.resolve(OUTPUT_FILE_NAME), diff --git a/packages/gatsby/src/commands/serve.ts b/packages/gatsby/src/commands/serve.ts index efeb2d637f94c..690e2b8d3b342 100644 --- a/packages/gatsby/src/commands/serve.ts +++ b/packages/gatsby/src/commands/serve.ts @@ -185,7 +185,7 @@ module.exports = async (program: IServeProgram): Promise => { express.json(), express.raw(), async (req, res, next) => { - const { "0": pathFragment } = req.params + const { "0": pathFragment } = req.params as { 0: string } // Check first for exact matches. let functionObj = functions.find( diff --git a/packages/gatsby/src/schema/schema-composer.ts b/packages/gatsby/src/schema/schema-composer.ts index a6b7a0f32ada6..db3e36a9d00c9 100644 --- a/packages/gatsby/src/schema/schema-composer.ts +++ b/packages/gatsby/src/schema/schema-composer.ts @@ -3,6 +3,7 @@ import { addDirectives, GraphQLFieldExtensionDefinition } from "./extensions" import { GraphQLDate } from "./types/date" import { IGatsbyResolverContext } from "./type-definitions" import { getNodeInterface } from "./types/node-interface" +import { getOrCreateRemoteFileInterface } from "./types/remote-file-interface" export const createSchemaComposer = ({ fieldExtensions, @@ -12,7 +13,10 @@ export const createSchemaComposer = ({ const schemaComposer: SchemaComposer> = new SchemaComposer() + // set default interfaces so plugins can use them getNodeInterface({ schemaComposer }) + getOrCreateRemoteFileInterface(schemaComposer) + schemaComposer.add(GraphQLDate) schemaComposer.add(GraphQLJSON) addDirectives({ schemaComposer, fieldExtensions }) diff --git a/packages/gatsby/src/schema/schema.js b/packages/gatsby/src/schema/schema.js index d1855e17370f8..752e4cf82a18d 100644 --- a/packages/gatsby/src/schema/schema.js +++ b/packages/gatsby/src/schema/schema.js @@ -25,6 +25,10 @@ const report = require(`gatsby-cli/lib/reporter`) const { addNodeInterfaceFields } = require(`./types/node-interface`) const { overridableBuiltInTypeNames } = require(`./types/built-in-types`) const { addInferredTypes } = require(`./infer`) +const { + addRemoteFileInterfaceFields, +} = require(`./types/remote-file-interface`) + const { findOne, findManyPaginated, @@ -202,8 +206,13 @@ const processTypeComposer = async ({ }) if (typeComposer.hasInterface(`Node`)) { - await addNodeInterfaceFields({ schemaComposer, typeComposer, parentSpan }) + await addNodeInterfaceFields({ schemaComposer, typeComposer }) } + + if (typeComposer.hasInterface(`RemoteFile`)) { + addRemoteFileInterfaceFields(schemaComposer, typeComposer) + } + await determineSearchableFields({ schemaComposer, typeComposer, @@ -247,6 +256,7 @@ const addTypes = ({ schemaComposer, types, parentSpan }) => { if (typeof typeOrTypeDef === `string`) { typeOrTypeDef = parseTypeDef(typeOrTypeDef) } + if (isASTDocument(typeOrTypeDef)) { let parsedTypes const createdFrom = `sdl` diff --git a/packages/gatsby/src/schema/types/__tests__/remote-file-interface.ts b/packages/gatsby/src/schema/types/__tests__/remote-file-interface.ts new file mode 100644 index 0000000000000..7e2289433cb0a --- /dev/null +++ b/packages/gatsby/src/schema/types/__tests__/remote-file-interface.ts @@ -0,0 +1,258 @@ +import { store } from "../../../redux" +import { build } from "../../index" +import { + DEFAULT_PIXEL_DENSITIES, + DEFAULT_BREAKPOINTS, +} from "../remote-file-interface" + +interface ISrcsetImageChunk { + url: string + params: string + descriptor: string +} + +jest.mock(`gatsby/reporter`, () => { + return { + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + activityTimer: jest.fn(() => { + return { + start: jest.fn(), + setStatus: jest.fn(), + end: jest.fn(), + } + }), + phantomActivity: jest.fn(() => { + return { + start: jest.fn(), + end: jest.fn(), + } + }), + } +}) + +function extractImageChunks(url: string): { + url: string + params: string +} { + const chunks = url.split(`/`) + return { + url: Buffer.from(chunks[3], `base64`).toString(), + params: Buffer.from(chunks[4], `base64`).toString(), + } +} + +function extractImageChunksFromSrcSet( + srcSet: string +): Array { + const sources = srcSet.split(`,`) + const sourceChunks: Array = [] + for (const source of sources) { + const [url, descriptor] = source.trim().split(` `) + sourceChunks.push({ + ...extractImageChunks(url), + descriptor: descriptor ?? ``, + }) + } + + return sourceChunks +} + +describe(`remote-file`, () => { + let schema + + beforeAll(async () => { + global.__GATSBY = { + root: process.cwd(), + } + + await build({}) + schema = store.getState().schema + }) + + describe(`resize`, () => { + let resize + const remoteFile = { + url: `https://images.unsplash.com/photo-1587300003388-59208cc962cb?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=640`, + contentType: `image/jpg`, + filename: `pauline-loroy-U3aF7hgUSrk-unsplash.jpg`, + width: 1200, + height: 800, + } + + beforeAll(() => { + const fields = schema.getType(`RemoteFile`).getFields() + resize = fields.resize.resolve + }) + + it(`should resize the remote url`, async () => { + const data = await resize( + remoteFile, + { + width: 100, + height: 100, + }, + {}, + {} + ) + const { url, params } = extractImageChunks(data) + + expect(url).toEqual(remoteFile.url) + expect(params).toMatchInlineSnapshot(`"w=100&h=100&fm=jpg"`) + expect(data).toMatchInlineSnapshot( + `"/_gatsby/image/aHR0cHM6Ly9pbWFnZXMudW5zcGxhc2guY29tL3Bob3RvLTE1ODczMDAwMDMzODgtNTkyMDhjYzk2MmNiP2l4bGliPXJiLTEuMi4xJnE9ODAmZm09anBnJmNyb3A9ZW50cm9weSZjcz10aW55c3JnYiZ3PTY0MA==/dz0xMDAmaD0xMDAmZm09anBn"` + ) + }) + }) + + describe(`getImageData`, () => { + let gatsbyImageData + const remoteFile = { + url: `https://images.unsplash.com/photo-1587300003388-59208cc962cb?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=640`, + contentType: `image/jpg`, + filename: `pauline-loroy-U3aF7hgUSrk-unsplash.jpg`, + width: 1200, + height: 800, + } + + beforeAll(() => { + const fields = schema.getType(`RemoteFile`).getFields() + gatsbyImageData = fields.gatsbyImageData.resolve + }) + + it(`should get the correct fixed sizes`, async () => { + const data = await gatsbyImageData( + remoteFile, + { + layout: `fixed`, + formats: [`auto`], + width: 100, + outputPixelDensities: DEFAULT_PIXEL_DENSITIES, + }, + {}, + {} + ) + const { url: fallbackUrl, params: fallbackParams } = extractImageChunks( + data.images.fallback.src + ) + const extractedSrcSet = extractImageChunksFromSrcSet( + data.images.fallback.srcSet + ) + + expect(fallbackUrl).toBe(remoteFile.url) + expect(fallbackParams).toContain(`w=100&h=67`) + expect(data.images.fallback.sizes).toBe(`100px`) + expect(extractedSrcSet).toEqual( + expect.arrayContaining([ + { + url: remoteFile.url, + params: expect.stringContaining(`w=100&h=67`), + descriptor: `100w`, + }, + { + url: remoteFile.url, + params: expect.stringContaining(`w=200&h=133`), + descriptor: `200w`, + }, + ]) + ) + expect(data.layout).toBe(`fixed`) + }) + + it(`should get the correct constrained sizes`, async () => { + const data = await gatsbyImageData( + remoteFile, + { + layout: `constrained`, + formats: [`auto`], + width: 100, + outputPixelDensities: DEFAULT_PIXEL_DENSITIES, + }, + {}, + {} + ) + const { url: fallbackUrl, params: fallbackParams } = extractImageChunks( + data.images.fallback.src + ) + const extractedSrcSet = extractImageChunksFromSrcSet( + data.images.fallback.srcSet + ) + + expect(fallbackUrl).toBe(remoteFile.url) + expect(fallbackParams).toContain(`w=25`) + expect(data.images.fallback.sizes).toBe(`(min-width: 100px) 100px, 100vw`) + expect(extractedSrcSet).toEqual( + expect.arrayContaining([ + { + url: remoteFile.url, + params: expect.stringContaining(`w=25&h=17`), + descriptor: `25w`, + }, + { + url: remoteFile.url, + params: expect.stringContaining(`w=50`), + descriptor: `50w`, + }, + { + url: remoteFile.url, + params: expect.stringContaining(`w=100`), + descriptor: `100w`, + }, + { + url: remoteFile.url, + params: expect.stringContaining(`w=200`), + descriptor: `200w`, + }, + ]) + ) + expect(data.layout).toBe(`constrained`) + }) + + it(`should get the correct fullWidth sizes`, async () => { + const data = await gatsbyImageData( + remoteFile, + { + layout: `fullWidth`, + formats: [`auto`], + width: 100, + outputPixelDensities: DEFAULT_PIXEL_DENSITIES, + breakpoints: DEFAULT_BREAKPOINTS, + }, + {}, + {} + ) + const { url: fallbackUrl, params: fallbackParams } = extractImageChunks( + data.images.fallback.src + ) + const extractedSrcSet = extractImageChunksFromSrcSet( + data.images.fallback.srcSet + ) + + expect(fallbackUrl).toBe(remoteFile.url) + expect(fallbackParams).toContain(`w=750`) + expect(data.images.fallback.sizes).toBe(`100vw`) + expect(extractedSrcSet).toEqual( + expect.arrayContaining([ + { + url: remoteFile.url, + params: expect.stringContaining(`w=750&h=500`), + descriptor: `750w`, + }, + { + url: remoteFile.url, + params: expect.stringContaining(`w=1080`), + descriptor: `1080w`, + }, + { + url: remoteFile.url, + params: expect.stringContaining(`w=1200`), + descriptor: `1200w`, + }, + ]) + ) + expect(data.layout).toBe(`fullWidth`) + }) + }) +}) diff --git a/packages/gatsby/src/schema/types/remote-file-interface.ts b/packages/gatsby/src/schema/types/remote-file-interface.ts new file mode 100644 index 0000000000000..27f7c175dcb1a --- /dev/null +++ b/packages/gatsby/src/schema/types/remote-file-interface.ts @@ -0,0 +1,47 @@ +import { + SchemaComposer, + ObjectTypeComposer, + InterfaceTypeComposer, +} from "graphql-compose" +import { store } from "../../redux/index" +import { + getRemoteFileEnums, + getRemoteFileFields, +} from "gatsby-plugin-utils/polyfill-remote-file" + +export const DEFAULT_PIXEL_DENSITIES = [0.25, 0.5, 1, 2] +export const DEFAULT_BREAKPOINTS = [750, 1080, 1366, 1920] + +export function addRemoteFileInterfaceFields( + schemaComposer: SchemaComposer, + typeComposer: ObjectTypeComposer +): void { + const remoteFileInterfaceType = getOrCreateRemoteFileInterface(schemaComposer) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + typeComposer.addFields(remoteFileInterfaceType.getFields() as any) +} + +export function getOrCreateRemoteFileInterface( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + schemaComposer: SchemaComposer +): InterfaceTypeComposer { + const enums = getRemoteFileEnums( + schemaComposer.createEnumTC.bind(schemaComposer) + ) + + schemaComposer.getOrCreateOTC(`RemoteFileResize`, tc => { + tc.addFields({ + width: `Int`, + height: `Int`, + src: `String`, + }) + }) + + return schemaComposer.getOrCreateIFTC(`RemoteFile`, tc => { + tc.addInterface(`Node`) + tc.setDescription(`Remote Interface`) + + // @ts-ignore - types are messed up by schema composer maybe new version helps here + tc.addFields(getRemoteFileFields(enums, store)) + }) +} diff --git a/packages/gatsby/src/utils/start-server.ts b/packages/gatsby/src/utils/start-server.ts index 01158518a8f74..67e6635647cdd 100644 --- a/packages/gatsby/src/utils/start-server.ts +++ b/packages/gatsby/src/utils/start-server.ts @@ -38,7 +38,6 @@ import { import { getPageData as getPageDataExperimental } from "./get-page-data" import { findPageByPath } from "./find-page-by-path" import apiRunnerNode from "../utils/api-runner-node" -import { Express } from "express" import * as path from "path" import { Stage, IProgram } from "../commands/types" @@ -53,6 +52,8 @@ import { getServerData, IServerData } from "./get-server-data" import { ROUTES_DIRECTORY } from "../constants" import { getPageMode } from "./page-mode" import { configureTrailingSlash } from "./express-middlewares" +import type { Express } from "express" +import { addImageRoutes } from "gatsby-plugin-utils/polyfill-remote-file" type ActivityTracker = any // TODO: Replace this with proper type once reporter is typed @@ -280,12 +281,16 @@ export async function startServer( }) app.get(`/__open-stack-frame-in-editor`, (req, res) => { - const fileName = path.resolve(process.cwd(), req.query.fileName) - const lineNumber = parseInt(req.query.lineNumber, 10) - launchEditor(fileName, isNaN(lineNumber) ? 1 : lineNumber) + if (req.query.fileName) { + const fileName = path.resolve(process.cwd(), req.query.fileName as string) + const lineNumber = parseInt(req.query.lineNumber as string, 10) + launchEditor(fileName, isNaN(lineNumber) ? 1 : lineNumber) + } res.end() }) + addImageRoutes(app) + const webpackDevMiddlewareInstance = webpackDevMiddleware(compiler, { publicPath: devConfig.output.publicPath, stats: `errors-only`, @@ -414,9 +419,9 @@ export async function startServer( return } - const moduleId = req?.query?.moduleId - const lineNumber = parseInt(req.query.lineNumber, 10) - const columnNumber = parseInt(req.query.columnNumber, 10) + const moduleId = req.query?.moduleId + const lineNumber = parseInt((req.query?.lineNumber as string) ?? 1, 10) + const columnNumber = parseInt((req.query?.columnNumber as string) ?? 1, 10) let fileModule for (const module of compilation.modules) { @@ -490,9 +495,9 @@ export async function startServer( sourceContent: null, } - const filePath = req?.query?.filePath - const lineNumber = parseInt(req.query.lineNumber, 10) - const columnNumber = parseInt(req.query.columnNumber, 10) + const filePath: string | undefined = req.query?.filePath as string + const lineNumber = parseInt(req.query?.lineNumber as string, 10) + const columnNumber = parseInt(req.query?.columnNumber as string, 10) if (!filePath) { res.json(emptyResponse) @@ -603,7 +608,7 @@ export async function startServer( const renderResponse = await renderDevHTML({ path: pathObj.path, page: pathObj, - skipSsr: req.query[`skip-ssr`] || false, + skipSsr: Object.prototype.hasOwnProperty.call(req.query, `skip-ssr`), store, htmlComponentRendererPath: PAGE_RENDERER_PATH, directory: program.directory, diff --git a/yarn.lock b/yarn.lock index a94f17efa93b3..6fe8f02844921 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3917,9 +3917,9 @@ integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== "@sindresorhus/is@^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.0.0.tgz#2ff674e9611b45b528896d820d3d7a812de2f0e4" - integrity sha512-FyD2meJpDPjyNQejSjvnhpgI/azsQkA4lGbuu5BQZfjvJ9cbRZXzeWL2HceCekW4lixO9JPesIIQkSoLjeJHNQ== + version "4.4.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.4.0.tgz#e277e5bdbdf7cb1e20d320f02f5e2ed113cd3185" + integrity sha512-QppPM/8l3Mawvh4rn9CNEYIU9bxpXUCRMaX9yUpvBk1nMKusLKpfXGDEKExKaPhLzcn3lzil7pR6rnJ11HgeRQ== "@sindresorhus/slugify@^1.1.2": version "1.1.2" @@ -4158,9 +4158,9 @@ integrity sha512-PbaxAeU8SZhbVd6+IuepvyWN7KAjEThsrkdvITDxKAlN6/abIr3NW3WPzNLjJekqbVijg4YUYsyrVc84xXUHQw== "@types/cacheable-request@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/@types/cacheable-request/-/cacheable-request-6.0.1.tgz#5d22f3dded1fd3a84c0bbeb5039a7419c2c91976" - integrity sha512-ykFq2zmBGOCbpIXtoVbz4SKY5QriWPh3AjyU4G74RYbtt5yOc5OfaY75ftjg7mikMOla1CTGpX3lLbuJh8DTrQ== + version "6.0.2" + resolved "https://registry.yarnpkg.com/@types/cacheable-request/-/cacheable-request-6.0.2.tgz#c324da0197de0a98a2312156536ae262429ff6b9" + integrity sha512-B3xVo+dlKM6nnKTcmm5ZtY/OL8bOAOd2Olee9M1zft65ox50OzjEHW91sDiU9j6cvW8Ejg1/Qkf4xd2kugApUA== dependencies: "@types/http-cache-semantics" "*" "@types/keyv" "*" @@ -4255,21 +4255,23 @@ version "1.2.0" resolved "https://registry.yarnpkg.com/@types/events/-/events-1.2.0.tgz#81a6731ce4df43619e5c8c945383b3e62a89ea86" -"@types/express-serve-static-core@*": - version "4.17.0" - resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.0.tgz#e80c25903df5800e926402b7e8267a675c54a281" - integrity sha512-Xnub7w57uvcBqFdIGoRg1KhNOeEj0vB6ykUM7uFWyxvbdE89GFyqgmUcanAriMr4YOxNFZBAWkfcWIb4WBPt3g== +"@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.18": + version "4.17.28" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz#c47def9f34ec81dc6328d0b1b5303d1ec98d86b8" + integrity sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig== dependencies: "@types/node" "*" + "@types/qs" "*" "@types/range-parser" "*" -"@types/express@^4.17.3": - version "4.17.3" - resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.3.tgz#38e4458ce2067873b09a73908df488870c303bd9" - integrity sha512-I8cGRJj3pyOLs/HndoP+25vOqhqWkAZsWMEmq1qXy/b/M3ppufecUwaK2/TVDVxcV61/iSdhykUjQQ2DLSrTdg== +"@types/express@^4.17.13": + version "4.17.13" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.13.tgz#a76e2995728999bab51a33fabce1d705a3709034" + integrity sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA== dependencies: "@types/body-parser" "*" - "@types/express-serve-static-core" "*" + "@types/express-serve-static-core" "^4.17.18" + "@types/qs" "*" "@types/serve-static" "*" "@types/fs-extra@^9.0.13": @@ -4536,6 +4538,11 @@ version "1.5.2" resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8" +"@types/qs@*": + version "6.9.7" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb" + integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw== + "@types/range-parser@*": version "1.2.3" resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c" @@ -7464,9 +7471,9 @@ color@^3.0.0, color@^3.1.1: color-string "^1.5.4" color@^4.2.0: - version "4.2.1" - resolved "https://registry.yarnpkg.com/color/-/color-4.2.1.tgz#498aee5fce7fc982606c8875cab080ac0547c884" - integrity sha512-MFJr0uY4RvTQUKvPq7dh9grVOTYSFeXja2mBXioCGjnjJoXrAp9jJ1NQTDR73c9nwBSAQiNKloKl5zq9WB9UPw== + version "4.2.0" + resolved "https://registry.yarnpkg.com/color/-/color-4.2.0.tgz#0c782459a3e98838ea01e4bc0fb43310ca35af78" + integrity sha512-hHTcrbvEnGjC7WBMk6ibQWFVDgEFTVmjrz2Q5HlU6ltwxv0JJN2Z8I7uRbWeQLF04dikxs8zgyZkazRJvSMtyQ== dependencies: color-convert "^2.0.1" color-string "^1.9.0" @@ -8913,9 +8920,9 @@ defer-to-connect@^1.0.1: integrity sha512-k09hcQcTDY+cwgiwa6PYKLm3jlagNzQ+RSvhjzESOGOx+MNOuXkxTfEvPrO1IOQ81tArCFYQgi631clB70RpQw== defer-to-connect@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.0.tgz#83d6b199db041593ac84d781b5222308ccf4c2c1" - integrity sha512-bYL2d05vOSf1JEZNx5vSAtPuBMkX8K9EUutg7zlKvTqKXHt7RhWJFbmd7qakVuf13i+IkGmp6FwSsONOf6VYIg== + version "2.0.1" + resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587" + integrity sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg== deferred-leveldown@~5.2.1: version "5.2.1" @@ -11381,9 +11388,9 @@ get-stream@^5.0.0, get-stream@^5.1.0: pump "^3.0.0" get-stream@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.0.tgz#3e0012cb6827319da2706e601a1583e8629a6718" - integrity sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg== + version "6.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" + integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== get-symbol-description@^1.0.0: version "1.0.0" @@ -16409,6 +16416,11 @@ mime@2.5.2, mime@^2.0.3, mime@^2.2.0, mime@^2.4.4, mime@^2.4.6, mime@^2.5.2: resolved "https://registry.yarnpkg.com/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe" integrity sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg== +mime@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7" + integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A== + mimic-fn@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" From 765183b3ba33712fee8cb02fee49928dc2559972 Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Thu, 17 Feb 2022 14:10:31 +0100 Subject: [PATCH 03/46] fix circular dep --- docs/docs/how-to/testing/unit-testing.md | 1 + examples/using-jest/jest.config.js | 1 + .../gatsby-source-wordpress/jest.config.js | 1 + jest.config.js | 1 + packages/gatsby-plugin-utils/package.json | 3 ++- .../src/polyfill-remote-file/index.ts | 8 +++++++- .../src/polyfill-remote-file/jobs/dispatchers.ts | 14 +++++++++++++- .../src/polyfill-remote-file/utils/cache.ts | 8 +++++++- 8 files changed, 33 insertions(+), 4 deletions(-) diff --git a/docs/docs/how-to/testing/unit-testing.md b/docs/docs/how-to/testing/unit-testing.md index fa276fbb3907a..c2700ecde1c27 100644 --- a/docs/docs/how-to/testing/unit-testing.md +++ b/docs/docs/how-to/testing/unit-testing.md @@ -44,6 +44,7 @@ module.exports = { ".+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": `/__mocks__/file-mock.js`, "^gatsby-page-utils/(.*)$": `gatsby-page-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771 "^gatsby-core-utils/(.*)$": `gatsby-core-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771 + "^gatsby-plugin-utils/(.*)$": `gatsby-plugin-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771 }, testPathIgnorePatterns: [`node_modules`, `\\.cache`, `.*/public`], transformIgnorePatterns: [`node_modules/(?!(gatsby)/)`], diff --git a/examples/using-jest/jest.config.js b/examples/using-jest/jest.config.js index 588b2b06ec5b1..ce8e92768bb75 100644 --- a/examples/using-jest/jest.config.js +++ b/examples/using-jest/jest.config.js @@ -7,6 +7,7 @@ module.exports = { ".+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": `/__mocks__/file-mock.js`, "^gatsby-page-utils/(.*)$": `gatsby-page-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771 "^gatsby-core-utils/(.*)$": `gatsby-core-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771 + "^gatsby-plugin-utils/(.*)$": `gatsby-plugin-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771 }, testPathIgnorePatterns: [`node_modules`, `.cache`], transformIgnorePatterns: [`node_modules/(?!(gatsby)/)`], diff --git a/integration-tests/gatsby-source-wordpress/jest.config.js b/integration-tests/gatsby-source-wordpress/jest.config.js index d468a716f3da8..7c821f7d6abd5 100644 --- a/integration-tests/gatsby-source-wordpress/jest.config.js +++ b/integration-tests/gatsby-source-wordpress/jest.config.js @@ -3,5 +3,6 @@ module.exports = { bail: true, moduleNameMapper: { "^gatsby-core-utils/(.*)$": `gatsby-core-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771 + "^gatsby-plugin-utils/(.*)$": `gatsby-plugin-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771 }, } diff --git a/jest.config.js b/jest.config.js index f30f5b8353b19..7371aa759d62f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -48,6 +48,7 @@ module.exports = { "^msgpackr$": `/node_modules/msgpackr/dist/node.cjs`, "^gatsby-page-utils/(.*)$": `gatsby-page-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771 "^gatsby-core-utils/(.*)$": `gatsby-core-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771 + "^gatsby-plugin-utils/(.*)$": `gatsby-plugin-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771 }, snapshotSerializers: [`jest-serializer-path`], collectCoverageFrom: coverageDirs, diff --git a/packages/gatsby-plugin-utils/package.json b/packages/gatsby-plugin-utils/package.json index d9c6584430a66..4d75054465a08 100644 --- a/packages/gatsby-plugin-utils/package.json +++ b/packages/gatsby-plugin-utils/package.json @@ -10,7 +10,8 @@ "./dist/polyfill-remote-file": null, "./dist/utils": null, "./polyfill-remote-file": "./dist/polyfill-remote-file/index.js", - "./dist/polyfill-remote-file/jobs/gatsby-worker.js": "./dist/polyfill-remote-file/jobs/gatsby-worker.js" + "./dist/polyfill-remote-file/jobs/gatsby-worker.js": "./dist/polyfill-remote-file/jobs/gatsby-worker.js", + "./dist/polyfill-remote-file/graphql/*": "./dist/polyfill-remote-file/graphql/*.js" }, "typesVersions": { "*": { diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts index 97f9c26837849..992acb06767f8 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts @@ -1,6 +1,6 @@ import path from "path" import { SchemaComposer } from "graphql-compose" -import { actions } from "gatsby/dist/redux/actions" +import importFrom from "import-from" import { getRemoteFileEnums } from "./graphql/get-remote-file-enums" import { getGatsbyVersion } from "./utils/get-gatsby-version" import { hasFeature } from "../has-feature" @@ -108,7 +108,13 @@ function addRemoteFilePolyfillInterface< }) ) + // We need to use import-from to remove circular dependency + const actions = importFrom( + global.__GATSBY.root ?? process.cwd(), + `gatsby/dist/redux/actions` + ) store.dispatch( + // @ts-ignore - importFrom doesn't work with types actions.createTypes(types, { name: `gatsby`, version: getGatsbyVersion(), diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts index b49eac8166213..84094b7672339 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts @@ -1,5 +1,5 @@ import path from "path" -import { actions } from "gatsby/dist/redux/actions" +import importFrom from "import-from" import { getFileExtensionFromMimeType } from "../utils/mime-type-helpers" import { getGatsbyVersion } from "../utils/get-gatsby-version" import { generatePublicUrl, generateImageArgs } from "../utils/url-generator" @@ -30,6 +30,11 @@ export function dispatchLocalFileServiceJob( const filename = publicUrl.pop() publicUrl.unshift(`public`) + const actions = importFrom( + global.__GATSBY.root ?? process.cwd(), + `gatsby/dist/redux/actions` + ) + // @ts-ignore - we dont have correct typings for this actions.createJobV2( { name: `FILE_CDN`, @@ -79,6 +84,13 @@ export function dispatchLocalImageServiceJob( mimeType: `image/${extension}`, }).split(`/`) publicUrl.unshift(`public`) + + // We need to use import-from to remove circular dependency + const actions = importFrom( + global.__GATSBY.root ?? process.cwd(), + `gatsby/dist/redux/actions` + ) + // @ts-ignore - importFrom doesn't work with types actions.createJobV2( { name: `IMAGE_CDN`, diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/cache.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/cache.ts index 8e9bcc5fde181..681d51bed1007 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/cache.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/cache.ts @@ -1,6 +1,12 @@ -import { getCache as getGatsbyCache } from "gatsby/dist/utils/get-cache" +import importFrom from "import-from" import type { GatsbyCache } from "gatsby" export function getCache(): GatsbyCache { + // We need to use import-from to remove circular dependency + const { getCache: getGatsbyCache } = importFrom( + global.__GATSBY.root ?? process.cwd(), + `gatsby/dist/utils/get-cache` + ) as { getCache: (key: string) => GatsbyCache } + return getGatsbyCache(`gatsby`) } From 7918dfc6d310269c4fc66a5aa2a1f1dc25430a97 Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Thu, 17 Feb 2022 14:27:02 +0100 Subject: [PATCH 04/46] add support to check imagecdn is available --- packages/gatsby-plugin-utils/README.md | 39 +++++++++++++++++++ .../src/polyfill-remote-file/index.ts | 9 +++++ 2 files changed, 48 insertions(+) diff --git a/packages/gatsby-plugin-utils/README.md b/packages/gatsby-plugin-utils/README.md index 601b488f54b51..7676eb03b5bc8 100644 --- a/packages/gatsby-plugin-utils/README.md +++ b/packages/gatsby-plugin-utils/README.md @@ -80,3 +80,42 @@ if (!hasFeature(`image-cdn`)) { // You can polyfill image-cdn here so older versions have support as well } ``` + +### Add ImageCDN support + +Our new ImageCDN allows source plugins to lazily download and process images. if you're a plugin author please use this polyfill to add support for all Gatsby V4 versions. + +For more information check (TODO)[] + +#### Example + +```js +const { + addRemoteFilePolyfillInterface, + polyfillImageServiceDevRoutes, +} = require(`gatsby-plugin-utils/pollyfill-remote-file`) + +/** @type {import('gatsby').createSchemaCustomization} */ +exports.createSchemaCustomization ({ actions, schema, store }) => { + actions.createTypes([ + addRemoteFilePolyfillInterface( + schema.buildObjectType({ + name: `PrefixAsset`, + fields: { + // your fields + }, + interfaces: [`Node`, 'RemoteFile'], + }), + { + schema, + store, + } + ) + ]); +} + +/** @type {import('gatsby').onCreateDevServer} */ +exports.onCreateDevServer = ({ app }) => { + polyfillImageServiceDevRoutes(app) +} +``` diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts index 992acb06767f8..9a8662ddaa3c0 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts @@ -16,6 +16,7 @@ import { generateGatsbyImageDataFieldConfig, gatsbyImageDataResolver, } from "./graphql/gatsby-image-data-resolver" +import { shouldDispatch } from "./jobs/dispatchers" import type { Store } from "gatsby" import type { InterfaceTypeComposerAsObjectDefinition } from "graphql-compose" @@ -140,6 +141,13 @@ function addRemoteFilePolyfillInterface< return type } +function isImageCdnEnabled(): boolean { + return ( + process.env.GATSBY_CLOUD_IMAGE_CDN === `1` || + process.env.GATSBY_CLOUD_IMAGE_CDN === `true` + ) +} + export { polyfillImageServiceDevRoutes, addImageRoutes } from "./http-routes" export { getRemoteFileEnums, @@ -147,4 +155,5 @@ export { gatsbyImageDataResolver, resizeResolver, publicUrlResolver, + isImageCdnEnabled, } From a6ce84f422eb404170bd20c5e51112b4079a71b1 Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Thu, 17 Feb 2022 15:12:13 +0100 Subject: [PATCH 05/46] remove unused code --- packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts index 9a8662ddaa3c0..164aa0413413e 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts @@ -16,7 +16,6 @@ import { generateGatsbyImageDataFieldConfig, gatsbyImageDataResolver, } from "./graphql/gatsby-image-data-resolver" -import { shouldDispatch } from "./jobs/dispatchers" import type { Store } from "gatsby" import type { InterfaceTypeComposerAsObjectDefinition } from "graphql-compose" From ba5630f2a58f511e48466939cd9ad35df25845eb Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Thu, 17 Feb 2022 18:41:33 +0100 Subject: [PATCH 06/46] fix cache --- .../placeholder-handler.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts index a4e83a3be22cd..9f69613cbdca9 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts @@ -135,7 +135,7 @@ async function placeholderToBase64({ }: IPlaceholderGenerationArgs): Promise { const cache = getCache() const cacheKey = `image-cdn:${contentDigest}:base64` - const cachedValue = await cache.get(cacheKey) + let cachedValue = await cache.get(cacheKey) if (cachedValue) { return cachedValue } @@ -143,6 +143,12 @@ async function placeholderToBase64({ const mutex = createMutex(getMutexKey(contentDigest)) await mutex.acquire() + // check cache again after mutex is acquired + cachedValue = await cache.get(cacheKey) + if (cachedValue) { + return cachedValue + } + try { let url = originalUrl let hasWidthOrHeightAttr = false @@ -179,7 +185,7 @@ async function placeholderToBase64({ ) }) - cache.set(cacheKey, base64Placeholder) + await cache.set(cacheKey, base64Placeholder) return base64Placeholder } finally { @@ -196,13 +202,20 @@ async function placeholderToDominantColor({ }: IPlaceholderGenerationArgs): Promise { const cache = getCache() const cacheKey = `image-cdn:${contentDigest}:dominantColor` - const cachedValue = await cache.get(cacheKey) + let cachedValue = await cache.get(cacheKey) if (cachedValue) { return cachedValue } const mutex = createMutex(getMutexKey(contentDigest)) await mutex.acquire() + + // check cache again after mutex is acquired + cachedValue = await cache.get(cacheKey) + if (cachedValue) { + return cachedValue + } + try { let url = originalUrl if (placeholderUrl) { @@ -235,7 +248,7 @@ async function placeholderToDominantColor({ ) }) - cache.set(cacheKey, dominantColor) + await cache.set(cacheKey, dominantColor) return dominantColor } finally { From 1f066a0ba1f3cbfa9cbc05ed2933cdc922ed1651 Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Fri, 18 Feb 2022 08:40:00 +0100 Subject: [PATCH 07/46] update default values when not using GraphQL --- .../graphql/gatsby-image-data-resolver.ts | 16 ++++++++++++++++ .../graphql/resize-resolver.ts | 12 ++++++++++++ 2 files changed, 28 insertions(+) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts index 9cabaf59c24f9..675da5b05e3a1 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts @@ -80,6 +80,22 @@ export async function gatsbyImageDataResolver( return null } + if (!args.formats) { + args.formats = [`auto`, `webp`, `avif`] + } + + if (!args.outputPixelDensities) { + args.outputPixelDensities = DEFAULT_PIXEL_DENSITIES + } + + if (!args.breakpoints) { + args.breakpoints = DEFAULT_BREAKPOINTS + } + + if (!args.fit) { + args.fit = `cover` + } + let backgroundColor = args.backgroundColor const sourceMetadata: ISourceMetadata = { width: source.width, diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts index 8ec9293e3e30c..23258425fec57 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts @@ -39,6 +39,18 @@ export async function resizeResolver( return null } + if (!args.format) { + args.format = `auto` + } + + if (!args.fit) { + args.fit = `cover` + } + + if (!args.cropFocus) { + args.cropFocus = `edges` + } + const formats = validateAndNormalizeFormats( [args.format], getImageFormatFromMimeType(source.mimeType) From 854c890f25a0f81454fb38bb1fc64aad74ca52cb Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Fri, 18 Feb 2022 14:23:23 +0100 Subject: [PATCH 08/46] fix placeholder --- packages/gatsby-plugin-utils/package.json | 1 + .../placeholder-handler.ts | 24 +++++++++---------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/gatsby-plugin-utils/package.json b/packages/gatsby-plugin-utils/package.json index 4d75054465a08..f89a143be94d9 100644 --- a/packages/gatsby-plugin-utils/package.json +++ b/packages/gatsby-plugin-utils/package.json @@ -48,6 +48,7 @@ "dependencies": { "@babel/runtime": "^7.15.4", "gatsby-core-utils": "3.9.0-next.0", + "gatsby-plugin-utils": "^3.3.0-next.0", "graphql-compose": "^9.0.7", "import-from": "^4.0.0", "joi": "^17.4.2", diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts index 9f69613cbdca9..ad63f0b6fd708 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts @@ -143,13 +143,13 @@ async function placeholderToBase64({ const mutex = createMutex(getMutexKey(contentDigest)) await mutex.acquire() - // check cache again after mutex is acquired - cachedValue = await cache.get(cacheKey) - if (cachedValue) { - return cachedValue - } - try { + // check cache again after mutex is acquired + cachedValue = await cache.get(cacheKey) + if (cachedValue) { + return cachedValue + } + let url = originalUrl let hasWidthOrHeightAttr = false if (placeholderUrl) { @@ -210,13 +210,13 @@ async function placeholderToDominantColor({ const mutex = createMutex(getMutexKey(contentDigest)) await mutex.acquire() - // check cache again after mutex is acquired - cachedValue = await cache.get(cacheKey) - if (cachedValue) { - return cachedValue - } - try { + // check cache again after mutex is acquired + cachedValue = await cache.get(cacheKey) + if (cachedValue) { + return cachedValue + } + let url = originalUrl if (placeholderUrl) { url = generatePlaceholderUrl({ From 7c4f628030b5eeabf71b1c201bc8c2dd03d74696 Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Sun, 20 Feb 2022 19:35:35 +0100 Subject: [PATCH 09/46] add basic unit tests --- .../gatsby-pipeline/gatsby-node.js | 2 +- .../__fixtures__/dog-landscape.jpg | Bin 0 -> 22137 bytes .../__fixtures__/dog-portrait.jpg | Bin 0 -> 101011 bytes .../__tests__/gatsby-image-data-resolver.ts | 502 ++++++++++++++++++ .../__tests__/public-resolver.ts | 131 +++++ .../__tests__/resize-resolver.ts | 319 +++++++++++ .../graphql/gatsby-image-data-resolver.ts | 89 +++- .../graphql/public-url-resolver.ts | 8 +- .../graphql/resize-resolver.ts | 46 +- .../src/polyfill-remote-file/graphql/utils.ts | 23 +- .../jobs/__tests__/gatsby-worker.ts | 0 .../polyfill-remote-file/jobs/dispatchers.ts | 13 +- .../src/polyfill-remote-file/tester.js | 13 + .../src/polyfill-remote-file/types.ts | 8 +- .../src/polyfill-remote-file/utils/cache.ts | 2 +- .../utils/url-generator.ts | 16 +- 16 files changed, 1102 insertions(+), 70 deletions(-) create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/__fixtures__/dog-landscape.jpg create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/__fixtures__/dog-portrait.jpg create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-data-resolver.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/__tests__/gatsby-worker.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/tester.js diff --git a/integration-tests/gatsby-pipeline/gatsby-node.js b/integration-tests/gatsby-pipeline/gatsby-node.js index 07cf62385916c..7f312b54226c8 100644 --- a/integration-tests/gatsby-pipeline/gatsby-node.js +++ b/integration-tests/gatsby-pipeline/gatsby-node.js @@ -4,7 +4,7 @@ const path = require("path") const fs = require("fs-extra") /** @type{import('gatsby').createSchemaCustomization} */ -exports.createSchemaCustomization = ({ actions, schema, cache, reporter }) => { +exports.createSchemaCustomization = ({ actions, schema, cache }) => { actions.createTypes( schema.buildObjectType({ name: "MyRemoteFile", diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__fixtures__/dog-landscape.jpg b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__fixtures__/dog-landscape.jpg new file mode 100644 index 0000000000000000000000000000000000000000..997e0e460063220f1c7c87f0d882c4633893d94b GIT binary patch literal 22137 zcmb@sWq2I1(k43QF*D;7Ht-x}=h-tEE@^yZUz*fFdm}B@O@s0|Q8Y9)Q35;DS;jA_j`e3gS|-VxJiR063G7 zsIV9~3IJed@9L~9AxxsFrA-3!695Fj1JD7ifUia-E{;Nqin4%DvUjjI`}hBU(&IAV zQxgEN!0=Dk|C#rHy@NAxbawredjC9GOwC+O001ziPu}L6tK(-nSk5PpVQljszWyKY ztg0;XnLhlQ8HtLgi>sNP3yGAyiG#DFgR_yVndztOf9v<}DFEt!1^=}e zH8VFi7tMcC|9`yv>xciUyHom4`+{Lj|4-Wg6lCJ&?Eb&*z`$7nfdAY5|LDlU0RR+A z007AM|4Czu1pxBt0RZIG|4E}{1^`gL006x?e>VUj0B{Hh2uKJ>C@4tie*^@=zyN`W zuyCITB02&R!sibi6$2Id^CZH-#Ka*YBP1XsqotsrWoKa*6VnCz|El2cAOIBzmH@5^ z0Y(Y{M+JjG1^YV;;QVxEFsOgd{=W$v0ssaH1q}m)g#-Jn_5VBTpX{dz&@g}30SFME zxhN2yuK?f`Rhu~Rm0cUj)ojupFWD<+e7PpB{#1xgB&e7^h!BVn8k5Rom>47~l1eHC zpf*I4M3WAc7EBUM$VZ@tPzk^=rEm<%fC1{!!RWySl&exW7s^6|Vdwy`${2B_+Lh%R z8I=$n9Ng?{qjkF68YdS(w(&9POsrJmn&@J)F_xJsx@DR%%q(#A#WhAk$^`?;A(%Yq zAa$J78XWqh0!d*MX@E!o1&I_8B>7qP|2n|L0gF1+fL35Xg61MLJ$XXtAc`UzD5b1| zat&KDthKePO}$NQ(s)W$k3xTzNAyq&>2;!T05M=dd#PnEj0%L4A(9#p6%J*i40NTG z&M6PoQZgDxlLV&^=+K5ICkZ9_Spb;?MXiX93+9x7jR5}7bSB0ARK;?ZQxOCfDbiI) zAQP5)TZQ=+wj|#A`tLQoB~IAzR=GiOS?anZ!`!v;GewS?>T2hlNn2<^b1HJfq!3pw zI_;E%(3tvDF{dEm$;F@`M{GL5d=^ycNpzs5m}!UF+O85{EU^r0+kQy1$ zk0iV%MIy+QTAYLfR;2{nF8TPl;(oyq(J9y!K%M|8WxR-Cc;xYPEZ6+;=zjvS!uZ{|jO@bIK%G&9 zNduY&2#o?|ny+RGqEQ0VXPr>t9~k}P`5sw@eL)6#emf)kQ3p`bDvPjmXXc-mNyXBLDLul~& zW3moBhS&`TZN6*x=Rr^0fg{!?+=n ztK&voSd&9z2l##1(T=%uZ`VfrG+(^gi0M*qdTpEkcW;4&?cJNYO8(5f-ormuz56~` zC^g^UUmZ?Y`o!9iCwpV}<6OMw|J2<1GibiCPPs2`j`2+GuM}<-*-WfAHC#M;G9E4S zQO-7{y^Rg&*WMGk2;Bb#9Pg|`_06|F(Hy+$xOUK4r`d$(FM`(_!mCcvvf#DYS!ax& zfclp!0+wkSD3=$ zk-|wf+qR?)H0u}N+`I9+UL=?^A7;ZZf50g@boCJaM)!EB>uY)V63UnRK%DcoaP$|j z{1=dw%;V2~&Wng#tS`LRu>*hg@TbM$S|H!UALXdKZq6ppsN2kwg)`rYQ||;uChV6l zL3{2tbHy*8m><=4Z`f6Dyj8?K#Jv1bcu8ed*z#+fusU{&M`F4Rm@d3G|Qp1%0ftud~Qrk6@ddHSTKpb{D0#9_*)O$_Q|#{a5{S- zZIm6~8Km8fiTt$Hlkxev*-_APtVZ|xRD z@HO_y*2B^Ms@neE_Ba25uVkCXjJs^BoWA#=FKhbwz?^UJA%Cxg+afnn@aUzRy-3^B z759hQ=`SNaDv_hMrSI%sS|JtZMcU|&q28{#AbaVoaNNS;ei6_R zK+EdO!Yv`)O!hrl=9H*l$!-70wmv!)jQ&M;-Ff%Y{0GEpi~_?A zv7%#V^$p+63F)@niNCqW^?HXnHdlKat4qDhAjef-U0-_NHFmzER@vDSy|!+LHgyMi zk`(yzXAO}>`8tWBehKTA*Mr2|=3FfJM#JD8H*A9J8SSo>YKhYOmus33ESZ0n80neD z>n4d1ni(TWCpk-MkpqQ=w3ix_^L};DTI?c?%)Dx(Up}iZ6uQ=F9C`TJoW^7R?)~ok z*0dLI|IOz~>egYlmVM{ISMOLKWqGNGc)pvxdCsBwJ@b6JEdId&zhQgugiu#^N^{07 zZU=uko4@X!b@WX@ck+kSjAjNx15*ICFq#yAAzDkBC~3-6m$d3?`MxF)Ni1V>BW>1q z$Sw~XTfNO%cXLCxdQ*2}GhvM8vYMGgI0=?KCZXJh=~M_wHIkfE3Y?Lp2h zerk3@9}~B8XHFsG&+08v+s|#wo%%nVtG>WniTS#K?+Py#jMu%~gI+rV4}XS^$s6mm z9R0xNE4xn1obEZHUL1bj&m^AT`0WI_)agEdIpc?K@NIa}dgNU5yud$=_-VL=)KX6> ziqlS>0A>r7QZYrehD}p@PjM~-t}fQO!Jyal-Ob6ywPb0lWW8l+sb+Nr8`w@~lX4=C zcfp=3n$5JjLH4pfk05r5~u+;L1cO zlYpqfpm3zQ66mzXMJBwLWWk_dmH27u(>n!#h-ZX;T+rA80(3XpLjet zAU@5Wd+D8=T!ATo8waBk`dLOpugC-%ltuv;7G54fYpF*Yj*QwwYcNo3FPTZr-LD;Z zvg|f>S$rv}RniPIQj%9LTwI)&?4K|S45kxCDJG$zrO+iGXX9am1i{(hQ*r68aT+?X z++DtVUtj8H{ZY643sB&e*Aw97E1E9LeefMhX;LXuao(VusG@voy{#imzSsfSd0ckB z>y$qd$zA(swD_IAU%VP9HK;eskAMa73=a}u9FdXZKm@I#08juG(Et_o#(G1s=9Fmj z9v2BV!ue}6J=gRWM(6d1R^=KPC6Ec0=~B>mRxYA2Gj|Y^X|*9iu^~>0VwJk12_$AS z+J^SDfdFyZ@%?lk<91@@v70A*kA>IqYRjFyU7MA@_tHUk_PM;2emC?&^{>YBNdwpS zv(k+xUh^0MqD#MEFPKvz)_Fo~(?Rm9fv9N(T^en%0qNleX=h{A))GA%_95U%A~-CD zo*-=_POB93aJvB+Bc|gL`60?Skt6Y)xCsZw6BIDu9dNk zp$s}Ujd-dx6duWwWK6t%f)sP>NA2{)+1Ii8P(=b z*5J%&PsYQ#x|swjK{`2gUn0g%cX7m;lY#|nzzlc8glmPgR~a=BZL#Sh zMNJV3s;}}RNSIcXaREp2Rh-%xswy=ggmNZ0^*}o5!0}xbMs2K6=LiOtcHhkBEP3_W zN18R+`wMKh2^YJL{N~&pjf#hhE$a)`@APMQb{sCYj2B@i4hz? zzi!6q+!1OBoqh1z8yK!IXEz){d`(?YpR?_*84vnu9?d7?Jy&rNOGmfaLEd;6^*dNu zJXBa63DcEv7o#;U@Dq+;L;-+ZP=Auy7KA6dCvh-KKZ+GtA`(jo9=$M{JE||q^~^4q1LPG*rLv> zG&bng#BB3B`V$yvfBZ-Q?N#ldZpW$>HIMQ^CzGLhhxzI|ON$^169Ic2&Ue2e?}o*# z(|D8$2aV^7iJ7>;9ymRzuBMiC6<~md)9efhpn{Ql@p||rL7}k_eXm20j3tXUGgeGo z{d7A=FKy#Y&S~X5DnWuRaR+k&4D(~<3>4Iv--@hw)vIo0+5S-BQBTiFibXVLEMTXIfmg612-`ex3UW5dV6N`}|*opBAB|1|S@ z*G>XSFk!j*HU7*2L-PEh^_~Ono$W-`ZG^$po>Sw|zIXMqToD!vAy<2IcDVpaJy|ig z9b^{FEB2k*nss_uA|1wTYqF2XBAig>ePg)`eGyyYp`nMRXsLQ!d z@KYSHaZYda-yJh=GIkIJ~QN zzSOSaigg&dBc!|c>E2Btum!4Ng-+U{emRZksM;1W-V@H+mLlVw^!$;YF{qFUUKCjE zT%|^;qdo4dkGs+AR(7M{NTH{&kJz<5wTxDf(~90Nh$V?pz3=?Bz1T;?#4Ki6$u3h& zdP#U|9&DlfYGZk!?&#XhRZn8_k9^}ZV0CTi)%aJY@iV!L{P&EF9`-p6W49?rz4)v; z52t&&HMLi{S~Vg%yM6Q@X&^OG)c$gC9O?0)IOq9;J_Tb@Bxs9*7KDPb?*nevk3EEP zPM4leIDCQe8fd(sKfI2ju;gedL9RnI%N%@NB*iW38>s8mZMAVD57k!gLx^j?)N7m# zcKlLR<>DzYEeq<{HF8(Ka*(>d&{G zSGJitOQ?9B_fp86A~L4na^|v)2-yW&Hv(}`UzzWVE*`!~*Ewk2%XjTe4_*!sw0r9& zXM=y=&Z>%OFsT>mn607F=J%hH%FblxrCByu+^DnNPSaGebI6jeT}xZ;hDu?~W`FC< z?Sgw;ZC{tbO0^yaVaOGCs=I%s^8+r3w~(2UcLjo4bqQ7k7*cYF!0Xd|G9sCKg3UiX z!nWzpTs7Bzz3#~1$=BZ0rYca~IOUqn4Ya7Ox6-XOXp?~5O^o^WS?aYvW9l9jaz)$~ zCmTl#>xYfPvZhd{I|NSA=AAc7?kFKyxgLPiT?fC+D)gCX8q<_pkVvDlm@#bIT^8x1 ziCJi$zZh*T7I$QYDP?UK)8$49!@Yk0QrkpFO7{ik)mDt3>YU%D^c|;Nye{VO!{hA> zx+_9U?aTI_2{QGcJC2bx$8EkE+!RNp)G(;=-ZFJXRmU|wa+X;4_N*{ZgR)G+5~r-n zgJb2Dbso+U7I9vgAaYFE{9W0ENhcNTYAp=48u^|b3Q5Pi;B!MF-NI=kebxGY{U*9Z z&|EI6gTH%?s9Nn3gvPucu}0a!O^s%Tn?i`2?Vg9*(xlsU>gFjre4lfsh@3O=P+~vL zzAXzZ7Rr8kr4klHF=_VD(feagG&aHw_?jXv-RU3AWm`Gp)B3j3d7`f7Ao0PBMVrB;aLco$248ojG-b26M;TP8 zFbZ7D_D#x_wfvSC?l8?24ByUiM?>4{jl_ z@ni`|J{1yfg``9oyz9KPbG4SJMd0xafmyyK)UZNMQl{R>tS-wP{u!@J>fAK?K2D2g zpm_dFTy)K$>tDdq_aV1Xg{-sY>OUssbmI(r!G8e@Z+j# zwu99LZv#aqRaDYkOC!^2tXrbA-Lb1wn#JVPmwdK&E)*7G;yVsFwxa7A8%FoiN|(fF&DO7TTc1?t3M((> zWCe<>DYP0lmX=@IRRubVPXS`XpCDK(ibB)C`TCa)s@cK5pSP$}EF zc%Y&>75_&Pn_EC~w-Bm_;#R}ZK~MQ+!` zgW%%*wD+*`a=WVd85<%rcYWq;8)gF`x$kXzvlZ#BeMoch6$0Wc|A_y1{(ac(xF_r5 z5QTuz*GvD0hi4LQg<^L_1ZiJHYNC#V-9RwZCIr_ih+0)dAU!l3bxyPyiGo`5SC|*m#>WV-aLts+B6HT9`h%%b>+t3={vUeFrXuAB zoy(7i=jSgCHkSNiNSpAdsW7fdYe-$vs1%vjU(T2UkTAbvN!W&{vzkYB=ew!Picj#} zUcw@Vr$j%R?(E48sKUAs+u;6(h+ViXRye$F>hr439_PE4(!tQtYt}s1pXDw4-I-F|US(`XZ{D!Ke5ryy?JU#{g}eh}h-tF6^h__-v)HT71JKhD)HZ{+~V) zmspqh;B^)UdexP!_#1RC&v-Ky4rG1NG;M`rZ`%}o3+bG04d?zP3l-hcUTJA6oH|L5 zlg(jqc$Z+NUbA5W*>J#v6j`K&db2OE@7HbmAM)MG^CUhCgeJ6G5Zl2tSbZhYk~gPp zZ5{KjbV6KOy=)9{*!Zs( zZFRTjc??h=KZv#H@F!QQ_?L30yY~05yhORv&bA)vEZtC*d}n(7)@yZt0;3~!%~Bk^ z?IxZowQl!?yA7=Pf)#=frs`!TmgN4;@?X!qb>xu_&uWlm96Z6lS|W@@4GbwVb-7Z_ zXn$4yW}u0>@-6?MYL%t8jR?My1ECO$;r%F!(&mNwcN?8{<~HFU?DHenbBU(YABz$H z)ICdVv92({Tt)LyWzPE6+Qwn#nUBX~EkTlP({0d7=TJh^Zg31?G)Q}3bVfSq7pK~^ z@OHlO|N5$<^=Nl`>JyiW93aqgu!oCF>~1zpmYEGU&Ks$j*6Q+I9DyLom~=jldD+{k z<}tV6vbk`w5@R?bg@lxu03(KxWeL#Q#ZSbvw$OuDS;GvbgAPR_&>kP+yTB}gntY9) z+7X}>Y!*xm{Y_Rc0d;L-H`Da?(Ybp%5LFzum!+#zLx0TneL`w3&KNZ{i0pIFo=9*R z!7o55l1$3d2%@S*;<|G9#?8WvCF|{8WhG_foo=5GNeG)MDM_sY$tuqbB9SWAB~4R~ zz!5YqB*Pz`7Ijp){xkL)brj((j``p`DJM-dwmdTAB?K{ZN`1sJB{}vqW^G_5!h!30 zcH9*1_|`eASVR`iiFzN(H>6YoNJF3cI`!3=D#zg7VCR5))+OgJ;86CPtTTay10;wq zmL^ESl|VG3G2tl@yx|Bv9hy=O;+Wqv1D9D(>H0E`y&@2o5$27(`h@OrdfF(Q6(Txb zV+`s!&n4&NpEBx?KF&xP?jR(mqCnP>1V)Jn_H<^GxDDFtaMYpqUgL9b$K+{GAC$5j z%wai^JaO_!>&t$0gt}ij)KAcmUTg7VG0w_$CRMXE^Gz~LVN3y^<3@au{>sy%Y*dn> z;IVwO@T>G`BD^pXWiI`sW^|+yzdEvC)yAgs*l7s)o*KIE6?Y&zT_RgmJ?$_>G-ia67MwXG7#?I;QXe+ zI$=s+=B>gYG+=>?`vaHYodKrqfcpV=SOJwaIky`XlC-!&|jLWrmjT=XGtCHKqeSO^Fgv!ii zu;{|2H2&H4hODi`6kO8R*srL!LvW6lZ}6MBBP)MOD)QE=slnpR6@3rjBJqM*$++yU zLfWQ5anQ(Ln{qsE48jk%W?BCNx+ReKVI^jT9)~$eJ|rT?fP1nh-@58$kP}DWqO^uv zxj>Q3iTjKfa2yySla+iYBm_xntkDWBs7V7j4z5+2!Kg{ImnVCMzI~w)v#jwUyMF<{ zn&!@OgiR>?V!2^uJVTr2eVqnQXHQ2sBaAGjo(Ma8#twfoHK4M4lDY$Ib+pWK_Zyen#5~wGr{;UCynTr6C$DHUdzqqq}JB z53Bq!)ZC&fltO1q1{m|zc zUz>4-QklLG+7p@uOYEIy%p@;0%982i2A>7<3=id8O<39@w2H-Fz?ZP)b=ppRx#G$< zF=dY}qj5`&e1bPBq3`Sf>-NCYo>SuG@_iG#Ie42=$Wbii|5OC|%W2W_lSag8^N~FD1ok;}Abfw*f zrnO+)&u=}7em}J8&dP7Xm5ar+H=7-@*liXmy^91ZCCC-n-7F;Kn(4O|D9m?e^mKkJ zN;y=LLd1PXOD5%B1FVSEy%aT}oceI(X>PoQ$i7us+k6+WG)*}T3^7+4`U?P}-TGq2 zxJ;@iVh1z-R&Sh{D{M|>{j;jMlQ8iWh~6uf`%+FTID?gwY#?5h;p(VrW!Jo}ec>K7 zcbxUe#a)X<`UjT0%r+ZS|BGM#!h~otrS5)HYgAjI;aR&(bx6bLvLZf_0x|WkN ziA#iNI$N_KnE@L#4ad787ZRFe5+A<|$~4VTRAyN)>fAI}pQ`TN@E8QdVa}#zFp|%* zCzwAQ54~RCH)P%vp6skuPRE3jVucCq%qc$1Bh8}6#GfHBa{M)SVkL;^#N`clK&a-+ zU##+JXtT*>fxSi3?1fSa1&wM9Ya=n59q71)k!nJmAdWTr1Z+a%?LdMbnTq&!(qL(> z=5l|e21;M260QK2cm&DRB2q?B;oEVN0n@BLanf(Tm*pZ7{WG3Ky=$F(18Eh}HdQs3tgK-2;U7 zoJ+g|j3CHw1_GxP)M7`SGWt|ur|?Y{2OmS}Ju+w>g(Y0~yTY?7R}s3wCR|}&;ajc7 zOS^Z7&vTqMV>%MlL+<8!)QL+MoStfpRpPKz%ii(mc}7Z4w|7gYpvm#CLxZw?6-WHb zg_hJT31l4|iL=&P4Y^=PR9=K_#03R!cx_wfWF3io;VR_rZ+dEj=HuBQ5A&Vn;yo`c zSFW(=X-4<-xc9=GQK(igwYaN4X2G$V{2vrpT!hw0x=V;Y#RGkuVm zyQ7H&7rP{8IbeoZW+QWD*iD|L@m*93o)|4p)Rc}7$l#lht|$4~sWl(4I`or58dLoR z1ggbd7D=0R`q|(=IIs02*7Ilh%6~`@kS+d+d?37ht=Y{0NH$2C)Lc5z3)=`~476MW0`d=P1 z2XP##&7KRd%pXNZqZu#E!F>&7vyyq!&2${rX;q#H6%$Iyja4%qMQm7`%a(7+4(k(# zw^q2#+Set;4ex>L=-ISw&DIMY%<{s%?v2*A_E!?W509>BIa^`==UD_ zvW?xOkc%zP`eEm5uR@Z=vJYu30cAbIg_5dlJwotuTW1x32%O!*Fj6n?3!*6eW<0cP+%qnqd6(y~8k!WVB^;*u-n-vQ?9Mi6Y->RqchBi1PfV{C*?Z>os7w9Rw%Sw~0 zO2<$98d~ngf_+d3TcPIi#9a+>3Q4BPeb`{xHdDu{C^Y^$(c>FwrAA3p$6#c5q7O?< z!whX|85^XY;56M9(tL)~aWNR<(OWfx>&9VmcJ;vW#4ytjrgDK`0{7T7qZcBpH<7*7 z%(c$F24AejN%}!+uBf=_6D}Ar^AyR(%=Q!l7*!ytgQ;Ws0;>1heb1ijm8#pmoMa5h9%Y1*7ah>k=_W{%gU~*1HzvLUI3SvnDb^ zUBe0iJG9s52mNzv8GZHnLj%4JuwLHek(T@`KcKwJB5$<86-n${n9YI*x?z?~)1T_- z$nn1b<#>WzHaVN`P-Bdi9GOAmB+b)am3Ae(8lx8xzeNH>)3L!gMZ)<%&Coa5Mp z>xZuR#kws=)T+bv9G!dWqq2@s!4O^Bt~HMhjZp}04-nUdzuZISCi9OD$KI9l{pQ02d$t9byRGWauRRe2+Jv5A*a$)8=Iu`t^B7s{j ze;%E>PSohc(c;amD4AilBHP{-7zK+%XwKCf3WIGCMR-M0F}OCM^Oca(%{jev9|&D9 z?C8c(6arAKFI6r_yi?OeMy6nP%r=j($47W2iVh5{=HLx+*6eTk0egg{mm-p@2=+#+ zdgd6g(I(~uGeXRp_H(pTGzF^vG(QQGr}~iJcpPk-tHC9d)UXdo)9rV)XXnt4Q{65c zY+R_}siowuD`cxyzF=5`I1+v?$#RMi3B9d1<>>*7U6dJ7t}%V&8k*M#SE7u2CBttKLRQ&6VuA&gJ@}+yg6npvA7uX$u8Pxj@(LfOyxC8@7Pprvwj6W zAEQghrVy$oBn_*Rb^;|gu{vB9^88T(&hC8em(n??Sv4mv)NE<*+$2EEvb1M@F>7=Z zaAXi#D{ADH6>csP`{k`HmbV$AD2OR7i3DjR zbB0t|rH`>Lc+!@p2`l<;g5x$dBzLs^SQY*8YhOAim}JkkrNQ zH*poV21{m>RJ7p?zqth|^6?s=aV2aexg}=^l2lQ;Ts7V6$QfSd8Dh$QH8aW5`~EN? zl|PT5rS8I^9@LL~0M{aGhjNXB8$83lq<W@1oFAOIRU{4C3U|W{c&^C!rZN1b!>3By+ z)w@9wxzPj>hv<36@_Imfm^qj&~7EgK>QOb)I zKLP`a$QXxAnmW(n-HfG0YJR3w=Y5CDW(-%+o=3&y3VoZemXQD&cEZCVio0+$pdNb@TaH^1(O<7+6)SAi;i zf-k)m=UdW@*2B;`ji-U~Y1g#S*#YXx;m`h`hyV@BCj#)l=yU7-e-Hsw5HpGpiIS01 zU?S5$5Mb|C@aBInoeQ9ZLjr|SRx&4$kU7T%!b1O6K7N>#+CPsook~j*Zgm57N31FyT!1nZo zW?Yo}LO+YR^^hDTH;lv?O9*=bA<4^&1SbdFc_N8CR}Jj#ZF=|+5#bESLo<^Rfurn( z@FGci5Da5`RK;e1ZOyi zTr!bV7=QOOTbLl>Mj&#}d>cj3JK*U^;+B_*Z}76JN(#-1u%-1;7s_LvgUQ1uW0aXB z5{(Mxvq^+e!pV7&kdUz$#33oBD#Cu8Gk$GB{1PoZ8l1XFYjF@5)?3}3Hww(rZKuK< z$X8pko@Ox1Gpv?9#D&i#*BE|6V$d$=rxMz5w?4^h}e)FKaRo ziAG+F==|9->;p*hNXYxt(%@Bcd1tFp5oLC7h6KHLPLFgp9T{lmf5;s78)(%6u__-s zvrJ1uUWL_rmFv$T=c_Hy!7OE_Gq$+j#VDKT2h;-F)#U-F;&$8P$ClZqD+vyJ(zRVDfEPDyeRbz8v@K=wz@2s$S=t zQfD-J&hb$u#<`mWpZwwL4EBjPsG#ScFXW?{Zb&@jG6 zB!$i?(ZYI(7oX9_&$!}`e}KRa!`S*r{_4XU5dCh*p9<6$`GNlBqyEJ$^w(d2(cS~}WNjGf=okuK=O>)h zAuSyz={JtHUVs)?Gi`?JP(r-f-bR-)C2F346sNg zv^|uFh#R|u{G)Cy*yweTB_iR#v$1JxIea*C?XM4nQB8K$lXGS~WoWkQMOTPG%O?K~ zW_qZ?SWfKH=r2p6E!rd;4eruGOPOBn!uECn=r<)}mQTfFUH-$0nWa_6t;D#S7&WG0 z#6`#GR+Vr5`5`l!kA*$v=bYTX^_nk_cy2bj%+d)mqFWJ{rfvo2-o7lPX{1DB3JT>Y{Pmn;SI# z0Yns6A~0>kycb_$#Z@HV!j$f0oHml`4t!bn-QOnp4S zi!Hdf6Ux@eE?++64wt}LD#2(hYP9!b=~t!l z-rbE8e5xPiD+Gk08p`EcMm28KAahC$UWQUwhJs#uCe#Gp{Sza%m|ugx)8NQ5>BKl; z+pY=DSmNyhQMmbnXHtX>RoZsE%(Pz(Z)bYSZiH-jFvX$U=T>346PkV=dZRgJ zZu{bGat<{P7u*-S*rDOB<86;)_D!{HldI1V+?R1ZrYF{$QoO9wC=GMQvah_mx|u{A zm!6_o{fk@Hs8Lo(kiaxZ5njEa?RTOc?9U-%IzCn+1g{t$xuH)}{}XayU|@m?U(|l$ znUY}XtMk$^9>9%R%z33TB-ReTMfBtAaTX5&V$A(~sAoW{Ef8MvGQ%Sc;F-dT)!%$L zVg?+h3Wttdz*H8Im)+_+og#K02&pcO#-%9|ORws3#?Wg={s*>Q50TX-) zRM+`9DtMDw-2_%BXK9+;`)0>T{#ZGzP#EqiVgI+(&?(`PO+D=|Y6$J#%H`GbSJ2oPUQ1JguFI%sXyuj*jp~22x&Z zVW@hMaUO4ztjMvtEK?6;xsCC`Zylv49{!b`DME8Cv_|a82}SsjRyvqN%b2uQ5B>n2 zVN4XqmL)GH(Pl^NfUi=X`FdNNDYbfaL)!;Pk0rMb#xSxCvY7MMgZAjxo9sqs!CoU) zadW{(bWC2k!3~if;@1z4q8zb-22Y#oSo6b757(ApnvFyTH^cnGZZ7mqtKps`aDjuN zk?>q)^=Gw2|LiwHLLB_pf?8X&S5Z%8&%mf2>&z?`0xNrV=QSbb!{2GeJzWcsw=M)UJlg0hrwRJ4dKsAT%+N^ncc}M zr|jHCF)O$i+zTw%55=ewUx{Gz27miz>o!3TprPv@>I@FN!KJNAb)O$~RKY@UeG=1w z@mf?VhZEGIrhlKZ0g}8vdZ!3RyfH6c@@U0e`bzJLkA%qfEA9E>x)a{`p`^oPu_Lm# z{HWN9z|pZ(TdA;KUN;#ix1W*iw{iR`XC?s4Kfx*lUX+}_>NiS6=_T!)^>1`I`t=S3 zwEyhLU5Di0;Ka##qEQ|%-DET3Z>r7o-;MwvVc{f>U^++p;Z&p$agG|>4*4x1=~C&+ zcj{F}G*HiWjKVV*fUK;Ax2DysLY6wz?9!`ZUk50g^qMZ0Fh>D4Z#sTDs6+CnVZ(#3E0jonN(?##8hBQrrd8nPywO=l(PV|A{C=qb4F0T` zi=*o=uMvvkgdfDrq0MBmTe;*B_e*p!AgiIE!OWZH5;Ga*+qm~l9F577i z4N@3Ds#sgjSn}@d)4;7S5`uZq3g}jqr9Latn1iLrzr^-0AI5Wv?z;vsU0}z{Js|sV z;1HEQJ%jm87d0X__N%>LVCZ`+K7E2}l_@psZjH(VFnSQ!1*(4m^J9W*eF^Q@)KfKb zYGgbGF>FH?B&B>p@gg>nZdiW-p?kYyQMWk%>IQW7AcBe9b;F`gmsf0#B4iVZlMXP7 zyN0&@HSh4ih}`4B0>+7 z`t?HnKu&XCXe=xInzdg@R&{J4`M@DOX_ooT(DI*dZZu=&lUj^BZEul?iWlmtz_Li_=LUIa$62uj$fj48}1M$ev zysEv>2!tXDf#t%)gN~jkjKnb49P9q&3&rf3F%O99OHUbbyOS#x=-crA=zVyQkPFGS zr*5=PZiH0?6Ez~uu(AdGW-8$HOA_ut2 zz|K_atUar);9za#;`J^%qWwTSN!vAZ{uXRD2?Y(E2c#z4z`^sf(*$)0C{tTx!qQ?kpuG0{y?Ul#{}*ku(W;87gNL;HHpwP5%;LX6Q~ zoc0H`rkS1q&`5=I-|41EzQ{C=2&xg8b4#$3NXk-y@$7mh$$(+OE=RicWy;bto(sms zg{ZT9EmVp@hs>Mvvdc41MiU)N-cA_Co8sB4nXUrNK#V8Ot@6>p9pE#niUbGz22Fqy zeF4+Q`9?1mmmLlkYLwKfXH4OkpR|PsA&T@fFo=kwDi)`j36wUK*=3#tG>v2m?j8l9 zc&7)*V{6$kp}kP5M7>i=2R1VQM0boSybqe|i>OfgGI{VP)LtbYH#addPH4hy5LU{R zo%w#!w=eK>+8g76!}M7L-Wg}l&xwPKdOGA;hIHx+K5nZvA(ECqfbT~stfTWG#*(in zB)jD~HMCgd07a(FAz=s8bNuL*oDh?q%F+D3(qG$w<=wQ^a+K zHMKR}G(za1_g;kv3B8FVR1u^~Ga?B^no7BJ6i6Th2u&#g=^Yg*7O>JnK!{hkL)9GoQ27OFmQ2L75Qi1Dgpzm&E zVSc+$c`z}-d8Kt>UTG!A*sTu7g8bWis9fr%si_QvRp9U+eu+=xwQXLI3K{1K{l^!X zdGDj+O}$yRr1#Ci3UmW=JpL)>JOMk)jGZ-CRJMqMJjr0_dDICXV(c{P+opXQbZ$Mm zQ&bZn<|e--Y?NgbwB?nS&mDK4^rIGZ^8R-ErwRUNm{k8etTrlf;xFdWZ3Qm=%+b*~ zIx3GF*UNf`aC!FZ9r;&_MvCxe@QKdyP5-7rgHtua8Dmy#BXtIuhlqP(7aK;nh4=@^l))N60AJq2-^nhuB0(Cx8(nf|N2xBpq0bVF zrD8(eMQMO3|d<=xInIpu=*D_(_D(66DxS*pi@TO)%WfhTL-@@)**aI z5txO&2`DNBXvI@>liL~wuc*6mU^^GYzHa|HlKQfIwSdSkhGQfbclfjg`YMN)4@)l+ zl{W)P$k$xwQ?nBl;v0+4%WLwQznYK{1=rP6Rm)n)yKn6YZT6<|AD8N-xpa6qmt#4M zFVcM|GpXhF*MG{UUU=q}B=-T29qqGzvC79zGG#3+>*!$0@|3l~a@%G~^1Qsfus9D0 z1=Qls84ZC_qeA&(Cb+E_hFt93?&+r*lTl31;oSC?0RQ;G4~+9#br-9`jaF0c6#A;n zX4W0%a!Q9Zo&PHK>`P&ar0C?J_nQ@8tJbhrWg1Tso%cqMh@r2-*vj0}5{ESc^o`+K zvBFU$M{{z;@fEU(w@ATH!#gtnGRJzI?@b=~y7X^N5yL;UKi>$^Ot_m4zs#QybfK?(~oS;J%z{+ zC!TW!fMiH=t?ytezjy;+Bx1OCK}A2lc>;82^W&}#?VpXF<{f|J(O|2wUg4RHO_1pQ z8^;drtJHYZrndOON@h-@Fzn2Yz2buG!EL%J!m}ffYYr~QV$Rs*K@eU(Fp<6oD^-j* z2^Ejjw~n-TuTiD>V?v#kCq+d}L6eAv5RJ)u*mv0?`(*@m?X=z*TRz6@@z_&ET5JF4 zq*HNf*d&~3GBU-@eNJ9Xt^WEm-(hNV=Mw9;z|#?4kxQfTn=Erj>|m|_J@Lq;JH*WY>Ot6(M9@oFD9ME%W{L z()lbw{S7HH!@5vCAcXy@Y&&f&eIuc$mLJ#`x$p-T`HAgag8Tu(Y4W{$GfyI4-7pVW z>`lM{p2=U%vcDkRHxs^2o)r7k68IUawn7?!_-Di@X-29L<2$upisQR1HK1u>_CCZ* zwQ3jfE*_xjX_2dL-qM2w5#7b@Tzja?lRmR-4gRt7 zgtd+yLYTTCaa1ExA|W7dx6wpoNdao2OnqrPRqxM!p)hBqABh`sRoim%_}=hX&Y@k= z@p$+VCtYs5=N7C9Et|wMhE4Qw=S7JyyDd3eZYw8XpZLG@r2pZ4?G{5;(GBT&B^yDtbS1}s*PcA7J?Vc<*N?v)1 zF5OWBWsm70dj^s`L=DgWj5}}Pd^@e_xH>(=xQ+Sx8XJy2x3t6Z%JQ>9Bz*g&0m^X&$QfRgRaT8q@h zI5E>aaNieurTJhbuo~Js1+!pT4Bn%O7aLm9t*J>7hBd#Y|QKm^vui*xKpKORKn|ct3RCO*!;c> zdjX5eZv)I3mSwRK#;jjkc!Q(|Yw8fi5-Cq-)DkJoBBn@mpmKGhAhcpMqoJ1Vy}vaZ zR&QM5yl>=LQ4L|}{`DG~30{r)gg*jV1iJyU6Htn^H_nlJ$$817M3%Ei;E#NAlAooX z09k{YfDl&S;eQ2{AOJrY!ctHK&}?!@XF;ftf;k2!;^b2BpM(Me0#5^o)xSRP0alEw z)~gP7j_;=RnI1LG!$S6m9Ia#|!3@BmD>v{XIuP7$8zYM7%%M}wY9{K9uQg^<)W<5DH>vJ9T)$kQ826vl zXy38m64+95ttvI)zC!%`1e-6MYV6sZUwR!_*$f_!n9{L_A9w4gKD zAFZtw!Upn^T{^9i>Z5{49rwO0=V!LF&9N+&ZoxGVoN!{lY!+B9@ujxd4-*p3O&u|2 z{01}<*T(lnpPLJt-&ZXy5FTq0fz|0IH-+OE^dFlYsQJjcN5Pg#L~F2h<30CpUsHR# z?_Z)#K2rEXcD_~`V+98FVc=*6Y;ljg(+ufxvm%4!8SPf6_;2Uv_95u1Myt<55z zGR`FG{cWidn?(}v4av?&w)-L(;&cc1^DJ(ud3mz3(pV;==4T$x>@%Mr6S?RRBejrf zPCj^U7*QMpu-sjb=e2{DWUe0EYCiX^{Y3_*(O54muPK^ zP3$fr)*q$<4yRsgOu8whApu=z?sUf*;{T5o?{!ti;A~14h||pgN^2< z2>h7`>)-fX-~Ec<6Lf4>2pF2->8m^P&zK>0lo>p9-UUrobPS#-dw!j9dHpv)(~(}? z1^ErQtM|~2EB85YyDDz)Ze0ZGk$G=s+do7v(E_A@t;#8k0k`r7BdnI(q34n0c;+8r z%%<8cd|-&V*>T29z}Xcu6mtg*$3>blO3%1x21dU~qxan-SYk)4q)2rk`Ea=8A@WNX z+e&HEjdf1Tk>s+xnw|IPu|%!rHRJa?l;CAdXwd9uo-J*&MH*fa&O3AV)EG{49{-oU zU0qqxRr@vyFR}H|#`*oz05sm?u#D6&iS=(C&if!51UslG4isvHs*>Bc_`vUBb_su zLEck3-Zk29tr){eV$oOQge6@|jw#3PXgB(eHbaG1-+0>$4z1oT_UQV)%L|r7#XK;R z5$@#QVvgp%jw?m-FWMUuub+J5yMEetVEUGEn~XjE>=NWQRdTJRO=$@Uvuqi+Uf@z- z)2Bfj%gWnUtu1(YRW8fiYSt{POVuWd-6{-uhZn z@cWy>>t(sZ*BNuhB~m9j>gl4=zhb0GG7Ga+fKVJ`H8}k#Ry?4bY6Hh(_eyO@vfnMo z%hi^sB6=7dpjIfkKX6>%-NVj|jM~wJ(3?hO@Ttoh!>q*TIwDFzqzY)JCi-Q*bnDrPc=sPRSNFiRi&;&4-6+O6d0QsUY6 zSg0AqUd%1|%5MOYO$uNDkA08Jcup6ox9oe{F!KFI*(A&ZCx%^7kA0o8(3jIQgSBdK z-jlB7S-EzhB6k2ciT|pSp`t(jr8nNd8voDo$gx^I^%How3L${_W+tEFJNlKjN~o=3 z{1bfIHM^-X>o*|yi1CS=sivd_C1>W0rh{MUs?vTr1h;2Bly#G?h3QMvrPEAg21E-4 zHSmm}!z%WXI+c(%UB5t)$LdMw5|ug)@|=wQJ{KG~Cw^>D5E;DLtrFe#miGQvl}PJF zHn^su=eeR`juIrX(9TD zJoAY5P|uI?c0r?vvQ~kK<`T5EZDS;@wJ>SmHGNDZ!AM05E`2=Eo(+B5K@xMk*82DU z>4$W|;VQnj`RDZKURwkD&5BCKE6hTwd*kbLYs{CETs^MxJEo}7MDxupTS@dp0zx`% zVCv#f(%*nKhJqXIL|X6tfl+G@P_u55k&#&XZ-9=>EyZ0?IpB)pL>dNllYMzP`d(`k z{P84DMtDg_+CbOKfsS=0wNj}ESkT}gX#TGf;dx(gXHh&;F~klWC68F7-N-_T+qTC_ zy0y}2{kTJ@Sd2=*uvgEOg4_@GxN@)NC!(o9?FB z=H|`0n7^MCysPT~|0v~#zgGOfzqbwrL*M_@eh!4MPcsbdD&L8la)9IYnvv35p(R?0 plgH(+ln3QaAobyanBB~?k?rN66xcL6AJQnFG2AP@+U`P=}1_Yt&Z#l?+O)Rd)U6(m0s000CT z1#1UKFcbj5-oed9Ol%2!wy+Zsw*|pUm>f2re3G;-4%40Kgzv{ui747rU8z zecA~Ch&wuYyI5OVxslMA(vq<9^74?#n0wlpySXu{7@OJ}yO@!PJK8%LJ9q;C|0?r8 zxd3SY_?G0;$y}_wTwIK7OrP%mclh5X{#)yR5C7EdzclVu|3{qxu_pf``%m8g$Q%m+ z0G`KB-z5A;W|9d2w1olyIP3qBQG5pgkV61~)|vk*57s~J#mddiiI17t)6|Bv|p7J+hzLXn;sS|*lt6kQ8;}Pm1e63S0M&pxz%M{cpaaky=m!i2 zMgx<8S-=8d1+W3w0qh5k1LuHiz&+p@@DBJ21`dV*h5<$hMg_(U#sek_CJ&|oW&mah z<_zWo76KLrmH}1>Rt?q))(`d*Yzb@^>;mi&8~_dvjs;E%&Irx}E)K2?t_yAf?gAbF z9tEBTUI<N(agY z$`>jIDi^8_>Ic*u)B)5ZG&D3WG(EH+v@*0Yv>S97bS88)bRYCQ^bzzk3_J`m3>%CT zj4q4=Ob|>OOeIV&%sk8q%o{8UEF~--tTL<_tT${tY%y#n>>C^^95tLEoCcf? zTp(NqTrJ!%+$P*TJUl!ZJTJT|yfyqc_zd`Z_%ZlB_!k5;1Udw91bqZ|ggAsUgdYfN z2=|DHh*XFoh`NYwh;fJ&h=YjRh|fsqNK8nwNM=Z1k+P9mk>-)EkztW3kwuXWk$sTU zkeiTakS|eSQK(SFQH)UnP_j`vP*zYLQPELZQI%2cQDaamQO8kF(V)?&&?M2!&_d9P z&<4>C(81Bk(Z$hC(Sy;8(TCBGFrYA~F=R1pFrqPPFs3nXFwrnMFhQ7JnAw;;n7dfu zSX5ZDSaw+PSdCcASg+WG*rM3x*pb+^*z?%Gaqw|OaLjR{aO!ZDa9(jqa3yhVaT9Ud zaJTRv@aXVV@jUVJ@P_d&@iFlE@lElg@Eh^h3BU;G2-FCC35p0N2_6ZF2&D;~2r~%> z2``DTh(w5Nh*F4piOz`8iG_%*iBpLCh|ftdNkmEPNHR!QE$>9(g@Qy(-hJy(n8Vl(Av`G($3O>(Q(pQ(Ph)k(1X!)(Oc8!($6tK zGVn4uFcdPZFd{IDGI}spG43&8F)1(wF|{#WGgC0@GAA;RGXG)WWU*r@X4zmxW0hkK zV(nzTW20j;Wy@w;WJhF|V*kqC&VI{5&tcAy$Fa(Z&Z)>5&N;yO!Ntwx!d1(4#!bQf zg*%6Pg$IL2g(rq*j2DtujQ1;VH}4A{7oQtnBi}E6Mt)oVO8!#;Y5{YBVu1reGC^a( z0>ND&5}_|b--ULCNrjDt3x)SZ$VJRVN<~gYX+>>Bt3|KHSj1e!TEu>f^NIV5|BwKe zkd}y&n3hD7)RfGU+?FDfvXrWlx{>CT_Lcr211TdflPI$yODJn9TOoTR$1UeCHzbcB zuP&b@zpp^2;G)o}2&O2fn5?*|M5*MU)TRtjmQ_wx-cq4faaQS4g;G^f%~m~DV^#B0 z8&yYFH&U-uf6@@sh}YQAq|tQO8~`DM^g$J%-&zt{Nm{$w%-a6iKXveQtaaLTp>;vJ zrMi!Ll6t9nNBW%lVfxDk)COJ#t@@}kbTxk4gB5RUk za$_oPnr?bwCSsOic4jVUo@9PvA!w0gacU`OnQVDxC2W;ub!jbbon?J%BV&_q^V?S0 zw#@dgot9mlJ&gSq`wj;*2U~|BM(MvqZ{>k-fwqBDK`cQ@K@Y)N!JQ$5A>Tp{LZw5i!jQsT!dAlh!}B8` zBCI2(BiSP}BmYF1MNLGrM5jf+$C$=U#InX_#Qu%5h?|b*iqA`cN^nS6P83coPeM)d zNjgYYOm0mfNr_5%NHt0wPvc0-Plro)Pv6Z@$Y{@`$V|w5&$7x|$`;G6%OS{#%=w*b zmOGy(l2@Bgm>-@0`rZ2bYJqe?Yavx(S`k!{N6~RHsCcx5r=+43uQa;!qs*afw_L4! zsDitqqLQF8z6z|$z3Qx5zk0StqNcr;v9_QNt1h}8Q14!U-teVixlz7xpoyocwwbay zrv<$wsukGk-Fnkz)wbKN(>~WB*D=t^-`Uc|)K%6^+MV5l*%RLj*BjFNx6h~V@rToo z%YMuLg8`#~jX~|f#Ua(9>0$Zdu@R|}!BNrCzA?eE?s2~HjtQQLwx3)-TPHauTc$Xt zTBf)vp03%g-L8AA zzioWmgxrkULfJ~&CfF|Aq1~z5<=XAplh~WwSKr?_Fg>_9bUl1O3OPnRPCFq!sW@dj z?LLz_n?2V%Ke}+bc)JX{LcPklrnzqZCGuVTx|NZ-S0}uiL0|y6(0Ed8tg!p`P zK|#U5Ktsd8Ai~0ZZiwgzNC=-FbW{vfV8s^hnSRfqm^XtEp{yBtz z1VBOmT?Zh5f2N{pPpnc5>$a@7!THzJ%&b#XrmOtgwW5Rr$k8q^Du`^8 z^T%+1W;MU0C;o{mVPO#K)nCAj&u&_yAZR%N3MLausK{I1hvWS(pl}NZ`;{4Le>1bF zV9)BSbtIf<2nD2m-U>U4`U8`B0VRCnRpd+XvWa7ZVDrmLR~9w~C9IFDg8cqKk}#jy zUJ=+rN~a03kPQ6mMv#nEK~dlkXl z_J*#kXxRn#cz6?BG*0uSsi*Ln4BlRP%!YQh{j0~5DFy76w3YLz6MH(!z`oq`jalQr zfR-0ma@R&#s7=l4QI}g6R%r|MuN-b?Ntl5&(1{W35QOTg zfgtJ=9%z5lhQuldCws>Tt%5{5xf*UTAp`W3vxxi76X(@F{sq`lA7e^H%{O?l?M&Im z@BB#7C1h!PF<&KT=h?g~e3swfPoQhMhz#}R^*#8WXkWf%a_gxHltnrOTRs=GbW;S@ z4Aa~N443QI6$tS&&fWf4ewa8@oCt}7Zr2Gwcui*4|@8hLL|1||Ch7C1x3|_taBQbL= zP8f%;u`5_N1&t>8G$XyqXj#ojB4@$P&Ea{|dbUYE_`(||m7Rb+tu-my0lJ8Y-v__w#1uXdWPequ^*sk-J$Czy|C~Xdh-jfq z<9=r5Wgl=a)ZyrC{&?*Pku1eft@@zl+Y;{y!BqOEoD-jgR%bNTgV`l!CJNRkl5g>= z!(V__`DI(B5XbA@dAGuO=0}}8CVh}84HwinX}tmVJqwP9OWXH6S2S!uoQj2R;B9JV zJraAC3|{!gYEVFbwhV4t-0ii8(pq&?vys|wKgTVK-yCqdGwmjJXLrG?$~f1;39pvz zif$g>4~CTH_PGTIbX$7WhnULq8f;33&q6UY*~*3c8>!lY0xS3Rh(pQdSHuD$65DZk z(W4_N24q5FK4r)17v8I}hk7G#i&m@aZuGK#VCf-uyDjXb%`HX6uAL2-QD-sys+CxJ zQQAT~wU?1__xs*pxU5t4MicUA=a|^GI6Hl@` z%J2K5nct6g`7TSg+)ZnF zKc{`Nh15rSvp;Ta^jIygZin-YOwzjgVF&M~#+>1EF4su^kg{FwetGkvf|O|n*` zh)#n_-s;DCi#K6~HA&UD<mYp=I|1!h@UN(Q~zdX5TRq-NZJvNp-( z*DxQ-aKn6ZH;n=z4SZ7^&7q#V!cL@NFzPaV(V1WBNKxwqggY`uHqwZn*4$v%+4i}w zhYbk5Qi}+J$=)Hxm}XT0uTJY+bF>6rK8;+qn3ZvH(M-k`$Q8`igx1wsF^yo3^E{#o$xcS zK0Za`2ns1ex;bZC^^sg_@puzE`9Y*=WPsXLPBl^awfCS`#LtPhj+W$LOJ(}2Wj6G% z9LKe;o|KEi6{|GQw6?`8(0*8bLaYbTdLgJvv&D|dVUFA7@a=JrQUE?+v|ZDQcBh;u zczR7Ntw)eLgl5*@OT&;xL&!Hx_+WhcI+mDpiJzOMJ3(jqY1j;UN8u3_CUx?XD6CdG zSr`$?WwJko>=0h=K`)^MdhIBvPY&nQ_Z;K#6L0wV;c*W$i~_^a5fR4hWxur=vCL$a z{{noQaWaX(?5n*oQm6O1y`k)M#tF%=Z!_VnDvS59(TcHxEOn8*@%GAtq>y<0lK0Tb zO%yIk`tar(8!#)$P=zWK^fEwQR?AyHknV=xMSCnuv16Mcz7$85YkOsrgIC zCa9&5uiqE+nT0d$vYGB{8V2@XKF`rrwXU{GTlVXpC?o{ul!Qg@Zf&EExsVv^pwr1| z{%FACG9SR^u4yzBh|DjJM8m4k5gUswo?|%j9+W=5s+kwOH(V{CqnHLR1tDvx^5hKO zVdu}CmwSIe90$IHq9?X#$D1_RzDrUYiWtH2&>Yl(%#P5T&=Xp@qTK?XTJNAI4D_(+ zzp+v-_GU;a%Ox?hqhBe2zBgI%WE{ zZQcmK;vLHBPk7d2)+x_vEnTz@*w@VDC>n3dv8gSsU16X>DEY{QM#u~xlp=-RX1?Tb zDaEAxIcJJP94o{oKOQHkq4}N*E<*;OCC6zr{b3pB@JIf%&}2RtKAjLROQ4a`rGrFt=cRYr z8YbN;EP^PA#nrx@UhwvPu+Fv3(1x_rzHN?TDqcSV$1BOYpAhyZ0z(xVRZh-ykCS6# zEE?veMZZ*R6?VJuuOYT}Sp#USA8Mg0HHD-JNc4ue;&OJYd52pZG1ND48sJdwJ=R6bsP)CitOmQBtWB_(Lh;suxK6*G1m zs{IAp^7=+yRva1+KUx+FPP%MnJr!Ht(0jYyuMe{e!3aC7zNH;a%EHogkev5osHTJx zHGXHwjA0xz{()$<=~c~KU)ictuZ3C%#)&^FhhE{(s(`UYE-i6@dlf=BxooeoheITB zO_)HwqE^)OZ9Z^6=*H3}-c5#QU6mbkI25;4(Xi+Qq03q_Zw;Ri4+-BlQ>Y6;0D~5e z+9!esrGB=l%JyvyLSl=Wx=)|@;J`f9kAhGesz=!+WaGjWN`@LO8 zKp{@7n$6s>sGNP3O=a}q+uY(WqC`!7?-2td*lym#Sbq*GyQNBp$)}&0);*ExhN*s| zspzN&qN`U!EFT`{M=j-`Z|2+rqtRJm`a1XuE$fQIog$1)q4;`Mr$fo05a(p2&Uwa_ zAs(UNt_5f1WhPFYLN&*5ge@cWkjM{Ov0KX|h}RXjJd)yg-r2JsLHVMR2eZGpnFu?H z7IT)@*yo>R#buUcc;3EqDcH;~(DhOokKSa8i@KIFoPG?Do(7uK1uQSi~Z~=NqIam>Vl3;!@ZyKu4P;lUq$ZI0y*rPOSCv)eRlUxtCnA=3>SqgMK>><0zM(c?)I&r z3YBhVR(JCjkWJAC*9Ie_z=UU71eg@#2^x-gWLusg*+q|@t z#j(dU5TAnUg{$U?|5cw=T>bd!SGZ}eaM)znIbp#*37DF)d)*I@@*oHK+l}2}XwnXM z@r515ldJv(OZy%0sj5aLBstZ48qcMwL%9!>_dLzP_~~moythFa4eB?74k6Cyq2Lmk z7LUZJR z;Qa*p-J3fc90b-3f2qk2rcKuipfimzg~DGz5&9p(!y+nUZ9MrbT|GD384$~VoY;WT8}Be1Xno1ii#(+1RQ0EYD%AsS?FjFw6L+Pm z8}U!onbRyiZQZNgE7#5f7FVgEr<`G zpycfI+?a^;I#X) z3XQEo-5@L9ubPER=_|Ixu1?ZPF~1jmE(o5myx4`SbSY%7^OgOw#8bxnro;KrLg7IW zWyNYBc1CliJND7ibVdz-*zf}Kyf?}lcqM^;`d$|^!fD=S3`#^aS|My5() zL?TXP+91|lJ=5?P$2U#v``jDfh-)NUu z!8(r5%&VQRoF6mS``a2K%ebRORKW>$b%3MoVjWTEOM{!+eUIL~#g^({fN;^-S5<2Q z`ag~Z%$o0guiP;vOB<={HThca*OIq`{Mo&{madkg%DGb+Cv~otE6BNsojhlo;hL|% z$%nmB;xR8a)Awt9lTYlfL3eb8J$V(+?@s3H4Lun z>qbDOj2r~PPU_~^8Plbf4RU0EkoQ7&J^7R| zn|{|RfxFzN19AS~{oW9%SHz9?_5``S(j?DaCoTkfxnsSI(}z4=hH2YOvgFBEy>qZH z6$_zUJ&*ebb&ff-55LN=5iW8WwX0NY{M5^&2~be!YcM3Ug_O+-?1Wm0N##f>w-9vSlOHs zF5SR@^xAPgxfQ<)e-1RF_b8LXcFG#|r_W5Sv zI;oQ)Ybgd0^(n&Ok#$+b!*(^)%U4p0x-Z>4zLG`qEAo@{@br?$M85i1eVeN(ZWT0> zXLx68o;EJI%N@P`fggYeA;uDN3TcyqYJ5GDDKmorj^=)B=J%~b^BYO;D3Tmgg^gY= z%nzK=Cqaqqe0?pj>lgXc9=ylkpUiW0fx?n6lb-iNJ__-D?EJm9ZPV9Zg)X*QZQDFY z+QKppH(buav)oPwOS3PwW|XpSt!w`Rcv`0JJLNG^#B;qzvyj_t(^I4Y54Tys~AiV=u&s$I62Jnu-~6NYhz{KMSi znA!L}{)s9gadje`){Kk(Zef8hGy2pPzmj!trxkQE{WCpoTyNqc@tAw=kJ(bzNzIRq z6G7ijzoYvue<9ngg)*evjphzcWU9;j_R%fwmGHc-i>=ZgA?N#*TDSY@j@il7g%UkQ zUCe@Olk>gVP}(4}?hT*sp8geIWP?PbYiL&eL(|rcbQZd`)r&L6^fId@!YwsbiYWuHC8Xa6eO zNmIWUdf!`7%6hQQKJ=XFe7}BO?SYwac<&PRpK=X1@HpD~ZQ#Ct(TCX8bwY4$NejG zCDdrT#!+F)+Gm7=YH>B(&?U5Ivdgv}>N^%x7`LkYRaNeoJ50^eiu}E^B}**9-twgd z^A%t2Ly6Kei4WKMq-%{Jy<54;>zgw0TsXo!ox2pp_EEWFu~ASf$d)kvNCee>Ah5UU z$n>_!_~}k%=aZcrxd8gaQKy%>>D>ELHzqc7eEw}wNu7TP7ekY0xhieR@K-Fh?UpG}uYp(slWXIK@-)~Zn z@x%!AUdGV4&$biwjPFrx%CPxSv01HlXF~zTwT9kg`tw z6vo65(|J{#y{%R(i{I9cG?$eH3&BmVB%~WeUeO9TOr$Z1Eq|qi){GkcusT0unar~ycK<`~HSh-1ggO5Tk zFoPL4?)x=+h!FmjdTAS|YE5({>|#$O`2Nz^T{hdVw5Kpxr{n$W8a6NN(_=Fw z`Ped1SH7s346{+GcP7-0dbXvOSZ$e!wH5IMH7bW2G#{vo?n3jCGbSEt|pL;-YsY$1d@}62jI{YluV~~D-1`Sk1RHg3~pCjO@rx9 zrIQp~Ui3sbtZ!uQ9VeeTbse_oR~xy_ ztL)P}F_&QhG-k%-?|$igMBg|W_gP=w9Kl-xBl%<@;A9%aZN?X>?S5ouNiNf%HlMt% z@QR6(ynSEMn(pv(JYhSD#U(HiwA^@g!jLMiSh;zv}GY74xA z8~0S4KJwf^L|KiwDtmSwqf0FoK)pUkB1ZkV)<-eiRkLK{27~Nhv%t(!*W2zN1=+Y2 zZPHCD2Tco8x~o(tM#ZI6P>Ib`kd0)Is7=pg z7UYS)Y3)lFmTPj`VElC6$AUa88_4)$%c2k89guBiiCPl!&93h55JjD;;XSb({-S%% z05Z8d7JOz>ooA(6Fd5~Jf&)H_XV5q$?yi~dS zK~beAXBjwBkW2P!FVh99J&ru&d!DD@!^%2gnuf&@TpPo%Bd;B76tg%tg6M#;;KCyi zU9&P`9pPx%;L>HA4&IUg22i>(YKqW2}!c=ErtC!ro*^yL2h)w#5R1M*yU{oDe;_ z6ZWQ8!Xej_EE=JT>eIlR7>Wg~xweFtNze|eI zylaHI=32I}EyTK>x1p(B2LAp~%E@76+gzQjuaN_Gw0IYlT~5+O{h2gjkaw7?I}v)K z>7WNoe2ZG+hLuR6WANzx`3$S^Ue8F)rYnff1_mAvdVla>Tx%+3+?=)IwsvJ|Eh9Cw zltDe~neHA-y{@wyV(al;-$q+V!z3~^oZNAa}Eoay@aXI=}J26U$QXeQ` z^e8~vIyASIJgVRQMa4-uW}Up-EqN}!#QP_+o@K;WrbRC{1?Ipr>Exbb=ngt~)cWLy zTSsT>d8ywXbR%?Vq9rk|Xk(-pwGG{Ef+wf$EJ!*wDQm}{f9DEWzacZq5mX;Zu4x9n zvYjtEq8pAWrIsV9$1B&(G18C_j4YHkVNZ818on{<=ug5%;_O3 z#D7sgr|0vGHwZO*9uBLc?vW_j&i58pr+SYZv_D$a?l^ARh~`C;iVd*UA1KPw5_V2n zCedx}57yN+CU-Jiqf1MA`KbtV5R?mthOXDm=srp6Dtmxbs^E+t&9@dum3lt*xL$Tf z5U(B1ANLY{t76T9ob%`iPC3QW4+;+)(j{^u_Uh2DJAJ`B7Q@veUaR8qgik2=vPu;Q zqQqsQ%YIyN!8q9Zs5XbsX{_|+}xDJ$Ht4Ul2wYWFNb<^mP)B5FTtic zJl$iCaQW&%iySl_MAZF#^)H~Y2UW)ceA=2VIchV@x5yCGs9`a%)FfOJrLUt8MXi>2 zV4Hn^?}dZRPdX!@O1L@-Ntg5PKG}ZS@sl;G?weac~kkvCJIa zQZhKj*d{?T&mR@A1hZT+wU&Hm^7hDyOp|DAnGfM~1!F^ds0f&TkWCFQsW{hR$>VlG z6;?3cmc{2Ie2O!n(pOpRQI0o7eK_py$ICSi5j5DTViZnm%wBT6?pxRFs@S2` zp~)EW>9lR!h6l!&YpG$?Nt2W491JX3N|d>(Y4Z@qRO!b@u#_#irNN4t<+5$&m4502 zN16rJ^D&KDyMVdPWe(;3)n#7-Sy9JIlGQ&L(NVgo%xG;an@2Ot&yG}7@=}FoTEA&K zGF1r$CgST+xV_sB(D9Nn9$?Lo(-JvRMIKYP;o5>W3$n6Q0x6<~5zI$USW587M(;=NAxA7JCSc|* zK8fs+ckg`CG3syH=orj!jH?mFD;mHHXcSxKkB`n2B_LqbqHZzSx@XQ4b74<#v$|BG zG^R!xh*)8jkdvbK>79GL6z`Wm(S}%zo8rsq)u5Tq{d}n-Ln3cflUQpasMST~a3#j{ zj$wSe>Klb~e$L~ER3u*V7vuhlY`NS5S^!HWovcV_R$wvOFuC+AflNe=qI=%TnUv7R>J=_`namb*_S ze{af-ys6%4Q-R^1945<67WRdA0PD_TyaIjj){3Y_7>0k_t!Yw-hiN@y@ zd-TNZ3ZEDu-$JM9p3)}XA35zo@w#d1O~_5Edj|y^F6a@y9n`^Y1ztfV;RK6&md1Az z(@Bw+`pqhChpgn#WJ6>O2<@roQB)rw_fVMg{OsoDnHei)St7`Z&p0h8g4t;TTTyqU z)7{5%zLaftQzn-h^J4|0B(Y)e?oU{=;|`$)z{FyoA!;nvJra^`?Za}|teS(2>j1BA zUyxwYIr>GYn|tqi;5*y9W_kzOmk6#(LAy(iM6K-x@PM&-#zji9{_(|keYUa2W zA`IrD7H8V9nNR1wFcBT(21vVm%&9VRMXwAsJ-S|tY^}8BZoZwDdLv`%0_!eEk!Kg0 zP&7k$v0nZHXk@>GTOiat^%kkEL@oGsU%#14z70WF*~2yp z3zoB4*l0EVoXRFrS`hzQ0qbq`{dO2HGNspg4*G9v5`*^kbb`t}Xtc1k*7Ic|rq0N7 z3KFJ=bb+nu?B~p%E#flotEIfi0UM%rB|Yn+(uy8&TdrRNuE#N`GM@KiwLCF(ANF?g zDlMRBJqzp_e+e*eDr?wW`0RqKS$6+Sy<~mqtI;sV9e`~f?UQ*Q&b~g;@pADA^?O>) z!fq8Og{$(Tu`C+ckIk-~wohcRrMvqRp7Vw_!Ha!4la&`VE2xlEqR^jCdSaYuDs+F; zMxdiAL7E)z@5gsePM#-TSPR*pKQ+SM&~Q-{+D#ElYE%x9&~Bh zcE+}aOE?okit@tYCv-dXAG7i9*S~ZveOeLa=D5kT_S`JVoe_{Nzf`t$tq{vtQ8bE_ zF2@ns4P={wYJ` zk8Y-!+FXW<)g;QQ*3@iOCsSfTj=JuLA%7t?#b|gPE z7n|tJ)!xKduW^Ii#?4^m023(X1?KS|^^dIe>>3=fo8*rOd!% zTzTE8q?T=qj#|N(ECIzwShvz*MePz4D*Gd{f-Z~FnvG=qy_i)j;61Z(<}+r8uG*u| z8M?LEhQzk#YLx~<*XcNc#!IXYih6&Ky#~kz@oo%>8FJ(MVsNJnn?5$a?RZec3+o zg)^SM{LI-ZaOokdxo8txV+a<3w{>T?2V7PC5IKIe%^BW7El0A&kQW?xMM(_s*~N6! z_)O0)icCi6prW*2DU9{s?j;O_Dw<8&mwq9V2;BPJVMDvllL3(bNNZx7;< z7CvarcxfBGyM5}%Mta5bU^CQ7AD*F%+nArwF7hLIk5fGw(RF5U!3n&lL5Nz@iZ{yq z#p}|B)&Xu^mpmE6w>=G6=0>C~1U^l5r@L3kgqo)2F=dH|ba##Q^-Y;cwF?~C~ z8-c1%&LtNtc|#$B?!MMvqC|62WYH9vwIn(*_Bwe_S5ur@Y z!vI~IE0fZkP0Zj~;W9OO=iS7IwBi`&o4Gcr=|dzwKZnGNj6ng#-v^qV@T+*y@P)X6 zIFN$Uo&FZpBOb$S-Ntk4#w%o-;R@N@A8Ypln(s~-R!3RUJUs8)($RWltBm+beEmXP z+*V=*bxAK&f6m&65~29nc^Rv)ZoUk)9IFH_RraIk{c(FNETPZ9G?7Uswf+lGhETg& zs4~QS`yF=a0Scw7XJEt8hH|QGg$H1?Mkw%oz6|UDXVQlTx@@arPz4!~r(%q;z zvqa`c)oKV>r-3dKpA%?CwOT&SLN?!I9K<~@r6c34j(QRpTt4M>^00l`)IIBOI+32P z@VaehMz`6gbc)01$3O^rI-cn2M}Xjhm$8ZoExs0xQPRUHs^BYaxbc6l;dvFyv4mI_ zqw}c6N;X2wp^~@XrJxU@pTr#UyN6Q2){)UsjD!M9OJ~z+_%}rVsRkR$hLcGJ3WKbK$v^y9{f~UWkOc9cLBgORiQP$i!iW=~VB=r&V-XiemXWFZz5> z)JGmmsjB(gq-<>B&zKk*Quhd9u$sA}HJw47gXpyy zYlK|v9D4V+h8}me97nv5C*m%s2HR##XF#cRJ_8`ogfPj-8 z>K2g)y>F;tBpF$XuBR|(Jap-@RZ9~E#(G+CFEuD<6DMu24TjO9@-ZI~>F2|C_@EQB zO^*{{U@H$;Xk-z8%Lf3*-JvQmVqndq zr;TgqVj9Wp2jlWhsmM5jyTNoj8FA(!0Z;wW!WsNH6^}^hyV+c~kd-LtX#0$Jtt%1a z;NWYuZ_a_uk|@&-ocOW>-AH0ypBzZEe@Ct#k!bjnWFUVlvry`}iVE6ADL!en&n$XJ zC;x&j!#3zx+8T?ez2+oGx1l>g@b!bZ%b=RnYnRKLP z#LfKxK^SMXn;qCloP!c*?6nyDI+aV|lAkKBVnPTZiC37Lz$4Gr-+YY8WWClPtwIW> z5xH`@M;ZvFLTktKHbdSYDbBn?9#fVFkZ9yEz)dxY@ED9P-B@a^Ne0a%b9!R=Wcf-{ z$ZFc)q!BgB@fuojqGuQk23A$|jmL&eJ;xOBVrcz1=|aOrb4RRt>*@~mO!&IXBMTA8 z4%5w1FwM`Gr`>=1V{w3(wY9&s$4z;{4IeZm(4tMjIn_GlO#6#=0o7704n}Ip)CI8A zy#iVFmXt-H;Y1=9^Rrtv>2y^F-l! z$RPD%oii>0NB+x!#iq|YNtaWltv#KSFpp@ zm$XOa)ui#N*82YR#qP3X_k$b<-$D@1xBklnA7rlScSAHw5u2G`}zsWTi7DjTi5Fa)Vg1*wbIZsiLjOKf>uPM$Hr{F2 zHX;|bwFd62vXsRp4EaY;ztK$7By-$vfkk~eLl%7yPd69Ovv1{XE^%Td2{WEOSR5zf zYrWBqw55p}(fCMF{-6=~QJ=wcH#qBFleNxz8ggT(_>R7UghpRuv`)?{98dcx&u^Cf z@ThVuxEUwl__w4(PBv+tgHlPWFN3VQNZA&Ag{=YuNIkB+(Yp8KS)un}n@Teozm;!2 zOnBxECJ(XVEF?X-az?A(7De2zx z)w5L(3aVJZ;}bXW2Sd3cjCUO4$#PBt6pGk8JS>>Ley+QrL;-+(hR;Pb|8s@SKWl3M zkWgr-=ol!_m?SJ%BA=^lU_KY-0KtHnSCq-mmJi~dwsTA6+j}NZE+isS>{A^l>=e$+ z(vt*i)L~a!eTQ7#en*Myfp{Cbg7w6iw3f~Kh}H7MaEbX(I>h0S45qqw0+~6?;{)S4 zx2)^|Q?aX#c9+V=)vMbZ=gbrc5Ypsi0{YYT=%HW?<4I#b@~Aa9x({@W2rR+S*x>S* z)`!<+q9;)>K?QkYV!obNf2e%X3C&yAthViFGc%dT2ayu`bl{78Ev|+Pa_g3=x^E8n zzUfpgEwj&9T2iUcPhNV1^DgwJt-AEewj~HWdEAk+4kM*ZD=DAuTXcyAi4EAbGr)g& zV=XhgY}~NRmQ8YHXBV=(Gg~W|C2Z{j+W{lx1qKb|L> zEK&?q!sPfxXMX36ibl<_|I`x4nvrq4DCt$G2h2U3FohUyGG0a;Iv`IPpTW`L<>h5( zr+aiuXu_vITc2`})h@LNJZF!i!n>ezI~+Ca+CM_F;3(yzm89WD2#**?MdLAMwLH<| zQ_DzveC<&I2ahJn_zNIBDn77o>a!k*e9t5!yJx4QR62}CsNQHBt6nm%mG*U7cnWhC zbf|Z{IyWq@LnqE|zSo_i(Wr&qV9Q~>m2r2ly?jVZLuBfRg z%%r5G*Wnv5(oJYH$1^iC^MpI9Om=nIEJ?m!aN)C!0}u}MBk})8wjbc?cTX!HZ*~ku z`G2zylWAx0bs&6bvXjkTE0^o&A!@Z@8RICQCc}J)QjiNleVll+4zD9JgN8 z!)~{CqIVnLYCs0Op;=zmsfVS}XzJC^J+~G)JKPh8J=I~%@i73nI8wn@)Clv~vaw81 zZkqBfOhf_q^Dy8sESZy&laiU4{qyIyX(=fwDJdx>&X1E(=A!A;F@lG{l z9tJ#hF!3vqf*1vKoL&?Jz|#&c$_&AP9)>fJFb0;^ZDT~LQ|XoTkdTp>n4j@EIWagG zfz#}+ZW!v#w85$7RqN@hbzS;7CH?-JZK-Gtkmc211j%oF7?CWN_lz+0B~my`g2h7S zplNejz?Fm#mJysbEPX!KVTYt?{0Bg&oieIOl*&m`9b{x=dVIlB1SN+|ENi0UE=xte zLo2@C$~KiVLswL|apxs%f^;0oivXdFDS=s%**Fb0s|ig2!Gz6x)YPL{RhF3(hRM!M z$@{&dAPOHH2@I-rCX*?hV?iU)D3tnr71~W>#muv=y!UOajeuvc4MjNRC{*4(X;+iC zD;&YhCY)h_013npgawEh0`UU8TOEptz?$dB_F9k>kd_=6lD!jImC1e+iU=GLh5Y{j zC&aYK7V|8$%zM|0ZV`(xm752G}o-TD|qD2SCs)* z0*uz?y&9DAs|J2aWe`3)cM}{uAYltT0efbBkkvIO7I?-~k$1n^Qp}l?7N*D1?*j}O z77LchnEwDXJ)`iUz@`C7mMIOz)RJMa&RYqDPB2xKAE_;^&QH2LP@I{2y`s5KMFgy& zqvb&%BN5O*0Z=Q_R~7Za{9qUqV4OGFw}pYT^+lvwQ-HrG3S^*!yLLE`0&%Uy1~FjW zB^c~%fhE6Jx?DE3{E7U6 z9$=z>CL|y{7I~6kFpT7+{es3fxU`w#M&4`eh7u)q1$CE9X+y6aM>$$ZH3ob zE7H@%Jgz$1E2@L3kOEa(p+|o#m8=KJNk&M_A%{IiNV_&lG!I^kjrh09@kpiG7s0)Vl>&Wa+buniL!>az_Zx0NJ~uFSqK#pT)Zg6M~2U6 zAOmvBPwJLK!K+?ySUqibF|D%6i@h^n4BUe`#X7ISY*!5z}a<+H?7!<9n7jq z@P^e#SEw$B&uL^F6iVLLg@qIhPit5!G<^L&iEIa}!4IjR6D;k{{XM(CbY=S z#OAoI6|K#x#v$vqm!X+`9&5N-$(AzDSwmIh@+a9di#cs}qb$geNQr5))(cy_0@zb9 zehE^a0~N=us)2&e+%)Jm8}0V{z3}j{4Jl?iVczp;n+i(F`E=ikpoOtJ0=J)3Lv`z_ zAj38Sx(Po$e!pCVkN8Qh>9G*PlWsvI21kD*&$mRFGS+ciRQSY@(K^MmuW9E{(N;YvxS9@4>ZqVV(EyO ztR(`Xmot{eGju|SO~Lg)#Txw|e%JWc=lH8-qnWJPH@SJnsB2XvBt_Z_zi&OH zypaJ%;6vcoV*zYKmlJ;)3m<>DA_RdbcpAL~9w!DCwX3u3#n8IvBP-*`W=aRSuqC7G zin`q|8~#23Hc5R4W7@Xf*K(iZRgAV{UXDFZxE4-Kc>MF{Jo!9c4itg3q;5bBiMDdr znZV%znqD?d@pdhBmDnhmz|U~pJ5pG)uB6lG=;#LKeLmDMVsk;q^jcBJHSYH|M8wX; zW_I6jU7nuCvqN7^LRLW`(nGK3k1~G3U^X6h7Ws79u{KT$WXaEu30-t?U*p@hwG4yxwpeDswGU1q#f@#6JB~l4N;WgQExB$> zbX&6C7WBCc@>1l*$)}S~C;tG{f9lJWk1IY_e6#-mQ9PLWF>hJ7{>%GG+2dB)4x-wn z8QVJbnEi-=^-ADque+HQ&JD9Fjxon{JX>eHjlR5HmN%9+A5b-BGB@LAqubn|TTMpq zeWcyRO{r}sV>hPf+4ar1xAuL%XW8~m-?#4jru5zWru5yLvvzL%j(XiL_ga>Wk5LF; zLtMv>imo-Hx5iYeqQQjAZ8$L%s#4<`cAnE`!u7Cw?XBKmivynF60o@BTWt>})Zga% zO*YcCu1{Lqe(AgR{hw#q^?iR-+WLN;C}9$B!O7c>e$%Jb3>AC_k)r7cAhCB|yEW8d3~l(+)jO zni}V~VLH8z;d_21{Z}2*>viW`5UTcdd;My4{Bum^S--DnPx3z+mB@AL*RNk4diCqq z$B7=jdie3<$B!B9gEN!;rl^liieeIC+e`803%zR$na@*1V_92<@k?yAv)9<=`N3t` zZbfuzqyj!w45MMLs&!9(YP6dbZSS?W=}1WR>({T39ejB4G2_RN9z1yQ*HOr_N(< zSPiu+*I52R;>8pB*c+!$2h}}5kxEWU+B-R>)C3lBc0sQhRu}rnw$GH?kP|F=l;EV`0?ZYcw8xrxvs{4 zAKT*wnM#sl=S1B-Vw=sjF11+cOCwJsG$7PLF=*?;Fh$N{Hm=Zy+Usm_i!Kgn{{TYC z{{W)KhGOV#(aHpR4Jr;b_Rj{ZB>YjoSo7H;fr-ELFL_BR!{dx2Gw z=j=8|$`5uMx_he3-BMe238gP^Gmk%AZ}}sRPyFX6{)1i>*o=eJ;Pa7zolSp`V90;= z75PnGWB&lBE$+W}Qn+1`Ir4Y@3$`&3l>&O_qA1>M$uV(FcU%gPM(_1K?JDbbwyw#O8Y&Tq${{ZrwFZxS=V3Nn=3?^19 zs?&dW3n4LIBQ6>Yn8tPh<5sWNEqmf7zTrHIy_#2TDR1V=WpE31_XgWiGQ`@ML?S|1h0QqxY9bkiZig-zQt{|TYLSl-A`r8H(&FdFZzgjrBJ_8>x$wQ zFEVjhnZ+$C5DXa^A+U%hJ1g7O?>2M*jP=z`oOadChSIj|Mv)2B)-PIy+p^??lLuRo ztvlX3G5-Kb{{Vh``SaLiW7M%=y3n_qEUwevKcNZ_YMB`Y2L{<_o^~dCbi2zMT0BU2 z{e|ftw5_zWu-n?$bggo=%3B=K79G}=l-?WZ7CP3 z>0EVzR7~c(S$mDOE5+xnL!M${Pa00ioCU~gH+*k%dcDeqI(?T?|SsO_krG+N!I+g19b zAY&`)T)3TnzW%1n9QcTqixFO?uT4*FUh1f`u@M2J3SQZt8(8E0KJ8oTh1kKl^@?&qY}p{2RF z4v?-}At=yQ@?Cj%q5uE;CoNqsvcwW0CIm6=tnQ<-?~s z%~sL&U$01()&BtCN~M|03Nj3kbsi`FV7wf7IZG&tav%1*X$i(&vBGUO-maph{P{bPi=XPV9r-?%i~#DW3aQq z^{kujlChBwa0S1^8h@0Cin*sMvaW37V{2d<+ZD~Odv3~wr4zkwh9(>F70VoPN`cfOeddT5rD-L>haBP%JsJN>huKDvh{J@X4>lu`0q<_ zjA9-=nKkU+As6LnvlI*I7j&*GWwmcMc3v#fGjC{^$cfSB%FEV4rz_T34vi*Tb-KvP z?7rX6AD)(~B}}buhOc9Jk0bSUk~aB6sbMzVmbNX;wpxi3)mS?>AIGX7Y-2D&<^ z&9PdTwN`plEwNPT6%q(k{{SHBjb@!%_6_OLwe^8){JmPr_iPf|8l-Hkt`R}yV!v+w z&i&?F9U!Vm*s}isGhS+to_Q1Prd_)(VayL-T&=OAMYq^z4RlH8hK0E>D6FUcdQdiL zwwtn~Rx}+V&*W4aXeILINs&}tf=Qk#vm();!4nqeT;+zs$4iR;01~#xhgQ~AwDwF$ zT00Gr$^6&U40GF^`};k<)S&@o?hX*h$7OYF3eFiuDNRVH)c~n-E>}({kaVQuoTsL? zuFd`CJkVCf$bYhoY+6#X)~9IcI$1M)`g=QM`0?Y!+b4t2wi(Dx)Oer5oT`fGdz!_S zk@kB#uQjzSxy?awaY#6E%(%xhzM-~K#kdMp0DD?Xm?bj16wX6dvRZb?u~>voTKq&V zY&F7@shb9pwIy}dW{rywYwOpn85Ih%4JMp6t8G<~kuiYOP`36PO$>6_ADUWEBb#Zg z-8w*x&U;&6+dj=O+KWVDc~Ox&GVZcjQ)Nfmiptp7X_%q9`MAboRj`8HxN228 zw@L8}t!at*J}>L>K~WB26^9NbsXGfck?FPen!~pBoJt#VzE(v<+jTg*T!%qpvzo%P zOvb}=Q)4aSnPgy)%eYVij7DqO6KrfU0Fz)f_{m~Qy-Izog=&hYaO>5tPQ*B$8oC$g zP^JBRQi?H}iWXdRWs@T@HJvh_=CI2EfRf8qw3IGA&7i}}ba;wwOCa#3#GKp8 z6H)gdT#T`(sJBJ5t}6=ixlwO0Qy?%%Q_%t=>Um9y=kUnfcDDw0Er$?j7X4Z%e19BN z*K3|kWXpyt`XxYBtX!+7p|Q7?0xCr~rqwOA*e3S;)vJuga7|m6ZM}~lHgUN~ROT?s zq}H`)Eiu#KcqEo8B2Jsz)LNEfzo zRelkpG`KJib32d1kaw}FOwVJrp z{{T`tc+*Cn-iK$bq^6xpwKS8eX0j+(*IY&IV5Y+~>*JH{t<6hR-*LmMze8SZZn;|suoW63UbW3uy-(JIw!x~kt+6sQ z_+_o5tJFmS*To~KLd@hyDI(*p`luDU?6LN|?B$0&l&ppE+t|IV9weq6!DJjnkqw8O z95fYNnQ84CHZW(%kutGZ)?LzAT^84f4V`pXs<5qLRw%&M5X&|z+6||T!E2SRKcq4v zppO#c(yTVOY8JMy3tOwEPHkHi_L{w>!*cDNorbc`%E6VOQPDq~>+8pf?8Y`2%wq-~ z(l0kAw04-dLi#6f1(4;ky7w0=6|mcD0CmF?WmYDYl`(^3Ejw1{RV`s$Z;b}QF)I{{eZRkmB1 zmowO~m}6dPky$PPz%q;27G+IDA~yRJ7r$j@!!@?(=H2dRZ4r_=17fa*o0n@?jdE%F zzHzA(;;QQk{8O;K;ce;K9hF^%*Ah2u5mCdPjYXXcNx(@(`#DAI-0ki`Q?$FbY%X}+ zfDM?cT=FJE6o7!1*JX)tqHC49tyLxRoU;rH<`jAbZ@oKa@&y|3U7M%j(S!b?7^b~>8 zJ~LU*+F7zUSFXa9N2~>-ToW%7$uh)_ns>G5POWh z(X7F<=gv&Y4E*D*R+Kamsj}9#vMEp`7|E{@mg(B0(p=Jzk$Y_YuekGt>UA3a9z1-; z{5)+9U&P=KRt0sG9&8LwW~|aZu?b8_uoiJ2ksc%^V7M5GWD$gnF6~cfJ}vzxU~M{t zAtLOxZo_j^SLkKmYF5Fj8ymJ7WK7puNO)>0X|{!$4Q88;2zz!zajnNh%L=m*`8b?* z5$r5?2~q2twH3;bC5Jz6-}lDFRtnXdwDvw4#sWk!fia%17O_&a#aHbi>TT=Jah9H- zA+()}pLGit-F?j@)wYB-mcU=-mTxmiu05j^w2t1+HcgjEvLxxYx%mPECTwgOrUZOy zS9ecsOj#o)p9vZ1jP%F2p>sx;mq-QL>tfNJGC9+>f zRk70l08?96u|rww)$#qm8G1E>tpMwO9j>#A%{7s{m=|+nEhCbLMIx_~Ni%=C^@4>$ z@k7QDGj~yEN}`M$pU&O9_WY0D-TQud@M-Ak&Ets2MfNcKanOD7)YsRlysOpMnZoSq zW{u{vb*87PqjE57T-I@99nb99ajcBlEs@}{g1AK5YHs}(E(sAPRy6}%H7~8w7qrc) zw?THC5RrhD$Ux2!OTNFUnnVGlrOQyp%8;zK0 zE9t4w`Mv{@#-CIhTz*++wtqWAZ$n+mN;v+`GXX>T9B{TlM2dgDb>kF{qwHcUfko zs@IWxt!$llTmJx_9a7L3)S{77X0&K6WrbLBW6pT#)^Y{=2i)0G)|_l%>&dDiknedm z9q#wd`qqAEq|Jy}X`ahlR{6gy=E2&XhQdlM8PA^0WSp76;NoJmQi|nv#X+fM7DnX@ zR@1PdRm+lynRjW=IWz3+Rs^0uqCl8 zdfGQ&;HDyb6fX`2GH1*$^T&*wsbF~xnsxQx6zgL`sy201w8KlM&lTW0$a9 zxeYyM&6B?OD`n&O91cYaq zn2C=ae0k3O;(W7My)d9cGtPB@fRgDk88O$dUcE;K4_~xUZ6vQQ^Gx8lUE8r_Z0Bjq zKr-1>)6YHViYJ?zv&L6#S0gLNTh}gyl+@K8_NDm3k6Z_dj}S8+3_v>0dopJNCj;~( zurhK6W6lUpc$sVe0K3=iTARE|^%us?N1k++Esd-=oKbE94WuWkBQ@r&N?#(fq3FmN ztj%#OWJsAdb`i0CK;s|Y*1ZnzBvvP2&P3i_JmC4uxvDSSzPRfPjF{>v@q->BeEFQ~ z5hgR{2HkDOt68}$>zq?Gm+ryi7O2^x7}j~zK`lY5p0Y}YINr8ajY92n zIp<8~^Uf2>>sZuR*fn}fI@=1cpxE4RALCnYGL9cdjo0J$b-&xUdqZc|0#!INN7M!| zrXWUSkJ$&?zhG~6qhX_Y==TSAZZzy{>Yfbo&7GwZ*$Tr4;h4_75JMEA<5iN^IayYg zr%AV`M^j^q)#KaU3GpsEhHn0gby2OMXKAw5I#lg#N|rE#;)wVLhtD5@`Qx!LTP^mH z!AbP+Q|IWL0@YYxveUE*;FYq}i*^aVh@WP)?}tD`a_{AQl7mdpjjlr&`t`KjYBXAu zY~X5@rPh8$KkByHJr?mh-E9lHpK`&h7ek_FJQ0gzy|ZF$O~!72^oPlt%#WNmosT}P z%D?mq*Q%cJn!R1_E{-uniR$?Me7SO(?DE6jRwINrB+S^l_?jF|%cEsM86K0;1=ZWBI(&+r=ig-paX2v~!=W4N;mtOc>t5GxR_Mc5-TknYQh?8lMvenqXhwngoG7bn6l+0B@QMsjb?+;&a^ zHDajcRRy5supd(V-lKY}&m}n@i^DGSuh{VFyk4%c+#?Oj3V=YIzzPF^qTNN`XdD7-c2c$Zm)3T*oXRqX?AkSeoP9%${AJHUJ@N6=M`X|KtU10@c=>NYBG?r!PfRM72t6;Y26p-RK2t`sPkoc2pazQQRv z#!`K4M$>Q0*~#?>Nt^xGwBDx>hDmprk@GS*!xJL<9UwlYJ~DFSxb1D_yA~3Yr?1zI zBdx>fgg;{K?QOIlB23yV2WXzDqhpYUyJJG#)iI_agCzoFaDD+y!Gcuhh(C3w(`Rp= zVMc}O=uQ384$MO!A_(KMiZD&*EesRG#xaD5^5}>x-C5)A+E=MfYTD{_B1mF1h-Sm) zsV=6yj?7sMNmONYT6VxTb(+29z3&>?os_Ud6ixU2rr2yMEKK)ux+a@dZ;Rzfcf}aV&lY%v+i}D~#?Gehej_Aiy-Er$0?GTw#1*_EkC6TTuej4`W+qHMKS){p+sUw)H!aP(`|dMmWOt zJZ2wL=iY89X~jeWQ-^S@CfBt`7e1J`HofF$m86d)%qL!k^7hf>tJvDSr9C?4Kum46 zJZ~-SpUM-s`F%`{Mklc3FyL#LAb6Px&_cp`})TZo4lVm#zfCEjrI*K(W z!im(%l`8Jk1#7CUv_|XR4Tmv*I>1xtBz7j1nAsNPoBP|r96ufahZsaV$61L`lvZ~DA3rVc?TL>INsBX)#Z!lrB>}qdNymR*U@NKvbnVO zMX}i{cm1CEWvPm@Ty_5dJW&lRbSc*rbsLh2lV-g13FPhOCCmQ+_NUH&^(D!f$Unb5 zq+XjLZ%BVuyQ>m&t>Erda zDB8Pqv7n9*snq`fa5iST6667VrBW5(sGii|DFX&qi6R*YpJd@H4m(a02E{!M-6FJ_ zJ!SqgqT$HmHtnNTv*MGhsY<614lBN|rYjs{TcEYZ9sGQn_69W@4Wjg#iq*9*({Hq2 zdg;Gdy?x#9$Pty?zRuElxquxY!5@W-RShR`9+O? z3AB~pxvaa$owl8g^=av@VS$M}=po`^Xqh&v+zc$HWNu_L+QEggd@fFXZ?@ZI zwN0++ZHtR*E7-IfNri-Q#c}RCp^LlCWA*FQTjTV!ANe5T5w7_Vi9c&>s^J}RG)Q>^ z``dE9^JC34E86Ma-Y)jnlLK6cWMK|QOU<6(P^rpmlurPsk8!xqX#?e>fX5F{;=eeD?_h++a;OhSX=GVq4YeWzr`9pH*Hd=}) zrN{RAZaAmx`7YZ*QMvD{ajMZQ3HIXhKJ1C8K>#IAk;VA1tHdM`vMW%wO)+g9j zj(z>VvMROEab%59!YlKvTgi5w5|y5ozTJmzw(muIZO3+$^p+*da1$JEie&M&+ss8^ zkog66Bg6>TMSS%JB{Q z9R)5c3qe4PfLTb<**Tb@zr}dPE4+og`qi#JRn^il!D!x8C|Mfs`m50}K?`wc9EV^Z ztPCKSOPpznARV6}*Los)wn&ER?3;Y9MQ0Oi*#i=GphCrlDmt zV`G<&uO`N<8VG5)@+3qhqScNv;Xx#F8kt!IrZ_CvViE0)itLMgvBFi$dv3*9*VCpe zZFvUVOtwVe<$c<462DsFL?Z zr2Khi8(U@XRl3$0Q&nsXtd4ZGb4F|ArJRq4j~dusl>!TO+&4a^+T624KuK{@@hMe0;gE^D$1B-vJi-dFx>*v zbH?guwbNRoLMNabCvm4*qRM$5r}%XBmMb8&Ts^6OGC+>8?1; zc2vX5nWA!pmN@CBo1=-)V4cI)Pl98H z<8>#9NZUUOiY7HQH&3n%ZnYVTVeYa_&*8=qyBiZjLm)Qo%D)eQ$g_sXsk?cxZXhO5eHQyp_xKC z1^ExF!+@2S4<=uUXLreHwelre6o^k_>>XAm%&g{-Sw`zI@(5^{g`mk?3Ert&2m34k z06hInpsobE>_TLpd!ob4$2+d;4lsqlbE-a2iw-oRek3uvpvE?v(FTps6{^gBtHU%{ zhhxi0<>-LZ%;rQYS}ehODnuerl`Rn|$dvJkj*V)amABbx{{Zk2&OEZH=`^tVCd(VO z*3`^=WE%xZhCDFeba4%&H$apMjiHVsQlgIyXh&l*p)iKz84@HQR6%=i;dX(VUi;bc{`k+8UW?YAS z%b8ZIGAYB(`uP#$3d)5lO*$r(O4D02n0?br1$O$E=U!F*-SJ|0S-DQ5-EHcFf@o&mk%Ep{6%uN&r}feul5K0 zhs7&g)2J!ViZXhDqbzQ#Hh`$5Cc?v4z_6@ywAd)%bHxh{v~w*LIWnk)!$Gf2`LPP(ZBKqo}VS_jn22@8#gG3PpJvdZKu$Q03bnpkVEnZF=`DWSU{wOIlV zTB~wk2uF=$j=-pc8_n4@W$Df5VR)W`PxU3Ec!ZUV7@JTC~pxH$xZc}|! z_}4wvOw5uLixLz6ef0B|`w$^43t$sxmQU3sEUQ$5d-N!`9@WFkH&tRvQ5BNt=n(0Y? zgQCO7ft!ZjYDAQBT6L_i1g5f{*YgdQGfco8X?=)!C{QV!JqixorWS=^4jHu+ZF{={ znq@Su(|pR&TBK1Zgi$4F<*$%j%EHYiU6YvI`m2lT4={pi9z_&Wlf23(qy9;?u~NYC zPp;uT+B;+%90tpTO^nSw5SfLcv=dq{vTY#ZhHok1d#D0Bf}UZ>?poO}KP#QLROtkb zjrys}GMgkRWXWrl6BCru;pA&gBFP9lq7RUy5J2p*q16jP4UzHzzf#o{7uwiT!-xZ2 zQ|Z2S=dYM4r8J;|wdlPUr@XpHFIvCT+TyN>Zdqh_KkZik0Ha{Am-579Ns|r z2#K+xZE_Eqk0?2HPILH~_uWL|C}^T%JyS~LDky{8PJ+>rd0pMd%#R|qLXpghrK-_V z1q{J7swkf!NSWhkJCzNL-xpLe`B3vH`4mw^t3_yn1qJB65$vm{k?rPDN61nY&12WJ^K2cl!~)4o51yF}$-71aU|K25z;ENInD!LT$%kOQg#&1iL!iNfl4 z3+&tmt1I8`fd|Z^NRKN+FOdaUN64c{lmciPd2r`rt7IoJxd|8$A6%jl^3@9Zwt@&A zh<09!(O*R2(L8idPFYGNTB5a7pOCjXuGX_B5)sO&QlaD%ZCb-e35HTZ=t5wJEOz3j z#7o3*K8x&ia;!VVZL*D(qN1%dDq12H4=W1FG=(5Op+p2hLnt(>kwK zPN;NM)nnjjYi2Yy^iFgqna*)V&v89awAFrQ(4^X=2|+frKrG8t09LFlu0ocmIBJ*_ zv(DDDbt58ZaMe>a^C-DkK_1AWiYTHHMHGC2K>c36LXZI!W6ZJ1r*#wBo6&8e6r9$l zII9~Vi&KI(OGT=sm@QGVvY>p8EQMHF`F1MM3B;hRq7kJ<8rc%%OI07z+)37ySz1AIPzk51$WrGau+H!R#qTq>YXw?Rw zHz}@iX=)+Jx67FVDRUqJM3m#?167rk0*bU+5VdIZPPM0zzb?p?K{_feBp%^oF%dU8 z>WPF}2q1^m2q2o8nupaX!>WDmxmJjdsJW6MDR6EX|*kcS3asbQH7VH4L(}x zn&jRiebo%KfStq3DDwoMLgrPf2q8;fAzoHcOa(FRKp<#&Rwg>=;Gr?ZK|?4*$bXhm zPAUD9`zLlpA`!IZ(ORU&U!vj7zKAzvrl?YDji{7uOeHY2Hq>Njn8g?=&vu(2?a&P< zwdjzwR8<9u%a<_sty7*X?x^amzC{~U)DQ>{B6e1-IiV7bqR_QkCf4hn7qtlF4no@z z-=gBg5k(Z_f6EEIlT%PbRNUxV8I={dO$FE47D`Fm$dbKL8zKU7a41kl)pbTrso@Y) z8sAj+>{H(DQ@bkYh$xicR)~iZjUa-ui$|59ugg@>vn^FCD5_d+j&$ZVW;IE}5?$F` zO*heL{&4aLAgykQD6|!1rvg)jt9GlJkW^MnE6P`!`|t_w4fYOM+mO$Y#qPFA$@IhKj5 zs&gEziK8375&r->$^olYx0f@OTG3QxTh;kz z@}Qe)I#Ga{(zBGyOaTi`B{Y;WqDmz>5#&m)ijWDRYJo$_$_^VWyA`Sf$-pV`5(dW` zoa4|7U$l6LgT@mmKZw%t%nfmHj}|u?sl$$7K~G>rr;U}3LTT2M2FssCD3CN=6(&a6 zRNwOlmWx%&t=?L#3e>N&L^T}9lnawu7OO;UK9lJ>)glv|5k(fP%F_ajrV*kIa&0Y8 z0@h-PM>3#~l_JKX=OQhU0 zKorcWpet6bYqG~<=MiC{bKU9DU@$SVOk*1~-M;8dji5E40E%;UyC2B_;d>#jp2!Zp zSE|5xgbs@l6J%-bu{ccg;4Vn+_hDg~>%)&x~k0by+|eH0V}X{;~Snt$wDf zl{)EB3ZqZWpiiyQa@A8dPUsVXK{k^^kZ49kDKs?;^X5c~0X!$U6W7X@8c#&l$D*qB zSE9;L9Z*Lya@7JgO&u4Ac+Ma)7|3tlkd9EW9uEft3^T}nIS*A@bxdI1ig;Z?`7R_} zqu=@`JLP3>?xk_QflHJX#0N#;UMHGJnjpk=AZYJ(i@{^g8SFMX)nDeZ5l5RL3Ry92 zrrnfU#`3BkSRvg6(bY4$XLTQSA9Wvf9qyUkMQS{%GVRm>$|BRMiq5D3@g@?Y0W<_M z6h}nxoY0B#A#6N}KTy?I*2w);N0pkAp5d6F5bla3A!|hI3RjTUw78zZ3|3rDG?5=j z`>YR!;lwaT*xS*K(Gw35=b8Tipj0n5r?P1_{Zl~rIe%2fNDF<_cv5BgA;UWAX?}q; zo8PeLqy$|BYz_$J;xKAAULE4_fgq=kh228MVB>pU-XloSz$Qb8r!y(Ox+vm|YNIaD zruR(lo82?!M6)|YbMd7EHE}6t>qsTl?Dlkl~WDH&nw5iFd*`SbSVTrNER6 zU0V>bei!00G0rDlTm3?Z85jrk3lEONI8bQ=l#MlBJAll`nTtOcML0T4;qy}e0N;P+ zo&NxJ-b+3zhkq|$FI2`23e4@+QMzllOr!}k zwffcMQ;IdWm5(CS;1@EDk*vjAkRfrWWNrJz1&8qe01jgs=KF<{4=aJ`y5^Oapg)_F z@qQ%vNaT78Tej44Lwg({Gw=SjmF{YeS6!k91K^PEJlvPEJlvPESuyPfzZj z-8;H>bnfW)MIR>@B6m(bQ^OP6jD3`b{jF9KFJ+qxD_>biOozoL4QqZQ zt9JhYETiOq&26Z|O{m4D?2N$SpmpE$TL3*3GTpgZtev|f$KjUeVb=R84~WmgX(K%Y z?fqAXNaGoM_1tw*;2E56V35Aaj?G$rNMgKM;2LvA+uC=~qSNlC$jufUe~XVR9n@C; z0PfsQ>D9V6o!{yd@B(|0t5j(;3RYua^@Ke~^^d@=1Uw`DY?!(ye1K~4n!0J}d_yr#z6O-y2mH$I4?YcNG0 zI`Y*NO2p#kYa?MKs4!5nNm|0~_0=*k7{C!WPQL0~MB+T$y@r}7;fE`FkNHc8IKHGP z{(#7@P@32pb97u*5_@P9i`*-H(25q1MQ*SYf#yVn(Ek91HQ?pf(zQYDYunn2d^nfU z(5Dx*M+|dj8WtA*X#F9d#1kNn5huXlHSACYvNgnZkbn6s%%=f$l-|XxlP7+UY#``HNf!_&g+SWj@?|Szd#SUQ9=L%oT`aDjc7Np7NY}n z&`p|A7wS`YV~FiYx*|yTKuR7y5_jyQhDywD*=NTHM)$AD13W;Fy{f{#E*bnZ;IaN1 z{vQ>SK;z4w`C1anGO^K~tvdciVewcQB9MGdUqi>u71#$){{V&6F@ZHtjKxI+Lo?)c z1>+|Vnmk4pX)paYNH+H?_J<5l7sD|PuDJ_`>1oU~;97efJm?^xZY7n3eA09Sy738O zX`R9$_%Hh?qi&ABbYy*oIo-MiSbc@{>Wpl7&6UpF_4Wzx<8-l3cc*06MCktj6D%VJ zJ|}HYqJ|>{3$N~`#y!{4CULDhpyyZJ39fG?5T_Qg8QKqh)9h?fYXK(5WgFu(bSOnU zkjO2!sPrhaXJ#MKI}_sAxrwoufWO8^{A#9jp!^TY?_!KjKYkbLFQEXUKwZC4x-pT) z+tBn)#Nkeq@DopnTZK!rhksx9M53G#zpZ{$oP3*t$HytWv|9S?SA;*|#kwQ=%s$^5f@UD(h8LRCGleF%;@kPA zvze{V^YFKzSgC9RT_MwYYg8C`Cw6S*x)*nG?5-djem5q!77t9pza%qe8x+&vQS(^@ z+I)}71-ZlW37BX@2K{LUjU7X}hX^Z(1&glgT-U=Obnd3aT|)2v+O;EG*5oZdsK^_e zOVI`zv?T&|3(}mZ7L(CG=~#|kL50LHX}zja03L?fAnD`j1tGosr2I$t_cd zL+Y+vjud)@JHN(6-0f)Aw;%49(3liu1La>(D@1)tE0JOt`;G2l{nJawBd_r#{{Y4V zmOj4I4-5YQHmnSQad2;M@R0B9unruv$sf2Q`7BFfZ_)f&u6Jzp{MU&_!NrY}4_1P_ z3l(2Tz~w#I;?>|2u!wQ5gpv2gZ`VbS*XWp>B!j+k9r~}@V&*sK5;``YxI_MWEN&Tt ziG`Vkx>J}&>&CGM>~K>*P;Vjrs~jQAJ|7a|KkY(X-~Q#4%LC(dY`pnh8`yvOsAFx0 zhK}kM@|R^%h{(=0QcsN|h+4?l*0){B3k!uLayXvIHnPSAeyay599Wn%-jsQakiLc4 z0PNxDzLkcGakR0p>;C|S8)E?V8x@Iu9!YR5w-%9>>s8`7Tv?8A_7HW{r#KLGq)W1& zgzT7REPJS(1C}=hj?$dd(`6jF4bz6#y5r!U#{hIrgB>(^GK|K7AMP1TvU%#Ad#yqr zTQU{oTjga~K%J0kYUNEqVqtAU_eb2d$5l2a43jv#*Z{dhg@`;h&19@ii4p^&w+Mp%1|LI($J{Zw-o zFz818Q5Z+IH&`25mK{L^KsH0{EQ zT&{gm@ruOW?>yv6G$G(9jQFExBG{EN?LgmiZ|pMtyxSUp?*S; z1IgLfDmIEVj6Np~yRTk}xSTF=sOX%@Wc+R#p1~Z>{pVWQc#a$WJWGJ=bsws3VaLP8 z>h?CjK%kx@EOEgjN1@;IPM^Zsbj&n&qG1Gw5Kgy94BS_N&7*d@P~-5daB$4ls*wAm z6fun3xF>N7uXPqD9?bm@Rfd&Z@v|Oz9rIFPIdNEO%rwgoEkFCtiw88{WdV)HxBeCz!(h!b#hpTn z#4(O@7+K$45Br;1t5xJvlY$qiO-%}%i3p!7Kq~rxg{sJj2-=nh0vww&+{{6-At=H) zu{%5`uVtRXZlOODiW+i*su8pX^snTP(&1}B__a>M_=6&Q35r|S@RZ^l_R*orLbqk0 z23n-~M%~sGoQ{7nr1oKl@jY<$K!pVxt{lb(;PI zsvyzn{_52rflVqfnj4}y)i*@r^(>$msH^(771j$xWJdKC=6X@z&hFE6^q z;xP?t-Wm#LheYt!>7^$%*Xc{>_{g7|mp-UY=AMpTA>s4XV7fE7?6I=WZWjJsmMP+$ zqkTFi7umG@K0r0P(G0}>6j?nMCGga6@cLfrJS$rGFf7m3&qLRp+O@#p?rSrUKsYD@lpP32KdY2cN~xUaZ!2p2A785TM@K= zwJTJKK1~Wt5pj1mhKH*0De=u~W@vNWyS4~TgJ&>x4xLu|A`i8v&Gu4@7sybMRVd#F z*)(SAQyH5pPMv$Hg&zB>dqD=t-3A9(-=f1n!+kZR`5Tp-t?ChR)mxMp3yr7~L7UBe2%g@5DYp_X1sGS}PkJXQ>C|RvQ-(ixMN9pZ4(r>-b&M^Iivw*9|-H zk{`J*+DGzm)PAeQuno^F7Z*vGbH;!759|Ko$~kmIA1f#=Q>S*?EHqK&by_jVUIvKC zBU(2qJT@CUhQVifO_8LmvQp(k%4uTEofL(q%3M>l8YjRN6jAb|Nm5K{jn#OZc!nEw z6VR22J`WL*m7>bx5e`B;V|#yfnV%(=!4?({jkS1ff?IzjfNOaev?!JzMa-=GE+m0t z4~6jJh&#GCUL;Nfi2neLj>GP)eDPR+fZUWi2(4W~eho`WMPnKZ76nIQ1aUB+H%b1~=Hj}56 zc1&b86&fQ{*x8-IGa2F_@Bw3brm_&m=mKkjDv!-n*VG$D%-5(~r`=|1=Js5^$mmx6 ziaCll0RI4m2DB1}Cjj=BWziVCN#Y)U^eH{iXK-8@i&xSHViRCgRwNwpxVO|SOYsHf zLl+2)<+=v~yf$4-j=xc;_NA0lW)J-j>X=VJd;b7+i-F*S{{TG~hWN~8W(L8FJ4ikm zKacyYRt|Y!#4{|c-&^^QL}Gaf@7O0a>Zg^LN=ThW9?8!%0-1uy@;#C5^<5L(*9}M1 zDD1RG{GG%AE({mdW+zktE?M5WmoJpjvz0y1w;L-=rAMELP-`-SvM+H&gh5=u6$}wt zB|sZGP-Z_#IQR^^5eWi*HQrZ=O5tJ7rW=pEOpHSub( zj(eQhS}*Y$ULgs}SzgCQi*%q!4-D^X4UNJBoHACNvAN#Abe_ubE*)-`7(=e_{e9h>in;JmbGB}qKQb^H56dESUIkgkq)~3e^HKGi8uc+KaS=qltL8Io>3cn+; zPO#?$)-r<3<(0MNs9e5Q5o?p=ovVR`9?I`knbv{|%5Z8S;3sgwC}O-`DBdl$n#fqj ziU1(U-K{Ly-FgCq@MdpS!zR%$04`FrUt|y>l@xPovkH;!3Q8KW5 zs`4Yvw2mfB#A(q-hh|pvMa5=yC$ZVOdFke7Z~!3#Lx@4Gr0E>8trZ_2LUi+oR*9Hs z01YJ>@y2(@Sw0<5qDl@0m+>IXDLumbArrcbLBQy^S+v)pcL-VtY`LoS2$5lPQ3w}W zsH_FT!~=Rc6Bt*b;$d7^Kt>j77U5a)E;Ni$T8|4q4sY~{_`eVS6C_|Df(X!!kCQW5 z-8{aS6s?{tBgU|{^IrM&PkyQSveGd`3g@L0pwSoOngHsISWrC^p5jq-pm#znIMbrH z7J{t(Ledj45oC7jfh~`Oq2;P7OjaSS8+B3Ouz>Zg6}b6x=$nay)jBB)Pu0^qf@1{) zNhoo3R@}jIt3j#L%+`d=HW)aB_dGNW3FvCmFn$~y8J9k&29an!P|@yDYz-cX#qK|b zf+c|UrO%p?{);PDKg|}r5VXt+4=W*SLYb@?N)p7|-t;NJ096#+P7oZyzO`t<4qVo= zxT-!zTt(K&xO^(&Jg*e+=|iG@v#e*~E5hZBaa`zK)vg!%h2ce)4BS_A4F~mD$Ti`` ze#K%j_{YyF1Qt~M-bpiRT!eoH*WpzO?7_LPJz04A1_xVZNE6d=L;ml9`v zS}DwUAgHmJZF?udDQWX@LOA%39;Ie+17NI`(HljM3A06+sI<{q`8yFf(?FSp!(|)N zB%M)Op(7})uJk|!Wm~w839M#O(M2{sAy#vB7J$LTKXo{Q0Zp(kR*g~NT61sLRPF=4 z7un{kwCvjH<@zk}2QE;p$;M2?D1Pcp_CPNh{iQCJSWL!l&fO73&`rS zVe+eY)gXB~Hw|=5#X*>F(KtMUH6BM3FmW0L%r+l}- z`vt3$OJ2^2j>C>)RD&*pC2~Dn71?DuO6L}tZLgX+==5J`@DHd~NBpJB=z;M0xpQ)z z788!(aI&-0GHdm2e`B%?l8(h^l7`ELh4~>P$t5sVE?lIb{6Z1osb(c}toW8v;bDGA zz2y+)n-UgGF8vdoDJsP##{l+OpG#k=2&G5LxlYKt?HHddjbQ4d!C>

qKOEHn&!O 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

FtW^j^6(>Jmm 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 H@PGf=r(Wl2 literal 0 HcmV?d00001 diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-data-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-data-resolver.ts new file mode 100644 index 0000000000000..873fbdff97c3b --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-data-resolver.ts @@ -0,0 +1,502 @@ +import path from "path" +import { ensureDir, remove } from "fs-extra" +import importFrom from "import-from" +import { fetchRemoteFile } from "gatsby-core-utils/fetch-remote-file" +import { gatsbyImageDataResolver } from "../index" +import * as dispatchers from "../jobs/dispatchers" +import type { Store } from "gatsby" +import { PlaceholderType } from "../placeholder-handler" + +jest.spyOn(dispatchers, `shouldDispatch`).mockImplementation(() => false) +jest.mock(`import-from`) +jest.mock(`gatsby-core-utils/fetch-remote-file`, () => { + return { + fetchRemoteFile: jest.fn(), + } +}) +jest.mock(`gatsby-core-utils/mutex`, () => { + return { + createMutex: jest.fn(() => { + return { + acquire: jest.fn(() => Promise.resolve()), + release: jest.fn(() => Promise.resolve()), + } + }), + } +}) + +function parseSrcSet(srcSet: string): Array<{ src: string; viewport: string }> { + return srcSet.split(`,`).map(line => { + const [src, viewport] = line.split(` `) + + return { src, viewport } + }) +} + +describe(`gatsbyImageData`, () => { + const cacheDir = path.join(__dirname, `.cache`) + + beforeAll(async () => { + await ensureDir(cacheDir) + + importFrom.mockReturnValue({ + getCache: jest.fn(() => { + return { + get: jest.fn(() => Promise.resolve()), + set: jest.fn(() => Promise.resolve()), + directory: cacheDir, + } + }), + }) + }) + afterAll(() => remove(cacheDir)) + + beforeEach(() => { + dispatchers.shouldDispatch.mockClear() + fetchRemoteFile.mockClear() + }) + + const store = {} as Store + const portraitSource = { + id: `1`, + url: `https://images.unsplash.com/photo-1588795945-b9c8d9f9b9c7?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=80`, + width: 600, + height: 962, + mimeType: `image/jpeg`, + filename: `dog-portrait.jpg`, + parent: null, + children: [], + internal: { + type: `Test`, + owner: `test`, + contentDigest: `1`, + }, + } + const landscapeSource = { + id: `2`, + url: `https://images.unsplash.com/photo-1561037404-61cd46aa615b?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=600&q=80`, + width: 600, + height: 400, + mimeType: `image/jpeg`, + filename: `dog-landscape.jpg`, + parent: null, + children: [], + internal: { + type: `Test`, + owner: `test`, + contentDigest: `1`, + }, + } + + const portrait = [ + `portrait`, + portraitSource, + { + fixed: [300, 481], + constrained: [], + fullWidth: [], + widthOnly: [300, 481], + heightOnly: [187, 300], + widthWithFit: [ + [`cover`, 300, 481], + [`fill`, 300, 962], + [`outside`, 300, 481], + [`contain`, 300, 481], + ], + heightWithFit: [ + [`cover`, 187, 300], + [`fill`, 600, 300], + [`outside`, 187, 300], + [`contain`, 187, 300], + ], + bothWithFit: [ + [`cover`, 300, 300], + [`fill`, 300, 300], + [`outside`, 300, 481], + [`contain`, 300, 300], + ], + }, + ] + + it(`should return null when source is not an image`, async () => { + expect( + await gatsbyImageDataResolver( + { + id: `1`, + url: `https://origin.com/my-pdf.pdf`, + mimeType: `application/pdf`, + filename: `my-pdf.pdf`, + parent: null, + children: [], + internal: { + type: `Test`, + owner: `test`, + contentDigest: `1`, + }, + }, + // @ts-ignore - don't care + {}, + store + ) + ).toBe(null) + expect(dispatchers.shouldDispatch).not.toHaveBeenCalled() + }) + + it(`should return proper image props for fixed layout`, async () => { + const result = await gatsbyImageDataResolver( + portraitSource, + { + layout: `fixed`, + width: 300, + placeholder: `none`, + }, + store + ) + + const parsedSrcSet = parseSrcSet(result.images.sources[0].srcSet) + expect(parsedSrcSet.length).toBe(2) + expect(parsedSrcSet[0].src).toEqual( + `/_gatsby/image/${Buffer.from(portraitSource.url).toString( + `base64` + )}/${Buffer.from(`w=300&h=481&fm=webp`).toString(`base64`)}.webp` + ) + expect(parsedSrcSet[0].viewport).toEqual(`1x`) + expect(parsedSrcSet[1].src).toEqual( + `/_gatsby/image/${Buffer.from(portraitSource.url).toString( + `base64` + )}/${Buffer.from(`w=600&h=962&fm=webp`).toString(`base64`)}.webp` + ) + expect(parsedSrcSet[1].viewport).toEqual(`2x`) + + expect(result).toEqual({ + height: 481, + width: 300, + layout: `fixed`, + placeholder: undefined, + backgroundColor: undefined, + images: { + fallback: { + sizes: `300px`, + src: expect.any(String), + srcSet: expect.any(String), + }, + sources: [ + { + sizes: `300px`, + srcSet: expect.any(String), + type: `image/webp`, + }, + { + sizes: `300px`, + srcSet: expect.any(String), + type: `image/avif`, + }, + ], + }, + }) + }) + + it(`should return proper image props for constrained layout`, async () => { + const result = await gatsbyImageDataResolver( + portraitSource, + { + layout: `constrained`, + width: 300, + placeholder: `none`, + }, + store + ) + + const parsedSrcSet = parseSrcSet(result.images.sources[0].srcSet) + expect(parsedSrcSet.length).toBe(4) + expect(parsedSrcSet[0].src).toEqual( + `/_gatsby/image/${Buffer.from(portraitSource.url).toString( + `base64` + )}/${Buffer.from(`w=75&h=120&fm=webp`).toString(`base64`)}.webp` + ) + expect(parsedSrcSet[0].viewport).toEqual(`75w`) + expect(parsedSrcSet[1].src).toEqual( + `/_gatsby/image/${Buffer.from(portraitSource.url).toString( + `base64` + )}/${Buffer.from(`w=150&h=241&fm=webp`).toString(`base64`)}.webp` + ) + expect(parsedSrcSet[1].viewport).toEqual(`150w`) + expect(parsedSrcSet[2].src).toEqual( + `/_gatsby/image/${Buffer.from(portraitSource.url).toString( + `base64` + )}/${Buffer.from(`w=300&h=481&fm=webp`).toString(`base64`)}.webp` + ) + expect(parsedSrcSet[2].viewport).toEqual(`300w`) + expect(parsedSrcSet[3].src).toEqual( + `/_gatsby/image/${Buffer.from(portraitSource.url).toString( + `base64` + )}/${Buffer.from(`w=600&h=962&fm=webp`).toString(`base64`)}.webp` + ) + expect(parsedSrcSet[3].viewport).toEqual(`600w`) + + expect(result).toEqual({ + height: 481, + width: 300, + layout: `constrained`, + placeholder: undefined, + backgroundColor: undefined, + images: { + fallback: { + sizes: `(min-width: 300px) 300px, 100vw`, + src: expect.any(String), + srcSet: expect.any(String), + }, + sources: [ + { + sizes: `(min-width: 300px) 300px, 100vw`, + srcSet: expect.any(String), + type: `image/webp`, + }, + { + sizes: `(min-width: 300px) 300px, 100vw`, + srcSet: expect.any(String), + type: `image/avif`, + }, + ], + }, + }) + }) + + it(`should return proper image props for fullWidth layout`, async () => { + const result = await gatsbyImageDataResolver( + { + ...portraitSource, + width: 2000, + height: 3206, + }, + { + layout: `fullWidth`, + width: 2000, + placeholder: `none`, + }, + store + ) + + const parsedSrcSet = parseSrcSet(result.images.sources[0].srcSet) + expect(parsedSrcSet).toHaveLength(4) + expect(parsedSrcSet[0].src).toEqual( + `/_gatsby/image/${Buffer.from(portraitSource.url).toString( + `base64` + )}/${Buffer.from(`w=750&h=1202&fm=webp`).toString(`base64`)}.webp` + ) + expect(parsedSrcSet[0].viewport).toEqual(`750w`) + expect(parsedSrcSet[1].src).toEqual( + `/_gatsby/image/${Buffer.from(portraitSource.url).toString( + `base64` + )}/${Buffer.from(`w=1080&h=1731&fm=webp`).toString(`base64`)}.webp` + ) + expect(parsedSrcSet[1].viewport).toEqual(`1080w`) + expect(parsedSrcSet[2].src).toEqual( + `/_gatsby/image/${Buffer.from(portraitSource.url).toString( + `base64` + )}/${Buffer.from(`w=1366&h=2190&fm=webp`).toString(`base64`)}.webp` + ) + expect(parsedSrcSet[2].viewport).toEqual(`1366w`) + expect(parsedSrcSet[3].src).toEqual( + `/_gatsby/image/${Buffer.from(portraitSource.url).toString( + `base64` + )}/${Buffer.from(`w=1920&h=3078&fm=webp`).toString(`base64`)}.webp` + ) + expect(parsedSrcSet[3].viewport).toEqual(`1920w`) + + expect(result).toEqual({ + height: 3206, + width: 2000, + layout: `fullWidth`, + placeholder: undefined, + backgroundColor: undefined, + images: { + fallback: { + sizes: `100vw`, + src: expect.any(String), + srcSet: expect.any(String), + }, + sources: [ + { + sizes: `100vw`, + srcSet: expect.any(String), + type: `image/webp`, + }, + { + sizes: `100vw`, + srcSet: expect.any(String), + type: `image/avif`, + }, + ], + }, + }) + }) + + it(`should return proper srcSet from outputPixelDensities`, async () => { + const fixedResult = await gatsbyImageDataResolver( + portraitSource, + { + layout: `fixed`, + width: 300, + placeholder: `none`, + outputPixelDensities: [1, 2], + }, + store + ) + const constrainedResult = await gatsbyImageDataResolver( + portraitSource, + { + layout: `constrained`, + width: 300, + placeholder: `none`, + outputPixelDensities: [1, 2], + }, + store + ) + const fullWidthResult = await gatsbyImageDataResolver( + { + ...portraitSource, + width: 2000, + height: 3206, + }, + { + layout: `fullWidth`, + width: 300, + placeholder: `none`, + outputPixelDensities: [1, 2], + }, + store + ) + + const parsedFixedSrcSet = parseSrcSet(fixedResult.images.sources[0].srcSet) + expect(parsedFixedSrcSet).toHaveLength(2) + expect(parsedFixedSrcSet[0].src).toEqual( + `/_gatsby/image/${Buffer.from(portraitSource.url).toString( + `base64` + )}/${Buffer.from(`w=300&h=481&fm=webp`).toString(`base64`)}.webp` + ) + expect(parsedFixedSrcSet[1].src).toEqual( + `/_gatsby/image/${Buffer.from(portraitSource.url).toString( + `base64` + )}/${Buffer.from(`w=600&h=962&fm=webp`).toString(`base64`)}.webp` + ) + + const parsedConstrainedSrcSet = parseSrcSet( + constrainedResult.images.sources[0].srcSet + ) + expect(parsedConstrainedSrcSet).toHaveLength(2) + expect(parsedConstrainedSrcSet[0].src).toEqual( + `/_gatsby/image/${Buffer.from(portraitSource.url).toString( + `base64` + )}/${Buffer.from(`w=300&h=481&fm=webp`).toString(`base64`)}.webp` + ) + expect(parsedConstrainedSrcSet[1].src).toEqual( + `/_gatsby/image/${Buffer.from(portraitSource.url).toString( + `base64` + )}/${Buffer.from(`w=600&h=962&fm=webp`).toString(`base64`)}.webp` + ) + + const parsedFullWidthSrcSet = parseSrcSet( + fullWidthResult.images.sources[0].srcSet + ) + expect(parsedFullWidthSrcSet).toHaveLength(4) + }) + + it(`should return proper srcSet from breakpoints only for fullWidth`, async () => { + const biggerPortraitSource = { + ...portraitSource, + width: 2000, + height: 3206, + } + const fixedResult = await gatsbyImageDataResolver( + biggerPortraitSource, + { + layout: `fixed`, + width: 300, + placeholder: `none`, + breakpoints: [350, 700], + }, + store + ) + const constrainedResult = await gatsbyImageDataResolver( + biggerPortraitSource, + { + layout: `constrained`, + width: 300, + placeholder: `none`, + breakpoints: [350, 700], + }, + store + ) + const fullWidthResult = await gatsbyImageDataResolver( + biggerPortraitSource, + { + layout: `fullWidth`, + width: 1000, + placeholder: `none`, + breakpoints: [350, 700], + }, + store + ) + + const parsedFixedSrcSet = parseSrcSet(fixedResult.images.sources[0].srcSet) + expect(parsedFixedSrcSet).toHaveLength(2) + expect(parsedFixedSrcSet[0].viewport).toEqual(`1x`) + const parsedConstrainedSrcSet = parseSrcSet( + constrainedResult.images.sources[0].srcSet + ) + expect(parsedConstrainedSrcSet).toHaveLength(4) + expect(parsedConstrainedSrcSet[0].viewport).toEqual(`75w`) + + const parsedFullWidthSrcSet = parseSrcSet( + fullWidthResult.images.sources[0].srcSet + ) + expect(parsedFullWidthSrcSet).toHaveLength(2) + expect(parsedFullWidthSrcSet[0].viewport).toEqual(`350w`) + expect(parsedFullWidthSrcSet[1].viewport).toEqual(`700w`) + }) + + it(`should generate dominant color placeholder by default`, async () => { + fetchRemoteFile.mockResolvedValueOnce( + path.join(__dirname, `..`, `__fixtures__`, `dog-portrait.jpg`) + ) + const fixedResult = await gatsbyImageDataResolver( + portraitSource, + { + layout: `fixed`, + width: 300, + }, + store + ) + + expect(fetchRemoteFile).toHaveBeenCalledTimes(1) + expect(fixedResult?.backgroundColor).toEqual(`rgb(56,40,40)`) + }) + + it(`should generate base64 placeholder`, async () => { + fetchRemoteFile.mockResolvedValueOnce( + path.join(__dirname, `..`, `__fixtures__`, `dog-portrait.jpg`) + ) + const fixedResult = await gatsbyImageDataResolver( + portraitSource, + { + layout: `fixed`, + width: 300, + placeholder: PlaceholderType.BLURRED, + }, + store + ) + + expect(fetchRemoteFile).toHaveBeenCalledTimes(1) + expect(fetchRemoteFile).toHaveBeenCalledWith({ + url: portraitSource.url, + cacheKey: `1`, + directory: expect.stringContaining(cacheDir), + }) + expect(fixedResult?.placeholder).toMatchInlineSnapshot(` + Object { + "fallback": "data:image/jpg;base64,/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAhABQDASIAAhEBAxEB/8QAGQABAAMBAQAAAAAAAAAAAAAAAAQGBwUI/8QAMBAAAQMDAgQEAwkAAAAAAAAAAQIDBAAFEQYhEhMxUSJBYYEHNLEIFTIzUnGCkcH/xAAYAQADAQEAAAAAAAAAAAAAAAABAwQABf/EABwRAAICAwEBAAAAAAAAAAAAAAABAgMREkFRMf/aAAwDAQACEQMRAD8Aol31vIuGk59kf8RfuCLgFpUTwE54k79ACQQK1L7Nj0mdp+7sR3gH48hKgeYErCVp8XX1TWD6WjNO3RlU5h19C1JHJRuXCr8I7gFWM48q07VF2nWlpKtKsuW+W6sx1LbYaBeS2CSNhgDGFAdeo3pKs1mkxsqdq3g1LUC9PxLktu46iksycBSkKYCyP3INK8vXTVWoJEsuzpzheUBuppAJH9UqjafCZUV9LnprTsmNqKJMajyuQy6JHG+sJARjYq2xnbp2PerBNtDmp7eiPDcZQpl4OuK4vMpI6d9xn2rqX+6PuWp2MzHcaSvwFx9QOAfMYPXt61G03aJMK5OyUEBC1LXy1rBJSrBwdtiCM5B9DUDjJyT8OkrIqLIEf4bW8ND7yadkyP1tqWBjyG1KtqVunJUGSSc+Ikke+KU3MvSdtZOPe/kZH8PrXdb+XZ9v8pSt0D+Igvfmq9vpSlKIs//Z", + } + `) + }) +}) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts new file mode 100644 index 0000000000000..5305b64ab59eb --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts @@ -0,0 +1,131 @@ +import path from "path" +import type { Store } from "gatsby" +import importFrom from "import-from" +import { publicUrlResolver } from "../index" +import * as dispatchers from "../jobs/dispatchers" +// import { fetchRemoteFile } from "gatsby-core-utils/fetch-remote-file" + +jest.spyOn(dispatchers, `shouldDispatch`).mockImplementation(() => false) +jest.mock(`import-from`) + +describe(`publicResolver`, () => { + const store = {} as Store + + it(`should return a file based url`, () => { + const source = { + id: `1`, + mimeType: `application/pdf`, + url: `https://example.com/file.pdf`, + filename: `file.pdf`, + parent: null, + children: [], + internal: { + type: `Test`, + owner: `test`, + contentDigest: `1`, + }, + } + + expect(publicUrlResolver(source, store)).toEqual( + `/_gatsby/file/${Buffer.from(source.url).toString(`base64`)}.pdf` + ) + }) + + it(`should return an image based url`, () => { + const source = { + id: `1`, + mimeType: `image/jpeg`, + url: `https://example.com/image.jpg`, + filename: `image.jpg`, + parent: null, + children: [], + internal: { + type: `Test`, + owner: `test`, + contentDigest: `1`, + }, + } + + expect(publicUrlResolver(source, store)).toEqual( + `/_gatsby/file/${Buffer.from(source.url).toString(`base64`)}.jpg` + ) + }) + + it(`should dispatch a file job if it's a file`, () => { + const actions = { + createJobV2: jest.fn(() => jest.fn()), + } + dispatchers.shouldDispatch.mockImplementationOnce(() => true) + importFrom.mockImplementation(() => actions) + + const source = { + id: `1`, + mimeType: `image/jpeg`, + url: `https://example.com/my-report.pdf`, + filename: `my-report.pdf`, + parent: null, + children: [], + internal: { + type: `Test`, + owner: `test`, + contentDigest: `1`, + }, + } + publicUrlResolver(source, store) + expect(actions.createJobV2).toHaveBeenCalledWith( + expect.objectContaining({ + args: { + contentDigest: `1`, + filename: expect.any(String), + url: source.url, + }, + inputPaths: [], + name: `FILE_CDN`, + outputDir: expect.stringContaining( + path.join(`public`, `_gatsby`, `file`) + ), + }), + expect.any(Object) + ) + }) + + it(`should dispatch a file job if it's an image`, () => { + const actions = { + createJobV2: jest.fn(() => jest.fn()), + } + dispatchers.shouldDispatch.mockImplementationOnce(() => true) + importFrom.mockImplementation(() => actions) + + const source = { + id: `1`, + mimeType: `image/jpeg`, + url: `https://example.com/image.jpg`, + filename: `image.jpg`, + width: 300, + height: 300, + parent: null, + children: [], + internal: { + type: `Test`, + owner: `test`, + contentDigest: `1`, + }, + } + publicUrlResolver(source, store) + expect(actions.createJobV2).toHaveBeenCalledWith( + expect.objectContaining({ + args: { + contentDigest: `1`, + filename: expect.any(String), + url: source.url, + }, + inputPaths: [], + name: `FILE_CDN`, + outputDir: expect.stringContaining( + path.join(`public`, `_gatsby`, `file`) + ), + }), + expect.any(Object) + ) + }) +}) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts new file mode 100644 index 0000000000000..af7dad3efdfd1 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts @@ -0,0 +1,319 @@ +import path from "path" +import importFrom from "import-from" +import { resizeResolver } from "../index" +import * as dispatchers from "../jobs/dispatchers" +import type { Store } from "gatsby" +import type { ImageFit, IRemoteImageNode } from "../types" +// import { fetchRemoteFile } from "gatsby-core-utils/fetch-remote-file" + +jest.spyOn(dispatchers, `shouldDispatch`).mockImplementation(() => false) +jest.mock(`import-from`) + +describe(`resizeResolver`, () => { + beforeEach(() => { + dispatchers.shouldDispatch.mockClear() + }) + + const store = {} as Store + const portraitSource = { + id: `1`, + url: `https://images.unsplash.com/photo-1588795945-b9c8d9f9b9c7?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=80`, + width: 600, + height: 962, + mimeType: `image/jpeg`, + filename: `dog-portrait.jpg`, + parent: null, + children: [], + internal: { + type: `Test`, + owner: `test`, + contentDigest: `1`, + }, + } + const landscapeSource = { + id: `2`, + url: `https://images.unsplash.com/photo-1561037404-61cd46aa615b?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=600&q=80`, + width: 600, + height: 400, + mimeType: `image/jpeg`, + filename: `dog-landscape.jpg`, + parent: null, + children: [], + internal: { + type: `Test`, + owner: `test`, + contentDigest: `1`, + }, + } + + const portrait = [ + `portrait`, + portraitSource, + { + widthOnly: [300, 481], + heightOnly: [187, 300], + widthWithFit: [ + [`cover`, 300, 481], + [`fill`, 300, 962], + [`outside`, 300, 481], + [`contain`, 300, 481], + ], + heightWithFit: [ + [`cover`, 187, 300], + [`fill`, 600, 300], + [`outside`, 187, 300], + [`contain`, 187, 300], + ], + bothWithFit: [ + [`cover`, 300, 300], + [`fill`, 300, 300], + [`outside`, 300, 481], + [`contain`, 300, 300], + ], + }, + ] + + const landscape = [ + `landscape`, + landscapeSource, + { + widthOnly: [300, 200], + heightOnly: [450, 300], + widthWithFit: [ + [`cover`, 300, 200], + [`fill`, 300, 400], + [`outside`, 300, 200], + [`contain`, 300, 200], + ], + heightWithFit: [ + [`cover`, 450, 300], + [`fill`, 600, 300], + [`outside`, 450, 300], + [`contain`, 450, 300], + ], + bothWithFit: [ + [`cover`, 300, 300], + [`fill`, 300, 300], + [`outside`, 450, 300], + [`contain`, 300, 300], + ], + }, + ] + + it(`should return null when source is not an image`, async () => { + expect( + await resizeResolver( + { + id: `1`, + url: `https://origin.com/my-pdf.pdf`, + mimeType: `application/pdf`, + filename: `my-pdf.pdf`, + parent: null, + children: [], + internal: { + type: `Test`, + owner: `test`, + contentDigest: `1`, + }, + }, + { + width: 100, + }, + store + ) + ).toBe(null) + expect(dispatchers.shouldDispatch).not.toHaveBeenCalled() + }) + + it(`should allow you to change the format of the file`, async () => { + const result = await resizeResolver( + portraitSource, + { + width: 100, + format: `webp`, + }, + + store + ) + expect(result.src).toMatch(/\.webp$/) + }) + + it(`should fail when wrong format is given`, async () => { + await expect( + resizeResolver( + portraitSource, + { + width: 100, + format: `unknown`, + }, + store + ) + ).rejects.toThrowError( + `Unknown format "unknown" was given to resize ${portraitSource.url}` + ) + }) + + it(`should fail when no height or width is given`, async () => { + await expect( + resizeResolver(portraitSource, {}, store) + ).rejects.toThrowError( + `No width or height is given to resize "${portraitSource.url}"` + ) + }) + + it(`should add cropFocus when it's set`, async () => { + const result = await resizeResolver( + portraitSource, + { + width: 100, + cropFocus: [`top`, `left`], + }, + store + ) + + const [, , , , args] = result?.src.split(`/`) ?? [] + const [transformArgs] = args.split(`.`) + const transformAsArgs = Buffer.from(transformArgs, `base64`).toString() + expect(transformAsArgs).toContain(`fit=crop`) + expect(transformAsArgs).toContain(`crop=top,left`) + }) + + describe.each([portrait, landscape] as Array< + [ + string, + IRemoteImageNode, + { + widthOnly: [number, number] + heightOnly: [number, number] + widthWithFit: Array<[ImageFit, number, number]> + heightWithFit: Array<[ImageFit, number, number]> + bothWithFit: Array<[ImageFit, number, number]> + } + ] + >)(`%s image`, (type, source, expected) => { + it(`should resize an image when width is given`, async () => { + const result = await resizeResolver( + source, + { + width: 300, + }, + store + ) + + const [, , , url, args] = result?.src.split(`/`) ?? [] + const [transformArgs] = args.split(`.`) + expect(Buffer.from(url, `base64`).toString()).toBe(source.url) + expect(Buffer.from(transformArgs, `base64`).toString()).toBe( + `w=${expected.widthOnly[0]}&h=${expected.widthOnly[1]}&fm=jpg` + ) + expect(result?.width).toBe(expected.widthOnly[0]) + expect(result?.height).toBe(expected.widthOnly[1]) + }) + + it(`should resize an image when height is given`, async () => { + const result = await resizeResolver( + source, + { + height: 300, + }, + store + ) + + const [, , , url, args] = result?.src.split(`/`) ?? [] + const [transformArgs] = args.split(`.`) + expect(Buffer.from(url, `base64`).toString()).toBe(source.url) + expect(Buffer.from(transformArgs, `base64`).toString()).toBe( + `w=${expected.heightOnly[0]}&h=${expected.heightOnly[1]}&fm=jpg` + ) + expect(result?.width).toBe(expected.heightOnly[0]) + expect(result?.height).toBe(expected.heightOnly[1]) + }) + + it.each(expected.widthWithFit)( + `should resize an image correctly when width is given with fit as %s`, + async (fit, expectedWidth, expectedHeight) => { + const result = await resizeResolver( + source, + { + width: 300, + fit, + }, + store + ) + + expect(result?.width).toBe(expectedWidth) + expect(result?.height).toBe(expectedHeight) + } + ) + + it.each(expected.heightWithFit)( + `should resize an image correctly when height is given with fit as %s`, + async (fit, expectedWidth, expectedHeight) => { + const result = await resizeResolver( + source, + { + height: 300, + fit, + }, + store + ) + + expect(result?.width).toBe(expectedWidth) + expect(result?.height).toBe(expectedHeight) + } + ) + + it.each(expected.bothWithFit)( + `should resize an image correctly when width and height is given with fit as %s`, + async (fit, expectedWidth, expectedHeight) => { + const result = await resizeResolver( + source, + { + width: 300, + height: 300, + fit, + }, + store + ) + + expect(result?.width).toBe(expectedWidth) + expect(result?.height).toBe(expectedHeight) + } + ) + }) + + it(`should dispatch a job`, () => { + const actions = { + createJobV2: jest.fn(() => jest.fn()), + } + dispatchers.shouldDispatch.mockImplementationOnce(() => true) + importFrom.mockImplementation(() => actions) + + resizeResolver(portraitSource, { width: 100 }, store) + expect(actions.createJobV2).toHaveBeenCalledWith( + expect.objectContaining({ + args: { + contentDigest: `1`, + url: portraitSource.url, + filename: `${Buffer.from(`w=100&h=160&fm=jpg`).toString( + `base64` + )}.jpg`, + format: `jpg`, + width: 100, + height: expect.any(Number), + }, + inputPaths: [], + name: `IMAGE_CDN`, + outputDir: expect.stringContaining( + path.join( + `public`, + `_gatsby`, + `image`, + Buffer.from(portraitSource.url).toString(`base64`) + ) + ), + }), + expect.any(Object) + ) + }) +}) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts index 675da5b05e3a1..dd34338b025f9 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts @@ -5,12 +5,11 @@ import { dispatchLocalImageServiceJob, shouldDispatch, } from "../jobs/dispatchers" -import { generatePlaceholder } from "../placeholder-handler" -import { isImage } from "../types" +import { generatePlaceholder, PlaceholderType } from "../placeholder-handler" +import { ImageCropFocus, ImageFit, isImage } from "../types" import { validateAndNormalizeFormats, calculateImageDimensions } from "./utils" import type { Store } from "gatsby" -import type { PlaceholderType } from "../placeholder-handler" import type { IRemoteFileNode, IRemoteImageNode, @@ -41,12 +40,18 @@ interface ISourceMetadata { filename: string } -type IGatsbyImageDataArgs = CalculateImageSizesArgs & { - formats: Array - backgroundColor: string - placeholder: PlaceholderType | "none" - aspectRatio: number - sizes: string +type IGatsbyImageDataArgs = Omit< + CalculateImageSizesArgs, + "fit" | "outputPixelDensities" +> & { + formats?: Array + backgroundColor?: string + placeholder?: PlaceholderType | "none" + aspectRatio?: number + sizes?: string + cropFocus?: Array + fit?: CalculateImageSizesArgs["fit"] + outputPixelDensities?: CalculateImageSizesArgs["outputPixelDensities"] } type ImageSizeArgs = CalculateImageSizesArgs & { @@ -73,13 +78,21 @@ export async function gatsbyImageDataResolver( layout: string width: number height: number - backgroundColor: string + backgroundColor?: string placeholder?: { fallback: string } | undefined } | null> { if (!isImage(source)) { return null } + if (!args.layout) { + throw new Error(`The "layout" argument is required for "${source.url}"`) + } + + if (!args.width && !args.height) { + throw new Error(`The "layout" argument is required for "${source.url}"`) + } + if (!args.formats) { args.formats = [`auto`, `webp`, `avif`] } @@ -96,6 +109,10 @@ export async function gatsbyImageDataResolver( args.fit = `cover` } + if (!args.placeholder) { + args.placeholder = PlaceholderType.DOMINANT_COLOR + } + let backgroundColor = args.backgroundColor const sourceMetadata: ISourceMetadata = { width: source.width, @@ -107,7 +124,10 @@ export async function gatsbyImageDataResolver( args.formats, sourceMetadata.format ) - const imageSizes = calculateImageSizes(sourceMetadata, args) + const imageSizes = calculateImageSizes( + sourceMetadata, + args as CalculateImageSizesArgs + ) const sizes = getSizesAttrFromLayout( args.layout, imageSizes.presentationWidth @@ -130,7 +150,7 @@ export async function gatsbyImageDataResolver( width, height: Math.round(width / imageSizes.aspectRatio), format, - fit: args.fit, + fit: args.fit as ImageFit, contentDigest: source.internal.contentDigest, }, store @@ -141,7 +161,7 @@ export async function gatsbyImageDataResolver( width, height: Math.round(width / imageSizes.aspectRatio), format, - fit: args.fit, + cropFocus: args.cropFocus, })}.${format}` if (!fallbackSrc) { @@ -150,7 +170,10 @@ export async function gatsbyImageDataResolver( return { src, - width, + viewport: + args.layout === `fixed` + ? `${width / imageSizes.presentationWidth}x` + : `${width}w`, } }) @@ -291,6 +314,9 @@ export function generateGatsbyImageDataFieldConfig( type: enums.fit.getTypeName(), defaultValue: enums.fit.getField(`COVER`).value, }, + cropFocus: { + type: enums.cropFocus.List.getTypeName(), + }, }, resolve(source, args): ReturnType { return gatsbyImageDataResolver(source, args, store) @@ -303,9 +329,9 @@ function sortNumeric(a: number, b: number): number { } function createSrcSetFromImages( - images: Array<{ src: string; width: number }> + images: Array<{ src: string; viewport: string }> ): string { - return images.map(image => `${image.src} ${image.width}w`).join(`,`) + return images.map(image => `${image.src} ${image.viewport}`).join(`,`) } // eslint-disable-next-line consistent-return @@ -320,13 +346,13 @@ function calculateImageSizes( breakpoints, }: CalculateImageSizesArgs ): IImageSizes { - if (Number(width) <= 0) { + if (width && Number(width) <= 0) { throw new Error( `The provided width of "${width}" is incorrect. Dimensions should be a positive number.` ) } - if (Number(height) <= 0) { + if (height && Number(height) <= 0) { throw new Error( `The provided height of "${height}" is incorrect. Dimensions should be a positive number.` ) @@ -343,6 +369,7 @@ function calculateImageSizes( }) } case `constrained`: { + // @ts-ignore - only width or height can be undefined but it doesn't let me type this correctly return calculateResponsiveImageSizes({ sourceMetadata, width, @@ -353,6 +380,7 @@ function calculateImageSizes( }) } case `fullWidth`: { + // @ts-ignore - only width or height can be undefined but it doesn't let me type this correctly return calculateResponsiveImageSizes({ sourceMetadata, width, @@ -394,7 +422,7 @@ function calculateFixedImageSizes({ } else { // if we only get one value calculate the other value based on aspectRatio if (!width) { - width = Math.round(height * aspectRatio) + width = Math.round((height as number) * aspectRatio) } else { height = Math.round(width / aspectRatio) } @@ -470,13 +498,20 @@ function calculateResponsiveImageSizes({ width = calculated.width height = calculated.height aspectRatio = calculated.aspectRatio + } else { + if (!width) { + width = (height as number) / aspectRatio + } else { + height = width * aspectRatio + } } - // Case 1: width of height were passed in, make sure it isn't larger than the actual image - width = width && Math.round(Math.min(width, sourceMetadata.width)) - height = height && Math.min(height, sourceMetadata.height) + // width of height were passed in, make sure it isn't larger than the actual image + width = width ? Math.round(Math.min(width, sourceMetadata.width)) : undefined + height = height ? Math.min(height, sourceMetadata.height) : undefined - const originalWidth = width + const nonNullableWidth = width as number + const originalWidth = width as number if (breakpoints && breakpoints.length > 0) { sizes = breakpoints.filter(size => size <= sourceMetadata.width) @@ -490,14 +525,14 @@ function calculateResponsiveImageSizes({ } } else { sizes = Array.from(densities).map(density => - Math.round(density * (width as number)) + Math.round(density * nonNullableWidth) ) sizes = sizes.filter(size => size <= sourceMetadata.width) } // ensure that the size passed in is included in the final output - if (layout === `constrained` && !sizes.includes(width)) { - sizes.push(width) + if (layout === `constrained` && !sizes.includes(nonNullableWidth)) { + sizes.push(nonNullableWidth) } sizes = sizes.sort(sortNumeric) @@ -507,7 +542,7 @@ function calculateResponsiveImageSizes({ aspectRatio, presentationWidth: originalWidth, presentationHeight: Math.round(originalWidth / aspectRatio), - unscaledWidth: width, + unscaledWidth: nonNullableWidth, } } diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts index a0a61a68d3b22..1c70338ec424e 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts @@ -23,7 +23,13 @@ export function publicUrlResolver( } const extension = getFileExtensionFromMimeType(source.mimeType) - return generatePublicUrl(source) + `.${extension}` + return ( + generatePublicUrl({ + url: source.url, + // We always want file based url + mimeType: `application/octet-stream`, + }) + `.${extension}` + ) } export function generatePublicUrlFieldConfig( diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts index 23258425fec57..61e2c559bc17a 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts @@ -15,20 +15,27 @@ import type { ImageFit, ImageFormat, ImageCropFocus, + WidthOrHeight, } from "../types" import type { getRemoteFileEnums } from "./get-remote-file-enums" interface IResizeArgs { - width: number - height: number fit: ImageFit format: ImageFormat - cropFocus: ImageCropFocus + cropFocus: Array } +const allowedFormats: Array = [ + `jpg`, + `png`, + `webp`, + `avif`, + `auto`, +] + export async function resizeResolver( source: IRemoteFileNode, - args: IResizeArgs, + args: Partial & WidthOrHeight, store: Store ): Promise<{ width: number @@ -43,12 +50,14 @@ export async function resizeResolver( args.format = `auto` } - if (!args.fit) { - args.fit = `cover` + if (!allowedFormats.includes(args.format)) { + throw new Error( + `Unknown format "${args.format}" was given to resize ${source.url}` + ) } - if (!args.cropFocus) { - args.cropFocus = `edges` + if (!args.width && !args.height) { + throw new Error(`No width or height is given to resize "${source.url}"`) } const formats = validateAndNormalizeFormats( @@ -56,14 +65,19 @@ export async function resizeResolver( getImageFormatFromMimeType(source.mimeType) ) const [format] = formats - const { width, height } = calculateImageDimensions(source, args) + const { width, height } = calculateImageDimensions( + source, + args as IResizeArgs & WidthOrHeight + ) if (shouldDispatch()) { dispatchLocalImageServiceJob( { url: source.url, extension: format, - ...args, + ...(args as IResizeArgs), + width, + height, format, contentDigest: source.internal.contentDigest, }, @@ -72,7 +86,9 @@ export async function resizeResolver( } const src = `${generatePublicUrl(source)}/${generateImageArgs({ - ...args, + ...(args as IResizeArgs), + width, + height, format, })}.${format}` @@ -89,7 +105,7 @@ export function generateResizeFieldConfig( ): IGraphQLFieldConfigDefinition< IRemoteFileNode, ReturnType, - IResizeArgs + IResizeArgs & WidthOrHeight > { return { type: `RemoteFileResize`, @@ -110,12 +126,10 @@ export function generateResizeFieldConfig( both PNG and JPG is not supported and will be ignored.`, }, cropFocus: { - type: enums.cropFocus.getTypeName(), - defaultValue: enums.cropFocus.getField(`EDGES`) - .value as IResizeArgs["cropFocus"], + type: enums.cropFocus.List.getTypeName(), }, }, - resolve(source, args: IResizeArgs): ReturnType { + resolve(source, args): ReturnType { return resizeResolver(source, args, store) }, } diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/utils.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/utils.ts index ee8908b131d8a..67a3551a563e3 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/utils.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/utils.ts @@ -1,4 +1,4 @@ -import { ImageFormat, ImageFit } from "../types" +import { ImageFormat, ImageFit, WidthOrHeight } from "../types" export function validateAndNormalizeFormats( formats: Array, @@ -29,7 +29,7 @@ export function calculateImageDimensions( fit, width: requestedWidth, height: requestedHeight, - }: { fit: ImageFit; width: number; height: number } + }: { fit: ImageFit } & WidthOrHeight ): { width: number; height: number; aspectRatio: number } { // Calculate the eventual width/height of the image. const imageAspectRatio = originalDimensions.width / originalDimensions.height @@ -37,11 +37,6 @@ export function calculateImageDimensions( let width = requestedWidth let height = requestedHeight switch (fit) { - case `cover`: { - width = requestedWidth ?? originalDimensions.width - height = requestedHeight ?? originalDimensions.height - break - } case `inside`: { const widthOption = requestedWidth ?? Number.MAX_SAFE_INTEGER const heightOption = requestedHeight ?? Number.MAX_SAFE_INTEGER @@ -64,11 +59,17 @@ export function calculateImageDimensions( ) break } + case `fill`: { + width = requestedWidth ?? originalDimensions.width + height = requestedHeight ?? originalDimensions.height + + break + } default: { if (requestedWidth && !requestedHeight) { width = requestedWidth - height = Math.round(requestedHeight / imageAspectRatio) + height = Math.round(requestedWidth / imageAspectRatio) } if (requestedHeight && !requestedWidth) { @@ -79,8 +80,8 @@ export function calculateImageDimensions( } return { - width, - height, - aspectRatio: width / height, + width: width as number, + height: height as number, + aspectRatio: (width as number) / (height as number), } } diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/__tests__/gatsby-worker.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/__tests__/gatsby-worker.ts new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts index 84094b7672339..ad48e6cb7ca62 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts @@ -24,16 +24,21 @@ export function dispatchLocalFileServiceJob( store: Store ): void { const GATSBY_VERSION = getGatsbyVersion() - const publicUrl = generatePublicUrl({ url, mimeType }).split(`/`) + const publicUrl = generatePublicUrl({ + url, + // We always want file based url + mimeType: `application/octet-stream`, + }).split(`/`) const extension = getFileExtensionFromMimeType(mimeType) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const filename = publicUrl.pop() publicUrl.unshift(`public`) const actions = importFrom( - global.__GATSBY.root ?? process.cwd(), + global.__GATSBY?.root ?? process.cwd(), `gatsby/dist/redux/actions` ) + // @ts-ignore - we dont have correct typings for this actions.createJobV2( { @@ -87,7 +92,7 @@ export function dispatchLocalImageServiceJob( // We need to use import-from to remove circular dependency const actions = importFrom( - global.__GATSBY.root ?? process.cwd(), + global.__GATSBY?.root ?? process.cwd(), `gatsby/dist/redux/actions` ) // @ts-ignore - importFrom doesn't work with types @@ -102,7 +107,7 @@ export function dispatchLocalImageServiceJob( args: { url, filename: - generateImageArgs({ width, height, format, fit }) + `.${extension}`, + generateImageArgs({ width, height, format }) + `.${extension}`, width, height, format, diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/tester.js b/packages/gatsby-plugin-utils/src/polyfill-remote-file/tester.js new file mode 100644 index 0000000000000..5b8e52b0d6313 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/tester.js @@ -0,0 +1,13 @@ +const path = require(`path`) +const sharp = require(`sharp`) +const fit = [`cover`, `fill`, `outside`, `contain`] + +const pipeline = sharp(path.join(__dirname, `./__fixtures__/dog-landscape.jpg`)) + +for (const f of fit) { + pipeline.resize(300, 300, { + fit: f, + }) + + pipeline.toFile(path.join(__dirname, `./__fixtures__/dog-300-${f}.jpg`)) +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts index 8f22fcfa316b4..cadcb26770d80 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts @@ -55,16 +55,16 @@ export type ImageCropFocus = | "faces" export type WidthOrHeight = - | { width: number; height: never } - | { width: never; height: number } | { width: number; height: number } + | { width: number; height?: never } + | { width?: never; height: number } -export type CalculateImageSizesArgs = WidthOrHeight & { +export type CalculateImageSizesArgs = { fit: ImageFit layout: ImageLayout outputPixelDensities: Array breakpoints?: Array -} +} & WidthOrHeight export function isImage(node: { mimeType: IRemoteFileNode["mimeType"] diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/cache.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/cache.ts index 681d51bed1007..343f72678928a 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/cache.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/cache.ts @@ -4,7 +4,7 @@ import type { GatsbyCache } from "gatsby" export function getCache(): GatsbyCache { // We need to use import-from to remove circular dependency const { getCache: getGatsbyCache } = importFrom( - global.__GATSBY.root ?? process.cwd(), + global.__GATSBY?.root ?? process.cwd(), `gatsby/dist/utils/get-cache` ) as { getCache: (key: string) => GatsbyCache } diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts index a1b955a0dd50e..a879dda3af298 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts @@ -1,5 +1,5 @@ import { isImage } from "../types" -import type { ImageFit, WidthOrHeight } from "../types" +import type { ImageCropFocus, WidthOrHeight } from "../types" export function generatePublicUrl({ url, @@ -20,8 +20,11 @@ export function generateImageArgs({ width, height, format, - fit, -}: WidthOrHeight & { format: string; fit: ImageFit }): string { + cropFocus, +}: WidthOrHeight & { + format: string + cropFocus?: ImageCropFocus | Array +}): string { const args: Array = [] if (width) { args.push(`w=${width}`) @@ -29,8 +32,11 @@ export function generateImageArgs({ if (height) { args.push(`h=${height}`) } - if (fit) { - args.push(`fit=${fit}`) + if (cropFocus) { + args.push(`fit=crop`) + args.push( + `crop=${Array.isArray(cropFocus) ? cropFocus.join(`,`) : cropFocus}` + ) } if (format) { args.push(`fm=${format}`) From 953248156324126271d9ebd98b19cbca6ce65ab9 Mon Sep 17 00:00:00 2001 From: veryspry Date: Tue, 22 Feb 2022 12:48:23 -0600 Subject: [PATCH 10/46] feat: preserve original filenames for image cdn compat --- .../__tests__/gatsby-image-data-resolver.ts | 102 ++++++++---------- .../__tests__/public-resolver.ts | 4 +- .../graphql/gatsby-image-data-resolver.ts | 6 +- .../graphql/public-url-resolver.ts | 4 +- .../graphql/resize-resolver.ts | 6 +- .../src/__tests__/gatsby-node.js | 4 +- 6 files changed, 60 insertions(+), 66 deletions(-) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-data-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-data-resolver.ts index 873fbdff97c3b..44070dd67ab8f 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-data-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-data-resolver.ts @@ -64,6 +64,7 @@ describe(`gatsbyImageData`, () => { height: 962, mimeType: `image/jpeg`, filename: `dog-portrait.jpg`, + basename: `dog-portrait`, parent: null, children: [], internal: { @@ -72,51 +73,6 @@ describe(`gatsbyImageData`, () => { contentDigest: `1`, }, } - const landscapeSource = { - id: `2`, - url: `https://images.unsplash.com/photo-1561037404-61cd46aa615b?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=600&q=80`, - width: 600, - height: 400, - mimeType: `image/jpeg`, - filename: `dog-landscape.jpg`, - parent: null, - children: [], - internal: { - type: `Test`, - owner: `test`, - contentDigest: `1`, - }, - } - - const portrait = [ - `portrait`, - portraitSource, - { - fixed: [300, 481], - constrained: [], - fullWidth: [], - widthOnly: [300, 481], - heightOnly: [187, 300], - widthWithFit: [ - [`cover`, 300, 481], - [`fill`, 300, 962], - [`outside`, 300, 481], - [`contain`, 300, 481], - ], - heightWithFit: [ - [`cover`, 187, 300], - [`fill`, 600, 300], - [`outside`, 187, 300], - [`contain`, 187, 300], - ], - bothWithFit: [ - [`cover`, 300, 300], - [`fill`, 300, 300], - [`outside`, 300, 481], - [`contain`, 300, 300], - ], - }, - ] it(`should return null when source is not an image`, async () => { expect( @@ -158,13 +114,17 @@ describe(`gatsbyImageData`, () => { expect(parsedSrcSet[0].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=300&h=481&fm=webp`).toString(`base64`)}.webp` + )}/${Buffer.from(`w=300&h=481&fm=webp`).toString(`base64`)}/${ + portraitSource.basename + }.webp` ) expect(parsedSrcSet[0].viewport).toEqual(`1x`) expect(parsedSrcSet[1].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=600&h=962&fm=webp`).toString(`base64`)}.webp` + )}/${Buffer.from(`w=600&h=962&fm=webp`).toString(`base64`)}/${ + portraitSource.basename + }.webp` ) expect(parsedSrcSet[1].viewport).toEqual(`2x`) @@ -212,25 +172,33 @@ describe(`gatsbyImageData`, () => { expect(parsedSrcSet[0].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=75&h=120&fm=webp`).toString(`base64`)}.webp` + )}/${Buffer.from(`w=75&h=120&fm=webp`).toString(`base64`)}/${ + portraitSource.basename + }.webp` ) expect(parsedSrcSet[0].viewport).toEqual(`75w`) expect(parsedSrcSet[1].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=150&h=241&fm=webp`).toString(`base64`)}.webp` + )}/${Buffer.from(`w=150&h=241&fm=webp`).toString(`base64`)}/${ + portraitSource.basename + }.webp` ) expect(parsedSrcSet[1].viewport).toEqual(`150w`) expect(parsedSrcSet[2].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=300&h=481&fm=webp`).toString(`base64`)}.webp` + )}/${Buffer.from(`w=300&h=481&fm=webp`).toString(`base64`)}/${ + portraitSource.basename + }.webp` ) expect(parsedSrcSet[2].viewport).toEqual(`300w`) expect(parsedSrcSet[3].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=600&h=962&fm=webp`).toString(`base64`)}.webp` + )}/${Buffer.from(`w=600&h=962&fm=webp`).toString(`base64`)}/${ + portraitSource.basename + }.webp` ) expect(parsedSrcSet[3].viewport).toEqual(`600w`) @@ -282,25 +250,33 @@ describe(`gatsbyImageData`, () => { expect(parsedSrcSet[0].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=750&h=1202&fm=webp`).toString(`base64`)}.webp` + )}/${Buffer.from(`w=750&h=1202&fm=webp`).toString(`base64`)}/${ + portraitSource.basename + }.webp` ) expect(parsedSrcSet[0].viewport).toEqual(`750w`) expect(parsedSrcSet[1].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=1080&h=1731&fm=webp`).toString(`base64`)}.webp` + )}/${Buffer.from(`w=1080&h=1731&fm=webp`).toString(`base64`)}/${ + portraitSource.basename + }.webp` ) expect(parsedSrcSet[1].viewport).toEqual(`1080w`) expect(parsedSrcSet[2].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=1366&h=2190&fm=webp`).toString(`base64`)}.webp` + )}/${Buffer.from(`w=1366&h=2190&fm=webp`).toString(`base64`)}/${ + portraitSource.basename + }.webp` ) expect(parsedSrcSet[2].viewport).toEqual(`1366w`) expect(parsedSrcSet[3].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=1920&h=3078&fm=webp`).toString(`base64`)}.webp` + )}/${Buffer.from(`w=1920&h=3078&fm=webp`).toString(`base64`)}/${ + portraitSource.basename + }.webp` ) expect(parsedSrcSet[3].viewport).toEqual(`1920w`) @@ -373,12 +349,16 @@ describe(`gatsbyImageData`, () => { expect(parsedFixedSrcSet[0].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=300&h=481&fm=webp`).toString(`base64`)}.webp` + )}/${Buffer.from(`w=300&h=481&fm=webp`).toString(`base64`)}/${ + portraitSource.basename + }.webp` ) expect(parsedFixedSrcSet[1].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=600&h=962&fm=webp`).toString(`base64`)}.webp` + )}/${Buffer.from(`w=600&h=962&fm=webp`).toString(`base64`)}/${ + portraitSource.basename + }.webp` ) const parsedConstrainedSrcSet = parseSrcSet( @@ -388,12 +368,16 @@ describe(`gatsbyImageData`, () => { expect(parsedConstrainedSrcSet[0].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=300&h=481&fm=webp`).toString(`base64`)}.webp` + )}/${Buffer.from(`w=300&h=481&fm=webp`).toString(`base64`)}/${ + portraitSource.basename + }.webp` ) expect(parsedConstrainedSrcSet[1].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=600&h=962&fm=webp`).toString(`base64`)}.webp` + )}/${Buffer.from(`w=600&h=962&fm=webp`).toString(`base64`)}/${ + portraitSource.basename + }.webp` ) const parsedFullWidthSrcSet = parseSrcSet( diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts index 5305b64ab59eb..6b5faa77943c4 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts @@ -27,7 +27,7 @@ describe(`publicResolver`, () => { } expect(publicUrlResolver(source, store)).toEqual( - `/_gatsby/file/${Buffer.from(source.url).toString(`base64`)}.pdf` + `/_gatsby/file/${Buffer.from(source.url).toString(`base64`)}/file.pdf` ) }) @@ -47,7 +47,7 @@ describe(`publicResolver`, () => { } expect(publicUrlResolver(source, store)).toEqual( - `/_gatsby/file/${Buffer.from(source.url).toString(`base64`)}.jpg` + `/_gatsby/file/${Buffer.from(source.url).toString(`base64`)}/image.jpg` ) }) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts index dd34338b025f9..c7bd225b7dec2 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts @@ -1,3 +1,4 @@ +import path from "path" import { generatePublicUrl, generateImageArgs } from "../utils/url-generator" import { getImageFormatFromMimeType } from "../utils/mime-type-helpers" import { stripIndent } from "../utils/strip-indent" @@ -162,7 +163,10 @@ export async function gatsbyImageDataResolver( height: Math.round(width / imageSizes.aspectRatio), format, cropFocus: args.cropFocus, - })}.${format}` + })}/${path.basename( + source.filename, + path.extname(source.filename) + )}.${format}` if (!fallbackSrc) { fallbackSrc = src diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts index 1c70338ec424e..891ade9ee699a 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts @@ -1,3 +1,4 @@ +import path from "path" import { generatePublicUrl } from "../utils/url-generator" import { getFileExtensionFromMimeType } from "../utils/mime-type-helpers" import { @@ -22,13 +23,12 @@ export function publicUrlResolver( ) } - const extension = getFileExtensionFromMimeType(source.mimeType) return ( generatePublicUrl({ url: source.url, // We always want file based url mimeType: `application/octet-stream`, - }) + `.${extension}` + }) + `/${source.filename}` ) } diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts index 61e2c559bc17a..60b9cbeeb1b96 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts @@ -1,3 +1,4 @@ +import path from "path" import { generatePublicUrl, generateImageArgs } from "../utils/url-generator" import { getImageFormatFromMimeType } from "../utils/mime-type-helpers" import { stripIndent } from "../utils/strip-indent" @@ -90,7 +91,10 @@ export async function resizeResolver( width, height, format, - })}.${format}` + })}/${path.basename( + source.filename, + path.extname(source.filename) + )}.${format}` return { src, diff --git a/packages/gatsby-source-contentful/src/__tests__/gatsby-node.js b/packages/gatsby-source-contentful/src/__tests__/gatsby-node.js index 9a5dcb9094dc3..dea822fbab78f 100644 --- a/packages/gatsby-source-contentful/src/__tests__/gatsby-node.js +++ b/packages/gatsby-source-contentful/src/__tests__/gatsby-node.js @@ -11,7 +11,9 @@ import restrictedContentTypeFixture from "../__fixtures__/restricted-content-typ jest.mock(`../fetch`) jest.mock(`gatsby-core-utils`, () => { + const originalModule = jest.requireActual(`gatsby-core-utils`) return { + ...originalModule, createContentDigest: () => `contentDigest`, } }) @@ -75,7 +77,7 @@ describe(`gatsby-node`, () => { pluginOptions = defaultPluginOptions ) { await createSchemaCustomization( - { schema, actions, reporter, cache }, + { schema, actions, reporter, cache, store }, pluginOptions ) From 679cd5a59a4f5c57b162b12c67a91f2000f0f1c0 Mon Sep 17 00:00:00 2001 From: veryspry Date: Tue, 22 Feb 2022 16:40:46 -0600 Subject: [PATCH 11/46] feat: gatsbyImageData: add support for "quality" argument and default value for "layout" --- .../__tests__/gatsby-image-data-resolver.ts | 28 +++++++++---------- .../__tests__/resize-resolver.ts | 9 +++--- .../graphql/gatsby-image-data-resolver.ts | 19 ++++++++++++- .../graphql/resize-resolver.ts | 12 ++++++++ .../polyfill-remote-file/jobs/dispatchers.ts | 11 ++++++-- .../src/polyfill-remote-file/tester.js | 3 ++ .../polyfill-remote-file/transform-images.ts | 7 ++++- .../utils/url-generator.ts | 7 +++-- 8 files changed, 69 insertions(+), 27 deletions(-) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-data-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-data-resolver.ts index 44070dd67ab8f..17a9e7014d3a1 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-data-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-data-resolver.ts @@ -114,7 +114,7 @@ describe(`gatsbyImageData`, () => { expect(parsedSrcSet[0].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=300&h=481&fm=webp`).toString(`base64`)}/${ + )}/${Buffer.from(`w=300&h=481&fm=webp&q=75`).toString(`base64`)}/${ portraitSource.basename }.webp` ) @@ -122,7 +122,7 @@ describe(`gatsbyImageData`, () => { expect(parsedSrcSet[1].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=600&h=962&fm=webp`).toString(`base64`)}/${ + )}/${Buffer.from(`w=600&h=962&fm=webp&q=75`).toString(`base64`)}/${ portraitSource.basename }.webp` ) @@ -172,7 +172,7 @@ describe(`gatsbyImageData`, () => { expect(parsedSrcSet[0].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=75&h=120&fm=webp`).toString(`base64`)}/${ + )}/${Buffer.from(`w=75&h=120&fm=webp&q=75`).toString(`base64`)}/${ portraitSource.basename }.webp` ) @@ -180,7 +180,7 @@ describe(`gatsbyImageData`, () => { expect(parsedSrcSet[1].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=150&h=241&fm=webp`).toString(`base64`)}/${ + )}/${Buffer.from(`w=150&h=241&fm=webp&q=75`).toString(`base64`)}/${ portraitSource.basename }.webp` ) @@ -188,7 +188,7 @@ describe(`gatsbyImageData`, () => { expect(parsedSrcSet[2].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=300&h=481&fm=webp`).toString(`base64`)}/${ + )}/${Buffer.from(`w=300&h=481&fm=webp&q=75`).toString(`base64`)}/${ portraitSource.basename }.webp` ) @@ -196,7 +196,7 @@ describe(`gatsbyImageData`, () => { expect(parsedSrcSet[3].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=600&h=962&fm=webp`).toString(`base64`)}/${ + )}/${Buffer.from(`w=600&h=962&fm=webp&q=75`).toString(`base64`)}/${ portraitSource.basename }.webp` ) @@ -250,7 +250,7 @@ describe(`gatsbyImageData`, () => { expect(parsedSrcSet[0].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=750&h=1202&fm=webp`).toString(`base64`)}/${ + )}/${Buffer.from(`w=750&h=1202&fm=webp&q=75`).toString(`base64`)}/${ portraitSource.basename }.webp` ) @@ -258,7 +258,7 @@ describe(`gatsbyImageData`, () => { expect(parsedSrcSet[1].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=1080&h=1731&fm=webp`).toString(`base64`)}/${ + )}/${Buffer.from(`w=1080&h=1731&fm=webp&q=75`).toString(`base64`)}/${ portraitSource.basename }.webp` ) @@ -266,7 +266,7 @@ describe(`gatsbyImageData`, () => { expect(parsedSrcSet[2].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=1366&h=2190&fm=webp`).toString(`base64`)}/${ + )}/${Buffer.from(`w=1366&h=2190&fm=webp&q=75`).toString(`base64`)}/${ portraitSource.basename }.webp` ) @@ -274,7 +274,7 @@ describe(`gatsbyImageData`, () => { expect(parsedSrcSet[3].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=1920&h=3078&fm=webp`).toString(`base64`)}/${ + )}/${Buffer.from(`w=1920&h=3078&fm=webp&q=75`).toString(`base64`)}/${ portraitSource.basename }.webp` ) @@ -349,14 +349,14 @@ describe(`gatsbyImageData`, () => { expect(parsedFixedSrcSet[0].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=300&h=481&fm=webp`).toString(`base64`)}/${ + )}/${Buffer.from(`w=300&h=481&fm=webp&q=75`).toString(`base64`)}/${ portraitSource.basename }.webp` ) expect(parsedFixedSrcSet[1].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=600&h=962&fm=webp`).toString(`base64`)}/${ + )}/${Buffer.from(`w=600&h=962&fm=webp&q=75`).toString(`base64`)}/${ portraitSource.basename }.webp` ) @@ -368,14 +368,14 @@ describe(`gatsbyImageData`, () => { expect(parsedConstrainedSrcSet[0].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=300&h=481&fm=webp`).toString(`base64`)}/${ + )}/${Buffer.from(`w=300&h=481&fm=webp&q=75`).toString(`base64`)}/${ portraitSource.basename }.webp` ) expect(parsedConstrainedSrcSet[1].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` - )}/${Buffer.from(`w=600&h=962&fm=webp`).toString(`base64`)}/${ + )}/${Buffer.from(`w=600&h=962&fm=webp&q=75`).toString(`base64`)}/${ portraitSource.basename }.webp` ) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts index af7dad3efdfd1..17b597d67120b 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts @@ -204,7 +204,7 @@ describe(`resizeResolver`, () => { const [transformArgs] = args.split(`.`) expect(Buffer.from(url, `base64`).toString()).toBe(source.url) expect(Buffer.from(transformArgs, `base64`).toString()).toBe( - `w=${expected.widthOnly[0]}&h=${expected.widthOnly[1]}&fm=jpg` + `w=${expected.widthOnly[0]}&h=${expected.widthOnly[1]}&fm=jpg&q=75` ) expect(result?.width).toBe(expected.widthOnly[0]) expect(result?.height).toBe(expected.widthOnly[1]) @@ -223,7 +223,7 @@ describe(`resizeResolver`, () => { const [transformArgs] = args.split(`.`) expect(Buffer.from(url, `base64`).toString()).toBe(source.url) expect(Buffer.from(transformArgs, `base64`).toString()).toBe( - `w=${expected.heightOnly[0]}&h=${expected.heightOnly[1]}&fm=jpg` + `w=${expected.heightOnly[0]}&h=${expected.heightOnly[1]}&fm=jpg&q=75` ) expect(result?.width).toBe(expected.heightOnly[0]) expect(result?.height).toBe(expected.heightOnly[1]) @@ -295,12 +295,11 @@ describe(`resizeResolver`, () => { args: { contentDigest: `1`, url: portraitSource.url, - filename: `${Buffer.from(`w=100&h=160&fm=jpg`).toString( - `base64` - )}.jpg`, + filename: `dog-portrait.jpg`, format: `jpg`, width: 100, height: expect.any(Number), + quality: 75, }, inputPaths: [], name: `IMAGE_CDN`, diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts index c7bd225b7dec2..69beaa8a490c6 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts @@ -53,6 +53,7 @@ type IGatsbyImageDataArgs = Omit< cropFocus?: Array fit?: CalculateImageSizesArgs["fit"] outputPixelDensities?: CalculateImageSizesArgs["outputPixelDensities"] + quality?: number } type ImageSizeArgs = CalculateImageSizesArgs & { @@ -69,6 +70,7 @@ interface IImageSizes { const DEFAULT_PIXEL_DENSITIES = [0.25, 0.5, 1, 2] const DEFAULT_BREAKPOINTS = [750, 1080, 1366, 1920] +const DEFAULT_QUALITY = 75 export async function gatsbyImageDataResolver( source: IRemoteFileNode, @@ -114,6 +116,10 @@ export async function gatsbyImageDataResolver( args.placeholder = PlaceholderType.DOMINANT_COLOR } + if (!args.quality) { + args.quality = DEFAULT_QUALITY + } + let backgroundColor = args.backgroundColor const sourceMetadata: ISourceMetadata = { width: source.width, @@ -148,11 +154,16 @@ export async function gatsbyImageDataResolver( { url: source.url, extension: format, + basename: path.basename( + source.filename, + path.extname(source.filename) + ), width, height: Math.round(width / imageSizes.aspectRatio), format, fit: args.fit as ImageFit, contentDigest: source.internal.contentDigest, + quality: args.quality as number, }, store ) @@ -163,6 +174,7 @@ export async function gatsbyImageDataResolver( height: Math.round(width / imageSizes.aspectRatio), format, cropFocus: args.cropFocus, + quality: args.quality as number, })}/${path.basename( source.filename, path.extname(source.filename) @@ -233,13 +245,14 @@ export function generateGatsbyImageDataFieldConfig( type: `JSON`, args: { layout: { - type: enums.layout.NonNull.getTypeName(), + type: enums.layout.getTypeName(), description: stripIndent` The layout for the image. FIXED: A static image sized, that does not resize according to the screen width FULL_WIDTH: 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. `, + defaultValue: enums.layout.getField(`CONSTRAINED`).value, }, width: { type: `Int`, @@ -321,6 +334,10 @@ export function generateGatsbyImageDataFieldConfig( cropFocus: { type: enums.cropFocus.List.getTypeName(), }, + quality: { + type: `Int`, + defaultValue: DEFAULT_QUALITY, + }, }, resolve(source, args): ReturnType { return gatsbyImageDataResolver(source, args, store) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts index 60b9cbeeb1b96..cb46d795ca209 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts @@ -24,8 +24,11 @@ interface IResizeArgs { fit: ImageFit format: ImageFormat cropFocus: Array + quality: number } +const DEFAULT_QUALITY = 75 + const allowedFormats: Array = [ `jpg`, `png`, @@ -51,6 +54,10 @@ export async function resizeResolver( args.format = `auto` } + if (!args.quality) { + args.quality = DEFAULT_QUALITY + } + if (!allowedFormats.includes(args.format)) { throw new Error( `Unknown format "${args.format}" was given to resize ${source.url}` @@ -76,6 +83,7 @@ export async function resizeResolver( { url: source.url, extension: format, + basename: path.basename(source.filename, path.extname(source.filename)), ...(args as IResizeArgs), width, height, @@ -132,6 +140,10 @@ export function generateResizeFieldConfig( cropFocus: { type: enums.cropFocus.List.getTypeName(), }, + quality: { + type: `Int`, + defaultValue: DEFAULT_QUALITY, + }, }, resolve(source, args): ReturnType { return resizeResolver(source, args, store) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts index ad48e6cb7ca62..f13a59e3a4639 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts @@ -67,19 +67,23 @@ export function dispatchLocalImageServiceJob( { url, extension, + basename, width, height, format, fit, contentDigest, + quality, }: { url: string extension: string + basename: string width: number height: number format: string fit: ImageFit contentDigest: string + quality: number }, store: Store ): void { @@ -102,16 +106,17 @@ export function dispatchLocalImageServiceJob( inputPaths: [], outputDir: path.join( global.__GATSBY?.root || process.cwd(), - publicUrl.filter(Boolean).join(`/`) + publicUrl.filter(Boolean).join(`/`), + generateImageArgs({ width, height, format, quality }) ), args: { url, - filename: - generateImageArgs({ width, height, format }) + `.${extension}`, + filename: `${basename}.${extension}`, width, height, format, fit, + quality, contentDigest, }, }, diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/tester.js b/packages/gatsby-plugin-utils/src/polyfill-remote-file/tester.js index 5b8e52b0d6313..70f62126684c5 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/tester.js +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/tester.js @@ -11,3 +11,6 @@ for (const f of fit) { pipeline.toFile(path.join(__dirname, `./__fixtures__/dog-300-${f}.jpg`)) } + +// pipeline.resize(300, 300).jpeg({ quality: 75 }).png({ quality: 75 }) +// pipeline.toFormat(`webp`).toFile(path.join(__dirname, `./__fixtures__/dog-300.webp`)) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/transform-images.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/transform-images.ts index 6db9152dda07e..04740891cbe14 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/transform-images.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/transform-images.ts @@ -10,6 +10,7 @@ export interface IResizeArgs { height: number format: string outputPath?: string + quality: number } // Lots of work to get the sharp instance @@ -101,7 +102,7 @@ export async function transformImage({ async function resizeImageWithSharp( pipeline: Pipeline | Buffer, - { width, height, format, outputPath }: IResizeArgs + { width, height, format, outputPath, quality }: IResizeArgs ): Promise { if (pipeline instanceof Buffer) { if (!outputPath) { @@ -113,6 +114,10 @@ async function resizeImageWithSharp( const resizedImage = pipeline .resize(width, height, {}) + .jpeg({ quality }) + .png({ quality }) + .webp({ quality }) + .avif({ quality }) .toFormat( format as unknown as keyof Awaited< ReturnType diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts index a879dda3af298..0691bdfb82573 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts @@ -21,9 +21,11 @@ export function generateImageArgs({ height, format, cropFocus, + quality, }: WidthOrHeight & { format: string cropFocus?: ImageCropFocus | Array + quality: number }): string { const args: Array = [] if (width) { @@ -38,9 +40,8 @@ export function generateImageArgs({ `crop=${Array.isArray(cropFocus) ? cropFocus.join(`,`) : cropFocus}` ) } - if (format) { - args.push(`fm=${format}`) - } + args.push(`fm=${format}`) + args.push(`q=${quality}`) return Buffer.from(args.join(`&`)).toString(`base64`) } From 7d8346cc55c026c692e30f7076f7baadfc0e9850 Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Wed, 23 Feb 2022 15:54:36 +0100 Subject: [PATCH 12/46] add tests & fix linting --- .../remote-file/gatsby-plugin-image.js | 81 ++++++++++++++++++ e2e-tests/development-runtime/gatsby-node.js | 83 +++++++++++++++++++ .../src/pages/remote-file.js | 73 ++++++++++++++++ .../cypress/integration/remote-file.js | 73 ++++++++++++++++ .../remote-file/gatsby-plugin-image.js | 81 ++++++++++++++++++ packages/gatsby-plugin-utils/package.json | 1 - .../graphql/public-url-resolver.ts | 14 ++-- .../src/polyfill-remote-file/http-routes.ts | 26 +++--- .../polyfill-remote-file/jobs/dispatchers.ts | 13 +-- .../polyfill-remote-file/transform-images.ts | 9 +- .../utils/url-generator.ts | 22 +++-- 11 files changed, 437 insertions(+), 39 deletions(-) create mode 100644 e2e-tests/development-runtime/cypress/integration/remote-file/gatsby-plugin-image.js create mode 100644 e2e-tests/development-runtime/src/pages/remote-file.js create mode 100644 e2e-tests/production-runtime/cypress/integration/remote-file.js create mode 100644 e2e-tests/production-runtime/cypress/integration/remote-file/gatsby-plugin-image.js diff --git a/e2e-tests/development-runtime/cypress/integration/remote-file/gatsby-plugin-image.js b/e2e-tests/development-runtime/cypress/integration/remote-file/gatsby-plugin-image.js new file mode 100644 index 0000000000000..06581bb5d7277 --- /dev/null +++ b/e2e-tests/development-runtime/cypress/integration/remote-file/gatsby-plugin-image.js @@ -0,0 +1,81 @@ +before(() => { + cy.exec(`npm run reset`) +}) + +after(() => { + cy.exec(`npm run reset`) +}) + +describe(`remote-file`, () => { + it(`should render correct dimensions`, () => { + cy.visit(`/remote-file/`).waitForRouteChange() + + cy.get('[data-testid="public"]').then($urls => { + const urls = Array.from($urls.map((_, $url) => $url.getAttribute("href"))) + + expect(urls[0].endsWith(".jpg")).to.be.true + expect(urls[1].endsWith(".jpg")).to.be.true + expect(urls[2].endsWith(".jpg")).to.be.true + }) + + cy.get(".resize").then($imgs => { + const imgDimensions = $imgs.map((_, $img) => $img.getBoundingClientRect()) + + expect(imgDimensions[0].width).to.be.equal(100) + expect(imgDimensions[0].height).to.be.equal(133) + expect(imgDimensions[1].width).to.be.equal(100) + expect(imgDimensions[1].height).to.be.equal(160) + expect(imgDimensions[2].width).to.be.equal(100) + expect(imgDimensions[2].height).to.be.equal(67) + }) + + cy.get(".fixed").then($imgs => { + const imgDimensions = $imgs.map((_, $img) => $img.getBoundingClientRect()) + + expect(imgDimensions[0].width).to.be.equal(100) + expect(imgDimensions[0].height).to.be.equal(133) + expect(imgDimensions[1].width).to.be.equal(100) + expect(imgDimensions[1].height).to.be.equal(160) + expect(imgDimensions[2].width).to.be.equal(100) + expect(imgDimensions[2].height).to.be.equal(67) + }) + + cy.get(".constrained").then($imgs => { + const imgDimensions = $imgs.map((_, $img) => $img.getBoundingClientRect()) + + expect(imgDimensions[0].width).to.be.equal(300) + expect(imgDimensions[0].height).to.be.equal(400) + expect(imgDimensions[1].width).to.be.equal(300) + expect(imgDimensions[1].height).to.be.equal(481) + expect(imgDimensions[2].width).to.be.equal(300) + expect(imgDimensions[2].height).to.be.equal(200) + }) + + cy.get(".full").then($imgs => { + const parentWidth = $imgs[0].parentElement.getBoundingClientRect().width + const imgDimensions = $imgs.map((_, $img) => $img.getBoundingClientRect()) + + expect(imgDimensions[0].width).to.be.equal(parentWidth) + expect(Math.ceil(imgDimensions[0].height)).to.be.equal(1229) + expect(imgDimensions[1].width).to.be.equal(parentWidth) + expect(Math.ceil(imgDimensions[1].height)).to.be.equal(1478) + expect(imgDimensions[2].width).to.be.equal(parentWidth) + expect(Math.ceil(imgDimensions[2].height)).to.be.equal(614) + }) + }) + + it(`should render a placeholder`, () => { + cy.visit(`/remote-file/`).waitForRouteChange() + + cy.get(".fixed [data-placeholder-image]") + .first() + .should("have.css", "background-color", "rgb(232, 184, 8)") + cy.get(".constrained [data-placeholder-image]") + .first() + .should("have.prop", "tagName", "IMG") + cy.get(".constrained [data-placeholder-image]") + .first() + .should("contain.prop", "src", "data:image/jpg;base64") + cy.get(".full [data-placeholder-image]").first().should("be.empty") + }) +}) diff --git a/e2e-tests/development-runtime/gatsby-node.js b/e2e-tests/development-runtime/gatsby-node.js index e2bed83a75bb2..555cdde5cf55e 100644 --- a/e2e-tests/development-runtime/gatsby-node.js +++ b/e2e-tests/development-runtime/gatsby-node.js @@ -1,6 +1,75 @@ const path = require(`path`) const { createFilePath } = require(`gatsby-source-filesystem`) +const { + addRemoteFilePolyfillInterface, + polyfillImageServiceDevRoutes, +} = require("gatsby-plugin-utils/polyfill-remote-file") +/** @type{import('gatsby').createSchemaCustomization} */ +exports.createSchemaCustomization = ({ actions, schema, store }) => { + actions.createTypes( + addRemoteFilePolyfillInterface( + schema.buildObjectType({ + name: "MyRemoteFile", + fields: {}, + interfaces: ["Node", "RemoteFile"], + }), + { + store, + schema, + } + ) + ) +} + +/** @type {imporg('gatsby').sourceNodes} */ +exports.sourceNodes = ({ actions, createNodeId, createContentDigest }) => { + const items = [ + { + name: "photoA.jpg", + url: "https://images.unsplash.com/photo-1517849845537-4d257902454a?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2000&q=80", + placeholderUrl: + "https://images.unsplash.com/photo-1517849845537-4d257902454a?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=%width%&h=%height%", + mimeType: "image/jpg", + filename: "photo-1517849845537.jpg", + width: 2000, + height: 2667, + }, + { + name: "photoB.jpg", + url: "https://images.unsplash.com/photo-1552053831-71594a27632d?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&h=2000&q=10", + mimeType: "image/jpg", + filename: "photo-1552053831.jpg", + width: 1247, + height: 2000, + }, + { + name: "photoC.jpg", + url: "https://images.unsplash.com/photo-1561037404-61cd46aa615b?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2000&q=80", + placeholderUrl: + "https://images.unsplash.com/photo-1561037404-61cd46aa615b?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=%width%&h=%height%", + mimeType: "image/jpg", + filename: "photo-1561037404.jpg", + width: 2000, + height: 1333, + }, + ] + + items.forEach((item, index) => { + actions.createNode({ + id: createNodeId(`remote-file-${index}`), + ...item, + internal: { + type: "MyRemoteFile", + contentDigest: createContentDigest(item.url), + }, + }) + }) +} + +/** + * @type {import('gatsby').onCreateNode} + */ exports.onCreateNode = function onCreateNode({ actions: { createNodeField }, node, @@ -27,6 +96,9 @@ exports.onCreateNode = function onCreateNode({ } } +/** + * @type {import('gatsby').createPages} + */ exports.createPages = async function createPages({ actions: { createPage, createRedirect }, graphql, @@ -115,6 +187,9 @@ exports.createPages = async function createPages({ }) } +/** + * @type {import('gatsby').onCreatePage} + */ exports.onCreatePage = async ({ page, actions }) => { const { createPage, createRedirect, deletePage } = actions @@ -169,6 +244,9 @@ exports.onCreatePage = async ({ page, actions }) => { } } +/** + * @type {import('gatsby').createResolvers} + */ exports.createResolvers = ({ createResolvers }) => { const resolvers = { QueryDataCachesJson: { @@ -192,3 +270,8 @@ exports.createResolvers = ({ createResolvers }) => { } createResolvers(resolvers) } + +/** @type{import('gatsby').onCreateDevServer} */ +exports.onCreateDevServer = ({ app }) => { + polyfillImageServiceDevRoutes(app) +} diff --git a/e2e-tests/development-runtime/src/pages/remote-file.js b/e2e-tests/development-runtime/src/pages/remote-file.js new file mode 100644 index 0000000000000..a355708493dd5 --- /dev/null +++ b/e2e-tests/development-runtime/src/pages/remote-file.js @@ -0,0 +1,73 @@ +import { graphql } from "gatsby" +import React from "react" + +import { GatsbyImage } from "gatsby-plugin-image" +import Layout from "../components/layout" +import SEO from "../components/seo" + +const RemoteFile = ({ data }) => { + return ( + + + + {data.allMyRemoteFile.nodes.map(node => { + return ( +

+

+ + {node.filename} + +

+ +
+ + + +
+
+ ) + })} + + ) +} + +export const pageQuery = graphql` + { + allMyRemoteFile { + nodes { + id + url + filename + publicUrl + resize(width: 100) { + height + width + src + } + fixed: gatsbyImageData( + layout: FIXED + width: 100 + placeholder: DOMINANT_COLOR + ) + constrained: gatsbyImageData( + layout: CONSTRAINED + width: 300 + placeholder: BLURRED + ) + full: gatsbyImageData(layout: FULL_WIDTH, width: 500, placeholder: NONE) + } + } + } +` + +export default RemoteFile diff --git a/e2e-tests/production-runtime/cypress/integration/remote-file.js b/e2e-tests/production-runtime/cypress/integration/remote-file.js new file mode 100644 index 0000000000000..a355708493dd5 --- /dev/null +++ b/e2e-tests/production-runtime/cypress/integration/remote-file.js @@ -0,0 +1,73 @@ +import { graphql } from "gatsby" +import React from "react" + +import { GatsbyImage } from "gatsby-plugin-image" +import Layout from "../components/layout" +import SEO from "../components/seo" + +const RemoteFile = ({ data }) => { + return ( + + + + {data.allMyRemoteFile.nodes.map(node => { + return ( +
+

+ + {node.filename} + +

+ +
+ + + +
+
+ ) + })} +
+ ) +} + +export const pageQuery = graphql` + { + allMyRemoteFile { + nodes { + id + url + filename + publicUrl + resize(width: 100) { + height + width + src + } + fixed: gatsbyImageData( + layout: FIXED + width: 100 + placeholder: DOMINANT_COLOR + ) + constrained: gatsbyImageData( + layout: CONSTRAINED + width: 300 + placeholder: BLURRED + ) + full: gatsbyImageData(layout: FULL_WIDTH, width: 500, placeholder: NONE) + } + } + } +` + +export default RemoteFile diff --git a/e2e-tests/production-runtime/cypress/integration/remote-file/gatsby-plugin-image.js b/e2e-tests/production-runtime/cypress/integration/remote-file/gatsby-plugin-image.js new file mode 100644 index 0000000000000..06581bb5d7277 --- /dev/null +++ b/e2e-tests/production-runtime/cypress/integration/remote-file/gatsby-plugin-image.js @@ -0,0 +1,81 @@ +before(() => { + cy.exec(`npm run reset`) +}) + +after(() => { + cy.exec(`npm run reset`) +}) + +describe(`remote-file`, () => { + it(`should render correct dimensions`, () => { + cy.visit(`/remote-file/`).waitForRouteChange() + + cy.get('[data-testid="public"]').then($urls => { + const urls = Array.from($urls.map((_, $url) => $url.getAttribute("href"))) + + expect(urls[0].endsWith(".jpg")).to.be.true + expect(urls[1].endsWith(".jpg")).to.be.true + expect(urls[2].endsWith(".jpg")).to.be.true + }) + + cy.get(".resize").then($imgs => { + const imgDimensions = $imgs.map((_, $img) => $img.getBoundingClientRect()) + + expect(imgDimensions[0].width).to.be.equal(100) + expect(imgDimensions[0].height).to.be.equal(133) + expect(imgDimensions[1].width).to.be.equal(100) + expect(imgDimensions[1].height).to.be.equal(160) + expect(imgDimensions[2].width).to.be.equal(100) + expect(imgDimensions[2].height).to.be.equal(67) + }) + + cy.get(".fixed").then($imgs => { + const imgDimensions = $imgs.map((_, $img) => $img.getBoundingClientRect()) + + expect(imgDimensions[0].width).to.be.equal(100) + expect(imgDimensions[0].height).to.be.equal(133) + expect(imgDimensions[1].width).to.be.equal(100) + expect(imgDimensions[1].height).to.be.equal(160) + expect(imgDimensions[2].width).to.be.equal(100) + expect(imgDimensions[2].height).to.be.equal(67) + }) + + cy.get(".constrained").then($imgs => { + const imgDimensions = $imgs.map((_, $img) => $img.getBoundingClientRect()) + + expect(imgDimensions[0].width).to.be.equal(300) + expect(imgDimensions[0].height).to.be.equal(400) + expect(imgDimensions[1].width).to.be.equal(300) + expect(imgDimensions[1].height).to.be.equal(481) + expect(imgDimensions[2].width).to.be.equal(300) + expect(imgDimensions[2].height).to.be.equal(200) + }) + + cy.get(".full").then($imgs => { + const parentWidth = $imgs[0].parentElement.getBoundingClientRect().width + const imgDimensions = $imgs.map((_, $img) => $img.getBoundingClientRect()) + + expect(imgDimensions[0].width).to.be.equal(parentWidth) + expect(Math.ceil(imgDimensions[0].height)).to.be.equal(1229) + expect(imgDimensions[1].width).to.be.equal(parentWidth) + expect(Math.ceil(imgDimensions[1].height)).to.be.equal(1478) + expect(imgDimensions[2].width).to.be.equal(parentWidth) + expect(Math.ceil(imgDimensions[2].height)).to.be.equal(614) + }) + }) + + it(`should render a placeholder`, () => { + cy.visit(`/remote-file/`).waitForRouteChange() + + cy.get(".fixed [data-placeholder-image]") + .first() + .should("have.css", "background-color", "rgb(232, 184, 8)") + cy.get(".constrained [data-placeholder-image]") + .first() + .should("have.prop", "tagName", "IMG") + cy.get(".constrained [data-placeholder-image]") + .first() + .should("contain.prop", "src", "data:image/jpg;base64") + cy.get(".full [data-placeholder-image]").first().should("be.empty") + }) +}) diff --git a/packages/gatsby-plugin-utils/package.json b/packages/gatsby-plugin-utils/package.json index f89a143be94d9..4d75054465a08 100644 --- a/packages/gatsby-plugin-utils/package.json +++ b/packages/gatsby-plugin-utils/package.json @@ -48,7 +48,6 @@ "dependencies": { "@babel/runtime": "^7.15.4", "gatsby-core-utils": "3.9.0-next.0", - "gatsby-plugin-utils": "^3.3.0-next.0", "graphql-compose": "^9.0.7", "import-from": "^4.0.0", "joi": "^17.4.2", diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts index 891ade9ee699a..11c8c0e181b87 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts @@ -1,6 +1,4 @@ -import path from "path" import { generatePublicUrl } from "../utils/url-generator" -import { getFileExtensionFromMimeType } from "../utils/mime-type-helpers" import { dispatchLocalFileServiceJob, shouldDispatch, @@ -24,11 +22,13 @@ export function publicUrlResolver( } return ( - generatePublicUrl({ - url: source.url, - // We always want file based url - mimeType: `application/octet-stream`, - }) + `/${source.filename}` + generatePublicUrl( + { + url: source.url, + mimeType: source.mimeType, + }, + false + ) + `/${source.filename}` ) } diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/http-routes.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/http-routes.ts index 26e0c216b1627..c39c4e82a6873 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/http-routes.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/http-routes.ts @@ -18,9 +18,9 @@ export function polyfillImageServiceDevRoutes(app: Application): void { } export function addImageRoutes(app: Application): Application { - app.get(`/_gatsby/file/:url`, async (req, res) => { + app.get(`/_gatsby/file/:url/:filename`, async (req, res) => { // remove the file extension - const [url] = req.params.url.split(`.`) + const url = req.params.url const outputDir = path.join( global.__GATSBY?.root || process.cwd(), `public`, @@ -31,14 +31,13 @@ export function addImageRoutes(app: Application): Application { const filePath = await fetchRemoteFile({ directory: outputDir, url: url, - name: req.params.url, + name: req.params.filename, }) fs.createReadStream(filePath).pipe(res) }) - app.get(`/_gatsby/image/:url/:params`, async (req, res) => { - const [params, extension] = req.params.params.split(`.`) - const url = req.params.url + app.get(`/_gatsby/image/:url/:params/:filename`, async (req, res) => { + const { params, url, filename } = req.params const searchParams = new URLSearchParams( Buffer.from(params, `base64`).toString() @@ -47,13 +46,13 @@ export function addImageRoutes(app: Application): Application { const resizeParams: { width: number height: number + quality: number format: string - fit: ImageFit } = { width: 0, height: 0, + quality: 75, format: ``, - fit: `cover`, } for (const [key, value] of searchParams) { @@ -70,8 +69,8 @@ export function addImageRoutes(app: Application): Application { resizeParams.format = value break } - case `fit`: { - resizeParams.fit = value as ImageFit + case `q`: { + resizeParams.quality = Number(value) break } } @@ -90,12 +89,15 @@ export function addImageRoutes(app: Application): Application { outputDir, args: { url: remoteUrl, - filename: generateImageArgs(resizeParams) + `.${extension}`, + filename, ...resizeParams, }, }) - res.setHeader(`content-type`, getFileExtensionFromMimeType(extension)) + res.setHeader( + `content-type`, + getFileExtensionFromMimeType(path.extname(filename)) + ) fs.createReadStream(filePath).pipe(res) }) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts index f13a59e3a4639..6efeb8f3c92d3 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts @@ -24,11 +24,14 @@ export function dispatchLocalFileServiceJob( store: Store ): void { const GATSBY_VERSION = getGatsbyVersion() - const publicUrl = generatePublicUrl({ - url, - // We always want file based url - mimeType: `application/octet-stream`, - }).split(`/`) + const publicUrl = generatePublicUrl( + { + url, + // We always want file based url + mimeType, + }, + false + ).split(`/`) const extension = getFileExtensionFromMimeType(mimeType) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const filename = publicUrl.pop() diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/transform-images.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/transform-images.ts index 04740891cbe14..07a98678efe4b 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/transform-images.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/transform-images.ts @@ -29,13 +29,9 @@ export async function transformImage({ args: { url, filename, contentDigest, ...args }, }: { outputDir: string - args: { + args: IResizeArgs & { url: string filename: string - width: number - height: number - format: string - fit: import("sharp").FitEnum[keyof import("sharp").FitEnum] contentDigest?: string } }): Promise { @@ -48,7 +44,8 @@ export async function transformImage({ return cachedValue } - const [basename, ext] = filename.split(`.`) + const ext = path.extname(filename) + const basename = path.basename(filename, ext) const filePath = await fetchRemoteFile({ directory: cache.directory, url: url, diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts index 0691bdfb82573..efb0fbac1b8d2 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts @@ -1,16 +1,22 @@ import { isImage } from "../types" import type { ImageCropFocus, WidthOrHeight } from "../types" -export function generatePublicUrl({ - url, - mimeType, -}: { - url: string - mimeType: string -}): string { +export function generatePublicUrl( + { + url, + mimeType, + }: { + url: string + mimeType: string + }, + checkMimeType: boolean = true +): string { const remoteUrl = Buffer.from(url).toString(`base64`) - let publicUrl = isImage({ mimeType }) ? `/_gatsby/image/` : `/_gatsby/file/` + let publicUrl = + checkMimeType && isImage({ mimeType }) + ? `/_gatsby/image/` + : `/_gatsby/file/` publicUrl += `${remoteUrl}` return publicUrl From 92f51a588236bab084004c6b5a222f6482fa99e7 Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Wed, 23 Feb 2022 20:07:05 +0100 Subject: [PATCH 13/46] remove unnecessary stuff --- .../gatsby-plugin-utils/src/polyfill-remote-file/http-routes.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/http-routes.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/http-routes.ts index c39c4e82a6873..f1f07553d8083 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/http-routes.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/http-routes.ts @@ -3,10 +3,8 @@ import fs from "fs-extra" import { fetchRemoteFile } from "gatsby-core-utils/fetch-remote-file" import { hasFeature } from "../has-feature" import { getFileExtensionFromMimeType } from "./utils/mime-type-helpers" -import { generateImageArgs } from "./utils/url-generator" import { transformImage } from "./transform-images" -import type { ImageFit } from "./types" import type { Application } from "express" export function polyfillImageServiceDevRoutes(app: Application): void { From 85d74fbda822a18c806c1318dc65abe2797b9a2a Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Thu, 24 Feb 2022 09:56:15 +0100 Subject: [PATCH 14/46] fix tests --- .../src/polyfill-remote-file/index.ts | 1 - packages/gatsby-sharp/.babelrc.js | 1 - .../__tests__/__snapshots__/print.js.snap | 534 ++++++++++++++++++ .../schema/__tests__/fixtures/node-model.js | 2 +- .../gatsby/src/schema/__tests__/node-model.js | 8 +- .../src/schema/types/remote-file-interface.ts | 1 - 6 files changed, 539 insertions(+), 8 deletions(-) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts index 164aa0413413e..d94f13e842502 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts @@ -97,7 +97,6 @@ function addRemoteFilePolyfillInterface< }), schema.buildInterfaceType({ name: `RemoteFile`, - interfaces: [`Node`], fields: getRemoteFileFields( enums, store diff --git a/packages/gatsby-sharp/.babelrc.js b/packages/gatsby-sharp/.babelrc.js index cad9956015e41..789d013146882 100644 --- a/packages/gatsby-sharp/.babelrc.js +++ b/packages/gatsby-sharp/.babelrc.js @@ -1,4 +1,3 @@ -console.log('hi there'); module.exports = { "presets": [["babel-preset-gatsby-package"]], "plugins": ["babel-plugin-replace-ts-export-assignment"] diff --git a/packages/gatsby/src/schema/__tests__/__snapshots__/print.js.snap b/packages/gatsby/src/schema/__tests__/__snapshots__/print.js.snap index ec08162e01a84..373a207b9d5fe 100644 --- a/packages/gatsby/src/schema/__tests__/__snapshots__/print.js.snap +++ b/packages/gatsby/src/schema/__tests__/__snapshots__/print.js.snap @@ -12,6 +12,184 @@ type InlineTest implements Node & ITest @childOf(types: [\\"OneMoreTest\\"]) @do exports[`Print type definitions allows specifying types owned by plugins to exclude 1`] = ` "### Type definitions saved at 2019-01-01 ### +enum RemoteFileFit { + COVER + FILL + OUTSIDE + CONTAIN +} + +enum RemoteFileFormat { + AUTO + JPG + PNG + WEBP + AVIF +} + +enum RemoteFileLayout { + FIXED + FULL_WIDTH + CONSTRAINED +} + +enum RemoteFilePlaceholder { + DOMINANT_COLOR + BLURRED + NONE +} + +enum RemoteFileCropFocus { + CENTER + TOP + RIGHT + BOTTOM + LEFT + ENTROPY + EDGES + FACES +} + +type RemoteFileResize { + width: Int + height: Int + src: String +} + +\\"\\"\\"Remote Interface\\"\\"\\" +interface RemoteFile { + id: ID! + mimeType: String! + filename: String! + filesize: Int + width: Int + height: Int + publicUrl: String! + resize( + width: Int + height: Int + fit: RemoteFileFit = COVER + + \\"\\"\\" + + The image formats to generate. Valid values are AUTO (meaning the same + format as the source image), JPG, PNG, WEBP and AVIF. + The default value is [AUTO, WEBP, AVIF], 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. + \\"\\"\\" + format: RemoteFileFormat = AUTO + cropFocus: [RemoteFileCropFocus] + quality: Int = 75 + ): RemoteFileResize + gatsbyImageData( + \\"\\"\\" + + The layout for the image. + FIXED: A static image sized, that does not resize according to the screen width + FULL_WIDTH: 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. + + \\"\\"\\" + layout: RemoteFileLayout = CONSTRAINED + + \\"\\"\\" + + The display width of the generated image for layout = FIXED, and the display + width of the largest image for layout = CONSTRAINED. + The actual largest image resolution will be this value multiplied by the largest value in outputPixelDensities + Ignored if layout = FLUID. + + \\"\\"\\" + width: Int + + \\"\\"\\" + + 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. + \\"\\"\\" + height: Int + + \\"\\"\\" + + 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 the argument \\"backgroundColor\\" to use a fixed background color. + \\"\\"\\" + placeholder: RemoteFilePlaceholder = DOMINANT_COLOR + + \\"\\"\\" + + If set along with width or height, this will set the value of the other + dimension to match the provided aspect ratio, cropping the image if needed. + If neither width or height is provided, height will be set based on the intrinsic width of the source image. + + \\"\\"\\" + aspectRatio: Float + + \\"\\"\\" + + The image formats to generate. Valid values are AUTO (meaning the same + format as the source image), JPG, PNG, WEBP and AVIF. + The default value is [AUTO, WEBP, AVIF], 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. + + \\"\\"\\" + formats: [RemoteFileFormat!] = [AUTO, WEBP, AVIF] + + \\"\\"\\" + + A list of image pixel densities to generate for FIXED and CONSTRAINED + images. You should rarely need to change this. 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, 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. + + \\"\\"\\" + outputPixelDensities: [Float] = [0.25, 0.5, 1, 2] + + \\"\\"\\" + + Specifies the image widths to generate. You should rarely need to change + this. For FIXED and CONSTRAINED images it is better to allow these to be + determined automatically, + based on the image size. For FULL_WIDTH images this can be used to override + the default, which is [750, 1080, 1366, 1920]. + It will never generate any images larger than the source. + + \\"\\"\\" + breakpoints: [Int] = [750, 1080, 1366, 1920] + + \\"\\"\\" + + 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. + + \\"\\"\\" + sizes: String + + \\"\\"\\" + Background color applied to the wrapper, or when \\"letterboxing\\" an image to another aspect ratio. + \\"\\"\\" + backgroundColor: String + fit: RemoteFileFit = COVER + cropFocus: [RemoteFileCropFocus] + quality: Int = 75 + ): JSON +} + type File implements Node @dontInfer { sourceInstanceName: String! absolutePath: String! @@ -217,6 +395,184 @@ type BarChild implements Node @childOf(types: [\\"Test\\"]) @dontInfer { exports[`Print type definitions allows specifying types to exclude 1`] = ` "### Type definitions saved at 2019-01-01 ### +enum RemoteFileFit { + COVER + FILL + OUTSIDE + CONTAIN +} + +enum RemoteFileFormat { + AUTO + JPG + PNG + WEBP + AVIF +} + +enum RemoteFileLayout { + FIXED + FULL_WIDTH + CONSTRAINED +} + +enum RemoteFilePlaceholder { + DOMINANT_COLOR + BLURRED + NONE +} + +enum RemoteFileCropFocus { + CENTER + TOP + RIGHT + BOTTOM + LEFT + ENTROPY + EDGES + FACES +} + +type RemoteFileResize { + width: Int + height: Int + src: String +} + +\\"\\"\\"Remote Interface\\"\\"\\" +interface RemoteFile { + id: ID! + mimeType: String! + filename: String! + filesize: Int + width: Int + height: Int + publicUrl: String! + resize( + width: Int + height: Int + fit: RemoteFileFit = COVER + + \\"\\"\\" + + The image formats to generate. Valid values are AUTO (meaning the same + format as the source image), JPG, PNG, WEBP and AVIF. + The default value is [AUTO, WEBP, AVIF], 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. + \\"\\"\\" + format: RemoteFileFormat = AUTO + cropFocus: [RemoteFileCropFocus] + quality: Int = 75 + ): RemoteFileResize + gatsbyImageData( + \\"\\"\\" + + The layout for the image. + FIXED: A static image sized, that does not resize according to the screen width + FULL_WIDTH: 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. + + \\"\\"\\" + layout: RemoteFileLayout = CONSTRAINED + + \\"\\"\\" + + The display width of the generated image for layout = FIXED, and the display + width of the largest image for layout = CONSTRAINED. + The actual largest image resolution will be this value multiplied by the largest value in outputPixelDensities + Ignored if layout = FLUID. + + \\"\\"\\" + width: Int + + \\"\\"\\" + + 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. + \\"\\"\\" + height: Int + + \\"\\"\\" + + 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 the argument \\"backgroundColor\\" to use a fixed background color. + \\"\\"\\" + placeholder: RemoteFilePlaceholder = DOMINANT_COLOR + + \\"\\"\\" + + If set along with width or height, this will set the value of the other + dimension to match the provided aspect ratio, cropping the image if needed. + If neither width or height is provided, height will be set based on the intrinsic width of the source image. + + \\"\\"\\" + aspectRatio: Float + + \\"\\"\\" + + The image formats to generate. Valid values are AUTO (meaning the same + format as the source image), JPG, PNG, WEBP and AVIF. + The default value is [AUTO, WEBP, AVIF], 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. + + \\"\\"\\" + formats: [RemoteFileFormat!] = [AUTO, WEBP, AVIF] + + \\"\\"\\" + + A list of image pixel densities to generate for FIXED and CONSTRAINED + images. You should rarely need to change this. 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, 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. + + \\"\\"\\" + outputPixelDensities: [Float] = [0.25, 0.5, 1, 2] + + \\"\\"\\" + + Specifies the image widths to generate. You should rarely need to change + this. For FIXED and CONSTRAINED images it is better to allow these to be + determined automatically, + based on the image size. For FULL_WIDTH images this can be used to override + the default, which is [750, 1080, 1366, 1920]. + It will never generate any images larger than the source. + + \\"\\"\\" + breakpoints: [Int] = [750, 1080, 1366, 1920] + + \\"\\"\\" + + 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. + + \\"\\"\\" + sizes: String + + \\"\\"\\" + Background color applied to the wrapper, or when \\"letterboxing\\" an image to another aspect ratio. + \\"\\"\\" + backgroundColor: String + fit: RemoteFileFit = COVER + cropFocus: [RemoteFileCropFocus] + quality: Int = 75 + ): JSON +} + type File implements Node @dontInfer { sourceInstanceName: String! absolutePath: String! @@ -432,6 +788,184 @@ input Language { exports[`Print type definitions saves correct type definitions 1`] = ` "### Type definitions saved at 2019-01-01 ### +enum RemoteFileFit { + COVER + FILL + OUTSIDE + CONTAIN +} + +enum RemoteFileFormat { + AUTO + JPG + PNG + WEBP + AVIF +} + +enum RemoteFileLayout { + FIXED + FULL_WIDTH + CONSTRAINED +} + +enum RemoteFilePlaceholder { + DOMINANT_COLOR + BLURRED + NONE +} + +enum RemoteFileCropFocus { + CENTER + TOP + RIGHT + BOTTOM + LEFT + ENTROPY + EDGES + FACES +} + +type RemoteFileResize { + width: Int + height: Int + src: String +} + +\\"\\"\\"Remote Interface\\"\\"\\" +interface RemoteFile { + id: ID! + mimeType: String! + filename: String! + filesize: Int + width: Int + height: Int + publicUrl: String! + resize( + width: Int + height: Int + fit: RemoteFileFit = COVER + + \\"\\"\\" + + The image formats to generate. Valid values are AUTO (meaning the same + format as the source image), JPG, PNG, WEBP and AVIF. + The default value is [AUTO, WEBP, AVIF], 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. + \\"\\"\\" + format: RemoteFileFormat = AUTO + cropFocus: [RemoteFileCropFocus] + quality: Int = 75 + ): RemoteFileResize + gatsbyImageData( + \\"\\"\\" + + The layout for the image. + FIXED: A static image sized, that does not resize according to the screen width + FULL_WIDTH: 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. + + \\"\\"\\" + layout: RemoteFileLayout = CONSTRAINED + + \\"\\"\\" + + The display width of the generated image for layout = FIXED, and the display + width of the largest image for layout = CONSTRAINED. + The actual largest image resolution will be this value multiplied by the largest value in outputPixelDensities + Ignored if layout = FLUID. + + \\"\\"\\" + width: Int + + \\"\\"\\" + + 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. + \\"\\"\\" + height: Int + + \\"\\"\\" + + 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 the argument \\"backgroundColor\\" to use a fixed background color. + \\"\\"\\" + placeholder: RemoteFilePlaceholder = DOMINANT_COLOR + + \\"\\"\\" + + If set along with width or height, this will set the value of the other + dimension to match the provided aspect ratio, cropping the image if needed. + If neither width or height is provided, height will be set based on the intrinsic width of the source image. + + \\"\\"\\" + aspectRatio: Float + + \\"\\"\\" + + The image formats to generate. Valid values are AUTO (meaning the same + format as the source image), JPG, PNG, WEBP and AVIF. + The default value is [AUTO, WEBP, AVIF], 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. + + \\"\\"\\" + formats: [RemoteFileFormat!] = [AUTO, WEBP, AVIF] + + \\"\\"\\" + + A list of image pixel densities to generate for FIXED and CONSTRAINED + images. You should rarely need to change this. 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, 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. + + \\"\\"\\" + outputPixelDensities: [Float] = [0.25, 0.5, 1, 2] + + \\"\\"\\" + + Specifies the image widths to generate. You should rarely need to change + this. For FIXED and CONSTRAINED images it is better to allow these to be + determined automatically, + based on the image size. For FULL_WIDTH images this can be used to override + the default, which is [750, 1080, 1366, 1920]. + It will never generate any images larger than the source. + + \\"\\"\\" + breakpoints: [Int] = [750, 1080, 1366, 1920] + + \\"\\"\\" + + 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. + + \\"\\"\\" + sizes: String + + \\"\\"\\" + Background color applied to the wrapper, or when \\"letterboxing\\" an image to another aspect ratio. + \\"\\"\\" + backgroundColor: String + fit: RemoteFileFit = COVER + cropFocus: [RemoteFileCropFocus] + quality: Int = 75 + ): JSON +} + type File implements Node @dontInfer { sourceInstanceName: String! absolutePath: String! diff --git a/packages/gatsby/src/schema/__tests__/fixtures/node-model.js b/packages/gatsby/src/schema/__tests__/fixtures/node-model.js index 02ebbac303bd1..3b1f071bbb62f 100644 --- a/packages/gatsby/src/schema/__tests__/fixtures/node-model.js +++ b/packages/gatsby/src/schema/__tests__/fixtures/node-model.js @@ -83,7 +83,7 @@ const nodes = [ id: `file2`, parent: null, children: [`post2`], - internal: { type: `RemoteFile`, contentDigest: `0` }, + internal: { type: `ExternalFile`, contentDigest: `0` }, url: `RemoteFile2`, }, { diff --git a/packages/gatsby/src/schema/__tests__/node-model.js b/packages/gatsby/src/schema/__tests__/node-model.js index 27846ba8462db..e6edc7a1bade8 100644 --- a/packages/gatsby/src/schema/__tests__/node-model.js +++ b/packages/gatsby/src/schema/__tests__/node-model.js @@ -22,7 +22,7 @@ describe(`NodeModel`, () => { `SiteBuildMetadata`, `Author`, `Contributor`, - `RemoteFile`, + `ExternalFile`, `Post`, ] @@ -37,7 +37,7 @@ describe(`NodeModel`, () => { ) const types = ` - union AllFiles = File | RemoteFile + union AllFiles = File | ExternalFile interface TeamMember { name: String! @@ -164,7 +164,7 @@ describe(`NodeModel`, () => { }) expect(result.length).toBe(3) expect( - result.every(r => [`File`, `RemoteFile`].includes(r.internal.type)) + result.every(r => [`File`, `ExternalFile`].includes(r.internal.type)) ).toBeTruthy() }) @@ -318,7 +318,7 @@ describe(`NodeModel`, () => { `Contributor`, `Post`, `File`, - `RemoteFile`, + `ExternalFile`, ]) ) }) diff --git a/packages/gatsby/src/schema/types/remote-file-interface.ts b/packages/gatsby/src/schema/types/remote-file-interface.ts index 27f7c175dcb1a..f1b53979d5f38 100644 --- a/packages/gatsby/src/schema/types/remote-file-interface.ts +++ b/packages/gatsby/src/schema/types/remote-file-interface.ts @@ -38,7 +38,6 @@ export function getOrCreateRemoteFileInterface( }) return schemaComposer.getOrCreateIFTC(`RemoteFile`, tc => { - tc.addInterface(`Node`) tc.setDescription(`Remote Interface`) // @ts-ignore - types are messed up by schema composer maybe new version helps here From 1b87181e73b3503bcae9847a01b5fdf614e4eb27 Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Thu, 24 Feb 2022 12:15:59 +0100 Subject: [PATCH 15/46] fix tests --- .../__tests__/gatsby-image-data-resolver.ts | 36 ++++++++-------- .../graphql/gatsby-image-data-resolver.ts | 6 +-- packages/gatsby/scripts/__tests__/api.js | 2 +- .../types/__tests__/remote-file-interface.ts | 41 ++++++++++++++----- 4 files changed, 53 insertions(+), 32 deletions(-) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-data-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-data-resolver.ts index 17a9e7014d3a1..7b146bb9d46e9 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-data-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-data-resolver.ts @@ -25,11 +25,13 @@ jest.mock(`gatsby-core-utils/mutex`, () => { } }) -function parseSrcSet(srcSet: string): Array<{ src: string; viewport: string }> { +function parseSrcSet( + srcSet: string +): Array<{ src: string; descriptor: string }> { return srcSet.split(`,`).map(line => { - const [src, viewport] = line.split(` `) + const [src, descriptor] = line.split(` `) - return { src, viewport } + return { src, descriptor } }) } @@ -118,7 +120,7 @@ describe(`gatsbyImageData`, () => { portraitSource.basename }.webp` ) - expect(parsedSrcSet[0].viewport).toEqual(`1x`) + expect(parsedSrcSet[0].descriptor).toEqual(`1x`) expect(parsedSrcSet[1].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` @@ -126,7 +128,7 @@ describe(`gatsbyImageData`, () => { portraitSource.basename }.webp` ) - expect(parsedSrcSet[1].viewport).toEqual(`2x`) + expect(parsedSrcSet[1].descriptor).toEqual(`2x`) expect(result).toEqual({ height: 481, @@ -176,7 +178,7 @@ describe(`gatsbyImageData`, () => { portraitSource.basename }.webp` ) - expect(parsedSrcSet[0].viewport).toEqual(`75w`) + expect(parsedSrcSet[0].descriptor).toEqual(`75w`) expect(parsedSrcSet[1].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` @@ -184,7 +186,7 @@ describe(`gatsbyImageData`, () => { portraitSource.basename }.webp` ) - expect(parsedSrcSet[1].viewport).toEqual(`150w`) + expect(parsedSrcSet[1].descriptor).toEqual(`150w`) expect(parsedSrcSet[2].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` @@ -192,7 +194,7 @@ describe(`gatsbyImageData`, () => { portraitSource.basename }.webp` ) - expect(parsedSrcSet[2].viewport).toEqual(`300w`) + expect(parsedSrcSet[2].descriptor).toEqual(`300w`) expect(parsedSrcSet[3].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` @@ -200,7 +202,7 @@ describe(`gatsbyImageData`, () => { portraitSource.basename }.webp` ) - expect(parsedSrcSet[3].viewport).toEqual(`600w`) + expect(parsedSrcSet[3].descriptor).toEqual(`600w`) expect(result).toEqual({ height: 481, @@ -254,7 +256,7 @@ describe(`gatsbyImageData`, () => { portraitSource.basename }.webp` ) - expect(parsedSrcSet[0].viewport).toEqual(`750w`) + expect(parsedSrcSet[0].descriptor).toEqual(`750w`) expect(parsedSrcSet[1].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` @@ -262,7 +264,7 @@ describe(`gatsbyImageData`, () => { portraitSource.basename }.webp` ) - expect(parsedSrcSet[1].viewport).toEqual(`1080w`) + expect(parsedSrcSet[1].descriptor).toEqual(`1080w`) expect(parsedSrcSet[2].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` @@ -270,7 +272,7 @@ describe(`gatsbyImageData`, () => { portraitSource.basename }.webp` ) - expect(parsedSrcSet[2].viewport).toEqual(`1366w`) + expect(parsedSrcSet[2].descriptor).toEqual(`1366w`) expect(parsedSrcSet[3].src).toEqual( `/_gatsby/image/${Buffer.from(portraitSource.url).toString( `base64` @@ -278,7 +280,7 @@ describe(`gatsbyImageData`, () => { portraitSource.basename }.webp` ) - expect(parsedSrcSet[3].viewport).toEqual(`1920w`) + expect(parsedSrcSet[3].descriptor).toEqual(`1920w`) expect(result).toEqual({ height: 3206, @@ -425,19 +427,19 @@ describe(`gatsbyImageData`, () => { const parsedFixedSrcSet = parseSrcSet(fixedResult.images.sources[0].srcSet) expect(parsedFixedSrcSet).toHaveLength(2) - expect(parsedFixedSrcSet[0].viewport).toEqual(`1x`) + expect(parsedFixedSrcSet[0].descriptor).toEqual(`1x`) const parsedConstrainedSrcSet = parseSrcSet( constrainedResult.images.sources[0].srcSet ) expect(parsedConstrainedSrcSet).toHaveLength(4) - expect(parsedConstrainedSrcSet[0].viewport).toEqual(`75w`) + expect(parsedConstrainedSrcSet[0].descriptor).toEqual(`75w`) const parsedFullWidthSrcSet = parseSrcSet( fullWidthResult.images.sources[0].srcSet ) expect(parsedFullWidthSrcSet).toHaveLength(2) - expect(parsedFullWidthSrcSet[0].viewport).toEqual(`350w`) - expect(parsedFullWidthSrcSet[1].viewport).toEqual(`700w`) + expect(parsedFullWidthSrcSet[0].descriptor).toEqual(`350w`) + expect(parsedFullWidthSrcSet[1].descriptor).toEqual(`700w`) }) it(`should generate dominant color placeholder by default`, async () => { diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts index 69beaa8a490c6..b96604a8e3d05 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts @@ -186,7 +186,7 @@ export async function gatsbyImageDataResolver( return { src, - viewport: + descriptor: args.layout === `fixed` ? `${width / imageSizes.presentationWidth}x` : `${width}w`, @@ -350,9 +350,9 @@ function sortNumeric(a: number, b: number): number { } function createSrcSetFromImages( - images: Array<{ src: string; viewport: string }> + images: Array<{ src: string; descriptor: string }> ): string { - return images.map(image => `${image.src} ${image.viewport}`).join(`,`) + return images.map(image => `${image.src} ${image.descriptor}`).join(`,`) } // eslint-disable-next-line consistent-return diff --git a/packages/gatsby/scripts/__tests__/api.js b/packages/gatsby/scripts/__tests__/api.js index bfd0008cf7104..95a821dfba780 100644 --- a/packages/gatsby/scripts/__tests__/api.js +++ b/packages/gatsby/scripts/__tests__/api.js @@ -31,7 +31,7 @@ it("generates the expected api output", done => { "wrapRootElement": Object {}, }, "features": Array [ - "image-service", + "image-cdn", ], "node": Object { "createPages": Object {}, diff --git a/packages/gatsby/src/schema/types/__tests__/remote-file-interface.ts b/packages/gatsby/src/schema/types/__tests__/remote-file-interface.ts index 7e2289433cb0a..d348d6e29a46d 100644 --- a/packages/gatsby/src/schema/types/__tests__/remote-file-interface.ts +++ b/packages/gatsby/src/schema/types/__tests__/remote-file-interface.ts @@ -1,4 +1,5 @@ import { store } from "../../../redux" +import { actions } from "../../../redux/actions" import { build } from "../../index" import { DEFAULT_PIXEL_DENSITIES, @@ -68,6 +69,14 @@ describe(`remote-file`, () => { root: process.cwd(), } + store.dispatch( + actions.createTypes(` + type MyAsset implements Node & RemoteFile { + id: ID! + } + `) + ) + await build({}) schema = store.getState().schema }) @@ -76,14 +85,17 @@ describe(`remote-file`, () => { let resize const remoteFile = { url: `https://images.unsplash.com/photo-1587300003388-59208cc962cb?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=640`, - contentType: `image/jpg`, + mimeType: `image/jpg`, filename: `pauline-loroy-U3aF7hgUSrk-unsplash.jpg`, width: 1200, height: 800, + internal: { + contentDigest: `1`, + }, } beforeAll(() => { - const fields = schema.getType(`RemoteFile`).getFields() + const fields = schema.getType(`MyAsset`).getFields() resize = fields.resize.resolve }) @@ -97,13 +109,17 @@ describe(`remote-file`, () => { {}, {} ) - const { url, params } = extractImageChunks(data) + const { url, params } = extractImageChunks(data.src) expect(url).toEqual(remoteFile.url) - expect(params).toMatchInlineSnapshot(`"w=100&h=100&fm=jpg"`) - expect(data).toMatchInlineSnapshot( - `"/_gatsby/image/aHR0cHM6Ly9pbWFnZXMudW5zcGxhc2guY29tL3Bob3RvLTE1ODczMDAwMDMzODgtNTkyMDhjYzk2MmNiP2l4bGliPXJiLTEuMi4xJnE9ODAmZm09anBnJmNyb3A9ZW50cm9weSZjcz10aW55c3JnYiZ3PTY0MA==/dz0xMDAmaD0xMDAmZm09anBn"` - ) + expect(params).toMatchInlineSnapshot(`"w=100&h=100&fm=jpg&q=75"`) + expect(data).toMatchInlineSnapshot(` + Object { + "height": 100, + "src": "/_gatsby/image/aHR0cHM6Ly9pbWFnZXMudW5zcGxhc2guY29tL3Bob3RvLTE1ODczMDAwMDMzODgtNTkyMDhjYzk2MmNiP2l4bGliPXJiLTEuMi4xJnE9ODAmZm09anBnJmNyb3A9ZW50cm9weSZjcz10aW55c3JnYiZ3PTY0MA==/dz0xMDAmaD0xMDAmZm09anBnJnE9NzU=/pauline-loroy-U3aF7hgUSrk-unsplash.jpg", + "width": 100, + } + `) }) }) @@ -111,14 +127,17 @@ describe(`remote-file`, () => { let gatsbyImageData const remoteFile = { url: `https://images.unsplash.com/photo-1587300003388-59208cc962cb?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=640`, - contentType: `image/jpg`, + mimeType: `image/jpg`, filename: `pauline-loroy-U3aF7hgUSrk-unsplash.jpg`, width: 1200, height: 800, + internal: { + contentDigest: `1`, + }, } beforeAll(() => { - const fields = schema.getType(`RemoteFile`).getFields() + const fields = schema.getType(`MyAsset`).getFields() gatsbyImageData = fields.gatsbyImageData.resolve }) @@ -149,12 +168,12 @@ describe(`remote-file`, () => { { url: remoteFile.url, params: expect.stringContaining(`w=100&h=67`), - descriptor: `100w`, + descriptor: `1x`, }, { url: remoteFile.url, params: expect.stringContaining(`w=200&h=133`), - descriptor: `200w`, + descriptor: `2x`, }, ]) ) From 840007adea1a4129e3d29c8cf10104ae722528fc Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Thu, 24 Feb 2022 13:25:32 +0100 Subject: [PATCH 16/46] fix jest.config --- examples/using-jest/jest.config.js | 5 ++++- integration-tests/gatsby-source-wordpress/jest.config.js | 5 ++++- jest.config.js | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/examples/using-jest/jest.config.js b/examples/using-jest/jest.config.js index ce8e92768bb75..50f7c8a6b36b2 100644 --- a/examples/using-jest/jest.config.js +++ b/examples/using-jest/jest.config.js @@ -7,7 +7,10 @@ module.exports = { ".+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": `/__mocks__/file-mock.js`, "^gatsby-page-utils/(.*)$": `gatsby-page-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771 "^gatsby-core-utils/(.*)$": `gatsby-core-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771 - "^gatsby-plugin-utils/(.*)$": `gatsby-plugin-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771 + "^gatsby-plugin-utils/(.*)$": [ + `gatsby-plugin-utils/dist/$1`, + `gatsby-plugin-utils/$1`, + ], // Workaround for https://github.com/facebook/jest/issues/9771 }, testPathIgnorePatterns: [`node_modules`, `.cache`], transformIgnorePatterns: [`node_modules/(?!(gatsby)/)`], diff --git a/integration-tests/gatsby-source-wordpress/jest.config.js b/integration-tests/gatsby-source-wordpress/jest.config.js index 7c821f7d6abd5..cd373ece1f2b5 100644 --- a/integration-tests/gatsby-source-wordpress/jest.config.js +++ b/integration-tests/gatsby-source-wordpress/jest.config.js @@ -3,6 +3,9 @@ module.exports = { bail: true, moduleNameMapper: { "^gatsby-core-utils/(.*)$": `gatsby-core-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771 - "^gatsby-plugin-utils/(.*)$": `gatsby-plugin-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771 + "^gatsby-plugin-utils/(.*)$": [ + `gatsby-plugin-utils/dist/$1`, + `gatsby-plugin-utils/$1`, + ], // Workaround for https://github.com/facebook/jest/issues/9771 }, } diff --git a/jest.config.js b/jest.config.js index 7371aa759d62f..211528be5fe34 100644 --- a/jest.config.js +++ b/jest.config.js @@ -48,7 +48,10 @@ module.exports = { "^msgpackr$": `/node_modules/msgpackr/dist/node.cjs`, "^gatsby-page-utils/(.*)$": `gatsby-page-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771 "^gatsby-core-utils/(.*)$": `gatsby-core-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771 - "^gatsby-plugin-utils/(.*)$": `gatsby-plugin-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771 + "^gatsby-plugin-utils/(.*)$": [ + `gatsby-plugin-utils/dist/$1`, + `gatsby-plugin-utils/$1`, + ], // Workaround for https://github.com/facebook/jest/issues/9771 }, snapshotSerializers: [`jest-serializer-path`], collectCoverageFrom: coverageDirs, From 37c8e53c1d451f99c0de27323729c98d49a12465 Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Thu, 24 Feb 2022 15:27:01 +0100 Subject: [PATCH 17/46] fix tests --- .../src/polyfill-remote-file/jobs/__tests__/gatsby-worker.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/__tests__/gatsby-worker.ts diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/__tests__/gatsby-worker.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/__tests__/gatsby-worker.ts deleted file mode 100644 index e69de29bb2d1d..0000000000000 From 9115c8845541a823bcf5dbf83c96f7951b0848a9 Mon Sep 17 00:00:00 2001 From: veryspry Date: Thu, 24 Feb 2022 15:35:28 -0600 Subject: [PATCH 18/46] fix: destructure actions import in remote job dispatcher --- .../src/polyfill-remote-file/jobs/dispatchers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts index 6efeb8f3c92d3..e4e1e4dfa56c6 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts @@ -37,10 +37,10 @@ export function dispatchLocalFileServiceJob( const filename = publicUrl.pop() publicUrl.unshift(`public`) - const actions = importFrom( + const { actions } = importFrom( global.__GATSBY?.root ?? process.cwd(), `gatsby/dist/redux/actions` - ) + ) as any // @ts-ignore - we dont have correct typings for this actions.createJobV2( From 6c00a4fed778f46b9b08d165d7f9f702e5406875 Mon Sep 17 00:00:00 2001 From: Tyler Barnes Date: Thu, 24 Feb 2022 19:07:09 -0800 Subject: [PATCH 19/46] rename image service gql field --- packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts index d94f13e842502..f398352bc0cc7 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts @@ -36,7 +36,7 @@ export function getRemoteFileFields( height: `Int`, publicUrl: generatePublicUrlFieldConfig(store), resize: generateResizeFieldConfig(enums, store), - gatsbyImageData: generateGatsbyImageDataFieldConfig(enums, store), + gatsbyImage: generateGatsbyImageDataFieldConfig(enums, store), } } From 4a512265ef33f368cadfc1fce178cebf81d945bb Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Fri, 25 Feb 2022 10:01:21 +0100 Subject: [PATCH 20/46] fix buss errors --- packages/gatsby-plugin-utils/package.json | 1 + .../polyfill-remote-file/placeholder-handler.ts | 15 ++++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/gatsby-plugin-utils/package.json b/packages/gatsby-plugin-utils/package.json index 4d75054465a08..6200e4b226f50 100644 --- a/packages/gatsby-plugin-utils/package.json +++ b/packages/gatsby-plugin-utils/package.json @@ -48,6 +48,7 @@ "dependencies": { "@babel/runtime": "^7.15.4", "gatsby-core-utils": "3.9.0-next.0", + "gatsby-sharp": "^0.3.0-next.0", "graphql-compose": "^9.0.7", "import-from": "^4.0.0", "joi": "^17.4.2", diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts index ad63f0b6fd708..4f78f0843feaa 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts @@ -18,10 +18,11 @@ interface IPlaceholderGenerationArgs { format: string width: number height: number + id: string contentDigest: string } -const QUEUE_CONCURRENCY = 5 +const QUEUE_CONCURRENCY = 10 let tmpDir: string @@ -102,6 +103,7 @@ export async function generatePlaceholder( case PlaceholderType.BLURRED: { return { fallback: await placeholderToBase64({ + id: source.id, placeholderUrl: source.placeholderUrl, originalUrl: source.url, format: getImageFormatFromMimeType(source.mimeType), @@ -114,6 +116,7 @@ export async function generatePlaceholder( case PlaceholderType.DOMINANT_COLOR: { return { backgroundColor: await placeholderToDominantColor({ + id: source.id, placeholderUrl: source.placeholderUrl, originalUrl: source.url, format: getImageFormatFromMimeType(source.mimeType), @@ -131,16 +134,17 @@ async function placeholderToBase64({ originalUrl, width, height, + id, contentDigest, }: IPlaceholderGenerationArgs): Promise { const cache = getCache() - const cacheKey = `image-cdn:${contentDigest}:base64` + const cacheKey = `image-cdn:${id}-${contentDigest}:base64` let cachedValue = await cache.get(cacheKey) if (cachedValue) { return cachedValue } - const mutex = createMutex(getMutexKey(contentDigest)) + const mutex = createMutex(getMutexKey(`${id}-${contentDigest}`)) await mutex.acquire() try { @@ -198,16 +202,17 @@ async function placeholderToDominantColor({ originalUrl, width, height, + id, contentDigest, }: IPlaceholderGenerationArgs): Promise { const cache = getCache() - const cacheKey = `image-cdn:${contentDigest}:dominantColor` + const cacheKey = `image-cdn:${id}-${contentDigest}:dominantColor` let cachedValue = await cache.get(cacheKey) if (cachedValue) { return cachedValue } - const mutex = createMutex(getMutexKey(contentDigest)) + const mutex = createMutex(getMutexKey(`${id}-${contentDigest}`)) await mutex.acquire() try { From 18d8ee2946b512a7603af9591c874a4cc87b5a3f Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Fri, 25 Feb 2022 13:41:23 +0100 Subject: [PATCH 21/46] overall fixes --- .../__fixtures__/dog-landscape.jpg | Bin .../__fixtures__/dog-portrait.jpg | Bin ...a-resolver.ts => gatsby-image-resolver.ts} | 30 +++++++++--------- .../__tests__/public-resolver.ts | 11 +++++-- .../__tests__/resize-resolver.ts | 10 +++--- ...a-resolver.ts => gatsby-image-resolver.ts} | 4 +-- .../src/polyfill-remote-file/index.ts | 10 +++--- .../polyfill-remote-file/jobs/dispatchers.ts | 2 +- .../src/polyfill-remote-file/tester.js | 16 ---------- 9 files changed, 37 insertions(+), 46 deletions(-) rename packages/gatsby-plugin-utils/src/polyfill-remote-file/{ => __tests__}/__fixtures__/dog-landscape.jpg (100%) rename packages/gatsby-plugin-utils/src/polyfill-remote-file/{ => __tests__}/__fixtures__/dog-portrait.jpg (100%) rename packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/{gatsby-image-data-resolver.ts => gatsby-image-resolver.ts} (94%) rename packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/{gatsby-image-data-resolver.ts => gatsby-image-resolver.ts} (99%) delete mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/tester.js diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__fixtures__/dog-landscape.jpg b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/__fixtures__/dog-landscape.jpg similarity index 100% rename from packages/gatsby-plugin-utils/src/polyfill-remote-file/__fixtures__/dog-landscape.jpg rename to packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/__fixtures__/dog-landscape.jpg diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__fixtures__/dog-portrait.jpg b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/__fixtures__/dog-portrait.jpg similarity index 100% rename from packages/gatsby-plugin-utils/src/polyfill-remote-file/__fixtures__/dog-portrait.jpg rename to packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/__fixtures__/dog-portrait.jpg diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-data-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-resolver.ts similarity index 94% rename from packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-data-resolver.ts rename to packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-resolver.ts index 7b146bb9d46e9..d4cb7bf2a7f00 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-data-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-resolver.ts @@ -2,7 +2,7 @@ import path from "path" import { ensureDir, remove } from "fs-extra" import importFrom from "import-from" import { fetchRemoteFile } from "gatsby-core-utils/fetch-remote-file" -import { gatsbyImageDataResolver } from "../index" +import { gatsbyImageResolver } from "../index" import * as dispatchers from "../jobs/dispatchers" import type { Store } from "gatsby" import { PlaceholderType } from "../placeholder-handler" @@ -78,7 +78,7 @@ describe(`gatsbyImageData`, () => { it(`should return null when source is not an image`, async () => { expect( - await gatsbyImageDataResolver( + await gatsbyImageResolver( { id: `1`, url: `https://origin.com/my-pdf.pdf`, @@ -101,7 +101,7 @@ describe(`gatsbyImageData`, () => { }) it(`should return proper image props for fixed layout`, async () => { - const result = await gatsbyImageDataResolver( + const result = await gatsbyImageResolver( portraitSource, { layout: `fixed`, @@ -159,7 +159,7 @@ describe(`gatsbyImageData`, () => { }) it(`should return proper image props for constrained layout`, async () => { - const result = await gatsbyImageDataResolver( + const result = await gatsbyImageResolver( portraitSource, { layout: `constrained`, @@ -233,7 +233,7 @@ describe(`gatsbyImageData`, () => { }) it(`should return proper image props for fullWidth layout`, async () => { - const result = await gatsbyImageDataResolver( + const result = await gatsbyImageResolver( { ...portraitSource, width: 2000, @@ -311,7 +311,7 @@ describe(`gatsbyImageData`, () => { }) it(`should return proper srcSet from outputPixelDensities`, async () => { - const fixedResult = await gatsbyImageDataResolver( + const fixedResult = await gatsbyImageResolver( portraitSource, { layout: `fixed`, @@ -321,7 +321,7 @@ describe(`gatsbyImageData`, () => { }, store ) - const constrainedResult = await gatsbyImageDataResolver( + const constrainedResult = await gatsbyImageResolver( portraitSource, { layout: `constrained`, @@ -331,7 +331,7 @@ describe(`gatsbyImageData`, () => { }, store ) - const fullWidthResult = await gatsbyImageDataResolver( + const fullWidthResult = await gatsbyImageResolver( { ...portraitSource, width: 2000, @@ -394,7 +394,7 @@ describe(`gatsbyImageData`, () => { width: 2000, height: 3206, } - const fixedResult = await gatsbyImageDataResolver( + const fixedResult = await gatsbyImageResolver( biggerPortraitSource, { layout: `fixed`, @@ -404,7 +404,7 @@ describe(`gatsbyImageData`, () => { }, store ) - const constrainedResult = await gatsbyImageDataResolver( + const constrainedResult = await gatsbyImageResolver( biggerPortraitSource, { layout: `constrained`, @@ -414,7 +414,7 @@ describe(`gatsbyImageData`, () => { }, store ) - const fullWidthResult = await gatsbyImageDataResolver( + const fullWidthResult = await gatsbyImageResolver( biggerPortraitSource, { layout: `fullWidth`, @@ -444,9 +444,9 @@ describe(`gatsbyImageData`, () => { it(`should generate dominant color placeholder by default`, async () => { fetchRemoteFile.mockResolvedValueOnce( - path.join(__dirname, `..`, `__fixtures__`, `dog-portrait.jpg`) + path.join(__dirname, `__fixtures__`, `dog-portrait.jpg`) ) - const fixedResult = await gatsbyImageDataResolver( + const fixedResult = await gatsbyImageResolver( portraitSource, { layout: `fixed`, @@ -461,9 +461,9 @@ describe(`gatsbyImageData`, () => { it(`should generate base64 placeholder`, async () => { fetchRemoteFile.mockResolvedValueOnce( - path.join(__dirname, `..`, `__fixtures__`, `dog-portrait.jpg`) + path.join(__dirname, `__fixtures__`, `dog-portrait.jpg`) ) - const fixedResult = await gatsbyImageDataResolver( + const fixedResult = await gatsbyImageResolver( portraitSource, { layout: `fixed`, diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts index 6b5faa77943c4..18f208afff0fa 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts @@ -3,7 +3,6 @@ import type { Store } from "gatsby" import importFrom from "import-from" import { publicUrlResolver } from "../index" import * as dispatchers from "../jobs/dispatchers" -// import { fetchRemoteFile } from "gatsby-core-utils/fetch-remote-file" jest.spyOn(dispatchers, `shouldDispatch`).mockImplementation(() => false) jest.mock(`import-from`) @@ -56,7 +55,11 @@ describe(`publicResolver`, () => { createJobV2: jest.fn(() => jest.fn()), } dispatchers.shouldDispatch.mockImplementationOnce(() => true) - importFrom.mockImplementation(() => actions) + importFrom.mockImplementation(() => { + return { + actions, + } + }) const source = { id: `1`, @@ -94,7 +97,9 @@ describe(`publicResolver`, () => { createJobV2: jest.fn(() => jest.fn()), } dispatchers.shouldDispatch.mockImplementationOnce(() => true) - importFrom.mockImplementation(() => actions) + importFrom.mockImplementation(() => { + return { actions } + }) const source = { id: `1`, diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts index 17b597d67120b..b872277169d72 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts @@ -172,8 +172,7 @@ describe(`resizeResolver`, () => { ) const [, , , , args] = result?.src.split(`/`) ?? [] - const [transformArgs] = args.split(`.`) - const transformAsArgs = Buffer.from(transformArgs, `base64`).toString() + const transformAsArgs = Buffer.from(args, `base64`).toString() expect(transformAsArgs).toContain(`fit=crop`) expect(transformAsArgs).toContain(`crop=top,left`) }) @@ -200,7 +199,7 @@ describe(`resizeResolver`, () => { store ) - const [, , , url, args] = result?.src.split(`/`) ?? [] + const [, , , url, args, filename] = result?.src.split(`/`) ?? [] const [transformArgs] = args.split(`.`) expect(Buffer.from(url, `base64`).toString()).toBe(source.url) expect(Buffer.from(transformArgs, `base64`).toString()).toBe( @@ -208,6 +207,7 @@ describe(`resizeResolver`, () => { ) expect(result?.width).toBe(expected.widthOnly[0]) expect(result?.height).toBe(expected.widthOnly[1]) + expect(filename).toBe(source.filename) }) it(`should resize an image when height is given`, async () => { @@ -287,7 +287,9 @@ describe(`resizeResolver`, () => { createJobV2: jest.fn(() => jest.fn()), } dispatchers.shouldDispatch.mockImplementationOnce(() => true) - importFrom.mockImplementation(() => actions) + importFrom.mockImplementation(() => { + return { actions } + }) resizeResolver(portraitSource, { width: 100 }, store) expect(actions.createJobV2).toHaveBeenCalledWith( diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts similarity index 99% rename from packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts rename to packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts index b96604a8e3d05..13e40c4d1a74d 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts @@ -72,7 +72,7 @@ const DEFAULT_PIXEL_DENSITIES = [0.25, 0.5, 1, 2] const DEFAULT_BREAKPOINTS = [750, 1080, 1366, 1920] const DEFAULT_QUALITY = 75 -export async function gatsbyImageDataResolver( +export async function gatsbyImageResolver( source: IRemoteFileNode, args: IGatsbyImageDataArgs, store: Store @@ -233,7 +233,7 @@ export async function gatsbyImageDataResolver( } } -export function generateGatsbyImageDataFieldConfig( +export function generateGatsbyImageFieldConfig( enums: ReturnType, store: Store ): IGraphQLFieldConfigDefinition< diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts index f398352bc0cc7..80addced39df3 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts @@ -13,9 +13,9 @@ import { resizeResolver, } from "./graphql/resize-resolver" import { - generateGatsbyImageDataFieldConfig, - gatsbyImageDataResolver, -} from "./graphql/gatsby-image-data-resolver" + generateGatsbyImageFieldConfig, + gatsbyImageResolver, +} from "./graphql/gatsby-image-resolver" import type { Store } from "gatsby" import type { InterfaceTypeComposerAsObjectDefinition } from "graphql-compose" @@ -36,7 +36,7 @@ export function getRemoteFileFields( height: `Int`, publicUrl: generatePublicUrlFieldConfig(store), resize: generateResizeFieldConfig(enums, store), - gatsbyImage: generateGatsbyImageDataFieldConfig(enums, store), + gatsbyImage: generateGatsbyImageFieldConfig(enums, store), } } @@ -150,7 +150,7 @@ export { polyfillImageServiceDevRoutes, addImageRoutes } from "./http-routes" export { getRemoteFileEnums, addRemoteFilePolyfillInterface, - gatsbyImageDataResolver, + gatsbyImageResolver, resizeResolver, publicUrlResolver, isImageCdnEnabled, diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts index e4e1e4dfa56c6..1e6fa6248f101 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts @@ -98,7 +98,7 @@ export function dispatchLocalImageServiceJob( publicUrl.unshift(`public`) // We need to use import-from to remove circular dependency - const actions = importFrom( + const { actions } = importFrom( global.__GATSBY?.root ?? process.cwd(), `gatsby/dist/redux/actions` ) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/tester.js b/packages/gatsby-plugin-utils/src/polyfill-remote-file/tester.js deleted file mode 100644 index 70f62126684c5..0000000000000 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/tester.js +++ /dev/null @@ -1,16 +0,0 @@ -const path = require(`path`) -const sharp = require(`sharp`) -const fit = [`cover`, `fill`, `outside`, `contain`] - -const pipeline = sharp(path.join(__dirname, `./__fixtures__/dog-landscape.jpg`)) - -for (const f of fit) { - pipeline.resize(300, 300, { - fit: f, - }) - - pipeline.toFile(path.join(__dirname, `./__fixtures__/dog-300-${f}.jpg`)) -} - -// pipeline.resize(300, 300).jpeg({ quality: 75 }).png({ quality: 75 }) -// pipeline.toFormat(`webp`).toFile(path.join(__dirname, `./__fixtures__/dog-300.webp`)) From ea94099d338bf04f68fb5029ab32cb233f3447c2 Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Fri, 25 Feb 2022 15:17:57 +0100 Subject: [PATCH 22/46] fix e2e-tests --- .../remote-file/gatsby-plugin-image.js | 4 +++- e2e-tests/development-runtime/package.json | 24 +++++++++---------- .../src/pages/remote-file.js | 6 ++--- e2e-tests/production-runtime/package.json | 6 ++--- .../graphql/gatsby-image-resolver.ts | 6 ++--- .../polyfill-remote-file/jobs/dispatchers.ts | 4 ++-- 6 files changed, 26 insertions(+), 24 deletions(-) diff --git a/e2e-tests/development-runtime/cypress/integration/remote-file/gatsby-plugin-image.js b/e2e-tests/development-runtime/cypress/integration/remote-file/gatsby-plugin-image.js index 06581bb5d7277..a9f8dc17a5cc8 100644 --- a/e2e-tests/development-runtime/cypress/integration/remote-file/gatsby-plugin-image.js +++ b/e2e-tests/development-runtime/cypress/integration/remote-file/gatsby-plugin-image.js @@ -75,7 +75,9 @@ describe(`remote-file`, () => { .should("have.prop", "tagName", "IMG") cy.get(".constrained [data-placeholder-image]") .first() - .should("contain.prop", "src", "data:image/jpg;base64") + .should($el => { + expect($el.src).to.contain("data:image/jpg;base64") + }) cy.get(".full [data-placeholder-image]").first().should("be.empty") }) }) diff --git a/e2e-tests/development-runtime/package.json b/e2e-tests/development-runtime/package.json index 59e997e5ac34f..3de4ffdd59062 100644 --- a/e2e-tests/development-runtime/package.json +++ b/e2e-tests/development-runtime/package.json @@ -5,22 +5,22 @@ "author": "Dustin Schau ", "dependencies": { "babel-plugin-search-and-replace": "^1.1.0", - "gatsby": "^4.6.0-next.4", + "gatsby": "4.9.0-next.0-dev-1645793430687", "gatsby-image": "^3.0.0-next.0", - "gatsby-plugin-image": "^1.0.0-next.5", + "gatsby-plugin-image": "2.9.0-next.0-dev-1645793430687", "gatsby-plugin-less": "^5.1.0-next.2", - "gatsby-plugin-manifest": "^3.0.0-next.0", - "gatsby-plugin-offline": "^4.0.0-next.1", + "gatsby-plugin-manifest": "4.9.0-next.0-dev-1645793430687", + "gatsby-plugin-offline": "5.9.0-next.0-dev-1645793430687", "gatsby-plugin-react-helmet": "^4.0.0-next.0", - "gatsby-plugin-sass": "^4.1.0-next.2", - "gatsby-plugin-sharp": "^3.0.0-next.5", + "gatsby-plugin-sass": "5.9.0-next.0-dev-1645793430687", + "gatsby-plugin-sharp": "4.9.0-next.0-dev-1645793430687", "gatsby-plugin-stylus": "^3.1.0-next.2", - "gatsby-remark-images": "^6.6.0-next.2", + "gatsby-remark-images": "6.9.0-next.0-dev-1645793430687", "gatsby-seo": "^0.1.0", - "gatsby-source-filesystem": "^3.0.0-next.2", + "gatsby-source-filesystem": "4.9.0-next.0-dev-1645793430687", "gatsby-transformer-json": "^3.0.0-next.0", - "gatsby-transformer-remark": "^5.6.0-next.2", - "gatsby-transformer-sharp": "^3.0.0-next.1", + "gatsby-transformer-remark": "5.9.0-next.0-dev-1645793430687", + "gatsby-transformer-sharp": "4.9.0-next.0-dev-1645793430687", "node-fetch": "^2.6.1", "prop-types": "^15.6.2", "react": "16.9.0", @@ -59,8 +59,8 @@ "cypress": "6.1.0", "cypress-image-snapshot": "^4.0.1", "fs-extra": "^7.0.1", - "gatsby-core-utils": "^2.12.0", - "gatsby-cypress": "^0.1.7", + "gatsby-core-utils": "3.9.0-next.0-dev-1645793430687", + "gatsby-cypress": "2.9.0-next.0-dev-1645793430687", "is-ci": "^2.0.0", "prettier": "2.0.4", "start-server-and-test": "^1.7.11", diff --git a/e2e-tests/development-runtime/src/pages/remote-file.js b/e2e-tests/development-runtime/src/pages/remote-file.js index a355708493dd5..d5e2a35a312be 100644 --- a/e2e-tests/development-runtime/src/pages/remote-file.js +++ b/e2e-tests/development-runtime/src/pages/remote-file.js @@ -54,17 +54,17 @@ export const pageQuery = graphql` width src } - fixed: gatsbyImageData( + fixed: gatsbyImage( layout: FIXED width: 100 placeholder: DOMINANT_COLOR ) - constrained: gatsbyImageData( + constrained: gatsbyImage( layout: CONSTRAINED width: 300 placeholder: BLURRED ) - full: gatsbyImageData(layout: FULL_WIDTH, width: 500, placeholder: NONE) + full: gatsbyImage(layout: FULL_WIDTH, width: 500, placeholder: NONE) } } } diff --git a/e2e-tests/production-runtime/package.json b/e2e-tests/production-runtime/package.json index fdc5c61ece983..d441f3d7c6c38 100644 --- a/e2e-tests/production-runtime/package.json +++ b/e2e-tests/production-runtime/package.json @@ -6,15 +6,15 @@ "dependencies": { "babel-plugin-search-and-replace": "^1.1.0", "cypress": "^6.5.0", - "gatsby": "^4.1.6", + "gatsby": "4.9.0-next.0-dev-1645798295538", "gatsby-cypress": "^2.1.0", "gatsby-plugin-image": "^2.1.3", "gatsby-plugin-less": "^6.1.0", - "gatsby-plugin-manifest": "^4.1.4", + "gatsby-plugin-manifest": "4.9.0-next.0-dev-1645798295538", "gatsby-plugin-offline": "^5.1.4", "gatsby-plugin-react-helmet": "^5.1.0", "gatsby-plugin-sass": "^5.1.1", - "gatsby-plugin-sharp": "^4.1.4", + "gatsby-plugin-sharp": "4.9.0-next.0-dev-1645798295538", "gatsby-plugin-stylus": "^4.1.0", "gatsby-seo": "^0.1.0", "gatsby-source-filesystem": "^4.1.3", diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts index 13e40c4d1a74d..a9bb516610736 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts @@ -238,7 +238,7 @@ export function generateGatsbyImageFieldConfig( store: Store ): IGraphQLFieldConfigDefinition< IRemoteFileNode | IRemoteImageNode, - ReturnType, + ReturnType, IGatsbyImageDataArgs > { return { @@ -339,8 +339,8 @@ export function generateGatsbyImageFieldConfig( defaultValue: DEFAULT_QUALITY, }, }, - resolve(source, args): ReturnType { - return gatsbyImageDataResolver(source, args, store) + resolve(source, args): ReturnType { + return gatsbyImageResolver(source, args, store) }, } } diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts index 1e6fa6248f101..b5a8639a1c252 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts @@ -40,7 +40,7 @@ export function dispatchLocalFileServiceJob( const { actions } = importFrom( global.__GATSBY?.root ?? process.cwd(), `gatsby/dist/redux/actions` - ) as any + ) as { actions: { createJobV2: (...args: any) => void } } // @ts-ignore - we dont have correct typings for this actions.createJobV2( @@ -101,7 +101,7 @@ export function dispatchLocalImageServiceJob( const { actions } = importFrom( global.__GATSBY?.root ?? process.cwd(), `gatsby/dist/redux/actions` - ) + ) as { actions: { createJobV2: (...args: any) => void } } // @ts-ignore - importFrom doesn't work with types actions.createJobV2( { From d1faf53da2436d1d20e6fa9a0b470a84427b2ebb Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Fri, 25 Feb 2022 18:03:53 +0100 Subject: [PATCH 23/46] fix test --- .../gatsby/src/schema/__tests__/__snapshots__/print.js.snap | 6 +++--- .../src/schema/types/__tests__/remote-file-interface.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/gatsby/src/schema/__tests__/__snapshots__/print.js.snap b/packages/gatsby/src/schema/__tests__/__snapshots__/print.js.snap index 373a207b9d5fe..51fd093cb8ea9 100644 --- a/packages/gatsby/src/schema/__tests__/__snapshots__/print.js.snap +++ b/packages/gatsby/src/schema/__tests__/__snapshots__/print.js.snap @@ -84,7 +84,7 @@ interface RemoteFile { cropFocus: [RemoteFileCropFocus] quality: Int = 75 ): RemoteFileResize - gatsbyImageData( + gatsbyImage( \\"\\"\\" The layout for the image. @@ -467,7 +467,7 @@ interface RemoteFile { cropFocus: [RemoteFileCropFocus] quality: Int = 75 ): RemoteFileResize - gatsbyImageData( + gatsbyImage( \\"\\"\\" The layout for the image. @@ -860,7 +860,7 @@ interface RemoteFile { cropFocus: [RemoteFileCropFocus] quality: Int = 75 ): RemoteFileResize - gatsbyImageData( + gatsbyImage( \\"\\"\\" The layout for the image. diff --git a/packages/gatsby/src/schema/types/__tests__/remote-file-interface.ts b/packages/gatsby/src/schema/types/__tests__/remote-file-interface.ts index d348d6e29a46d..5b8d97808d248 100644 --- a/packages/gatsby/src/schema/types/__tests__/remote-file-interface.ts +++ b/packages/gatsby/src/schema/types/__tests__/remote-file-interface.ts @@ -138,7 +138,7 @@ describe(`remote-file`, () => { beforeAll(() => { const fields = schema.getType(`MyAsset`).getFields() - gatsbyImageData = fields.gatsbyImageData.resolve + gatsbyImageData = fields.gatsbyImage.resolve }) it(`should get the correct fixed sizes`, async () => { From f7271e3d2f44ceac07d52d863275e36d150f3e60 Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Fri, 25 Feb 2022 18:14:30 +0100 Subject: [PATCH 24/46] fix production e2e? --- .../src/schema/graphql-engine/bundle-webpack.ts | 2 +- .../src/schema/graphql-engine/lmdb-bundling-patch.ts | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/gatsby/src/schema/graphql-engine/bundle-webpack.ts b/packages/gatsby/src/schema/graphql-engine/bundle-webpack.ts index 2d20e072271fe..49c155e3ba781 100644 --- a/packages/gatsby/src/schema/graphql-engine/bundle-webpack.ts +++ b/packages/gatsby/src/schema/graphql-engine/bundle-webpack.ts @@ -71,7 +71,7 @@ export async function createGraphqlEngineBundle( module: { rules: [ { - test: require.resolve(`lmdb`), + test: /node_modules[/\\]lmdb[/\\]/, parser: { amd: false }, use: [ { diff --git a/packages/gatsby/src/schema/graphql-engine/lmdb-bundling-patch.ts b/packages/gatsby/src/schema/graphql-engine/lmdb-bundling-patch.ts index 06241aa2b68d0..ab4dbc82c4c7a 100644 --- a/packages/gatsby/src/schema/graphql-engine/lmdb-bundling-patch.ts +++ b/packages/gatsby/src/schema/graphql-engine/lmdb-bundling-patch.ts @@ -1,4 +1,6 @@ +/* eslint-disable @babel/no-invalid-this */ import { createRequireFromPath } from "gatsby-core-utils" +import path from "path" // This is hacky webpack loader that does string replacements to // allow lmdb@2 to be bundled by webpack for engines. @@ -19,13 +21,13 @@ import { createRequireFromPath } from "gatsby-core-utils" export default function (source: string): string { let lmdbBinaryLocation try { - const lmdbRequire = createRequireFromPath(require.resolve(`lmdb`)) + const lmdbRoot = + this?._module.resourceResolveData?.descriptionFileRoot || + path.dirname(this.resourcePath).replace(`/dist`, ``) + const lmdbRequire = createRequireFromPath(lmdbRoot) const nodeGypBuild = lmdbRequire(`node-gyp-build`) - const path = require(`path`) - lmdbBinaryLocation = nodeGypBuild.path( - path.dirname(require.resolve(`lmdb`)).replace(`/dist`, ``) - ) + lmdbBinaryLocation = nodeGypBuild.path(lmdbRoot) } catch (e) { return source } From 457b758ef82516919c125ecf7acae967300c47cd Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Fri, 25 Feb 2022 19:00:08 +0100 Subject: [PATCH 25/46] fix bootstrap --- .../gatsby/src/schema/graphql-engine/lmdb-bundling-patch.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/gatsby/src/schema/graphql-engine/lmdb-bundling-patch.ts b/packages/gatsby/src/schema/graphql-engine/lmdb-bundling-patch.ts index ab4dbc82c4c7a..39a379395334c 100644 --- a/packages/gatsby/src/schema/graphql-engine/lmdb-bundling-patch.ts +++ b/packages/gatsby/src/schema/graphql-engine/lmdb-bundling-patch.ts @@ -1,4 +1,5 @@ /* eslint-disable @babel/no-invalid-this */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { createRequireFromPath } from "gatsby-core-utils" import path from "path" @@ -18,8 +19,9 @@ import path from "path" // - https://github.com/DoctorEvidence/lmdb-js/blob/544b3fda402f24a70a0e946921e4c9134c5adf85/open.js#L77 // Reliance on `import.meta.url` + usage of `.replace` is what seems to cause problems currently. -export default function (source: string): string { - let lmdbBinaryLocation +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export default function (this: any, source: string): string { + let lmdbBinaryLocation: string | undefined try { const lmdbRoot = this?._module.resourceResolveData?.descriptionFileRoot || From 96ff8a14549f043e15a5ab033a8d0c85d3b97e88 Mon Sep 17 00:00:00 2001 From: veryspry Date: Fri, 25 Feb 2022 14:13:56 -0600 Subject: [PATCH 26/46] fix: incorrect error string --- .../src/polyfill-remote-file/graphql/gatsby-image-resolver.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts index a9bb516610736..a1fb3e94990fc 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts @@ -93,7 +93,9 @@ export async function gatsbyImageResolver( } if (!args.width && !args.height) { - throw new Error(`The "layout" argument is required for "${source.url}"`) + throw new Error(` + Either the "width" or "height" argument is required for "${source.url}" + `) } if (!args.formats) { From be6ecaac15c37b14581f70c3b7ea9a0bfcc37187 Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Fri, 25 Feb 2022 22:32:05 +0100 Subject: [PATCH 27/46] add npm-run-all? --- packages/gatsby-sharp/package.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/gatsby-sharp/package.json b/packages/gatsby-sharp/package.json index 5bc3366466163..ca62cabccaa96 100644 --- a/packages/gatsby-sharp/package.json +++ b/packages/gatsby-sharp/package.json @@ -21,7 +21,9 @@ "@babel/cli": "^7.15.5", "@babel/core": "^7.15.5", "babel-plugin-replace-ts-export-assignment": "^0.0.2", - "cross-env": "^7.0.3" + "cross-env": "^7.0.3", + "npm-run-all": "4.1.5", + "typescript": "^4.5.5" }, "engines": { "node": ">=14.15.0" @@ -38,4 +40,4 @@ "prepare": "cross-env NODE_ENV=production npm-run-all -s build typegen", "watch": "babel -w src --out-file dist/index.js --ignore \"**/__tests__\" --extensions \".ts,.js\"" } -} +} \ No newline at end of file From 18846dc4d2f90643bd90c9169ceb811ef57b6f05 Mon Sep 17 00:00:00 2001 From: Tyler Barnes Date: Fri, 25 Feb 2022 16:49:40 -0800 Subject: [PATCH 28/46] Add gatsbyImage field description --- .../src/polyfill-remote-file/graphql/gatsby-image-resolver.ts | 1 + packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts index a1fb3e94990fc..9140f363e45f9 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts @@ -245,6 +245,7 @@ export function generateGatsbyImageFieldConfig( > { return { type: `JSON`, + description: `Data used in the component. See https://gatsby.dev/img for more info.`, args: { layout: { type: enums.layout.getTypeName(), diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts index cadcb26770d80..c09d5aa0339fb 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts @@ -25,6 +25,7 @@ export interface IGraphQLFieldConfigDefinition< TArgs = Record > { type: string + description: string args?: { [Property in keyof TArgs]: | GraphqlType From d90fff79c664aa63c75d8b192457a7a59841f65f Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Sun, 27 Feb 2022 12:49:44 +0100 Subject: [PATCH 29/46] fix typings --- packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts index c09d5aa0339fb..3d3d2bc956f32 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts @@ -25,7 +25,7 @@ export interface IGraphQLFieldConfigDefinition< TArgs = Record > { type: string - description: string + description?: string args?: { [Property in keyof TArgs]: | GraphqlType From 309f866c44e8f49c3edd5a13fb5d8d9c8579c0a1 Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Sun, 27 Feb 2022 22:32:38 +0100 Subject: [PATCH 30/46] fix e2e tests --- .../remote-file/gatsby-plugin-image.js | 26 +++++++++++-------- .../__tests__/__snapshots__/print.js.snap | 12 +++++++++ .../schema/graphql-engine/bundle-webpack.ts | 4 +-- .../graphql-engine/lmdb-bundling-patch.ts | 11 +++++--- 4 files changed, 37 insertions(+), 16 deletions(-) diff --git a/e2e-tests/development-runtime/cypress/integration/remote-file/gatsby-plugin-image.js b/e2e-tests/development-runtime/cypress/integration/remote-file/gatsby-plugin-image.js index a9f8dc17a5cc8..33f4f2dbc7938 100644 --- a/e2e-tests/development-runtime/cypress/integration/remote-file/gatsby-plugin-image.js +++ b/e2e-tests/development-runtime/cypress/integration/remote-file/gatsby-plugin-image.js @@ -2,14 +2,17 @@ before(() => { cy.exec(`npm run reset`) }) -after(() => { - cy.exec(`npm run reset`) -}) - describe(`remote-file`, () => { - it(`should render correct dimensions`, () => { + beforeEach(() => { cy.visit(`/remote-file/`).waitForRouteChange() + // trigger intersection observer + cy.scrollTo("top") + cy.wait(100) + cy.scrollTo("bottom") + }) + + it(`should render correct dimensions`, () => { cy.get('[data-testid="public"]').then($urls => { const urls = Array.from($urls.map((_, $url) => $url.getAttribute("href"))) @@ -65,19 +68,20 @@ describe(`remote-file`, () => { }) it(`should render a placeholder`, () => { - cy.visit(`/remote-file/`).waitForRouteChange() - cy.get(".fixed [data-placeholder-image]") .first() .should("have.css", "background-color", "rgb(232, 184, 8)") cy.get(".constrained [data-placeholder-image]") .first() - .should("have.prop", "tagName", "IMG") - cy.get(".constrained [data-placeholder-image]") + .should($el => { + expect($el.prop("tagName")).to.be.equal("IMG") + expect($el.prop("src")).to.contain("data:image/jpg;base64") + }) + cy.get(".full [data-placeholder-image]") .first() .should($el => { - expect($el.src).to.contain("data:image/jpg;base64") + expect($el.prop("tagName")).to.be.equal("DIV") + expect($el).to.be.empty }) - cy.get(".full [data-placeholder-image]").first().should("be.empty") }) }) diff --git a/packages/gatsby/src/schema/__tests__/__snapshots__/print.js.snap b/packages/gatsby/src/schema/__tests__/__snapshots__/print.js.snap index 51fd093cb8ea9..f31dab46ecc63 100644 --- a/packages/gatsby/src/schema/__tests__/__snapshots__/print.js.snap +++ b/packages/gatsby/src/schema/__tests__/__snapshots__/print.js.snap @@ -84,6 +84,10 @@ interface RemoteFile { cropFocus: [RemoteFileCropFocus] quality: Int = 75 ): RemoteFileResize + + \\"\\"\\" + Data used in the component. See https://gatsby.dev/img for more info. + \\"\\"\\" gatsbyImage( \\"\\"\\" @@ -467,6 +471,10 @@ interface RemoteFile { cropFocus: [RemoteFileCropFocus] quality: Int = 75 ): RemoteFileResize + + \\"\\"\\" + Data used in the component. See https://gatsby.dev/img for more info. + \\"\\"\\" gatsbyImage( \\"\\"\\" @@ -860,6 +868,10 @@ interface RemoteFile { cropFocus: [RemoteFileCropFocus] quality: Int = 75 ): RemoteFileResize + + \\"\\"\\" + Data used in the component. See https://gatsby.dev/img for more info. + \\"\\"\\" gatsbyImage( \\"\\"\\" diff --git a/packages/gatsby/src/schema/graphql-engine/bundle-webpack.ts b/packages/gatsby/src/schema/graphql-engine/bundle-webpack.ts index 49c155e3ba781..b032ae1d566f4 100644 --- a/packages/gatsby/src/schema/graphql-engine/bundle-webpack.ts +++ b/packages/gatsby/src/schema/graphql-engine/bundle-webpack.ts @@ -71,7 +71,7 @@ export async function createGraphqlEngineBundle( module: { rules: [ { - test: /node_modules[/\\]lmdb[/\\]/, + test: /node_modules[/\\]lmdb[/\\].*\.[cm]?js/, parser: { amd: false }, use: [ { @@ -108,7 +108,7 @@ export async function createGraphqlEngineBundle( }, { // For node binary relocations, include ".node" files as well here - test: /\.(m?js|node)$/, + test: /\.([cm]?js|node)$/, // it is recommended for Node builds to turn off AMD support parser: { amd: false }, use: { diff --git a/packages/gatsby/src/schema/graphql-engine/lmdb-bundling-patch.ts b/packages/gatsby/src/schema/graphql-engine/lmdb-bundling-patch.ts index 39a379395334c..7ebf36706e879 100644 --- a/packages/gatsby/src/schema/graphql-engine/lmdb-bundling-patch.ts +++ b/packages/gatsby/src/schema/graphql-engine/lmdb-bundling-patch.ts @@ -1,6 +1,7 @@ /* eslint-disable @babel/no-invalid-this */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { createRequireFromPath } from "gatsby-core-utils" +import { createRequireFromPath } from "gatsby-core-utils/create-require-from-path" +import { slash } from "gatsby-core-utils/path" import path from "path" // This is hacky webpack loader that does string replacements to @@ -29,11 +30,15 @@ export default function (this: any, source: string): string { const lmdbRequire = createRequireFromPath(lmdbRoot) const nodeGypBuild = lmdbRequire(`node-gyp-build`) - lmdbBinaryLocation = nodeGypBuild.path(lmdbRoot) + lmdbBinaryLocation = slash( + path.relative( + path.dirname(this.resourcePath), + nodeGypBuild.path(lmdbRoot) + ) + ) } catch (e) { return source } - return source .replace( `require$1('node-gyp-build')(dirName)`, From 51188af7fcf8777d30345f4dfac770e8e710f5d5 Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Mon, 28 Feb 2022 10:43:46 +0100 Subject: [PATCH 31/46] update stuff --- .../remote-file/gatsby-plugin-image.js | 23 +++--- e2e-tests/production-runtime/gatsby-node.js | 65 ++++++++++++++++- e2e-tests/production-runtime/package.json | 24 +++---- .../src/pages/remote-file.js | 70 +++++++++++++++++++ .../src/fetch-remote-file.ts | 8 ++- .../src/remote-file-utils/fetch-file.ts | 1 + .../graphql/public-url-resolver.ts | 1 + .../polyfill-remote-file/jobs/dispatchers.ts | 21 ++++-- .../jobs/gatsby-worker.ts | 7 +- .../polyfill-remote-file/transform-images.ts | 4 +- 10 files changed, 188 insertions(+), 36 deletions(-) create mode 100644 e2e-tests/production-runtime/src/pages/remote-file.js diff --git a/e2e-tests/production-runtime/cypress/integration/remote-file/gatsby-plugin-image.js b/e2e-tests/production-runtime/cypress/integration/remote-file/gatsby-plugin-image.js index 06581bb5d7277..ee6ff0131d94c 100644 --- a/e2e-tests/production-runtime/cypress/integration/remote-file/gatsby-plugin-image.js +++ b/e2e-tests/production-runtime/cypress/integration/remote-file/gatsby-plugin-image.js @@ -2,14 +2,12 @@ before(() => { cy.exec(`npm run reset`) }) -after(() => { - cy.exec(`npm run reset`) -}) - describe(`remote-file`, () => { - it(`should render correct dimensions`, () => { + beforeEach(() => { cy.visit(`/remote-file/`).waitForRouteChange() + }) + it(`should render correct dimensions`, () => { cy.get('[data-testid="public"]').then($urls => { const urls = Array.from($urls.map((_, $url) => $url.getAttribute("href"))) @@ -65,17 +63,20 @@ describe(`remote-file`, () => { }) it(`should render a placeholder`, () => { - cy.visit(`/remote-file/`).waitForRouteChange() - cy.get(".fixed [data-placeholder-image]") .first() .should("have.css", "background-color", "rgb(232, 184, 8)") cy.get(".constrained [data-placeholder-image]") .first() - .should("have.prop", "tagName", "IMG") - cy.get(".constrained [data-placeholder-image]") + .should($el => { + expect($el.prop("tagName")).to.be.equal("IMG") + expect($el.prop("src")).to.contain("data:image/jpg;base64") + }) + cy.get(".full [data-placeholder-image]") .first() - .should("contain.prop", "src", "data:image/jpg;base64") - cy.get(".full [data-placeholder-image]").first().should("be.empty") + .should($el => { + expect($el.prop("tagName")).to.be.equal("DIV") + expect($el).to.be.empty + }) }) }) diff --git a/e2e-tests/production-runtime/gatsby-node.js b/e2e-tests/production-runtime/gatsby-node.js index 3c264cefe4796..b1d3af9e618f8 100644 --- a/e2e-tests/production-runtime/gatsby-node.js +++ b/e2e-tests/production-runtime/gatsby-node.js @@ -1,6 +1,10 @@ const path = require(`path`) const fs = require(`fs-extra`) const { createContentDigest } = require(`gatsby-core-utils`) +const { + addRemoteFilePolyfillInterface, + polyfillImageServiceDevRoutes, +} = require("gatsby-plugin-utils/polyfill-remote-file") exports.onPreBootstrap = () => { fs.copyFileSync( @@ -9,7 +13,7 @@ exports.onPreBootstrap = () => { ) } -exports.createSchemaCustomization = ({ actions }) => { +exports.createSchemaCustomization = ({ actions, schema, store }) => { const { createTypes } = actions const typeDefs = ` type Product implements Node { @@ -17,6 +21,20 @@ exports.createSchemaCustomization = ({ actions }) => { } ` createTypes(typeDefs) + + actions.createTypes( + addRemoteFilePolyfillInterface( + schema.buildObjectType({ + name: "MyRemoteFile", + fields: {}, + interfaces: ["Node", "RemoteFile"], + }), + { + store, + schema, + } + ) + ) } const products = ["Burger", "Chicken"] @@ -34,6 +52,51 @@ exports.sourceNodes = ({ actions, createNodeId }) => { name: product, }) }) + + const items = [ + { + name: "photoA.jpg", + url: + "https://images.unsplash.com/photo-1517849845537-4d257902454a?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2000&q=80", + placeholderUrl: + "https://images.unsplash.com/photo-1517849845537-4d257902454a?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=%width%&h=%height%", + mimeType: "image/jpg", + filename: "photo-1517849845537.jpg", + width: 2000, + height: 2667, + }, + { + name: "photoB.jpg", + url: + "https://images.unsplash.com/photo-1552053831-71594a27632d?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&h=2000&q=10", + mimeType: "image/jpg", + filename: "photo-1552053831.jpg", + width: 1247, + height: 2000, + }, + { + name: "photoC.jpg", + url: + "https://images.unsplash.com/photo-1561037404-61cd46aa615b?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2000&q=80", + placeholderUrl: + "https://images.unsplash.com/photo-1561037404-61cd46aa615b?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=%width%&h=%height%", + mimeType: "image/jpg", + filename: "photo-1561037404.jpg", + width: 2000, + height: 1333, + }, + ] + + items.forEach((item, index) => { + actions.createNode({ + id: createNodeId(`remote-file-${index}`), + ...item, + internal: { + type: "MyRemoteFile", + contentDigest: createContentDigest(item.url), + }, + }) + }) } exports.createPages = ({ actions: { createPage, createRedirect } }) => { diff --git a/e2e-tests/production-runtime/package.json b/e2e-tests/production-runtime/package.json index d441f3d7c6c38..3ff990cdedba3 100644 --- a/e2e-tests/production-runtime/package.json +++ b/e2e-tests/production-runtime/package.json @@ -6,18 +6,18 @@ "dependencies": { "babel-plugin-search-and-replace": "^1.1.0", "cypress": "^6.5.0", - "gatsby": "4.9.0-next.0-dev-1645798295538", - "gatsby-cypress": "^2.1.0", - "gatsby-plugin-image": "^2.1.3", - "gatsby-plugin-less": "^6.1.0", - "gatsby-plugin-manifest": "4.9.0-next.0-dev-1645798295538", - "gatsby-plugin-offline": "^5.1.4", - "gatsby-plugin-react-helmet": "^5.1.0", - "gatsby-plugin-sass": "^5.1.1", - "gatsby-plugin-sharp": "4.9.0-next.0-dev-1645798295538", - "gatsby-plugin-stylus": "^4.1.0", + "gatsby": "4.9.0-next.0-dev-1645962870806", + "gatsby-cypress": "2.9.0-next.0-dev-1645962870806", + "gatsby-plugin-image": "2.9.0-next.0-dev-1645962870806", + "gatsby-plugin-less": "6.9.0-next.0-dev-1645962870806", + "gatsby-plugin-manifest": "4.9.0-next.0-dev-1645962870806", + "gatsby-plugin-offline": "5.9.0-next.0-dev-1645962870806", + "gatsby-plugin-react-helmet": "5.9.0-next.0-dev-1645962870806", + "gatsby-plugin-sass": "5.9.0-next.0-dev-1645962870806", + "gatsby-plugin-sharp": "4.9.0-next.0-dev-1645962870806", + "gatsby-plugin-stylus": "4.9.0-next.0-dev-1645962870806", "gatsby-seo": "^0.1.0", - "gatsby-source-filesystem": "^4.1.3", + "gatsby-source-filesystem": "4.9.0-next.0-dev-1645962870806", "glob": "^7.1.3", "react": "^16.9.0", "react-dom": "^16.9.0", @@ -52,7 +52,7 @@ "devDependencies": { "cross-env": "^5.2.0", "fs-extra": "^7.0.1", - "gatsby-core-utils": "^3.1.3", + "gatsby-core-utils": "3.9.0-next.0-dev-1645962870806", "is-ci": "^2.0.0", "prettier": "2.0.4", "start-server-and-test": "^1.7.1" diff --git a/e2e-tests/production-runtime/src/pages/remote-file.js b/e2e-tests/production-runtime/src/pages/remote-file.js new file mode 100644 index 0000000000000..27abdb31fcd0b --- /dev/null +++ b/e2e-tests/production-runtime/src/pages/remote-file.js @@ -0,0 +1,70 @@ +import { graphql } from "gatsby" +import React from "react" + +import { GatsbyImage } from "gatsby-plugin-image" +import Layout from "../components/layout" + +const RemoteFile = ({ data }) => { + return ( + + {data.allMyRemoteFile.nodes.map(node => { + return ( +
+

+ + {node.filename} + +

+ +
+ + + +
+
+ ) + })} +
+ ) +} + +export const pageQuery = graphql` + { + allMyRemoteFile { + nodes { + id + url + filename + publicUrl + resize(width: 100) { + height + width + src + } + fixed: gatsbyImage( + layout: FIXED + width: 100 + placeholder: DOMINANT_COLOR + ) + constrained: gatsbyImage( + layout: CONSTRAINED + width: 300 + placeholder: BLURRED + ) + full: gatsbyImage(layout: FULL_WIDTH, width: 500, placeholder: NONE) + } + } + } +` + +export default RemoteFile diff --git a/packages/gatsby-core-utils/src/fetch-remote-file.ts b/packages/gatsby-core-utils/src/fetch-remote-file.ts index e9dc4f59fa77d..2f4a2f893db5c 100644 --- a/packages/gatsby-core-utils/src/fetch-remote-file.ts +++ b/packages/gatsby-core-utils/src/fetch-remote-file.ts @@ -128,6 +128,7 @@ async function fetchFile({ ext, name, cacheKey, + excludeDigest, }: IFetchRemoteFileOptions): Promise { // global introduced in gatsby 4.0.0 const BUILD_ID = global.__GATSBY?.buildId ?? `` @@ -167,10 +168,13 @@ async function fetchFile({ } const digest = createContentDigest(url) - await fs.ensureDir(path.join(fileDirectory, digest)) + const finalDirectory = excludeDigest + ? fileDirectory + : path.join(fileDirectory, digest) + await fs.ensureDir(finalDirectory) const tmpFilename = createFilePath(fileDirectory, `tmp-${digest}`, ext) - let filename = createFilePath(path.join(fileDirectory, digest), name, ext) + let filename = createFilePath(finalDirectory, name, ext) // See if there's response headers for this url // from a previous request. diff --git a/packages/gatsby-core-utils/src/remote-file-utils/fetch-file.ts b/packages/gatsby-core-utils/src/remote-file-utils/fetch-file.ts index 1d715e49fd5cf..4f3594ff24c04 100644 --- a/packages/gatsby-core-utils/src/remote-file-utils/fetch-file.ts +++ b/packages/gatsby-core-utils/src/remote-file-utils/fetch-file.ts @@ -14,6 +14,7 @@ export type IFetchRemoteFileOptions = { ext?: string name?: string cacheKey?: string + excludeDigest?: boolean } & ( | { directory: string diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts index 11c8c0e181b87..4176a8156247e 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts @@ -14,6 +14,7 @@ export function publicUrlResolver( dispatchLocalFileServiceJob( { url: source.url, + filename: source.filename, mimeType: source.mimeType, contentDigest: source.internal.contentDigest, }, diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts index b5a8639a1c252..9b18f9de81937 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts @@ -18,9 +18,10 @@ export function shouldDispatch(): boolean { export function dispatchLocalFileServiceJob( { url, + filename, mimeType, contentDigest, - }: { url: string; mimeType: string; contentDigest: string }, + }: { url: string; filename: string; mimeType: string; contentDigest: string }, store: Store ): void { const GATSBY_VERSION = getGatsbyVersion() @@ -32,9 +33,7 @@ export function dispatchLocalFileServiceJob( }, false ).split(`/`) - const extension = getFileExtensionFromMimeType(mimeType) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const filename = publicUrl.pop() + publicUrl.unshift(`public`) const { actions } = importFrom( @@ -42,6 +41,13 @@ export function dispatchLocalFileServiceJob( `gatsby/dist/redux/actions` ) as { actions: { createJobV2: (...args: any) => void } } + console.log({ + output: path.join( + global.__GATSBY?.root || process.cwd(), + ...publicUrl.filter(Boolean) + ), + filename, + }) // @ts-ignore - we dont have correct typings for this actions.createJobV2( { @@ -50,11 +56,11 @@ export function dispatchLocalFileServiceJob( // we know it's an image so we just mimic an image outputDir: path.join( global.__GATSBY?.root || process.cwd(), - publicUrl.filter(Boolean).join(`/`) + ...publicUrl.filter(Boolean) ), args: { url, - filename: `${filename}.${extension}`, + filename, contentDigest, }, }, @@ -102,6 +108,7 @@ export function dispatchLocalImageServiceJob( global.__GATSBY?.root ?? process.cwd(), `gatsby/dist/redux/actions` ) as { actions: { createJobV2: (...args: any) => void } } + // @ts-ignore - importFrom doesn't work with types actions.createJobV2( { @@ -109,7 +116,7 @@ export function dispatchLocalImageServiceJob( inputPaths: [], outputDir: path.join( global.__GATSBY?.root || process.cwd(), - publicUrl.filter(Boolean).join(`/`), + ...publicUrl.filter(Boolean), generateImageArgs({ width, height, format, quality }) ), args: { diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/gatsby-worker.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/gatsby-worker.ts index 280ae39f4da5d..1a9a5066e648c 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/gatsby-worker.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/gatsby-worker.ts @@ -1,3 +1,4 @@ +import path from "path" import { fetchRemoteFile } from "gatsby-core-utils/fetch-remote-file" import { cpuCoreCount } from "gatsby-core-utils/cpu-core-count" import Queue from "fastq" @@ -30,11 +31,15 @@ export async function FILE_CDN({ outputDir: string args: { url: string; filename: string; contentDigest: string } }): Promise { + const ext = path.extname(filename) + await fetchRemoteFile({ directory: outputDir, url: url, - name: filename, + name: path.basename(filename, ext), + ext, cacheKey: contentDigest, + excludeDigest: true, }) } diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/transform-images.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/transform-images.ts index 07a98678efe4b..114c9feec5759 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/transform-images.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/transform-images.ts @@ -50,11 +50,11 @@ export async function transformImage({ directory: cache.directory, url: url, name: basename, - ext: `.${ext}`, + ext, cacheKey: contentDigest, }) - const outputPath = `${outputDir}/${filename}` + const outputPath = path.join(outputDir, filename) await mkdirp(path.dirname(outputPath)) // if the queue already contains the url, we're going to add it to queue so, we can batch the transforms together. From 80968705fe73f41f837b9e0ae8eeeab129d7dacc Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Mon, 28 Feb 2022 15:28:49 +0100 Subject: [PATCH 32/46] fix test --- .../src/polyfill-remote-file/jobs/dispatchers.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts index 9b18f9de81937..62178a2af2ab4 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts @@ -1,6 +1,5 @@ import path from "path" import importFrom from "import-from" -import { getFileExtensionFromMimeType } from "../utils/mime-type-helpers" import { getGatsbyVersion } from "../utils/get-gatsby-version" import { generatePublicUrl, generateImageArgs } from "../utils/url-generator" import type { Store } from "gatsby" From 2d96d1b9df94fb284fddf75faa9974ae87615d54 Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Tue, 1 Mar 2022 00:53:32 +0100 Subject: [PATCH 33/46] add test for gatsby-worker --- packages/gatsby-plugin-utils/package.json | 5 +- .../jobs/__tests__/gatsby-worker.ts | 54 +++++++++++++ .../polyfill-remote-file/jobs/dispatchers.ts | 7 -- yarn.lock | 75 +++++++++++++++---- 4 files changed, 118 insertions(+), 23 deletions(-) create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/__tests__/gatsby-worker.ts diff --git a/packages/gatsby-plugin-utils/package.json b/packages/gatsby-plugin-utils/package.json index 6200e4b226f50..2fe53f9416ca7 100644 --- a/packages/gatsby-plugin-utils/package.json +++ b/packages/gatsby-plugin-utils/package.json @@ -47,7 +47,7 @@ "homepage": "https://github.com/gatsbyjs/gatsby/tree/master/packages/gatsby-plugin-utils#readme", "dependencies": { "@babel/runtime": "^7.15.4", - "gatsby-core-utils": "3.9.0-next.0", + "gatsby-core-utils": "3.9.0-next.1", "gatsby-sharp": "^0.3.0-next.0", "graphql-compose": "^9.0.7", "import-from": "^4.0.0", @@ -59,6 +59,7 @@ "@babel/core": "^7.15.5", "babel-preset-gatsby-package": "^2.10.0-next.0", "cross-env": "^7.0.3", + "msw": "^0.38.1", "rimraf": "^3.0.2", "typescript": "^4.5.5" }, @@ -71,4 +72,4 @@ "engines": { "node": ">=14.15.0" } -} \ No newline at end of file +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/__tests__/gatsby-worker.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/__tests__/gatsby-worker.ts new file mode 100644 index 0000000000000..28cf1d9cc7f97 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/__tests__/gatsby-worker.ts @@ -0,0 +1,54 @@ +import path from "path" +import fs from "fs-extra" +import { rest } from "msw" +import { setupServer } from "msw/node" +import { IMAGE_CDN } from "../gatsby-worker" +import getSharpInstance from "gatsby-sharp" + +const server = setupServer( + rest.get(`https://external.com/dog.jpg`, async (req, res, ctx) => { + const content = await fs.readFile( + path.join(__dirname, `../../__tests__/__fixtures__/dog-portrait.jpg`) + ) + + return res( + ctx.set(`Content-Type`, `image/jpg`), + ctx.set(`Content-Length`, content.length.toString()), + ctx.status(200), + ctx.body(content) + ) + }) +) + +describe(`gatsby-worker`, () => { + beforeAll(() => server.listen()) + afterAll(() => server.close()) + + describe(`IMAGE_CDN`, () => { + it(`should download and transform an image`, async () => { + const outputDir = path.join(__dirname, `.cache`) + await IMAGE_CDN({ + outputDir, + args: { + contentDigest: `1`, + filename: `abc.jpg`, + format: `jpg`, + height: 100, + width: 100, + quality: 80, + url: `https://external.com/dog.jpg`, + }, + }) + + const outputFile = path.join(outputDir, `abc.jpg`) + expect(await fs.pathExists(outputFile)).toBe(true) + + const sharp = await getSharpInstance() + const metadata = await sharp(outputFile).metadata() + expect(metadata.width).toBe(100) + expect(metadata.height).toBe(100) + + await fs.remove(outputFile) + }) + }) +}) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts index 62178a2af2ab4..ebb98d25e82bb 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts @@ -40,13 +40,6 @@ export function dispatchLocalFileServiceJob( `gatsby/dist/redux/actions` ) as { actions: { createJobV2: (...args: any) => void } } - console.log({ - output: path.join( - global.__GATSBY?.root || process.cwd(), - ...publicUrl.filter(Boolean) - ), - filename, - }) // @ts-ignore - we dont have correct typings for this actions.createJobV2( { diff --git a/yarn.lock b/yarn.lock index 6fe8f02844921..7f07cd6a1f60a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3268,6 +3268,18 @@ outvariant "^1.2.0" strict-event-emitter "^0.2.0" +"@mswjs/interceptors@^0.13.3": + version "0.13.5" + resolved "https://registry.yarnpkg.com/@mswjs/interceptors/-/interceptors-0.13.5.tgz#51d8a10562a4313774eebdb417a9c55f5204c247" + integrity sha512-hZnq197mUDLfTBRgWYOxgPP39VNHavKYfCBqU1QGilGqPYgmHBLI3U2LmlCXggD7uOHmDiv7Dizu1K8u80jQOA== + dependencies: + "@open-draft/until" "^1.0.3" + "@xmldom/xmldom" "^0.7.5" + debug "^4.3.3" + headers-polyfill "^3.0.4" + outvariant "^1.2.1" + strict-event-emitter "^0.2.0" + "@n1ru4l/push-pull-async-iterable-iterator@^2.0.1": version "2.1.2" resolved "https://registry.yarnpkg.com/@n1ru4l/push-pull-async-iterable-iterator/-/push-pull-async-iterable-iterator-2.1.2.tgz#e486bf86c4c29e78601694a26f31c2dec0c08d9b" @@ -4410,10 +4422,10 @@ resolved "https://registry.yarnpkg.com/@types/joi/-/joi-14.3.4.tgz#eed1e14cbb07716079c814138831a520a725a1e0" integrity sha512-1TQNDJvIKlgYXGNIABfgFp9y0FziDpuGrd799Q5RcnsDu+krD+eeW/0Fs5PHARvWWFelOhIG2OPCo6KbadBM4A== -"@types/js-levenshtein@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@types/js-levenshtein/-/js-levenshtein-1.1.0.tgz#9541eec4ad6e3ec5633270a3a2b55d981edc44a9" - integrity sha512-14t0v1ICYRtRVcHASzes0v/O+TIeASb8aD55cWF1PidtInhFWSXcmhzhHqGjUWf9SUq1w70cvd1cWKUULubAfQ== +"@types/js-levenshtein@^1.1.0", "@types/js-levenshtein@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@types/js-levenshtein/-/js-levenshtein-1.1.1.tgz#ba05426a43f9e4e30b631941e0aa17bf0c890ed5" + integrity sha512-qC4bCqYGy1y/NP7dDVr7KJarn+PbX1nSpwA7JXdu0HxT3QYjO8MJ+cntENtHFVy2dRAyBV23OZ6MxsW1AM1L8g== "@types/json-schema@*", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.7", "@types/json-schema@^7.0.8": version "7.0.9" @@ -5112,10 +5124,10 @@ dependencies: tslib "^2.1.0" -"@xmldom/xmldom@^0.7.2": - version "0.7.3" - resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.7.3.tgz#55de695f77afd3cc0e5bee0aa900040bc63c0f63" - integrity sha512-8XmJdPut2XGtfFcsNsqEsvMUmAwk7xLq7m+E/GcsU9b5qyFFIsiX4Fvnb5UoQ4wo12Wlm07YFJERoyWUYdbIpw== +"@xmldom/xmldom@^0.7.2", "@xmldom/xmldom@^0.7.5": + version "0.7.5" + resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.7.5.tgz#09fa51e356d07d0be200642b0e4f91d8e6dd408d" + integrity sha512-V3BIhmY36fXZ1OtVcI9W+FxQqxVLsPKcNjWigIaa81dLC9IolJl5Mt4Cvhmr0flUnjSpTdrbMTSbXqYqV5dT6A== "@xtuc/ieee754@^1.2.0": version "1.2.0" @@ -7975,10 +7987,10 @@ cookie@0.4.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba" integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg== -cookie@^0.4.1, cookie@~0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1" - integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA== +cookie@^0.4.1, cookie@^0.4.2, cookie@~0.4.1: + version "0.4.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" + integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== cookies@0.8.0: version "0.8.0" @@ -11938,6 +11950,11 @@ graphql@^15.5.1, graphql@^15.7.2, graphql@^15.8.0: resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.8.0.tgz#33410e96b012fa3bdb1091cc99a94769db212b38" integrity sha512-5gghUc24tP9HRznNpV2+FIoq3xKkj5dTQqf4v0CpdPbFVwFkWoxOM+o+2OC9ZSvjEMTjfmG9QT+gcvggTwW1zw== +graphql@^16.3.0: + version "16.3.0" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.3.0.tgz#a91e24d10babf9e60c706919bb182b53ccdffc05" + integrity sha512-xm+ANmA16BzCT5pLjuXySbQVFwH3oJctUVdy81w1sV0vBU0KgDdBGtxQOUd5zqOBk/JayAFeG8Dlmeq74rjm/A== + gray-matter@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/gray-matter/-/gray-matter-2.1.1.tgz#3042d9adec2a1ded6a7707a9ed2380f8a17a430e" @@ -12282,6 +12299,11 @@ header-case@^1.0.0: no-case "^2.2.0" upper-case "^1.1.3" +headers-polyfill@^3.0.3, headers-polyfill@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/headers-polyfill/-/headers-polyfill-3.0.4.tgz#cd70c815a441dd882372fcd6eda212ce997c9b18" + integrity sha512-I1DOM1EdWYntdrnCvqQtcKwSSuiTzoqOExy4v1mdcFixFZABlWP4IPHdmoLtPda0abMHqDOY4H9svhQ10DFR4w== + headers-utils@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/headers-utils/-/headers-utils-3.0.2.tgz#dfc65feae4b0e34357308aefbcafa99c895e59ef" @@ -16713,6 +16735,31 @@ msw@^0.36.3, msw@^0.36.8: type-fest "^1.2.2" yargs "^17.3.0" +msw@^0.38.1: + version "0.38.1" + resolved "https://registry.yarnpkg.com/msw/-/msw-0.38.1.tgz#87b59f4ea49a4d1b7abde080de2ecceea7f78c5a" + integrity sha512-4BMEc54nX12UzOAxw6cB31tEytuxfTPwmGoBrItCHoD9Aj9ZLO9aoBaZjCA1W0wfiYcd7sjekLpnT0lE/uR0qA== + dependencies: + "@mswjs/cookies" "^0.1.7" + "@mswjs/interceptors" "^0.13.3" + "@open-draft/until" "^1.0.3" + "@types/cookie" "^0.4.1" + "@types/js-levenshtein" "^1.1.1" + chalk "4.1.1" + chokidar "^3.4.2" + cookie "^0.4.2" + graphql "^16.3.0" + headers-polyfill "^3.0.3" + inquirer "^8.2.0" + is-node-process "^1.0.1" + js-levenshtein "^1.1.6" + node-fetch "^2.6.7" + path-to-regexp "^6.2.0" + statuses "^2.0.0" + strict-event-emitter "^0.2.0" + type-fest "^1.2.2" + yargs "^17.3.1" + multer@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.3.tgz#4db352d6992e028ac0eacf7be45c6efd0264297b" @@ -17592,7 +17639,7 @@ osenv@^0.1.5: os-homedir "^1.0.0" os-tmpdir "^1.0.0" -outvariant@^1.2.0: +outvariant@^1.2.0, outvariant@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/outvariant/-/outvariant-1.2.1.tgz#e630f6cdc1dbf398ed857e36f219de4a005ccd35" integrity sha512-bcILvFkvpMXh66+Ubax/inxbKRyWTUiiFIW2DWkiS79wakrLGn3Ydy+GvukadiyfZjaL6C7YhIem4EZSM282wA== @@ -25785,7 +25832,7 @@ yargs@^15.3.1, yargs@^15.4.0, yargs@^15.4.1: y18n "^4.0.0" yargs-parser "^18.1.2" -yargs@^17.0.1, yargs@^17.3.0: +yargs@^17.0.1, yargs@^17.3.0, yargs@^17.3.1: version "17.3.1" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.3.1.tgz#da56b28f32e2fd45aefb402ed9c26f42be4c07b9" integrity sha512-WUANQeVgjLbNsEmGk20f+nlHgOqzRFpiGWVaBrYGYIGANIIu3lWjoyi0fNlFmJkvfhCZ6BXINe7/W2O2bV4iaA== From 0e89dd018b936a096172a5ae5a0b71a0cafa202e Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Tue, 1 Mar 2022 00:54:08 +0100 Subject: [PATCH 34/46] revert e2e-test package.json --- e2e-tests/development-runtime/package.json | 24 +++++++++++----------- e2e-tests/production-runtime/package.json | 24 +++++++++++----------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/e2e-tests/development-runtime/package.json b/e2e-tests/development-runtime/package.json index 3de4ffdd59062..59e997e5ac34f 100644 --- a/e2e-tests/development-runtime/package.json +++ b/e2e-tests/development-runtime/package.json @@ -5,22 +5,22 @@ "author": "Dustin Schau ", "dependencies": { "babel-plugin-search-and-replace": "^1.1.0", - "gatsby": "4.9.0-next.0-dev-1645793430687", + "gatsby": "^4.6.0-next.4", "gatsby-image": "^3.0.0-next.0", - "gatsby-plugin-image": "2.9.0-next.0-dev-1645793430687", + "gatsby-plugin-image": "^1.0.0-next.5", "gatsby-plugin-less": "^5.1.0-next.2", - "gatsby-plugin-manifest": "4.9.0-next.0-dev-1645793430687", - "gatsby-plugin-offline": "5.9.0-next.0-dev-1645793430687", + "gatsby-plugin-manifest": "^3.0.0-next.0", + "gatsby-plugin-offline": "^4.0.0-next.1", "gatsby-plugin-react-helmet": "^4.0.0-next.0", - "gatsby-plugin-sass": "5.9.0-next.0-dev-1645793430687", - "gatsby-plugin-sharp": "4.9.0-next.0-dev-1645793430687", + "gatsby-plugin-sass": "^4.1.0-next.2", + "gatsby-plugin-sharp": "^3.0.0-next.5", "gatsby-plugin-stylus": "^3.1.0-next.2", - "gatsby-remark-images": "6.9.0-next.0-dev-1645793430687", + "gatsby-remark-images": "^6.6.0-next.2", "gatsby-seo": "^0.1.0", - "gatsby-source-filesystem": "4.9.0-next.0-dev-1645793430687", + "gatsby-source-filesystem": "^3.0.0-next.2", "gatsby-transformer-json": "^3.0.0-next.0", - "gatsby-transformer-remark": "5.9.0-next.0-dev-1645793430687", - "gatsby-transformer-sharp": "4.9.0-next.0-dev-1645793430687", + "gatsby-transformer-remark": "^5.6.0-next.2", + "gatsby-transformer-sharp": "^3.0.0-next.1", "node-fetch": "^2.6.1", "prop-types": "^15.6.2", "react": "16.9.0", @@ -59,8 +59,8 @@ "cypress": "6.1.0", "cypress-image-snapshot": "^4.0.1", "fs-extra": "^7.0.1", - "gatsby-core-utils": "3.9.0-next.0-dev-1645793430687", - "gatsby-cypress": "2.9.0-next.0-dev-1645793430687", + "gatsby-core-utils": "^2.12.0", + "gatsby-cypress": "^0.1.7", "is-ci": "^2.0.0", "prettier": "2.0.4", "start-server-and-test": "^1.7.11", diff --git a/e2e-tests/production-runtime/package.json b/e2e-tests/production-runtime/package.json index 3ff990cdedba3..fdc5c61ece983 100644 --- a/e2e-tests/production-runtime/package.json +++ b/e2e-tests/production-runtime/package.json @@ -6,18 +6,18 @@ "dependencies": { "babel-plugin-search-and-replace": "^1.1.0", "cypress": "^6.5.0", - "gatsby": "4.9.0-next.0-dev-1645962870806", - "gatsby-cypress": "2.9.0-next.0-dev-1645962870806", - "gatsby-plugin-image": "2.9.0-next.0-dev-1645962870806", - "gatsby-plugin-less": "6.9.0-next.0-dev-1645962870806", - "gatsby-plugin-manifest": "4.9.0-next.0-dev-1645962870806", - "gatsby-plugin-offline": "5.9.0-next.0-dev-1645962870806", - "gatsby-plugin-react-helmet": "5.9.0-next.0-dev-1645962870806", - "gatsby-plugin-sass": "5.9.0-next.0-dev-1645962870806", - "gatsby-plugin-sharp": "4.9.0-next.0-dev-1645962870806", - "gatsby-plugin-stylus": "4.9.0-next.0-dev-1645962870806", + "gatsby": "^4.1.6", + "gatsby-cypress": "^2.1.0", + "gatsby-plugin-image": "^2.1.3", + "gatsby-plugin-less": "^6.1.0", + "gatsby-plugin-manifest": "^4.1.4", + "gatsby-plugin-offline": "^5.1.4", + "gatsby-plugin-react-helmet": "^5.1.0", + "gatsby-plugin-sass": "^5.1.1", + "gatsby-plugin-sharp": "^4.1.4", + "gatsby-plugin-stylus": "^4.1.0", "gatsby-seo": "^0.1.0", - "gatsby-source-filesystem": "4.9.0-next.0-dev-1645962870806", + "gatsby-source-filesystem": "^4.1.3", "glob": "^7.1.3", "react": "^16.9.0", "react-dom": "^16.9.0", @@ -52,7 +52,7 @@ "devDependencies": { "cross-env": "^5.2.0", "fs-extra": "^7.0.1", - "gatsby-core-utils": "3.9.0-next.0-dev-1645962870806", + "gatsby-core-utils": "^3.1.3", "is-ci": "^2.0.0", "prettier": "2.0.4", "start-server-and-test": "^1.7.1" From cbc8d5d78d089af696e7e3e2576463865e4db1e9 Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Tue, 1 Mar 2022 00:56:59 +0100 Subject: [PATCH 35/46] add quality replacement for placeholder --- .../src/polyfill-remote-file/placeholder-handler.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts index 4f78f0843feaa..90931204714f4 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts @@ -163,6 +163,7 @@ async function placeholderToBase64({ url = generatePlaceholderUrl({ url: placeholderUrl, width: 20, + quality: 25, originalWidth: width, originalHeight: height, }) @@ -227,6 +228,7 @@ async function placeholderToDominantColor({ url = generatePlaceholderUrl({ url: placeholderUrl, width: 200, + quality: 25, originalWidth: width, originalHeight: height, }) @@ -264,11 +266,13 @@ async function placeholderToDominantColor({ function generatePlaceholderUrl({ url, width, + quality, originalWidth, originalHeight, }: { url: string width: number + quality: number originalWidth: number originalHeight: number }): string { @@ -277,4 +281,5 @@ function generatePlaceholderUrl({ return url .replace(`%width%`, String(width)) .replace(`%height%`, Math.floor(width / aspectRatio).toString()) + .replace(`%quality%`, String(quality)) } From 419e56fa522caace86cb4ac6927aefc94acef37e Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Tue, 1 Mar 2022 01:10:39 +0100 Subject: [PATCH 36/46] fix production test --- .../cypress/integration/remote-file.js | 147 ++++++++++-------- 1 file changed, 79 insertions(+), 68 deletions(-) diff --git a/e2e-tests/production-runtime/cypress/integration/remote-file.js b/e2e-tests/production-runtime/cypress/integration/remote-file.js index a355708493dd5..cd6ae7a2a4ba6 100644 --- a/e2e-tests/production-runtime/cypress/integration/remote-file.js +++ b/e2e-tests/production-runtime/cypress/integration/remote-file.js @@ -1,73 +1,84 @@ -import { graphql } from "gatsby" -import React from "react" +describe(`remote-file`, () => { + beforeEach(() => { + cy.visit(`/remote-file/`).waitForRouteChange() -import { GatsbyImage } from "gatsby-plugin-image" -import Layout from "../components/layout" -import SEO from "../components/seo" + // trigger intersection observer + cy.scrollTo("top") + cy.scrollTo("bottom", { + duration: 500, + }) + }) -const RemoteFile = ({ data }) => { - return ( - - + it(`should render correct dimensions`, () => { + cy.get('[data-testid="public"]').then($urls => { + const urls = Array.from($urls.map((_, $url) => $url.getAttribute("href"))) - {data.allMyRemoteFile.nodes.map(node => { - return ( -
-

- - {node.filename} - -

- -
- - - -
-
- ) - })} -
- ) -} + expect(urls[0].endsWith(".jpg")).to.be.true + expect(urls[1].endsWith(".jpg")).to.be.true + expect(urls[2].endsWith(".jpg")).to.be.true + }) -export const pageQuery = graphql` - { - allMyRemoteFile { - nodes { - id - url - filename - publicUrl - resize(width: 100) { - height - width - src - } - fixed: gatsbyImageData( - layout: FIXED - width: 100 - placeholder: DOMINANT_COLOR - ) - constrained: gatsbyImageData( - layout: CONSTRAINED - width: 300 - placeholder: BLURRED - ) - full: gatsbyImageData(layout: FULL_WIDTH, width: 500, placeholder: NONE) - } - } - } -` + cy.get(".resize").then($imgs => { + const imgDimensions = $imgs.map((_, $img) => $img.getBoundingClientRect()) -export default RemoteFile + expect(imgDimensions[0].width).to.be.equal(100) + expect(imgDimensions[0].height).to.be.equal(133) + expect(imgDimensions[1].width).to.be.equal(100) + expect(imgDimensions[1].height).to.be.equal(160) + expect(imgDimensions[2].width).to.be.equal(100) + expect(imgDimensions[2].height).to.be.equal(67) + }) + + cy.get(".fixed").then($imgs => { + const imgDimensions = $imgs.map((_, $img) => $img.getBoundingClientRect()) + + expect(imgDimensions[0].width).to.be.equal(100) + expect(imgDimensions[0].height).to.be.equal(133) + expect(imgDimensions[1].width).to.be.equal(100) + expect(imgDimensions[1].height).to.be.equal(160) + expect(imgDimensions[2].width).to.be.equal(100) + expect(imgDimensions[2].height).to.be.equal(67) + }) + + cy.get(".constrained").then($imgs => { + const imgDimensions = $imgs.map((_, $img) => $img.getBoundingClientRect()) + + expect(imgDimensions[0].width).to.be.equal(300) + expect(imgDimensions[0].height).to.be.equal(400) + expect(imgDimensions[1].width).to.be.equal(300) + expect(imgDimensions[1].height).to.be.equal(481) + expect(imgDimensions[2].width).to.be.equal(300) + expect(imgDimensions[2].height).to.be.equal(200) + }) + + cy.get(".full").then($imgs => { + const parentWidth = $imgs[0].parentElement.getBoundingClientRect().width + const imgDimensions = $imgs.map((_, $img) => $img.getBoundingClientRect()) + + expect(imgDimensions[0].width).to.be.equal(parentWidth) + expect(Math.ceil(imgDimensions[0].height)).to.be.equal(1229) + expect(imgDimensions[1].width).to.be.equal(parentWidth) + expect(Math.ceil(imgDimensions[1].height)).to.be.equal(1478) + expect(imgDimensions[2].width).to.be.equal(parentWidth) + expect(Math.ceil(imgDimensions[2].height)).to.be.equal(614) + }) + }) + + it(`should render a placeholder`, () => { + cy.get(".fixed [data-placeholder-image]") + .first() + .should("have.css", "background-color", "rgb(232, 184, 8)") + cy.get(".constrained [data-placeholder-image]") + .first() + .should($el => { + expect($el.prop("tagName")).to.be.equal("IMG") + expect($el.prop("src")).to.contain("data:image/jpg;base64") + }) + cy.get(".full [data-placeholder-image]") + .first() + .should($el => { + expect($el.prop("tagName")).to.be.equal("DIV") + expect($el).to.be.empty + }) + }) +}) From 363f558be0a5c0df55acd77735a4d182b6dbab31 Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Tue, 1 Mar 2022 01:23:15 +0100 Subject: [PATCH 37/46] review changes --- docs/docs/how-to/testing/unit-testing.md | 5 ++++- e2e-tests/development-runtime/gatsby-node.js | 11 +++++++---- packages/gatsby-plugin-utils/README.md | 1 - packages/gatsby-plugin-utils/package.json | 2 +- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/docs/how-to/testing/unit-testing.md b/docs/docs/how-to/testing/unit-testing.md index c2700ecde1c27..6c57cb5c90b79 100644 --- a/docs/docs/how-to/testing/unit-testing.md +++ b/docs/docs/how-to/testing/unit-testing.md @@ -44,7 +44,10 @@ module.exports = { ".+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": `/__mocks__/file-mock.js`, "^gatsby-page-utils/(.*)$": `gatsby-page-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771 "^gatsby-core-utils/(.*)$": `gatsby-core-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771 - "^gatsby-plugin-utils/(.*)$": `gatsby-plugin-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771 + "^gatsby-plugin-utils/(.*)$": [ + `gatsby-plugin-utils/dist/$1`, + `gatsby-plugin-utils/$1`, + ], // Workaround for https://github.com/facebook/jest/issues/9771 }, testPathIgnorePatterns: [`node_modules`, `\\.cache`, `.*/public`], transformIgnorePatterns: [`node_modules/(?!(gatsby)/)`], diff --git a/e2e-tests/development-runtime/gatsby-node.js b/e2e-tests/development-runtime/gatsby-node.js index 555cdde5cf55e..fef7e05ffd29a 100644 --- a/e2e-tests/development-runtime/gatsby-node.js +++ b/e2e-tests/development-runtime/gatsby-node.js @@ -22,12 +22,13 @@ exports.createSchemaCustomization = ({ actions, schema, store }) => { ) } -/** @type {imporg('gatsby').sourceNodes} */ +/** @type {import('gatsby').sourceNodes} */ exports.sourceNodes = ({ actions, createNodeId, createContentDigest }) => { const items = [ { name: "photoA.jpg", - url: "https://images.unsplash.com/photo-1517849845537-4d257902454a?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2000&q=80", + url: + "https://images.unsplash.com/photo-1517849845537-4d257902454a?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2000&q=80", placeholderUrl: "https://images.unsplash.com/photo-1517849845537-4d257902454a?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=%width%&h=%height%", mimeType: "image/jpg", @@ -37,7 +38,8 @@ exports.sourceNodes = ({ actions, createNodeId, createContentDigest }) => { }, { name: "photoB.jpg", - url: "https://images.unsplash.com/photo-1552053831-71594a27632d?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&h=2000&q=10", + url: + "https://images.unsplash.com/photo-1552053831-71594a27632d?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&h=2000&q=10", mimeType: "image/jpg", filename: "photo-1552053831.jpg", width: 1247, @@ -45,7 +47,8 @@ exports.sourceNodes = ({ actions, createNodeId, createContentDigest }) => { }, { name: "photoC.jpg", - url: "https://images.unsplash.com/photo-1561037404-61cd46aa615b?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2000&q=80", + url: + "https://images.unsplash.com/photo-1561037404-61cd46aa615b?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2000&q=80", placeholderUrl: "https://images.unsplash.com/photo-1561037404-61cd46aa615b?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=%width%&h=%height%", mimeType: "image/jpg", diff --git a/packages/gatsby-plugin-utils/README.md b/packages/gatsby-plugin-utils/README.md index 7676eb03b5bc8..b732850700cb1 100644 --- a/packages/gatsby-plugin-utils/README.md +++ b/packages/gatsby-plugin-utils/README.md @@ -95,7 +95,6 @@ const { polyfillImageServiceDevRoutes, } = require(`gatsby-plugin-utils/pollyfill-remote-file`) -/** @type {import('gatsby').createSchemaCustomization} */ exports.createSchemaCustomization ({ actions, schema, store }) => { actions.createTypes([ addRemoteFilePolyfillInterface( diff --git a/packages/gatsby-plugin-utils/package.json b/packages/gatsby-plugin-utils/package.json index 2fe53f9416ca7..44a0d74183e63 100644 --- a/packages/gatsby-plugin-utils/package.json +++ b/packages/gatsby-plugin-utils/package.json @@ -5,7 +5,7 @@ "main": "dist/index.js", "exports": { ".": "./dist/index.js", - "./*.js": "./dist/*.js", + "./*": "./dist/*.js", "./dist/*": "./dist/*.js", "./dist/polyfill-remote-file": null, "./dist/utils": null, From 4ad62e793f6d080a7d973f72f254175caa8ff493 Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Tue, 1 Mar 2022 01:28:21 +0100 Subject: [PATCH 38/46] Update packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts Co-authored-by: Tyler Barnes --- .../src/polyfill-remote-file/__tests__/resize-resolver.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts index b872277169d72..8636431f1f955 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts @@ -4,7 +4,6 @@ import { resizeResolver } from "../index" import * as dispatchers from "../jobs/dispatchers" import type { Store } from "gatsby" import type { ImageFit, IRemoteImageNode } from "../types" -// import { fetchRemoteFile } from "gatsby-core-utils/fetch-remote-file" jest.spyOn(dispatchers, `shouldDispatch`).mockImplementation(() => false) jest.mock(`import-from`) From 4855566ff6a527bafeec28a8b5bd03cb98da34f3 Mon Sep 17 00:00:00 2001 From: Tyler Barnes Date: Mon, 28 Feb 2022 17:11:15 -0800 Subject: [PATCH 39/46] encode image filename to not break srcset --- .../polyfill-remote-file/graphql/gatsby-image-resolver.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts index 9140f363e45f9..3009e48b452ff 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts @@ -177,9 +177,8 @@ export async function gatsbyImageResolver( format, cropFocus: args.cropFocus, quality: args.quality as number, - })}/${path.basename( - source.filename, - path.extname(source.filename) + })}/${encodeURIComponent( + path.basename(source.filename, path.extname(source.filename)) )}.${format}` if (!fallbackSrc) { From 0b11aed6c080e7a12475edbb24303e35599ffb3f Mon Sep 17 00:00:00 2001 From: Tyler Barnes Date: Mon, 28 Feb 2022 17:35:33 -0800 Subject: [PATCH 40/46] Update README.md --- packages/gatsby-plugin-utils/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gatsby-plugin-utils/README.md b/packages/gatsby-plugin-utils/README.md index b732850700cb1..10bfc575c9029 100644 --- a/packages/gatsby-plugin-utils/README.md +++ b/packages/gatsby-plugin-utils/README.md @@ -85,7 +85,7 @@ if (!hasFeature(`image-cdn`)) { Our new ImageCDN allows source plugins to lazily download and process images. if you're a plugin author please use this polyfill to add support for all Gatsby V4 versions. -For more information check (TODO)[] +For more information (see here)[https://gatsby.dev/img] #### Example From 56a5e2572b0e572688e989c193d5c095c6ec5cab Mon Sep 17 00:00:00 2001 From: Tyler Barnes Date: Mon, 28 Feb 2022 17:51:55 -0800 Subject: [PATCH 41/46] add encoding test --- .../__tests__/gatsby-image-resolver.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-resolver.ts index d4cb7bf2a7f00..9daa2a97e651b 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-resolver.ts @@ -388,6 +388,24 @@ describe(`gatsbyImageData`, () => { expect(parsedFullWidthSrcSet).toHaveLength(4) }) + it(`Should url encode filenames`, async () => { + const result = await gatsbyImageResolver( + { + ...portraitSource, + filename: `name with spaces.jpeg`, + }, + { + width: 300, + layout: `constrained`, + placeholder: `none`, + }, + store + ) + + expect(result.images.fallback.src).not.toContain(` `) + expect(result.images.fallback.src).toContain(`name%20with%20spaces`) + }) + it(`should return proper srcSet from breakpoints only for fullWidth`, async () => { const biggerPortraitSource = { ...portraitSource, From 7a8d75ad4b9b2857b15e90ae675b7a9bdf594fa0 Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Tue, 1 Mar 2022 01:43:18 +0100 Subject: [PATCH 42/46] cleanup test --- .../src/schema/types/__tests__/remote-file-interface.ts | 8 -------- packages/gatsby/src/schema/types/remote-file-interface.ts | 3 --- 2 files changed, 11 deletions(-) diff --git a/packages/gatsby/src/schema/types/__tests__/remote-file-interface.ts b/packages/gatsby/src/schema/types/__tests__/remote-file-interface.ts index 5b8d97808d248..02035a8fb9381 100644 --- a/packages/gatsby/src/schema/types/__tests__/remote-file-interface.ts +++ b/packages/gatsby/src/schema/types/__tests__/remote-file-interface.ts @@ -1,10 +1,6 @@ import { store } from "../../../redux" import { actions } from "../../../redux/actions" import { build } from "../../index" -import { - DEFAULT_PIXEL_DENSITIES, - DEFAULT_BREAKPOINTS, -} from "../remote-file-interface" interface ISrcsetImageChunk { url: string @@ -148,7 +144,6 @@ describe(`remote-file`, () => { layout: `fixed`, formats: [`auto`], width: 100, - outputPixelDensities: DEFAULT_PIXEL_DENSITIES, }, {}, {} @@ -187,7 +182,6 @@ describe(`remote-file`, () => { layout: `constrained`, formats: [`auto`], width: 100, - outputPixelDensities: DEFAULT_PIXEL_DENSITIES, }, {}, {} @@ -236,8 +230,6 @@ describe(`remote-file`, () => { layout: `fullWidth`, formats: [`auto`], width: 100, - outputPixelDensities: DEFAULT_PIXEL_DENSITIES, - breakpoints: DEFAULT_BREAKPOINTS, }, {}, {} diff --git a/packages/gatsby/src/schema/types/remote-file-interface.ts b/packages/gatsby/src/schema/types/remote-file-interface.ts index f1b53979d5f38..fb4b5ad820e5a 100644 --- a/packages/gatsby/src/schema/types/remote-file-interface.ts +++ b/packages/gatsby/src/schema/types/remote-file-interface.ts @@ -9,9 +9,6 @@ import { getRemoteFileFields, } from "gatsby-plugin-utils/polyfill-remote-file" -export const DEFAULT_PIXEL_DENSITIES = [0.25, 0.5, 1, 2] -export const DEFAULT_BREAKPOINTS = [750, 1080, 1366, 1920] - export function addRemoteFileInterfaceFields( schemaComposer: SchemaComposer, typeComposer: ObjectTypeComposer From 6267eb1a4336091e560d46c1f02d19393e1d1f9c Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Tue, 1 Mar 2022 18:31:37 +0100 Subject: [PATCH 43/46] fix production runtime --- .../remote-file/gatsby-plugin-image.js | 82 ------------------- .../placeholder-handler.ts | 21 ++--- 2 files changed, 9 insertions(+), 94 deletions(-) delete mode 100644 e2e-tests/production-runtime/cypress/integration/remote-file/gatsby-plugin-image.js diff --git a/e2e-tests/production-runtime/cypress/integration/remote-file/gatsby-plugin-image.js b/e2e-tests/production-runtime/cypress/integration/remote-file/gatsby-plugin-image.js deleted file mode 100644 index ee6ff0131d94c..0000000000000 --- a/e2e-tests/production-runtime/cypress/integration/remote-file/gatsby-plugin-image.js +++ /dev/null @@ -1,82 +0,0 @@ -before(() => { - cy.exec(`npm run reset`) -}) - -describe(`remote-file`, () => { - beforeEach(() => { - cy.visit(`/remote-file/`).waitForRouteChange() - }) - - it(`should render correct dimensions`, () => { - cy.get('[data-testid="public"]').then($urls => { - const urls = Array.from($urls.map((_, $url) => $url.getAttribute("href"))) - - expect(urls[0].endsWith(".jpg")).to.be.true - expect(urls[1].endsWith(".jpg")).to.be.true - expect(urls[2].endsWith(".jpg")).to.be.true - }) - - cy.get(".resize").then($imgs => { - const imgDimensions = $imgs.map((_, $img) => $img.getBoundingClientRect()) - - expect(imgDimensions[0].width).to.be.equal(100) - expect(imgDimensions[0].height).to.be.equal(133) - expect(imgDimensions[1].width).to.be.equal(100) - expect(imgDimensions[1].height).to.be.equal(160) - expect(imgDimensions[2].width).to.be.equal(100) - expect(imgDimensions[2].height).to.be.equal(67) - }) - - cy.get(".fixed").then($imgs => { - const imgDimensions = $imgs.map((_, $img) => $img.getBoundingClientRect()) - - expect(imgDimensions[0].width).to.be.equal(100) - expect(imgDimensions[0].height).to.be.equal(133) - expect(imgDimensions[1].width).to.be.equal(100) - expect(imgDimensions[1].height).to.be.equal(160) - expect(imgDimensions[2].width).to.be.equal(100) - expect(imgDimensions[2].height).to.be.equal(67) - }) - - cy.get(".constrained").then($imgs => { - const imgDimensions = $imgs.map((_, $img) => $img.getBoundingClientRect()) - - expect(imgDimensions[0].width).to.be.equal(300) - expect(imgDimensions[0].height).to.be.equal(400) - expect(imgDimensions[1].width).to.be.equal(300) - expect(imgDimensions[1].height).to.be.equal(481) - expect(imgDimensions[2].width).to.be.equal(300) - expect(imgDimensions[2].height).to.be.equal(200) - }) - - cy.get(".full").then($imgs => { - const parentWidth = $imgs[0].parentElement.getBoundingClientRect().width - const imgDimensions = $imgs.map((_, $img) => $img.getBoundingClientRect()) - - expect(imgDimensions[0].width).to.be.equal(parentWidth) - expect(Math.ceil(imgDimensions[0].height)).to.be.equal(1229) - expect(imgDimensions[1].width).to.be.equal(parentWidth) - expect(Math.ceil(imgDimensions[1].height)).to.be.equal(1478) - expect(imgDimensions[2].width).to.be.equal(parentWidth) - expect(Math.ceil(imgDimensions[2].height)).to.be.equal(614) - }) - }) - - it(`should render a placeholder`, () => { - cy.get(".fixed [data-placeholder-image]") - .first() - .should("have.css", "background-color", "rgb(232, 184, 8)") - cy.get(".constrained [data-placeholder-image]") - .first() - .should($el => { - expect($el.prop("tagName")).to.be.equal("IMG") - expect($el.prop("src")).to.contain("data:image/jpg;base64") - }) - cy.get(".full [data-placeholder-image]") - .first() - .should($el => { - expect($el.prop("tagName")).to.be.equal("DIV") - expect($el).to.be.empty - }) - }) -}) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts index 90931204714f4..c43a3474a2849 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts @@ -23,6 +23,8 @@ interface IPlaceholderGenerationArgs { } const QUEUE_CONCURRENCY = 10 +const PLACEHOLDER_BASE64_WIDTH = 20 +const PLACEHOLDER_QUALITY = 25 let tmpDir: string @@ -37,7 +39,6 @@ const queue = Queue< contentDigest: string width: number height: number - hasCorrectFormat: boolean type: PlaceholderType }, string @@ -68,8 +69,10 @@ const queue = Queue< const pipeline = sharp() fileStream.pipe(pipeline) buffer = await pipeline - .resize(20, Math.ceil(20 / (width / height))) - .toFormat(`jpg`) + .resize( + PLACEHOLDER_BASE64_WIDTH, + Math.ceil(PLACEHOLDER_BASE64_WIDTH / (width / height)) + ) .toBuffer() } catch (e) { buffer = await readFile(filePath) @@ -155,15 +158,11 @@ async function placeholderToBase64({ } let url = originalUrl - let hasWidthOrHeightAttr = false if (placeholderUrl) { - hasWidthOrHeightAttr = - placeholderUrl.includes(`%width%`) || - placeholderUrl.includes(`%height%`) url = generatePlaceholderUrl({ url: placeholderUrl, - width: 20, - quality: 25, + width: PLACEHOLDER_BASE64_WIDTH, + quality: PLACEHOLDER_QUALITY, originalWidth: width, originalHeight: height, }) @@ -176,7 +175,6 @@ async function placeholderToBase64({ contentDigest, width, height, - hasCorrectFormat: hasWidthOrHeightAttr, type: PlaceholderType.BLURRED, }, (err, result) => { @@ -228,7 +226,7 @@ async function placeholderToDominantColor({ url = generatePlaceholderUrl({ url: placeholderUrl, width: 200, - quality: 25, + quality: PLACEHOLDER_QUALITY, originalWidth: width, originalHeight: height, }) @@ -241,7 +239,6 @@ async function placeholderToDominantColor({ contentDigest, width, height, - hasCorrectFormat: true, type: PlaceholderType.DOMINANT_COLOR, }, (err, result) => { From b338fc56541e97c22295fb2b8fe1270fe7c81333 Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Tue, 1 Mar 2022 21:49:04 +0100 Subject: [PATCH 44/46] use actions & fix tests --- e2e-tests/production-runtime/gatsby-node.js | 2 +- .../__tests__/gatsby-image-resolver.ts | 30 ++++++++-------- .../__tests__/public-resolver.ts | 14 ++------ .../__tests__/resize-resolver.ts | 31 +++++++--------- .../graphql/gatsby-image-resolver.ts | 10 +++--- .../graphql/public-url-resolver.ts | 10 +++--- .../graphql/resize-resolver.ts | 10 +++--- .../src/polyfill-remote-file/index.ts | 36 ++++++++----------- .../polyfill-remote-file/jobs/dispatchers.ts | 26 ++++---------- packages/gatsby/package.json | 2 +- .../src/schema/types/remote-file-interface.ts | 6 +++- yarn.lock | 8 ++--- 12 files changed, 78 insertions(+), 107 deletions(-) diff --git a/e2e-tests/production-runtime/gatsby-node.js b/e2e-tests/production-runtime/gatsby-node.js index b1d3af9e618f8..c6026e3186437 100644 --- a/e2e-tests/production-runtime/gatsby-node.js +++ b/e2e-tests/production-runtime/gatsby-node.js @@ -3,7 +3,6 @@ const fs = require(`fs-extra`) const { createContentDigest } = require(`gatsby-core-utils`) const { addRemoteFilePolyfillInterface, - polyfillImageServiceDevRoutes, } = require("gatsby-plugin-utils/polyfill-remote-file") exports.onPreBootstrap = () => { @@ -32,6 +31,7 @@ exports.createSchemaCustomization = ({ actions, schema, store }) => { { store, schema, + actions, } ) ) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-resolver.ts index 9daa2a97e651b..6f18d73c94c51 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-resolver.ts @@ -4,7 +4,7 @@ import importFrom from "import-from" import { fetchRemoteFile } from "gatsby-core-utils/fetch-remote-file" import { gatsbyImageResolver } from "../index" import * as dispatchers from "../jobs/dispatchers" -import type { Store } from "gatsby" +import type { Actions } from "gatsby" import { PlaceholderType } from "../placeholder-handler" jest.spyOn(dispatchers, `shouldDispatch`).mockImplementation(() => false) @@ -58,7 +58,7 @@ describe(`gatsbyImageData`, () => { fetchRemoteFile.mockClear() }) - const store = {} as Store + const actions = {} as Actions const portraitSource = { id: `1`, url: `https://images.unsplash.com/photo-1588795945-b9c8d9f9b9c7?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=80`, @@ -94,7 +94,7 @@ describe(`gatsbyImageData`, () => { }, // @ts-ignore - don't care {}, - store + actions ) ).toBe(null) expect(dispatchers.shouldDispatch).not.toHaveBeenCalled() @@ -108,7 +108,7 @@ describe(`gatsbyImageData`, () => { width: 300, placeholder: `none`, }, - store + actions ) const parsedSrcSet = parseSrcSet(result.images.sources[0].srcSet) @@ -166,7 +166,7 @@ describe(`gatsbyImageData`, () => { width: 300, placeholder: `none`, }, - store + actions ) const parsedSrcSet = parseSrcSet(result.images.sources[0].srcSet) @@ -244,7 +244,7 @@ describe(`gatsbyImageData`, () => { width: 2000, placeholder: `none`, }, - store + actions ) const parsedSrcSet = parseSrcSet(result.images.sources[0].srcSet) @@ -319,7 +319,7 @@ describe(`gatsbyImageData`, () => { placeholder: `none`, outputPixelDensities: [1, 2], }, - store + actions ) const constrainedResult = await gatsbyImageResolver( portraitSource, @@ -329,7 +329,7 @@ describe(`gatsbyImageData`, () => { placeholder: `none`, outputPixelDensities: [1, 2], }, - store + actions ) const fullWidthResult = await gatsbyImageResolver( { @@ -343,7 +343,7 @@ describe(`gatsbyImageData`, () => { placeholder: `none`, outputPixelDensities: [1, 2], }, - store + actions ) const parsedFixedSrcSet = parseSrcSet(fixedResult.images.sources[0].srcSet) @@ -399,7 +399,7 @@ describe(`gatsbyImageData`, () => { layout: `constrained`, placeholder: `none`, }, - store + actions ) expect(result.images.fallback.src).not.toContain(` `) @@ -420,7 +420,7 @@ describe(`gatsbyImageData`, () => { placeholder: `none`, breakpoints: [350, 700], }, - store + actions ) const constrainedResult = await gatsbyImageResolver( biggerPortraitSource, @@ -430,7 +430,7 @@ describe(`gatsbyImageData`, () => { placeholder: `none`, breakpoints: [350, 700], }, - store + actions ) const fullWidthResult = await gatsbyImageResolver( biggerPortraitSource, @@ -440,7 +440,7 @@ describe(`gatsbyImageData`, () => { placeholder: `none`, breakpoints: [350, 700], }, - store + actions ) const parsedFixedSrcSet = parseSrcSet(fixedResult.images.sources[0].srcSet) @@ -470,7 +470,7 @@ describe(`gatsbyImageData`, () => { layout: `fixed`, width: 300, }, - store + actions ) expect(fetchRemoteFile).toHaveBeenCalledTimes(1) @@ -488,7 +488,7 @@ describe(`gatsbyImageData`, () => { width: 300, placeholder: PlaceholderType.BLURRED, }, - store + actions ) expect(fetchRemoteFile).toHaveBeenCalledTimes(1) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts index 18f208afff0fa..49f1f78b59fbe 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts @@ -1,11 +1,9 @@ import path from "path" import type { Store } from "gatsby" -import importFrom from "import-from" import { publicUrlResolver } from "../index" import * as dispatchers from "../jobs/dispatchers" jest.spyOn(dispatchers, `shouldDispatch`).mockImplementation(() => false) -jest.mock(`import-from`) describe(`publicResolver`, () => { const store = {} as Store @@ -55,11 +53,6 @@ describe(`publicResolver`, () => { createJobV2: jest.fn(() => jest.fn()), } dispatchers.shouldDispatch.mockImplementationOnce(() => true) - importFrom.mockImplementation(() => { - return { - actions, - } - }) const source = { id: `1`, @@ -74,7 +67,7 @@ describe(`publicResolver`, () => { contentDigest: `1`, }, } - publicUrlResolver(source, store) + publicUrlResolver(source, actions) expect(actions.createJobV2).toHaveBeenCalledWith( expect.objectContaining({ args: { @@ -97,9 +90,6 @@ describe(`publicResolver`, () => { createJobV2: jest.fn(() => jest.fn()), } dispatchers.shouldDispatch.mockImplementationOnce(() => true) - importFrom.mockImplementation(() => { - return { actions } - }) const source = { id: `1`, @@ -116,7 +106,7 @@ describe(`publicResolver`, () => { contentDigest: `1`, }, } - publicUrlResolver(source, store) + publicUrlResolver(source, actions) expect(actions.createJobV2).toHaveBeenCalledWith( expect.objectContaining({ args: { diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts index 8636431f1f955..a4c5a4412e83f 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts @@ -1,19 +1,17 @@ import path from "path" -import importFrom from "import-from" import { resizeResolver } from "../index" import * as dispatchers from "../jobs/dispatchers" -import type { Store } from "gatsby" +import type { Actions } from "gatsby" import type { ImageFit, IRemoteImageNode } from "../types" jest.spyOn(dispatchers, `shouldDispatch`).mockImplementation(() => false) -jest.mock(`import-from`) describe(`resizeResolver`, () => { beforeEach(() => { dispatchers.shouldDispatch.mockClear() }) - const store = {} as Store + const actions = {} as Actions const portraitSource = { id: `1`, url: `https://images.unsplash.com/photo-1588795945-b9c8d9f9b9c7?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=80`, @@ -118,7 +116,7 @@ describe(`resizeResolver`, () => { { width: 100, }, - store + actions ) ).toBe(null) expect(dispatchers.shouldDispatch).not.toHaveBeenCalled() @@ -132,7 +130,7 @@ describe(`resizeResolver`, () => { format: `webp`, }, - store + actions ) expect(result.src).toMatch(/\.webp$/) }) @@ -145,7 +143,7 @@ describe(`resizeResolver`, () => { width: 100, format: `unknown`, }, - store + actions ) ).rejects.toThrowError( `Unknown format "unknown" was given to resize ${portraitSource.url}` @@ -154,7 +152,7 @@ describe(`resizeResolver`, () => { it(`should fail when no height or width is given`, async () => { await expect( - resizeResolver(portraitSource, {}, store) + resizeResolver(portraitSource, {}, actions) ).rejects.toThrowError( `No width or height is given to resize "${portraitSource.url}"` ) @@ -167,7 +165,7 @@ describe(`resizeResolver`, () => { width: 100, cropFocus: [`top`, `left`], }, - store + actions ) const [, , , , args] = result?.src.split(`/`) ?? [] @@ -195,7 +193,7 @@ describe(`resizeResolver`, () => { { width: 300, }, - store + actions ) const [, , , url, args, filename] = result?.src.split(`/`) ?? [] @@ -215,7 +213,7 @@ describe(`resizeResolver`, () => { { height: 300, }, - store + actions ) const [, , , url, args] = result?.src.split(`/`) ?? [] @@ -237,7 +235,7 @@ describe(`resizeResolver`, () => { width: 300, fit, }, - store + actions ) expect(result?.width).toBe(expectedWidth) @@ -254,7 +252,7 @@ describe(`resizeResolver`, () => { height: 300, fit, }, - store + actions ) expect(result?.width).toBe(expectedWidth) @@ -272,7 +270,7 @@ describe(`resizeResolver`, () => { height: 300, fit, }, - store + actions ) expect(result?.width).toBe(expectedWidth) @@ -286,11 +284,8 @@ describe(`resizeResolver`, () => { createJobV2: jest.fn(() => jest.fn()), } dispatchers.shouldDispatch.mockImplementationOnce(() => true) - importFrom.mockImplementation(() => { - return { actions } - }) - resizeResolver(portraitSource, { width: 100 }, store) + resizeResolver(portraitSource, { width: 100 }, actions) expect(actions.createJobV2).toHaveBeenCalledWith( expect.objectContaining({ args: { diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts index 3009e48b452ff..a71c1d02691d0 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts @@ -10,7 +10,7 @@ import { generatePlaceholder, PlaceholderType } from "../placeholder-handler" import { ImageCropFocus, ImageFit, isImage } from "../types" import { validateAndNormalizeFormats, calculateImageDimensions } from "./utils" -import type { Store } from "gatsby" +import type { Actions } from "gatsby" import type { IRemoteFileNode, IRemoteImageNode, @@ -75,7 +75,7 @@ const DEFAULT_QUALITY = 75 export async function gatsbyImageResolver( source: IRemoteFileNode, args: IGatsbyImageDataArgs, - store: Store + actions: Actions ): Promise<{ images: IGatsbyImageData layout: string @@ -167,7 +167,7 @@ export async function gatsbyImageResolver( contentDigest: source.internal.contentDigest, quality: args.quality as number, }, - store + actions ) } @@ -236,7 +236,7 @@ export async function gatsbyImageResolver( export function generateGatsbyImageFieldConfig( enums: ReturnType, - store: Store + actions: Actions ): IGraphQLFieldConfigDefinition< IRemoteFileNode | IRemoteImageNode, ReturnType, @@ -342,7 +342,7 @@ export function generateGatsbyImageFieldConfig( }, }, resolve(source, args): ReturnType { - return gatsbyImageResolver(source, args, store) + return gatsbyImageResolver(source, args, actions) }, } } diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts index 4176a8156247e..592b68fa31090 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts @@ -3,12 +3,12 @@ import { dispatchLocalFileServiceJob, shouldDispatch, } from "../jobs/dispatchers" -import type { Store } from "gatsby" +import type { Actions } from "gatsby" import type { IRemoteFileNode, IGraphQLFieldConfigDefinition } from "../types" export function publicUrlResolver( source: IRemoteFileNode, - store: Store + actions: Actions ): string { if (shouldDispatch()) { dispatchLocalFileServiceJob( @@ -18,7 +18,7 @@ export function publicUrlResolver( mimeType: source.mimeType, contentDigest: source.internal.contentDigest, }, - store + actions ) } @@ -34,12 +34,12 @@ export function publicUrlResolver( } export function generatePublicUrlFieldConfig( - store: Store + actions: Actions ): IGraphQLFieldConfigDefinition { return { type: `String!`, resolve(source): string { - return publicUrlResolver(source, store) + return publicUrlResolver(source, actions) }, } } diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts index cb46d795ca209..c140416cd8f48 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts @@ -9,7 +9,7 @@ import { import { isImage } from "../types" import { validateAndNormalizeFormats, calculateImageDimensions } from "./utils" -import type { Store } from "gatsby" +import type { Actions } from "gatsby" import type { IRemoteFileNode, IGraphQLFieldConfigDefinition, @@ -40,7 +40,7 @@ const allowedFormats: Array = [ export async function resizeResolver( source: IRemoteFileNode, args: Partial & WidthOrHeight, - store: Store + actions: Actions ): Promise<{ width: number height: number @@ -90,7 +90,7 @@ export async function resizeResolver( format, contentDigest: source.internal.contentDigest, }, - store + actions ) } @@ -113,7 +113,7 @@ export async function resizeResolver( export function generateResizeFieldConfig( enums: ReturnType, - store: Store + actions: Actions ): IGraphQLFieldConfigDefinition< IRemoteFileNode, ReturnType, @@ -146,7 +146,7 @@ export function generateResizeFieldConfig( }, }, resolve(source, args): ReturnType { - return resizeResolver(source, args, store) + return resizeResolver(source, args, actions) }, } } diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts index 80addced39df3..33e6fc1f69c6e 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts @@ -1,6 +1,5 @@ import path from "path" import { SchemaComposer } from "graphql-compose" -import importFrom from "import-from" import { getRemoteFileEnums } from "./graphql/get-remote-file-enums" import { getGatsbyVersion } from "./utils/get-gatsby-version" import { hasFeature } from "../has-feature" @@ -17,7 +16,7 @@ import { gatsbyImageResolver, } from "./graphql/gatsby-image-resolver" -import type { Store } from "gatsby" +import type { Store, Actions } from "gatsby" import type { InterfaceTypeComposerAsObjectDefinition } from "graphql-compose" import type { SchemaBuilder, IRemoteFileNode } from "./types" @@ -25,7 +24,7 @@ let enums: ReturnType | undefined export function getRemoteFileFields( enums: ReturnType, - store: Store + actions: Actions ): Record { return { id: `ID!`, @@ -34,9 +33,9 @@ export function getRemoteFileFields( filesize: `Int`, width: `Int`, height: `Int`, - publicUrl: generatePublicUrlFieldConfig(store), - resize: generateResizeFieldConfig(enums, store), - gatsbyImage: generateGatsbyImageFieldConfig(enums, store), + publicUrl: generatePublicUrlFieldConfig(actions), + resize: generateResizeFieldConfig(enums, actions), + gatsbyImage: generateGatsbyImageFieldConfig(enums, actions), } } @@ -47,9 +46,11 @@ function addRemoteFilePolyfillInterface< { schema, store, + actions, }: { schema: SchemaBuilder store: Store + actions: Actions } ): T { // When the image-cdn is part of Gatsby we will only add the RemoteFile interface if necessary @@ -99,7 +100,7 @@ function addRemoteFilePolyfillInterface< name: `RemoteFile`, fields: getRemoteFileFields( enums, - store + actions ) as InterfaceTypeComposerAsObjectDefinition< IRemoteFileNode, unknown @@ -107,19 +108,12 @@ function addRemoteFilePolyfillInterface< }) ) - // We need to use import-from to remove circular dependency - const actions = importFrom( - global.__GATSBY.root ?? process.cwd(), - `gatsby/dist/redux/actions` - ) - store.dispatch( - // @ts-ignore - importFrom doesn't work with types - actions.createTypes(types, { - name: `gatsby`, - version: getGatsbyVersion(), - resolve: path.join(__dirname, `../`), - }) - ) + actions.createTypes(types, { + name: `gatsby`, + // @ts-ignore - version is allowed + version: getGatsbyVersion(), + resolve: path.join(__dirname, `../`), + }) } // @ts-ignore - wrong typing by typecomposer @@ -133,7 +127,7 @@ function addRemoteFilePolyfillInterface< type.config.fields = { // @ts-ignore - wrong typing by typecomposer ...type.config.fields, - ...getRemoteFileFields(enums, store), + ...getRemoteFileFields(enums, actions), } return type diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts index ebb98d25e82bb..8a59dff8fadee 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts @@ -1,8 +1,7 @@ import path from "path" -import importFrom from "import-from" import { getGatsbyVersion } from "../utils/get-gatsby-version" import { generatePublicUrl, generateImageArgs } from "../utils/url-generator" -import type { Store } from "gatsby" +import type { Actions } from "gatsby" import type { ImageFit } from "../types" export function shouldDispatch(): boolean { @@ -21,7 +20,7 @@ export function dispatchLocalFileServiceJob( mimeType, contentDigest, }: { url: string; filename: string; mimeType: string; contentDigest: string }, - store: Store + actions: Actions ): void { const GATSBY_VERSION = getGatsbyVersion() const publicUrl = generatePublicUrl( @@ -35,12 +34,6 @@ export function dispatchLocalFileServiceJob( publicUrl.unshift(`public`) - const { actions } = importFrom( - global.__GATSBY?.root ?? process.cwd(), - `gatsby/dist/redux/actions` - ) as { actions: { createJobV2: (...args: any) => void } } - - // @ts-ignore - we dont have correct typings for this actions.createJobV2( { name: `FILE_CDN`, @@ -58,10 +51,11 @@ export function dispatchLocalFileServiceJob( }, { name: `gatsby`, + // @ts-ignore - version is allowed version: GATSBY_VERSION, resolve: __dirname, } - )(store.dispatch, store.getState) + ) } export function dispatchLocalImageServiceJob( @@ -86,7 +80,7 @@ export function dispatchLocalImageServiceJob( contentDigest: string quality: number }, - store: Store + actions: Actions ): void { const GATSBY_VERSION = getGatsbyVersion() const publicUrl = generatePublicUrl({ @@ -95,13 +89,6 @@ export function dispatchLocalImageServiceJob( }).split(`/`) publicUrl.unshift(`public`) - // We need to use import-from to remove circular dependency - const { actions } = importFrom( - global.__GATSBY?.root ?? process.cwd(), - `gatsby/dist/redux/actions` - ) as { actions: { createJobV2: (...args: any) => void } } - - // @ts-ignore - importFrom doesn't work with types actions.createJobV2( { name: `IMAGE_CDN`, @@ -124,8 +111,9 @@ export function dispatchLocalImageServiceJob( }, { name: `gatsby`, + // @ts-ignore - version is allowed version: GATSBY_VERSION, resolve: __dirname, } - )(store.dispatch, store.getState) + ) } diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index 747151df83e26..c753e37ede939 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -105,7 +105,7 @@ "joi": "^17.4.2", "json-loader": "^0.5.7", "latest-version": "5.1.0", - "lmdb": "2.2.1", + "lmdb": "^2.2.3", "lodash": "^4.17.21", "md5-file": "^5.0.0", "meant": "^1.0.3", diff --git a/packages/gatsby/src/schema/types/remote-file-interface.ts b/packages/gatsby/src/schema/types/remote-file-interface.ts index fb4b5ad820e5a..402030ef7e4f4 100644 --- a/packages/gatsby/src/schema/types/remote-file-interface.ts +++ b/packages/gatsby/src/schema/types/remote-file-interface.ts @@ -3,7 +3,9 @@ import { ObjectTypeComposer, InterfaceTypeComposer, } from "graphql-compose" +import { bindActionCreators } from "redux" import { store } from "../../redux/index" +import { actions } from "../../redux/actions/index" import { getRemoteFileEnums, getRemoteFileFields, @@ -37,7 +39,9 @@ export function getOrCreateRemoteFileInterface( return schemaComposer.getOrCreateIFTC(`RemoteFile`, tc => { tc.setDescription(`Remote Interface`) + const boundActions = bindActionCreators(actions, store.dispatch) + // @ts-ignore - types are messed up by schema composer maybe new version helps here - tc.addFields(getRemoteFileFields(enums, store)) + tc.addFields(getRemoteFileFields(enums, boundActions)) }) } diff --git a/yarn.lock b/yarn.lock index 7f07cd6a1f60a..fcda475cc5bb0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15026,10 +15026,10 @@ livereload-js@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/livereload-js/-/livereload-js-2.3.0.tgz#c3ab22e8aaf5bf3505d80d098cbad67726548c9a" -lmdb@2.2.1, lmdb@^2.0.2, lmdb@^2.1.7: - version "2.2.1" - resolved "https://registry.yarnpkg.com/lmdb/-/lmdb-2.2.1.tgz#b7fd22ed2268ab74aa71108b793678314a7b94bb" - integrity sha512-tUlIjyJvbd4mqdotI9Xe+3PZt/jqPx70VKFDrKMYu09MtBWOT3y2PbuTajX+bJFDjbgLkQC0cTx2n6dithp/zQ== +lmdb@^2.0.2, lmdb@^2.1.7, lmdb@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/lmdb/-/lmdb-2.2.3.tgz#713ffa515c31e042808abf364b4aa0feaeaf6360" + integrity sha512-+OiHQpw22mBBxocb/9vcVNETqf0k5vgHA2r+KX7eCf8j5tSV50ZIv388iY1mnnrERIUhs2sjKQbZhPg7z4HyPQ== dependencies: msgpackr "^1.5.4" nan "^2.14.2" From 3aa8f61c8dba856716964d979532c808b172a3b5 Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Tue, 1 Mar 2022 21:51:04 +0100 Subject: [PATCH 45/46] use actions --- packages/gatsby-plugin-utils/README.md | 4 ++-- .../src/polyfill-remote-file/__tests__/public-resolver.ts | 8 ++++---- .../gatsby-plugin-utils/src/polyfill-remote-file/index.ts | 4 +--- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/gatsby-plugin-utils/README.md b/packages/gatsby-plugin-utils/README.md index 10bfc575c9029..c4bdccff0b020 100644 --- a/packages/gatsby-plugin-utils/README.md +++ b/packages/gatsby-plugin-utils/README.md @@ -95,7 +95,7 @@ const { polyfillImageServiceDevRoutes, } = require(`gatsby-plugin-utils/pollyfill-remote-file`) -exports.createSchemaCustomization ({ actions, schema, store }) => { +exports.createSchemaCustomization ({ actions, schema }) => { actions.createTypes([ addRemoteFilePolyfillInterface( schema.buildObjectType({ @@ -107,7 +107,7 @@ exports.createSchemaCustomization ({ actions, schema, store }) => { }), { schema, - store, + actions, } ) ]); diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts index 49f1f78b59fbe..db488f8ea62ec 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts @@ -1,12 +1,12 @@ import path from "path" -import type { Store } from "gatsby" +import type { Actions } from "gatsby" import { publicUrlResolver } from "../index" import * as dispatchers from "../jobs/dispatchers" jest.spyOn(dispatchers, `shouldDispatch`).mockImplementation(() => false) describe(`publicResolver`, () => { - const store = {} as Store + const actions = {} as Actions it(`should return a file based url`, () => { const source = { @@ -23,7 +23,7 @@ describe(`publicResolver`, () => { }, } - expect(publicUrlResolver(source, store)).toEqual( + expect(publicUrlResolver(source, actions)).toEqual( `/_gatsby/file/${Buffer.from(source.url).toString(`base64`)}/file.pdf` ) }) @@ -43,7 +43,7 @@ describe(`publicResolver`, () => { }, } - expect(publicUrlResolver(source, store)).toEqual( + expect(publicUrlResolver(source, actions)).toEqual( `/_gatsby/file/${Buffer.from(source.url).toString(`base64`)}/image.jpg` ) }) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts index 33e6fc1f69c6e..613a50d22784d 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts @@ -16,7 +16,7 @@ import { gatsbyImageResolver, } from "./graphql/gatsby-image-resolver" -import type { Store, Actions } from "gatsby" +import type { Actions } from "gatsby" import type { InterfaceTypeComposerAsObjectDefinition } from "graphql-compose" import type { SchemaBuilder, IRemoteFileNode } from "./types" @@ -45,11 +45,9 @@ function addRemoteFilePolyfillInterface< type: T, { schema, - store, actions, }: { schema: SchemaBuilder - store: Store actions: Actions } ): T { From a6651c027244dd4e0167a6dc43ac07db291f6d13 Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Tue, 1 Mar 2022 22:24:13 +0100 Subject: [PATCH 46/46] fix rebase --- packages/gatsby-plugin-utils/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/gatsby-plugin-utils/package.json b/packages/gatsby-plugin-utils/package.json index 44a0d74183e63..fac9fc94bc4c9 100644 --- a/packages/gatsby-plugin-utils/package.json +++ b/packages/gatsby-plugin-utils/package.json @@ -47,8 +47,8 @@ "homepage": "https://github.com/gatsbyjs/gatsby/tree/master/packages/gatsby-plugin-utils#readme", "dependencies": { "@babel/runtime": "^7.15.4", - "gatsby-core-utils": "3.9.0-next.1", - "gatsby-sharp": "^0.3.0-next.0", + "gatsby-core-utils": "3.10.0-next.0", + "gatsby-sharp": "^0.4.0-next.0", "graphql-compose": "^9.0.7", "import-from": "^4.0.0", "joi": "^17.4.2",