refactor(localize): add placeholder locations in extracted messages (#38536)

Some translation file formats would like to be able to render the
text of placeholders taken from the original source files. This commit
adds this information to the extracted messages so that it can be
used in translation file serializers.

PR Close #38536
This commit is contained in:
Pete Bacon Darwin 2020-08-19 15:31:55 +01:00 committed by Misko Hevery
parent b8351f3b10
commit db3a21b382
7 changed files with 471 additions and 199 deletions

View File

@ -9,7 +9,7 @@ import {ɵParsedMessage, ɵparseMessage} from '@angular/localize';
import {NodePath, PluginObj} from '@babel/core'; import {NodePath, PluginObj} from '@babel/core';
import {TaggedTemplateExpression} from '@babel/types'; import {TaggedTemplateExpression} from '@babel/types';
import {getLocation, isGlobalIdentifier, isNamedIdentifier, unwrapMessagePartsFromTemplateLiteral} from '../../source_file_utils'; import {getLocation, isGlobalIdentifier, isNamedIdentifier, unwrapExpressionsFromTemplateLiteral, unwrapMessagePartsFromTemplateLiteral} from '../../source_file_utils';
export function makeEs2015ExtractPlugin( export function makeEs2015ExtractPlugin(
messages: ɵParsedMessage[], localizeName = '$localize'): PluginObj { messages: ɵParsedMessage[], localizeName = '$localize'): PluginObj {
@ -18,9 +18,14 @@ export function makeEs2015ExtractPlugin(
TaggedTemplateExpression(path: NodePath<TaggedTemplateExpression>) { TaggedTemplateExpression(path: NodePath<TaggedTemplateExpression>) {
const tag = path.get('tag'); const tag = path.get('tag');
if (isNamedIdentifier(tag, localizeName) && isGlobalIdentifier(tag)) { if (isNamedIdentifier(tag, localizeName) && isGlobalIdentifier(tag)) {
const messageParts = unwrapMessagePartsFromTemplateLiteral(path.node.quasi.quasis); const quasiPath = path.get('quasi');
const location = getLocation(path.get('quasi')); const [messageParts, messagePartLocations] =
const message = ɵparseMessage(messageParts, path.node.quasi.expressions, location); unwrapMessagePartsFromTemplateLiteral(quasiPath.get('quasis'));
const [expressions, expressionLocations] =
unwrapExpressionsFromTemplateLiteral(quasiPath);
const location = getLocation(quasiPath);
const message = ɵparseMessage(
messageParts, expressions, location, messagePartLocations, expressionLocations);
messages.push(message); messages.push(message);
} }
} }

View File

@ -18,10 +18,12 @@ export function makeEs5ExtractPlugin(
CallExpression(callPath: NodePath<CallExpression>) { CallExpression(callPath: NodePath<CallExpression>) {
const calleePath = callPath.get('callee'); const calleePath = callPath.get('callee');
if (isNamedIdentifier(calleePath, localizeName) && isGlobalIdentifier(calleePath)) { if (isNamedIdentifier(calleePath, localizeName) && isGlobalIdentifier(calleePath)) {
const messageParts = unwrapMessagePartsFromLocalizeCall(callPath); const [messageParts, messagePartLocations] = unwrapMessagePartsFromLocalizeCall(callPath);
const expressions = unwrapSubstitutionsFromLocalizeCall(callPath.node); const [expressions, expressionLocations] = unwrapSubstitutionsFromLocalizeCall(callPath);
const location = getLocation(callPath); const [messagePartsArg, expressionsArg] = callPath.get('arguments');
const message = ɵparseMessage(messageParts, expressions, location); const location = getLocation(messagePartsArg, expressionsArg);
const message = ɵparseMessage(
messageParts, expressions, location, messagePartLocations, expressionLocations);
messages.push(message); messages.push(message);
} }
} }

View File

@ -67,7 +67,7 @@ export function buildLocalizeReplacement(
* @param call The AST node of the call to process. * @param call The AST node of the call to process.
*/ */
export function unwrapMessagePartsFromLocalizeCall(call: NodePath<t.CallExpression>): export function unwrapMessagePartsFromLocalizeCall(call: NodePath<t.CallExpression>):
TemplateStringsArray { [TemplateStringsArray, (ɵSourceLocation | undefined)[]] {
let cooked = call.get('arguments')[0]; let cooked = call.get('arguments')[0];
if (cooked === undefined) { if (cooked === undefined) {
@ -137,34 +137,44 @@ export function unwrapMessagePartsFromLocalizeCall(call: NodePath<t.CallExpressi
raw = arg2 !== undefined ? arg2 : cooked; raw = arg2 !== undefined ? arg2 : cooked;
} }
const cookedStrings = unwrapStringLiteralArray(cooked.node); const [cookedStrings] = unwrapStringLiteralArray(cooked);
const rawStrings = unwrapStringLiteralArray(raw.node); const [rawStrings, rawLocations] = unwrapStringLiteralArray(raw);
return ɵmakeTemplateObject(cookedStrings, rawStrings); return [ɵmakeTemplateObject(cookedStrings, rawStrings), rawLocations];
} }
export function unwrapSubstitutionsFromLocalizeCall(call: t.CallExpression): t.Expression[] { export function unwrapSubstitutionsFromLocalizeCall(call: NodePath<t.CallExpression>):
const expressions = call.arguments.splice(1); [t.Expression[], (ɵSourceLocation | undefined)[]] {
const expressions = call.get('arguments').splice(1);
if (!isArrayOfExpressions(expressions)) { if (!isArrayOfExpressions(expressions)) {
const badExpression = expressions.find(expression => !t.isExpression(expression))!; const badExpression = expressions.find(expression => !expression.isExpression())!;
throw new BabelParseError( throw new BabelParseError(
badExpression, badExpression.node,
'Invalid substitutions for `$localize` (expected all substitution arguments to be expressions).'); 'Invalid substitutions for `$localize` (expected all substitution arguments to be expressions).');
} }
return expressions; return [
expressions.map(path => path.node), expressions.map(expression => getLocation(expression))
];
} }
export function unwrapMessagePartsFromTemplateLiteral(elements: t.TemplateElement[]): export function unwrapMessagePartsFromTemplateLiteral(elements: NodePath<t.TemplateElement>[]):
TemplateStringsArray { [TemplateStringsArray, (ɵSourceLocation | undefined)[]] {
const cooked = elements.map(q => { const cooked = elements.map(q => {
if (q.value.cooked === undefined) { if (q.node.value.cooked === undefined) {
throw new BabelParseError( throw new BabelParseError(
q, `Unexpected undefined message part in "${elements.map(q => q.value.cooked)}"`); q.node,
`Unexpected undefined message part in "${elements.map(q => q.node.value.cooked)}"`);
} }
return q.value.cooked; return q.node.value.cooked;
}); });
const raw = elements.map(q => q.value.raw); const raw = elements.map(q => q.node.value.raw);
return ɵmakeTemplateObject(cooked, raw); const locations = elements.map(q => getLocation(q));
return [ɵmakeTemplateObject(cooked, raw), locations];
}
export function unwrapExpressionsFromTemplateLiteral(quasi: NodePath<t.TemplateLiteral>):
[t.Expression[], (ɵSourceLocation | undefined)[]] {
return [quasi.node.expressions, quasi.get('expressions').map(e => getLocation(e))];
} }
/** /**
@ -186,12 +196,14 @@ export function wrapInParensIfNecessary(expression: t.Expression): t.Expression
* Extract the string values from an `array` of string literals. * Extract the string values from an `array` of string literals.
* @param array The array to unwrap. * @param array The array to unwrap.
*/ */
export function unwrapStringLiteralArray(array: t.Expression): string[] { export function unwrapStringLiteralArray(array: NodePath<t.Expression>):
if (!isStringLiteralArray(array)) { [string[], (ɵSourceLocation | undefined)[]] {
if (!isStringLiteralArray(array.node)) {
throw new BabelParseError( throw new BabelParseError(
array, 'Unexpected messageParts for `$localize` (expected an array of strings).'); array.node, 'Unexpected messageParts for `$localize` (expected an array of strings).');
} }
return array.elements.map((str: t.StringLiteral) => str.value); const elements = array.get('elements') as NodePath<t.StringLiteral>[];
return [elements.map(str => str.node.value), elements.map(str => getLocation(str))];
} }
/** /**
@ -295,8 +307,8 @@ export function isStringLiteralArray(node: t.Node): node is t.Expression&
* Are all the given `nodes` expressions? * Are all the given `nodes` expressions?
* @param nodes The nodes to test. * @param nodes The nodes to test.
*/ */
export function isArrayOfExpressions(nodes: t.Node[]): nodes is t.Expression[] { export function isArrayOfExpressions(paths: NodePath<t.Node>[]): paths is NodePath<t.Expression>[] {
return nodes.every(element => t.isExpression(element)); return paths.every(element => element.isExpression());
} }
/** Options that affect how the `makeEsXXXTranslatePlugin()` functions work. */ /** Options that affect how the `makeEsXXXTranslatePlugin()` functions work. */
@ -361,7 +373,8 @@ export function getLocation(startPath: NodePath, endPath?: NodePath): ɵSourceLo
return { return {
start: getLineAndColumn(startLocation.start), start: getLineAndColumn(startLocation.start),
end: getLineAndColumn(endLocation.end), end: getLineAndColumn(endLocation.end),
file file,
text: getText(startPath),
}; };
} }
@ -375,7 +388,7 @@ export function serializeLocationPosition(location: ɵSourceLocation): string {
function getFileFromPath(path: NodePath|undefined): AbsoluteFsPath|null { function getFileFromPath(path: NodePath|undefined): AbsoluteFsPath|null {
const opts = path?.hub.file.opts; const opts = path?.hub.file.opts;
return opts?.filename ? return opts?.filename ?
resolve(opts.generatorOpts.sourceRoot, relative(opts.cwd, opts.filename)) : resolve(opts.generatorOpts.sourceRoot ?? opts.cwd, relative(opts.cwd, opts.filename)) :
null; null;
} }
@ -383,3 +396,10 @@ function getLineAndColumn(loc: {line: number, column: number}): {line: number, c
// Note we want 0-based line numbers but Babel returns 1-based. // Note we want 0-based line numbers but Babel returns 1-based.
return {line: loc.line - 1, column: loc.column}; return {line: loc.line - 1, column: loc.column};
} }
function getText(path: NodePath): string|undefined {
if (path.node.start === null || path.node.end === null) {
return undefined;
}
return path.hub.file.code.substring(path.node.start, path.node.end);
}

View File

@ -23,7 +23,8 @@ export function makeEs2015TranslatePlugin(
try { try {
const tag = path.get('tag'); const tag = path.get('tag');
if (isLocalize(tag, localizeName)) { if (isLocalize(tag, localizeName)) {
const messageParts = unwrapMessagePartsFromTemplateLiteral(path.node.quasi.quasis); const [messageParts] =
unwrapMessagePartsFromTemplateLiteral(path.get('quasi').get('quasis'));
const translated = translate( const translated = translate(
diagnostics, translations, messageParts, path.node.quasi.expressions, diagnostics, translations, messageParts, path.node.quasi.expressions,
missingTranslation); missingTranslation);

View File

@ -23,8 +23,8 @@ export function makeEs5TranslatePlugin(
try { try {
const calleePath = callPath.get('callee'); const calleePath = callPath.get('callee');
if (isLocalize(calleePath, localizeName)) { if (isLocalize(calleePath, localizeName)) {
const messageParts = unwrapMessagePartsFromLocalizeCall(callPath); const [messageParts] = unwrapMessagePartsFromLocalizeCall(callPath);
const expressions = unwrapSubstitutionsFromLocalizeCall(callPath.node); const [expressions] = unwrapSubstitutionsFromLocalizeCall(callPath);
const translated = const translated =
translate(diagnostics, translations, messageParts, expressions, missingTranslation); translate(diagnostics, translations, messageParts, expressions, missingTranslation);
callPath.replaceWith(buildLocalizeReplacement(translated[0], translated[1])); callPath.replaceWith(buildLocalizeReplacement(translated[0], translated[1]));

View File

@ -36,11 +36,50 @@ runInEachFileSystem(() => {
description: 'description', description: 'description',
meaning: 'meaning', meaning: 'meaning',
messageParts: ['a', 'b', 'c'], messageParts: ['a', 'b', 'c'],
messagePartLocations: [
{
start: {line: 0, column: 10},
end: {line: 0, column: 32},
file: absoluteFrom('/root/path/relative/path.js'),
text: ':meaning|description:a',
},
{
start: {line: 0, column: 36},
end: {line: 0, column: 37},
file: absoluteFrom('/root/path/relative/path.js'),
text: 'b',
},
{
start: {line: 0, column: 41},
end: {line: 0, column: 42},
file: absoluteFrom('/root/path/relative/path.js'),
text: 'c',
}
],
text: 'a{$PH}b{$PH_1}c', text: 'a{$PH}b{$PH_1}c',
placeholderNames: ['PH', 'PH_1'], placeholderNames: ['PH', 'PH_1'],
substitutions: jasmine.any(Object), substitutions: jasmine.any(Object),
substitutionLocations: {
PH: {
start: {line: 0, column: 34},
end: {line: 0, column: 35},
file: absoluteFrom('/root/path/relative/path.js'),
text: '1'
},
PH_1: {
start: {line: 0, column: 39},
end: {line: 0, column: 40},
file: absoluteFrom('/root/path/relative/path.js'),
text: '2'
}
},
legacyIds: [], legacyIds: [],
location: {start: {line: 0, column: 9}, end: {line: 0, column: 43}, file}, location: {
start: {line: 0, column: 9},
end: {line: 0, column: 43},
file,
text: '`:meaning|description:a${1}b${2}c`',
},
}); });
expect(messages[1]).toEqual({ expect(messages[1]).toEqual({
@ -49,11 +88,51 @@ runInEachFileSystem(() => {
description: '', description: '',
meaning: '', meaning: '',
messageParts: ['a', 'b', 'c'], messageParts: ['a', 'b', 'c'],
messagePartLocations: [
{
start: {line: 1, column: 69},
end: {line: 1, column: 72},
file: absoluteFrom('/root/path/relative/path.js'),
text: '"a"',
},
{
start: {line: 1, column: 74},
end: {line: 1, column: 97},
file: absoluteFrom('/root/path/relative/path.js'),
text: '":custom-placeholder:b"',
},
{
start: {line: 1, column: 99},
end: {line: 1, column: 102},
file: absoluteFrom('/root/path/relative/path.js'),
text: '"c"',
}
],
text: 'a{$custom-placeholder}b{$PH_1}c', text: 'a{$custom-placeholder}b{$PH_1}c',
placeholderNames: ['custom-placeholder', 'PH_1'], placeholderNames: ['custom-placeholder', 'PH_1'],
substitutions: jasmine.any(Object), substitutions: jasmine.any(Object),
substitutionLocations: {
'custom-placeholder': {
start: {line: 1, column: 106},
end: {line: 1, column: 107},
file: absoluteFrom('/root/path/relative/path.js'),
text: '1'
},
PH_1: {
start: {line: 1, column: 109},
end: {line: 1, column: 110},
file: absoluteFrom('/root/path/relative/path.js'),
text: '2'
}
},
legacyIds: [], legacyIds: [],
location: {start: {line: 1, column: 0}, end: {line: 1, column: 111}, file}, location: {
start: {line: 1, column: 10},
end: {line: 1, column: 107},
file,
text:
'__makeTemplateObject(["a", ":custom-placeholder:b", "c"], ["a", ":custom-placeholder:b", "c"])',
},
}); });
expect(messages[2]).toEqual({ expect(messages[2]).toEqual({
@ -65,8 +144,47 @@ runInEachFileSystem(() => {
text: 'a{$PH}b{$PH_1}c', text: 'a{$PH}b{$PH_1}c',
placeholderNames: ['PH', 'PH_1'], placeholderNames: ['PH', 'PH_1'],
substitutions: jasmine.any(Object), substitutions: jasmine.any(Object),
substitutionLocations: {
PH: {
start: {line: 2, column: 26},
end: {line: 2, column: 27},
file: absoluteFrom('/root/path/relative/path.js'),
text: '1'
},
PH_1: {
start: {line: 2, column: 31},
end: {line: 2, column: 32},
file: absoluteFrom('/root/path/relative/path.js'),
text: '2'
}
},
messagePartLocations: [
{
start: {line: 2, column: 10},
end: {line: 2, column: 24},
file: absoluteFrom('/root/path/relative/path.js'),
text: ':@@custom-id:a'
},
{
start: {line: 2, column: 28},
end: {line: 2, column: 29},
file: absoluteFrom('/root/path/relative/path.js'),
text: 'b'
},
{
start: {line: 2, column: 33},
end: {line: 2, column: 34},
file: absoluteFrom('/root/path/relative/path.js'),
text: 'c'
}
],
legacyIds: [], legacyIds: [],
location: {start: {line: 2, column: 9}, end: {line: 2, column: 35}, file}, location: {
start: {line: 2, column: 9},
end: {line: 2, column: 35},
file,
text: '`:@@custom-id:a${1}b${2}c`'
},
}); });
}); });
}); });

View File

@ -12,192 +12,303 @@ import {NodePath, TransformOptions, 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} from '@babel/types'; import {Expression, Identifier, TaggedTemplateExpression, ExpressionStatement, CallExpression, isParenthesizedExpression, numericLiteral, binaryExpression, NumericLiteral} from '@babel/types';
import {isGlobalIdentifier, isNamedIdentifier, isStringLiteralArray, isArrayOfExpressions, unwrapStringLiteralArray, unwrapMessagePartsFromLocalizeCall, wrapInParensIfNecessary, buildLocalizeReplacement, unwrapSubstitutionsFromLocalizeCall, unwrapMessagePartsFromTemplateLiteral, getLocation} from '../src/source_file_utils'; import {isGlobalIdentifier, isNamedIdentifier, isStringLiteralArray, isArrayOfExpressions, unwrapStringLiteralArray, unwrapMessagePartsFromLocalizeCall, wrapInParensIfNecessary, buildLocalizeReplacement, unwrapSubstitutionsFromLocalizeCall, unwrapMessagePartsFromTemplateLiteral, getLocation} from '../src/source_file_utils';
describe('utils', () => { runInEachFileSystem(() => {
describe('isNamedIdentifier()', () => { describe('utils', () => {
it('should return true if the expression is an identifier with name `$localize`', () => { describe('isNamedIdentifier()', () => {
const taggedTemplate = getTaggedTemplate('$localize ``;'); it('should return true if the expression is an identifier with name `$localize`', () => {
expect(isNamedIdentifier(taggedTemplate.get('tag'), '$localize')).toBe(true); const taggedTemplate = getTaggedTemplate('$localize ``;');
expect(isNamedIdentifier(taggedTemplate.get('tag'), '$localize')).toBe(true);
});
it('should return false if the expression is an identifier without the name `$localize`',
() => {
const taggedTemplate = getTaggedTemplate('other ``;');
expect(isNamedIdentifier(taggedTemplate.get('tag'), '$localize')).toBe(false);
});
it('should return false if the expression is not an identifier', () => {
const taggedTemplate = getTaggedTemplate('$localize() ``;');
expect(isNamedIdentifier(taggedTemplate.get('tag'), '$localize')).toBe(false);
});
}); });
it('should return false if the expression is an identifier without the name `$localize`', describe('isGlobalIdentifier()', () => {
() => { it('should return true if the identifier is at the top level and not declared', () => {
const taggedTemplate = getTaggedTemplate('other ``;'); const taggedTemplate = getTaggedTemplate('$localize ``;');
expect(isNamedIdentifier(taggedTemplate.get('tag'), '$localize')).toBe(false); expect(isGlobalIdentifier(taggedTemplate.get('tag') as NodePath<Identifier>)).toBe(true);
}); });
it('should return false if the expression is not an identifier', () => { it('should return true if the identifier is in a block scope and not declared', () => {
const taggedTemplate = getTaggedTemplate('$localize() ``;'); const taggedTemplate = getTaggedTemplate('function foo() { $localize ``; } foo();');
expect(isNamedIdentifier(taggedTemplate.get('tag'), '$localize')).toBe(false); expect(isGlobalIdentifier(taggedTemplate.get('tag') as NodePath<Identifier>)).toBe(true);
}); });
});
describe('isGlobalIdentifier()', () => { it('should return false if the identifier is declared locally', () => {
it('should return true if the identifier is at the top level and not declared', () => { const taggedTemplate = getTaggedTemplate('function $localize() {} $localize ``;');
const taggedTemplate = getTaggedTemplate('$localize ``;'); expect(isGlobalIdentifier(taggedTemplate.get('tag') as NodePath<Identifier>)).toBe(false);
expect(isGlobalIdentifier(taggedTemplate.get('tag') as NodePath<Identifier>)).toBe(true); });
it('should return false if the identifier is a function parameter', () => {
const taggedTemplate = getTaggedTemplate('function foo($localize) { $localize ``; }');
expect(isGlobalIdentifier(taggedTemplate.get('tag') as NodePath<Identifier>)).toBe(false);
});
}); });
it('should return true if the identifier is in a block scope and not declared', () => { describe('buildLocalizeReplacement', () => {
const taggedTemplate = getTaggedTemplate('function foo() { $localize ``; } foo();'); it('should interleave the `messageParts` with the `substitutions`', () => {
expect(isGlobalIdentifier(taggedTemplate.get('tag') as NodePath<Identifier>)).toBe(true); const messageParts = ɵmakeTemplateObject(['a', 'b', 'c'], ['a', 'b', 'c']);
const substitutions = [numericLiteral(1), numericLiteral(2)];
const expression = buildLocalizeReplacement(messageParts, substitutions);
expect(generate(expression).code).toEqual('"a" + 1 + "b" + 2 + "c"');
});
it('should wrap "binary expression" substitutions in parentheses', () => {
const messageParts = ɵmakeTemplateObject(['a', 'b'], ['a', 'b']);
const binary = binaryExpression('+', numericLiteral(1), numericLiteral(2));
const expression = buildLocalizeReplacement(messageParts, [binary]);
expect(generate(expression).code).toEqual('"a" + (1 + 2) + "b"');
});
}); });
it('should return false if the identifier is declared locally', () => { describe('unwrapMessagePartsFromLocalizeCall', () => {
const taggedTemplate = getTaggedTemplate('function $localize() {} $localize ``;'); it('should return an array of string literals and locations from a direct call to a tag function',
expect(isGlobalIdentifier(taggedTemplate.get('tag') as NodePath<Identifier>)).toBe(false); () => {
}); const localizeCall = getLocalizeCall(`$localize(['a', 'b\\t', 'c'], 1, 2)`);
const [parts, locations] = unwrapMessagePartsFromLocalizeCall(localizeCall);
expect(parts).toEqual(['a', 'b\t', 'c']);
expect(locations).toEqual([
{
start: {line: 0, column: 11},
end: {line: 0, column: 14},
file: absoluteFrom('/test/file.js'),
text: `'a'`,
},
{
start: {line: 0, column: 16},
end: {line: 0, column: 21},
file: absoluteFrom('/test/file.js'),
text: `'b\\t'`,
},
{
start: {line: 0, column: 23},
end: {line: 0, column: 26},
file: absoluteFrom('/test/file.js'),
text: `'c'`,
},
]);
});
it('should return false if the identifier is a function parameter', () => { it('should return an array of string literals and locations from a downleveled tagged template',
const taggedTemplate = getTaggedTemplate('function foo($localize) { $localize ``; }'); () => {
expect(isGlobalIdentifier(taggedTemplate.get('tag') as NodePath<Identifier>)).toBe(false); let localizeCall = getLocalizeCall(
}); `$localize(__makeTemplateObject(['a', 'b\\t', 'c'], ['a', 'b\\\\t', 'c']), 1, 2)`);
}); const [parts, locations] = unwrapMessagePartsFromLocalizeCall(localizeCall);
expect(parts).toEqual(['a', 'b\t', 'c']);
expect(parts.raw).toEqual(['a', 'b\\t', 'c']);
expect(locations).toEqual([
{
start: {line: 0, column: 51},
end: {line: 0, column: 54},
file: absoluteFrom('/test/file.js'),
text: `'a'`,
},
{
start: {line: 0, column: 56},
end: {line: 0, column: 62},
file: absoluteFrom('/test/file.js'),
text: `'b\\\\t'`,
},
{
start: {line: 0, column: 64},
end: {line: 0, column: 67},
file: absoluteFrom('/test/file.js'),
text: `'c'`,
},
]);
});
describe('buildLocalizeReplacement', () => { it('should return an array of string literals and locations from a lazy load template helper',
it('should interleave the `messageParts` with the `substitutions`', () => { () => {
const messageParts = ɵmakeTemplateObject(['a', 'b', 'c'], ['a', 'b', 'c']); let localizeCall = getLocalizeCall(`
const substitutions = [numericLiteral(1), numericLiteral(2)]; function _templateObject() {
const expression = buildLocalizeReplacement(messageParts, substitutions); var e = _taggedTemplateLiteral(['a', 'b\\t', 'c'], ['a', 'b\\\\t', 'c']);
expect(generate(expression).code).toEqual('"a" + 1 + "b" + 2 + "c"'); return _templateObject = function() { return e }, e
}); }
$localize(_templateObject(), 1, 2)`);
const [parts, locations] = unwrapMessagePartsFromLocalizeCall(localizeCall);
expect(parts).toEqual(['a', 'b\t', 'c']);
expect(parts.raw).toEqual(['a', 'b\\t', 'c']);
expect(locations).toEqual([
{
start: {line: 2, column: 61},
end: {line: 2, column: 64},
file: absoluteFrom('/test/file.js'),
text: `'a'`,
},
{
start: {line: 2, column: 66},
end: {line: 2, column: 72},
file: absoluteFrom('/test/file.js'),
text: `'b\\\\t'`,
},
{
start: {line: 2, column: 74},
end: {line: 2, column: 77},
file: absoluteFrom('/test/file.js'),
text: `'c'`,
},
]);
});
it('should wrap "binary expression" substitutions in parentheses', () => { it('should remove a lazy load template helper', () => {
const messageParts = ɵmakeTemplateObject(['a', 'b'], ['a', 'b']); let localizeCall = getLocalizeCall(`
const binary = binaryExpression('+', numericLiteral(1), numericLiteral(2));
const expression = buildLocalizeReplacement(messageParts, [binary]);
expect(generate(expression).code).toEqual('"a" + (1 + 2) + "b"');
});
});
describe('unwrapMessagePartsFromLocalizeCall', () => {
it('should return an array of string literals from a direct call to a tag function', () => {
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 localizeCall = getLocalizeCall(
`$localize(__makeTemplateObject(['a', 'b\\t', 'c'], ['a', 'b\\\\t', 'c']), 1, 2)`);
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() { function _templateObject() {
var e = _taggedTemplateLiteral(['a', 'b', 'c'], ['a', 'b', 'c']); var e = _taggedTemplateLiteral(['a', 'b', 'c'], ['a', 'b', 'c']);
return _templateObject = function() { return e }, e return _templateObject = function() { return e }, e
} }
$localize(_templateObject(), 1, 2)`); $localize(_templateObject(), 1, 2)`);
const parts = unwrapMessagePartsFromLocalizeCall(localizeCall); const localizeStatement = localizeCall.parentPath as NodePath<ExpressionStatement>;
expect(parts).toEqual(['a', 'b', 'c']); const statements = localizeStatement.container as object[];
expect(parts.raw).toEqual(['a', 'b', 'c']); expect(statements.length).toEqual(2);
unwrapMessagePartsFromLocalizeCall(localizeCall);
expect(statements.length).toEqual(1);
expect(statements[0]).toBe(localizeStatement.node);
});
}); });
it('should remove a lazy load template helper', () => { describe('unwrapSubstitutionsFromLocalizeCall', () => {
let localizeCall = getLocalizeCall(` it('should return the substitutions and locations from a direct call to a tag function',
function _templateObject() { () => {
var e = _taggedTemplateLiteral(['a', 'b', 'c'], ['a', 'b', 'c']); const call = getLocalizeCall(`$localize(['a', 'b\t', 'c'], 1, 2)`);
return _templateObject = function() { return e }, e const [substitutions, locations] = unwrapSubstitutionsFromLocalizeCall(call);
} expect((substitutions as NumericLiteral[]).map(s => s.value)).toEqual([1, 2]);
$localize(_templateObject(), 1, 2)`); expect(locations).toEqual([
const localizeStatement = localizeCall.parentPath as NodePath<ExpressionStatement>; {
const statements = localizeStatement.container as object[]; start: {line: 0, column: 28},
expect(statements.length).toEqual(2); end: {line: 0, column: 29},
unwrapMessagePartsFromLocalizeCall(localizeCall); file: absoluteFrom('/test/file.js'),
expect(statements.length).toEqual(1); text: '1'
expect(statements[0]).toBe(localizeStatement.node); },
}); {
}); start: {line: 0, column: 31},
end: {line: 0, column: 32},
file: absoluteFrom('/test/file.js'),
text: '2'
},
]);
});
describe('unwrapSubstitutionsFromLocalizeCall', () => { it('should return the substitutions and locations from a downleveled tagged template', () => {
it('should return the substitutions from a direct call to a tag function', () => { const call = getLocalizeCall(
const ast = template.ast`$localize(['a', 'b\t', 'c'], 1, 2)` as ExpressionStatement; `$localize(__makeTemplateObject(['a', 'b', 'c'], ['a', 'b', 'c']), 1, 2)`);
const call = ast.expression as CallExpression; const [substitutions, locations] = unwrapSubstitutionsFromLocalizeCall(call);
const substitutions = unwrapSubstitutionsFromLocalizeCall(call); expect((substitutions as NumericLiteral[]).map(s => s.value)).toEqual([1, 2]);
expect(substitutions.map(s => (s as NumericLiteral).value)).toEqual([1, 2]); expect(locations).toEqual([
{
start: {line: 0, column: 66},
end: {line: 0, column: 67},
file: absoluteFrom('/test/file.js'),
text: '1'
},
{
start: {line: 0, column: 69},
end: {line: 0, column: 70},
file: absoluteFrom('/test/file.js'),
text: '2'
},
]);
});
}); });
it('should return the substitutions from a downleveled tagged template', () => { describe('unwrapMessagePartsFromTemplateLiteral', () => {
const ast = template.ast it('should return a TemplateStringsArray built from the template literal elements', () => {
`$localize(__makeTemplateObject(['a', 'b', 'c'], ['a', 'b', 'c']), 1, 2)` as const taggedTemplate = getTaggedTemplate('$localize `a${1}b\\t${2}c`;');
ExpressionStatement; expect(unwrapMessagePartsFromTemplateLiteral(taggedTemplate.get('quasi').get('quasis'))[0])
const call = ast.expression as CallExpression; .toEqual(ɵmakeTemplateObject(['a', 'b\t', 'c'], ['a', 'b\\t', 'c']));
const substitutions = unwrapSubstitutionsFromLocalizeCall(call); });
expect(substitutions.map(s => (s as NumericLiteral).value)).toEqual([1, 2]);
});
});
describe('unwrapMessagePartsFromTemplateLiteral', () => {
it('should return a TemplateStringsArray built from the template literal elements', () => {
const taggedTemplate = getTaggedTemplate('$localize `a${1}b\\t${2}c`;');
expect(unwrapMessagePartsFromTemplateLiteral(taggedTemplate.node.quasi.quasis))
.toEqual(ɵmakeTemplateObject(['a', 'b\t', 'c'], ['a', 'b\\t', 'c']));
});
});
describe('wrapInParensIfNecessary', () => {
it('should wrap the expression in parentheses if it is binary', () => {
const ast = template.ast`a + b` as ExpressionStatement;
const wrapped = wrapInParensIfNecessary(ast.expression);
expect(isParenthesizedExpression(wrapped)).toBe(true);
}); });
it('should return the expression untouched if it is not binary', () => { describe('wrapInParensIfNecessary', () => {
const ast = template.ast`a` as ExpressionStatement; it('should wrap the expression in parentheses if it is binary', () => {
const wrapped = wrapInParensIfNecessary(ast.expression); const ast = template.ast`a + b` as ExpressionStatement;
expect(isParenthesizedExpression(wrapped)).toBe(false); const wrapped = wrapInParensIfNecessary(ast.expression);
}); expect(isParenthesizedExpression(wrapped)).toBe(true);
}); });
describe('unwrapStringLiteralArray', () => { it('should return the expression untouched if it is not binary', () => {
it('should return an array of string from an array expression', () => { const ast = template.ast`a` as ExpressionStatement;
const ast = template.ast`['a', 'b', 'c']` as ExpressionStatement; const wrapped = wrapInParensIfNecessary(ast.expression);
expect(unwrapStringLiteralArray(ast.expression)).toEqual(['a', 'b', 'c']); expect(isParenthesizedExpression(wrapped)).toBe(false);
});
}); });
it('should throw an error if any elements of the array are not literal strings', () => { describe('unwrapStringLiteralArray', () => {
const ast = template.ast`['a', 2, 'c']` as ExpressionStatement; it('should return an array of string from an array expression', () => {
expect(() => unwrapStringLiteralArray(ast.expression)) const array = getFirstExpression(`['a', 'b', 'c']`);
.toThrowError('Unexpected messageParts for `$localize` (expected an array of strings).'); const [expressions, locations] = unwrapStringLiteralArray(array);
}); expect(expressions).toEqual(['a', 'b', 'c']);
}); expect(locations).toEqual([
{
start: {line: 0, column: 1},
end: {line: 0, column: 4},
file: absoluteFrom('/test/file.js'),
text: `'a'`,
},
{
start: {line: 0, column: 6},
end: {line: 0, column: 9},
file: absoluteFrom('/test/file.js'),
text: `'b'`,
},
{
start: {line: 0, column: 11},
end: {line: 0, column: 14},
file: absoluteFrom('/test/file.js'),
text: `'c'`,
},
]);
});
describe('isStringLiteralArray()', () => { it('should throw an error if any elements of the array are not literal strings', () => {
it('should return true if the ast is an array of strings', () => { const array = getFirstExpression(`['a', 2, 'c']`);
const ast = template.ast`['a', 'b', 'c']` as ExpressionStatement; expect(() => unwrapStringLiteralArray(array))
expect(isStringLiteralArray(ast.expression)).toBe(true); .toThrowError(
'Unexpected messageParts for `$localize` (expected an array of strings).');
});
}); });
it('should return false if the ast is not an array', () => { describe('isStringLiteralArray()', () => {
const ast = template.ast`'a'` as ExpressionStatement; it('should return true if the ast is an array of strings', () => {
expect(isStringLiteralArray(ast.expression)).toBe(false); const ast = template.ast`['a', 'b', 'c']` as ExpressionStatement;
expect(isStringLiteralArray(ast.expression)).toBe(true);
});
it('should return false if the ast is not an array', () => {
const ast = template.ast`'a'` as ExpressionStatement;
expect(isStringLiteralArray(ast.expression)).toBe(false);
});
it('should return false if at least on of the array elements is not a string', () => {
const ast = template.ast`['a', 1, 'b']` as ExpressionStatement;
expect(isStringLiteralArray(ast.expression)).toBe(false);
});
}); });
it('should return false if at least on of the array elements is not a string', () => { describe('isArrayOfExpressions()', () => {
const ast = template.ast`['a', 1, 'b']` as ExpressionStatement; it('should return true if all the nodes are expressions', () => {
expect(isStringLiteralArray(ast.expression)).toBe(false); const call = getFirstExpression<CallExpression>('foo(a, b, c);');
}); expect(isArrayOfExpressions(call.get('arguments'))).toBe(true);
}); });
describe('isArrayOfExpressions()', () => { it('should return false if any of the nodes is not an expression', () => {
it('should return true if all the nodes are expressions', () => { const call = getFirstExpression<CallExpression>('foo(a, b, ...c);');
const ast = template.ast`function foo(a, b, c) {}` as FunctionDeclaration; expect(isArrayOfExpressions(call.get('arguments'))).toBe(false);
expect(isArrayOfExpressions(ast.params)).toBe(true); });
}); });
it('should return false if any of the nodes is not an expression', () => {
const ast = template.ast`function foo(a, b, ...c) {}` as FunctionDeclaration;
expect(isArrayOfExpressions(ast.params)).toBe(false);
});
});
runInEachFileSystem(() => {
describe('getLocation()', () => { describe('getLocation()', () => {
it('should return a plain object containing the start, end and file of a NodePath', () => { it('should return a plain object containing the start, end and file of a NodePath', () => {
const taggedTemplate = getTaggedTemplate('const x = $localize `message`;', { const taggedTemplate = getTaggedTemplate('const x = $localize `message`;', {
@ -214,7 +325,8 @@ describe('utils', () => {
}); });
it('should return `undefined` if the NodePath has no filename', () => { it('should return `undefined` if the NodePath has no filename', () => {
const taggedTemplate = getTaggedTemplate('const x = $localize ``;', {sourceRoot: '/root'}); const taggedTemplate = getTaggedTemplate(
'const x = $localize ``;', {sourceRoot: '/root', filename: undefined});
const location = getLocation(taggedTemplate); const location = getLocation(taggedTemplate);
expect(location).toBeUndefined(); expect(location).toBeUndefined();
}); });
@ -224,24 +336,38 @@ describe('utils', () => {
function getTaggedTemplate( function getTaggedTemplate(
code: string, options?: TransformOptions): NodePath<TaggedTemplateExpression> { code: string, options?: TransformOptions): NodePath<TaggedTemplateExpression> {
const {expressions, plugin} = collectExpressionsPlugin(); return getExpressions<TaggedTemplateExpression>(code, options)
transformSync(code, {...options, plugins: [plugin]}); .find(e => e.isTaggedTemplateExpression())!;
return expressions.find(e => e.isTaggedTemplateExpression()) as any;
} }
function collectExpressionsPlugin() { function getFirstExpression<T extends Expression>(
code: string, options?: TransformOptions): NodePath<T> {
return getExpressions<T>(code, options)[0];
}
function getExpressions<T extends Expression>(
code: string, options?: TransformOptions): NodePath<T>[] {
const expressions: NodePath<Expression>[] = []; const expressions: NodePath<Expression>[] = [];
const visitor = { transformSync(code, {
Expression: (path: NodePath<Expression>) => { code: false,
expressions.push(path); filename: '/test/file.js',
} plugins: [{
}; visitor: {
return {expressions, plugin: {visitor}}; Expression: (path: NodePath<Expression>) => {
expressions.push(path);
}
}
}],
...options
});
return expressions as NodePath<T>[];
} }
function getLocalizeCall(code: string): NodePath<CallExpression> { function getLocalizeCall(code: string): NodePath<CallExpression> {
let callPaths: NodePath<CallExpression>[] = []; let callPaths: NodePath<CallExpression>[] = [];
transformSync(code, { transformSync(code, {
code: false,
filename: '/test/file.js',
plugins: [{ plugins: [{
visitor: { visitor: {
CallExpression(path) { CallExpression(path) {