diff --git a/packages/localize/src/localize/src/localize.ts b/packages/localize/src/localize/src/localize.ts index e94d9853a8cde..660b4e2538ae9 100644 --- a/packages/localize/src/localize/src/localize.ts +++ b/packages/localize/src/localize/src/localize.ts @@ -22,15 +22,15 @@ export interface LocalizeFn { translate?: TranslateFn; /** * The current locale of the translated messages. - * + * * 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. - * + * * ``` * "fr" * ``` diff --git a/packages/localize/src/tools/src/translate/source_files/locale_plugin.ts b/packages/localize/src/tools/src/translate/source_files/locale_plugin.ts new file mode 100644 index 0000000000000..aaa0237628fa1 --- /dev/null +++ b/packages/localize/src/tools/src/translate/source_files/locale_plugin.ts @@ -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) { + 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'})); +} diff --git a/packages/localize/src/tools/src/translate/source_files/source_file_translation_handler.ts b/packages/localize/src/tools/src/translate/source_files/source_file_translation_handler.ts index 912ed2cb5f02e..391f568eec7aa 100644 --- a/packages/localize/src/tools/src/translate/source_files/source_file_translation_handler.ts +++ b/packages/localize/src/tools/src/translate/source_files/source_file_translation_handler.ts @@ -17,9 +17,11 @@ import {TranslationBundle, TranslationHandler} from '../translator'; import {makeEs2015TranslatePlugin} from './es2015_translate_plugin'; import {makeEs5TranslatePlugin} from './es5_translate_plugin'; +import {makeLocalePlugin} from './locale_plugin'; import {TranslatePluginOptions} from './source_file_utils'; + /** * Translate a file by inlining all messages tagged by `$localize` with the appropriate translated * message. @@ -76,6 +78,7 @@ export class SourceFileTranslationHandler implements TranslationHandler { compact: true, generatorOpts: {minified: true}, plugins: [ + makeLocalePlugin(translationBundle.locale), makeEs2015TranslatePlugin(diagnostics, translationBundle.translations, options), makeEs5TranslatePlugin(diagnostics, translationBundle.translations, options), ], diff --git a/packages/localize/src/tools/test/translate/source_files/locale_plugin_spec.ts b/packages/localize/src/tools/test/translate/source_files/locale_plugin_spec.ts new file mode 100644 index 0000000000000..b1033c4191048 --- /dev/null +++ b/packages/localize/src/tools/test/translate/source_files/locale_plugin_spec.ts @@ -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";'); + }); +}); diff --git a/packages/localize/src/tools/test/translate/source_files/source_file_translation_handler_spec.ts b/packages/localize/src/tools/test/translate/source_files/source_file_translation_handler_spec.ts index 62a4ec472dda0..97749abd77653 100644 --- a/packages/localize/src/tools/test/translate/source_files/source_file_translation_handler_spec.ts +++ b/packages/localize/src/tools/test/translate/source_files/source_file_translation_handler_spec.ts @@ -88,6 +88,29 @@ describe('SourceFileTranslationHandler', () => { .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', () => { const diagnostics = new Diagnostics(); const handler = new SourceFileTranslationHandler();