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..ba51641509d04 --- /dev/null +++ b/packages/localize/src/tools/src/translate/source_files/locale_plugin.ts @@ -0,0 +1,62 @@ +/** + * @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 {LogicalExpression, 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"` + * * `$localize && $localize.locale` -> `"locale"` + * * `xxx && $localize && $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() || property.node.name !== 'locale') { + return; + } + debugger; + // Check for the `$localize.locale` being guarded by a check on the existence of + // `$localize`. + const parent = expression.parentPath; + if (parent.isLogicalExpression() && parent.node.operator === '&&' && + parent.get('right') === expression) { + const left = parent.get('left'); + if (isLocalize(left, localizeName)) { + // Replace `$localize && $localize.locale` with `$localize.locale` + parent.replaceWith(expression); + } else if ( + left.isLogicalExpression() && left.node.operator === '&&' && + isLocalize(left.get('right'), localizeName)) { + // The `$localize` is part of a preceding logical AND. + // Replace XXX && $localize && $localize.locale` with `XXX && $localize.locale` + left.replaceWith(left.get('left')); + } + } + + // Replace the `$localize.locale` with the string literal + expression.replaceWith(stringLiteral(locale)); + } + } + }; +} 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..2c0cffb909e61 --- /dev/null +++ b/packages/localize/src/tools/test/translate/source_files/locale_plugin_spec.ts @@ -0,0 +1,74 @@ +/** + * @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('makeEs2015Plugin', () => { + 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 = '$localize && $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 input = 'someValue && $localize && $localize.locale;'; + const output = transformSync(input, {plugins: [makeLocalePlugin('fr')]}) !; + expect(output.code).toEqual('someValue && "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 {locale} = $localize;\nconst a = locale;'); + }); +}); 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..4e4fb15ebf340 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 = $localize && $localize.locale;\n' + + 'const z = $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();