Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

react-i18n - add from-dictionary-index plugin mode #1197

Merged
merged 1 commit into from
Nov 29, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Travis keeps timing out because the build takes >10m with no output. Making it verbose works around Travis' behaviour 🤷🏻‍♂️

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tbh it also works around my behaviour, I get a gut feeling that it's broken every time since we changed it from the old setup :P

- 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));
GoodForOneFare marked this conversation as resolved.
Show resolved Hide resolved

// 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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a pretty great mental image


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(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we have to use lodash merge here because this needs to merge deeply and whatnot? Does make me wonder if we want to use groupBy from lodash for our bucketing operations to make them clearer, but /shrug

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mmmmm, I'm conflicted about this because I hate adding lodash to anything 😸 The merge/clone things were unavoidable, but in for a penny...

I've created an issue to track this enhancement: #1200

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
GoodForOneFare marked this conversation as resolved.
Show resolved Hide resolved
// 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