diff --git a/.travis.yml b/.travis.yml index 45dadc1548..25fa715525 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,7 +15,7 @@ matrix: - <<: *node_container name: 'Node - build, type-check, and end-to-end tests' script: - - yarn build + - yarn build --verbose - yarn test --testPathPattern react-server-webpack-plugin address - <<: *node_container name: 'Node - unit tests' diff --git a/packages/react-i18n/CHANGELOG.md b/packages/react-i18n/CHANGELOG.md index 1040e49c9a..487dfa55c5 100644 --- a/packages/react-i18n/CHANGELOG.md +++ b/packages/react-i18n/CHANGELOG.md @@ -6,7 +6,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). --- - +## [Unreleased] + +### Added + +- Minor - added a `generateTranslationDictionaries` helper, and `mode=from-dictionary-index` option for the Babel plugin. This can be used to build many versions of an application, with each version containing a specific locale's translations directly within JavaScript ([#1197](https://github.com/Shopify/quilt/pull/1197/files)) ## [2.2.0] - 2019-11-22 diff --git a/packages/react-i18n/README.md b/packages/react-i18n/README.md index 1ab7645f3a..56989ad4b4 100644 --- a/packages/react-i18n/README.md +++ b/packages/react-i18n/README.md @@ -409,22 +409,80 @@ Because `babel-loader`'s cache is based on a component's source content hash, ne The generator will look for any `translations` folders and generate an array of local ids in `translations/index.js` based on the `{locale}.json` files found. We recommend that you add `**/translations/index.js` to `.gitignore` to make sure the generated files are not checked-in. ```js -// babel.config.js +// webpack.config.js +module.exports = { + resolve: { + extensions: ['.js', '.jsx'], + }, + module: { + rules: [ + { + test: /\.jsx?$/, + use: [ + { + loader: 'babel-loader', + options: { + plugins: [ + '@babel/plugin-syntax-dynamic-import', + ['@shopify/react-i18n/babel', {mode: 'from-generated-index'}], + ], + }, + }, + ], + }, + ], + }, +}; +``` + +```js +// generate-translations.js +const { + generateTranslationIndexes, +} = require('@shopify/react-i18n/generate-index'); + +generateTranslationIndexes(); +webpack(require(./webpack.config.js)); +``` + +#### Statically embedding locale-specific translations + +For large applications, even asynchronously loaded translations can significantly degrade the user experience: + +- Bundlers like webpack have to embed kilobytes of data to track each translation import +- Users not using the "default" language have to download kilobytes of translations for every language + +To avoid this, it is possible to build versions of app with specific locale translations embedded directly in JavaScript. To achieve this, run the Babel plugin in `from-dictionary-index` mode: + +```js +// webpack.config.js { plugins: [ - ['@shopify/react-i18n/babel', {mode: 'from-generated-index'}], + ['@shopify/react-i18n/babel', {mode: 'from-dictionary-index'}], ], } ``` +Then generate `translations/index.js` files containing specific locale data using the `@shopify/react-i18n/generate-dictionaries` helper. e.g., the following code generates three versions of an application with English, French, and German content using webpack. + ```js // generate-translations.js const { - generateTranslationIndexes, -} = require('@shopify/react-i18n/generate-index'); + generateTranslationDictionaries, +} = require('@shopify/react-i18n/generate-dictionaries'); -generateTranslationIndexes(); +// Build English app. +await generateTranslationDictionaries(['en']); +webpack(require(./webpack.config.js)); + +// Build French app. +await generateTranslationDictionaries(['fr'], {fallbackLocale: 'en'}); webpack(require(./webpack.config.js)); + +// Build German app. +await generateTranslationDictionaries(['de'], {fallbackLocale: 'en'}); +webpack(require(./webpack.config.js)); + ``` ## FAQ diff --git a/packages/react-i18n/generate-dictionaries.d.ts b/packages/react-i18n/generate-dictionaries.d.ts new file mode 100644 index 0000000000..15c4542d34 --- /dev/null +++ b/packages/react-i18n/generate-dictionaries.d.ts @@ -0,0 +1,4 @@ +export { + generateTranslationDictionaries, +} from './dist/src/babel-plugin/generate-dictionaries'; +export * from './dist/src/babel-plugin/generate-dictionaries'; diff --git a/packages/react-i18n/generate-dictionaries.js b/packages/react-i18n/generate-dictionaries.js new file mode 100644 index 0000000000..a1ae438170 --- /dev/null +++ b/packages/react-i18n/generate-dictionaries.js @@ -0,0 +1 @@ +module.exports = require('./dist/src/babel-plugin/generate-dictionaries'); diff --git a/packages/react-i18n/package.json b/packages/react-i18n/package.json index b95dc4c343..96ecd222a8 100644 --- a/packages/react-i18n/package.json +++ b/packages/react-i18n/package.json @@ -42,6 +42,8 @@ "@types/hoist-non-react-statics": "^3.0.1", "change-case": "^3.1.0", "glob": "^7.1.4", + "lodash.clonedeep": "^4.0.0", + "lodash.merge": "^4.0.0", "hoist-non-react-statics": "^3.0.1", "prop-types": "^15.6.2", "string-hash": "^1.1.3", @@ -61,6 +63,8 @@ "babel.d.ts", "babel.js", "generate-index.d.ts", - "generate-index.js" + "generate-index.js", + "generate-dictionaries.d.ts", + "generate-dictionaries.js" ] } diff --git a/packages/react-i18n/src/babel-plugin/babel-templates.ts b/packages/react-i18n/src/babel-plugin/babel-templates.ts index 33463eb03f..84958b279d 100644 --- a/packages/react-i18n/src/babel-plugin/babel-templates.ts +++ b/packages/react-i18n/src/babel-plugin/babel-templates.ts @@ -69,3 +69,23 @@ export function i18nCallExpression( }, )(); } + +export function i18nGeneratedDictionaryCallExpression( + template: TemplateBuilder, + {id, translationsID, bindingName}, +) { + return template( + `${bindingName}({ + id: '${id}', + fallback: Object.values(${translationsID})[0], + translations(locale) { + return Promise.resolve(${translationsID}[locale]); + }, + })`, + { + sourceType: 'module', + plugins: ['dynamicImport'], + preserveComments: true, + }, + )(); +} diff --git a/packages/react-i18n/src/babel-plugin/generate-dictionaries.ts b/packages/react-i18n/src/babel-plugin/generate-dictionaries.ts new file mode 100644 index 0000000000..966b4cdd48 --- /dev/null +++ b/packages/react-i18n/src/babel-plugin/generate-dictionaries.ts @@ -0,0 +1,74 @@ +import {join} from 'path'; + +import fs from 'fs-extra'; +import cloneDeep from 'lodash.clonedeep'; +import merge from 'lodash.merge'; + +import {DEFAULT_FALLBACK_LOCALE, findTranslationBuckets} from './shared'; + +export interface Options { + fallbackLocale: string; + rootDir: string; +} + +export async function generateTranslationDictionaries( + locales: string[], + { + fallbackLocale = DEFAULT_FALLBACK_LOCALE, + rootDir = process.cwd(), + }: Partial = {}, +) { + if (!locales || locales.length === 0) { + throw new Error( + 'generateTranslationDictionary must be called with at least one locale.', + ); + } + + const translationBuckets = findTranslationBuckets(rootDir); + + await Promise.all( + Object.entries(translationBuckets).map( + async ([translationsDir, translationFilePaths]) => { + const fallbackTranslations = await readLocaleTranslations( + fallbackLocale, + translationFilePaths, + ); + + const dictionary = await locales.reduce(async (accPromise, locale) => { + const localeTranslations = await readLocaleTranslations( + locale, + translationFilePaths, + ); + const acc = await accPromise; + acc[locale] = merge( + cloneDeep(fallbackTranslations), + localeTranslations, + ); + return acc; + }, {}); + + const contentStr = JSON.stringify(dictionary); + + // Writing the content out as a JSON.parse-wrapped string seems + // counter-intuitive, but browsers can parse this faster than they + // can parse JavaScript ‾\_(ツ)_/‾ + // https://www.youtube.com/watch?v=ff4fgQxPaO0 + await fs.writeFile( + join(translationsDir, 'index.js'), + `export default JSON.parse(${JSON.stringify(contentStr)})`, + ); + }, + ), + ); +} + +async function readLocaleTranslations( + locale: string, + translationFilePaths: string[], +) { + const translationPath = translationFilePaths.find(filePath => + filePath.endsWith(`/${locale}.json`), + ); + + return translationPath ? fs.readJson(translationPath) : {}; +} diff --git a/packages/react-i18n/src/babel-plugin/generate-index.ts b/packages/react-i18n/src/babel-plugin/generate-index.ts index aef84f08cc..16dab7fcd0 100644 --- a/packages/react-i18n/src/babel-plugin/generate-index.ts +++ b/packages/react-i18n/src/babel-plugin/generate-index.ts @@ -1,37 +1,22 @@ -import {execSync} from 'child_process'; -import {dirname, join} from 'path'; +import {join} from 'path'; import fs from 'fs-extra'; import { - TRANSLATION_DIRECTORY_NAME, DEFAULT_FALLBACK_LOCALE, + findTranslationBuckets, getLocaleIds, toArrayString, } from './shared'; +export interface Options { + fallbackLocale: string; + rootDir: string; +} + export function generateTranslationIndexes() { const fallbackLocale = DEFAULT_FALLBACK_LOCALE; - - // `find` is used here instead of Node's glob because it performs much faster - // (20s vs 1s in web with ~750 translation folders and 21 langs) - const files = execSync( - `find . -type d \\( -path ./node_modules -o -path ./build -o -path ./tmp -o -path ./.git -o -path ./public \\) -prune -o -name '*.json' -print | grep /${TRANSLATION_DIRECTORY_NAME}/`, - ) - .toString() - .trim() - .split('\n') - .sort(); - - const translationBuckets = files.reduce((acc, translationPath) => { - const translationsDir = dirname(translationPath); - if (!acc[translationsDir]) { - acc[translationsDir] = []; - } - - acc[translationsDir].push(translationPath); - return acc; - }, {}); + const translationBuckets = findTranslationBuckets(process.cwd()); Object.entries(translationBuckets).forEach( ([translationsDir, translationFilePaths]: [string, string[]]) => { diff --git a/packages/react-i18n/src/babel-plugin/index.ts b/packages/react-i18n/src/babel-plugin/index.ts index 19aac62122..ac360eeabf 100644 --- a/packages/react-i18n/src/babel-plugin/index.ts +++ b/packages/react-i18n/src/babel-plugin/index.ts @@ -11,13 +11,14 @@ import { fallbackTranslationsImport, translationsImport, i18nCallExpression, + i18nGeneratedDictionaryCallExpression, } from './babel-templates'; import {TRANSLATION_DIRECTORY_NAME, DEFAULT_FALLBACK_LOCALE} from './shared'; export const I18N_CALL_NAMES = ['useI18n', 'withI18n']; export interface Options { - mode?: 'from-generated-index'; + mode?: 'from-generated-index' | 'from-dictionary-index'; } interface State { @@ -36,10 +37,8 @@ export default function injectWithI18nArguments({ binding, bindingName, filename, - fallbackID, - translationArrayID, - fallbackLocale, insertImport, + rewritei18nCall, }) { const {referencePaths} = binding; @@ -73,17 +72,7 @@ export default function injectWithI18nArguments({ } insertImport(); - - referencePathsToRewrite[0].parentPath.replaceWith( - i18nCallExpression(template, { - id: generateID(filename), - fallbackID, - translationArrayID, - bindingName, - translationFilePaths, - fallbackLocale, - }), - ); + rewritei18nCall(referencePathsToRewrite[0], translationFilePaths); } return { @@ -116,24 +105,50 @@ export default function injectWithI18nArguments({ } const {mode} = state.opts; - const fromGeneratedIndex = mode === 'from-generated-index'; const fallbackLocale = DEFAULT_FALLBACK_LOCALE; const fallbackID = nodePath.scope.generateUidIdentifier( camelCase(fallbackLocale), ).name; + const {filename} = this.file.opts; + + if (mode === 'from-dictionary-index') { + const translationArrayID = '__shopify__i18n_translations'; + addI18nArguments({ + binding, + bindingName, + filename, + insertImport() { + const {program} = state; + + program.node.body.unshift( + translationsImport(template, { + id: translationArrayID, + }), + ); + }, + rewritei18nCall(referencePathToRewrite) { + referencePathToRewrite.parentPath.replaceWith( + i18nGeneratedDictionaryCallExpression(template, { + id: generateID(filename), + translationsID: translationArrayID, + bindingName, + }), + ); + }, + }); + return; + } + + const fromGeneratedIndex = mode === 'from-generated-index'; const translationArrayID = fromGeneratedIndex ? '__shopify__i18n_translations' : undefined; - const {filename} = this.file.opts; addI18nArguments({ binding, bindingName, filename, - fallbackID, - translationArrayID, - fallbackLocale, insertImport() { const {program} = state; program.node.body.unshift( @@ -151,6 +166,18 @@ export default function injectWithI18nArguments({ ); } }, + rewritei18nCall(referencePathToRewrite, translationFilePaths) { + referencePathToRewrite.parentPath.replaceWith( + i18nCallExpression(template, { + id: generateID(filename), + fallbackID, + translationArrayID, + bindingName, + translationFilePaths, + fallbackLocale, + }), + ); + }, }); }); }, diff --git a/packages/react-i18n/src/babel-plugin/shared.ts b/packages/react-i18n/src/babel-plugin/shared.ts index d8eedcdab7..ec68ed6acd 100644 --- a/packages/react-i18n/src/babel-plugin/shared.ts +++ b/packages/react-i18n/src/babel-plugin/shared.ts @@ -1,3 +1,4 @@ +import {execSync} from 'child_process'; import path from 'path'; export const TRANSLATION_DIRECTORY_NAME = 'translations'; @@ -23,3 +24,28 @@ export function toArrayString(stringArray: string[]) { .map(singleStr => JSON.stringify(singleStr)) .join(', ')}]`; } + +export function findTranslationBuckets(rootDir) { + // `find` is used here instead of Node's glob because it performs much faster + // (20s vs 1s in web with ~750 translation folders and 21 langs) + const files = execSync( + `find ${rootDir} -type d \\( -path ./node_modules -o -path ./build -o -path ./tmp -o -path ./.git -o -path ./public \\) -prune -o -name '*.json' -print | grep /${TRANSLATION_DIRECTORY_NAME}/`, + ) + .toString() + .trim() + .split('\n') + .sort(); + + return files.reduce( + (acc, translationPath) => { + const translationsDir = path.dirname(translationPath); + if (!acc[translationsDir]) { + acc[translationsDir] = []; + } + + acc[translationsDir].push(translationPath); + return acc; + }, + {} as {[key: string]: string[]}, + ); +} diff --git a/packages/react-i18n/src/babel-plugin/test/babel-plugin.test.ts b/packages/react-i18n/src/babel-plugin/test/babel-plugin.test.ts index 30bd38436d..b7b6ce8f8f 100644 --- a/packages/react-i18n/src/babel-plugin/test/babel-plugin.test.ts +++ b/packages/react-i18n/src/babel-plugin/test/babel-plugin.test.ts @@ -321,6 +321,36 @@ describe('babel-pluin-react-i18n', () => { ), ); }); + + describe('from-dictionary-index', () => { + it('injects a dictionary import, and returns dictionary values from useI18n', async () => { + expect( + await transformUsingI18nBabelPlugin( + useI18nFixture, + optionsForFile('MyComponent.tsx', true), + {mode: 'from-dictionary-index'}, + ), + ).toBe( + await normalize( + `import __shopify__i18n_translations from './translations'; + import React from 'react'; + import {useI18n} from '@shopify/react-i18n'; + + export default function MyComponent() { + const [i18n] = useI18n({ + id: 'MyComponent_${defaultHash}', + fallback: Object.values(__shopify__i18n_translations)[0], + translations(locale) { + return Promise.resolve(__shopify__i18n_translations[locale]); + } + }); + return i18n.translate('key'); + } + `, + ), + ); + }); + }); }); async function transformUsingI18nBabelPlugin( diff --git a/packages/react-i18n/src/babel-plugin/test/fixtures/dictionaryTranslations/translations/de.json b/packages/react-i18n/src/babel-plugin/test/fixtures/dictionaryTranslations/translations/de.json new file mode 100644 index 0000000000..f4e8002907 --- /dev/null +++ b/packages/react-i18n/src/babel-plugin/test/fixtures/dictionaryTranslations/translations/de.json @@ -0,0 +1,3 @@ +{ + "Foo": "foo_de" +} diff --git a/packages/react-i18n/src/babel-plugin/test/fixtures/dictionaryTranslations/translations/en.json b/packages/react-i18n/src/babel-plugin/test/fixtures/dictionaryTranslations/translations/en.json new file mode 100644 index 0000000000..158f215b20 --- /dev/null +++ b/packages/react-i18n/src/babel-plugin/test/fixtures/dictionaryTranslations/translations/en.json @@ -0,0 +1,4 @@ +{ + "Foo": "foo_en", + "Bar": "bar_en" +} diff --git a/packages/react-i18n/src/babel-plugin/test/fixtures/dictionaryTranslations/translations/es.json b/packages/react-i18n/src/babel-plugin/test/fixtures/dictionaryTranslations/translations/es.json new file mode 100644 index 0000000000..e9a6aa4261 --- /dev/null +++ b/packages/react-i18n/src/babel-plugin/test/fixtures/dictionaryTranslations/translations/es.json @@ -0,0 +1,4 @@ +{ + "Foo": "foo_es", + "Bar": "bar_es" +} diff --git a/packages/react-i18n/src/babel-plugin/test/fixtures/dictionaryTranslations/translations/fr.json b/packages/react-i18n/src/babel-plugin/test/fixtures/dictionaryTranslations/translations/fr.json new file mode 100644 index 0000000000..1764416d2c --- /dev/null +++ b/packages/react-i18n/src/babel-plugin/test/fixtures/dictionaryTranslations/translations/fr.json @@ -0,0 +1,4 @@ +{ + "Foo": "foo_fr", + "Bar": "bar_fr" +} diff --git a/packages/react-i18n/src/babel-plugin/test/generate-dictionaries.test.ts b/packages/react-i18n/src/babel-plugin/test/generate-dictionaries.test.ts new file mode 100644 index 0000000000..1bf41b4aba --- /dev/null +++ b/packages/react-i18n/src/babel-plugin/test/generate-dictionaries.test.ts @@ -0,0 +1,55 @@ +import path from 'path'; + +import {readFile, remove} from 'fs-extra'; + +import {generateTranslationDictionaries} from '../generate-dictionaries'; + +const rootDir = path.join( + __dirname, + 'fixtures', + 'dictionaryTranslations', + 'translations', +); + +describe('generate-dictionaries', () => { + afterEach(async () => { + await remove(path.join(rootDir, 'index.js')); + // Clear the generated index.js file out of the require cache. + jest.resetModules(); + }); + + it('combines requested locales into an index dictionary', async () => { + await generateTranslationDictionaries(['es', 'de'], {rootDir}); + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const dictionary = require(`${rootDir}/index`); + expect(Object.keys(dictionary.default)).toStrictEqual(['es', 'de']); + }); + + it('merges fallback translations into missing locale translations', async () => { + await generateTranslationDictionaries(['de'], { + rootDir, + fallbackLocale: 'en', + }); + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const dictionary = require(`${rootDir}/index`); + expect(Object.keys(dictionary.default)).toStrictEqual(['de']); + expect(dictionary.default.de).toStrictEqual({ + Foo: 'foo_de', + Bar: 'bar_en', + }); + }); + + it('encodes the dictionary as a JSON string for faster browser parsing speed', async () => { + await generateTranslationDictionaries(['en'], { + rootDir, + }); + + const dictionary = await readFile(`${rootDir}/index.js`); + + expect(dictionary.toString()).toStrictEqual( + `export default JSON.parse("{\\"en\\":{\\"Foo\\":\\"foo_en\\",\\"Bar\\":\\"bar_en\\"}}")`, + ); + }); +}); diff --git a/yarn.lock b/yarn.lock index d939531d7f..e029c31282 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7180,6 +7180,11 @@ lodash._reinterpolate@^3.0.0: resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0= +lodash.clonedeep@^4.0.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" + integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= + lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" @@ -7242,7 +7247,7 @@ lodash.memoize@^4.1.2: resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= -lodash.merge@^4.6.1: +lodash.merge@^4.0.0, lodash.merge@^4.6.1: version "4.6.2" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==