From 83425fa1196dadffc3556019f8da94aa9f6c63be Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Fri, 11 Oct 2019 10:02:40 +0100 Subject: [PATCH] fix(ivy): i18n - support lazy-load template string helpers (#33097) There are numerous approaches to downlevelling backticked template strings to ES5. This commit handles yet another one that Babel applies. PR Close #33097 --- .../source_files/source_file_utils.ts | 111 ++++++++++++++++-- .../source_files/es5_translate_plugin_spec.ts | 27 +++++ .../source_files/source_file_utils_spec.ts | 60 +++++++--- 3 files changed, 171 insertions(+), 27 deletions(-) diff --git a/packages/localize/src/tools/src/translate/source_files/source_file_utils.ts b/packages/localize/src/tools/src/translate/source_files/source_file_utils.ts index 4314e2a155..b3d5eb44ff 100644 --- a/packages/localize/src/tools/src/translate/source_files/source_file_utils.ts +++ b/packages/localize/src/tools/src/translate/source_files/source_file_utils.ts @@ -79,21 +79,29 @@ export function unwrapMessagePartsFromLocalizeCall(call: NodePath str.value); } +/** + * This expression is believed to be a call to a "lazy-load" template object helper function. + * This is expected to be of the form: + * + * ```ts + * function _templateObject() { + * var e = _taggedTemplateLiteral(['cooked string', 'raw string']); + * return _templateObject = function() { return e }, e + * } + * ``` + * + * We unwrap this to return the call to `_taggedTemplateLiteral()`. + * + * @param call the call expression to unwrap + * @returns the call expression + */ +export function unwrapLazyLoadHelperCall(call: NodePath): + NodePath { + const callee = call.get('callee'); + if (!callee.isIdentifier()) { + throw new BabelParseError( + callee.node, + 'Unexpected lazy-load helper call (expected a call of the form `_templateObject()`).'); + } + const lazyLoadBinding = call.scope.getBinding(callee.node.name); + if (!lazyLoadBinding) { + throw new BabelParseError(callee.node, 'Missing declaration for lazy-load helper function'); + } + const lazyLoadFn = lazyLoadBinding.path; + if (!lazyLoadFn.isFunctionDeclaration()) { + throw new BabelParseError( + lazyLoadFn.node, 'Unexpected expression (expected a function declaration'); + } + const returnedNode = getReturnedExpression(lazyLoadFn); + + if (returnedNode.isCallExpression()) { + return returnedNode; + } + + if (returnedNode.isIdentifier()) { + const identifierName = returnedNode.node.name; + const declaration = returnedNode.scope.getBinding(identifierName); + if (declaration === undefined) { + throw new BabelParseError( + returnedNode.node, 'Missing declaration for return value from helper.'); + } + if (!declaration.path.isVariableDeclarator()) { + throw new BabelParseError( + declaration.path.node, + 'Unexpected helper return value declaration (expected a variable declaration).'); + } + const initializer = declaration.path.get('init'); + if (!initializer.isCallExpression()) { + throw new BabelParseError( + declaration.path.node, + 'Unexpected return value from helper (expected a call expression).'); + } + + // Remove the lazy load helper if this is the only reference to it. + if (lazyLoadBinding.references === 1) { + lazyLoadFn.remove(); + } + + return initializer; + } + return call; +} + +function getReturnedExpression(fn: NodePath): NodePath { + const bodyStatements = fn.get('body').get('body'); + for (const statement of bodyStatements) { + if (statement.isReturnStatement()) { + const argument = statement.get('argument'); + if (argument.isSequenceExpression()) { + const expressions = argument.get('expressions'); + return Array.isArray(expressions) ? expressions[expressions.length - 1] : expressions; + } else if (argument.isExpression()) { + return argument; + } else { + throw new BabelParseError( + statement.node, 'Invalid return argument in helper function (expected an expression).'); + } + } + } + throw new BabelParseError(fn.node, 'Missing return statement in helper function.'); +} + /** * Is the given `node` an array of literal strings? * diff --git a/packages/localize/src/tools/test/translate/source_files/es5_translate_plugin_spec.ts b/packages/localize/src/tools/test/translate/source_files/es5_translate_plugin_spec.ts index b06579c081..ff2a1b89d6 100644 --- a/packages/localize/src/tools/test/translate/source_files/es5_translate_plugin_spec.ts +++ b/packages/localize/src/tools/test/translate/source_files/es5_translate_plugin_spec.ts @@ -103,6 +103,33 @@ describe('makeEs5Plugin', () => { expect(output.code).toEqual('"try" + (40 + 2) + "me";'); }); + it('should handle lazy-load helper calls', () => { + const diagnostics = new Diagnostics(); + const input = ` + function _templateObject2() { + var e = _taggedTemplateLiteral([':escaped-colons:Welcome to the i18n app.'], ['\\\\\\:escaped-colons:Welcome to the i18n app.']); + return _templateObject2 = function() { return e }, e + } + function _templateObject() { + var e = _taggedTemplateLiteral([' Hello ', ':INTERPOLATION:! ']); + return _templateObject = function() { return e }, e + } + function _taggedTemplateLiteral(e, t) { + return t || (t = e.slice(0)), + Object.freeze(Object.defineProperties(e, {raw: {value: Object.freeze(t)}})) + } + const message = $localize(_templateObject2()); + function foo() { + console.log($localize(_templateObject(), '\ufffd0\ufffd')); + } + `; + const output = transformSync(input, {plugins: [makeEs5TranslatePlugin(diagnostics, {})]}) !; + expect(output.code).toContain('const message = ":escaped-colons:Welcome to the i18n app."'); + expect(output.code).toContain('console.log(" Hello " + \'\ufffd0\ufffd\' + "! ");'); + expect(output.code).not.toContain('templateObject'); + expect(output.code).not.toContain('templateObject2'); + }); + it('should add diagnostic error with code-frame information if the arguments to `$localize` are missing', () => { const diagnostics = new Diagnostics(); diff --git a/packages/localize/src/tools/test/translate/source_files/source_file_utils_spec.ts b/packages/localize/src/tools/test/translate/source_files/source_file_utils_spec.ts index 4dba4b5b66..38b8c5f18a 100644 --- a/packages/localize/src/tools/test/translate/source_files/source_file_utils_spec.ts +++ b/packages/localize/src/tools/test/translate/source_files/source_file_utils_spec.ts @@ -9,7 +9,7 @@ import {ɵmakeTemplateObject} from '@angular/localize'; import {NodePath, transformSync} from '@babel/core'; import generate from '@babel/generator'; import template from '@babel/template'; -import {Expression, Identifier, TaggedTemplateExpression, ExpressionStatement, FunctionDeclaration, CallExpression, isParenthesizedExpression, numericLiteral, binaryExpression, NumericLiteral, traverse} from '@babel/types'; +import {Expression, Identifier, TaggedTemplateExpression, ExpressionStatement, FunctionDeclaration, CallExpression, isParenthesizedExpression, numericLiteral, binaryExpression, NumericLiteral} from '@babel/types'; import {isGlobalIdentifier, isNamedIdentifier, isStringLiteralArray, isArrayOfExpressions, unwrapStringLiteralArray, unwrapMessagePartsFromLocalizeCall, wrapInParensIfNecessary, buildLocalizeReplacement, unwrapSubstitutionsFromLocalizeCall, unwrapMessagePartsFromTemplateLiteral} from '../../../src/translate/source_files/source_file_utils'; describe('utils', () => { @@ -71,18 +71,45 @@ describe('utils', () => { describe('unwrapMessagePartsFromLocalizeCall', () => { it('should return an array of string literals from a direct call to a tag function', () => { - const call = getFirstCallExpression(`$localize(['a', 'b\\t', 'c'], 1, 2)`); - const parts = unwrapMessagePartsFromLocalizeCall(call); + const localizeCall = getLocalizeCall(`$localize(['a', 'b\\t', 'c'], 1, 2)`); + const parts = unwrapMessagePartsFromLocalizeCall(localizeCall); expect(parts).toEqual(['a', 'b\t', 'c']); }); it('should return an array of string literals from a downleveled tagged template', () => { - let call = getFirstCallExpression( + let localizeCall = getLocalizeCall( `$localize(__makeTemplateObject(['a', 'b\\t', 'c'], ['a', 'b\\\\t', 'c']), 1, 2)`); - const parts = unwrapMessagePartsFromLocalizeCall(call); + const parts = unwrapMessagePartsFromLocalizeCall(localizeCall); expect(parts).toEqual(['a', 'b\t', 'c']); expect(parts.raw).toEqual(['a', 'b\\t', 'c']); }); + + it('should return an array of string literals from a lazy load template helper', () => { + let localizeCall = getLocalizeCall(` + function _templateObject() { + var e = _taggedTemplateLiteral(['a', 'b', 'c'], ['a', 'b', 'c']); + return _templateObject = function() { return e }, e + } + $localize(_templateObject(), 1, 2)`); + const parts = unwrapMessagePartsFromLocalizeCall(localizeCall); + expect(parts).toEqual(['a', 'b', 'c']); + expect(parts.raw).toEqual(['a', 'b', 'c']); + }); + + it('should remove a lazy load template helper', () => { + let localizeCall = getLocalizeCall(` + function _templateObject() { + var e = _taggedTemplateLiteral(['a', 'b', 'c'], ['a', 'b', 'c']); + return _templateObject = function() { return e }, e + } + $localize(_templateObject(), 1, 2)`); + const localizeStatement = localizeCall.parentPath as NodePath; + const statements = localizeStatement.container as object[]; + expect(statements.length).toEqual(2); + unwrapMessagePartsFromLocalizeCall(localizeCall); + expect(statements.length).toEqual(1); + expect(statements[0]).toBe(localizeStatement.node); + }); }); describe('unwrapSubstitutionsFromLocalizeCall', () => { @@ -180,20 +207,15 @@ function collectExpressionsPlugin() { return {expressions, plugin: {visitor}}; } -function getFirstCallExpression(code: string): NodePath { - let callPath: NodePath|undefined = undefined; - transformSync(code, { - plugins: [{ - visitor: { - CallExpression(path) { - callPath = path; - path.stop(); - } - } - }] +function getLocalizeCall(code: string): NodePath { + let callPaths: NodePath[] = []; + transformSync(code, {plugins: [{visitor: {CallExpression(path) { callPaths.push(path); }}}]}); + const localizeCall = callPaths.find(p => { + const callee = p.get('callee'); + return (callee.isIdentifier() && callee.node.name === '$localize'); }); - if (callPath === undefined) { - throw new Error('CallExpression not found in code:' + code); + if (!localizeCall) { + throw new Error(`$localize cannot be found in ${code}`); } - return callPath; + return localizeCall; }