feat(ivy): i18n - inline current locale at compile-time (#33314)

During compile-time translation inlining, the `$localize.locale`
expression will now be replaced with a string literal containing the
current locale of the translations.

PR Close #33314
This commit is contained in:
Pete Bacon Darwin 2019-10-23 22:49:02 +01:00 committed by Andrew Kushnir
parent f17072c7af
commit fb84ea74fe
5 changed files with 220 additions and 5 deletions

View File

@ -22,15 +22,15 @@ export interface LocalizeFn {
translate?: TranslateFn; translate?: TranslateFn;
/** /**
* The current locale of the translated messages. * The current locale of the translated messages.
* *
* The compile-time translation inliner is able to replace the following code: * The compile-time translation inliner is able to replace the following code:
* *
* ``` * ```
* $localize && $localize.locale * typeof $localize !== "undefined" && $localize.locale
* ``` * ```
* *
* with a string literal of the current locale. E.g. * with a string literal of the current locale. E.g.
* *
* ``` * ```
* "fr" * "fr"
* ``` * ```

View File

@ -0,0 +1,91 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {NodePath, PluginObj} from '@babel/core';
import {MemberExpression, stringLiteral} from '@babel/types';
import {TranslatePluginOptions, isLocalize} from './source_file_utils';
/**
* This Babel plugin will replace the following code forms with a string literal containing the
* given `locale`.
*
* * `$localize.locale` -> `"locale"`
* * `typeof $localize !== "undefined" && $localize.locale` -> `"locale"`
* * `xxx && typeof $localize !== "undefined" && $localize.locale` -> `"xxx && locale"`
* * `$localize.locale || default` -> `"locale" || default`
*
* @param locale The name of the locale to inline into the code.
* @param options Additional options including the name of the `$localize` function.
*/
export function makeLocalePlugin(
locale: string, {localizeName = '$localize'}: TranslatePluginOptions = {}): PluginObj {
return {
visitor: {
MemberExpression(expression: NodePath<MemberExpression>) {
const obj = expression.get('object');
if (!isLocalize(obj, localizeName)) {
return;
}
const property = expression.get('property') as NodePath;
if (!property.isIdentifier({name: 'locale'})) {
return;
}
if (expression.parentPath.isAssignmentExpression() &&
expression.parentPath.get('left') === expression) {
return;
}
// Check for the `$localize.locale` being guarded by a check on the existence of
// `$localize`.
const parent = expression.parentPath;
if (parent.isLogicalExpression({operator: '&&'}) && parent.get('right') === expression) {
const left = parent.get('left');
if (isLocalizeGuard(left, localizeName)) {
// Replace `typeof $localize !== "undefined" && $localize.locale` with
// `$localize.locale`
parent.replaceWith(expression);
} else if (
left.isLogicalExpression({operator: '&&'}) &&
isLocalizeGuard(left.get('right'), localizeName)) {
// The `$localize` is part of a preceding logical AND.
// Replace XXX && typeof $localize !== "undefined" && $localize.locale` with `XXX &&
// $localize.locale`
left.replaceWith(left.get('left'));
}
}
// Replace the `$localize.locale` with the string literal
expression.replaceWith(stringLiteral(locale));
}
}
};
}
/**
* Returns true if the expression one of:
* * `typeof $localize !== "undefined"`
* * `"undefined" !== typeof $localize`
* * `typeof $localize != "undefined"`
* * `"undefined" != typeof $localize`
*
* @param expression the expression to check
* @param localizeName the name of the `$localize` symbol
*/
function isLocalizeGuard(expression: NodePath, localizeName: string): boolean {
if (!expression.isBinaryExpression() ||
!(expression.node.operator === '!==' || expression.node.operator === '!=')) {
return false;
}
const left = expression.get('left');
const right = expression.get('right');
return (left.isUnaryExpression({operator: 'typeof'}) &&
isLocalize(left.get('argument'), localizeName) &&
right.isStringLiteral({value: 'undefined'})) ||
(right.isUnaryExpression({operator: 'typeof'}) &&
isLocalize(right.get('argument'), localizeName) &&
left.isStringLiteral({value: 'undefined'}));
}

View File

@ -17,9 +17,11 @@ import {TranslationBundle, TranslationHandler} from '../translator';
import {makeEs2015TranslatePlugin} from './es2015_translate_plugin'; import {makeEs2015TranslatePlugin} from './es2015_translate_plugin';
import {makeEs5TranslatePlugin} from './es5_translate_plugin'; import {makeEs5TranslatePlugin} from './es5_translate_plugin';
import {makeLocalePlugin} from './locale_plugin';
import {TranslatePluginOptions} from './source_file_utils'; import {TranslatePluginOptions} from './source_file_utils';
/** /**
* Translate a file by inlining all messages tagged by `$localize` with the appropriate translated * Translate a file by inlining all messages tagged by `$localize` with the appropriate translated
* message. * message.
@ -76,6 +78,7 @@ export class SourceFileTranslationHandler implements TranslationHandler {
compact: true, compact: true,
generatorOpts: {minified: true}, generatorOpts: {minified: true},
plugins: [ plugins: [
makeLocalePlugin(translationBundle.locale),
makeEs2015TranslatePlugin(diagnostics, translationBundle.translations, options), makeEs2015TranslatePlugin(diagnostics, translationBundle.translations, options),
makeEs5TranslatePlugin(diagnostics, translationBundle.translations, options), makeEs5TranslatePlugin(diagnostics, translationBundle.translations, options),
], ],

View File

@ -0,0 +1,98 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {transformSync} from '@babel/core';
import {makeLocalePlugin} from '../../../src/translate/source_files/locale_plugin';
describe('makeLocalePlugin', () => {
it('should replace $localize.locale with the locale string', () => {
const input = '$localize.locale;';
const output = transformSync(input, {plugins: [makeLocalePlugin('fr')]}) !;
expect(output.code).toEqual('"fr";');
});
it('should replace $localize.locale with the locale string in the context of a variable assignment',
() => {
const input = 'const a = $localize.locale;';
const output = transformSync(input, {plugins: [makeLocalePlugin('fr')]}) !;
expect(output.code).toEqual('const a = "fr";');
});
it('should replace $localize.locale with the locale string in the context of a binary expression',
() => {
const input = '$localize.locale || "default";';
const output = transformSync(input, {plugins: [makeLocalePlugin('fr')]}) !;
expect(output.code).toEqual('"fr" || "default";');
});
it('should remove reference to `$localize` if used to guard the locale', () => {
const input = 'typeof $localize !== "undefined" && $localize.locale;';
const output = transformSync(input, {plugins: [makeLocalePlugin('fr')]}) !;
expect(output.code).toEqual('"fr";');
});
it('should remove reference to `$localize` if used in a longer logical expression to guard the locale',
() => {
const input1 = 'x || y && typeof $localize !== "undefined" && $localize.locale;';
const output1 = transformSync(input1, {plugins: [makeLocalePlugin('fr')]}) !;
expect(output1.code).toEqual('x || y && "fr";');
const input2 = 'x || y && "undefined" !== typeof $localize && $localize.locale;';
const output2 = transformSync(input2, {plugins: [makeLocalePlugin('fr')]}) !;
expect(output2.code).toEqual('x || y && "fr";');
const input3 = 'x || y && typeof $localize != "undefined" && $localize.locale;';
const output3 = transformSync(input3, {plugins: [makeLocalePlugin('fr')]}) !;
expect(output3.code).toEqual('x || y && "fr";');
const input4 = 'x || y && "undefined" != typeof $localize && $localize.locale;';
const output4 = transformSync(input4, {plugins: [makeLocalePlugin('fr')]}) !;
expect(output4.code).toEqual('x || y && "fr";');
});
it('should ignore properties on $localize other than `locale`', () => {
const input = '$localize.notLocale;';
const output = transformSync(input, {plugins: [makeLocalePlugin('fr')]}) !;
expect(output.code).toEqual('$localize.notLocale;');
});
it('should ignore indexed property on $localize', () => {
const input = '$localize["locale"];';
const output = transformSync(input, {plugins: [makeLocalePlugin('fr')]}) !;
expect(output.code).toEqual('$localize["locale"];');
});
it('should ignore `locale` on objects other than $localize', () => {
const input = '$notLocalize.locale;';
const output = transformSync(input, {plugins: [makeLocalePlugin('fr')]}) !;
expect(output.code).toEqual('$notLocalize.locale;');
});
it('should ignore `$localize.locale` if `$localize` is not global', () => {
const input = 'const $localize = {};\n$localize.locale;';
const output = transformSync(input, {plugins: [makeLocalePlugin('fr')]}) !;
expect(output.code).toEqual('const $localize = {};\n$localize.locale;');
});
it('should ignore `locale` if it is not directly accessed from `$localize`', () => {
const input = 'const {locale} = $localize;\nconst a = locale;';
const output = transformSync(input, {plugins: [makeLocalePlugin('fr')]}) !;
expect(output.code).toEqual('const {\n locale\n} = $localize;\nconst a = locale;');
});
it('should ignore `$localize.locale` on LHS of an assignment', () => {
const input = 'let a;\na = $localize.locale = "de";';
const output = transformSync(input, {plugins: [makeLocalePlugin('fr')]}) !;
expect(output.code).toEqual('let a;\na = $localize.locale = "de";');
});
it('should handle `$localize.locale on RHS of an assignment', () => {
const input = 'let a;\na = $localize.locale;';
const output = transformSync(input, {plugins: [makeLocalePlugin('fr')]}) !;
expect(output.code).toEqual('let a;\na = "fr";');
});
});

View File

@ -88,6 +88,29 @@ describe('SourceFileTranslationHandler', () => {
.toHaveBeenCalledWith('/translations/en-US/relative/path.js', output); .toHaveBeenCalledWith('/translations/en-US/relative/path.js', output);
}); });
it('should transform `$localize.locale` identifiers', () => {
const diagnostics = new Diagnostics();
const handler = new SourceFileTranslationHandler();
const translations: TranslationBundle[] = [
{locale: 'fr', translations: {}},
];
const contents = Buffer.from(
'const x = $localize.locale;\n' +
'const y = typeof $localize !== "undefined" && $localize.locale;\n' +
'const z = "undefined" !== typeof $localize && $localize.locale || "default";');
const getOutput = (locale: string) =>
`const x="${locale}";const y="${locale}";const z="${locale}"||"default";`;
handler.translate(
diagnostics, '/root/path', 'relative/path.js', contents, mockOutputPathFn, translations,
'en-US');
expect(FileUtils.writeFile)
.toHaveBeenCalledWith('/translations/fr/relative/path.js', getOutput('fr'));
expect(FileUtils.writeFile)
.toHaveBeenCalledWith('/translations/en-US/relative/path.js', getOutput('en-US'));
});
it('should error if the file is not valid JS', () => { it('should error if the file is not valid JS', () => {
const diagnostics = new Diagnostics(); const diagnostics = new Diagnostics();
const handler = new SourceFileTranslationHandler(); const handler = new SourceFileTranslationHandler();