diff --git a/packages/gatsby-plugin-prismic-previews/package.json b/packages/gatsby-plugin-prismic-previews/package.json index 40b53ea2..f6b4d998 100644 --- a/packages/gatsby-plugin-prismic-previews/package.json +++ b/packages/gatsby-plugin-prismic-previews/package.json @@ -32,7 +32,7 @@ "prismic" ], "dependencies": { - "@imgix/gatsby": "^1.6.3", + "@imgix/gatsby": "^1.6.6", "@reach/dialog": "^0.15.0", "camel-case": "^4.1.2", "chalk": "^4.1.1", diff --git a/packages/gatsby-plugin-prismic-previews/src/fieldProxies/imageFieldProxy.ts b/packages/gatsby-plugin-prismic-previews/src/fieldProxies/imageFieldProxy.ts index fce81182..8c0bac38 100644 --- a/packages/gatsby-plugin-prismic-previews/src/fieldProxies/imageFieldProxy.ts +++ b/packages/gatsby-plugin-prismic-previews/src/fieldProxies/imageFieldProxy.ts @@ -3,6 +3,8 @@ import * as imgixGatsbyHelpers from '@imgix/gatsby/dist/pluginHelpers.browser' import * as RE from 'fp-ts/ReaderEither' import * as R from 'fp-ts/Record' import * as O from 'fp-ts/Option' +import * as S from 'fp-ts/Semigroup' +import * as A from 'fp-ts/Array' import { pipe } from 'fp-ts/function' import { ProxyDocumentSubtreeEnv } from '../lib/proxyDocumentSubtree' @@ -49,9 +51,23 @@ const buildImageProxyValue = ( RE.fromOption(() => new Error(sprintf('Missing height'))), ), ), + RE.bindW('existingImageImgixParams', (env) => + pipe( + new URL(env.url), + (url) => [...url.searchParams.entries()], + R.fromFoldable(S.last(), A.Foldable), + RE.right, + ), + ), + RE.bind('mergedImageImgixParams', (env) => + RE.of({ + ...env.existingImageImgixParams, + ...env.imageImgixParams, + }), + ), RE.bind('args', (env) => RE.of({ - imgixParams: env.imageImgixParams, + imgixParams: env.mergedImageImgixParams, placeholderImgixParams: env.imagePlaceholderImgixParams, }), ), @@ -83,7 +99,7 @@ const buildImageProxyValue = ( width: env.sourceWidth, height: env.sourceHeight, }, - defaultParams: env.imageImgixParams, + defaultParams: env.mergedImageImgixParams, resolverArgs: {}, }), ), diff --git a/packages/gatsby-plugin-prismic-previews/test/usePrismicPreviewBootstrap-field-proxies.test.ts b/packages/gatsby-plugin-prismic-previews/test/usePrismicPreviewBootstrap-field-proxies.test.ts index 52fd1fec..35ba56b8 100644 --- a/packages/gatsby-plugin-prismic-previews/test/usePrismicPreviewBootstrap-field-proxies.test.ts +++ b/packages/gatsby-plugin-prismic-previews/test/usePrismicPreviewBootstrap-field-proxies.test.ts @@ -20,6 +20,7 @@ import { polyfillKy } from './__testutils__/polyfillKy' import { PluginOptions, + PrismicAPIDocumentNodeInput, PrismicPreviewProvider, PrismicRepositoryConfigs, usePrismicPreviewBootstrap, @@ -480,6 +481,87 @@ test.serial('image', async (t) => { ) }) +test.serial( + 'image retains existing URL parameters unless replaced by gatsby-plugin-image or gatsby-image', + async (t) => { + const gatsbyContext = createGatsbyContext() + const pluginOptions = createPluginOptions(t) + const config = createRepositoryConfigs(pluginOptions) + + // We want to leave `rect` untouched. We're adding the `w=1` parameter to + // ensure that it is replaced by the resolver to properly support responsive + // images. + const originalUrl = new URL( + 'https://example.com/image.png?rect=0,0,100,200&w=1', + ) + const doc = createPrismicAPIDocument({ + image: { + dimensions: { width: 400, height: 300 }, + alt: 'alt', + copyright: 'copyright', + url: originalUrl.toString(), + }, + }) + const queryResponse = createPrismicAPIQueryResponse([doc]) + + const result = await performPreview( + t, + // @ts-expect-error - Partial gatsbyContext provided + gatsbyContext, + pluginOptions, + config, + queryResponse, + '822e45b2918cabfa88ea9d2f5600eab7.json', + { + type: gatsbyPrismic.PrismicSpecialType.Document, + 'type.data': gatsbyPrismic.PrismicSpecialType.DocumentData, + 'type.data.image': gatsbyPrismic.PrismicFieldType.Image, + }, + ) + + const node = result.current.context[0].nodes[ + doc.id + ] as PrismicAPIDocumentNodeInput<{ + image: { + url: string + fixed: { src: string } + fluid: { src: string } + gatsbyImageData: { images: { fallback: { src: string } } } + } + }> + + const urlUrl = new URL(node.data.image.url) + t.is(urlUrl.searchParams.get('rect'), originalUrl.searchParams.get('rect')) + t.is(urlUrl.searchParams.get('w'), originalUrl.searchParams.get('w')) + + const fixedSrcUrl = new URL(node.data.image.fixed.src) + t.is( + fixedSrcUrl.searchParams.get('rect'), + originalUrl.searchParams.get('rect'), + ) + t.not(fixedSrcUrl.searchParams.get('w'), originalUrl.searchParams.get('w')) + + const fluidSrcUrl = new URL(node.data.image.fluid.src) + t.is( + fluidSrcUrl.searchParams.get('rect'), + originalUrl.searchParams.get('rect'), + ) + t.not(fluidSrcUrl.searchParams.get('w'), originalUrl.searchParams.get('w')) + + const gatsbyImageDataSrcUrl = new URL( + node.data.image.gatsbyImageData.images.fallback.src, + ) + t.is( + gatsbyImageDataSrcUrl.searchParams.get('rect'), + originalUrl.searchParams.get('rect'), + ) + t.not( + gatsbyImageDataSrcUrl.searchParams.get('w'), + originalUrl.searchParams.get('w'), + ) + }, +) + test.serial('group', async (t) => { const gatsbyContext = createGatsbyContext() const pluginOptions = createPluginOptions(t) diff --git a/packages/gatsby-source-prismic/package.json b/packages/gatsby-source-prismic/package.json index cf9c7a6c..4b77acb1 100644 --- a/packages/gatsby-source-prismic/package.json +++ b/packages/gatsby-source-prismic/package.json @@ -29,7 +29,7 @@ "prismic" ], "dependencies": { - "@imgix/gatsby": "^1.6.3", + "@imgix/gatsby": "^1.6.6", "camel-case": "^4.1.2", "fp-ts": "^2.10.5", "gatsby-node-helpers": "^1.2.1", diff --git a/packages/gatsby-source-prismic/src/builders/buildImageBaseFieldConfigMap.ts b/packages/gatsby-source-prismic/src/builders/buildImageBaseFieldConfigMap.ts index 12ddbbdf..f092ec58 100644 --- a/packages/gatsby-source-prismic/src/builders/buildImageBaseFieldConfigMap.ts +++ b/packages/gatsby-source-prismic/src/builders/buildImageBaseFieldConfigMap.ts @@ -2,7 +2,11 @@ import * as gqlc from 'graphql-compose' import * as gatsbyFs from 'gatsby-source-filesystem' import * as imgixGatsby from '@imgix/gatsby/dist/pluginHelpers' import * as RTE from 'fp-ts/ReaderTaskEither' -import { pipe } from 'fp-ts/function' +import * as O from 'fp-ts/Option' +import * as S from 'fp-ts/Semigroup' +import * as A from 'fp-ts/Array' +import * as R from 'fp-ts/Record' +import { constNull, pipe } from 'fp-ts/function' import { Dependencies, PrismicAPIImageField } from '../types' @@ -35,6 +39,65 @@ const resolveWidth = (source: PrismicAPIImageField): number | undefined => const resolveHeight = (source: PrismicAPIImageField): number | undefined => source.dimensions?.height +/** + * The minimum required GraphQL argument properties for an `@imgix/gatsby` field. + */ +interface ImgixGatsbyFieldArgsLike { + imgixParams: Record +} + +/** + * Modifies an `@imgix/gatsby` GraphQL field config to retain existing Imgix + * parameters set on the source URL. + * + * This is needed if the source URL contains parameters like `rect` (crops an + * image). Without this config enhancer, the `rect` parameter would be removed. + * + * @param fieldConfig GraphQL field config object to be enhanced. + * + * @returns `fieldConfig` with the ability to retain existing Imgix parameters on the source URL. + */ +const withExistingURLImgixParameters = < + TContext, + TArgs extends ImgixGatsbyFieldArgsLike, +>( + fieldConfig: gqlc.ObjectTypeComposerFieldConfigAsObjectDefinition< + PrismicAPIImageField, + TContext, + TArgs + >, +): typeof fieldConfig => ({ + ...fieldConfig, + resolve: (source, args, ...rest) => + pipe( + O.Do, + O.bind('url', () => + O.fromNullable(source.url ? new URL(source.url) : null), + ), + O.bind('existingImgixParams', (scope) => + pipe( + [...scope.url.searchParams.entries()], + R.fromFoldable(S.last(), A.Foldable), + O.of, + ), + ), + O.map((scope) => + fieldConfig.resolve?.( + source, + { + ...args, + imgixParams: { + ...scope.existingImgixParams, + ...args.imgixParams, + }, + }, + ...rest, + ), + ), + O.getOrElseW(constNull), + ), +}) + /** * Builds a GraphQL field configuration object to be used as part of another * Image field GraphQL configuration object. For example, this base @@ -61,12 +124,20 @@ export const buildImageBaseFieldConfigMap: RTE.ReaderTaskEither< }), ), ), - RTE.bind('urlField', (scope) => RTE.right(scope.imgixTypes.fields.url)), - RTE.bind('fixedField', (scope) => RTE.right(scope.imgixTypes.fields.fixed)), - RTE.bind('fluidField', (scope) => RTE.right(scope.imgixTypes.fields.fluid)), + RTE.bind('urlField', (scope) => + RTE.right(withExistingURLImgixParameters(scope.imgixTypes.fields.url)), + ), + RTE.bind('fixedField', (scope) => + RTE.right(withExistingURLImgixParameters(scope.imgixTypes.fields.fixed)), + ), + RTE.bind('fluidField', (scope) => + RTE.right(withExistingURLImgixParameters(scope.imgixTypes.fields.fluid)), + ), RTE.bind('gatsbyImageDataField', (scope) => pipe( - RTE.right(scope.imgixTypes.fields.gatsbyImageData), + RTE.right( + withExistingURLImgixParameters(scope.imgixTypes.fields.gatsbyImageData), + ), // This field is 'JSON!' by default (i.e. non-nullable). If an image is // not set in Prismic, however, this field throws a GraphQL error saying a // non-nullable field was returned a null value. This should not happen diff --git a/packages/gatsby-source-prismic/test/create-schema-customization-image.test.ts b/packages/gatsby-source-prismic/test/create-schema-customization-image.test.ts index 75fe6646..5943fd3a 100644 --- a/packages/gatsby-source-prismic/test/create-schema-customization-image.test.ts +++ b/packages/gatsby-source-prismic/test/create-schema-customization-image.test.ts @@ -1,5 +1,7 @@ import test from 'ava' import * as sinon from 'sinon' +import * as msw from 'msw' +import * as mswn from 'msw/node' import { createGatsbyContext } from './__testutils__/createGatsbyContext' import { createPluginOptions } from './__testutils__/createPluginOptions' @@ -242,3 +244,107 @@ test('thumbnail field resolves thumbnails', async (t) => { t.true(res.mobile === field.mobile) }) + +test('existing image URL parameters are persisted unless replaced in gatsby-plugin-image and gatsby-image fields', async (t) => { + const gatsbyContext = createGatsbyContext() + const pluginOptions = createPluginOptions(t) + + pluginOptions.schemas = { + foo: { + Main: { + image: { type: PrismicFieldType.Image, config: {} }, + }, + }, + } + + // @ts-expect-error - Partial gatsbyContext provided + await createSchemaCustomization(gatsbyContext, pluginOptions) + + const call = findCreateTypesCall( + 'PrismicPrefixFooDataImageImageType', + gatsbyContext.actions.createTypes as sinon.SinonStub, + ) + // We'll override the `sat` parameter, but leave `rect` untouched. + // We're adding the `w=1` parameter to ensure that it is replaced by the + // resolver to properly support responsive images. + const originalUrl = new URL( + 'https://example.com/image.png?rect=0,0,100,200&sat=100&w=1', + ) + const field = { url: originalUrl.toString() } + const imgixParams = { sat: 50 } + + const server = mswn.setupServer( + msw.rest.get( + `${originalUrl.origin}${originalUrl.pathname}`, + (req, res, ctx) => { + const params = req.url.searchParams + + if (params.get('fm') === 'json') { + return res( + ctx.json({ + 'Content-Type': 'image/png', + PixelWidth: 200, + PixelHeight: 100, + }), + ) + } else { + t.fail( + 'Forcing a failure due to an unhandled request for Imgix image metadata', + ) + + return + } + }, + ), + ) + server.listen({ onUnhandledRequest: 'error' }) + t.teardown(() => { + server.close() + }) + + const urlRes = await call.config.fields.url.resolve(field, { imgixParams }) + const urlUrl = new URL(urlRes) + t.is(urlUrl.searchParams.get('sat'), imgixParams.sat.toString()) + t.is(urlUrl.searchParams.get('rect'), originalUrl.searchParams.get('rect')) + t.is(urlUrl.searchParams.get('w'), originalUrl.searchParams.get('w')) + + const fixedRes = await call.config.fields.fixed.resolve(field, { + imgixParams, + }) + const fixedSrcUrl = new URL(fixedRes.src) + t.is(fixedSrcUrl.searchParams.get('sat'), imgixParams.sat.toString()) + t.is( + fixedSrcUrl.searchParams.get('rect'), + originalUrl.searchParams.get('rect'), + ) + t.not(fixedSrcUrl.searchParams.get('w'), originalUrl.searchParams.get('w')) + + const fluidRes = await call.config.fields.fluid.resolve(field, { + imgixParams, + }) + const fluidSrcUrl = new URL(fluidRes.src) + t.is(fluidSrcUrl.searchParams.get('sat'), imgixParams.sat.toString()) + t.is( + fluidSrcUrl.searchParams.get('rect'), + originalUrl.searchParams.get('rect'), + ) + t.not(fluidSrcUrl.searchParams.get('w'), originalUrl.searchParams.get('w')) + + const gatsbyImageDataRes = await call.config.fields.gatsbyImageData.resolve( + field, + { imgixParams }, + ) + const gatsbyImageDataSrcUrl = new URL(gatsbyImageDataRes.images.fallback.src) + t.is( + gatsbyImageDataSrcUrl.searchParams.get('sat'), + imgixParams.sat.toString(), + ) + t.is( + gatsbyImageDataSrcUrl.searchParams.get('rect'), + originalUrl.searchParams.get('rect'), + ) + t.not( + gatsbyImageDataSrcUrl.searchParams.get('w'), + originalUrl.searchParams.get('w'), + ) +}) diff --git a/tsconfig.json b/tsconfig.json index f3284106..152a2191 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "module": "CommonJS", "target": "ESNext", - "lib": ["DOM", "ESNext"], + "lib": ["DOM", "DOM.Iterable", "ESNext"], "importHelpers": true, "sourceMap": true, "strict": true, diff --git a/yarn.lock b/yarn.lock index 3ae65e61..38e4e15d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1647,10 +1647,10 @@ resolved "https://registry.yarnpkg.com/@iarna/toml/-/toml-2.2.5.tgz#b32366c89b43c6f8cefbdefac778b9c828e3ba8c" integrity sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg== -"@imgix/gatsby@^1.6.3": - version "1.6.3" - resolved "https://registry.yarnpkg.com/@imgix/gatsby/-/gatsby-1.6.3.tgz#d8ae1032767d7aa3488032b63f5492fb8cca8901" - integrity sha512-ueK0RK58M/66Z1pCM6UmRleBFshGJRkQmaNjYxGs5C1cQ8rrnEL5Lg8pNf0d2Vr2EuFhvgnI3x4FVRozXuMVGg== +"@imgix/gatsby@^1.6.6": + version "1.6.6" + resolved "https://registry.yarnpkg.com/@imgix/gatsby/-/gatsby-1.6.6.tgz#c38abf35cd39b22f53cc020e6c471b0d382e9782" + integrity sha512-bngboBzagVgEfDieJBrSRoyLMVqXm4rOh0tAJINdGdNF2bc6v1YO03JV2FZtM9ueo1UOI+lBLeb5zUJ0qAobNg== dependencies: "@imgix/js-core" "3.1.3" camel-case "^4.1.2"