fix(ivy): i18n - ensure that escaped chars are handled in localized strings (#34065)

When creating synthesized tagged template literals, one must provide both
the "cooked" text and the "raw" (unparsed) text. Previously there were no
good APIs for creating the AST nodes with raw text for such literals.
Recently the APIs were improved to support this, and they do an extra
check to ensure that the raw text parses to be equal to the cooked text.

It turns out there is a bug in this check -
see https://github.com/microsoft/TypeScript/issues/35374.

This commit works around the bug by synthesizing a "head" node and morphing
it by changing its `kind` into the required node type.

// FW-1747

PR Close #34065
This commit is contained in:
Pete Bacon Darwin 2019-11-26 14:03:19 +00:00 committed by Miško Hevery
parent d5aedbe892
commit ee7857300b
3 changed files with 50 additions and 11 deletions

View File

@ -552,27 +552,49 @@ export class TypeTranslatorVisitor implements ExpressionVisitor, TypeVisitor {
function createLocalizedStringTaggedTemplate( function createLocalizedStringTaggedTemplate(
ast: LocalizedString, context: Context, visitor: ExpressionVisitor) { ast: LocalizedString, context: Context, visitor: ExpressionVisitor) {
let template: ts.TemplateLiteral; let template: ts.TemplateLiteral;
const length = ast.messageParts.length;
const metaBlock = ast.serializeI18nHead(); const metaBlock = ast.serializeI18nHead();
if (ast.messageParts.length === 1) { if (length === 1) {
template = ts.createNoSubstitutionTemplateLiteral(metaBlock.cooked, metaBlock.raw); template = ts.createNoSubstitutionTemplateLiteral(metaBlock.cooked, metaBlock.raw);
} else { } else {
// Create the head part
const head = ts.createTemplateHead(metaBlock.cooked, metaBlock.raw); const head = ts.createTemplateHead(metaBlock.cooked, metaBlock.raw);
const spans: ts.TemplateSpan[] = []; const spans: ts.TemplateSpan[] = [];
for (let i = 1; i < ast.messageParts.length; i++) { // Create the middle parts
for (let i = 1; i < length - 1; i++) {
const resolvedExpression = ast.expressions[i - 1].visitExpression(visitor, context); const resolvedExpression = ast.expressions[i - 1].visitExpression(visitor, context);
const templatePart = ast.serializeI18nTemplatePart(i); const templatePart = ast.serializeI18nTemplatePart(i);
const templateMiddle = ts.createTemplateMiddle(templatePart.cooked, templatePart.raw); const templateMiddle = createTemplateMiddle(templatePart.cooked, templatePart.raw);
spans.push(ts.createTemplateSpan(resolvedExpression, templateMiddle)); spans.push(ts.createTemplateSpan(resolvedExpression, templateMiddle));
} }
if (spans.length > 0) { // Create the tail part
// The last span is supposed to have a tail rather than a middle const resolvedExpression = ast.expressions[length - 2].visitExpression(visitor, context);
spans[spans.length - 1].literal.kind = ts.SyntaxKind.TemplateTail; const templatePart = ast.serializeI18nTemplatePart(length - 1);
} const templateTail = createTemplateTail(templatePart.cooked, templatePart.raw);
spans.push(ts.createTemplateSpan(resolvedExpression, templateTail));
// Put it all together
template = ts.createTemplateExpression(head, spans); template = ts.createTemplateExpression(head, spans);
} }
return ts.createTaggedTemplate(ts.createIdentifier('$localize'), template); return ts.createTaggedTemplate(ts.createIdentifier('$localize'), template);
} }
// HACK: Use this in place of `ts.createTemplateMiddle()`.
// Revert once https://github.com/microsoft/TypeScript/issues/35374 is fixed
function createTemplateMiddle(cooked: string, raw: string): ts.TemplateMiddle {
const node: ts.TemplateLiteralLikeNode = ts.createTemplateHead(cooked, raw);
node.kind = ts.SyntaxKind.TemplateMiddle;
return node as ts.TemplateMiddle;
}
// HACK: Use this in place of `ts.createTemplateTail()`.
// Revert once https://github.com/microsoft/TypeScript/issues/35374 is fixed
function createTemplateTail(cooked: string, raw: string): ts.TemplateTail {
const node: ts.TemplateLiteralLikeNode = ts.createTemplateHead(cooked, raw);
node.kind = ts.SyntaxKind.TemplateTail;
return node as ts.TemplateTail;
}
/** /**
* Translate the `LocalizedString` node into a `$localize` call using the imported * Translate the `LocalizedString` node into a `$localize` call using the imported
* `__makeTemplateObject` helper for ES5 formatted output. * `__makeTemplateObject` helper for ES5 formatted output.

View File

@ -78,7 +78,7 @@ function tokenizeBackTickString(str: string): Piece[] {
const pieces: Piece[] = ['`']; const pieces: Piece[] = ['`'];
// Unescape backticks that are inside the backtick string // Unescape backticks that are inside the backtick string
// (we had to double escape them in the test string so they didn't look like string markers) // (we had to double escape them in the test string so they didn't look like string markers)
str = str.replace(/\\\\\\`/, '\\`'); str = str.replace(/\\\\\\`/g, '\\`');
const backTickPieces = str.slice(2, -2).split(BACKTICK_INTERPOLATION); const backTickPieces = str.slice(2, -2).split(BACKTICK_INTERPOLATION);
backTickPieces.forEach((backTickPiece) => { backTickPieces.forEach((backTickPiece) => {
if (BACKTICK_INTERPOLATION.test(backTickPiece)) { if (BACKTICK_INTERPOLATION.test(backTickPiece)) {

View File

@ -956,23 +956,40 @@ describe('i18n support in the template compiler', () => {
it('should properly escape quotes in content', () => { it('should properly escape quotes in content', () => {
const input = ` const input = `
<div i18n>Some text 'with single quotes', "with double quotes" and without quotes.</div> <div i18n>Some text 'with single quotes', "with double quotes", \`with backticks\` and without quotes.</div>
`; `;
const output = String.raw ` const output = String.raw `
var $I18N_0$; var $I18N_0$;
if (ngI18nClosureMode) { if (ngI18nClosureMode) {
const $MSG_EXTERNAL_4924931801512133405$$APP_SPEC_TS_0$ = goog.getMsg("Some text 'with single quotes', \"with double quotes\" and without quotes."); const $MSG_EXTERNAL_4924931801512133405$$APP_SPEC_TS_0$ = goog.getMsg("Some text 'with single quotes', \"with double quotes\", ` +
'`with backticks`' + String.raw ` and without quotes.");
$I18N_0$ = $MSG_EXTERNAL_4924931801512133405$$APP_SPEC_TS_0$; $I18N_0$ = $MSG_EXTERNAL_4924931801512133405$$APP_SPEC_TS_0$;
} }
else { else {
$I18N_0$ = $localize \`Some text 'with single quotes', "with double quotes" and without quotes.\`; $I18N_0$ = $localize \`Some text 'with single quotes', "with double quotes", \\\`with backticks\\\` and without quotes.\`;
} }
`; `;
verify(input, output); verify(input, output);
}); });
it('should handle interpolations wrapped in backticks', () => {
const input = '<div i18n>`{{ count }}`</div>';
const output = String.raw `
var $I18N_0$;
if (ngI18nClosureMode) {
const $MSG_APP_SPEC_TS_1$ = goog.getMsg("` +
'`{$interpolation}`' + String.raw `", { "interpolation": "\uFFFD0\uFFFD" });
$I18N_0$ = $MSG_APP_SPEC_TS_1$;
}
else {
$I18N_0$ = $localize \`\\\`$` +
String.raw `{"\uFFFD0\uFFFD"}:INTERPOLATION:\\\`\`;
}`;
verify(input, output);
});
it('should handle i18n attributes with plain-text content', () => { it('should handle i18n attributes with plain-text content', () => {
const input = ` const input = `
<div i18n>My i18n block #1</div> <div i18n>My i18n block #1</div>