fix(ivy): i18n - support "\", "`" and "${" sequences in i18n messages (#33820)
Since i18n messages are mapped to `$localize` tagged template strings, the "raw" version must be properly escaped. Otherwise TS will throw an error such as: ``` Error: Debug Failure. False expression: Expected argument 'text' to be the normalized (i.e. 'cooked') version of argument 'rawText'. ``` This commit ensures that we properly escape these raw strings before creating TS AST nodes from them. PR Close #33820
This commit is contained in:

committed by
Alex Rickabaugh

parent
62f7d0fe5c
commit
a6247aafa1
@ -8,7 +8,6 @@
|
||||
|
||||
import {ArrayType, AssertNotNull, BinaryOperator, BinaryOperatorExpr, BuiltinType, BuiltinTypeName, CastExpr, ClassStmt, CommaExpr, CommentStmt, ConditionalExpr, DeclareFunctionStmt, DeclareVarStmt, Expression, ExpressionStatement, ExpressionType, ExpressionVisitor, ExternalExpr, ExternalReference, FunctionExpr, IfStmt, InstantiateExpr, InvokeFunctionExpr, InvokeMethodExpr, JSDocCommentStmt, LiteralArrayExpr, LiteralExpr, LiteralMapExpr, MapType, NotExpr, ReadKeyExpr, ReadPropExpr, ReadVarExpr, ReturnStatement, Statement, StatementVisitor, StmtModifier, ThrowStmt, TryCatchStmt, Type, TypeVisitor, TypeofExpr, WrappedNodeExpr, WriteKeyExpr, WritePropExpr, WriteVarExpr} from '@angular/compiler';
|
||||
import {LocalizedString} from '@angular/compiler/src/output/output_ast';
|
||||
import {serializeI18nHead, serializeI18nTemplatePart} from '@angular/compiler/src/render3/view/i18n/meta';
|
||||
import * as ts from 'typescript';
|
||||
|
||||
import {DefaultImportRecorder, ImportRewriter, NOOP_DEFAULT_IMPORT_RECORDER, NoopImportRewriter} from '../../imports';
|
||||
@ -547,7 +546,7 @@ export class TypeTranslatorVisitor implements ExpressionVisitor, TypeVisitor {
|
||||
*/
|
||||
function visitLocalizedString(ast: LocalizedString, context: Context, visitor: ExpressionVisitor) {
|
||||
let template: ts.TemplateLiteral;
|
||||
const metaBlock = serializeI18nHead(ast.metaBlock, ast.messageParts[0]);
|
||||
const metaBlock = ast.serializeI18nHead();
|
||||
if (ast.messageParts.length === 1) {
|
||||
template = ts.createNoSubstitutionTemplateLiteral(metaBlock.cooked, metaBlock.raw);
|
||||
} else {
|
||||
@ -555,9 +554,8 @@ function visitLocalizedString(ast: LocalizedString, context: Context, visitor: E
|
||||
const spans: ts.TemplateSpan[] = [];
|
||||
for (let i = 1; i < ast.messageParts.length; i++) {
|
||||
const resolvedExpression = ast.expressions[i - 1].visitExpression(visitor, context);
|
||||
const templatePart =
|
||||
serializeI18nTemplatePart(ast.placeHolderNames[i - 1], ast.messageParts[i]);
|
||||
const templateMiddle = ts.createTemplateMiddle(templatePart);
|
||||
const templatePart = ast.serializeI18nTemplatePart(i);
|
||||
const templateMiddle = ts.createTemplateMiddle(templatePart.cooked, templatePart.raw);
|
||||
spans.push(ts.createTemplateSpan(resolvedExpression, templateMiddle));
|
||||
}
|
||||
if (spans.length > 0) {
|
||||
|
@ -14,15 +14,15 @@ import {NgtscProgram} from '../../src/ngtsc/program';
|
||||
|
||||
const IDENTIFIER = /[A-Za-z_$ɵ][A-Za-z0-9_$]*/;
|
||||
const OPERATOR =
|
||||
/!|\?|%|\*|\/|\^|&&?|\|\|?|\(|\)|\{|\}|\[|\]|:|;|<=?|>=?|={1,3}|!==?|=>|\+\+?|--?|@|,|\.|\.\.\./;
|
||||
/!|\?|%|\*|\/|\^|&&?|\|\|?|\(|\)|\{|\}|\[|\]|:|;|<=?|>=?|={1,3}|!==?|=>|\+\+?|--?|@|,|\.|\.\.\.|`|\\'/;
|
||||
const STRING = /'(\\'|[^'])*'|"(\\"|[^"])*"/;
|
||||
const BACKTICK_STRING = /\\`(([\s\S]*?)(\$\{[^}]*?\})?)*?\\`/;
|
||||
const BACKTICK_STRING = /\\`(([\s\S]*?)(\$\{[^}]*?\})?)*?[^\\]\\`/;
|
||||
const BACKTICK_INTERPOLATION = /(\$\{[^}]*\})/;
|
||||
const NUMBER = /\d+/;
|
||||
|
||||
const ELLIPSIS = '…';
|
||||
const TOKEN = new RegExp(
|
||||
`\\s*((${IDENTIFIER.source})|(${OPERATOR.source})|(${STRING.source})|(${BACKTICK_STRING.source})|${NUMBER.source}|${ELLIPSIS})\\s*`,
|
||||
`\\s*((${IDENTIFIER.source})|(${BACKTICK_STRING.source})|(${OPERATOR.source})|(${STRING.source})|${NUMBER.source}|${ELLIPSIS})\\s*`,
|
||||
'y');
|
||||
|
||||
type Piece = string | RegExp;
|
||||
@ -76,6 +76,9 @@ function tokenize(text: string): Piece[] {
|
||||
*/
|
||||
function tokenizeBackTickString(str: string): Piece[] {
|
||||
const pieces: Piece[] = ['`'];
|
||||
// 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)
|
||||
str = str.replace(/\\\\\\`/, '\\`');
|
||||
const backTickPieces = str.slice(2, -2).split(BACKTICK_INTERPOLATION);
|
||||
backTickPieces.forEach((backTickPiece) => {
|
||||
if (BACKTICK_INTERPOLATION.test(backTickPiece)) {
|
||||
|
@ -111,6 +111,15 @@ const verifyUniqueConsts = (output: string) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape the template string for being placed inside a backtick string literal.
|
||||
*
|
||||
* * "\" would erroneously indicate a control character
|
||||
* * "`" and "${" strings would erroneously indicate the end of a message part
|
||||
*/
|
||||
const escapeTemplate = (template: string) =>
|
||||
template.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '$\\{');
|
||||
|
||||
const getAppFilesWithTemplate = (template: string, args: any = {}) => ({
|
||||
app: {
|
||||
'spec.ts': `
|
||||
@ -120,7 +129,7 @@ const getAppFilesWithTemplate = (template: string, args: any = {}) => ({
|
||||
selector: 'my-component',
|
||||
${args.preserveWhitespaces ? 'preserveWhitespaces: true,' : ''}
|
||||
${args.interpolation ? 'interpolation: ' + JSON.stringify(args.interpolation) + ', ' : ''}
|
||||
template: \`${template}\`
|
||||
template: \`${escapeTemplate(template)}\`
|
||||
})
|
||||
export class MyComponent {}
|
||||
|
||||
@ -180,7 +189,8 @@ describe('i18n support in the template compiler', () => {
|
||||
<div i18n-title="meaningD|descD" title="Title D">Content D</div>
|
||||
<div i18n-title="meaningE@@idE" title="Title E">Content E</div>
|
||||
<div i18n-title="@@idF" title="Title F">Content F</div>
|
||||
<div i18n-title="[BACKUP_MESSAGE_ID:idH]desc@@idG" title="Title G">Content G</div>
|
||||
<div i18n-title="[BACKUP_$\{MESSAGE}_ID:idH]\`desc@@idG" title="Title G">Content G</div>
|
||||
<div i18n="Some text \\' [BACKUP_MESSAGE_ID: xxx]">Content H</div>
|
||||
`;
|
||||
|
||||
const output = String.raw `
|
||||
@ -258,15 +268,28 @@ describe('i18n support in the template compiler', () => {
|
||||
var $I18N_23$;
|
||||
if (ngI18nClosureMode) {
|
||||
/**
|
||||
* @desc [BACKUP_MESSAGE_ID:idH]desc
|
||||
* @desc [BACKUP_$` +
|
||||
String.raw `{MESSAGE}_ID:idH]` +
|
||||
'`' + String.raw `desc
|
||||
*/
|
||||
const $MSG_EXTERNAL_idG$$APP_SPEC_TS_24$ = goog.getMsg("Title G");
|
||||
$I18N_23$ = $MSG_EXTERNAL_idG$$APP_SPEC_TS_24$;
|
||||
}
|
||||
else {
|
||||
$I18N_23$ = $localize \`:[BACKUP_MESSAGE_ID\:idH]desc@@idG:Title G\`;
|
||||
$I18N_23$ = $localize \`:[BACKUP_$\{MESSAGE}_ID\:idH]\\\`desc@@idG:Title G\`;
|
||||
}
|
||||
const $_c25$ = ["title", $I18N_23$];
|
||||
var $I18N_20$;
|
||||
if (ngI18nClosureMode) {
|
||||
/**
|
||||
* @desc Some text \' [BACKUP_MESSAGE_ID: xxx]
|
||||
*/
|
||||
const $MSG_EXTERNAL_idG$$APP_SPEC_TS_21$ = goog.getMsg("Content H");
|
||||
$I18N_20$ = $MSG_EXTERNAL_idG$$APP_SPEC_TS_21$;
|
||||
}
|
||||
else {
|
||||
$I18N_20$ = $localize \`:Some text \\' [BACKUP_MESSAGE_ID\: xxx]:Content H\`;
|
||||
}
|
||||
…
|
||||
consts: [[${AttributeMarker.I18n}, "title"]],
|
||||
template: function MyComponent_Template(rf, ctx) {
|
||||
@ -298,6 +321,9 @@ describe('i18n support in the template compiler', () => {
|
||||
$r3$.ɵɵi18nAttributes(18, $_c25$);
|
||||
$r3$.ɵɵtext(19, "Content G");
|
||||
$r3$.ɵɵelementEnd();
|
||||
$r3$.ɵɵelementStart(20, "div");
|
||||
$r3$.ɵɵi18n(21, $I18N_20$);
|
||||
$r3$.ɵɵelementEnd();
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
Reference in New Issue
Block a user