diff --git a/packages/gatsby-plugin-manifest/README.md b/packages/gatsby-plugin-manifest/README.md index 876bd3bf2342b..ee3fd3817c1aa 100644 --- a/packages/gatsby-plugin-manifest/README.md +++ b/packages/gatsby-plugin-manifest/README.md @@ -9,8 +9,9 @@ This plugin provides several features beyond manifest configuration to make your - [Favicon support](https://www.w3.org/2005/10/howto-favicon) - Legacy icon support (iOS)[^1] - [Cache busting](https://www.keycdn.com/support/what-is-cache-busting) +- Localization - Provides unqiue manifests for path-based localization ([Gatsby Example](https://github.com/gatsbyjs/gatsby/tree/master/examples/using-i18n)) -Each of these features has extensive configuration available so you're always in control. +Each of these features has extensive configuration available so you are always in control. ## Install @@ -56,18 +57,21 @@ There are three modes in which icon generation can function: automatic, hybrid, - Favicon - yes - Legacy icon support - yes - Cache busting - yes + - Localization - optional - Hybrid - Generate a manually configured set of icons from a single source icon. - Favicon - yes - Legacy icon support - yes - Cache busting - yes + - Localization - optional - Manual - Don't generate or pre-configure any icons. - Favicon - never - Legacy icon support - yes - Cache busting - never + - Localization - optional **_IMPORTANT:_** For best results, if you're providing an icon for generation it should be... @@ -132,6 +136,49 @@ In the manual mode, you are responsible for defining the entire web app manifest ### Feature configuration - **Optional** +#### Localization configuration + +Localization allows you to create unique manifests for each localized version of your site. As many languages as you want are supported. Localization requires unique paths for each language (e.g. if your default about page is at `/about`, the german(`de`) version would be `/de/about`) + +The default site language should be configured in your root plugin options. Any additional languages should be defined in the `localize` array. The root settings will be used as defaults if not overridden in a locale. Any configuration option available in the root is also available in the `localize` array. + +`lang` and `start_url` are the only _required_ options in the array objects. `name`, `short_name`, and `description` are [recommended](https://www.w3.org/TR/appmanifest/#dfn-directionality-capable-members) to be translated if being used in the default language. All other config options are optional. This is helpful if you want to provide unique icons for each locale. + +The [`lang` option](https://www.w3.org/TR/appmanifest/#lang-member) is part of the web app manifest specification and thus is required to be a [valid language tag](https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry) + +Using localization requires name based cache busting when using a unique icon in automatic mode for a specific locale. This is automatically enabled if you provide and `icon` in a specific locale without uniquely defining `icons`. If you're using icon creation in hybrid or manual mode for your locales, rememmber to provide unique icon paths. + +```js +// in gatsby-config.js +module.exports = { + plugins: [ + { + resolve: `gatsby-plugin-manifest`, + options: { + name: `The Cool Application`, + short_name: `Cool App`, + description: `The application does cool things and makes your life better.`, + lang: `en`, + display: `standalone`, + icon: `src/images/icon.png`, + start_url: `/`, + background_color: `#663399`, + theme_color: `#fff`, + localize: [ + { + start_url: `/de/`, + lang: `de`, + name: `Die coole Anwendung`, + short_name: `Coole Anwendung`, + description: `Die Anwendung macht coole Dinge und macht Ihr Leben besser.`, + }, + ], + }, + }, + ], +} +``` + #### Iterative icon options The `icon_options` object may be used to iteratively add configuration items to the `icons` array. Any options included in this object will be merged with each object of the `icons` array (custom or default). Key value pairs already in the `icons` array will take precedence over duplicate items in the `icon_options` array. @@ -224,7 +271,7 @@ Cache busting works by calculating a unique "digest" of the provided icon and mo - **\`query\`** - This is the default mode. File names are unmodified but a URL query is appended to all links. e.g. `icons/icon-48x48.png?digest=abc123` -- **\`name\`** - Changes the cache busting mode to be done by file name. File names and links are modified with the icon digest. e.g. `icons/icon-48x48-abc123.png` (only needed if your CDN does not support URL query based cache busting) +- **\`name\`** - Changes the cache busting mode to be done by file name. File names and links are modified with the icon digest. e.g. `icons/icon-48x48-abc123.png` (only needed if your CDN does not support URL query based cache busting). This mode is required and automatically enabled for a locale's icons if you are providing a unique icon for a specific locale in automatic mode using the localization features. - **\`none\`** - Disables cache busting. File names and links remain unmodified. diff --git a/packages/gatsby-plugin-manifest/src/__tests__/__snapshots__/gatsby-node.js.snap b/packages/gatsby-plugin-manifest/src/__tests__/__snapshots__/gatsby-node.js.snap index 3f6f3d8ce0ac6..783940566560d 100644 --- a/packages/gatsby-plugin-manifest/src/__tests__/__snapshots__/gatsby-node.js.snap +++ b/packages/gatsby-plugin-manifest/src/__tests__/__snapshots__/gatsby-node.js.snap @@ -1,3 +1,20 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Test plugin manifest options correctly works with default parameters 1`] = `"{\\"name\\":\\"GatsbyJS\\",\\"short_name\\":\\"GatsbyJS\\",\\"start_url\\":\\"/\\",\\"background_color\\":\\"#f7f0eb\\",\\"theme_color\\":\\"#a2466c\\",\\"display\\":\\"standalone\\",\\"icons\\":[{\\"src\\":\\"icons/icon-48x48.png\\",\\"sizes\\":\\"48x48\\",\\"type\\":\\"image/png\\"},{\\"src\\":\\"icons/icon-72x72.png\\",\\"sizes\\":\\"72x72\\",\\"type\\":\\"image/png\\"},{\\"src\\":\\"icons/icon-96x96.png\\",\\"sizes\\":\\"96x96\\",\\"type\\":\\"image/png\\"},{\\"src\\":\\"icons/icon-144x144.png\\",\\"sizes\\":\\"144x144\\",\\"type\\":\\"image/png\\"},{\\"src\\":\\"icons/icon-192x192.png\\",\\"sizes\\":\\"192x192\\",\\"type\\":\\"image/png\\"},{\\"src\\":\\"icons/icon-256x256.png\\",\\"sizes\\":\\"256x256\\",\\"type\\":\\"image/png\\"},{\\"src\\":\\"icons/icon-384x384.png\\",\\"sizes\\":\\"384x384\\",\\"type\\":\\"image/png\\"},{\\"src\\":\\"icons/icon-512x512.png\\",\\"sizes\\":\\"512x512\\",\\"type\\":\\"image/png\\"}]}"`; + +exports[`Test plugin manifest options does file name based cache busting 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "public/manifest.webmanifest", + "{\\"name\\":\\"GatsbyJS\\",\\"short_name\\":\\"GatsbyJS\\",\\"start_url\\":\\"/\\",\\"background_color\\":\\"#f7f0eb\\",\\"theme_color\\":\\"#a2466c\\",\\"display\\":\\"standalone\\",\\"icons\\":[{\\"src\\":\\"icons/icon-48x48-contentDigest.png\\",\\"sizes\\":\\"48x48\\",\\"type\\":\\"image/png\\",\\"purpose\\":\\"all\\"},{\\"src\\":\\"icons/icon-128x128-contentDigest.png\\",\\"sizes\\":\\"128x128\\",\\"type\\":\\"image/png\\"}]}", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; diff --git a/packages/gatsby-plugin-manifest/src/__tests__/__snapshots__/gatsby-ssr.js.snap b/packages/gatsby-plugin-manifest/src/__tests__/__snapshots__/gatsby-ssr.js.snap index 7512d688892f4..3a0930cb062c4 100644 --- a/packages/gatsby-plugin-manifest/src/__tests__/__snapshots__/gatsby-ssr.js.snap +++ b/packages/gatsby-plugin-manifest/src/__tests__/__snapshots__/gatsby-ssr.js.snap @@ -492,6 +492,104 @@ Array [ ] `; +exports[`gatsby-plugin-manifest Manifest Link Generation Adds correct (default) i18n "manifest" link to head 1`] = ` +Array [ + , + , + , + , + , + , + , + , + , +] +`; + +exports[`gatsby-plugin-manifest Manifest Link Generation Adds correct (es) i18n "manifest" link to head 1`] = ` +Array [ + , + , + , + , + , + , + , + , + , +] +`; + exports[`gatsby-plugin-manifest Manifest Link Generation Does not add a "theme color" meta tag if "theme_color_in_head" is set to false 1`] = ` Array [ { /* * We mock sharp because it depends on fs implementation (which is mocked) * this causes test failures, so mock it to avoid + * */ jest.mock(`sharp`, () => { @@ -229,11 +230,8 @@ describe(`Test plugin manifest options`, () => { ...pluginSpecificOptions, }) - expect(sharp).toHaveBeenCalledTimes(3) - expect(fs.writeFileSync).toHaveBeenCalledWith( - expect.anything(), - JSON.stringify(manifestOptions) - ) + expect(sharp).toHaveBeenCalledTimes(2) + expect(fs.writeFileSync).toMatchSnapshot() }) it(`does not do cache cache busting`, async () => { @@ -249,7 +247,7 @@ describe(`Test plugin manifest options`, () => { ...pluginSpecificOptions, }) - expect(sharp).toHaveBeenCalledTimes(3) + expect(sharp).toHaveBeenCalledTimes(2) expect(fs.writeFileSync).toHaveBeenCalledWith( expect.anything(), JSON.stringify(manifestOptions) @@ -270,9 +268,91 @@ describe(`Test plugin manifest options`, () => { ...pluginSpecificOptions, }) - expect(sharp).toHaveBeenCalledTimes(3) + expect(sharp).toHaveBeenCalledTimes(2) const content = JSON.parse(fs.writeFileSync.mock.calls[0][1]) expect(content.icons[0].purpose).toEqual(`all`) expect(content.icons[1].purpose).toEqual(`maskable`) }) + + it(`generates all language versions`, async () => { + fs.statSync.mockReturnValueOnce({ isFile: () => true }) + const pluginSpecificOptions = { + localize: [ + { + ...manifestOptions, + start_url: `/de/`, + lang: `de`, + }, + { + ...manifestOptions, + start_url: `/es/`, + lang: `es`, + }, + { + ...manifestOptions, + start_url: `/`, + }, + ], + } + const { localize, ...manifest } = pluginSpecificOptions + const expectedResults = localize.concat(manifest).map(x => { + return { ...manifest, ...x } + }) + + await onPostBootstrap(apiArgs, pluginSpecificOptions) + + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.anything(), + JSON.stringify(expectedResults[0]) + ) + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.anything(), + JSON.stringify(expectedResults[1]) + ) + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.anything(), + JSON.stringify(expectedResults[2]) + ) + }) + + it(`merges default and language options`, async () => { + fs.statSync.mockReturnValueOnce({ isFile: () => true }) + const pluginSpecificOptions = { + ...manifestOptions, + localize: [ + { + start_url: `/de/`, + lang: `de`, + }, + { + start_url: `/es/`, + lang: `es`, + }, + ], + } + const { localize, ...manifest } = pluginSpecificOptions + const expectedResults = localize + .concat(manifest) + .map(({ language, manifest }) => { + return { + ...manifestOptions, + ...manifest, + } + }) + + await onPostBootstrap(apiArgs, pluginSpecificOptions) + + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.anything(), + JSON.stringify(expectedResults[0]) + ) + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.anything(), + JSON.stringify(expectedResults[1]) + ) + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.anything(), + JSON.stringify(expectedResults[2]) + ) + }) }) diff --git a/packages/gatsby-plugin-manifest/src/__tests__/gatsby-ssr.js b/packages/gatsby-plugin-manifest/src/__tests__/gatsby-ssr.js index 056c9cad504fc..89cebf4958514 100644 --- a/packages/gatsby-plugin-manifest/src/__tests__/gatsby-ssr.js +++ b/packages/gatsby-plugin-manifest/src/__tests__/gatsby-ssr.js @@ -15,6 +15,7 @@ const setHeadComponents = args => (headComponents = headComponents.concat(args)) const ssrArgs = { setHeadComponents, + pathname: `/`, } describe(`gatsby-plugin-manifest`, () => { @@ -91,6 +92,38 @@ describe(`gatsby-plugin-manifest`, () => { }) expect(headComponents).toMatchSnapshot() }) + + const i18nArgs = [ + { + ...ssrArgs, + pathname: `/about-us`, + testName: `Adds correct (default) i18n "manifest" link to head`, + }, + { + ...ssrArgs, + pathname: `/es/sobre-nosotros`, + testName: `Adds correct (es) i18n "manifest" link to head`, + }, + ] + + i18nArgs.forEach(({ testName, ...args }) => + it(testName, () => { + onRenderBody(args, { + start_url: `/`, + localize: [ + { + start_url: `/de/`, + lang: `de`, + }, + { + start_url: `/es/`, + lang: `es`, + }, + ], + }) + expect(headComponents).toMatchSnapshot() + }) + ) }) describe(`Legacy Icons`, () => { diff --git a/packages/gatsby-plugin-manifest/src/gatsby-node.js b/packages/gatsby-plugin-manifest/src/gatsby-node.js index ab9b13e27f43c..9877d5ac5af09 100644 --- a/packages/gatsby-plugin-manifest/src/gatsby-node.js +++ b/packages/gatsby-plugin-manifest/src/gatsby-node.js @@ -17,34 +17,88 @@ try { // doesn't support cpu-core-count utility. } -function generateIcons(icons, srcIcon) { - return Promise.all( - icons.map(async icon => { - const size = parseInt( - icon.sizes.substring(0, icon.sizes.lastIndexOf(`x`)) - ) - const imgPath = path.join(`public`, icon.src) - - // For vector graphics, instruct sharp to use a pixel density - // suitable for the resolution we're rasterizing to. - // For pixel graphics sources this has no effect. - // Sharp accept density from 1 to 2400 - const density = Math.min(2400, Math.max(1, size)) - - return sharp(srcIcon, { density }) - .resize({ - width: size, - height: size, - fit: `contain`, - background: { r: 255, g: 255, b: 255, alpha: 0 }, - }) - .toFile(imgPath) +async function generateIcon(icon, srcIcon) { + const imgPath = path.join(`public`, icon.src) + + // console.log(`generating icon: `, icon.src) + // if (fs.existsSync(imgPath)) { + // console.log(`icon already Exists, not regenerating`) + // return true + // } + const size = parseInt(icon.sizes.substring(0, icon.sizes.lastIndexOf(`x`))) + + // For vector graphics, instruct sharp to use a pixel density + // suitable for the resolution we're rasterizing to. + // For pixel graphics sources this has no effect. + // Sharp accept density from 1 to 2400 + const density = Math.min(2400, Math.max(1, size)) + + return sharp(srcIcon, { density }) + .resize({ + width: size, + height: size, + fit: `contain`, + background: { r: 255, g: 255, b: 255, alpha: 0 }, }) - ) + .toFile(imgPath) +} + +async function checkCache(cache, icon, srcIcon, srcIconDigest, callback) { + const cacheKey = createContentDigest(`${icon.src}${srcIcon}${srcIconDigest}`) + + let created = cache.get(cacheKey, srcIcon) + + if (!created) { + cache.set(cacheKey, true) + + try { + // console.log(`creating icon`, icon.src, srcIcon) + await callback(icon, srcIcon) + } catch (e) { + cache.set(cacheKey, false) + throw e + } + } else { + // console.log(`icon exists`, icon.src, srcIcon) + } +} + +exports.onPostBootstrap = async ({ reporter }, { localize, ...manifest }) => { + const activity = reporter.activityTimer(`Build manifest and related icons`) + activity.start() + + let cache = new Map() + + await makeManifest(cache, reporter, manifest) + + if (Array.isArray(localize)) { + const locales = [...localize] + await Promise.all( + locales.map(locale => { + let cacheModeOverride = {} + + /* localization requires unique filenames for output files if a different src Icon is defined. + otherwise one language would override anothers icons in automatic mode. + */ + if (locale.hasOwnProperty(`icon`) && !locale.hasOwnProperty(`icons`)) { + // console.debug(`OVERRIDING CACHE BUSTING`, locale) + cacheModeOverride = { cache_busting_mode: `name` } + } + + return makeManifest(cache, reporter, { + ...manifest, + ...locale, + ...cacheModeOverride, + }) + }) + ) + } + activity.end() } -exports.onPostBootstrap = async ({ reporter }, pluginOptions) => { +const makeManifest = async (cache, reporter, pluginOptions) => { const { icon, ...manifest } = pluginOptions + const suffix = pluginOptions.lang ? `_${pluginOptions.lang}` : `` // Delete options we won't pass to the manifest.webmanifest. delete manifest.plugins @@ -55,12 +109,9 @@ exports.onPostBootstrap = async ({ reporter }, pluginOptions) => { delete manifest.icon_options delete manifest.include_favicon - const activity = reporter.activityTimer(`Build manifest and related icons`) - activity.start() - // If icons are not manually defined, use the default icon set. if (!manifest.icons) { - manifest.icons = defaultIcons + manifest.icons = [...defaultIcons] } // Specify extra options for each icon (if requested). @@ -111,30 +162,38 @@ exports.onPostBootstrap = async ({ reporter }, pluginOptions) => { ? pluginOptions.cache_busting_mode : `query` + const iconDigest = createContentDigest(fs.readFileSync(icon)) + //if cacheBusting is being done via url query icons must be generated before cache busting runs if (cacheMode === `query`) { - await generateIcons(manifest.icons, icon) + await Promise.all( + manifest.icons.map(dstIcon => + checkCache(cache, dstIcon, icon, iconDigest, generateIcon) + ) + ) } if (cacheMode !== `none`) { - const iconDigest = createContentDigest(fs.readFileSync(icon)) - - manifest.icons.forEach(icon => { - icon.src = addDigestToPath(icon.src, iconDigest, cacheMode) + manifest.icons = manifest.icons.map(icon => { + let newIcon = { ...icon } + newIcon.src = addDigestToPath(icon.src, iconDigest, cacheMode) + return newIcon }) } //if file names are being modified by cacheBusting icons must be generated after cache busting runs if (cacheMode !== `query`) { - await generateIcons(manifest.icons, icon) + await Promise.all( + manifest.icons.map(dstIcon => + checkCache(cache, dstIcon, icon, iconDigest, generateIcon) + ) + ) } } //Write manifest fs.writeFileSync( - path.join(`public`, `manifest.webmanifest`), + path.join(`public`, `manifest${suffix}.webmanifest`), JSON.stringify(manifest) ) - - activity.end() } diff --git a/packages/gatsby-plugin-manifest/src/gatsby-ssr.js b/packages/gatsby-plugin-manifest/src/gatsby-ssr.js index fb2625893571f..ba1108e0d60d8 100644 --- a/packages/gatsby-plugin-manifest/src/gatsby-ssr.js +++ b/packages/gatsby-plugin-manifest/src/gatsby-ssr.js @@ -10,7 +10,24 @@ const withPrefix = withAssetPrefix || fallbackWithPrefix let iconDigest = null -exports.onRenderBody = ({ setHeadComponents }, pluginOptions) => { +exports.onRenderBody = ( + { setHeadComponents, pathname = `/` }, + { localize, ...pluginOptions } +) => { + if (Array.isArray(localize)) { + const locales = pluginOptions.start_url + ? localize.concat(pluginOptions) + : localize + const manifest = locales.find(locale => + RegExp(`^${locale.start_url}.*`, `i`).test(pathname) + ) + pluginOptions = { + ...pluginOptions, + ...manifest, + } + if (!pluginOptions) return false + } + // We use this to build a final array to pass as the argument to setHeadComponents at the end of onRenderBody. let headComponents = [] @@ -49,12 +66,14 @@ exports.onRenderBody = ({ setHeadComponents }, pluginOptions) => { } } + const suffix = pluginOptions.lang ? `_${pluginOptions.lang}` : `` + // Add manifest link tag. headComponents.push( ) @@ -97,4 +116,5 @@ exports.onRenderBody = ({ setHeadComponents }, pluginOptions) => { } setHeadComponents(headComponents) + return true }