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:
Pete Bacon Darwin
2019-11-15 16:25:59 +00:00
committed by Alex Rickabaugh
parent 62f7d0fe5c
commit a6247aafa1
7 changed files with 156 additions and 104 deletions

View File

@ -7,8 +7,6 @@
*/
import {ParseSourceSpan} from '../parse_util';
import {serializeI18nHead, serializeI18nTemplatePart} from '../render3/view/i18n/meta';
import * as o from './output_ast';
import {SourceMapGenerator} from './source_map';
@ -363,14 +361,12 @@ export abstract class AbstractEmitterVisitor implements o.StatementVisitor, o.Ex
}
visitLocalizedString(ast: o.LocalizedString, ctx: EmitterVisitorContext): any {
const head = serializeI18nHead(ast.metaBlock, ast.messageParts[0]);
ctx.print(ast, '$localize `' + escapeBackticks(head.raw));
const head = ast.serializeI18nHead();
ctx.print(ast, '$localize `' + head.raw);
for (let i = 1; i < ast.messageParts.length; i++) {
ctx.print(ast, '${');
ast.expressions[i - 1].visitExpression(this, ctx);
ctx.print(
ast,
`}${escapeBackticks(serializeI18nTemplatePart(ast.placeHolderNames[i - 1], ast.messageParts[i]))}`);
ctx.print(ast, `}${ast.serializeI18nTemplatePart(i).raw}`);
}
ctx.print(ast, '`');
return null;
@ -560,7 +556,3 @@ function _createIndent(count: number): string {
}
return res;
}
function escapeBackticks(str: string): string {
return str.replace(/`/g, '\\`');
}

View File

@ -499,8 +499,74 @@ export class LocalizedString extends Expression {
visitExpression(visitor: ExpressionVisitor, context: any): any {
return visitor.visitLocalizedString(this, context);
}
/**
* Serialize the given `meta` and `messagePart` into "cooked" and "raw" strings that can be used
* in a `$localize` tagged string. The format of the metadata is the same as that parsed by
* `parseI18nMeta()`.
*
* @param meta The metadata to serialize
* @param messagePart The first part of the tagged string
*/
serializeI18nHead(): {cooked: string, raw: string} {
let metaBlock = this.metaBlock.description || '';
if (this.metaBlock.meaning) {
metaBlock = `${this.metaBlock.meaning}|${metaBlock}`;
}
if (this.metaBlock.customId || this.metaBlock.legacyId) {
metaBlock = `${metaBlock}@@${this.metaBlock.customId || this.metaBlock.legacyId}`;
}
return createCookedRawString(metaBlock, this.messageParts[0]);
}
/**
* Serialize the given `placeholderName` and `messagePart` into "cooked" and "raw" strings that
* can be used in a `$localize` tagged string.
*
* @param placeholderName The placeholder name to serialize
* @param messagePart The following message string after this placeholder
*/
serializeI18nTemplatePart(partIndex: number): {cooked: string, raw: string} {
const placeholderName = this.placeHolderNames[partIndex - 1];
const messagePart = this.messageParts[partIndex];
return createCookedRawString(placeholderName, messagePart);
}
}
const escapeSlashes = (str: string): string => str.replace(/\\/g, '\\\\');
const escapeStartingColon = (str: string): string => str.replace(/^:/, '\\:');
const escapeColons = (str: string): string => str.replace(/:/g, '\\:');
const escapeForMessagePart = (str: string): string =>
str.replace(/`/g, '\\`').replace(/\${/g, '$\\{');
/**
* Creates a `{cooked, raw}` object from the `metaBlock` and `messagePart`.
*
* The `raw` text must have various character sequences escaped:
* * "\" would otherwise indicate that the next character is a control character.
* * "`" and "${" are template string control sequences that would otherwise prematurely indicate
* the end of a message part.
* * ":" inside a metablock would prematurely indicate the end of the metablock.
* * ":" at the start of a messagePart with no metablock would erroneously indicate the start of a
* metablock.
*
* @param metaBlock Any metadata that should be prepended to the string
* @param messagePart The message part of the string
*/
function createCookedRawString(metaBlock: string, messagePart: string) {
if (metaBlock === '') {
return {
cooked: messagePart,
raw: escapeForMessagePart(escapeStartingColon(escapeSlashes(messagePart)))
};
} else {
return {
cooked: `:${metaBlock}:${messagePart}`,
raw: escapeForMessagePart(
`:${escapeColons(escapeSlashes(metaBlock))}:${escapeSlashes(messagePart)}`)
};
}
}
export class ExternalExpr extends Expression {
constructor(

View File

@ -234,50 +234,6 @@ export function parseI18nMeta(meta?: string): I18nMeta {
return {customId, meaning, description};
}
/**
* Serialize the given `meta` and `messagePart` "cooked" and "raw" strings that can be used in a
* `$localize` tagged string. The format of the metadata is the same as that parsed by
* `parseI18nMeta()`.
*
* @param meta The metadata to serialize
* @param messagePart The first part of the tagged string
*/
export function serializeI18nHead(
meta: I18nMeta, messagePart: string): {cooked: string, raw: string} {
let metaBlock = meta.description || '';
if (meta.meaning) {
metaBlock = `${meta.meaning}|${metaBlock}`;
}
if (meta.customId || meta.legacyId) {
metaBlock = `${metaBlock}@@${meta.customId || meta.legacyId}`;
}
if (metaBlock === '') {
// There is no metaBlock, so we must ensure that any starting colon is escaped.
return {cooked: messagePart, raw: escapeStartingColon(messagePart)};
} else {
return {
cooked: `:${metaBlock}:${messagePart}`,
raw: `:${escapeColons(metaBlock)}:${messagePart}`
};
}
}
/**
* Serialize the given `placeholderName` and `messagePart` into strings that can be used in a
* `$localize` tagged string.
*
* @param placeholderName The placeholder name to serialize
* @param messagePart The following message string after this placeholder
*/
export function serializeI18nTemplatePart(placeholderName: string, messagePart: string): string {
if (placeholderName === '') {
// There is no placeholder name block, so we must ensure that any starting colon is escaped.
return escapeStartingColon(messagePart);
} else {
return `:${placeholderName}:${messagePart}`;
}
}
// Converts i18n meta information for a message (id, description, meaning)
// to a JsDoc statement formatted as expected by the Closure compiler.
export function i18nMetaToDocStmt(meta: I18nMeta): o.JSDocCommentStmt|null {
@ -290,11 +246,3 @@ export function i18nMetaToDocStmt(meta: I18nMeta): o.JSDocCommentStmt|null {
}
return tags.length == 0 ? null : new o.JSDocCommentStmt(tags);
}
export function escapeStartingColon(str: string): string {
return str.replace(/^:/, '\\:');
}
export function escapeColons(str: string): string {
return str.replace(/:/g, '\\:');
}

View File

@ -15,7 +15,7 @@ import {I18nContext} from '../../../src/render3/view/i18n/context';
import {serializeI18nMessageForGetMsg} from '../../../src/render3/view/i18n/get_msg_utils';
import {serializeIcuNode} from '../../../src/render3/view/i18n/icu_serializer';
import {serializeI18nMessageForLocalize} from '../../../src/render3/view/i18n/localize_utils';
import {I18nMeta, parseI18nMeta, serializeI18nHead, serializeI18nTemplatePart} from '../../../src/render3/view/i18n/meta';
import {I18nMeta, parseI18nMeta} from '../../../src/render3/view/i18n/meta';
import {formatI18nPlaceholderName} from '../../../src/render3/view/i18n/util';
import {parseR3 as parse} from './util';
@ -210,47 +210,66 @@ describe('Utils', () => {
});
it('serializeI18nHead()', () => {
expect(serializeI18nHead(meta(), '')).toEqual({cooked: '', raw: ''});
expect(serializeI18nHead(meta('', '', 'desc'), ''))
expect(o.localizedString(meta(), [''], [], []).serializeI18nHead())
.toEqual({cooked: '', raw: ''});
expect(o.localizedString(meta('', '', 'desc'), [''], [], []).serializeI18nHead())
.toEqual({cooked: ':desc:', raw: ':desc:'});
expect(serializeI18nHead(meta('id', '', 'desc'), ''))
expect(o.localizedString(meta('id', '', 'desc'), [''], [], []).serializeI18nHead())
.toEqual({cooked: ':desc@@id:', raw: ':desc@@id:'});
expect(serializeI18nHead(meta('', 'meaning', 'desc'), ''))
expect(o.localizedString(meta('', 'meaning', 'desc'), [''], [], []).serializeI18nHead())
.toEqual({cooked: ':meaning|desc:', raw: ':meaning|desc:'});
expect(serializeI18nHead(meta('id', 'meaning', 'desc'), ''))
expect(o.localizedString(meta('id', 'meaning', 'desc'), [''], [], []).serializeI18nHead())
.toEqual({cooked: ':meaning|desc@@id:', raw: ':meaning|desc@@id:'});
expect(serializeI18nHead(meta('id', '', ''), '')).toEqual({cooked: ':@@id:', raw: ':@@id:'});
expect(o.localizedString(meta('id', '', ''), [''], [], []).serializeI18nHead())
.toEqual({cooked: ':@@id:', raw: ':@@id:'});
// Escaping colons (block markers)
expect(serializeI18nHead(meta('id:sub_id', 'meaning', 'desc'), ''))
expect(
o.localizedString(meta('id:sub_id', 'meaning', 'desc'), [''], [], []).serializeI18nHead())
.toEqual({cooked: ':meaning|desc@@id:sub_id:', raw: ':meaning|desc@@id\\:sub_id:'});
expect(serializeI18nHead(meta('id', 'meaning:sub_meaning', 'desc'), '')).toEqual({
cooked: ':meaning:sub_meaning|desc@@id:',
raw: ':meaning\\:sub_meaning|desc@@id:'
});
expect(serializeI18nHead(meta('id', 'meaning', 'desc:sub_desc'), ''))
expect(o.localizedString(meta('id', 'meaning:sub_meaning', 'desc'), [''], [], [])
.serializeI18nHead())
.toEqual(
{cooked: ':meaning:sub_meaning|desc@@id:', raw: ':meaning\\:sub_meaning|desc@@id:'});
expect(o.localizedString(meta('id', 'meaning', 'desc:sub_desc'), [''], [], [])
.serializeI18nHead())
.toEqual({cooked: ':meaning|desc:sub_desc@@id:', raw: ':meaning|desc\\:sub_desc@@id:'});
expect(serializeI18nHead(meta('id', 'meaning', 'desc'), 'message source')).toEqual({
cooked: ':meaning|desc@@id:message source',
raw: ':meaning|desc@@id:message source'
});
expect(serializeI18nHead(meta('id', 'meaning', 'desc'), ':message source')).toEqual({
cooked: ':meaning|desc@@id::message source',
raw: ':meaning|desc@@id::message source'
});
expect(serializeI18nHead(meta('', '', ''), 'message source'))
expect(o.localizedString(meta('id', 'meaning', 'desc'), ['message source'], [], [])
.serializeI18nHead())
.toEqual({
cooked: ':meaning|desc@@id:message source',
raw: ':meaning|desc@@id:message source'
});
expect(o.localizedString(meta('id', 'meaning', 'desc'), [':message source'], [], [])
.serializeI18nHead())
.toEqual({
cooked: ':meaning|desc@@id::message source',
raw: ':meaning|desc@@id::message source'
});
expect(o.localizedString(meta('', '', ''), ['message source'], [], []).serializeI18nHead())
.toEqual({cooked: 'message source', raw: 'message source'});
expect(serializeI18nHead(meta('', '', ''), ':message source'))
expect(o.localizedString(meta('', '', ''), [':message source'], [], []).serializeI18nHead())
.toEqual({cooked: ':message source', raw: '\\:message source'});
});
it('serializeI18nPlaceholderBlock()', () => {
expect(serializeI18nTemplatePart('', '')).toEqual('');
expect(serializeI18nTemplatePart('abc', '')).toEqual(':abc:');
expect(serializeI18nTemplatePart('', 'message')).toEqual('message');
expect(serializeI18nTemplatePart('abc', 'message')).toEqual(':abc:message');
expect(serializeI18nTemplatePart('', ':message')).toEqual('\\:message');
expect(serializeI18nTemplatePart('abc', ':message')).toEqual(':abc::message');
expect(o.localizedString(meta('', '', ''), ['', ''], [''], []).serializeI18nTemplatePart(1))
.toEqual({cooked: '', raw: ''});
expect(
o.localizedString(meta('', '', ''), ['', ''], ['abc'], []).serializeI18nTemplatePart(1))
.toEqual({cooked: ':abc:', raw: ':abc:'});
expect(o.localizedString(meta('', '', ''), ['', 'message'], [''], [])
.serializeI18nTemplatePart(1))
.toEqual({cooked: 'message', raw: 'message'});
expect(o.localizedString(meta('', '', ''), ['', 'message'], ['abc'], [])
.serializeI18nTemplatePart(1))
.toEqual({cooked: ':abc:message', raw: ':abc:message'});
expect(o.localizedString(meta('', '', ''), ['', ':message'], [''], [])
.serializeI18nTemplatePart(1))
.toEqual({cooked: ':message', raw: '\\:message'});
expect(o.localizedString(meta('', '', ''), ['', ':message'], ['abc'], [])
.serializeI18nTemplatePart(1))
.toEqual({cooked: ':abc::message', raw: ':abc::message'});
});
function meta(customId?: string, meaning?: string, description?: string): I18nMeta {