Skip to content

Commit

Permalink
react-i18n - add from-dictionary-index mode + generator
Browse files Browse the repository at this point in the history
  • Loading branch information
GoodForOneFare committed Nov 29, 2019
1 parent 3c4a704 commit a8f418f
Show file tree
Hide file tree
Showing 18 changed files with 360 additions and 52 deletions.
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
6 changes: 5 additions & 1 deletion packages/react-i18n/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).

---

<!-- ## [Unreleased] -->
## [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

Expand Down
68 changes: 63 additions & 5 deletions packages/react-i18n/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/react-i18n/generate-dictionaries.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export {
generateTranslationDictionaries,
} from './dist/src/babel-plugin/generate-dictionaries';
export * from './dist/src/babel-plugin/generate-dictionaries';
1 change: 1 addition & 0 deletions packages/react-i18n/generate-dictionaries.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('./dist/src/babel-plugin/generate-dictionaries');
6 changes: 5 additions & 1 deletion packages/react-i18n/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
]
}
20 changes: 20 additions & 0 deletions packages/react-i18n/src/babel-plugin/babel-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,23 @@ export function i18nCallExpression(
},
)();
}

export function i18nGeneratedDictionaryCallExpression(
template: TemplateBuilder<Types.ImportDeclaration | Types.ObjectExpression>,
{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,
},
)();
}
74 changes: 74 additions & 0 deletions packages/react-i18n/src/babel-plugin/generate-dictionaries.ts
Original file line number Diff line number Diff line change
@@ -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<Options> = {},
) {
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) : {};
}
31 changes: 8 additions & 23 deletions packages/react-i18n/src/babel-plugin/generate-index.ts
Original file line number Diff line number Diff line change
@@ -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[]]) => {
Expand Down
Loading

0 comments on commit a8f418f

Please sign in to comment.