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:
parent
f17072c7af
commit
fb84ea74fe
|
@ -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"
|
||||||
* ```
|
* ```
|
||||||
|
|
|
@ -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'}));
|
||||||
|
}
|
|
@ -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),
|
||||||
],
|
],
|
||||||
|
|
|
@ -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";');
|
||||||
|
});
|
||||||
|
});
|
|
@ -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();
|
||||||
|
|
Loading…
Reference in New Issue