diff --git a/packages/gatsby-plugin-sharp/README.md b/packages/gatsby-plugin-sharp/README.md index 0b1c664d6478d..dd9e8dc854007 100644 --- a/packages/gatsby-plugin-sharp/README.md +++ b/packages/gatsby-plugin-sharp/README.md @@ -30,9 +30,12 @@ plugins: [ { resolve: `gatsby-plugin-sharp`, options: { - useMozJpeg: false, + // Available options and their defaults: + base64Width: 20, + forceBase64Format: ``, // valid formats: png,jpg,webp + useMozJpeg: process.env.GATSBY_JPEG_ENCODER === `MOZJPEG`, stripMetadata: true, - defaultQuality: 75, + defaultQuality: 50, }, }, ] @@ -137,6 +140,8 @@ following: - `grayscale` (bool, default: false) - `duotone` (bool|obj, default: false) - `toFormat` (string, default: '') +- `toFormatBase64` (string, default: '') +- `base64Width` (int, default: 20) - `cropFocus` (string, default: 'ATTENTION') - `fit` (string, default: 'COVER') - `pngCompressionSpeed` (int, default: 4) @@ -147,6 +152,26 @@ following: Convert the source image to one of the following available options: `NO_CHANGE`, `JPG`, `PNG`, `WEBP`. +#### toFormatBase64 + +base64 image uses the image format from the source , or the value of `toFormat`. This setting allows a different image format instead, available options are: `JPG`, `PNG`, `WEBP`. + +`WEBP` allows for a smaller data size, allowing you to reduce your HTML size when transferring over the network, or to use a larger base64 placeholder width default for improved image placeholder quality. + +[Not all browsers support `WEBP`](https://caniuse.com/#feat=webp). It would be wasteful to include a fallback image format in this case. Consider also adding a `backgroundColor` placeholder as a fallback instead. + +The plugin config option `forceBase64Format` performs the equivalent functionality by default to all your base64 placeholders. `toFormatBase64` has a higher priority for base64 images that need to selectively use a different format. + +#### base64Width + +The width in pixels for your base64 placeholder to use. The height will also be adjusted based on the aspect ratio of the image. Use this to increase the image quality by allowing more pixels to be used at the expense of increasing the file size of the data to be transferred. + +The default for Gatsby is `20`px. This keeps the data size low enough to embed into the HTML document for immediate display on DOM loaded and avoids an additional network request. + +[Facebook](https://engineering.fb.com/android/the-technology-behind-preview-photos/) and [Medium](https://jmperezperez.com/medium-image-progressive-loading-placeholder/) are both known to use `42`px width for their image placeholders. However Medium presently uses a solid background color placeholder to load the page as fast as possible, followed by an image placeholder requested over the network instead of embedding it with base64. + +The plugin config has an equivalent option, allowing you to change the default for all base64 placeholders. This parameter option has a higher priority over the plugin config option. + #### cropFocus Change the cropping focus. Available options: `CENTER`, `NORTH`, `NORTHEAST`, diff --git a/packages/gatsby-plugin-sharp/src/__tests__/__snapshots__/index.js.snap b/packages/gatsby-plugin-sharp/src/__tests__/__snapshots__/index.js.snap index ec2715d39962b..b43dd07b2090b 100644 --- a/packages/gatsby-plugin-sharp/src/__tests__/__snapshots__/index.js.snap +++ b/packages/gatsby-plugin-sharp/src/__tests__/__snapshots__/index.js.snap @@ -10,6 +10,16 @@ Object { } `; +exports[`gatsby-plugin-sharp base64 should support option: 'background' 1`] = ` +Object { + "aspectRatio": 1, + "height": 20, + "originalName": "alphatest.png", + "src": "data:image/jpeg;base64,/9j/2wBDABALDA4MChAODQ4SERATGCgaGBYWGDEjJR0oOjM9PDkzODdASFxOQERXRTc4UG1RV19iZ2hnPk1xeXBkeFxlZ2P/2wBDARESEhgVGC8aGi9jQjhCY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2P/wgARCAAUABQDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAGfAAAB/8QAFBABAAAAAAAAAAAAAAAAAAAAMP/aAAgBAQABBQIf/8QAFBEBAAAAAAAAAAAAAAAAAAAAIP/aAAgBAwEBPwEf/8QAFBEBAAAAAAAAAAAAAAAAAAAAIP/aAAgBAgEBPwEf/8QAFBABAAAAAAAAAAAAAAAAAAAAMP/aAAgBAQAGPwIf/8QAFBABAAAAAAAAAAAAAAAAAAAAMP/aAAgBAQABPyEf/9oADAMBAAIAAwAAABAAAAD/xAAUEQEAAAAAAAAAAAAAAAAAAAAg/9oACAEDAQE/EB//xAAUEQEAAAAAAAAAAAAAAAAAAAAg/9oACAECAQE/EB//xAAUEAEAAAAAAAAAAAAAAAAAAAAw/9oACAEBAAE/EB//2Q==", + "width": 20, +} +`; + exports[`gatsby-plugin-sharp duotone fixed 1`] = ` Object { "aspectRatio": 1, @@ -72,8 +82,9 @@ exports[`gatsby-plugin-sharp fixed correctly infers the width when only the heig }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, @@ -126,8 +137,9 @@ exports[`gatsby-plugin-sharp fixed does not warn when the requested width is equ }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, @@ -180,8 +192,9 @@ exports[`gatsby-plugin-sharp fixed should give the same result with createJob as }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, @@ -220,8 +233,9 @@ exports[`gatsby-plugin-sharp fixed warns when the requested width is greater tha }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, @@ -288,8 +302,9 @@ exports[`gatsby-plugin-sharp fluid accepts srcSet breakpoints 1`] = ` }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, @@ -328,8 +343,9 @@ exports[`gatsby-plugin-sharp fluid adds pathPrefix if defined 1`] = ` }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, @@ -408,8 +424,9 @@ Array [ }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, @@ -487,8 +504,9 @@ Array [ }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, @@ -566,8 +584,9 @@ Array [ }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, @@ -645,8 +664,9 @@ Array [ }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, @@ -702,8 +722,9 @@ Array [ }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, @@ -763,8 +784,9 @@ Array [ }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, @@ -824,8 +846,9 @@ Array [ }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, @@ -885,8 +908,9 @@ Array [ }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, @@ -918,8 +942,9 @@ exports[`gatsby-plugin-sharp fluid does not change the arguments object it is gi }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, @@ -986,8 +1011,9 @@ exports[`gatsby-plugin-sharp fluid ensure maxWidth is in srcSet breakpoints 1`] }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, @@ -1095,8 +1121,9 @@ exports[`gatsby-plugin-sharp fluid infers the maxWidth if only maxHeight is give }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, @@ -1135,8 +1162,9 @@ exports[`gatsby-plugin-sharp fluid keeps original file name 1`] = ` }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, @@ -1196,8 +1224,9 @@ exports[`gatsby-plugin-sharp fluid prevents duplicate breakpoints 1`] = ` }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, @@ -1264,8 +1293,9 @@ exports[`gatsby-plugin-sharp fluid reject any breakpoints larger than the origin }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, @@ -1304,8 +1334,9 @@ exports[`gatsby-plugin-sharp fluid should give the same result with createJob as }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, @@ -1359,8 +1390,9 @@ exports[`gatsby-plugin-sharp queueImageResizing with createJob file name works w }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, @@ -1401,8 +1433,9 @@ exports[`gatsby-plugin-sharp queueImageResizing with createJob should round heig }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, @@ -1443,8 +1476,9 @@ exports[`gatsby-plugin-sharp queueImageResizing with createJobV2 file name works }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, @@ -1483,8 +1517,9 @@ exports[`gatsby-plugin-sharp queueImageResizing with createJobV2 should round he }, ], "pluginOptions": Object { + "base64Width": 20, "defaultQuality": 50, - "forceBase64Format": false, + "forceBase64Format": "", "lazyImageGeneration": true, "stripMetadata": true, "useMozJpeg": false, diff --git a/packages/gatsby-plugin-sharp/src/__tests__/index.js b/packages/gatsby-plugin-sharp/src/__tests__/index.js index 36b9fef400f00..879999dea0d37 100644 --- a/packages/gatsby-plugin-sharp/src/__tests__/index.js +++ b/packages/gatsby-plugin-sharp/src/__tests__/index.js @@ -23,6 +23,7 @@ fs.existsSync = jest.fn().mockReturnValue(false) const { base64, + generateBase64, fluid, fixed, queueImageResizing, @@ -31,6 +32,11 @@ const { setBoundActionCreators, } = require(`../`) +const { + getPluginOptionsDefaults, + setPluginOptions, +} = require(`../plugin-options`) + jest.mock(`gatsby-cli/lib/reporter`, () => { return { log: jest.fn(), @@ -508,6 +514,95 @@ describe(`gatsby-plugin-sharp`, () => { expect(result).toEqual(result2) expect(result).not.toEqual(result3) }) + + // Matches base64 string in snapshot, converts to jpg to force use of bg + // Testing pixel colour for a match would be better + it(`should support option: 'background'`, async () => { + const result = await generateBase64({ + file: getFileObject(path.join(__dirname, `images/alphatest.png`)), + args: { + background: `#ff0000`, + toFormatBase64: `jpg`, + }, + }) + + expect(result).toMatchSnapshot() + }) + + describe(`should support option: 'base64Width'`, () => { + // Uses 'generateBase64()` directly to avoid `base64()` caching affecting results. + it(`should support a configurable width`, async () => { + const result = await generateBase64({ + file, + args: { base64Width: 42 }, + }) + + expect(result.width).toEqual(42) + }) + + it(`should support a configurable default width`, async () => { + setPluginOptions({ base64Width: 32 }) + + const result = await generateBase64({ + file, + args, + }) + + expect(result.width).toEqual(32) + setPluginOptions(getPluginOptionsDefaults()) + }) + + it(`width via arg overrides global default`, async () => { + setPluginOptions({ base64Width: 32 }) + + const result = await generateBase64({ + file, + args: { base64Width: 42 }, + }) + + expect(result.width).toEqual(42) + setPluginOptions(getPluginOptionsDefaults()) + }) + }) + + describe(`should support options: 'toFormatBase64' and 'forceBase64Format'`, () => { + it(`should support a different image format for base64`, async () => { + const result = await generateBase64({ + file, + args: { toFormatBase64: `webp` }, + }) + + expect(result.src).toEqual( + expect.stringMatching(/^data:image\/webp;base64/) + ) + }) + + it(`should support a configurable default base64 image format`, async () => { + setPluginOptions({ forceBase64Format: `webp` }) + const result = await generateBase64({ + file, + args, + }) + + expect(result.src).toEqual( + expect.stringMatching(/^data:image\/webp;base64/) + ) + setPluginOptions(getPluginOptionsDefaults()) + }) + + it(`image format via arg overrides global default`, async () => { + setPluginOptions({ forceBase64Format: `png` }) + const result = await generateBase64({ + file, + args: { toFormatBase64: `webp` }, + }) + + expect(result.src).toEqual( + expect.stringMatching(/^data:image\/webp;base64/) + ) + setPluginOptions(getPluginOptionsDefaults()) + }) + }) }) describe(`image quirks`, () => { diff --git a/packages/gatsby-plugin-sharp/src/index.js b/packages/gatsby-plugin-sharp/src/index.js index 0e89278068769..dc182805e6ebb 100644 --- a/packages/gatsby-plugin-sharp/src/index.js +++ b/packages/gatsby-plugin-sharp/src/index.js @@ -283,12 +283,12 @@ function batchQueueImageResizing({ file, transforms = [], reporter }) { }) } -// A value in pixels(Int) -const defaultBase64Width = () => getPluginOptions().base64Width || 20 async function generateBase64({ file, args = {}, reporter }) { const pluginOptions = getPluginOptions() const options = healOptions(pluginOptions, args, file.extension, { - width: defaultBase64Width(), + // Should already be set to base64Width by `fluid()`/`fixed()` methods + // calling `generateBase64()`. Useful in Jest tests still. + width: args.base64Width || pluginOptions.base64Width, }) let pipeline try { @@ -306,10 +306,10 @@ async function generateBase64({ file, args = {}, reporter }) { pipeline = pipeline.trim(options.trim) } - const forceBase64Format = - args.toFormatBase64 || pluginOptions.forceBase64Format - if (forceBase64Format) { - args.toFormat = forceBase64Format + const changedBase64Format = + options.toFormatBase64 || pluginOptions.forceBase64Format + if (changedBase64Format) { + options.toFormat = changedBase64Format } pipeline @@ -323,16 +323,16 @@ async function generateBase64({ file, args = {}, reporter }) { .png({ compressionLevel: options.pngCompressionLevel, adaptiveFiltering: false, - force: args.toFormat === `png`, + force: options.toFormat === `png`, }) .jpeg({ quality: options.jpegQuality || options.quality, progressive: options.jpegProgressive, - force: args.toFormat === `jpg`, + force: options.toFormat === `jpg`, }) .webp({ quality: options.webpQuality || options.quality, - force: args.toFormat === `webp`, + force: options.toFormat === `webp`, }) // grayscale @@ -347,7 +347,7 @@ async function generateBase64({ file, args = {}, reporter }) { // duotone if (options.duotone) { - pipeline = await duotone(options.duotone, args.toFormat, pipeline) + pipeline = await duotone(options.duotone, options.toFormat, pipeline) } const { data: buffer, info } = await pipeline.toBuffer({ resolveWithObject: true, @@ -558,12 +558,13 @@ async function fluid({ file, args = {}, reporter, cache }) { let base64Image if (options.base64) { - const base64Width = options.base64Width || defaultBase64Width() + const base64Width = options.base64Width const base64Height = Math.max( 1, Math.round(base64Width / images[0].aspectRatio) ) const base64Args = { + background: options.background, duotone: options.duotone, grayscale: options.grayscale, rotate: options.rotate, @@ -693,12 +694,13 @@ async function fixed({ file, args = {}, reporter, cache }) { let base64Image if (options.base64) { - const base64Width = options.base64Width || defaultBase64Width() + const base64Width = options.base64Width const base64Height = Math.max( 1, Math.round(base64Width / images[0].aspectRatio) ) const base64Args = { + background: options.background, duotone: options.duotone, grayscale: options.grayscale, rotate: options.rotate, @@ -768,6 +770,7 @@ function toArray(buf) { exports.queueImageResizing = queueImageResizing exports.resize = queueImageResizing exports.base64 = base64 +exports.generateBase64 = generateBase64 exports.traceSVG = traceSVG exports.sizes = fluid exports.resolutions = fixed diff --git a/packages/gatsby-plugin-sharp/src/plugin-options.js b/packages/gatsby-plugin-sharp/src/plugin-options.js index b3a5bab3bcaf5..d024bd1dda8e1 100644 --- a/packages/gatsby-plugin-sharp/src/plugin-options.js +++ b/packages/gatsby-plugin-sharp/src/plugin-options.js @@ -2,7 +2,8 @@ const _ = require(`lodash`) /// Plugin options are loaded onPreBootstrap in gatsby-node const pluginDefaults = { - forceBase64Format: false, + base64Width: 20, + forceBase64Format: ``, // valid formats: png,jpg,webp useMozJpeg: process.env.GATSBY_JPEG_ENCODER === `MOZJPEG`, stripMetadata: true, lazyImageGeneration: true, @@ -37,6 +38,7 @@ exports.setPluginOptions = opts => { } exports.getPluginOptions = () => pluginOptions +exports.getPluginOptionsDefaults = () => pluginDefaults /** * Creates a transform object @@ -69,7 +71,7 @@ exports.createTransformObject = args => { } exports.healOptions = ( - { defaultQuality: quality }, + { defaultQuality: quality, base64Width }, args, fileExtension = ``, defaultArgs = {} @@ -80,6 +82,7 @@ exports.healOptions = ( options.pngCompressionSpeed = parseInt(options.pngCompressionSpeed, 10) options.toFormat = options.toFormat.toLowerCase() options.toFormatBase64 = options.toFormatBase64.toLowerCase() + options.base64Width = options.base64Width || base64Width // when toFormat is not set we set it based on fileExtension if (options.toFormat === ``) {