diff --git a/docs/docs/how-to/testing/unit-testing.md b/docs/docs/how-to/testing/unit-testing.md index fa276fbb3907a..6c57cb5c90b79 100644 --- a/docs/docs/how-to/testing/unit-testing.md +++ b/docs/docs/how-to/testing/unit-testing.md @@ -44,6 +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`, + `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/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..33f4f2dbc7938 --- /dev/null +++ b/e2e-tests/development-runtime/cypress/integration/remote-file/gatsby-plugin-image.js @@ -0,0 +1,87 @@ +before(() => { + cy.exec(`npm run reset`) +}) + +describe(`remote-file`, () => { + 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"))) + + 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/e2e-tests/development-runtime/gatsby-node.js b/e2e-tests/development-runtime/gatsby-node.js index e2bed83a75bb2..fef7e05ffd29a 100644 --- a/e2e-tests/development-runtime/gatsby-node.js +++ b/e2e-tests/development-runtime/gatsby-node.js @@ -1,6 +1,78 @@ 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 {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", + 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 +99,9 @@ exports.onCreateNode = function onCreateNode({ } } +/** + * @type {import('gatsby').createPages} + */ exports.createPages = async function createPages({ actions: { createPage, createRedirect }, graphql, @@ -115,6 +190,9 @@ exports.createPages = async function createPages({ }) } +/** + * @type {import('gatsby').onCreatePage} + */ exports.onCreatePage = async ({ page, actions }) => { const { createPage, createRedirect, deletePage } = actions @@ -169,6 +247,9 @@ exports.onCreatePage = async ({ page, actions }) => { } } +/** + * @type {import('gatsby').createResolvers} + */ exports.createResolvers = ({ createResolvers }) => { const resolvers = { QueryDataCachesJson: { @@ -192,3 +273,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..d5e2a35a312be --- /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: 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/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..cd6ae7a2a4ba6 --- /dev/null +++ b/e2e-tests/production-runtime/cypress/integration/remote-file.js @@ -0,0 +1,84 @@ +describe(`remote-file`, () => { + beforeEach(() => { + cy.visit(`/remote-file/`).waitForRouteChange() + + // trigger intersection observer + cy.scrollTo("top") + cy.scrollTo("bottom", { + duration: 500, + }) + }) + + 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/e2e-tests/production-runtime/gatsby-node.js b/e2e-tests/production-runtime/gatsby-node.js index 3c264cefe4796..c6026e3186437 100644 --- a/e2e-tests/production-runtime/gatsby-node.js +++ b/e2e-tests/production-runtime/gatsby-node.js @@ -1,6 +1,9 @@ const path = require(`path`) const fs = require(`fs-extra`) const { createContentDigest } = require(`gatsby-core-utils`) +const { + addRemoteFilePolyfillInterface, +} = require("gatsby-plugin-utils/polyfill-remote-file") exports.onPreBootstrap = () => { fs.copyFileSync( @@ -9,7 +12,7 @@ exports.onPreBootstrap = () => { ) } -exports.createSchemaCustomization = ({ actions }) => { +exports.createSchemaCustomization = ({ actions, schema, store }) => { const { createTypes } = actions const typeDefs = ` type Product implements Node { @@ -17,6 +20,21 @@ exports.createSchemaCustomization = ({ actions }) => { } ` createTypes(typeDefs) + + actions.createTypes( + addRemoteFilePolyfillInterface( + schema.buildObjectType({ + name: "MyRemoteFile", + fields: {}, + interfaces: ["Node", "RemoteFile"], + }), + { + store, + schema, + actions, + } + ) + ) } 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/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/examples/using-jest/jest.config.js b/examples/using-jest/jest.config.js index 588b2b06ec5b1..50f7c8a6b36b2 100644 --- a/examples/using-jest/jest.config.js +++ b/examples/using-jest/jest.config.js @@ -7,6 +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`, + `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-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/integration-tests/gatsby-source-wordpress/jest.config.js b/integration-tests/gatsby-source-wordpress/jest.config.js index d468a716f3da8..cd373ece1f2b5 100644 --- a/integration-tests/gatsby-source-wordpress/jest.config.js +++ b/integration-tests/gatsby-source-wordpress/jest.config.js @@ -3,5 +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`, + `gatsby-plugin-utils/$1`, + ], // Workaround for https://github.com/facebook/jest/issues/9771 }, } diff --git a/jest.config.js b/jest.config.js index f30f5b8353b19..211528be5fe34 100644 --- a/jest.config.js +++ b/jest.config.js @@ -48,6 +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`, + `gatsby-plugin-utils/$1`, + ], // Workaround for https://github.com/facebook/jest/issues/9771 }, snapshotSerializers: [`jest-serializer-path`], collectCoverageFrom: coverageDirs, 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-core-utils/src/fetch-remote-file.ts b/packages/gatsby-core-utils/src/fetch-remote-file.ts index ecd332dffaef6..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. @@ -202,7 +206,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, 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-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..c4bdccff0b020 100644 --- a/packages/gatsby-plugin-utils/README.md +++ b/packages/gatsby-plugin-utils/README.md @@ -76,7 +76,45 @@ 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 +} +``` + +### 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 (see here)[https://gatsby.dev/img] + +#### Example + +```js +const { + addRemoteFilePolyfillInterface, + polyfillImageServiceDevRoutes, +} = require(`gatsby-plugin-utils/pollyfill-remote-file`) + +exports.createSchemaCustomization ({ actions, schema }) => { + actions.createTypes([ + addRemoteFilePolyfillInterface( + schema.buildObjectType({ + name: `PrefixAsset`, + fields: { + // your fields + }, + interfaces: [`Node`, 'RemoteFile'], + }), + { + schema, + actions, + } + ) + ]); +} + +/** @type {import('gatsby').onCreateDevServer} */ +exports.onCreateDevServer = ({ app }) => { + polyfillImageServiceDevRoutes(app) } ``` diff --git a/packages/gatsby-plugin-utils/package.json b/packages/gatsby-plugin-utils/package.json index 576f12e78ab40..fac9fc94bc4c9 100644 --- a/packages/gatsby-plugin-utils/package.json +++ b/packages/gatsby-plugin-utils/package.json @@ -3,6 +3,31 @@ "version": "3.4.0-next.0", "description": "Gatsby utils that help creating plugins", "main": "dist/index.js", + "exports": { + ".": "./dist/index.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", + "./dist/polyfill-remote-file/graphql/*": "./dist/polyfill-remote-file/graphql/*.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,13 +47,19 @@ "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.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", + "mime": "^3.0.0" }, "devDependencies": { "@babel/cli": "^7.15.4", "@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" }, @@ -36,8 +67,7 @@ "gatsby": "^4.0.0-next" }, "files": [ - "dist/", - "src/" + "dist/" ], "engines": { "node": ">=14.15.0" diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/__fixtures__/dog-landscape.jpg b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/__fixtures__/dog-landscape.jpg new file mode 100644 index 0000000000000..997e0e4600632 Binary files /dev/null and b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/__fixtures__/dog-landscape.jpg differ diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/__fixtures__/dog-portrait.jpg b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/__fixtures__/dog-portrait.jpg new file mode 100644 index 0000000000000..34f5f6a5ac0fd Binary files /dev/null and b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/__fixtures__/dog-portrait.jpg differ 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 new file mode 100644 index 0000000000000..6f18d73c94c51 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/gatsby-image-resolver.ts @@ -0,0 +1,506 @@ +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 { gatsbyImageResolver } from "../index" +import * as dispatchers from "../jobs/dispatchers" +import type { Actions } 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; descriptor: string }> { + return srcSet.split(`,`).map(line => { + const [src, descriptor] = line.split(` `) + + return { src, descriptor } + }) +} + +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 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`, + width: 600, + height: 962, + mimeType: `image/jpeg`, + filename: `dog-portrait.jpg`, + basename: `dog-portrait`, + parent: null, + children: [], + internal: { + type: `Test`, + owner: `test`, + contentDigest: `1`, + }, + } + + it(`should return null when source is not an image`, async () => { + expect( + await gatsbyImageResolver( + { + 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 + {}, + actions + ) + ).toBe(null) + expect(dispatchers.shouldDispatch).not.toHaveBeenCalled() + }) + + it(`should return proper image props for fixed layout`, async () => { + const result = await gatsbyImageResolver( + portraitSource, + { + layout: `fixed`, + width: 300, + placeholder: `none`, + }, + actions + ) + + 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&q=75`).toString(`base64`)}/${ + portraitSource.basename + }.webp` + ) + expect(parsedSrcSet[0].descriptor).toEqual(`1x`) + expect(parsedSrcSet[1].src).toEqual( + `/_gatsby/image/${Buffer.from(portraitSource.url).toString( + `base64` + )}/${Buffer.from(`w=600&h=962&fm=webp&q=75`).toString(`base64`)}/${ + portraitSource.basename + }.webp` + ) + expect(parsedSrcSet[1].descriptor).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 gatsbyImageResolver( + portraitSource, + { + layout: `constrained`, + width: 300, + placeholder: `none`, + }, + actions + ) + + 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&q=75`).toString(`base64`)}/${ + portraitSource.basename + }.webp` + ) + expect(parsedSrcSet[0].descriptor).toEqual(`75w`) + expect(parsedSrcSet[1].src).toEqual( + `/_gatsby/image/${Buffer.from(portraitSource.url).toString( + `base64` + )}/${Buffer.from(`w=150&h=241&fm=webp&q=75`).toString(`base64`)}/${ + portraitSource.basename + }.webp` + ) + expect(parsedSrcSet[1].descriptor).toEqual(`150w`) + expect(parsedSrcSet[2].src).toEqual( + `/_gatsby/image/${Buffer.from(portraitSource.url).toString( + `base64` + )}/${Buffer.from(`w=300&h=481&fm=webp&q=75`).toString(`base64`)}/${ + portraitSource.basename + }.webp` + ) + expect(parsedSrcSet[2].descriptor).toEqual(`300w`) + expect(parsedSrcSet[3].src).toEqual( + `/_gatsby/image/${Buffer.from(portraitSource.url).toString( + `base64` + )}/${Buffer.from(`w=600&h=962&fm=webp&q=75`).toString(`base64`)}/${ + portraitSource.basename + }.webp` + ) + expect(parsedSrcSet[3].descriptor).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 gatsbyImageResolver( + { + ...portraitSource, + width: 2000, + height: 3206, + }, + { + layout: `fullWidth`, + width: 2000, + placeholder: `none`, + }, + actions + ) + + 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&q=75`).toString(`base64`)}/${ + portraitSource.basename + }.webp` + ) + expect(parsedSrcSet[0].descriptor).toEqual(`750w`) + expect(parsedSrcSet[1].src).toEqual( + `/_gatsby/image/${Buffer.from(portraitSource.url).toString( + `base64` + )}/${Buffer.from(`w=1080&h=1731&fm=webp&q=75`).toString(`base64`)}/${ + portraitSource.basename + }.webp` + ) + expect(parsedSrcSet[1].descriptor).toEqual(`1080w`) + expect(parsedSrcSet[2].src).toEqual( + `/_gatsby/image/${Buffer.from(portraitSource.url).toString( + `base64` + )}/${Buffer.from(`w=1366&h=2190&fm=webp&q=75`).toString(`base64`)}/${ + portraitSource.basename + }.webp` + ) + expect(parsedSrcSet[2].descriptor).toEqual(`1366w`) + expect(parsedSrcSet[3].src).toEqual( + `/_gatsby/image/${Buffer.from(portraitSource.url).toString( + `base64` + )}/${Buffer.from(`w=1920&h=3078&fm=webp&q=75`).toString(`base64`)}/${ + portraitSource.basename + }.webp` + ) + expect(parsedSrcSet[3].descriptor).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 gatsbyImageResolver( + portraitSource, + { + layout: `fixed`, + width: 300, + placeholder: `none`, + outputPixelDensities: [1, 2], + }, + actions + ) + const constrainedResult = await gatsbyImageResolver( + portraitSource, + { + layout: `constrained`, + width: 300, + placeholder: `none`, + outputPixelDensities: [1, 2], + }, + actions + ) + const fullWidthResult = await gatsbyImageResolver( + { + ...portraitSource, + width: 2000, + height: 3206, + }, + { + layout: `fullWidth`, + width: 300, + placeholder: `none`, + outputPixelDensities: [1, 2], + }, + actions + ) + + 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&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&q=75`).toString(`base64`)}/${ + portraitSource.basename + }.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&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&q=75`).toString(`base64`)}/${ + portraitSource.basename + }.webp` + ) + + const parsedFullWidthSrcSet = parseSrcSet( + fullWidthResult.images.sources[0].srcSet + ) + 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`, + }, + actions + ) + + 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, + width: 2000, + height: 3206, + } + const fixedResult = await gatsbyImageResolver( + biggerPortraitSource, + { + layout: `fixed`, + width: 300, + placeholder: `none`, + breakpoints: [350, 700], + }, + actions + ) + const constrainedResult = await gatsbyImageResolver( + biggerPortraitSource, + { + layout: `constrained`, + width: 300, + placeholder: `none`, + breakpoints: [350, 700], + }, + actions + ) + const fullWidthResult = await gatsbyImageResolver( + biggerPortraitSource, + { + layout: `fullWidth`, + width: 1000, + placeholder: `none`, + breakpoints: [350, 700], + }, + actions + ) + + const parsedFixedSrcSet = parseSrcSet(fixedResult.images.sources[0].srcSet) + expect(parsedFixedSrcSet).toHaveLength(2) + expect(parsedFixedSrcSet[0].descriptor).toEqual(`1x`) + const parsedConstrainedSrcSet = parseSrcSet( + constrainedResult.images.sources[0].srcSet + ) + expect(parsedConstrainedSrcSet).toHaveLength(4) + expect(parsedConstrainedSrcSet[0].descriptor).toEqual(`75w`) + + const parsedFullWidthSrcSet = parseSrcSet( + fullWidthResult.images.sources[0].srcSet + ) + expect(parsedFullWidthSrcSet).toHaveLength(2) + expect(parsedFullWidthSrcSet[0].descriptor).toEqual(`350w`) + expect(parsedFullWidthSrcSet[1].descriptor).toEqual(`700w`) + }) + + it(`should generate dominant color placeholder by default`, async () => { + fetchRemoteFile.mockResolvedValueOnce( + path.join(__dirname, `__fixtures__`, `dog-portrait.jpg`) + ) + const fixedResult = await gatsbyImageResolver( + portraitSource, + { + layout: `fixed`, + width: 300, + }, + actions + ) + + 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 gatsbyImageResolver( + portraitSource, + { + layout: `fixed`, + width: 300, + placeholder: PlaceholderType.BLURRED, + }, + actions + ) + + 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..db488f8ea62ec --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/public-resolver.ts @@ -0,0 +1,126 @@ +import path from "path" +import type { Actions } from "gatsby" +import { publicUrlResolver } from "../index" +import * as dispatchers from "../jobs/dispatchers" + +jest.spyOn(dispatchers, `shouldDispatch`).mockImplementation(() => false) + +describe(`publicResolver`, () => { + const actions = {} as Actions + + 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, actions)).toEqual( + `/_gatsby/file/${Buffer.from(source.url).toString(`base64`)}/file.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, actions)).toEqual( + `/_gatsby/file/${Buffer.from(source.url).toString(`base64`)}/image.jpg` + ) + }) + + it(`should dispatch a file job if it's a file`, () => { + const actions = { + createJobV2: jest.fn(() => jest.fn()), + } + dispatchers.shouldDispatch.mockImplementationOnce(() => true) + + 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, actions) + 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) + + 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, actions) + 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..a4c5a4412e83f --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/__tests__/resize-resolver.ts @@ -0,0 +1,314 @@ +import path from "path" +import { resizeResolver } from "../index" +import * as dispatchers from "../jobs/dispatchers" +import type { Actions } from "gatsby" +import type { ImageFit, IRemoteImageNode } from "../types" + +jest.spyOn(dispatchers, `shouldDispatch`).mockImplementation(() => false) + +describe(`resizeResolver`, () => { + beforeEach(() => { + dispatchers.shouldDispatch.mockClear() + }) + + 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`, + 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, + }, + actions + ) + ).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`, + }, + + actions + ) + expect(result.src).toMatch(/\.webp$/) + }) + + it(`should fail when wrong format is given`, async () => { + await expect( + resizeResolver( + portraitSource, + { + width: 100, + format: `unknown`, + }, + actions + ) + ).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, {}, actions) + ).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`], + }, + actions + ) + + const [, , , , args] = result?.src.split(`/`) ?? [] + const transformAsArgs = Buffer.from(args, `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, + }, + actions + ) + + 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( + `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]) + expect(filename).toBe(source.filename) + }) + + it(`should resize an image when height is given`, async () => { + const result = await resizeResolver( + source, + { + height: 300, + }, + actions + ) + + 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&q=75` + ) + 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, + }, + actions + ) + + 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, + }, + actions + ) + + 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, + }, + actions + ) + + 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) + + resizeResolver(portraitSource, { width: 100 }, actions) + expect(actions.createJobV2).toHaveBeenCalledWith( + expect.objectContaining({ + args: { + contentDigest: `1`, + url: portraitSource.url, + filename: `dog-portrait.jpg`, + format: `jpg`, + width: 100, + height: expect.any(Number), + quality: 75, + }, + 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-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts new file mode 100644 index 0000000000000..a71c1d02691d0 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-resolver.ts @@ -0,0 +1,588 @@ +import path from "path" +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, PlaceholderType } from "../placeholder-handler" +import { ImageCropFocus, ImageFit, isImage } from "../types" +import { validateAndNormalizeFormats, calculateImageDimensions } from "./utils" + +import type { Actions } from "gatsby" +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 = Omit< + CalculateImageSizesArgs, + "fit" | "outputPixelDensities" +> & { + formats?: Array + backgroundColor?: string + placeholder?: PlaceholderType | "none" + aspectRatio?: number + sizes?: string + cropFocus?: Array + fit?: CalculateImageSizesArgs["fit"] + outputPixelDensities?: CalculateImageSizesArgs["outputPixelDensities"] + quality?: number +} + +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] +const DEFAULT_QUALITY = 75 + +export async function gatsbyImageResolver( + source: IRemoteFileNode, + args: IGatsbyImageDataArgs, + actions: Actions +): Promise<{ + images: IGatsbyImageData + layout: string + width: number + height: number + 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(` + Either the "width" or "height" argument is required for "${source.url}" + `) + } + + 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` + } + + if (!args.placeholder) { + args.placeholder = PlaceholderType.DOMINANT_COLOR + } + + if (!args.quality) { + args.quality = DEFAULT_QUALITY + } + + 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 as CalculateImageSizesArgs + ) + 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, + 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, + }, + actions + ) + } + + const src = `${generatePublicUrl(source)}/${generateImageArgs({ + width, + height: Math.round(width / imageSizes.aspectRatio), + format, + cropFocus: args.cropFocus, + quality: args.quality as number, + })}/${encodeURIComponent( + path.basename(source.filename, path.extname(source.filename)) + )}.${format}` + + if (!fallbackSrc) { + fallbackSrc = src + } + + return { + src, + descriptor: + args.layout === `fixed` + ? `${width / imageSizes.presentationWidth}x` + : `${width}w`, + } + }) + + 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 generateGatsbyImageFieldConfig( + enums: ReturnType, + actions: Actions +): IGraphQLFieldConfigDefinition< + IRemoteFileNode | IRemoteImageNode, + ReturnType, + IGatsbyImageDataArgs +> { + return { + type: `JSON`, + description: `Data used in the component. See https://gatsby.dev/img for more info.`, + args: { + layout: { + 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`, + 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, + }, + cropFocus: { + type: enums.cropFocus.List.getTypeName(), + }, + quality: { + type: `Int`, + defaultValue: DEFAULT_QUALITY, + }, + }, + resolve(source, args): ReturnType { + return gatsbyImageResolver(source, args, actions) + }, + } +} + +function sortNumeric(a: number, b: number): number { + return a - b +} + +function createSrcSetFromImages( + images: Array<{ src: string; descriptor: string }> +): string { + return images.map(image => `${image.src} ${image.descriptor}`).join(`,`) +} + +// eslint-disable-next-line consistent-return +function calculateImageSizes( + sourceMetadata: ISourceMetadata, + { + width, + height, + layout, + fit, + outputPixelDensities, + breakpoints, + }: CalculateImageSizesArgs +): IImageSizes { + if (width && Number(width) <= 0) { + throw new Error( + `The provided width of "${width}" is incorrect. Dimensions should be a positive number.` + ) + } + + if (height && 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`: { + // @ts-ignore - only width or height can be undefined but it doesn't let me type this correctly + return calculateResponsiveImageSizes({ + sourceMetadata, + width, + height, + fit, + outputPixelDensities, + layout, + }) + } + case `fullWidth`: { + // @ts-ignore - only width or height can be undefined but it doesn't let me type this correctly + 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 as number) * 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 + } else { + if (!width) { + width = (height as number) / aspectRatio + } else { + height = width * aspectRatio + } + } + + // 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 nonNullableWidth = width as number + const originalWidth = width as number + + 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 * 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(nonNullableWidth)) { + sizes.push(nonNullableWidth) + } + + sizes = sizes.sort(sortNumeric) + + return { + sizes, + aspectRatio, + presentationWidth: originalWidth, + presentationHeight: Math.round(originalWidth / aspectRatio), + unscaledWidth: nonNullableWidth, + } +} + +// 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..592b68fa31090 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts @@ -0,0 +1,45 @@ +import { generatePublicUrl } from "../utils/url-generator" +import { + dispatchLocalFileServiceJob, + shouldDispatch, +} from "../jobs/dispatchers" +import type { Actions } from "gatsby" +import type { IRemoteFileNode, IGraphQLFieldConfigDefinition } from "../types" + +export function publicUrlResolver( + source: IRemoteFileNode, + actions: Actions +): string { + if (shouldDispatch()) { + dispatchLocalFileServiceJob( + { + url: source.url, + filename: source.filename, + mimeType: source.mimeType, + contentDigest: source.internal.contentDigest, + }, + actions + ) + } + + return ( + generatePublicUrl( + { + url: source.url, + mimeType: source.mimeType, + }, + false + ) + `/${source.filename}` + ) +} + +export function generatePublicUrlFieldConfig( + actions: Actions +): IGraphQLFieldConfigDefinition { + return { + type: `String!`, + resolve(source): string { + 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 new file mode 100644 index 0000000000000..c140416cd8f48 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts @@ -0,0 +1,152 @@ +import path from "path" +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 { Actions } from "gatsby" +import type { + IRemoteFileNode, + IGraphQLFieldConfigDefinition, + ImageFit, + ImageFormat, + ImageCropFocus, + WidthOrHeight, +} from "../types" +import type { getRemoteFileEnums } from "./get-remote-file-enums" + +interface IResizeArgs { + fit: ImageFit + format: ImageFormat + cropFocus: Array + quality: number +} + +const DEFAULT_QUALITY = 75 + +const allowedFormats: Array = [ + `jpg`, + `png`, + `webp`, + `avif`, + `auto`, +] + +export async function resizeResolver( + source: IRemoteFileNode, + args: Partial & WidthOrHeight, + actions: Actions +): Promise<{ + width: number + height: number + src: string +} | null> { + if (!isImage(source)) { + return null + } + + if (!args.format) { + 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}` + ) + } + + if (!args.width && !args.height) { + throw new Error(`No width or height is given to resize "${source.url}"`) + } + + const formats = validateAndNormalizeFormats( + [args.format], + getImageFormatFromMimeType(source.mimeType) + ) + const [format] = formats + const { width, height } = calculateImageDimensions( + source, + args as IResizeArgs & WidthOrHeight + ) + + if (shouldDispatch()) { + dispatchLocalImageServiceJob( + { + url: source.url, + extension: format, + basename: path.basename(source.filename, path.extname(source.filename)), + ...(args as IResizeArgs), + width, + height, + format, + contentDigest: source.internal.contentDigest, + }, + actions + ) + } + + const src = `${generatePublicUrl(source)}/${generateImageArgs({ + ...(args as IResizeArgs), + width, + height, + format, + })}/${path.basename( + source.filename, + path.extname(source.filename) + )}.${format}` + + return { + src, + width, + height, + } +} + +export function generateResizeFieldConfig( + enums: ReturnType, + actions: Actions +): IGraphQLFieldConfigDefinition< + IRemoteFileNode, + ReturnType, + IResizeArgs & WidthOrHeight +> { + 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.List.getTypeName(), + }, + quality: { + type: `Int`, + defaultValue: DEFAULT_QUALITY, + }, + }, + resolve(source, args): ReturnType { + return resizeResolver(source, args, actions) + }, + } +} 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..67a3551a563e3 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/utils.ts @@ -0,0 +1,87 @@ +import { ImageFormat, ImageFit, WidthOrHeight } 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 } & WidthOrHeight +): { 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 `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 + } + case `fill`: { + width = requestedWidth ?? originalDimensions.width + height = requestedHeight ?? originalDimensions.height + + break + } + + default: { + if (requestedWidth && !requestedHeight) { + width = requestedWidth + height = Math.round(requestedWidth / imageAspectRatio) + } + + if (requestedHeight && !requestedWidth) { + width = Math.round(requestedHeight * imageAspectRatio) + height = requestedHeight + } + } + } + + return { + 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/http-routes.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/http-routes.ts new file mode 100644 index 0000000000000..f1f07553d8083 --- /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 { transformImage } from "./transform-images" + +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/:filename`, async (req, res) => { + // remove the file extension + const url = req.params.url + const outputDir = path.join( + global.__GATSBY?.root || process.cwd(), + `public`, + `_gatsby`, + `file` + ) + + const filePath = await fetchRemoteFile({ + directory: outputDir, + url: url, + name: req.params.filename, + }) + fs.createReadStream(filePath).pipe(res) + }) + + 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() + ) + + const resizeParams: { + width: number + height: number + quality: number + format: string + } = { + width: 0, + height: 0, + quality: 75, + format: ``, + } + + 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 `q`: { + resizeParams.quality = Number(value) + 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, + ...resizeParams, + }, + }) + + res.setHeader( + `content-type`, + getFileExtensionFromMimeType(path.extname(filename)) + ) + + 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..613a50d22784d --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts @@ -0,0 +1,149 @@ +import path from "path" +import { SchemaComposer } from "graphql-compose" +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 { + generateGatsbyImageFieldConfig, + gatsbyImageResolver, +} from "./graphql/gatsby-image-resolver" + +import type { Actions } from "gatsby" +import type { InterfaceTypeComposerAsObjectDefinition } from "graphql-compose" +import type { SchemaBuilder, IRemoteFileNode } from "./types" + +let enums: ReturnType | undefined + +export function getRemoteFileFields( + enums: ReturnType, + actions: Actions +): Record { + return { + id: `ID!`, + mimeType: `String!`, + filename: `String!`, + filesize: `Int`, + width: `Int`, + height: `Int`, + publicUrl: generatePublicUrlFieldConfig(actions), + resize: generateResizeFieldConfig(enums, actions), + gatsbyImage: generateGatsbyImageFieldConfig(enums, actions), + } +} + +function addRemoteFilePolyfillInterface< + T = ReturnType +>( + type: T, + { + schema, + actions, + }: { + schema: SchemaBuilder + actions: Actions + } +): 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`, + fields: getRemoteFileFields( + enums, + actions + ) as InterfaceTypeComposerAsObjectDefinition< + IRemoteFileNode, + unknown + >["fields"], + }) + ) + + actions.createTypes(types, { + name: `gatsby`, + // @ts-ignore - version is allowed + 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, actions), + } + + 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, + addRemoteFilePolyfillInterface, + gatsbyImageResolver, + resizeResolver, + publicUrlResolver, + isImageCdnEnabled, +} 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 new file mode 100644 index 0000000000000..8a59dff8fadee --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts @@ -0,0 +1,119 @@ +import path from "path" +import { getGatsbyVersion } from "../utils/get-gatsby-version" +import { generatePublicUrl, generateImageArgs } from "../utils/url-generator" +import type { Actions } 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, + filename, + mimeType, + contentDigest, + }: { url: string; filename: string; mimeType: string; contentDigest: string }, + actions: Actions +): void { + const GATSBY_VERSION = getGatsbyVersion() + const publicUrl = generatePublicUrl( + { + url, + // We always want file based url + mimeType, + }, + false + ).split(`/`) + + 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) + ), + args: { + url, + filename, + contentDigest, + }, + }, + { + name: `gatsby`, + // @ts-ignore - version is allowed + version: GATSBY_VERSION, + resolve: __dirname, + } + ) +} + +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 + }, + actions: Actions +): 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), + generateImageArgs({ width, height, format, quality }) + ), + args: { + url, + filename: `${basename}.${extension}`, + width, + height, + format, + fit, + quality, + contentDigest, + }, + }, + { + name: `gatsby`, + // @ts-ignore - version is allowed + version: GATSBY_VERSION, + resolve: __dirname, + } + ) +} 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..1a9a5066e648c --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/gatsby-worker.ts @@ -0,0 +1,63 @@ +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" +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 { + const ext = path.extname(filename) + + await fetchRemoteFile({ + directory: outputDir, + url: url, + name: path.basename(filename, ext), + ext, + cacheKey: contentDigest, + excludeDigest: true, + }) +} + +// 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..c43a3474a2849 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts @@ -0,0 +1,282 @@ +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 + id: string + contentDigest: string +} + +const QUEUE_CONCURRENCY = 10 +const PLACEHOLDER_BASE64_WIDTH = 20 +const PLACEHOLDER_QUALITY = 25 + +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 + 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( + PLACEHOLDER_BASE64_WIDTH, + Math.ceil(PLACEHOLDER_BASE64_WIDTH / (width / height)) + ) + .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({ + id: source.id, + 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({ + id: source.id, + 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, + id, + contentDigest, +}: IPlaceholderGenerationArgs): Promise { + const cache = getCache() + const cacheKey = `image-cdn:${id}-${contentDigest}:base64` + let cachedValue = await cache.get(cacheKey) + if (cachedValue) { + return cachedValue + } + + const mutex = createMutex(getMutexKey(`${id}-${contentDigest}`)) + await mutex.acquire() + + try { + // check cache again after mutex is acquired + cachedValue = await cache.get(cacheKey) + if (cachedValue) { + return cachedValue + } + + let url = originalUrl + if (placeholderUrl) { + url = generatePlaceholderUrl({ + url: placeholderUrl, + width: PLACEHOLDER_BASE64_WIDTH, + quality: PLACEHOLDER_QUALITY, + originalWidth: width, + originalHeight: height, + }) + } + + const base64Placeholder = await new Promise((resolve, reject) => { + queue.push( + { + url, + contentDigest, + width, + height, + type: PlaceholderType.BLURRED, + }, + (err, result) => { + if (err) { + reject(err) + return + } + + resolve(result as string) + } + ) + }) + + await cache.set(cacheKey, base64Placeholder) + + return base64Placeholder + } finally { + await mutex.release() + } +} + +async function placeholderToDominantColor({ + placeholderUrl, + originalUrl, + width, + height, + id, + contentDigest, +}: IPlaceholderGenerationArgs): Promise { + const cache = getCache() + const cacheKey = `image-cdn:${id}-${contentDigest}:dominantColor` + let cachedValue = await cache.get(cacheKey) + if (cachedValue) { + return cachedValue + } + + const mutex = createMutex(getMutexKey(`${id}-${contentDigest}`)) + await mutex.acquire() + + try { + // check cache again after mutex is acquired + cachedValue = await cache.get(cacheKey) + if (cachedValue) { + return cachedValue + } + + let url = originalUrl + if (placeholderUrl) { + url = generatePlaceholderUrl({ + url: placeholderUrl, + width: 200, + quality: PLACEHOLDER_QUALITY, + originalWidth: width, + originalHeight: height, + }) + } + + const dominantColor = await new Promise((resolve, reject) => { + queue.push( + { + url, + contentDigest, + width, + height, + type: PlaceholderType.DOMINANT_COLOR, + }, + (err, result) => { + if (err) { + reject(err) + return + } + + resolve(result as string) + } + ) + }) + + await cache.set(cacheKey, dominantColor) + + return dominantColor + } finally { + await mutex.release() + } +} + +function generatePlaceholderUrl({ + url, + width, + quality, + originalWidth, + originalHeight, +}: { + url: string + width: number + quality: number + originalWidth: number + originalHeight: number +}): string { + const aspectRatio = originalWidth / originalHeight + + return url + .replace(`%width%`, String(width)) + .replace(`%height%`, Math.floor(width / aspectRatio).toString()) + .replace(`%quality%`, String(quality)) +} 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..114c9feec5759 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/transform-images.ts @@ -0,0 +1,153 @@ +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 + quality: number +} + +// 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: IResizeArgs & { + url: string + filename: string + 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 ext = path.extname(filename) + const basename = path.basename(filename, ext) + const filePath = await fetchRemoteFile({ + directory: cache.directory, + url: url, + name: basename, + ext, + cacheKey: contentDigest, + }) + + 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. + // 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, quality }: IResizeArgs +): Promise { + if (pipeline instanceof Buffer) { + if (!outputPath) { + return pipeline + } + + return writeFile(outputPath, pipeline) + } + + const resizedImage = pipeline + .resize(width, height, {}) + .jpeg({ quality }) + .png({ quality }) + .webp({ quality }) + .avif({ quality }) + .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..3d3d2bc956f32 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts @@ -0,0 +1,80 @@ +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 + description?: 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: number } + | { width: number; height?: never } + | { width?: never; height: number } + +export type CalculateImageSizesArgs = { + fit: ImageFit + layout: ImageLayout + outputPixelDensities: Array + breakpoints?: Array +} & WidthOrHeight + +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..343f72678928a --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/cache.ts @@ -0,0 +1,12 @@ +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`) +} 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..efb0fbac1b8d2 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts @@ -0,0 +1,53 @@ +import { isImage } from "../types" +import type { ImageCropFocus, WidthOrHeight } from "../types" + +export function generatePublicUrl( + { + url, + mimeType, + }: { + url: string + mimeType: string + }, + checkMimeType: boolean = true +): string { + const remoteUrl = Buffer.from(url).toString(`base64`) + + let publicUrl = + checkMimeType && isImage({ mimeType }) + ? `/_gatsby/image/` + : `/_gatsby/file/` + publicUrl += `${remoteUrl}` + + return publicUrl +} + +export function generateImageArgs({ + width, + height, + format, + cropFocus, + quality, +}: WidthOrHeight & { + format: string + cropFocus?: ImageCropFocus | Array + quality: number +}): string { + const args: Array = [] + if (width) { + args.push(`w=${width}`) + } + if (height) { + args.push(`h=${height}`) + } + if (cropFocus) { + args.push(`fit=crop`) + args.push( + `crop=${Array.isArray(cropFocus) ? cropFocus.join(`,`) : cropFocus}` + ) + } + args.push(`fm=${format}`) + args.push(`q=${quality}`) + + return Buffer.from(args.join(`&`)).toString(`base64`) +} 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-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 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 ) 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..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", @@ -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..95a821dfba780 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-cdn", + ], "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/__tests__/__snapshots__/print.js.snap b/packages/gatsby/src/schema/__tests__/__snapshots__/print.js.snap index ec08162e01a84..f31dab46ecc63 100644 --- a/packages/gatsby/src/schema/__tests__/__snapshots__/print.js.snap +++ b/packages/gatsby/src/schema/__tests__/__snapshots__/print.js.snap @@ -12,6 +12,188 @@ 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 + + \\"\\"\\" + Data used in the component. See https://gatsby.dev/img for more info. + \\"\\"\\" + gatsbyImage( + \\"\\"\\" + + 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 +399,188 @@ 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 + + \\"\\"\\" + Data used in the component. See https://gatsby.dev/img for more info. + \\"\\"\\" + gatsbyImage( + \\"\\"\\" + + 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 +796,188 @@ 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 + + \\"\\"\\" + Data used in the component. See https://gatsby.dev/img for more info. + \\"\\"\\" + gatsbyImage( + \\"\\"\\" + + 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/graphql-engine/bundle-webpack.ts b/packages/gatsby/src/schema/graphql-engine/bundle-webpack.ts index 2d20e072271fe..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: require.resolve(`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 06241aa2b68d0..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,4 +1,8 @@ -import { createRequireFromPath } from "gatsby-core-utils" +/* eslint-disable @babel/no-invalid-this */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +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 // allow lmdb@2 to be bundled by webpack for engines. @@ -16,20 +20,25 @@ import { createRequireFromPath } from "gatsby-core-utils" // - 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 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 = slash( + path.relative( + path.dirname(this.resourcePath), + nodeGypBuild.path(lmdbRoot) + ) ) } catch (e) { return source } - return source .replace( `require$1('node-gyp-build')(dirName)`, 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..02035a8fb9381 --- /dev/null +++ b/packages/gatsby/src/schema/types/__tests__/remote-file-interface.ts @@ -0,0 +1,269 @@ +import { store } from "../../../redux" +import { actions } from "../../../redux/actions" +import { build } from "../../index" + +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(), + } + + store.dispatch( + actions.createTypes(` + type MyAsset implements Node & RemoteFile { + id: ID! + } + `) + ) + + 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`, + mimeType: `image/jpg`, + filename: `pauline-loroy-U3aF7hgUSrk-unsplash.jpg`, + width: 1200, + height: 800, + internal: { + contentDigest: `1`, + }, + } + + beforeAll(() => { + const fields = schema.getType(`MyAsset`).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.src) + + expect(url).toEqual(remoteFile.url) + 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, + } + `) + }) + }) + + 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`, + mimeType: `image/jpg`, + filename: `pauline-loroy-U3aF7hgUSrk-unsplash.jpg`, + width: 1200, + height: 800, + internal: { + contentDigest: `1`, + }, + } + + beforeAll(() => { + const fields = schema.getType(`MyAsset`).getFields() + gatsbyImageData = fields.gatsbyImage.resolve + }) + + it(`should get the correct fixed sizes`, async () => { + const data = await gatsbyImageData( + remoteFile, + { + layout: `fixed`, + formats: [`auto`], + width: 100, + }, + {}, + {} + ) + 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: `1x`, + }, + { + url: remoteFile.url, + params: expect.stringContaining(`w=200&h=133`), + descriptor: `2x`, + }, + ]) + ) + expect(data.layout).toBe(`fixed`) + }) + + it(`should get the correct constrained sizes`, async () => { + const data = await gatsbyImageData( + remoteFile, + { + layout: `constrained`, + formats: [`auto`], + width: 100, + }, + {}, + {} + ) + 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, + }, + {}, + {} + ) + 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..402030ef7e4f4 --- /dev/null +++ b/packages/gatsby/src/schema/types/remote-file-interface.ts @@ -0,0 +1,47 @@ +import { + SchemaComposer, + ObjectTypeComposer, + InterfaceTypeComposer, +} from "graphql-compose" +import { bindActionCreators } from "redux" +import { store } from "../../redux/index" +import { actions } from "../../redux/actions/index" +import { + getRemoteFileEnums, + getRemoteFileFields, +} from "gatsby-plugin-utils/polyfill-remote-file" + +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.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, boundActions)) + }) +} 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..fcda475cc5bb0 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" @@ -3917,9 +3929,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 +4170,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 +4267,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": @@ -4408,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" @@ -4536,6 +4550,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" @@ -5105,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" @@ -7464,9 +7483,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" @@ -7968,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" @@ -8913,9 +8932,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 +11400,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" @@ -11931,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" @@ -12275,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" @@ -14997,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" @@ -16409,6 +16438,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" @@ -16701,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" @@ -17580,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== @@ -25773,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==