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
This commit is contained in:
Pete Bacon Darwin 2019-10-11 10:02:40 +01:00 committed by Miško Hevery
parent 1845faa66b
commit 83425fa119
3 changed files with 171 additions and 27 deletions

View File

@ -79,21 +79,29 @@ export function unwrapMessagePartsFromLocalizeCall(call: NodePath<t.CallExpressi
} }
} }
// Check for `__makeTemplateObject(cooked, raw)` call // Check for `__makeTemplateObject(cooked, raw)` or `__templateObject()` calls.
if (cooked.isCallExpression()) { if (cooked.isCallExpression()) {
const arg2 = cooked.get('arguments')[1]; let call = cooked;
if (!arg2.isExpression()) { if (call.get('arguments').length === 0) {
throw new BabelParseError( // No arguments so perhaps it is a `__templateObject()` call.
arg2.node, // Unwrap this to get the `_taggedTemplateLiteral(cooked, raw)` call.
'Unexpected `raw` argument to the "makeTemplateObject()" function (expected an expression).'); call = unwrapLazyLoadHelperCall(call);
} }
raw = arg2;
cooked = cooked.get('arguments')[0]; cooked = call.get('arguments')[0];
if (!cooked.isExpression()) { if (!cooked.isExpression()) {
throw new BabelParseError( throw new BabelParseError(
cooked.node, cooked.node,
'Unexpected `cooked` argument to the "makeTemplateObject()" function (expected an expression).'); 'Unexpected `cooked` argument to the "makeTemplateObject()" function (expected an expression).');
} }
const arg2 = call.get('arguments')[1];
if (arg2 && !arg2.isExpression()) {
throw new BabelParseError(
arg2.node,
'Unexpected `raw` argument to the "makeTemplateObject()" function (expected an expression).');
}
// If there is no second argument then assume that raw and cooked are the same
raw = arg2 !== undefined ? arg2 : cooked;
} }
const cookedStrings = unwrapStringLiteralArray(cooked.node); const cookedStrings = unwrapStringLiteralArray(cooked.node);
@ -153,6 +161,93 @@ export function unwrapStringLiteralArray(array: t.Expression): string[] {
return array.elements.map((str: t.StringLiteral) => str.value); return array.elements.map((str: t.StringLiteral) => 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<t.CallExpression>):
NodePath<t.CallExpression> {
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<t.FunctionDeclaration>): NodePath<t.Expression> {
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? * Is the given `node` an array of literal strings?
* *

View File

@ -103,6 +103,33 @@ describe('makeEs5Plugin', () => {
expect(output.code).toEqual('"try" + (40 + 2) + "me";'); 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', it('should add diagnostic error with code-frame information if the arguments to `$localize` are missing',
() => { () => {
const diagnostics = new Diagnostics(); const diagnostics = new Diagnostics();

View File

@ -9,7 +9,7 @@ import {ɵmakeTemplateObject} from '@angular/localize';
import {NodePath, transformSync} from '@babel/core'; import {NodePath, transformSync} from '@babel/core';
import generate from '@babel/generator'; import generate from '@babel/generator';
import template from '@babel/template'; 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'; import {isGlobalIdentifier, isNamedIdentifier, isStringLiteralArray, isArrayOfExpressions, unwrapStringLiteralArray, unwrapMessagePartsFromLocalizeCall, wrapInParensIfNecessary, buildLocalizeReplacement, unwrapSubstitutionsFromLocalizeCall, unwrapMessagePartsFromTemplateLiteral} from '../../../src/translate/source_files/source_file_utils';
describe('utils', () => { describe('utils', () => {
@ -71,18 +71,45 @@ describe('utils', () => {
describe('unwrapMessagePartsFromLocalizeCall', () => { describe('unwrapMessagePartsFromLocalizeCall', () => {
it('should return an array of string literals from a direct call to a tag function', () => { 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 localizeCall = getLocalizeCall(`$localize(['a', 'b\\t', 'c'], 1, 2)`);
const parts = unwrapMessagePartsFromLocalizeCall(call); const parts = unwrapMessagePartsFromLocalizeCall(localizeCall);
expect(parts).toEqual(['a', 'b\t', 'c']); expect(parts).toEqual(['a', 'b\t', 'c']);
}); });
it('should return an array of string literals from a downleveled tagged template', () => { 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)`); `$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).toEqual(['a', 'b\t', 'c']);
expect(parts.raw).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<ExpressionStatement>;
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', () => { describe('unwrapSubstitutionsFromLocalizeCall', () => {
@ -180,20 +207,15 @@ function collectExpressionsPlugin() {
return {expressions, plugin: {visitor}}; return {expressions, plugin: {visitor}};
} }
function getFirstCallExpression(code: string): NodePath<CallExpression> { function getLocalizeCall(code: string): NodePath<CallExpression> {
let callPath: NodePath<CallExpression>|undefined = undefined; let callPaths: NodePath<CallExpression>[] = [];
transformSync(code, { transformSync(code, {plugins: [{visitor: {CallExpression(path) { callPaths.push(path); }}}]});
plugins: [{ const localizeCall = callPaths.find(p => {
visitor: { const callee = p.get('callee');
CallExpression(path) { return (callee.isIdentifier() && callee.node.name === '$localize');
callPath = path;
path.stop();
}
}
}]
}); });
if (callPath === undefined) { if (!localizeCall) {
throw new Error('CallExpression not found in code:' + code); throw new Error(`$localize cannot be found in ${code}`);
} }
return callPath; return localizeCall;
} }